Commit graph

282 commits

Author SHA1 Message Date
a4ecd05c60 nws: rewrite _render() to 4-line format with SAME-branched hazard and motion
New wire format:
  L1: emoji + event type (no office name)
  L2: NWSheadline (title-cased, 80 chars) or "{event} for {area}" fallback
  L3: SAME-code-branched hazard + certainty/threat:
      TOR: on-ground/radar + damage threat
      SVR: wind/hail + radar confirmed/indicated
      FFW/FLW: hazard sentence + inferred flood cause
      Others: hazard sentence + certainty if Observed/Likely
  L4: motion (compass + mph from eventMotionDescription) + locations

Drop expires, area/county, and impact lines. Add _parse_motion() helper
for eventMotionDescription (knots -> mph conversion). Add "locations"
pattern to _parse_nws_description(). Update test to remove expires check.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 07:05:22 +00:00
7210de1ed9 nws: remove _SAME_INSTRUCTION and instruction line from wire format
Drop the instruction line (line 6) from NWS wire output entirely.
Remove the _SAME_INSTRUCTION dict that was added in 503c16d.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 06:53:47 +00:00
503c16db5e nws: add _SAME_INSTRUCTION map and tighten instruction to 40-byte limit
Short, actionable instructions keyed by SAME event code (TOR -> "Seek
shelter immediately.", SVR -> "Move indoors now.", etc.). Falls back to
the CAP instruction field when no SAME code matches. Truncates at 40
UTF-8 bytes to keep wire size compact for mesh broadcast.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 06:46:35 +00:00
b3105c65f5 nws: multi-line wire format with SAME emoji, NWS office, hazard/impact/instruction
Replace single-line _render() with structured 6-line format:
  L1: SAME emoji + event type + NWS office (from WMO identifier)
  L2: area (first areaDesc segment, max 60 chars)
  L3: hazard (from HAZARD.../TORNADO... or maxWindGust/maxHailSize params)
  L4: impact (from IMPACT... in description)
  L5: expires
  L6: instruction (max 80 chars)

Add module-level helpers: _SAME_EMOJI, _NWS_OFFICE_SHORT, _nws_office(),
_parse_nws_description(). Emoji prefers SAME event code, falls back to
_emoji_for_event() substring match. All _render() call sites pass d=d.

Update test to match new format (coordinates removed from wire).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 06:37:03 +00:00
15c414f255 fix(incident): correct mile_marker key from _enrichment to _enriched
The enrichment pipeline writes to d._enriched, not d._enrichment.
Fix both _parse_state_511_incident and _parse_itd_511_incident.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 05:43:49 +00:00
ff887b500c fix(incident): add lowercase direction variants to _DIRECTION_LONG
ITD 511 sometimes sends lowercase direction strings (e.g. "east"
instead of "East"). Add lowercase and abbreviated lowercase keys
so the renderer resolves them without falling through to raw echo.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 05:22:53 +00:00
49de5f3a86 feat(incident): add ITD 511 severity, category, and sub-type filters
Backend:
- Add itd_511.min_severity (str, default None), enabled_categories
  (json, default [incident, closure]), enabled_sub_types (json,
  default 7 common types) to adapter_config registry.
- Wire _parse_itd_511_incident to gate on all three: severity
  ordering (None < Minor < Major), category whitelist, sub-type
  whitelist (empty = all pass).

Dashboard:
- Add Roads511Config state + API load/save/discard for itd_511.
- Add Broadcast Filters section to the 511 Road Conditions panel:
  severity dropdown, category checkboxes, sub-type checkboxes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 05:03:23 +00:00
1e6d22ecfe feat(dashboard): add TomTom broadcast filter knobs to traffic panel
Add min_magnitude dropdown (1-4), drop_non_present and
drop_zero_magnitude toggles to the TomTom Traffic adapter card.
State loads from /api/adapter-config/tomtom_incidents on mount
and saves changed keys on save, following the same pattern as
the WFIGS and fires config panels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 03:55:43 +00:00
6149900917 feat(incident): config-driven TomTom min_magnitude filter
Add tomtom_incidents.min_magnitude setting (default 4 = severe)
to adapter_config registry. Replace the hardcoded magnitude==0
drop check with a config-driven floor that silently drops any
TomTom event below the configured threshold.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 03:53:02 +00:00
b60ea5c5db fix(incident): remove TomTom road fallback to from field
Let road stay None when road_numbers is absent so the renderer
uses the from → to segment format instead of clobbering it with
the raw from string.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 03:49:12 +00:00
198827a1b0 feat(incident): render TomTom from/to segments and length in wire output
Line 2 now falls back to from_loc → to_loc when road is absent
(common for TomTom street-level incidents without road_numbers).

Line 3 renders length (meters from TomTom) as human-readable
distance (mi or m) alongside delay and lanes_affected.

Add length field to _parse_tomtom_incident return dict.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 03:41:30 +00:00
71fb5dcef7 feat(incident): remove render prefix, add comment field to wire output
Remove the prefix parameter from _render() and all callers — the
New:/Update: labels are no longer surfaced in the multi-line format.

Add comment field extraction to _parse_state_511_incident and
_parse_itd_511_incident return dicts. Render comment as line 3b
when it provides additional context beyond lanes_affected and is
<=140 chars, skipping duplicates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 00:11:04 +00:00
bc6df56e0f feat(incident): multi-line render format with display names and direction expansion
Replace single-line _render() with structured multi-line output:
  Line 1: emoji + display name + city/state anchor
  Line 2: road + full direction (Eastbound) + mile marker
  Line 3: lanes affected + delay
  Line 4: cause (if non-default)

Add _SUB_TYPE_DISPLAY and _DIRECTION_LONG mappings. Extend
_parse_state_511_incident and _parse_itd_511_incident return dicts
with lanes_affected, cause, description, and mile_marker fields.
Add mile_marker: None to _parse_tomtom_incident for consistency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-07 21:41:24 +00:00
d305ce65d7 feat(dispatcher): bypass cooldown for immediate-severity events
Events with severity=immediate skip the per-toggle cooldown check
entirely — they are already rate-controlled by source handler change
detection. Also set cooldown_seconds default to 0 (disabled).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-07 16:57:21 +00:00
fa5a869401 feat(dashboard): add debug endpoint to clear dispatcher cooldowns
POST /api/debug/clear-cooldowns clears both in-memory toggle
cooldown map and SQLite dispatcher_cooldowns table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-07 16:50:45 +00:00
b859a3b86a feat(firms): disable standalone broadcasts, use WFIGS renderer for growth
Disable all standalone FIRMS broadcast paths (cluster, halt, spotting)
by inserting early return None. Growth events now use the shared WFIGS
_render() for consistent multi-line format with movement data, and set
_severity_override=immediate + _cooldown_suffix for per-fire cooldown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-07 16:16:20 +00:00
34e2e3f040 feat(dispatcher): per-fire cooldown independence via _cooldown_suffix
Each WFIGS fire event now carries its irwin_id as _cooldown_suffix in
event.data. The dispatcher incorporates this suffix into the cooldown
key region field, giving each fire its own independent cooldown slot
instead of sharing one per (toggle, category, region).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-07 16:11:49 +00:00
f320333f5f refactor(fire_digest): replace LLM renderer with deterministic output
Remove _build_prompt, _llm_render, _terse_fallback and all LLM backend
references. render_digest() now queries the fires table directly and
builds a structured multi-line wire: header with count, up to 5 fires
with acres/containment/anchor, and a +N more overflow line.

FireDigestScheduler no longer accepts or uses llm_backend. Updated the
pipeline __init__.py call site accordingly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-07 16:03:51 +00:00
dea86883db fix(wfigs): set all fire broadcasts to immediate severity
Fire events are already change-detected by the wfigs handler so the
grouper coalescing window adds no value and causes commit callbacks
to be lost when events are replaced. Setting severity to immediate
unconditionally bypasses the grouper entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-07 08:20:22 +00:00
00b5e381ca fix(wfigs): title-case town names and drop Near: prefix from wire
Apply .title() to all town name returns in _location_anchor so
anchors render with proper capitalisation regardless of source
casing. Remove the redundant Near: prefix from the location line
in _render — the anchor text is self-describing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-07 08:05:31 +00:00
4dfed1115b fix(normalizer): restrict Photon OSM values to city/town/village
Remove hamlet, suburb, and locality from _TOWN_OSM_VALUES so the
nearest_town Photon lookup only returns meaningful population centers,
avoiding misleading anchors from tiny named places.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-07 07:55:28 +00:00
b09a5843ce fix(wfigs): add IncidentSize to acres keys and curated town_anchors lookup
Acres: prepend IncidentSize to _WFIGS_ACRES_RAW_KEYS so the normalizer
picks up the primary size field before falling back to DiscoveryAcres
and FinalAcres.

Location anchor: query the curated town_anchors table before falling
back to the Photon geocoder nearest_town call, giving consistent
anchor names for Idaho fires.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-07 07:52:36 +00:00
ebb34b75ef feat(wfigs): multi-line renderer with delta/bold logic and new fire fields
Normalizer: add fire_cause, agency, personnel, unique_fire_id from
WFIGS raw payload to the normalized incident dict.

Renderer: replace single-line wire format with structured multi-line
output — header, size/contained with bold deltas on updates, location
anchor, cause/discovered date, and unique fire ID. Update call sites
pass last_bcast_acres and last_bcast_contained for case-(iii) updates
to enable delta calculation and selective bolding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-07 07:36:14 +00:00
fc78f26c82 fix(grouper): strip non-serializable values before persisting held events
event.data can contain callables and internal _on_ callbacks that
cause json.dumps to fail with TypeError. Filter these out before
serializing to SQLite.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-07 06:57:11 +00:00
3b12be2242 fix(normalizer): accept wildfire IncidentTypeCategory in WFIGS parser
WFIGS API returns both "WF" and "wildfire" as IncidentTypeCategory
values. The previous check only accepted "WF", silently dropping
wildfire-typed incidents.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-07 06:20:28 +00:00
94118f7b6d fix(normalizer): handle None return from _parse_wfigs_incidents
When the non-WF filter returns None, the caller must check before
assigning to n[_kind]. Fixes TypeError on non-wildfire incidents.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-07 01:32:15 +00:00
69ccbc035a feat(wfigs): add severity override for fire broadcasts
Fire events now carry _severity_override in the data dict:
- New fires (case i/ii): priority by default
- Update fires (case iii): priority by default
- All fires: immediate if acres > 1000 OR contained_pct == 0

consumer.py checks for _severity_override before falling back to
map_severity(inner.severity). This ensures fire broadcasts are
prioritized appropriately in the dispatch queue.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-07 01:18:39 +00:00
fa8553a1e0 refactor(dashboard): move Central Connection to dedicated tab
- Add 'Central' as first tab in FAMILIES array (before Weather)
- Import Server icon from lucide-react
- Remove Central Connection card from header area
- Render Central config in its own tab panel (no adapter sub-tabs)

The Central tab now shows URL, Durable, and Region fields with the
enabled toggle, matching the previous inline card behavior.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-06 20:18:34 +00:00
9d8d5fd321 feat(dashboard): expanded fire config with incident types and triggers
- Add allowed_incident_types (WF/RX/OTHER) checkboxes
- Conditionally show native polling settings (tick_seconds, state)
- Broadcast triggers section with acres/containment toggles
- Cooldown and freshness window inputs in hours

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-06 19:27:12 +00:00
335dbf632c feat(dashboard): fire adapter config in Environment page
Rework the NIFC Fire Perimeters tab to show all fire config in one place:
- Native polling settings (tick_seconds, state) shown only when native
- Broadcast Settings section (always shown):
  - Broadcast on acres increase toggle
  - Broadcast on containment increase toggle
  - Update cooldown (hours, converted from cooldown_seconds)
  - Freshness window (hours, 0 = disabled)
- Daily Digest section (always shown):
  - Digest enabled toggle
  - Schedule times (HH:MM list)

Changes persist to adapter_config table via PUT /api/adapter-config/{adapter}/{key}.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-06 19:04:29 +00:00
87cce0048d fix(dispatcher): disable staleness filter for fire events
Fire events are always relevant regardless of age (a wildfire burning
for 9 hours is not stale — it's ongoing). The staleness filter was
designed for incidents with time_validity semantics, not persistent
fire state.

- defaults.py: add wfigs.freshness_seconds = 0 (disabled)
- dispatcher.py: for fire toggle family, read from adapter_config
  instead of toggle; skip staleness check when freshness_s == 0

Fixes Blue Ridge fire being dropped after LAST_PER_SUBJECT replay.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-06 18:34:15 +00:00
bd4b264672 fix(central): use LAST_PER_SUBJECT and filter non-WF incidents
- consumer.py: change DeliverPolicy from NEW to LAST_PER_SUBJECT to
  get latest state per subject on reconnect instead of replaying backlog
- central_normalizer.py: drop RX and non-wildfire (non-WF) incident
  types early in _parse_wfigs_incidents before they reach the handler

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-06 18:17:17 +00:00
798712d20c docs(v0.7): comprehensive dashboard docs rewrite -- Reference +8 sections, per-page tooltips, component polish
All three approved tiers in one commit. Reference.tsx is the deep docs
hub (8 new sections); the 10 other pages get short helper text +
tooltips that cross-reference back into Reference; 3 components get
operational-context tooltips. No new features land here -- this is the
copy that catches the GUI up to v0.6 + v0.7 system behavior.

Decisions applied per Matt's call:
- Keep both bang commands AND the LLM DM path (bangs are short on a
  mesh-constrained interface; LLM is the anything-else path). Cross-
  references between the two land in Reference -> Commands and
  Reference -> LLM DM Queries.
- Rename "wire-string rendering" to "broadcast text" in user-facing
  copy on TownAnchors.tsx, GaugeSites.tsx, and the Curation section of
  Reference.tsx.
- Keep the "AND-model anti-pattern" tooltip as-is on Environment.tsx +
  GaugeSites.tsx (specificity is the value for advanced users); the
  OR-not-AND Reference section is its home definition that other
  tooltips can link to.

Ham terminology preserved:
- Reference.tsx solar/Kp section retains "Quiet sun" / "Quiet HF
  conditions" language (SFI/Kp vocabulary, not the deleted Quiet Hours
  feature -- confirmed via direct grep before writing).

Tier 1: Reference.tsx (the depth doc) -- 8 new sections, ordered for
readability:

- "Fire Tracker (Fusion)": Phases 1-4 unified. Six fire-family alert
  categories with example wire strings (wildfire_declared,
  wildfire_growth, wildfire_halted, wildfire_spotting,
  unattributed_hotspot_cluster, wildfire_incident). Attribution
  mechanics (spread_radius_mi default, centroid as 24h median).
  Movement mechanics (pass_id bucketing, per-pass centroid, 8-way
  bearing, mi/h drift). Spotting mechanics (convex-hull perimeter +
  vertex-distance approximation + per-fire cooldown). Daily LLM digest
  (twice-daily summary broadcaster). The 10 fires.* adapter_config
  knobs with defaults.
- "Broadcast Types": the three prefix categories -- New: (first sight),
  Update: (material change), Active: (clock-driven reminder).
- "Reminder System": cadences per adapter (WFIGS 8h, SWPC 8h, ITD 511
  per-zone). The tombstone (fires.tombstoned_at) termination. The
  per-adapter reminder_enabled flag.
- "LLM DM (Natural-Language Queries)": all 7 env_reporter adapter
  blocks (build_fires_detail / build_alerts_detail / build_quakes_detail
  / build_traffic_detail / build_gauges_detail / build_swpc_detail /
  build_drop_audit) with example questions that hit each one. The
  grounding clause behavior ("No active X right now" when an adapter
  block is empty -- the v0.7-fire-tracker-4-final clamp). The
  include_in_llm_context per-adapter toggle.
- "OR-not-AND Architecture": the per-adapter Central vs native
  contract. Mutually exclusive. The AND-mode anti-pattern definition
  (referenced by the Environment + GaugeSites tooltips). The Spokane
  fix context.
- "Adapter Config & the CODE Rule": the GUI knob hub. The CONFIG-vs-
  CODE split (thresholds in CONFIG, sentence templates / emoji /
  translation maps in CODE). Restart-required vs live keys. The
  include_in_llm_context toggle.
- "Curation: Gauges & Towns": Gauge Sites (NWS-AHPS thresholds, USGS
  lookup, Action/Minor/Moderate/Major). Town Anchors (broadcast text
  suffix lookup chain: Photon -> this table -> landclass -> county
  -> coords). Example output "3 mi N of Almo".
- "Schema Migrations": light touch. v11-v16 schema additions tagged
  with the phase they shipped under.

Tier 2: per-page tooltips and cross-references (10 pages):

- AdapterConfig.tsx: header paragraph extended with the CODE rule
  pointer + LLM context toggle explanation.
- Alerts.tsx: !subscribe blurb extended with the three broadcast types
  and links to Reference -> Broadcast Types + Reminder System.
- Config.tsx: environmental section description updated to point at
  Environment.tsx for adapter knobs + Reference -> OR-not-AND for the
  architecture.
- Dashboard.tsx: RF Propagation title carries SWPC R/S/G + Kp legend
  tooltip; LOCAL badge defines what counts as local.
- Environment.tsx: Central region-token helper now references the
  OR-not-AND section; tick_seconds defined inline as the native-mode
  poll interval.
- GaugeSites.tsx: page description rewritten -- replaces "envelope
  time" jargon with operational language, explains USGS lookup
  mechanics, points at Reference -> OR-not-AND for the central-feed
  disable.
- Mesh.tsx: Topology + Geographic buttons get tooltips defining the
  rendering model.
- Notifications.tsx: band-conditions block extended with the daily
  fire digest pointer + Reference -> Fire Tracker + Broadcast Types
  cross-refs.
- TownAnchors.tsx: page description rewritten -- "wire-string
  rendering" -> "broadcast text", chain fallback explained ("Photon
  -> this table -> landclass -> county/state -> coords"), example
  output included.

Tier 3: component tooltip polish (3 components):

- NodeTable.tsx: Battery + Last Heard column headers get title-bearing
  spans with the voltage chart + offline-threshold legend.
- NodeDetail.tsx: SNR quality bands documented as a comment in the
  neighbor render block (the legend lives next to where the colored
  quality dots are computed).
- RestartBanner.tsx: banner copy extended with the restart-required
  catalog (Config -> environmental, LLM backend swap, dispatcher
  cold-start grace) so operators know what touched it.

Build verification:
- tsc + vite build green (one warning about chunk size > 500kB --
  pre-existing).
- All 8 new TOPICS ids resolve in the served bundle:
    adapter-config, broadcast-types, curation, fire-tracker,
    llm-dm, or-not-and, reminders, schema.
- Distinctive new strings present in the bundle ("3 mi N of Almo",
  "Photon nearest-town", "AND-mode anti-pattern", "R (Radio Blackouts").
- "Quiet sun" preserved (the ham SFI/Kp vocabulary in the Solar
  section, not the deleted Quiet Hours feature).
- Container Up healthy, 0 tracebacks in 2 min post-rebuild.

Changelog: v0.7-docs-rewrite.md (per-page strip / rewrite / add table).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 15:24:34 +00:00
0934601265 fix(v0.7-fire-tracker-4-final): widen env-scope keyword catch + anti-hallucination clause -- close all 7 LLM DM paths
Follow-up to v0.7-fire-tracker-4-revised. 7-path verify identified 4
paths failing for 2 root causes: (A) _ENV_KEYWORDS_TO_SUBTYPE missing
"traffic" + drop-audit phrases so build_traffic_detail and
build_drop_audit never ran; (B) LLM hallucinated specific numbers when
env blocks were empty (fabricated 144 earthquakes from a blank
quake_events table). This commit widens the keyword catch (phrase-match
for multi-word triggers to avoid false positives) and adds a positive-
framed anti-hallucination instruction to the env-context system prompt.
Re-verified all 7 paths against real Gemini in the prod container;
verdicts in v0.7-firetracker-phase4.md.

Class A -- routing miss fix:
- _ENV_KEYWORDS_TO_SUBTYPE gains "traffic"/"commute"/"highway"
  mapped to the existing "traffic" subtype.
- New _ENV_PHRASES_TO_SUBTYPE dict for multi-word triggers, matched
  as whole-phrase substrings (NOT single-word membership). Drop-audit
  phrases: "why didn't"/"why didnt"/"why am i not"/"why am i missing"
  /"what was filtered"/"drop audit"/"filtered out" all map to a new
  "drop_audit" subtype. Phrase-match keeps "why" alone from
  false-positing every "why is X" question.
- _detect_env_subtype now checks phrases first, then falls back to
  single-word tokenized match.

Class B -- positive-framed anti-hallucination clause:
- New module-level ENV_GROUNDING_CLAUSE constant. Appended to the
  system prompt whenever env scope is detected (after env_block +
  drop_block injection).
- Per Matt's mitigation guidance: positive ("answer from the blocks")
  not negative ("do not hallucinate"). Wording:
    "ENVIRONMENTAL CONTEXT GROUNDING:
     Answer only from the environmental context blocks above. If a
     block is empty or missing for an adapter the user asked about
     (e.g. no NWS alerts in the block), say something like 'No active
     <category> right now' -- never invent specific numbers, place
     names, or counts. If you do not have a relevant block for the
     question, say so briefly."

7-path verification, post-fix (real Gemini, prod container):

| # | query                                           | method               | verdict |
|---|-------------------------------------------------|----------------------|---------|
| 1 | "are there any fires near me?"                  | build_fires_detail   | PASS    |
| 2 | "any weather alerts?"                           | build_alerts_detail  | PASS    |
| 3 | "any earthquakes nearby?"                       | build_quakes_detail  | PASS    |
| 4 | "how's traffic on I-84?"                        | build_traffic_detail | PASS    |
| 5 | "what's the snake river level?"                 | build_gauges_detail  | PASS    |
| 6 | "what are the band conditions?"                 | build_swpc_detail    | PASS    |
| 7 | "why didn't I hear about anything today?"       | build_drop_audit     | PASS    |

Hallucination evidence (pre vs post on the quakes path):
  pre-fix: "There have been 144 earthquakes of magnitude 1.5 or
            greater in the past 24 hours worldwide. Some of the most
            recent earthquakes reported include: A magnitude 2.1
            earthquake in Pahala, Hawaii..."  (fabricated)
  post-fix: "I haven't observed any information about earthquakes
            in the mesh data."  (grounded)

Routing-miss evidence (pre vs post on the traffic path):
  pre-fix:  _detect_env_subtype("how's traffic on I-84?") -> None
            -> env scope NOT triggered, build_traffic_detail never
               called, LLM fabricated I-84 conditions in OR
  post-fix: _detect_env_subtype("how's traffic on I-84?") -> "traffic"
            -> env scope triggers, build_traffic_detail returns
               185 chars of real Ada-county incident data, LLM
               grounds on it: "I haven't observed any active
               traffic incidents on I-84 within the last two hours.
               The current active incidents are on North 9th Street
               / South 9th Street and SH-21, both in Ada."

Tests:
- 7 phase4 tests pass (no new tests needed; verification is the LLM
  DM path itself).
- Full suite: 56 passed in 3.80s across phase1+phase2+phase3+phase4
  +or-arch+include-roundtrip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 08:07:01 +00:00
89640f624d fix(v0.7-fire-tracker-4-revised): rip ?status; LLM DM 7-path verification 3 of 7 pass (NOT verified)
Matt review caught a scope error: ?status was a hypothetical sketch
in the design doc ("a node could ping ?status cache peak") treated as
authorization without asking. Ripping the structured-command path
entirely. The LLM DM path with env_reporter injection is the natural-
language interface; ?status was redundant infrastructure parallel to
the path the design depends on.

What landed:
- router.py: _maybe_rewrite_status_query + _lookup_fire_fuzzy +
  _build_fire_status_context removed. route() restored to:
  bang -> IGNORE-empty -> LLM with verbatim query.
- tests/test_fire_tracker_phase4.py: 5 ?status tests removed; replaced
  with two regression guards:
    test_natural_language_fire_question_routes_to_llm -- "how's the
      cache peak fire?" returns RouteType.LLM with the verbatim query
      (no in-router rewriting).
    test_status_helpers_removed_from_router -- hard-block on
      _maybe_rewrite_status_query / _lookup_fire_fuzzy / "?status"
      appearing anywhere in router.py source. If anyone adds a
      structured-command path for fires, this test fails and the
      author has to talk to Matt first.
- 56 passed in 3.80s across phase1+phase2+phase3+phase4+or-arch+
  include-roundtrip.

What stays (NOT ripped):
- Daily fire digest -- scheduled broadcaster, not a command. Its 4
  adapter_config rows (fires.digest_enabled / digest_schedule /
  digest_timezone / digest_max_chars) stay GUI-editable.
- Bug A fix (UnboundLocalError at router.py:745) -- independent of
  ?status. Confirmed still in effect.

LLM DM 7-path verification result -- 3 of 7 pass, INCOMPLETE:

| # | query                                         | env_reporter         | verdict |
|---|-----------------------------------------------|----------------------|---------|
| 1 | "are there any fires near me?"                | build_fires_detail   | PASS    |
| 2 | "any weather alerts?"                         | build_alerts_detail  | FAIL    |
| 3 | "any earthquakes nearby?"                     | build_quakes_detail  | FAIL    |
| 4 | "how's traffic on I-84?"                      | build_traffic_detail | FAIL    |
| 5 | "what's the snake river level?"               | build_gauges_detail  | PASS    |
| 6 | "what are the band conditions?"               | build_swpc_detail    | PASS    |
| 7 | "why didn't I hear about anything today?"     | build_drop_audit     | FAIL    |

Two distinct failure classes:

Class A -- routing miss (#4 traffic, #7 drop):
  _ENV_KEYWORDS_TO_SUBTYPE lacks "traffic" (only road/jam/crash/
  closure/511/incident map to "traffic"), so a query literally
  mentioning "traffic" never triggers env scope -> build_traffic_detail
  never runs even though traffic_events has 9 rows on disk. The LLM
  fell back to training data and hallucinated I-84 conditions.
  build_drop_audit has no natural-language trigger phrase at all;
  "why didn't I hear about anything today?" has no env keyword.

Class B -- empty data + LLM hallucination (#2 alerts, #3 quakes):
  Env scope IS detected, build_alerts_detail and build_quakes_detail
  DO run, but return empty because nws_alerts has 0 rows and
  quake_events 24h-window has 0 rows (legitimate empty state). The
  LLM has no env block to ground on and hallucinated "144 earthquakes
  worldwide" -- sounds authoritative, is fabricated.

Not fixed in this commit -- needs Matt's call on:
  (a) keyword additions to _ENV_KEYWORDS_TO_SUBTYPE for traffic +
      drop_audit triggers (risk: false-positive env-scope triggers
      for unrelated phrases).
  (b) anti-hallucination prompt clamp: "If a topic's env block is
      missing/empty, say you don't have live data instead of
      answering from general knowledge." (risk: bot apologizes
      every other message.)

Per the "STOP if any path fails" instruction, this commit does NOT
claim verification done; the report at
v0.7-firetracker-phase4.md has the full table + per-row mesh-receiver
wire + per-failure root cause analysis.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 07:33:11 +00:00
f69a05dd6d feat(v0.7-fire-tracker-4): fix LLM DM path + daily fire digest + ?status queries
Phase 4 of FIRMS+WFIGS fusion. Foundation: every direct LLM DM
mentioning a fire/weather/quake/avalanche/flood/etc. keyword was
failing silently in prod with UnboundLocalError because router.py
referenced scope_type before assigning it. With that path restored,
two new features land: a twice-daily fire-digest scheduled broadcast
(LLM-rendered) and a ?status <fire_name> on-demand mesh-DM intent.

BUG-FIX ROOT CAUSE (Job Zero):
  router.py:745 ("if should_inject_mesh and scope_type == 'env'") read
  `scope_type` -- a local variable bound only at line 761 inside an
  unrelated `if self.source_manager and self.mesh_reporter` block.
  Python's lexical scoping made scope_type a local of the whole
  generate_llm_response function, so reading it before the assignment
  raised UnboundLocalError on every env-keyword DM. The exception
  propagated to main.py's outer except, no response went out, bot
  appeared dead on fire/weather/quake/avalanche/flood queries.

  Evidence (synthetic in-process trace against the live container's
  config + GoogleBackend):
    "are there any fires near me?" -> UnboundLocalError (pre-fix)
                                  -> real LLM answer (post-fix)
                                     "Yes, there are a few active
                                      fires reported in the region.
                                      Salmon River: 4,200 acres, 78%
                                      contained. Cache Peak: 1,847
                                      acres, 23% contained. ..."
    "what's the weather?"          -> UnboundLocalError (pre-fix)
                                  -> "I do not have current weather
                                      information. I can tell you
                                      about active fires, stream gauge
                                      levels, space weather, or band
                                      conditions if you'd like." (post-fix)
    "hi there"                     -> normal LLM answer in both cases

  Fix: hoist `scope_type, scope_value = self._detect_mesh_scope(query)`
  to right after `should_inject_mesh` is computed; remove the
  now-duplicate detection inside the source_manager block.

  Secondary mitigation: tightened the "do not invent commands" prompt
  with an explicit "if no list appears above, you have NO commands"
  clause. The prior prompt told the LLM "answer based on the command
  list provided below" without always providing one, so the LLM
  hallucinated plausible-sounding !commands (the "use ! commands"
  canned-looking response Matt was seeing on non-env queries).

PHASE 4 FEATURES:

1. Fire-digest scheduler (meshai/notifications/scheduled/fire_digest.py).
   Modeled after BandConditionsScheduler. Runs in the pipeline's
   start_pipeline coroutine alongside band_conditions + reminders.
   On each slot (default 06:00 + 18:00 America/Boise):
     - Queries active fires (tombstoned_at IS NULL) + last 24h passes.
     - Builds a prompt asking for a single mesh-wire summary <= 200
       chars.
     - Calls the LLM (Google/Anthropic/OpenAI per config).
     - Falls back to a terse "Fires today (N): Cache Peak 1847 ac;
       Twin Peaks 320 ac; +N more" line when the LLM is unavailable.
     - Dispatches via dispatcher.dispatch_scheduled_broadcast (same
       path band_conditions uses).
   Idempotency: v16.sql adds fire_digest_broadcasts(slot_epoch PK,
   sent_at, summary, source). INSERT OR IGNORE pattern blocks the same
   slot firing twice (matters when container restarts mid-day).

2. ?status <fire_name> on-demand intent (router.py).
   Before falling through to the LLM, route() now checks for a leading
   "?status" / "status:" sigil or natural-language triggers like
   "how is X fire?". On match:
     - _lookup_fire_fuzzy walks fires by exact -> startswith ->
       contains -> word-overlap (skipping a trailing " fire" word so
       "cache peak fire" matches "Cache Peak"). Active fires rank
       above tombstoned ones.
     - _build_fire_status_context composes a small context block
       (name, acres, containment, county/state, last 3 passes with
       drift).
     - The query is REWRITTEN into an LLM prompt with that context
       inlined; the rest of the normal LLM path (chunking, history,
       summary persistence) runs unchanged.
   Live verification: "?status Cache Peak" -> "The Cache Peak fire is
   1,847 acres and 23% contained. It's located in Probe / ID.";
   "?status Salmon" -> word-overlap matches "Salmon River" ->
   "The Salmon River fire is 4,200 acres and 78% contained, located
   in Probe / ID."

3. adapter_config rows (GUI-editable per CONFIG-vs-CODE rule):
     fires.digest_enabled         = true   (master toggle)
     fires.digest_schedule        = ["06:00", "18:00"]
     fires.digest_timezone        = "America/Boise"
     fires.digest_max_chars       = 200

Schema (v16.sql):
- fire_digest_broadcasts(slot_epoch INTEGER PK, sent_at, summary,
  source) with source in {'llm', 'fallback_terse', 'skipped_no_fires'}.
- Index on sent_at for ops queries.

Tests (tests/test_fire_tracker_phase4.py, 10 cases all green):
- Regression guard: scope_type appears as an assignment BEFORE the
  env_reporter check (prevents the UnboundLocalError from coming back).
- adapter_config seeds all 4 digest keys with expected defaults.
- render_digest returns ('', 'no_fires') when no active fires.
- render_digest falls back to terse line when LLM is None; wire fits cap.
- render_digest with a stub LLM returns ('<llm text>', 'llm').
- _lookup_fire_fuzzy: exact, "X fire" trim, word-overlap, no-match.
- _maybe_rewrite_status_query: builds context-bearing prompt; returns
  None on non-status queries.

Combined suite: 60 passed in 3.81s across phase1+phase2+phase3+phase4
+or-arch+include-roundtrip.

Live verification on CT108 after rebuild:
- v16 migration applied (schema_meta=16, no Traceback in 3 min).
- FireDigestScheduler started: enabled=True schedule=['06:00','18:00']
  tz=America/Boise.
- LLM DM probe (real Gemini) returns real answers on env queries
  (Bug A fixed end-to-end).
- ?status Cache Peak + ?status Salmon return fire-specific summaries.
- render_digest with real LLM returns source=llm + non-empty wire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 07:13:17 +00:00
31e543ca04 feat(v0.7-fire-tracker-3): spotting detection -- pixels beyond perimeter trigger immediate broadcast
Phase 3 of FIRMS+WFIGS fusion. v15.sql adds perimeter_geojson to
fire_passes + last_spotting_broadcast_at to fires. FIRMS handler
computes convex hull of each pass on pass-boundary close; attributed
pixels >= 1.5 mi (configurable) from previous-pass perimeter emit
wildfire_spotting broadcast. Cooldown 1h between spotting broadcasts
per fire so rapid embers do not spam. wildfire_spotting category at
immediate severity -- spotting is the highest-actionable fire signal
(spread beyond perimeter). All thresholds GUI-editable. Phase 4 (LLM
summaries + on-demand queries) deferred.

Schema (v15.sql):
- fire_passes gains perimeter_geojson TEXT (nullable; populated by
  _close_prev_perimeter at boundary). GeoJSON Polygon, single outer
  ring in (lon, lat) order per RFC 7946, closed (first == last).
- fires gains last_spotting_broadcast_at REAL (per-fire cooldown
  latch). Index (irwin_id, last_spotting_broadcast_at) for the
  cooldown probe.

adapter_config (defaults.py REGISTRY):
- fires.spotting_distance_threshold_mi = 1.5 (float). Matches design
  doc Phase 3 spec; design doc open question #6 lists this as TBD
  pending real spotting observation data.
- fires.spotting_cooldown_seconds = 3600 (int, 1h). Suppresses
  rapid-ember spam from a single satellite pass.

ALERT_CATEGORIES (notifications/categories.py):
- wildfire_spotting: immediate / fire. Highest fire severity --
  spotting represents fire spread BEYOND the existing perimeter, the
  most actionable detection signal.

FIRMS handler (central/firms_handler.py):
- _handle_pass_boundary now closes the prior pass's perimeter (convex
  hull of fire_pixels via Andrew's monotone chain) on the first
  boundary; subsequent in-pass pixels reuse the stored hull.
- _check_spotting runs for every attributed pixel: looks up the most
  recent CLOSED pass (perimeter_geojson NOT NULL AND pass_id !=
  current), point-in-polygon test, vertex-distance approximation
  per design doc Q (sparse pixels make edge projection overkill at
  VIIRS 375 m resolution), per-fire cooldown gate.
- Priority order: spotting (immediate) > growth (priority) > cluster
  (priority) > halt (routine). Spotting preempts growth at the same
  pixel because immediate > priority.
- Helpers: _convex_hull (Andrew's monotone chain), _hull_to_geojson
  (RFC 7946 Polygon), _point_in_polygon (ray casting),
  _close_prev_perimeter, _check_spotting, _prev_has_perimeter.

Wire string:
- wildfire_spotting: "🔥 Possible spotting <dist:.1f> mi <dir> of
  <incident_name> perimeter" -- direction is 8-way bearing from the
  previous pass's centroid to the spotting pixel.

Tests (tests/test_fire_tracker_phase3.py, 11 cases all green):
- Pass close stamps perimeter_geojson as a closed Polygon (6 hex
  vertices -> 7-entry closed ring).
- Pixel 2 mi NE of perimeter fires spotting with distance in the
  1.0..2.5 mi band (vertex-distance approximation) and direction NE.
- Pixel inside perimeter -> NO spotting wire.
- Second spotting candidate within 1h cooldown -> suppressed.
- Past-cooldown spotting fires again.
- Convex hull / point-in-polygon / GeoJSON round-trip helper tests.
- adapter_config seed for both new fires.* keys.
- wildfire_spotting category registered with immediate severity.
- 49 tests green across phase1/phase2/phase3/or-arch/include-roundtrip.

Live verification on CT108 after rebuild:
- v15 migration applied (schema_meta=15, no Traceback in 3 min).
- Container healthy.

Synthetic 25-pixel probe (PROBE-V07P3-*, cleaned up after):
- Pass A: 20 pixels in a ~0.3 mi circle. Perimeter stored on boundary.
- Pass B: 5 pixels at distances 0.5/1.0/2.0/5.0/7.0 mi from center.
  Observed wires:
    "🔥 Possible spotting 1.7 mi NE of Probe Spotting Fire perimeter"
    "🔥 Possible spotting 4.7 mi NW of Probe Spotting Fire perimeter"
  (Plus a Phase 2 growth wire on the first pass B pixel -- documented
  side effect: single-pixel pass B centroid shows 0.5 mi drift from
  pass A.)
- 7.0 mi E pixel: outside 5 mi spread, no broadcast (cluster check
  found no co-located unattributed pixels). Cleanup confirmed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 06:43:22 +00:00
f5c566c6c0 feat(v0.7-fire-tracker-2): movement analysis -- growth + halt detection
Phase 2 of FIRMS+WFIGS fusion. v14.sql adds fire_passes table for
per-satellite-pass centroid tracking + drift computation. FIRMS handler
now detects pass boundaries (satellite + time bucket), computes pass
centroid (median of pass pixels), Haversine drift from previous pass,
bearing to 8-way direction, mi/h speed. Drift >= 0.5 mi (configurable)
emits wildfire_growth broadcast with wire including movement vector
and nearest-town context. Halt detection: fire with no new pixels for
>=12h (configurable) emits wildfire_halted broadcast (routine). Two
new ALERT_CATEGORIES: wildfire_growth (priority), wildfire_halted
(routine). All thresholds GUI-editable via adapter_config.fires.*.
Phase 3 (spotting) and Phase 4 (LLM summaries) deferred to subsequent
commits.

Schema (v14.sql):
- fire_passes table (irwin_id FK CASCADE, pass_id, pass_centroid_lat/lon,
  pixel_count, total_frp, pass_started_at, pass_ended_at,
  drift_mi_from_prev, drift_direction, drift_mi_per_hour). PRIMARY KEY
  (irwin_id, pass_id) so the UPSERT path is cheap; secondary index on
  (irwin_id, pass_ended_at) for the prev-pass lookup + halt counter.
- fires gains last_pass_id, last_pass_at, halt_broadcast_at columns.
  halt_broadcast_at is latched per halt event; the detector filter
  (halt_broadcast_at IS NULL OR halt_broadcast_at < last_pass_at)
  reopens eligibility automatically when an idle fire receives a new
  attributed pixel that advances last_pass_at.

adapter_config (defaults.py REGISTRY):
- fires.growth_drift_threshold_mi = 0.5 (float). Per-pass centroid drift
  at or above this fires wildfire_growth. 0.5 mi matches the design
  doc Phase 2 spec and is roughly 2x the VIIRS 375m pixel size (i.e.,
  detectable as more than centroid jitter).
- fires.halt_passes_threshold = 2 (int). Documented intent; the
  operational rule uses halt_minimum_seconds below as the time gate
  because per-satellite pass-count enforcement would require modeling
  the global VIIRS schedule per satellite. The 12h gate subsumes it
  (4 passes/day in Idaho).
- fires.halt_minimum_seconds = 43200 (int, 12h).

ALERT_CATEGORIES (notifications/categories.py):
- wildfire_growth: priority/fire. FIRMS handler tags data["category"]
  + data["severity"] on the pass-boundary path when drift >= threshold.
- wildfire_halted: routine/fire. Halt detector tags data["category"]
  + data["severity"] when a fire transitions to idle for >=12h.

FIRMS handler (central/firms_handler.py):
- The Phase 1 attribution branch now passes through
  _handle_pass_boundary(): UPSERT fire_passes row for the current
  (irwin_id, pass_id) with median centroid + pixel count + total FRP
  + min/max acq_time; lookup the prior pass; compute drift mi +
  8-way direction + mi/h speed and write them into the current pass
  row (only the FIRST boundary fills these; subsequent in-pass pixels
  COALESCE keep them stable). Update fires cursor (last_pass_id,
  last_pass_at) and current_centroid_lat/lon to the latest pass
  centroid -- this overrides Phase 1's 24h all-pixels median for
  fires that have pass data.
- Growth wire emitted ONLY at the boundary (last_pass_id != current,
  prev pass exists, drift >= threshold). Subsequent in-pass pixels
  stay silent because pass_id == last_pass_id.
- _maybe_emit_halt runs as a final fallback when neither growth nor
  cluster has fired. SELECT one fire matching the halt criteria,
  stamp halt_broadcast_at, return the wire. The fallback ordering is
  growth > cluster > halt so a busy fire's growth broadcast doesn't
  starve a quiet fire's halt.
- New helpers: _bearing() (great-circle initial bearing, deg CW from N),
  _direction_8() (compass 8-way mapping with +/-22.5 deg sectors).

Wire strings:
- wildfire_growth: `🔥 <incident_name> moving <dir> <speed:.1f> mi/h
  ~<dist_to_nearest_town:.1f> mi from <nearest_town>`. nearest_town
  via meshai.central_normalizer.nearest_town (same Photon-backed
  cache that wfigs_handler uses); failure falls back to bare
  "moving <dir> <speed> mi/h".
- wildfire_halted: `🔥 <incident_name> no growth in <hours>h`.

Tests (tests/test_fire_tracker_phase2.py, 10 cases all green):
- 2-pass attribution with pass2 1.0 mi N of pass1 -> drift=1.0,
  direction='N', mi/h computed, growth wire returned, data tagged.
- Drift below threshold (0.3 mi) -> NO growth broadcast; pass row
  still records the (sub-threshold) drift for ops visibility.
- Halt detector: last_pass_at 14h ago -> fires once, halt_broadcast_at
  stamped.
- Re-run halt detector with halt latched -> NO second broadcast.
- Halt re-eligibility: halt_broadcast_at < last_pass_at -> eligible
  again (a resurrected then re-idled fire).
- Bearing + direction round-trip across all 8 cardinals.
- Direction sector boundary (22.5/67.5 deg) correctness.
- adapter_config seed for 3 new fires.* keys.
- Two new ALERT_CATEGORIES registered.
- 5-pixel single-pass aggregate (pixel_count, total_frp sum, median
  centroid, started/ended_at min/max).

Phase 1 test fix:
- tests/test_fire_tracker_phase1.py::test_centroid_recomputes_as_median_across_passes
  retimed to 12:00/12:10/12:20 so all 3 pixels land in one
  N20 bucket. Phase 2 makes current_centroid_* the per-pass median
  (latest pass overrides Phase 1's 24h median); the same-pass shape
  preserves the original median-computation intent. 39 total tests
  green across phase1/phase2/or-arch/include-roundtrip.

Live verification on CT108 after rebuild:
- v14 migration applied (schema_meta version=14, no Traceback in 3 min).
- adapter_config.fires.growth_drift_threshold_mi = 0.5
- adapter_config.fires.halt_passes_threshold = 2
- adapter_config.fires.halt_minimum_seconds = 43200
- Container healthy.

Synthetic 100-pixel probe inside prod container (PROBE-V07P2-*,
cleaned up after):
- Pass A (50 pixels @ 12:00-12:25, N20 bucket 329768): centroid
  (44.30000, -115.50000), pixel_count=50, total_frp=975.0, drift=NULL
  (first pass).
- Pass B (50 pixels @ 18:00-18:25, N20 bucket 329772, centered 1.2 mi
  NE of A): centroid (44.31230, -115.48282), pixel_count=50,
  total_frp=975.0, drift_mi_from_prev=1.1703 (~design target 1.2 mi
  with -0.03 mi rounding), drift_direction="NE",
  drift_mi_per_hour=0.209 (1.17 mi over 5.5h between pass ends).
- Growth wire: "🔥 Probe Movement Fire moving NE 0.2 mi/h, ~13.0 mi
  from Long Creek Summit Home" (Photon nearest-town anchor populated
  successfully).
- Exactly ONE growth broadcast (first pixel of pass B); 99 other
  pixels stayed silent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 06:12:36 +00:00
dd8e687aca feat(v0.7-fire-tracker-1): registry correlation + 2 new categories
Phase 1 of the FIRMS+WFIGS fusion design doc. v13.sql adds fire_pixels
table for per-fire pixel history + spread_radius_mi/current_centroid_*/
last_hotspot_at on fires. FIRMS handler now attributes incoming pixels
to fires via point-in-circle within configurable radius (default 5 mi),
updating per-fire centroid as median of recent pixels. Unattributed
pixels go through a cluster detector: 3+ pixels within 1 mi within 60
min triggers a single unattributed_hotspot_cluster broadcast (Possible
new fire). Two new ALERT_CATEGORIES: wildfire_declared (priority,
WFIGS first-sight) and unattributed_hotspot_cluster (priority, FIRMS
cluster). All thresholds GUI-editable via adapter_config.fires.* and
adapter_config.firms.*. Phases 2-4 (movement analysis, spotting, LLM
summaries) deferred to subsequent commits.

Schema (v13.sql):
- fire_pixels table (irwin_id FK CASCADE, acq_time, lat/lon, frp,
  satellite, pass_id, attributed_at). Indexed on (irwin_id, acq_time)
  for centroid queries + on (acq_time) for Phase 2.
- fires gains spread_radius_mi (nullable; NULL => use global default),
  current_centroid_lat/lon (median of last 24h pixels, distinct from
  the WFIGS-declared anchor lat/lon), last_hotspot_at (Phase 2 halt
  detector).
- firms_pixels gains attributed_at + cluster_broadcast_at + compound
  index on (attributed_at, cluster_broadcast_at, acq_time) for the
  cluster query.

adapter_config (defaults.py REGISTRY + ADAPTER_META):
- fires.spread_radius_mi_default = 5.0 (float)
- firms.cluster_min_pixels = 3 (int)
- firms.cluster_max_radius_mi = 1.0 (float)
- firms.cluster_time_window_minutes = 60 (int)
- ADAPTER_META["fires"] meta block (display_name + description).

ALERT_CATEGORIES (notifications/categories.py):
- wildfire_declared: priority/fire. WFIGS handler tags data["category"]
  on cases (i)+(ii) [INSERT or row-exists-but-never-broadcast]; case
  (iii) Update keeps the existing wildfire_incident category.
- unattributed_hotspot_cluster: priority/fire. FIRMS handler tags
  data["category"] + data["severity"] when emitting the cluster wire.

FIRMS handler (central/firms_handler.py):
- Unchanged storage path: filter, INSERT OR IGNORE into firms_pixels.
- New _attribute_or_cluster() runs on every newly-stored pixel (dedup
  hits skip -- the original insert had its shot already).
- Attribution: bbox prefilter on fires.tombstoned_at IS NULL, then
  exact Haversine to fires(current_centroid_lat ?? lat,
  current_centroid_lon ?? lon) inside spread_radius_mi (per-fire ?? global
  default). Multi-match resolves to nearest (design doc Q2). On match:
  INSERT fire_pixels, UPDATE firms_pixels.attributed_at,
  recompute centroid as median of last 24h pixels for this fire.
- Cluster: on attribution miss, query firms_pixels WHERE attributed_at
  IS NULL AND cluster_broadcast_at IS NULL AND acq_time > NOW-window.
  If count >= cluster_min_pixels, fire the cluster wire and stamp
  cluster_broadcast_at on every member so a 4th arrival cannot re-fire.

WFIGS handler (central/wfigs_handler.py): the existing prefix=New
branches (i)+(ii) now set data["category"]="wildfire_declared".
Existing _render() unchanged.

Wire strings:
- wildfire_declared: re-uses _render(prefix="New") -- emoji + name +
  type + anchor + acres + containment + coords.
- unattributed_hotspot_cluster: _render_cluster_wire() emits
  "Possible new fire: <N> hotspots within <r> mi @ <lat>,<lon>
  (combined <total_frp> MW)".

Tests (tests/test_fire_tracker_phase1.py, 10 cases all green):
- Pixel within radius -> attribution + centroid + last_hotspot_at.
- Centroid recomputes as median across multiple passes.
- Pixel outside radius -> NO attribution + stays unattributed.
- 3 unattributed within 1 mi within 60 min -> cluster broadcast fires
  exactly once, all 3 stamped cluster_broadcast_at.
- 4th pixel in the same footprint -> NO second broadcast (existing
  3 are stamped so SQL filter excludes them).
- 5th-7th pixels 2h later -> form a NEW cluster (window prune fires).
- WFIGS first-sight tags data["category"]="wildfire_declared".
- WFIGS Update branch does NOT retag wildfire_declared.
- New adapter_config rows seeded on init_db.
- ALERT_CATEGORIES contains both new entries with correct toggle/severity.

Live verification on CT108 after rebuild:
- v13 migration applied (schema_meta version=13, no Traceback).
- adapter_config.fires.spread_radius_mi_default = 5.0
- adapter_config.firms.cluster_min_pixels = 3
- adapter_config.firms.cluster_max_radius_mi = 1.0
- adapter_config.firms.cluster_time_window_minutes = 60
- fires gains 4 new columns; firms_pixels gains 2 new columns; fire_pixels
  table created.
- Container healthy, FIRMS pixels continue arriving (126 pre-deploy).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 05:34:22 +00:00
3351e7b444 fix(v0.6-tail-4): register !include YAML tag constructor in config loader -- closes prod PUT 500
Pre-existing issue surfaced by v0.6-tail-3: prod config at
/data/config/config.yaml:82 uses !include to compose from separate
files, but the loader had no constructor registered so PUT
/api/config/<section> returned 500 with could-not-determine-constructor
when the section save path round-tripped YAML. This adds the !include
constructor (read path) + preserves the include structure on write so
multi-file config layouts work end-to-end via the GUI. The runtime
config behavior is unchanged; this only fixes the PUT-and-round-trip
case.

Implementation note: the read-only runtime path
(_load_yaml_with_includes) already had a working !include constructor
that recursively substitutes content. The bug was specifically in
save_section() -- it used plain yaml.safe_load() to re-read the target
file off disk for secret-ref preservation and for in-place section
updates. When target_file == "config.yaml" that file contains !include
directives for OTHER sections, and safe_load died on them.

Adding a third constructor that substitutes !include on save would
have flattened the multi-file layout to a single file the first time
anyone PUT an inline section. Instead this commit adds a preserve-mode
loader/dumper pair:

  - _load_yaml_preserve() returns an Include("path") sentinel for each
    !include node instead of recursing into the referenced file.
  - _dump_yaml_preserve() re-emits Include("path") back to disk as
    `!include path`. (PyYAML auto-quotes when the scalar contains a
    period, so the on-disk form is `!include 'foo.yaml'`; both forms
    are equivalent at parse time.)
  - save_section()'s three yaml-touching sites (the secret-ref raw
    read, the existing-target read, and the final dump) now use these
    helpers. Local.yaml stays on yaml.safe_load/dump because local.yaml
    never contains !include.

The runtime loader is untouched, so boot-time config still substitutes
includes and Config dataclasses see real values. Only the GUI's
section-save path round-trips through the preserve helpers.

Tests (tests/test_include_roundtrip.py, 8 cases):
- Runtime loader still substitutes !include content (regression guard)
- Preserve loader returns Include() sentinels
- Preserve dumper re-emits `!include path` (tolerant of PyYAML
  auto-quoting)
- Read -> write -> read identity through the preserve helpers
- save_section('bot', ...) on a config.yaml that uses !include for
  sibling sections succeeds AND leaves the includes intact on disk
  (this is the exact prod PUT 500 case from v0.6-tail-3)
- After save_section, the runtime loader re-resolves all !include
  files AND sees the saved change to the inline section
- save_section on a dedicated file (env_feeds.yaml) writes only that
  file; config.yaml's !include directives are untouched
- Runtime cycle detection still trips on A!include->B!include->A

Live verification on CT108 after rebuild:
  PUT /api/config/bot {"name":"AIDA","owner":"Malice","respond_to_dms":true,"filter_bbs_protocols":true}
  -> HTTP 200 {"saved":true,"restart_required":false,"changed_keys":[]}
  /data/config/config.yaml retains all 7 !include directives
  (meshtastic, mesh_sources, mesh_intelligence, environmental,
  notifications, llm, dashboard)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 04:37:24 +00:00
f89e9c11fb feat(v0.6-tail-3): enforce OR-not-AND continuously -- close USGS direct-lookup leak + flag environmental config changes as restart-required
Gap 1 -- env_routes.lookup_usgs_site no longer creates a temporary
USGSStreamsAdapter to hit USGS.gov directly. When the env_store has no
native usgs adapter (because usgs.feed_source != native), the endpoint
returns HTTP 404 with a body that says "site lookup unavailable in
central-feed mode; values must be entered manually or sourced from
Central". This closes the AND-mode anti-pattern Central's v0.10.2
report flagged: meshai was in central-feed mode for usgs but the
lookup helper would still call USGS.gov directly the first time the
dashboard opened the Add-Gauge form.

Gap 2 -- config_routes.RESTART_REQUIRED_SECTIONS gains "environmental"
and the PUT handler now diffs the section before/after, returning
{saved, restart_required, changed_keys}. restart_required is true only
when there are actual changes AND the section is in the restart-required
set, so a no-op PUT to environmental never raises a false alarm.

Frontend wiring:
- New RestartBanner component (yellow top-of-main banner) listens to a
  meshai:restart-required CustomEvent + cross-tab storage event,
  persists across navigations via localStorage, shows changed_keys
  preview + Restart-now button (POSTs /api/system/restart) + dismiss.
- Layout.tsx mounts <RestartBanner /> above {children} so it surfaces
  on every page.
- Config.tsx saveSection() now calls notifyRestartRequired(changed_keys)
  alongside its existing setRestartRequired(true) when the API flags
  the section.
- GaugeSites.tsx probes /api/config/environmental at mount and shows a
  "USGS lookup" button next to the site_id input. The button is
  disabled with an explanatory tooltip when usgs.feed_source != native,
  and gracefully renders the 404 detail when the API returns 404 in
  central-feed mode -- enter-manually UX, no silent fallback.

Tests -- tests/test_or_arch_continuous.py (11 cases, all passing):
- USGS lookup 404 with no env_store / no native usgs adapter
- 502 on native-adapter exception
- 200 + payload on native-adapter happy path
- environmental in RESTART_REQUIRED_SECTIONS
- PUT environmental with changed feed_source -> restart_required:true
  + changed_keys list including foo.feed_source dotted path
- PUT bot (non-restart section) -> restart_required:false
- No-op PUT to bot / environmental -> restart_required:false, empty
  changed_keys
- _diff_keys helper unit tests (nested dicts, list-element changes)

Why this matters: per the Spokane post-mortem and Central's v0.10.2
response, both sides need belt-and-suspenders against transient
AND-modes. meshai's static OR enforcement at env_store boot is the
runtime guard; this commit makes the GUI honor it continuously --
the lookup helper can't sneak past it any more, and the user is told
explicitly that an environmental config change does not take effect
until the container restarts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 03:51:10 +00:00
24763947c3 fix(v0.6-tail-2): finish Quiet Hours rip (Reference.tsx + dashboard bundle rebuild)
Phase 2 (b948ed7) stripped Notifications.tsx + backend config + pipeline
checks but missed Reference.tsx, which has live documentation describing
Quiet Hours as if it still exists. Per feedback-quiet-hours-trash.md the
intent is removal, not refactor -- the Reference page tells users about
a feature that does not exist. Strip the SectionHeader Quiet Hours block
(lines 952-956) and the quiet-hours clause from the rules-evaluation
checklist (line 922). Rebuild dashboard bundle so the JS no longer
contains the strings.

Reference.tsx changes:
  - Line 922 rules-evaluation checklist: drop the 'Are we in quiet hours?'
    clause from the 'MeshAI checks your rules' bullet.
  - Lines 952-956 deleted: the SectionHeader 'Quiet Hours' + the two
    paragraphs explaining 10pm-6am suppression and the 'Override Quiet
    Hours' rule flag.

Other 'Quiet' matches in Reference.tsx are SFI/Kp solar terminology
('Quiet sun', 'Quiet. Best HF conditions.') and remain untouched --
unrelated to quiet hours.

Dashboard bundle rebuilt via vite. New artefacts:
  meshai/dashboard/static/assets/index-WV9oBF1j.js  (2,145.58 kB)
  meshai/dashboard/static/assets/index-j88L17ja.css (45.08 kB)

Verification:
  grep -rln 'quiet hours|quiet_hours|quietHours|override_quiet|Override Quiet' dashboard-frontend/src
    -> 0 hits
  grep -ic 'quiet hours|override.quiet|quiet_hours|quietHours' index-WV9oBF1j.js
    -> 0 hits

Side artefact (not in commit): independent verification of Central
v0.10.2 Spokane-class leak fix completed today; all four meshai-side
checks pass (config audit, JetStream subscribe, event_log breakdown,
mesh_broadcasts_out scan). Report at OneDrive:
v0.6-tail-2-spokane-verify.md. No meshai-side code change needed.
2026-06-06 03:18:42 +00:00
566b06de06 feat(v0.6-tail): close 5 v0.6-phase1-complete.md follow-ups
(1) Auto-call refresh-toggles on PUT /api/config/notifications
    meshai/dashboard/api/config_routes.py adds register_config_routes_hooks(app)
    which registers a FastAPI HTTP middleware: on any 2xx PUT whose path
    matches /api/config/notifications or /api/config, the middleware
    invokes _refresh_toggle_filter(app) which reaches into app.state.bus._
    pipeline_components["toggle_filter"] and calls .refresh(app.state.config).
    The dashboard no longer has to remember to ping POST /api/notifications/
    refresh-toggles after a toggle change. The explicit endpoint stays for
    backwards-compat.

(2) env_reporter block-size cap moved to adapter_config
    New registry row pipeline.env_reporter_block_chars (int, default 3000).
    meshai/notifications/env_reporter.py replaces the hardcoded
    _BLOCK_MAX_CHARS = 3000 with _DEFAULT_BLOCK_MAX_CHARS (the fallback) +
    a _block_cap() helper that reads from adapter_config on every slice.
    Mutating the row via PUT /api/adapter-config takes effect on the next
    env_reporter call -- no restart.

(3) Bulk-import endpoint for gauge_sites
    meshai/dashboard/api/gauge_sites_import.py adds
    POST /api/gauge-sites/import with two paths:
      format=csv      -- expects "data" (CSV text with header row matching
                          gauge_sites columns: site_id, gauge_name, lat, lon,
                          and optionally action_ft/flood_minor_ft/
                          flood_moderate_ft/flood_major_ft/enabled). UPSERT
                          via ON CONFLICT(site_id) DO UPDATE. Returns
                          {inserted, updated, skipped}.
      format=nws-ahps -- expects "wfo" (list of WFO codes). Fetches
                          water.weather.gov/ahps2/index.php?wfo=<WFO> for each,
                          regex-parses gauge links, then fetches up to 50
                          gauge detail pages per request and regex-parses
                          lat/lon + four threshold values. Best-effort; rows
                          stored under "AHPS-<gauge_id>" so they dont collide
                          with USGS-* ids. Returns the same shape plus
                          detail_fetched + errors list.
    Frontend (dashboard-frontend/src/pages/GaugeSites.tsx) gains a
    Import button + modal with two tabs (Paste CSV / Scrape NWS-AHPS)
    rendered via an ImportModal component. CSV tab has a 48-row textarea
    with the column-header hint inline; AHPS tab has a comma-separated WFO
    input defaulting to BOI. Both submit via fetch() and show the JSON
    response inline. Invalidates the curation cache server-side on any
    successful insert/update so nwis_handler sees the new gauges on its
    next call.

(4) WFIGS tombstone column -- CORRECTNESS
    v12.sql adds fires.tombstoned_at REAL (nullable) + idx_fires_tombstoned_at.
    meshai/central/wfigs_handler.py: the tombstone branch
    (kind=="wfigs_tombstone") UPDATE fires SET tombstoned_at=COALESCE(
    tombstoned_at, ?) so the first tombstone-time wins (idempotent against
    repeated tombstone envelopes).
    meshai/notifications/reminders/__init__.py: the wfigs tombstone
    termination condition now checks row["tombstoned_at"] IS NOT NULL.
    Reminders correctly STOP for closed fires -- before this change the
    8h cadence would have kept Active: broadcasts going indefinitely past
    a WFIGS removal.
    SCHEMA_VERSION 11 -> 12.

(5) Delete INCIDENT_BROADCAST_HEARTBEAT_S
    meshai/central/incident_handler.py: removed the dead constant
    (v0.5.9 REVISED dropped the heartbeat path but left the constant
    imported-but-never-read).
    tests/test_incident_handler.py: removed the orphan
    test_i_8h_heartbeat_triggers_update test (asserted None, used the
    deleted constant for time arithmetic) and the stray import line.

Tests (tests/test_tail_followups.py, 16 cases):
  - middleware fires refresh on PUT /api/config/notifications (200), does
    NOT fire on PUT /api/config/llm
  - env_reporter _block_cap() default 3000; mutate via PUT, invalidate,
    next read returns the new cap
  - CSV import inserts new rows, updates existing, skips bad rows,
    rejects missing required columns, rejects bad format
  - AHPS index parser extracts (gauge_id, name) from realistic HTML
  - AHPS detail parser extracts lat/lon + four thresholds from realistic
    HTML
  - fires has tombstoned_at column after migrations
  - wfigs tombstone branch stamps tombstoned_at
  - ReminderScheduler skips a fire whose tombstoned_at is NOT NULL
  - ReminderScheduler still fires for a fire whose tombstoned_at IS NULL
  - INCIDENT_BROADCAST_HEARTBEAT_S no longer importable

Foundation/API test counts bumped:
  REGISTRY 58 -> 59 (+ env_reporter_block_chars)
  schema_meta v11 -> v12

Test count: 844 -> 859 (+16 new, -1 deleted dead test). 0 regressions.
2026-06-05 21:37:05 +00:00
3a410d5087 feat(v0.6-phase3): reminder system + schema split + NWS dedup relaxation
Third broadcast type Active: clock-driven re-broadcasts of still-live
events at human-scale cadences. WFIGS fires 8h, itd_511 work zones daily
8 AM Mountain, SWPC G-storms 8h. NWS is NOT a clock reminder -- instead
the per-CAP-id dedup is relaxed to allow re-broadcast if >3h since last.
Schema split first_broadcast_at + last_broadcast_at on all reminder-
eligible tables. Wire prefix logic: New (first sight), Update (WFIGS
material change), Active (clock reminder). All cadences, channels, day-
of-week patterns, timezones, and termination conditions GUI-editable
from day one via the existing adapter_config editor. Termination:
tombstone OR containment_100 OR end_date_passed (no max-count). Quiet
hours not respected -- ripped out in Phase 2.

Schema (v11.sql):
  - ALTER TABLE fires|nws_alerts|traffic_events|quake_events|swpc_events|
    gauge_readings ADD COLUMN first_broadcast_at REAL
  - Backfill: UPDATE ... SET first_broadcast_at = last_broadcast_at
    WHERE last_broadcast_at IS NOT NULL
  - ALTER TABLE adapter_meta ADD COLUMN reminder_enabled INTEGER NOT NULL
    DEFAULT 0
  - UPDATE adapter_meta SET reminder_enabled=1 WHERE adapter IN
    ('wfigs', 'swpc') -- itd_511_work_zone is a new meta row seeded
    with reminder_enabled=1
  - SCHEMA_VERSION 10 -> 11

Handler commit-callbacks (wfigs/nws/quake/swpc/incident):
  - UPDATE ... SET last_broadcast_at=?, first_broadcast_at=COALESCE(
    first_broadcast_at, ?) -- first_broadcast_at stamped once, never
    overwritten

NWS handler (meshai/central/nws_handler.py):
  - _render() gains a prefix kwarg
  - After-first-broadcast branch: when (now - last_broadcast_at) >=
    adapter_config.nws.duplicate_allowed_after_seconds (default 10800
    = 3h), allow the re-broadcast with prefix=Active. Under the
    window, suppress as before. The commit callback continues to
    update last_broadcast_at.

ReminderScheduler (meshai/notifications/reminders/__init__.py):
  - Async loop, ticks every 60s
  - Each tick: SELECT adapter FROM adapter_meta WHERE reminder_enabled=1
  - Per adapter, load reminders_<adapter> config from adapter_config
    (cadence_kind, cadence_value, channels, terminate_when, dow_mask,
    timezone)
  - Interval cadence: rows where last_broadcast_at <= now - cadence_value
  - Clock cadence: localizes now to configured tz, finds slots that
    just passed in the last tick window, gated by dow_mask
  - Termination conditions checked per adapter:
      wfigs.containment_100      -> current_contained_pct >= 100
      wfigs.last_event_age_24h   -> last_event_at older than 24h
      swpc.end_date_passed       -> payload_json end_time in past
      itd_511_work_zone.end_date_passed -> traffic_events.end_at in past
  - Active: prefix on every emitted wire; dispatcher.dispatch_scheduled_
    broadcast() honors cold-start grace, bypasses toggle path
  - On success, last_broadcast_at = now; first_broadcast_at preserved

Launched from notifications/pipeline/__init__.py:start_pipeline()
alongside BandConditionsScheduler.

adapter_config registry (+15 new keys, 43 -> 58):
  - reminders_wfigs.cadence_kind/cadence_value/channels/terminate_when
  - reminders_swpc.cadence_kind/cadence_value/channels/terminate_when
  - reminders_itd_511_work_zone.cadence_kind/cadence_value/channels/
    dow_mask/timezone/terminate_when
  - nws.duplicate_allowed_after_seconds

adapter_meta (+4 rows, 15 -> 19):
  - reminders_wfigs, reminders_swpc, reminders_itd_511_work_zone
    (pseudo-adapters carrying the reminder config)
  - itd_511_work_zone (reminder target row; reminder_enabled=1)
  - reminder_enabled flag added to wfigs/swpc (existing rows updated by
    v11.sql) and to itd_511_work_zone seed.

Tests (tests/test_reminders.py, 10 cases):
  - wfigs reminder fires past 8h cadence, stamps last_broadcast_at,
    preserves first_broadcast_at
  - reminder skipped within cadence
  - reminder skipped when containment_100, last_event_age_24h
  - swpc reminder fires (interval)
  - work_zone clock reminder fires at 08:00 Mountain on enabled DOW
  - work_zone reminder skipped when end_date_passed
  - work_zone reminder skipped outside slot window
  - reminder_enabled=0 suppresses all reminders for that adapter

tests/test_nws_dedup_relaxation.py (5 cases):
  - First sighting renders without Active: prefix
  - Re-broadcast within 3h suppressed
  - Re-broadcast after 3h allowed with Active: prefix
  - adapter_config.nws.duplicate_allowed_after_seconds override takes
    effect (1h window verified)
  - First sighting stamps first_broadcast_at=committed_at,
    last_broadcast_at=committed_at; 4h later broadcast stamps
    last_broadcast_at only, first_broadcast_at preserved

Test count: 829 -> 844 (+15 new, 0 regressions). Foundation tests
updated for new counts (REGISTRY=58, ADAPTER_META=19, schema=v11).
2026-06-05 21:11:32 +00:00
b948ed775f feat(v0.6-phase2): rip out quiet hours entirely -- dashboard toggle, config schema, pipeline checks. Per Matt's repeated feedback (saved as feedback-quiet-hours-trash.md): silent is better than ugly, mesh users who need a fire alert at 3 AM need it at 3 AM. No replacement.
Backend removals:
  meshai/config.py
    - NotificationRuleConfig.override_quiet field
    - NotificationToggle.quiet_hours_override field
    - NotificationsConfig.quiet_hours_enabled / quiet_hours_start /
      quiet_hours_end fields
    - _default_toggles() no longer sets quiet_hours_override=True
    - rule migration helper no longer copies override_quiet
  meshai/notifications/router.py
    - self._quiet_enabled / _quiet_start / _quiet_end instance vars
    - _in_quiet_hours() method (deleted entirely)
    - The dispatch-time check that suppressed non-overriding rules
      during quiet hours
    - 'override_quiet': False dropped from subscription rule dicts
  meshai/notifications/pipeline/dispatcher.py
    - _toggle_to_rule() no longer passes override_quiet=... to the
      NotificationRuleConfig constructor

Test changes:
  tests/test_notification_toggles.py
    - RecChannel.deliver() no longer records override_quiet
    - test_quiet_hours_override_immediate_only deleted (only tested the
      removed feature)

Frontend removals (dashboard-frontend/src/pages/Notifications.tsx):
  - The 'Enable Quiet Hours' card with its time-range inputs deleted
  - 'Override Quiet Hours' per-rule toggle deleted
  - 'Quiet-hours override (immediate only)' per-toggle field deleted
  - quiet_hours_* fields removed from TS interfaces
  - quietHoursEnabled prop + state plumbing removed from the RuleEditor
  - All override_quiet: false defaults dropped from rule scaffolds
  - Unused Moon icon import dropped

Verification (post-strip):
  grep -rn 'quiet_hours\|override_quiet' meshai/*.py meshai/**/*.py
    -> 0 hits
  grep -rn 'quiet_hours\|override_quiet\|quietHours' dashboard-frontend/src
    -> 0 hits

Test count: 830 -> 829 (-1: test_quiet_hours_override_immediate_only
deleted; no other regressions).

No replacement. Mesh users who need a fire alert at 3 AM need it at 3 AM.
2026-06-05 20:39:36 +00:00
90783376e8 feat(v0.6-6): inhibit_state + grouper_held persistence + ToggleFilter live-reload + Inhibitor/Grouper config knobs
Closes audit doc section A.9 + finding #5. The last Phase-1 pipeline
state that lived only in instance memory now writes through to SQLite,
and ToggleFilter changes propagate without a container restart.

Schema:
  v10.sql adds inhibit_state(key PK, rank, expires_at, updated_at) and
  grouper_held(group_key PK, event_json, hold_until_at, updated_at).
  Indexes on expires_at / hold_until_at support the prune sweeps.
  SCHEMA_VERSION 9 -> 10.

Migration runner:
  Fixed the alphabetical-vs-numeric sort bug v10 surfaced -- the runner
  now sorts pending migrations by their integer version, not by
  filename, so v10.sql correctly applies AFTER v9.sql (was applying
  after v1 alphabetically, which made schema_meta stick at 9).

Inhibitor (meshai/notifications/pipeline/inhibitor.py):
  - __init__ restores non-expired keys from inhibit_state on construct.
  - handle() write-throughs every (key, rank, expires_at) tuple.
  - _prune_expired DELETEs the same expired keys from disk.
  - clear() (test path) drops the table.

Grouper (meshai/notifications/pipeline/grouper.py):
  - __init__ restores non-expired held events from grouper_held; the
    Event is rebuilt via Event.from_dict(json.loads(event_json)).
  - handle() write-throughs (group_key, event_json, hold_until_at).
  - tick() and flush_all() DELETE on emit.

ToggleFilter (meshai/notifications/pipeline/toggle_filter.py):
  - new refresh(config) method re-reads config.notifications.toggles and
    rebuilds the enabled set.

Live wiring:
  - meshai/dashboard/api/config_routes.py adds a POST
    /api/notifications/refresh-toggles endpoint that reaches into
    app.state.bus._pipeline_components["toggle_filter"] and calls
    refresh(app.state.config). The frontend pings this after PUT
    /api/config/notifications so toggles take effect on the next event.
  - meshai/main.py stashes self.event_bus on the dashboard FastAPI
    app.state after build_pipeline so the route can reach it.
  - Inhibitor.ttl_seconds and Grouper.window_seconds already read from
    adapter_config.pipeline.{inhibitor_ttl_seconds, grouper_window_seconds}
    via the v0.6-3b None-default wiring (rows seeded in v0.6-3a.1).

Tests (tests/test_pipeline_persistence.py, 11 cases):
  - v10 tables present
  - Inhibitor: state persists across simulated restart; expired rows
    not restored; prune removes from disk; clear() wipes both.
  - Grouper: state persists across restart; tick() clears disk;
    expired rows not restored.
  - ToggleFilter: refresh() picks up new enabled set; refresh(None)
    is a no-op; disabling a family in config + refresh drops it.

Test count: 819 -> 830 (+11 pipeline persistence cases + schema test
bump).
2026-06-05 20:23:34 +00:00
e3bf53ade4 feat(v0.6-4): gauge_sites + town_anchors curation tables + GUI CRUD
Closes Section A.5 (gauge_sites) and A.12 (town_anchors) of the audit
doc by lifting both Python-dict curation lists into editable SQLite
tables. Operators can add/edit/disable rows from the dashboard without
a deploy; runtime reads go through cached accessors that invalidate
when the REST API mutates state.

Schema:
  v8.sql adds gauge_sites(site_id PK, gauge_name, lat, lon, action_ft,
    flood_minor_ft, flood_moderate_ft, flood_major_ft, enabled, updated_at).
  v9.sql adds town_anchors(anchor_id AUTOINC PK, name UNIQUE, lat, lon,
    state, enabled, updated_at).
  SCHEMA_VERSION 7 -> 9.

Seed (meshai/persistence/curation.py):
  _GAUGE_SITES_SEED carries the original 9 Idaho rows from
  IDAHO_CURATED_SITES verbatim.
  _TOWN_ANCHORS_SEED carries the 29 Idaho-and-neighbor towns from
  _TOWN_COORDS verbatim.
  seed_gauge_sites() / seed_town_anchors() INSERT OR IGNORE -- safe to
  re-run; never overwrites user edits.

Handler integration:
  - meshai/central/idaho_gauge_sites.py: IDAHO_CURATED_SITES dict deleted.
    lookup_site() now calls meshai.persistence.curation.lookup_gauge_site()
    which reads the table. THRESHOLD_RANK, normalize_site_id, and
    compute_threshold_state remain in this module (CODE per Matt s rule).
  - meshai/central/nwis_handler.py drops IDAHO_CURATED_SITES from its
    import list; the table-backed lookup_site() is API-compatible.
  - meshai/central_normalizer.py: _TOWN_COORDS dict deleted.
    _compute_distance_bearing() now calls
    meshai.persistence.curation.lookup_town_anchor() with the same
    lowercased-name semantics it always used.

REST API (meshai/dashboard/api/curation_routes.py):
  /api/gauge-sites  GET list, GET one, POST add, PUT update, DELETE
  /api/town-anchors GET list, GET one, POST add, PUT update, DELETE
  Every mutation calls invalidate_curation_cache() so handler reads see
  the new state on the next call -- no container restart.

Dashboard (dashboard-frontend/src/pages/):
  - GaugeSites.tsx: table view with Add row / Edit row inline / Delete
    confirm + per-row enabled toggle. 8 columns mirror the schema.
  - TownAnchors.tsx: same pattern, 5 columns. Name is lowercased on
    save to match the lookup key.
  - Left-nav entries "Gauge Sites" (Droplets icon) and "Town Anchors"
    (MapPin icon) added to Layout.tsx; routes added to App.tsx.

Tests (tests/test_curation.py, 18 cases):
  - v8/v9 tables exist
  - Seed lands every row from both dicts
  - Seed idempotent; never overwrites user edits
  - lookup_gauge_site hits/miss, disabled rows are invisible
  - lookup_town_anchor case-insensitive
  - REST API: GET list, GET one, GET 404, POST add, PUT update, DELETE,
    POST missing-field 400; both gauge_sites + town_anchors
  - Accessor reflects API mutations after invalidate_curation_cache()

tests/test_nwis_handler.py back-compat: IDAHO_CURATED_SITES dict alias
points at _GAUGE_SITES_SEED so the existing assertion suite still passes.
tests/test_adapter_config_foundation.py schema_meta v7 -> v9 bump.

Test count: 797 -> 819 (+18 curation cases + 4 maintenance updates).
2026-06-05 20:19:13 +00:00
eb84f27941 feat(v0.6-5): env_reporter + router wiring + include_in_llm_context per-adapter toggle -- LLM gains read access to every adapter table via the existing mesh_reporter pre-rendered prompt-injection pattern
Closes audit doc Section C. The LLM can now answer "any fires near me?",
"how are band conditions?", "why didnt I hear about that quake?"
without any tool-use / MCP / SQL pass-through -- via the same prompt-
injection contract mesh_reporter uses.

env_reporter (meshai/notifications/env_reporter.py):
  - EnvReporter class with build_env_summary / build_fires_detail /
    build_alerts_detail / build_quakes_detail / build_traffic_detail /
    build_gauges_detail / build_swpc_detail / build_drop_audit / build_all
  - Reads from fires + firms_pixels + nws_alerts + quake_events +
    traffic_events + gauge_readings + swpc_events +
    band_conditions_broadcasts + event_log + dispatcher_state
  - Each build_*_detail() checks adapter_meta.include_in_llm_context for
    the relevant adapter(s) before reading; turning the meta off via
    /api/adapter-meta drops that adapters block out of the LLM prompt
  - Defensive: missing meta row defaults to True (include); DB-unavailable
    returns empty string; per-block 3000-char cap
  - Module-level env_reporter singleton for the router

Router wiring (meshai/router.py):
  - Extended _MESH_KEYWORDS dispatcher with _ENV_KEYWORDS_TO_SUBTYPE
    mapping (fire/quake/flood/warning/storm/road/swpc/etc -> coarse
    subtype). "flood" intentionally precedes "warning" so
    "river flood warning" routes to gauges, not alerts
  - _detect_env_subtype helper at module level (also test-importable)
  - _is_mesh_question now also fires for env keywords -- single detector
    per Matt s spec
  - _detect_mesh_scope returns ("env", subtype) when an env keyword
    matches, taking precedence over the node/region branches
  - generate_llm_response: when scope_type == "env", appends
    env_reporter.build_all() + env_reporter.build_drop_audit(hours=1)
    to the system prompt. Wrapped in try/except so a reporter fault
    never blocks the LLM call

Tests:
  - tests/test_env_reporter.py (18 cases): meta gate, every build_*
    method shape, build_all combines blocks, all-off produces empty
  - tests/test_router_env_scope.py (18 cases): parametrized subtype
    detection across fires/quakes/alerts/gauges/traffic/swpc, word-
    boundary check (firearm != fire), synthetic-probe end-to-end
    (seed fires table -> env_reporter emits a fires block with the
    seeded row)

Test count: 761 -> 797 (+36 new, 0 regressions).
2026-06-05 20:11:40 +00:00
42b3106e97 feat(v0.6-3c): adapter_config REST API + dashboard editor
Closes the audit-doc Section A keystone (the GUI editor). Together with
v0.6-3a foundation, v0.6-3a.1 trim, and v0.6-3b handler wiring, every
Rule-17 CONFIG knob from the audit is now editable in the dashboard
without a container restart.

API (meshai/dashboard/api/adapter_config_routes.py):
  GET  /api/adapter-config              -- {adapter: [{key, value, default,
                                            type, description}]}
  GET  /api/adapter-config/<adapter>    -- one adapter list
  GET  /api/adapter-config/<adapter>/<key> -- single row
  PUT  /api/adapter-config/<adapter>/<key> body {value} -- typed validation
       int: int or whole-number float; rejects bool, fractional float, str
       float: int or float; rejects bool
       str: str only
       bool: bool only
       json: any JSON-serializable value
       Every PUT calls invalidate_cache() so the next handler accessor
       read sees the new value -- no container restart needed.
  POST /api/adapter-config/<adapter>/<key>/reset -- value_json = default_json,
       cache invalidated
  GET  /api/adapter-meta                -- {adapter: {display_name,
                                            include_in_llm_context, description}}
  PUT  /api/adapter-meta/<adapter> partial-update body, fields:
       include_in_llm_context: bool, display_name: non-empty str

Dashboard (dashboard-frontend/src/pages/AdapterConfig.tsx):
  - Per-adapter cards. Header row shows display_name, the include_in_llm_context
    toggle, and an expand chevron. Adapters with zero config keys (e.g. itd_511)
    still render so users can toggle their LLM-context inclusion.
  - Expanded body lists each key with a type-aware widget:
      bool  -> checkbox, commit-on-change
      int/float -> number input, commit-on-blur (or Enter)
      str   -> text input, commit-on-blur
      json  -> textarea, commit-on-blur (JSON.parse with inline error)
    Each row shows the key name, type tag, description, "edited" badge when
    value != default, a per-key Reset button, and a save badge (spinner,
    check, error tooltip, or a small amber dot for unsaved local changes).
  - Auto-save semantics: every blur/change/reset triggers PUT immediately;
    no explicit Save button needed. Reset is one-click per key.

Wiring:
  - meshai/dashboard/server.py registers the new router with prefix /api.
  - dashboard-frontend/src/App.tsx adds the /adapter-config route.
  - dashboard-frontend/src/components/Layout.tsx adds the left-nav entry
    (Sliders icon, label "Adapter Config", after Reference).
  - Vite build produces a fresh meshai/dashboard/static bundle. The
    Dockerfile copies meshai/ so the new bundle ships with the container
    image at next rebuild.

Tests (tests/test_adapter_config_api.py, 30 cases):
  - GET grouped, per-adapter, single key
  - GET per-adapter returns [] for adapters with zero keys (itd_511)
  - PUT updates value, GET shows new value, accessor returns new value
    (proves cache invalidation propagates to the in-process accessor)
  - PUT type validation per (int, float, str, bool, json) incl. edge cases:
    int rejects str + fractional float + bool but accepts whole-number float;
    float accepts int + float, rejects bool; bool rejects int; str rejects
    other types; json accepts list / dict / None
  - PUT 404 on unknown key, 400 on missing value field
  - POST reset restores default + invalidates cache
  - GET /api/adapter-meta: include_in_llm_context defaults match registry
    (central / geocoder false, rest true)
  - PUT meta partial update: only provided fields change
  - PUT meta rejects non-bool include_in_llm_context, empty display_name,
    unknown adapter

Test count: 731 -> 761 (+30 API cases, 0 regressions).

Refs audit doc v0.6-phase1-audit.md Section A keystone + finding #4.
2026-06-05 18:50:30 +00:00
914d38c907 feat(v0.6-3b): wire every handler to adapter_config + v7.sql firms dedup_key column
Replaces module-level magic numbers in 12 handlers with reads via the
v0.6-3a.1 typed accessor. Every default matches the prior hardcoded
value exactly, so first-deploy behavior is unchanged.

Handlers wired (43 keys across the 43-row registry):

  wfigs            cooldown_seconds, anchor_max_mi, broadcast_on_acres,
                   broadcast_on_contained
  nws              broadcast_severities, tombstone_msgtypes,
                   warning_suffix_promotes
  usgs_quake       regional_centroid, regional_radius_mi,
                   broadcast_pager_alerts, global_mag_floor,
                   regional_mag_floor, escalate_mag_floor
  swpc             geomag_kp_floor (extends G-scale down to Kp 5 when
                                    lowered), flare_class_floor (R-scale
                                    extended to M-class when lowered),
                   proton_pfu_floor
  usgs_nwis        parameter_codes, broadcast_on_recede
  incident         freshness_seconds, broadcast_on_update (Update path
                                    re-implemented when toggled True:
                                    magnitude step-up / delay doubling /
                                    icon_category change)
  tomtom_incidents drop_zero_magnitude, drop_non_present
  state_511_atis   skipped_states (case-insensitive match against both
                                    state_code and primary_region suffix)
  central          severity_thresholds (immediate_min check ordered before
                                    priority_max so the +inf clamp still
                                    works)
  dispatcher       dedup_lru_max, cooldown_prune_size,
                   cooldown_prune_multiplier, dedup_db_retention_days
  band_conditions  swpc_freshness_seconds, hamqsl_url, hamqsl_timeout_s
  geocoder         (photon_url/timeout/radius/limit/town_osm_values/
                   h3_cache_max -- module-level constants kept as
                   backward-compat aliases; runtime reads via accessor)
  pipeline         Inhibitor.ttl_seconds + Grouper.window_seconds now
                   default to None, falling back to
                   adapter_config.pipeline.{inhibitor_ttl_seconds,
                   grouper_window_seconds}. Explicit constructor values
                   still win (test fixtures unchanged).
  firms            confidence_floor, frp_floor, bbox, dedup_distance_m

Schema:
  v7.sql adds firms_pixels.dedup_key column + drops the old hardcoded
  round(lat,5) UNIQUE INDEX, replaces with UNIQUE (dedup_key, acq_time,
  satellite). The firms_handler quantizes lat/lon to
  (dedup_distance_m / 111000) degrees at INSERT time -- meters-based
  precision per Matt s spec, tunable via the GUI without schema changes.

  SCHEMA_VERSION 6 -> 7. firms_pixels has 0 rows in production so no
  backfill needed.

CODE preserved (Matt s rule): sentence templates, emoji literals, the
TomTom icon_map / ITD sub_type_map / Central adapter_map / category_map
translation tables, the band_conditions Kp/SFI -> Good/Fair/Poor
heuristic, anchor-priority ordering, expires-bucket boundaries, the
NOAA G/R/S scale tables. None of these reach the GUI.

Hot-path performance: every accessor read hits the in-memory cache after
the first call; cache hit is one dict get. Per-event reads (e.g. WFIGS
cooldown_seconds on every WFIGS poll-cycle) add a single dict lookup
to existing pipelines.

Backward-compat aliases retained for module-level imports that exist in
test code: WFIGS_BROADCAST_COOLDOWN_S, FIRMS_CONFIDENCE_FLOOR,
FIRMS_FRP_FLOOR, FIRMS_BBOX_OPTIONAL, INCIDENT_FRESHNESS_MAX_S,
PHOTON_BASE_URL/TIMEOUT_S/RADIUS_KM/LIMIT. Handler code reads via
adapter_config; tests can either monkeypatch the module attribute (firms)
or mutate adapter_config DB values.

Test count: 731 -> 731 (no new tests in 3b -- handler wiring is a pure
refactor; coverage comes from the existing handler test suites passing
unchanged).

Refs audit doc v0.6-phase1-audit.md Section A + Matt s CONFIG-vs-CODE rule.
2026-06-05 18:38:21 +00:00