Commit graph

296 commits

Author SHA1 Message Date
ae884b9651 feat: avalanche multi-line wire format, danger-level re-emit, GUI panel
- store.py: add avalanche-specific elif block with danger_level rise
  detection; re-emit on level increase with _is_update flag
- avalanche.py: rewrite to_event() with multi-line wire format
  (ski emoji + New:/Update: prefix, zone, danger name/level,
  travel advice, center_id), min_danger_level floor from adapter_config
- defaults.py: add (avalanche, min_danger_level) to REGISTRY (default=3)
- Environment.tsx: structured avalanche panel with broadcast settings
  section, min danger level select (3-Considerable/4-High/5-Extreme)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-09 04:00:47 +00:00
fe6589e0e5 feat: quake multi-line wire format + GUI panel wired to real adapter_config keys
A) _render() now emits multi-line format matching Fire/Roads style:
   emoji prefix M{mag} — place / Depth · coords / TSUNAMI WARNING
B) Environment.tsx usgs_quake panel replaced — dead min_magnitude/bbox
   controls removed, wired to real adapter_config keys: global_mag_floor,
   regional_mag_floor, regional_radius_mi, escalate_mag_floor,
   broadcast_pager_alerts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-09 03:38:46 +00:00
951fddf079 docs: park nwis_handler -- flow-only feed, no stage data on Idaho sites
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-09 03:29:53 +00:00
96f14afba8 refactor: promote WZDx to first-class adapter with own config namespace
Move work zone settings out of itd_511 into dedicated wzdx adapter:
- config.py: add WZDxConfig dataclass with feed settings
- defaults.py: migrate 3 work_zone keys to wzdx namespace (broadcast,
  min_severity, sub_types) + add ADAPTER_META entry
- incident_handler.py: work zone gate reads adapter_config.wzdx
- Environment.tsx: full WzdxConfig state/load/save/discard, native feed
  fields when feed_source!=central, broadcast settings panel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 21:33:38 +00:00
dc64430394 dashboard: split WZDx Work Zones into dedicated Roads sub-tab
Add 'wzdx' adapter key with its own META entry and render block.
Move work zone controls (enable toggle, min severity, sub-types)
out of the roads511 panel into the new WZDx tab. Data still
loads/saves via /api/adapter-config/itd_511 using the existing
roads511Config state. The wzdx panel mirrors roads511 enabled and
feed_source since they share the same backend adapter.

Bundle: D045j2lq -> BiMKNe5L.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 21:01:36 +00:00
53decde03c itd_511: add configurable work zone broadcast gate + dashboard controls
defaults.py: add work_zone_enabled (bool, default false),
work_zone_min_severity (str, default Minor), work_zone_sub_types
(json, default [road_works, lane_closed, road_closed]) to itd_511.

incident_handler.py: replace hardcoded work_zone return None with
adapter_config-driven gate. Resolve sub_type and event_sev before
the work_zone check so severity and sub-type filters apply. Non-work-zone
events keep the existing min_severity / enabled_categories / enabled_sub_types
filters unchanged.

Environment.tsx: add work_zone_enabled, work_zone_min_severity,
work_zone_sub_types to Roads511Config. Load/save/discard wired. Work Zones
section in roads511 panel with enable toggle, min severity dropdown, and
sub-type checkboxes (visible only when enabled).

Bundle: KLGUZQYL -> D045j2lq.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 18:18:39 +00:00
f75bf75378 dashboard: rebuild frontend bundle (BERKejLl -> KLGUZQYL)
Includes NWS broadcast filter controls from 31c464c that were missing
from the previous bundle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 17:57:14 +00:00
c6c15e03c2 central: silently drop work_zone envelopes from broadcast pipeline
consumer.py: return None immediately for work_zone/road_closure/road_incident
categories instead of routing through format_work_zone_mesh.

incident_handler.py: add work_zone kind to _parse_itd_511_incident and return
None immediately so itd_511 work_zone events never reach change-detection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 17:21:01 +00:00
31c464c0ee dashboard: add NWS broadcast filter controls to Environment page
Add NwsConfig adapter config (broadcast_severities, duplicate_allowed_after_seconds)
with load/save/discard/change-detection wiring. When feed_source=central, hide
native-only fields (User Agent, Tick Seconds) and show Broadcast Filters section
with severity checkboxes and re-broadcast cooldown input.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 14:55:05 +00:00
fa8d89c9cd nws: replace NWSheadline with structured line 2 — Until {time} {tz} — {area}
Drop NWSheadline entirely. Build line 2 from expires_epoch (formatted
to local America/Boise time with timezone abbrev) and first areaDesc
segment (truncated to 50 chars at word boundary).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 14:23:18 +00:00
9de514c4bd nws: use raw NWSheadline with no casing manipulation
Remove all headline casing (title-case, sentence-case, TZ regex). Pass
the NWSheadline string through as-is from the CAP parameters, only
applying word-boundary truncation to 80 UTF-8 bytes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 07:18:40 +00:00
d312f10ff8 nws: sentence-case NWSheadline instead of title-case
Replace .title() with manual sentence-casing: capitalize first char,
lowercase the rest, then re-uppercase timezone abbreviations (MDT, MST,
PDT, PST, CDT, CST, EDT, EST, UTC) via regex.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 07:16:21 +00:00
e27d60ca49 nws: lowercase wind speed, strip trailing punctuation on locations, fix compass
Fix 1: wind.lower() so 60 MPH winds becomes 60 mph winds.
Fix 2: rstrip trailing period/comma/space from locations text.
Fix 3: bearing is direction storm moves TOWARD, not FROM — remove
the +180 flip and use (deg+22.5)/45 for correct compass bucketing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 07:09:47 +00:00
ddf24e10a9 nws: truncate NWSheadline at word boundary, no ellipsis
Cut at last space before 80 UTF-8 bytes instead of hard-slicing at 77
chars with trailing dots.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 07:07:07 +00:00
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