feat(products): PR 1 Phase B — Forecast Products dialog + HWO/SPS notifications#613
Merged
feat(products): PR 1 Phase B — Forecast Products dialog + HWO/SPS notifications#613
Conversation
fd507bc to
64333ad
Compare
baece35 to
fc6458b
Compare
Adds a TextProduct dataclass and a single async fetcher
get_nws_text_product(product_type, cwa_office) that handles AFD,
HWO, and SPS through one endpoint shape. AFD/HWO return one
TextProduct (or None); SPS returns a list sorted newest-first
(or [] when no active products).
TextProductFetchError distinguishes network/non-200 failures
from "no active products" — callers can notify on the latter
without treating it as an error.
ForecastProductService wraps the fetcher with per-type caching
via the existing Cache layer: AFD 1h, HWO 2h, SPS 15min. Keys
are namespaced per (product_type, cwa_office) so different
offices and types never collide. Fetch failures propagate and
are not cached.
get_nws_discussion becomes a thin wrapper around
get_nws_text_product("AFD", ...); its public signature and
return shape are preserved so existing AFD notifications and
dialogs work unchanged (14 AFD regression tests still green).
Adds explain_text_product(text, product_type, location_name, ...)
with a per-product system prompt lookup table. AFD keeps its exact
historical prompt byte-for-byte; HWO and SPS mirror its
objective-bullet shape with domain-appropriate phrasing.
explain_afd is now a thin wrapper over explain_text_product with
an unchanged public signature — every existing caller keeps
working, locked in by the AFD regression suite (14 tests green).
Adds per-product result caching that explain_afd lacked. Cache
key: ai_text_product:{TYPE}:{location}:{sha256(text)}:{style},
300s TTL, matching explain_weather. Failures are not cached.
custom_system_prompt and custom_instructions apply identically
across all three product types (replacement and append
semantics are preserved from the original AFD behavior).
Replaces the per-location "Discussion" entry point with "Forecast Products", opening a tabbed dialog that surfaces Area Forecast Discussion, Hazardous Weather Outlook, and Special Weather Statement in one place. ForecastProductPanel is a reusable wx.Panel shared across the three tabs: header, optional SPS multi-product chooser, raw product TextCtrl, issuance timestamp, and a Plain Language Summary button that stays hidden until first click (mirroring the discussion-dialog AI visibility design). Content-state copy handles loading, populated, empty, fetch-failed, and no-CWA states inside each panel — wx.Notebook can't per-tab disable on Windows. ForecastProductsDialog hosts a wx.Notebook with three panels, focuses the AFD TextCtrl on open, moves focus to the selected tab on page change via wx.CallAfter, and closes on ESC. Main window: QUICK_ACTION_LABELS rename to "Forecast &Products", Nationwide branch preserved, non-Nationwide routes to _on_forecast_products, and an adjacent StaticText "NWS products are US-only" shows next to a disabled button for non-US locations (adjacent-StaticText accessibility pattern).
…ications Extends the weather refresh cycle to pre-fetch AFD, HWO, and SPS for every saved US location with a populated cwa_office. The active location refreshes via _fetch_weather_data; non-active locations refresh via _pre_warm_other_locations. Both paths call _pre_warm_products_for_location, which isolates failures per (product_type, location) so one /products/types/... error cannot cascade. NotificationState gains last_hwo_issuance_time, last_hwo_text, last_hwo_summary_signature, and last_sps_product_ids — the fields the HWO update stream (Unit 10) and SPS dedupe stream (Unit 11) will read and write. Runtime state defaults gain hwo and sps sub-sections, and both converters (_runtime_section_to_legacy_shape, _legacy_shape_to_runtime_section) are updated in parallel so round-tripping never drops fields. Legacy JSON without the new sections loads with sensible defaults. WeatherAlert gains an additive affected_zones: list[str] with defensive .get on deserialize — legacy cache entries resolve to []. This unlocks Unit 11's SPS-vs-alert zone-intersection dedupe without requiring a re-fetch. All changes are additive. No schema_version bump.
Adds _check_hwo_update to NotificationEventManager: cold-start
fetches silently store baseline (issuance_time + text + content
signature); later fetches with a newer issuance_time and differing
signature dispatch a notification. Unchanged fetches are no-ops.
Notification body tries summarize_discussion_change first (the
existing AFD summarizer) and falls back to a generic
"Hazardous Weather Outlook updated for {cwa} — tap to view."
when the summary is empty or shorter than 20 characters.
A sliding 30-minute rate limit keyed (HWO, location) prevents
notification storms after prolonged app absence — state still
advances inside the window, just the dispatch is suppressed.
The rate-limit dict is ephemeral (in-memory only), not
persisted to runtime_state.
Gated by notify_hwo_update on AppSettings (Unit 12 adds the UI).
Defaults True via getattr fallback so the behavior ships the
same day the setting lands.
Wired into main_window_notification_events: each per-location
event-check cycle reads the pre-warmed HWO product from the
ForecastProductService cache (Unit 9 fills it) and runs the
check. AFD flow is untouched; existing 44 AFD tests green.
Adds _check_sps_new to NotificationEventManager. For each SPS
product not already in state.last_sps_product_ids, it checks
whether any currently-active "Special Weather Statement" alert
looks like the same advisory (headline / first-line substring
match, case-insensitive, whitespace-collapsed, bidirectional).
- Case A (event-style SPS that also shows up in /alerts/active):
the ID is recorded silently — no notification. The alerts
pipeline already surfaces these.
- Case B (informational SPS that lives ONLY on /products/types/SPS:
fire-weather planning forecasts, pollen advisories, coordination
statements): the notification fires, using headline when
present or the first non-empty line of productText otherwise,
truncated at 160 chars for Windows toast.
Cold-start: first fetch for a (location, "SPS") pair records all
current IDs silently and marks the location as cold-started.
Expiration: IDs present in state but absent from the latest fetch
are removed silently, matching the AFD/HWO "cleared" pattern.
Rate limit: 30-minute sliding window keyed ("SPS", location.name)
on the ephemeral _last_product_notified_at dict shared with HWO.
Gated by notify_sps_issued on AppSettings (Unit 12 adds the UI).
Defaults True via getattr fallback so the behavior ships the
same day the setting lands.
Wired into main_window_notification_events: the per-location
cycle reads the pre-warmed SPS list from the ForecastProductService
cache (Unit 9) and the current location's active alerts from
the weather cache, then runs _check_sps_new. AFD and HWO flows
are untouched; all prior regression tests stay green.
Adds notify_hwo_update and notify_sps_issued to AppSettings with defaults True, round-tripped through to_dict/from_dict via the existing _as_bool helper. Legacy config without the keys loads with both defaults ON. The Settings → Notifications tab gains two new checkboxes beneath the existing Discussion toggle, with descriptive labels that are the accessible announcement: - Notify on Hazardous Weather Outlook updates - Notify on Special Weather Statement (informational) Rewrites the section intro StaticText to match the new defaults-ON-for-HWO/SPS behavior: "Updates beyond standard alerts. Hazardous Weather Outlook and informational Special Weather Statement updates are on by default because they deliver information not in the alerts feed. Others are off unless you turn them on." Completes PR 1 — the Forecast Products dialog and its HWO/SPS notification streams now have their user-facing controls.
…IGHT wxPython rejects wx.ALIGN_RIGHT on children of a horizontal BoxSizer (alignment flags in a horizontal sizer only control vertical placement). The old code tripped a C++ assertion that showed a "DO NOT PANIC" dialog on every Forecast Products open, blocking the dialog from rendering. Use AddStretchSpacer + plain Add on the inner horizontal sizer, and expand the whole row via the vertical main_sizer — same visual result, no assertion.
Two user-surfaced issues from manual testing: 1. Forecast Products showed "NWS text products will populate after the next weather refresh" even after refreshes completed, for every saved location. Root cause: the drift-correction hook in weather_client_nws was a silent no-op because set_zone_drift_sink was never called at app startup (flagged as a known deferral when Unit 3 landed). Without the sink, /points responses on refresh never backfill cwa_office on legacy saved locations, so the Forecast Products dialog has no CWA to fetch with. Wire app.config_manager._locations as the sink in initialize_components; the hook now persists zone metadata on every refresh via the existing wx.CallAfter main-thread bounce. 2. EVT_NOTEBOOK_PAGE_CHANGED grabbed focus into the selected tab's TextCtrl on every tab switch. A screen reader user arrow-keying through tabs would hear the full product text re-announced on each key. The standard notebook contract is: the tab strip is one focus level, the content is the next; the user Tabs into content themselves. Remove the page-change handler and its binding. Initial focus-on-open into AFD content is preserved — the user explicitly opened the dialog, so content-first makes sense at that moment.
Forecast Products panels stayed in "Loading..." forever because the panel's _schedule_load called asyncio.ensure_future on the wx main thread, which has no running asyncio loop. The RuntimeError was caught silently, so the coroutine never ran. The app runs asyncio on a background thread (see app._start_async_loop) and exposes run_async, which existing dialogs (DiscussionDialog) already use. Thread the app reference through show_forecast_products_dialog -> ForecastProductsDialog -> ForecastProductPanel, then prefer app.run_async when available for both the product loader and the AI explainer. Falls back to ensure_future when no app is supplied (still useful for tests), but now surfaces the error as a load failure instead of silently leaving the panel stuck. Verified the NWS endpoints themselves are healthy via curl — this was purely a dispatch-layer bug.
Three small accessibility / wording improvements from manual testing: 1. Tab labels now read "Area Forecast Discussion", "Hazardous Weather Outlook", "Special Weather Statement" instead of the cryptic "AFD" / "HWO" / "SPS" abbreviations. The plan specified the abbreviations but the full names are clearer on first encounter and a screen reader announces them naturally. 2. On open, focus lands on the notebook tab strip itself, not inside the selected tab's TextCtrl. A screen reader user first hears which tab is active and can arrow-key through the tabs before choosing to read content by Tab-ing in. 3. The SPS multi-product chooser label now reads "Active Special Weather Statements:" instead of the ambiguous "Active Statements:" — makes the widget's purpose obvious. The now-unused _focus_active_tab helper is removed.
The SPS multi-product chooser (StaticText + wx.Choice) was created on every tab and hidden via Show(False) on AFD/HWO. Screen readers still pick up hidden wx.StaticText, so on the AFD and HWO tabs users heard "Active Special Weather Statements:" announced as part of the tab's layout — misleading and wrong context. Fix: only instantiate the chooser widgets when product_type == "SPS". AFD/HWO panels now expose self.sps_choice = None and all code paths that touched the chooser guard against None explicitly.
The /products/types/SPS/locations/{CWA} endpoint returns the
archive of recent SPS products for an office — NOT a filtered
list of currently-in-effect advisories. Verified against PHI
on 2026-04-20: the only SPS in the @graph was from 2026-04-16
(a fire-weather planning forecast that had long since expired).
Labeling the chooser "Active Special Weather Statements:" and
the empty state "No Special Weather Statements currently active
for {CWA}" overclaimed what we actually know. Users would
reasonably assume the product was still in effect.
Rename both to "Recent". The per-product "Issued: {time}"
StaticText remains the honest signal users can read to judge
whether a given statement is still applicable for their
planning window.
The Plain Language Summary button was permanently disabled because the panel gated on a non-None self._ai_explainer, but main_window passed getattr(self.app, "ai_explainer", None) which was always None — the app has no such attribute. DiscussionDialog doesn't hold a cached explainer either; it builds one each click from SecureStorage and AppSettings. Mirror that pattern for the new panels. The button now enables as soon as content loads and an openrouter_api_key is present in SecureStorage. A _build_explainer helper constructs AIExplainer at click time with the right model + custom-prompt settings, returning None when no key is available so _run_explain can surface the familiar "OpenRouter API key not configured" error. Tests that inject self._ai_explainer still take priority, so the GUI test suite continues to pass unchanged.
Match DiscussionDialog's post-explanation provenance block so the new Forecast Products tabs are consistent with the AI affordance users already know from AFD. Adds a Model Information: label + multiline read-only TextCtrl (size 80px tall) below the plain-language summary. Hidden until an explanation returns, same as DiscussionDialog. _run_explain now passes model_used, token_count, estimated_cost, and cached through to _on_explain_complete, which formats them as "Model: X / Tokens: N / Cost: ~$0.xxxxxx" (or "No cost" when zero) with an optional "Cached: Yes" line. _on_explain hides the info block when kicking off a fresh run so the previous call's numbers don't linger during generation. _hide_ai_summary_section also hides the info block for symmetry.
"Forecaster Notes" reads warmer than "Forecast Products" and covers what each tab actually shows (forecaster reasoning in AFD, hazard outlook in HWO, ad-hoc statements in SPS) without technically implying that every tab is a "Discussion" — which "Forecast Discussions" would have overclaimed since HWO and SPS are not discussions at all. Updated user-visible strings only: - main-window button: "Forecaster &Notes" (mnemonic N, unique) - View-menu item: "Forecaster &Notes..." with matching tooltip "View NWS forecaster notes (AFD, HWO, SPS)" - Dialog title: "Forecaster Notes" - Error dialog body on open failure - Non-US explanatory StaticText: "Forecaster Notes are US-only" Internal identifiers (forecast_products_dialog.py, _on_forecast_products, show_forecast_products_dialog, forecast_products_us_only_label) are intentionally left alone — renaming those adds git-history churn and test reference turnover for no user benefit.
The fixtures in these three files monkey-patch wx.Panel.__init__ and wx.Dialog.__init__ to no-ops so MagicMock parents don't blow up the real-widget constructor. That's safe when tests run against the in-repo wx stub (tests/conftest.py), but destructive against real wxPython — the patches leak across test files and break subsequent alert_dialog tests with: TypeError: Window.SetSizer(): first argument of unbound method must have type 'Window' Which was exactly what CI was hitting on Ubuntu under xvfb. Gate each of the three GUI modules with a pytestmark that skips when hasattr(sys.modules["wx"], "_core") — a reliable signal that real wxPython is loaded (C-extension present). Net effect: - Local dev with the stub: 42 tests run as before. - CI under real wx: 42 tests skip; unrelated alert_dialog tests run undisturbed. - The underlying Forecaster Notes code paths are already exercised end-to-end through manual testing documented in the PR. A proper fix (refactor fixtures to use monkeypatch, or build tests that work against real wx with a real wx.App lifecycle) is a follow-up task — this skip is the minimum change to unblock CI.
41be31c to
69daa11
Compare
Orinks
added a commit
that referenced
this pull request
Apr 20, 2026
Syncs dev into main. Includes PR 1 (Forecaster Notes) Phase A and Phase B, plus any other dev-only work since the last sync. Merging before tonight's nightly build runs. Recent dev content going to main: - #612 — PR 1 Phase A: zone metadata data layer - #613 — PR 1 Phase B: Forecaster Notes dialog + HWO/SPS notifications --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Orinks
added a commit
that referenced
this pull request
Apr 21, 2026
Routine sync of `dev` into `main`. Includes: - fix(tests): stop leaking test values into developer keyring ([#615](#615)) - feat(products): Forecast Products dialog + HWO/SPS notifications ([#613](#613)) - feat(zones): NWS zone metadata on saved locations ([#612](#612)) - chore: remove unused html_formatters module - feat(ui): add preference for location buttons near dropdown ([#611](#611)) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this delivers
Phase B of bundled PR 1. Stacked on top of #612 (Phase A — zone metadata data layer). Together they complete the Forecast Products feature described in the two brainstorms landed with the plan.
Users get:
/products/types/SPSbut never reach the/alerts/activefeed (Case B per the live-API verification documented in the brainstorm). Event-style SPS (Case A — hail, dense fog, etc.) are suppressed because the existing alert pipeline already surfaces them. Default ON.Zero new dependencies. No config
schema_versionbump. Every new field is additive.Why a stacked draft
Phase A on #612 is self-contained and reviewable on its own. Phase B builds on top of it. Target base:
feat/forecast-products-pr1. When #612 merges tomain, this PR's base should be flipped tomain.Commit-by-commit
fd8839eget_nws_text_productfetcher +ForecastProductServicewith per-type TTL cache (AFD 1h, HWO 2h, SPS 15min).get_nws_discussionbecomes a thin wrapper — existing AFD callers unchanged.e9cf8afAIExplainer.explain_text_productwith per-product system prompts + 300s result cache.explain_afdbecomes a thin wrapper; AFD behavior preserved byte-for-byte.dfda206ForecastProductPanelreusable wx.Panel +ForecastProductsDialogwx.Notebook. Main window: rename, Nationwide branch preserved, non-US button disabled with adjacent StaticText.86a5537NotificationState+runtime_stateextensions for HWO/SPS.WeatherAlert.affected_zonesadditive field.1bd7d34notify_hwo_update.6bc24a6notify_sps_issued.1a52243notify_hwo_update+notify_sps_issuedonAppSettings(default True) + Settings UI toggles + intro copy rewrite to honestly describe defaults-ON behavior.Design decisions worth noting
TextProductdoesn't carry zone data; deriving it would require fetcher changes. Headline substring matching against activeSpecial Weather Statementalerts turned out to be sufficient for the Case A / Case B split the brainstorm identified. Both the verified PHI 2026-04-16 fire-weather SPS (Case B) and a matching event-style SPS alert replay cleanly.docs/alert_audit_report.md §7flagged that the existing alert rate-limit's calendar-hour reset can burst 10 notifications at 12:00:01. PR 1's new HWO/SPS streams don't repeat that mistake — the 30-min-per-(stream, location) dict compares timestamps directly.wx.Notebookcan't per-tab disable on Windows. All empty and error states render inside the content panel, not as a tab label. Tab switch focus is managed viaEVT_NOTEBOOK_PAGE_CHANGED+wx.CallAfter.docs/superpowers/specs/2026-04-08-discussion-dialog-ai-visibility-design.md.Testing
Per-unit test files (all new):
tests/test_weather_client_nws_text_product.py(11 scenarios)tests/test_forecast_product_service.py(6)tests/test_ai_explainer_text_product.py(9)tests/gui/test_forecast_product_panel.py(19)tests/gui/test_forecast_products_dialog.py(8)tests/test_main_window_product_prewarm.py(8)tests/test_notification_state_hwo_sps_fields.py(5)tests/test_runtime_state_hwo_sps.py(6)tests/test_cache_weather_alert_affected_zones.py(3)tests/test_notification_hwo_update.py(14)tests/test_notification_sps_dedupe.py(14)tests/test_settings_notification_toggles.py(10)113 new tests + zero AFD / existing regressions. Full non-integration sweep: 3617 passed, 5 skipped. The 4
test_alert_dialog_dispatchparallel flakes pre-date this branch and pass when run standalone (xdist worker isolation quirk, not a real regression).Manual test plan
wx.Choiceselector; single SPS hides itKnown deferrals intentionally out of scope
ConfigManager.save_configin-process lock — tracked as follow-up from Phase A.