22 Commits

Author SHA1 Message Date
b3f7c9c1f2 Add roadmap and implementation plan to move towards hybrid deterministic/llm generation of reports 2026-06-13 23:20:04 -05:00
d425132ae7 Validate near-term implementation 2026-06-12 17:48:02 +00:00
925d351341 Document near-term report behavior 2026-06-12 17:45:37 +00:00
c4107490df Add near-term report config overrides 2026-06-12 17:42:49 +00:00
b38230bc35 Add near-term generation artifact coverage 2026-06-12 17:40:54 +00:00
1f5f9964e0 Add near-term generate command 2026-06-12 17:36:58 +00:00
b0d8c6983d Support near-term derived facts 2026-06-12 17:34:40 +00:00
55f0180599 Add near-term module composition 2026-06-12 17:31:37 +00:00
5284033fb8 Add near-term report identity 2026-06-12 17:28:26 +00:00
0b1423c90d Add a roadmap and implementation plan for rolling near-term forecast reports 2026-06-12 12:24:07 -05:00
e6a4bb2d16 Clean up and normalize prompt output modules 2026-06-12 12:00:40 -05:00
c3a051d372 Validate SPC convective outlook feature 2026-06-12 15:18:38 +00:00
3482551360 Document SPC convective outlook behavior 2026-06-12 15:17:06 +00:00
d7a72f8581 Add SPC convective workflow coverage 2026-06-12 15:14:09 +00:00
c09b7410ce Add SPC convective modules to report defaults 2026-06-12 15:10:40 +00:00
96ce0edd46 Add SPC convective discussion briefing module 2026-06-12 15:05:42 +00:00
7cd68ff222 Add SPC convective outlook briefing module 2026-06-12 15:02:49 +00:00
16680e3f61 Route SPC convective stanzas in prompt packages 2026-06-12 14:58:14 +00:00
3389d4fa93 Derive report-period SPC convective outlooks 2026-06-12 14:56:17 +00:00
0041845935 Fetch SPC convective outlook data 2026-06-12 14:51:54 +00:00
3bcccb4a7b Add SPC convective outlook data contracts 2026-06-12 14:47:42 +00:00
2aba52f552 Add roadmap and staged plan for implementing SPC convective outlook support 2026-06-12 09:42:43 -05:00
59 changed files with 4290 additions and 203 deletions

View File

@@ -21,6 +21,7 @@ the managed Markdown report after final metadata is saved.
weatherreporter --help weatherreporter --help
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate near-term [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
@@ -37,8 +38,11 @@ weatherreporter inspect sources [--config PATH] RUN_ID
`generate` commands write a JSON module snapshot, YAML data package, preflight `generate` commands write a JSON module snapshot, YAML data package, preflight
artifact, managed Markdown report, and metadata under the configured workspace. artifact, managed Markdown report, and metadata under the configured workspace.
`--out` writes an extra Markdown copy for the operator; distributor `--out` writes an extra Markdown copy for the operator; distributor
notification uses the managed report path, not the extra copy. `generate storm` notification uses the managed report path, not the extra copy. `generate
requires explicit event-window bounds with `--start` and `--end`. near-term` uses the current generation time, covers the next six hours in the
effective report timezone, and does not accept date or event window flags.
`generate storm` requires explicit event-window bounds with `--start` and
`--end`.
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook `run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch
@@ -50,6 +54,9 @@ notification is enabled, batch summaries and status lines include notification
status, accepted distributor run ID, or notification error fields for each status, accepted distributor run ID, or notification error fields for each
attempted report. attempted report.
Near-Term Report generation is explicit only; it is not included in `run
morning` or `run evening`.
`inspect` commands read existing workspace artifacts and emit JSON to stdout. `inspect` commands read existing workspace artifacts and emit JSON to stdout.
They do not fetch weather data or invoke `scriptorium`. They do not fetch weather data or invoke `scriptorium`.
@@ -76,6 +83,7 @@ are no distributor-specific CLI flags.
```sh ```sh
weatherreporter generate tomorrow --out ./tomorrow.md weatherreporter generate tomorrow --out ./tomorrow.md
weatherreporter generate near-term --out ./near-term.md
weatherreporter generate three-day --out ./three-day.md weatherreporter generate three-day --out ./three-day.md
weatherreporter generate weekend --out ./weekend.md weatherreporter generate weekend --out ./weekend.md
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md

View File

@@ -130,7 +130,9 @@ name the variable only; they should not contain the token value.
- `sources`: optional map of source-specific overrides, using the same policy values. - `sources`: optional map of source-specific overrides, using the same policy values.
Hourly forecast data is required for generated reports. Optional sources use Hourly forecast data is required for generated reports. Optional sources use
the missing-source policy. the missing-source policy. Source override keys include `observations`,
`current`, `narrative`, `alerts`, `discussion`, `weather_story`, and
`spc_convective_outlooks`.
### `scriptorium` ### `scriptorium`
@@ -179,9 +181,10 @@ snapshot exists and a threshold is crossed.
`reports` optionally overrides the ordered deterministic modules declared by `reports` optionally overrides the ordered deterministic modules declared by
report definitions. Omit a report entry to use its default module order. report definitions. Omit a report entry to use its default module order.
Supported report keys are `daily`, `tomorrow`, `three_day`, `weekend`, and Supported report keys are `daily`, `tomorrow`, `near_term`, `near-term`,
`storm`. Canonical report IDs such as `daily_today` and `daily_tomorrow` are `three_day`, `weekend`, and `storm`. Canonical report IDs such as
also accepted. `daily_today` and `daily_tomorrow` are also accepted. `near_term` and
`near-term` are aliases for the same Near-Term Report override.
Each report entry supports: Each report entry supports:
@@ -197,10 +200,13 @@ reports:
- metadata - metadata
- current_conditions - current_conditions
- narrative_forecast - narrative_forecast
- alert_digest
- spc_convective_outlooks
- id: area_forecast_discussion - id: area_forecast_discussion
options: options:
sections: sections:
- short_term - short_term
- spc_convective_discussion
- hourly_forecast - hourly_forecast
``` ```

View File

@@ -37,6 +37,10 @@ generation.
means the endpoint was checked and there are no current active alerts. The means the endpoint was checked and there are no current active alerts. The
adapter records a non-missing alerts source and an empty alert run. adapter records a non-missing alerts source and an empty alert run.
For `/outlooks/convective`, `data: null` means no latest run is available and
follows missing-source policy. A non-null run with empty `outlooks` and
`discussions` arrays is checked empty data, not a missing source.
Malformed JSON envelopes, non-2xx statuses, and response read failures include Malformed JSON envelopes, non-2xx statuses, and response read failures include
endpoint context in returned errors. Decode errors include source context when endpoint context in returned errors. Decode errors include source context when
they fail the fetch; optional malformed sources follow the missing-source policy. they fail the fetch; optional malformed sources follow the missing-source policy.
@@ -49,11 +53,66 @@ The adapter sends these query parameters:
- `units`: from `weather_api.units` - `units`: from `weather_api.units`
- `precision`: from `weather_api.precision` on observations, current - `precision`: from `weather_api.precision` on observations, current
conditions, hourly forecast, and narrative forecast requests conditions, hourly forecast, and narrative forecast requests
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast, and - `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast,
discussion requests discussion, and SPC convective outlook requests
Alerts do not receive `precision` or `tz`. Weather story requests receive only Alerts do not receive `precision` or `tz`. Weather story requests receive only
`format=json`. `format=json`. SPC convective outlook requests receive only `format=json` and
`tz`; they do not receive `units` or `precision`.
## SPC Convective Outlooks
The adapter fetches SPC convective outlook data from:
```text
GET /outlooks/convective?format=json&tz=<weather_api.timezone>
```
The response uses the standard `data` envelope. `data: null` means no latest
run is available and follows missing-source policy. A non-null object with
empty `outlooks` and `discussions` arrays is accepted as checked empty data.
Run fields consumed by weatherreporter:
- `locationId`
- `locationName`
- `asOf`
- `issuedAt`
- `updatedAt`
- `product`
- `outlooks`
- `discussions`
Outlook fields consumed:
- `id`
- `provider`
- `product`
- `day`
- `outlookType`
- `label`
- `labelText`
- `forecaster`
- `severityRank`
- `validFrom`
- `validTo`
- `issuedAt`
- `expiresAt`
- `sourceUrl`
- `imageUrl`
- `containsLocation`
- `geometry`
Discussion fields consumed:
- `day`
- `headline`
- `summary`
- `discussion`
- `updatedAt`
GeoJSON `geometry` is decoded into collected weather facts and persisted in
bundle/debug artifacts, but prompt-facing SPC module output omits geometry.
## Endpoints Used ## Endpoints Used
@@ -66,6 +125,7 @@ The adapter fetches these endpoints once per bundle:
- `/alerts/active` - `/alerts/active`
- `/discussion` - `/discussion`
- `/weatherstories/latest` - `/weatherstories/latest`
- `/outlooks/convective`
`weatherreporter` does not call day-slice forecast endpoints or discussion `weatherreporter` does not call day-slice forecast endpoints or discussion
subsection endpoints. Report-period selection and daypart summarization happen subsection endpoints. Report-period selection and daypart summarization happen
@@ -88,6 +148,7 @@ source-specific `missing_source.sources` policy:
- `alerts` for `/alerts/active` - `alerts` for `/alerts/active`
- `discussion` for `/discussion` - `discussion` for `/discussion`
- `weather_story` for `/weatherstories/latest` - `weather_story` for `/weatherstories/latest`
- `spc_convective_outlooks` for `/outlooks/convective`
Policy behavior: Policy behavior:
@@ -99,6 +160,9 @@ For `/alerts/active`, an HTTP error or missing `data` field still fails or
follows the relevant error path, but explicit `data: null` is not a follows the relevant error path, but explicit `data: null` is not a
missing-source condition. missing-source condition.
For `/outlooks/convective`, a non-null data object with empty outlook and
discussion arrays is accepted as checked empty data.
## Source Identity ## Source Identity
For source payloads accepted into the bundle, including the explicit `null` For source payloads accepted into the bundle, including the explicit `null`
@@ -116,8 +180,8 @@ warnings list.
## Compatibility Assumptions ## Compatibility Assumptions
The adapter expects payload fields compatible with the internal forecast bundle The adapter expects payload fields compatible with the internal weather data
types in `internal/forecast/bundle.go`, including: bundle types in `internal/weatherdata/bundle.go`, including:
- observation timestamps and observation values - observation timestamps and observation values
- current condition values - current condition values
@@ -126,6 +190,8 @@ types in `internal/forecast/bundle.go`, including:
- discussion metadata, key messages, and short/long-term section text - discussion metadata, key messages, and short/long-term section text
- latest weather story title, description, timing, priority, order, alt text, - latest weather story title, description, timing, priority, order, alt text,
and download URL and download URL
- SPC convective outlook run metadata, outlooks, discussions, and GeoJSON
geometry
The adapter intentionally keeps upstream transport and envelope details inside The adapter intentionally keeps upstream transport and envelope details inside
`internal/adapters/weatherapi`; downstream packages consume the normalized `internal/adapters/weatherapi`; downstream packages consume the normalized

View File

@@ -28,7 +28,9 @@ Outputs:
supported reports, fact requirements, missing-data behavior, and builder supported reports, fact requirements, missing-data behavior, and builder
- `module.Output` values for source-oriented stanzas: - `module.Output` values for source-oriented stanzas:
`metadata`, `current_conditions`, `narrative_forecast`, `hourly_forecast`, `metadata`, `current_conditions`, `narrative_forecast`, `hourly_forecast`,
`alert_digest`, `area_forecast_discussion`, and `weather_story` `alert_digest`, `spc_convective_outlooks`,
`area_forecast_discussion`, `spc_convective_discussion`, and
`weather_story`
- `module.Output` values for derived stanzas: - `module.Output` values for derived stanzas:
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`, `derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
`outdoor_windows`, and `tomorrow_planning` `outdoor_windows`, and `tomorrow_planning`
@@ -36,6 +38,14 @@ Outputs:
Every registered composition entry has a builder. Unknown or unimplemented Every registered composition entry has a builder. Unknown or unimplemented
module IDs fail validation instead of being skipped. module IDs fail validation instead of being skipped.
Near-Term Report supports source and valid-period modules that operate over its
rolling six-hour period: `metadata`, `current_conditions`, `hourly_forecast`,
`precip_timing`, `alert_digest`, `spc_convective_outlooks`,
`area_forecast_discussion`, `spc_convective_discussion`, and `weather_story`.
It does not support daily/daypart-only modules such as
`derived_daily_summary`, `derived_daypart_summaries`, `outdoor_windows`, or
`tomorrow_planning`.
Prompt-facing module values use local, human-readable date and time labels Prompt-facing module values use local, human-readable date and time labels
where the LLM is expected to reason about report content. Canonical timestamps where the LLM is expected to reason about report content. Canonical timestamps
remain in report metadata, source provenance, and integration artifacts. remain in report metadata, source provenance, and integration artifacts.
@@ -57,7 +67,24 @@ builders run. Configured `location` values are prompt context only; Weather API
`sourceLocationId` and `sourceLocation` remain source provenance. `sourceLocationId` and `sourceLocation` remain source provenance.
`area_forecast_discussion` uses optional `sections` configuration to include a `area_forecast_discussion` uses optional `sections` configuration to include a
subset of discussion fields. subset of discussion fields. Near-Term Report defaults this module to
`key_messages` and `short_term`.
`spc_convective_outlooks` uses collected SPC run metadata and derived
report-period outlooks. It emits `checked: true` for a successfully fetched
empty run, reports `outlook_count`, and includes prompt-facing outlook fields
such as risk label, `period_begins`, `period_ends`, image URL, and whether the
outlook contains the configured location. It does not emit GeoJSON geometry,
source URL, expiration time, or severity rank.
Prompt-facing module intervals use friendly local `period_begins` and
`period_ends` labels. Canonical report metadata, source provenance,
`issued_at`, `updated_at`, and point-in-time fields remain separate.
`spc_convective_discussion` uses the same derived report-period outlooks and
discussion records. It is omitted unless at least one retained categorical
outlook for the same SPC day has severity rank `3` or higher and matching
discussion text exists.
## External Adapters Used ## External Adapters Used
@@ -86,6 +113,11 @@ return an error for invalid required inputs.
forecast discussion, and weather story stanzas. forecast discussion, and weather story stanzas.
- Alert digest output distinguishes checked empty alert data from missing alert - Alert digest output distinguishes checked empty alert data from missing alert
source data. source data.
- SPC convective outlook output distinguishes checked empty outlook data from
missing outlook source data and omits GeoJSON geometry from prompt-facing
fields.
- SPC convective discussion output is omitted unless a retained outlook has
severity rank `3` or higher and matching discussion text is available.
## Tests ## Tests

View File

@@ -20,14 +20,29 @@ Inputs:
Outputs: Outputs:
- `facts.CollectedFacts` with normalized source facts plus separate source - `facts.CollectedFacts` with normalized source facts plus separate source
provenance and warnings provenance and warnings. SPC convective outlook source data is carried
through when present in the bundle, including upstream geometry and source
provenance.
- `facts.DerivedFacts` with valid-period forecast slices, alert overlaps, - `facts.DerivedFacts` with valid-period forecast slices, alert overlaps,
daily summaries, daypart summaries, and Storm Report window summary report-period SPC convective outlooks and discussions, daily summaries,
daypart summaries, and Storm Report window summary
Near-Term Report uses the generic valid-period hourly and narrative selection
for its rolling six-hour window. Its derived facts include precipitation timing
from the selected hourly periods, alert overlaps for the six-hour period, and
SPC outlooks/discussions overlapping that period. It does not build daily
summaries, daypart summaries, or a storm-window summary.
## Boundaries ## Boundaries
- This package owns fact assembly and reusable deterministic derivation for a - This package owns fact assembly and reusable deterministic derivation for a
report run. report run.
- SPC convective outlook derivation selects already-collected outlooks whose
half-open valid intervals overlap the resolved report period and retains
discussions for represented outlook days.
- Derived SPC outlook records preserve the collected outlook fields, including
geometry, for downstream components that need source-level facts. Prompt
modules decide which fields are exposed to Scriptorium.
- It does not fetch upstream data, build prompt wording, compare prior - It does not fetch upstream data, build prompt wording, compare prior
snapshots, write workflow state, invoke Scriptorium, or define modules. snapshots, write workflow state, invoke Scriptorium, or define modules.
@@ -53,8 +68,13 @@ and inspection.
- Invalid timezone names return an error. - Invalid timezone names return an error.
- Missing required hourly forecast data returns the underlying forecast - Missing required hourly forecast data returns the underlying forecast
derivation error for reports that require daily summaries. derivation error for reports that require daily summaries.
- Near-Term Report can derive its default module facts without daily or
daypart summaries.
- Missing optional narrative, alert, discussion, daily, or weather story data - Missing optional narrative, alert, discussion, daily, or weather story data
produces empty or nil derived fields. produces empty or nil derived fields.
- Missing optional SPC convective outlook data produces a nil collected field.
- A present SPC convective outlook source with no report-period matches
produces non-nil empty derived outlook and discussion slices.
## Tests ## Tests
@@ -67,6 +87,8 @@ Inspect:
- Collected facts are built once from a fetched bundle. - Collected facts are built once from a fetched bundle.
- Derived facts are scoped to one resolved report. - Derived facts are scoped to one resolved report.
- SPC convective outlook selection uses the resolved report period and the
already-collected outlook run.
- Source provenance and warnings stay separate from ordinary fact fields. - Source provenance and warnings stay separate from ordinary fact fields.
- Prompt-specific wording and one-off presentation decisions stay outside this - Prompt-specific wording and one-off presentation decisions stay outside this
package. package.

View File

@@ -37,7 +37,9 @@ The registry recognizes these IDs:
- `derived_daypart_summaries` - `derived_daypart_summaries`
- `precip_timing` - `precip_timing`
- `alert_digest` - `alert_digest`
- `spc_convective_outlooks`
- `area_forecast_discussion` - `area_forecast_discussion`
- `spc_convective_discussion`
- `weather_story` - `weather_story`
- `outdoor_windows` - `outdoor_windows`
- `tomorrow_planning` - `tomorrow_planning`
@@ -45,9 +47,29 @@ The registry recognizes these IDs:
Every registered module has a builder. Report composition entries that refer to Every registered module has a builder. Report composition entries that refer to
unknown or unimplemented module IDs fail validation instead of being skipped. unknown or unimplemented module IDs fail validation instead of being skipped.
## Near-Term Composition
The default Near-Term Report module order is:
1. `metadata`
2. `current_conditions`
3. `hourly_forecast`
4. `precip_timing`
5. `alert_digest`
6. `spc_convective_outlooks`
7. `area_forecast_discussion`
8. `spc_convective_discussion`
9. `weather_story`
Near-Term Report does not include daily or daypart summary modules by default.
Its `area_forecast_discussion` item is configured to include only
`key_messages` and `short_term`.
## Options ## Options
Most modules use an empty options struct. `area_forecast_discussion` accepts: Most modules use an empty options struct, including
`spc_convective_outlooks` and `spc_convective_discussion`.
`area_forecast_discussion` accepts:
```yaml ```yaml
sections: sections:
@@ -61,6 +83,31 @@ An omitted or empty `sections` list includes all available discussion sections.
Invalid option shapes fail during config normalization or composition Invalid option shapes fail during config normalization or composition
validation. validation.
## SPC Convective Module Outputs
`spc_convective_outlooks` emits a prompt-facing risk-product stanza with:
- `checked`
- `as_of`
- `issued_at`
- `location_id`
- `location_name`
- `outlook_count`
- `outlooks`
Each outlook entry may include `day`, `outlook_type`, `label`, `label_text`,
`period_begins`, `period_ends`, `issued_at`, `contains_location`, and
`image_url`. It omits GeoJSON geometry, source URL, expiration time, and
severity rank.
`spc_convective_discussion` emits a narrative stanza only when a retained
report-period categorical outlook has severity rank `3` or higher and matching
discussion text is available. Its output includes `included_because` and
`discussions`; each discussion may include `day`, `period_begins`,
`period_ends`, `headline`, `summary`, `discussion`, and `updated_at`.
Discussions are included only for SPC days whose retained categorical outlooks
meet the severity threshold.
## Boundaries ## Boundaries
- This package owns module identifiers, config item envelopes, output - This package owns module identifiers, config item envelopes, output

View File

@@ -41,6 +41,7 @@ briefing:
metadata: {} metadata: {}
applicable_risk_products: applicable_risk_products:
alert_digest: {} alert_digest: {}
spc_convective_outlooks: {}
derived_summaries: derived_summaries:
derived_daily_summary: {} derived_daily_summary: {}
derived_daypart_summaries: {} derived_daypart_summaries: {}
@@ -49,6 +50,7 @@ briefing:
narrative_products: narrative_products:
narrative_forecast: {} narrative_forecast: {}
area_forecast_discussion: {} area_forecast_discussion: {}
spc_convective_discussion: {}
weather_story: {} weather_story: {}
raw_data: raw_data:
current_conditions: {} current_conditions: {}
@@ -62,13 +64,26 @@ weather module stanzas under prompt-facing categories. This grouping is a YAML
presentation concern only: module snapshots remain flat, and loaded presentation concern only: module snapshots remain flat, and loaded
`promptinput.Package` values expose flat stanza names in `Briefing.Values`. `promptinput.Package` values expose flat stanza names in `Briefing.Values`.
Within each category, stanza order follows the module snapshot output order. Within each category, stanza order follows the module snapshot output order.
Prompt-facing module intervals use local `period_begins` and `period_ends`
labels; canonical report metadata and source timestamps remain structured
timestamps where applicable.
Near-Term Report uses the same package schema and categories. Its default
package includes `precip_timing` under `derived_summaries`, alert and SPC
outlooks under `applicable_risk_products`, AFD/SPC discussion/weather story
under `narrative_products`, and current/hourly data under `raw_data`. It does
not include daily or daypart summary stanzas.
Current categories are: Current categories are:
- `applicable_risk_products`: location-applicable alerts, warnings, outlooks, - `applicable_risk_products`: location-applicable alerts, warnings, outlooks,
discussions, and similar risk products. and similar risk products. Current stanzas include `alert_digest` and
`spc_convective_outlooks`.
- `derived_summaries`: deterministic summaries and calculated report facts. - `derived_summaries`: deterministic summaries and calculated report facts.
- `narrative_products`: official narrative text products and forecast stories. - `narrative_products`: official narrative text products and forecast stories.
Current stanzas include `narrative_forecast`,
`area_forecast_discussion`, `spc_convective_discussion`, and
`weather_story`.
- `raw_data`: minimally transformed underlying weather data. - `raw_data`: minimally transformed underlying weather data.
## Boundaries ## Boundaries
@@ -97,6 +112,7 @@ None.
## Skip And Resume Behavior ## Skip And Resume Behavior
None. Recent Changes is always present as an `items` list and may be empty. None. Recent Changes is always present as an `items` list and may be empty.
Near-Term Report currently writes an empty `items` list.
## Failure Behavior ## Failure Behavior

View File

@@ -31,6 +31,7 @@ Each report definition declares:
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| Daily Today | `daily_today` | `weather.daily_report` | `daily` | `daily.md` | Daily Today, Daily Tomorrow | | Daily Today | `daily_today` | `weather.daily_report` | `daily` | `daily.md` | Daily Today, Daily Tomorrow |
| Daily Tomorrow | `daily_tomorrow` | `weather.daily_report` | `daily` | `tomorrow.md` | Daily Today, Daily Tomorrow | | Daily Tomorrow | `daily_tomorrow` | `weather.daily_report` | `daily` | `tomorrow.md` | Daily Today, Daily Tomorrow |
| Near-Term Report | `near_term` | `weather.near_term_report` | `near-term` | `near-term.md` | Near-Term Report |
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `three-day` | `three-day.md` | 3-Day Outlook | | 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `three-day` | `three-day.md` | 3-Day Outlook |
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `weekend` | `weekend.md` | Weekend Outlook | | Weekend Outlook | `weekend` | `weather.weekend_outlook` | `weekend` | `weekend.md` | Weekend Outlook |
| Storm Report | `storm` | `weather.storm_report` | `storm` | `storm.md` | Storm Report | | Storm Report | `storm` | `weather.storm_report` | `storm` | `storm.md` | Storm Report |
@@ -42,6 +43,9 @@ All report definitions are eligible for generation.
- Daily Today covers the selected local civil day, or the current local civil - Daily Today covers the selected local civil day, or the current local civil
day when no date override is supplied. day when no date override is supplied.
- Daily Tomorrow covers the next local civil day from generation time. - Daily Tomorrow covers the next local civil day from generation time.
- Near-Term Report covers the half-open six-hour period from generation time in
the effective report timezone. The duration is an internal report constant,
not a configuration field.
- 3-Day Outlook covers the interval from generation time through local midnight - 3-Day Outlook covers the interval from generation time through local midnight
three days later. three days later.
- Weekend Outlook covers the upcoming weekend window and is not scheduled for - Weekend Outlook covers the upcoming weekend window and is not scheduled for
@@ -64,7 +68,15 @@ IDs, then uses the registry for report policy.
## Config Fields Used ## Config Fields Used
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
output path copying uses batch output names from report definitions. output path copying uses batch output names from report definitions. Report
module overrides can use the `near_term` or `near-term` keys for Near-Term
Report.
## Batch Membership
Morning batches include Daily Today, 3-Day Outlook, and Weekend Outlook except
on Sunday. Evening batches include Daily Tomorrow. Near-Term Report is not part
of a scheduled batch.
## State And App Usage ## State And App Usage

View File

@@ -4,10 +4,10 @@ This document describes Weather API ingestion into `weatherdata.Bundle`.
## Purpose ## Purpose
`internal/adapters/weatherapi` fetches normalized weather data from one `internal/adapters/weatherapi` fetches normalized weather data from the
configured Weather API endpoint and assembles the bundle consumed by forecast configured Weather API and assembles the bundle consumed by forecast derivation
derivation and module builders. Module builders expose normalized current and module builders. Module builders expose normalized current conditions and
conditions and weather story context when those sources are available. weather story context when those sources are available.
## Inputs And Outputs ## Inputs And Outputs
@@ -21,7 +21,8 @@ Outputs:
- `weatherdata.Bundle` with observation, current conditions, hourly forecast, - `weatherdata.Bundle` with observation, current conditions, hourly forecast,
narrative forecast, active alerts, discussion, latest weather story, source narrative forecast, active alerts, discussion, latest weather story, source
records, and source warnings records, source warnings, and typed SPC convective outlook data when that
optional source is available
- optional saved bundle JSON through app fetch helpers - optional saved bundle JSON through app fetch helpers
## Boundaries ## Boundaries
@@ -54,9 +55,19 @@ contract used by this project.
The adapter records source name, endpoint, query, fetch time, source timestamps The adapter records source name, endpoint, query, fetch time, source timestamps
when available, SHA-256 hash over compact raw `data` JSON, missing status, and when available, SHA-256 hash over compact raw `data` JSON, missing status, and
source warnings. Successful `data: null` responses from `/alerts/active` source warnings. Successful `data: null` responses from `/alerts/active`
represent a checked empty active-alert list, not a missing source. represent a checked empty active-alert list, not a missing source. Successful
non-null `/outlooks/convective` responses with empty outlook and discussion
arrays represent checked empty outlook data.
`app.FetchAndSaveBundle` can write bundle JSON atomically for inspection. `app.FetchAndSaveBundle` can write bundle JSON atomically for inspection.
SPC convective outlook data is stored on
`weatherdata.Bundle.SPCConvectiveOutlooks`. The collected run keeps upstream
run metadata, location identifiers, ordered outlook records, discussion
records, and each outlook's raw GeoJSON geometry. Source provenance for this
payload uses the `spc_convective_outlooks` source name, endpoint
`/outlooks/convective`, the query sent by the adapter, timestamps, and a hash
of the raw `data` object.
## Skip And Resume Behavior ## Skip And Resume Behavior
No resume behavior. Optional missing or malformed sources may be omitted, No resume behavior. Optional missing or malformed sources may be omitted,
@@ -72,6 +83,8 @@ data is required and cannot be skipped.
- Optional sources follow missing-source policy. - Optional sources follow missing-source policy.
- Explicit `data: null` from `/alerts/active` produces an empty, non-missing - Explicit `data: null` from `/alerts/active` produces an empty, non-missing
alert run. alert run.
- Explicit `data: null` from `/outlooks/convective` follows optional
missing-source policy.
## Tests ## Tests

View File

@@ -11,6 +11,7 @@ Generation commands:
```text ```text
weatherreporter generate daily --date 2026-05-29 weatherreporter generate daily --date 2026-05-29
weatherreporter generate tomorrow weatherreporter generate tomorrow
weatherreporter generate near-term
weatherreporter generate three-day weatherreporter generate three-day
weatherreporter generate weekend weatherreporter generate weekend
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
@@ -25,6 +26,10 @@ Markdown report after `scriptorium run` succeeds and final metadata is saved.
`--out PATH` writes an extra Markdown copy for the current generated report; it `--out PATH` writes an extra Markdown copy for the current generated report; it
is not used as the distributor upload source. is not used as the distributor upload source.
`generate near-term` is an explicit generation command. It covers the six-hour
rolling period from generation time in the effective report timezone and is not
part of scheduled morning or evening batches.
Batch commands: Batch commands:
```text ```text
@@ -62,6 +67,10 @@ workspace/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.modules.json <run_id>.modules.json
<run_id>.metadata.json <run_id>.metadata.json
near-term/
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
storm/ storm/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.modules.json <run_id>.modules.json
@@ -76,6 +85,9 @@ workspace/
weekend/ weekend/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.data_package.yaml <run_id>.data_package.yaml
near-term/
YYYY-MM-DD/
<run_id>.data_package.yaml
storm/ storm/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.data_package.yaml <run_id>.data_package.yaml
@@ -89,6 +101,9 @@ workspace/
weekend/ weekend/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.render.json <run_id>.render.json
near-term/
YYYY-MM-DD/
<run_id>.render.json
storm/ storm/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.render.json <run_id>.render.json
@@ -102,6 +117,9 @@ workspace/
weekend/ weekend/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.distributor.json <run_id>.distributor.json
near-term/
YYYY-MM-DD/
<run_id>.distributor.json
storm/ storm/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.distributor.json <run_id>.distributor.json
@@ -112,6 +130,8 @@ workspace/
<run_id>.md <run_id>.md
weekend/ weekend/
<run_id>.md <run_id>.md
near-term/
<run_id>.md
storm/ storm/
<run_id>.md <run_id>.md
``` ```
@@ -218,8 +238,8 @@ Markdown or YAML text.
Daily Today and Daily Tomorrow can compare with each other when they cover the Daily Today and Daily Tomorrow can compare with each other when they cover the
same valid local date. 3-Day Outlook compares with prior compatible 3-Day same valid local date. 3-Day Outlook compares with prior compatible 3-Day
snapshots for the same valid local date. Weekend Outlook compares with prior snapshots for the same valid local date. Weekend Outlook compares with prior
compatible Weekend snapshots for the same weekend window. Storm Report leaves compatible Weekend snapshots for the same weekend window. Near-Term Report and
Recent Changes empty. Storm Report leave Recent Changes empty.
When no prior comparable snapshot exists, or no configured threshold is crossed, When no prior comparable snapshot exists, or no configured threshold is crossed,
`recentChanges.items` is empty. `recentChanges.items` is empty.

View File

@@ -0,0 +1,343 @@
# GeneratedText Report Rendering Roadmap
## Purpose
This roadmap defines planned work to move selected reports from full Markdown
LLM generation to deterministic Markdown rendering with structured LLM-filled
text slots. The first target report is the rolling next-hours report, now named
`hourly` rather than `near_term`.
This feature is not implemented yet, so this document lives under
`docs/roadmap/`.
## Intent
The current module and prompt-input system has reached the point where many
report facts can be calculated deterministically. The next step is to let Go
own report structure while using the LLM for only the prose that benefits from
language synthesis.
The intended workflow is:
1. collect upstream facts once;
2. derive deterministic report facts;
3. build module outputs and the prompt data package;
4. ask Scriptorium for structured JSON using the schema associated with the
prompt ID on the Scriptorium backend;
5. validate and persist that structured response as `GeneratedText`;
6. build a curated render context;
7. render final Markdown from an embedded human-editable template.
This should improve consistency for headings, dates, ordering, units, recurring
sections, and report-specific format while preserving the LLM's role in
summarizing and explaining weather impacts.
## Terminology
- `CollectedFacts`: normalized upstream inputs from Weather API sources.
- `DerivedFacts`: deterministic Go-calculated facts sliced to a report period.
- `ModuleOutput`: prompt-facing deterministic module stanzas.
- `GeneratedText`: validated structured LLM output. It is not authoritative
weather data; it is prose or short text intended to fill named template
slots.
- `RenderContext`: the final deterministic object passed to a Markdown
template. It may expose selected values from CollectedFacts, DerivedFacts,
ModuleOutput, report metadata, and GeneratedText.
The report template should not receive the unbounded raw data package directly.
It should receive a curated render context so templates remain readable and do
not become a second application logic layer.
## Locked Decisions
- Use `GeneratedText` as the name for the structured LLM output layer.
- Continue using Scriptorium behind `internal/adapters/scriptorium`.
- Use Scriptorium structured JSON output. The Scriptorium backend associates
the prompt ID with the JSON schema; weatherreporter does not pass a schema
file at runtime.
- Keep Scriptorium retries inside Scriptorium.
- Also validate the returned JSON inside `weatherreporter` before rendering.
- Use embedded, separate, human-editable Markdown template files.
- Use embedded, separate, human-readable JSON schema files.
- Use Go standard library `text/template` for Markdown rendering unless a
concrete limitation appears.
- Let Scriptorium enforce the prompt-associated JSON Schema and perform
retries, then have weatherreporter validate by unmarshalling into a typed
GeneratedText struct and checking required fields. Do not add a Go JSON
Schema dependency in the first implementation.
- Do not generate schemas or templates from inline Go.
- Start with the rolling next-hours report only.
- Rename the planned `near_term` report to `hourly`.
- Do not keep both `near_term` and `hourly` public report IDs.
- Do not add a generic workflow engine or plugin system.
## Hourly Report Target
The planned `near_term` report should become the `hourly` report.
Target report identity:
- report ID: `hourly`
- CLI command: `weatherreporter generate hourly`
- display name: `Hourly Report`
- prompt ID for structured text generation: `weather.hourly_generated_text`
- Markdown template ID: `hourly`
- GeneratedText schema ID: `hourly`
- artifact group: `hourly`
- batch output name: `hourly.md`
- default valid-period length: 6 hours
- valid period: `[generation_time, generation_time + hourlyReportHours)`
Use a package-owned constant for the valid-period length, initially 6 hours.
The duration should not be user-configurable in the first implementation.
The report remains explicit-generation only at first. Do not add it to morning
or evening batches until cadence and downstream behavior are proven.
## Embedded Asset Layout
Recommended asset package:
```text
internal/reporttemplate/
templates/
hourly.md.tmpl
schemas/
hourly.generated_text.schema.json
```
`internal/reporttemplate` should embed assets with `go:embed` and expose a
narrow API for:
- looking up a template by template ID;
- looking up a schema by schema ID;
- rendering Markdown from a typed render context.
Do not allow template or schema asset IDs to come from free-form user input in
the initial implementation. Report definitions should declare the IDs.
## Report Definition Fields
Extend report definitions only as needed for this generation mode. Required
fields:
- `GenerationMode`
- `TemplateID`
- `GeneratedTextSchemaID`
Recommended generation modes:
- `GenerationModeScriptoriumMarkdown`: existing full Markdown generation path.
- `GenerationModeGeneratedTextTemplate`: structured GeneratedText plus
deterministic template rendering.
Existing reports should stay on the current full Markdown path until migrated.
The `hourly` report should use the GeneratedText/template path from the start.
For generated-text reports, the existing `PromptID` field remains the
Scriptorium prompt ID and should be set to `weather.hourly_generated_text`.
## GeneratedText Contract
The hourly JSON schema should define only the text slots the template needs.
Keep the first schema small. Example shape:
```json
{
"summary": "Brief near-term overview.",
"timing": "Plain-language timing of notable changes.",
"impacts": "Practical impacts for the next few hours.",
"confidence": "Optional confidence or uncertainty note."
}
```
The exact schema should be locked during implementation based on the first
hourly template. Required fields should be genuinely required by the template.
Optional fields should be omitted from the rendered Markdown when empty.
GeneratedText should be persisted as structured JSON exactly as validated by
weatherreporter. Raw Scriptorium output should also be persisted for debugging
when practical.
## Template Contract
The hourly Markdown template should be a readable file that a human can edit
without digging into Go code. It should own:
- Markdown heading structure;
- section order;
- deterministic fact placement;
- conditional inclusion of optional GeneratedText slots;
- final report layout.
The template should not own:
- Weather API fetching;
- fact derivation;
- module composition;
- Scriptorium invocation;
- schema validation;
- path construction;
- distributor upload behavior.
The render context should prepare strings and simple values so template logic
stays shallow. Prefer small conditionals over complex template functions.
## Scriptorium Adapter Direction
`internal/adapters/scriptorium` should expose weatherreporter-owned request and
result types for structured JSON generation. The adapter should hide
Scriptorium argv details from app, report, facts, module, prompt-input, and
template packages.
Scriptorium uses the prompt ID to select the backend-associated structured
output schema. The adapter should not pass a schema path at runtime.
## State And Artifacts
Add managed artifacts for the new generation path. Recommended logical
artifacts:
- module snapshot JSON;
- data package YAML;
- raw GeneratedText JSON written by Scriptorium;
- structured Scriptorium command result for the GeneratedText run;
- validated GeneratedText JSON;
- render context JSON;
- rendered Markdown report;
- metadata linking all of the above.
The existing `preflight` artifact should remain as the rendered prompt/input
inspection artifact before structured generation. GeneratedText reports add
separate artifacts for raw GeneratedText, structured run result, validated
GeneratedText, and render context. Metadata should record the
`GeneratedTextSchemaID`; no per-run schema artifact is persisted.
Artifact paths should remain under the workspace and use the report artifact
group, valid start date, and RunID conventions already used by state.
## End-To-End Behavior
Reports using `GenerationModeGeneratedTextTemplate` should follow the same
fact collection, derivation, module snapshot, prompt package, state, and
notification boundaries as current reports. The difference is limited to the
last rendering segment: Scriptorium returns structured GeneratedText, Go
validates that structure, Go builds a curated render context, and an embedded
template renders the final Markdown.
The existing full-Markdown Scriptorium path should remain available for
reports that have not migrated.
## Target Architecture
GeneratedText rendering adds one new generation mode, not a replacement for the
whole report system. Reports that opt into this mode should continue to use
the existing report registry, fact pipeline, module snapshot, data package,
state store, and notification boundaries.
The target flow is:
1. `internal/report` declares whether a report uses the existing full-Markdown
Scriptorium path or the GeneratedText/template path.
2. The app builds CollectedFacts, DerivedFacts, module snapshots, and the
prompt data package using the same boundaries as existing reports.
3. `internal/adapters/scriptorium` asks Scriptorium for structured JSON using
the report's prompt ID. The Scriptorium backend owns the prompt-to-schema
association.
4. Weatherreporter validates the returned JSON as typed `GeneratedText`.
5. A report-specific render context is built from selected deterministic facts,
module outputs, metadata, and GeneratedText.
6. `internal/reporttemplate` renders the final Markdown from an embedded,
human-editable template.
7. The managed Markdown report remains the canonical output and the distributor
upload source.
This structure keeps each layer understandable:
- CollectedFacts and DerivedFacts remain weather-data contracts.
- ModuleOutput remains the prompt-facing deterministic facts contract.
- GeneratedText remains LLM-written prose slots, not weather truth.
- RenderContext remains a curated template input, not a second raw data package.
- Templates own presentation order and headings, not weather derivation logic.
## Editing Surface
The desired human editing surface is file-based and report-specific. For the
hourly report, the important files should be easy to find and inspect:
- one Markdown template file for the report layout;
- one JSON schema file describing the expected GeneratedText payload;
- one report definition declaring the prompt ID, template ID, schema ID, module
composition, valid period, artifact group, and output naming policy.
The template and schema should be embedded into the binary, but they should
remain ordinary files in the repository. A maintainer should be able to tweak
headings, reorder sections, or inspect the structured prose contract without
reading unrelated orchestration code.
## Boundary Guidelines
- Keep report identity, prompt IDs, generation mode, template IDs, schema IDs,
valid periods, module composition, and output naming in `internal/report`.
- Keep Scriptorium subprocess details in `internal/adapters/scriptorium`.
- Keep Markdown template lookup and rendering in `internal/reporttemplate`.
- Keep GeneratedText validation in a narrow package or component with typed
report-specific structs.
- Keep app orchestration responsible for sequencing and artifact persistence,
not for report-specific prose or template structure.
- Keep distributor upload behavior unchanged: upload the managed Markdown
report after it exists.
- Do not let templates fetch data, derive facts, perform source selection, or
construct workspace paths.
- Do not let Scriptorium-specific flags, schema paths, or subprocess details
leak into report definitions or domain packages.
- Do not add user-provided template or schema paths in the first
implementation.
## Target Acceptance Criteria
The feature is successful when:
- `weatherreporter generate hourly` produces a deterministic Markdown report
whose headings, dates, and section order come from an embedded template;
- Scriptorium fills only the report's structured GeneratedText slots;
- weatherreporter validates GeneratedText before rendering;
- the raw GeneratedText, validated GeneratedText, render context, rendered
Markdown, and metadata are inspectable after a run;
- existing daily, tomorrow, three-day, weekend, and storm reports keep their
current full-Markdown Scriptorium path until explicitly migrated;
- failures in structured generation, validation, or template rendering are
diagnosable from saved artifacts where practical;
- the managed Markdown report remains the source for optional copies and
distributor uploads;
- the implementation does not introduce a generic workflow engine, plugin
system, or broad report rewrite.
## Deferred Work
Do not include these in the first implementation:
- migrating daily, tomorrow, three-day, weekend, or storm to GeneratedText;
- user-configurable template or local schema source overrides;
- user-defined templates or schemas;
- a generic workflow engine;
- template hot reloading;
- dynamic module selection for templates;
- distributor-specific generated-text behavior;
- prompt/schema generation from Go structs;
- scheduled hourly batch membership;
- configurable hourly report duration.
## Open Questions
No questions block roadmap creation.
Recommended approach: keep JSON schema enforcement in Scriptorium for retry
behavior, and also perform weatherreporter-side typed validation before
rendering. Weatherreporter should validate the returned JSON by unmarshalling
into the report's GeneratedText type and checking required fields; it should
not add a Go JSON Schema dependency in the first implementation.
Viable alternative: trust Scriptorium validation and only unmarshal into typed
Go structs without explicit required-field checks. This is simpler, but it
weakens weatherreporter's rendering contract and may make empty or malformed
LLM output harder to diagnose.

View File

@@ -0,0 +1,778 @@
# GeneratedText Hourly Report Implementation Roadmap
## Purpose
This roadmap defines the concrete implementation sequence for
`docs/roadmap/generated-text.md`. It is written for an LLM coding agent that
will implement each stage in order.
This is a future-work roadmap. Until a stage is implemented, non-roadmap docs
must not describe `GeneratedText`, embedded report templates, or
`weatherreporter generate hourly` as available behavior.
## Source Feature Roadmap
Use `docs/roadmap/generated-text.md` as the authoritative feature roadmap for
intent, terminology, target behavior, and policy choices. This document is the
step-by-step implementation plan. If target behavior changes, update
`generated-text.md` first, then update this plan.
Future implementation agents should read `generated-text.md` before starting
Stage 1. Treat that file as the conceptual contract for why the feature exists
and what final shape it should have; treat this file as the ordered work plan
for reaching that shape.
## Locked Implementation Decisions
- Implement the first GeneratedText path for the rolling next-hours report,
named `hourly`.
- Replace planned/public `near_term` naming with `hourly`; do not keep a
public `near-term` alias.
- Use report ID `hourly`, artifact group `hourly`, batch output name
`hourly.md`, and CLI command `weatherreporter generate hourly`.
- Keep the rolling report explicit-generation only; do not add scheduled batch
membership in this implementation.
- Keep the valid-period length as a package-owned constant, initially 6 hours.
- Add report generation modes:
- `scriptorium_markdown` for existing reports;
- `generated_text_template` for `hourly`.
- Keep existing reports on the current Scriptorium Markdown path.
- For generated-text reports, keep `Definition.PromptID` as the Scriptorium
prompt ID and set the hourly value to `weather.hourly_generated_text`.
- Add `TemplateID` and `GeneratedTextSchemaID` to report definitions.
- Use embedded assets under `internal/reporttemplate`.
- Use standard-library `text/template`.
- Let Scriptorium enforce its prompt-associated JSON Schema and retries;
weatherreporter validates by unmarshalling into a typed GeneratedText struct
and checking required fields. Do not add a Go JSON Schema dependency in this
implementation.
- Keep the existing preflight artifact for prompt/input inspection.
- Add managed artifacts for raw GeneratedText JSON, structured Scriptorium run
result, validated GeneratedText JSON, and render context JSON.
- Distributor notification continues to upload the managed Markdown report only.
- Preserve current CLI behavior, artifact paths, and generated output behavior
for daily, tomorrow, three-day, weekend, and storm reports.
## Stage 1: Roadmap Reconciliation And Hourly Identity
Goal: remove conflicting future instructions and establish `hourly` as the only
planned public name for the rolling next-hours report.
Files to inspect:
- `docs/roadmap/generated-text.md`
- `docs/roadmap/near-term.md`
- `docs/roadmap/future.md`
- `docs/roadmap/implementation.md`
- `internal/app/app.go`
- `internal/report/definition.go`
- `internal/report/registry.go`
Implementation:
- Replace `docs/roadmap/near-term.md` with a short superseded note pointing to
`docs/roadmap/generated-text.md`, or delete it if the repository policy at
implementation time prefers removing obsolete roadmap files.
- Ensure no roadmap instructs a future agent to implement public
`near_term` / `near-term` behavior.
- If partial `near_term` code already exists, rename it to `hourly` rather
than adding aliases:
- `ReportNearTerm` -> `ReportHourly`;
- `report.NearTerm` -> `report.Hourly`;
- CLI command `near-term` -> `hourly`;
- artifact group `near-term` -> `hourly`;
- prompt IDs and tests updated to hourly names.
- Do not add compatibility aliases for `near-term`.
Acceptance criteria:
- Active roadmap docs use `hourly` for the rolling report.
- No active roadmap describes `near_term` as the target public report ID.
- Existing code compiles after any rename work.
Suggested validation:
```bash
rg -n "near_term|near-term|NearTerm" docs internal
go test ./internal/app ./internal/report ./internal/cli
```
This stage is small enough for one implementation prompt.
## Stage 2: Report Definition Generation Mode
Goal: add generation-mode metadata to report definitions without changing the
current report generation path.
Files to inspect:
- `internal/report/definition.go`
- `internal/report/registry.go`
- `internal/report/*_report.go`
- `internal/report/period_test.go`
- `internal/state/filesystem.go`
Implementation:
- Add a `GenerationMode` type in `internal/report`.
- Add constants:
- `GenerationModeScriptoriumMarkdown`
- `GenerationModeGeneratedTextTemplate`
- Add fields to `report.Definition`:
- `GenerationMode GenerationMode`
- `TemplateID string`
- `GeneratedTextSchemaID string`
- Default or explicitly set existing reports to
`GenerationModeScriptoriumMarkdown`.
- Update report definition tests so every generated report declares a
generation mode.
- Do not wire app behavior in this stage.
- Keep existing `PromptID` semantics: it is the Scriptorium prompt ID for the
report's generation mode.
Acceptance criteria:
- Existing report definitions retain current prompt IDs and behavior.
- Registry tests fail if a generated report omits generation mode.
- Generated-text-only fields are empty for existing Markdown reports.
Suggested validation:
```bash
go test ./internal/report ./internal/state
```
This stage is small enough for one implementation prompt.
## Stage 3: Hourly Report Definition, Modules, Facts, And CLI Shell
Goal: add the `hourly` report identity, rolling valid period, module
composition, facts derivation, config override aliases, and CLI command shell.
Files to inspect:
- `internal/report/definition.go`
- `internal/report/registry.go`
- new `internal/report/hourly_report.go`
- `internal/report/period_test.go`
- `internal/briefing/modules.go`
- `internal/facts/facts.go`
- `internal/facts/facts_test.go`
- `internal/config/reports.go`
- `internal/config/config_test.go`
- `internal/app/app.go`
- `internal/cli/root.go`
- `internal/cli/root_test.go`
Implementation:
- Add report ID `Hourly ID = "hourly"`.
- Add comparison strategy `CompareRollingWindow = "rolling_window"`.
- Add `hourlyReportHours` constant, initially `6`.
- Add `hourlyDefinition()`:
- `Name: "Hourly Report"`
- `PromptID: "weather.hourly_generated_text"`
- `GenerationMode: GenerationModeGeneratedTextTemplate`
- `TemplateID: "hourly"`
- `GeneratedTextSchemaID: "hourly"`
- `ComparisonStrategy: CompareRollingWindow`
- `ArtifactGroup: "hourly"`
- `BatchOutputName: "hourly.md"`
- `Generated: true`
- `CompatiblePriorIDs: []ID{Hourly}`
- no morning/evening batch membership
- Resolve valid period as `[generation_time, generation_time + 6h)` in the
effective timezone.
- Add default hourly modules in order:
1. `metadata`
2. `current_conditions`
3. `hourly_forecast`
4. `precip_timing`
5. `alert_digest`
6. `spc_convective_outlooks`
7. `area_forecast_discussion`
8. `spc_convective_discussion`
9. `weather_story`
- Configure hourly `area_forecast_discussion` options to include only:
- `key_messages`
- `short_term`
- Update module supported-report lists:
- include `report.Hourly` in all broadly compatible modules;
- include `report.Hourly` for `hourly_forecast`;
- keep daily/daypart modules incompatible.
- Add `facts.BuildDerived` support for hourly:
- valid-period hourly slice;
- precipitation timing from that slice;
- alert overlap filtering;
- SPC outlook/discussion overlap filtering;
- no daily summaries, daypart summaries, or storm summary.
- Add config report aliases:
- `hourly`
- reject or do not recognize `near_term` / `near-term`.
- Add app report kind `ReportHourly = "hourly"` and map it to
`report.Hourly`.
- Add CLI parsing and help for:
```bash
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
```
- Do not add `--date`, `--start`, `--end`, or duration flags.
- It is acceptable for `generate hourly` to return an explicit
"generation mode not implemented" error until the app workflow stage, but
parser and resolution tests should pass.
Acceptance criteria:
- `report.DefaultRegistry().Lookup(report.Hourly)` succeeds.
- hourly valid period is exactly six hours and not civil-day truncated.
- hourly default module composition validates.
- daily-only modules reject hourly.
- facts derivation supports the hourly module list.
- CLI help includes `generate hourly`.
- Existing command behavior is unchanged.
Suggested validation:
```bash
go test ./internal/report ./internal/briefing ./internal/facts
go test ./internal/config ./internal/app ./internal/cli
go run ./cmd/weatherreporter --help
```
This stage is large but still suitable for one implementation prompt if kept
strictly to report identity/facts/CLI shell. If it becomes unwieldy, split it
after report/facts and implement CLI/config aliases in a follow-up prompt.
## Stage 4: Embedded Template And Schema Assets
Goal: create the embedded asset package and first hourly template/schema.
Files to create:
- `internal/reporttemplate/reporttemplate.go`
- `internal/reporttemplate/templates/hourly.md.tmpl`
- `internal/reporttemplate/schemas/hourly.generated_text.schema.json`
- `internal/reporttemplate/reporttemplate_test.go`
Implementation:
- Use `go:embed` for templates and schemas.
- Expose a narrow API:
- `Template(id string) (string, error)` or equivalent;
- `Schema(id string) ([]byte, error)` or equivalent;
- `Render(id string, data any) ([]byte, error)`.
- Use `text/template`.
- Register only `hourly` assets initially.
- Missing or unknown asset IDs must return actionable errors.
- Keep template functions minimal. Prefer prepared render-context strings over
complex template logic.
- Add an initial hourly JSON Schema with:
- required `summary`;
- required `timing`;
- required `impacts`;
- optional `confidence`;
- all fields strings;
- `additionalProperties: false`.
- Add an initial hourly Markdown template that is readable and deterministic.
It should include fixed headings, report valid period, selected deterministic
facts, and GeneratedText slots.
Acceptance criteria:
- schema asset can be looked up for tests, local validation, and out-of-band
Scriptorium registration;
- template asset can be looked up;
- template renders from a test render context;
- missing template/schema IDs fail clearly;
- no generated schema or template content is inline in Go.
Suggested validation:
```bash
go test ./internal/reporttemplate
```
This stage is small enough for one implementation prompt.
## Stage 5: GeneratedText Contract And Render Context
Goal: add typed GeneratedText validation and a typed hourly render context.
Files to create or inspect:
- new `internal/generatedtext` package, or another narrow package if the
implementation chooses a better local name
- `internal/reporttemplate`
- `internal/module`
- `internal/briefing`
- `internal/promptinput`
Implementation:
- Add typed hourly GeneratedText:
```go
type Hourly struct {
Summary string `json:"summary"`
Timing string `json:"timing"`
Impacts string `json:"impacts"`
Confidence string `json:"confidence,omitempty"`
}
```
- Add validation:
- malformed JSON fails;
- empty required fields fail after trimming whitespace;
- unknown fields fail by using `json.Decoder.DisallowUnknownFields`;
- validated output is normalized to stable JSON.
- Do not add a Go JSON Schema dependency in this implementation.
- Add typed hourly render context.
- Build render context from:
- report metadata and location;
- selected module outputs from the module snapshot;
- validated hourly GeneratedText.
- Use `module.StanzaValue` or equivalent typed decoding rather than ad hoc map
traversal where practical.
- Keep render context curated; do not pass the full raw data package to the
template.
Acceptance criteria:
- valid GeneratedText passes and round-trips to stable JSON;
- missing required fields fail;
- unknown fields fail;
- render context has enough deterministic fields for the hourly template;
- render context construction fails clearly when required module stanzas are
missing.
Suggested validation:
```bash
go test ./internal/generatedtext ./internal/reporttemplate ./internal/module
```
This stage is small enough for one implementation prompt.
## Stage 6: State Artifacts For GeneratedText Reports
Goal: add explicit managed paths and save/load helpers for generated-text
artifacts.
Files to inspect:
- `internal/state/filesystem.go`
- `internal/state/store.go`
- `internal/state/metadata.go`
- `internal/state/filesystem_test.go`
- `internal/fileutil`
Implementation:
- Extend `state.ArtifactPaths` with:
- `GeneratedTextRaw`
- `GeneratedTextResult`
- `GeneratedText`
- `RenderContext`
- Store all four under the existing snapshots tree:
- `snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.raw.json`
- `snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.run.json`
- `snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.json`
- `snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.render_context.json`
- Do not add new workspace config directories.
- Extend `state.Metadata` with optional path fields for those artifacts.
Also record `GeneratedTextSchemaID` as metadata for auditability without
copying schema contents into every run.
- Add store methods as needed, for example:
- `SaveGeneratedTextRaw`
- `SaveGeneratedTextResult`
- `SaveGeneratedText`
- `SaveRenderContext`
- Use atomic writes through existing `fileutil` helpers.
- Keep existing metadata JSON shape for Markdown-path reports, with new fields
omitted when empty.
Acceptance criteria:
- path tests cover all new artifact paths;
- save helpers create parent directories and write atomically;
- metadata links generated-text paths for hourly reports;
- existing report metadata tests still pass.
Suggested validation:
```bash
go test ./internal/state ./internal/fileutil
```
This stage is small enough for one implementation prompt.
## Stage 7: Scriptorium GeneratedText Run Adapter
Goal: add a GeneratedText run helper behind the Scriptorium adapter while
preserving the existing `scriptorium run` command shape.
Files to inspect:
- `internal/adapters/scriptorium/runner.go`
- `internal/adapters/scriptorium/runner_test.go`
- `docs/integrations/scriptorium.md` after implementation
Implementation:
- Add weatherreporter-owned request/result types:
```go
type StructuredRunRequest struct {
PromptID string
DataPackagePath string
OutputPath string
}
```
- Add `StructuredRun(ctx, req)` to the runner.
- Validate prompt ID, data package path, and output path before
subprocess execution.
- Preserve current command result capture behavior:
- full argv;
- stdout;
- stderr;
- truncation flags;
- exit code;
- output path.
- Do not change current `Render` or `Run` methods.
- Scriptorium selects the structured output schema from the backend prompt
configuration associated with `PromptID`; do not pass a schema path at
runtime.
- Use the same `scriptorium run` command form as Markdown report generation.
The only material difference is that `--out` points to the raw GeneratedText
JSON artifact path rather than a Markdown report path.
- Use this argv shape:
```bash
scriptorium run \
--prompt <prompt_id> \
--input data_package=<path> \
--out <generated_text_raw_path>
```
- Do not add `--format json` or any schema flag. Structured output is selected
by Scriptorium's internal prompt configuration.
- If local Scriptorium documentation needs updating after implementation,
update `docs/integrations/scriptorium.md` to describe this contract.
Acceptance criteria:
- argv construction tests prove `scriptorium run` is invoked without
`--format` or schema flags and with `--out <generated_text_raw_path>`;
- nonzero exits return captured result plus error;
- missing required fields fail before subprocess execution;
- current Scriptorium render/run tests still pass;
- tokens/secrets are not added to argv or errors.
Suggested validation:
```bash
go test ./internal/adapters/scriptorium
```
This stage is small enough for one implementation prompt.
## Stage 8: GeneratedText App Workflow Success Path
Goal: wire the successful generated-text/template path into app orchestration
for hourly while preserving the existing path for all current reports.
Files to inspect:
- `internal/app/app.go`
- `internal/app/app_test.go`
- `internal/adapters/scriptorium`
- `internal/reporttemplate`
- `internal/generatedtext`
- `internal/state`
- `internal/promptinput`
Implementation:
- Extend the app `Renderer` interface, or introduce a second narrow interface,
so tests can fake structured generation.
- Branch report generation by `req.Resolved.Definition.GenerationMode`.
- For `GenerationModeScriptoriumMarkdown`, keep the existing flow unchanged.
- For the successful `GenerationModeGeneratedTextTemplate` path:
1. run existing preflight and save preflight artifact;
2. call Scriptorium structured generation with raw output path
`GeneratedTextRaw`;
3. save structured Scriptorium command result to `GeneratedTextResult`;
4. validate raw JSON into typed GeneratedText;
5. save normalized GeneratedText to `GeneratedText`;
6. build hourly render context from metadata, module snapshot, and
GeneratedText;
7. save render context JSON;
8. render Markdown via embedded template to managed report path;
9. save final metadata with all generated-text paths and the schema ID;
- Defer distributor notification and optional output copy regression coverage
to Stage 10.
- Keep failure-specific persistence tests minimal in this stage; Stage 9 owns
failure artifact behavior.
- Keep Recent Changes empty for hourly.
Acceptance criteria:
- hourly generation reaches Scriptorium structured generation, validates
GeneratedText, renders Markdown, and persists final metadata;
- daily/tomorrow/three-day/weekend/storm generation still uses existing
Markdown Scriptorium path;
- generated-text artifacts are linked from metadata for successful hourly runs;
- app tests cover the successful hourly workflow with fake structured
Scriptorium output.
Suggested validation:
```bash
go test ./internal/app ./internal/state ./internal/reporttemplate ./internal/generatedtext
```
This stage is small enough for one implementation prompt if failure behavior is
kept to the later stages.
## Stage 9: GeneratedText Failure Artifact Persistence
Goal: make generated-text failures inspectable without changing successful
behavior from Stage 8.
Files to inspect:
- `internal/app/app.go`
- `internal/app/app_test.go`
- `internal/adapters/scriptorium`
- `internal/generatedtext`
- `internal/reporttemplate`
- `internal/state`
Implementation:
- Add focused app tests for failures at each generated-text step:
- preflight failure;
- structured Scriptorium generation failure;
- GeneratedText validation failure;
- render context construction failure;
- Markdown template rendering failure.
- Ensure failures preserve available artifacts:
- failed preflight saves preflight output when possible;
- failed structured generation saves command result when available;
- validation failures preserve raw GeneratedText output when available;
- render-context failures preserve validated GeneratedText when already
available;
- template failures preserve GeneratedText and render context when already
built.
- Return actionable errors that include report ID, RunID, and operation context
without exposing data package contents or secrets.
- Do not run distributor notification after any generated-text failure.
- Do not create optional `--out` copies after any generated-text failure.
Acceptance criteria:
- every generated-text failure point has an app-level regression test;
- partial artifacts are persisted where available and linked from metadata when
metadata can be safely written;
- failed hourly runs do not notify distributor;
- existing Markdown-generation failure behavior is unchanged.
Suggested validation:
```bash
go test ./internal/app ./internal/state ./internal/generatedtext ./internal/reporttemplate
```
This stage is small enough for one implementation prompt.
## Stage 10: GeneratedText Output Copy And Notification Regression Coverage
Goal: preserve existing user-facing output behavior and distributor boundaries
for generated-text reports.
Files to inspect:
- `internal/app/app.go`
- `internal/app/app_test.go`
- `internal/adapters/distributor`
- `internal/state`
Implementation:
- Keep `--out` copy behavior unchanged after the managed Markdown report
exists.
- Keep `--out-dir` behavior unchanged for batch paths if hourly is ever run
through a batch helper during tests.
- Run distributor notification, if enabled, only after:
- structured generation succeeds;
- GeneratedText validates;
- Markdown template rendering succeeds;
- final metadata is saved.
- Use the managed Markdown report path as the distributor source.
- Never use optional `--out` or `--out-dir` copies as distributor source files.
- Keep distributor request construction template-driven; do not add
hourly-specific path branching.
- Keep Recent Changes empty for hourly in this implementation.
Acceptance criteria:
- `--out` copies the managed generated-text Markdown report after success;
- distributor notification receives the managed Markdown report path;
- distributor notification is not called on any failure from Stage 9;
- enabled distributor failure makes hourly generation fail with the same
semantics as existing generated reports;
- existing daily/tomorrow/three-day/weekend/storm distributor behavior is
unchanged.
Suggested validation:
```bash
go test ./internal/app ./internal/adapters/distributor ./internal/state
```
This stage is small enough for one implementation prompt.
## Stage 11: End-To-End CLI, Config, And Prompt Package Coverage
Goal: prove the public hourly workflow and configured module overrides work
through the CLI/app boundary.
Files to inspect:
- `internal/cli/root.go`
- `internal/cli/root_test.go`
- `internal/config/reports.go`
- `internal/config/config_test.go`
- `internal/app/app_test.go`
- `examples/config.yml`
Implementation:
- Add CLI command tests for `generate hourly`.
- Reject `generate near-term`.
- Reject hourly `--date`, `--start`, `--end`, and duration-like flags.
- Add config override tests for `reports.hourly.deterministic_modules`.
- Ensure `near_term` and `near-term` report config keys are rejected.
- Add app/CLI workflow tests using fake Scriptorium structured output.
- Update examples only if they enumerate report module overrides.
Acceptance criteria:
- `weatherreporter --help` lists `generate hourly`;
- `generate near-term` is not accepted;
- hourly config overrides validate compatible modules;
- incompatible daily/daypart modules fail clearly for hourly;
- examples load successfully.
Suggested validation:
```bash
go test ./internal/cli ./internal/config ./internal/app
go run ./cmd/weatherreporter --help
```
This stage is small enough for one implementation prompt.
## Stage 12: Implemented Documentation
Goal: document implemented behavior after the code exists.
Files to inspect and update:
- `docs/cli.md`
- `docs/operations.md`
- `docs/config.md`
- `docs/internal/app-orchestration.md`
- `docs/internal/report-registry.md`
- `docs/internal/briefing.md`
- `docs/internal/prompt-input.md`
- `docs/internal/scriptorium-adapter.md`
- `docs/internal/state.md`
- new `docs/internal/reporttemplate.md`
- `docs/integrations/scriptorium.md`
- `docs/roadmap/future.md`
- `docs/roadmap/near-term.md`
- `examples/config.yml`, only if examples changed
Documentation requirements:
- Non-roadmap docs describe only implemented hourly and GeneratedText behavior.
- `docs/cli.md` documents `generate hourly`, not `generate near-term`.
- `docs/config.md` documents `reports.hourly` only if override support is
implemented.
- Internal docs distinguish:
- CollectedFacts;
- DerivedFacts;
- ModuleOutput;
- GeneratedText;
- RenderContext.
- Scriptorium integration docs include the structured-output command actually
used by the adapter.
- State docs list generated-text artifacts and metadata links.
- `docs/roadmap/near-term.md` is deleted or marked superseded.
- Deferred migrations for other report types stay under roadmap docs only.
Acceptance criteria:
- No non-roadmap docs describe unimplemented generated-text migrations.
- No active docs describe `near_term` as the target public report.
- Maintained examples remain valid.
Suggested validation:
```bash
go test ./internal/config
rg -n "near_term|near-term|NearTerm" docs internal examples
git diff --check
```
This stage is small enough for one implementation prompt.
## Stage 13: Final Validation
Goal: run full validation and perform targeted manual checks.
Commands:
```bash
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Manual checks:
- `weatherreporter --help` lists `generate hourly`.
- `weatherreporter --help` does not list `generate near-term`.
- generated hourly Markdown has deterministic headings and section order.
- GeneratedText JSON is persisted separately from deterministic facts.
- render context JSON is persisted and readable.
- embedded hourly template is readable and easy to edit.
- embedded hourly schema is readable and easy to edit.
- existing Markdown-generation reports still run through the previous
Scriptorium `run --out <markdown>` path.
- distributor notification, when enabled, uploads the managed Markdown report.
This stage is small enough for one implementation prompt.
## Deferred Work
Do not include these in the first implementation:
- migrating daily, tomorrow, three-day, weekend, or storm to GeneratedText;
- user-configurable template or local schema source overrides;
- user-defined templates or schemas;
- a generic workflow engine;
- template hot reloading;
- dynamic module selection for templates;
- prompt/schema generation from Go structs;
- scheduled hourly batch membership;
- configurable hourly report duration;
- rolling-window Recent Changes for hourly;
- distributor-specific generated-text behavior.
## Open Questions
None block implementation.

363
docs/roadmap/near-term.md Normal file
View File

@@ -0,0 +1,363 @@
# Near-Term Report Roadmap
## Purpose
This roadmap defines planned work to add a rolling `near_term` report focused
on the next several hours. The feature is not implemented yet, so this document
lives under `docs/roadmap/`.
The goal is a frequently generated report that helps readers understand what
matters in the immediate future: current conditions, hourly evolution,
precipitation timing, applicable hazards, applicable SPC risk products, and the
most relevant short-term narrative context.
## Target Behavior
Add a generated report with:
- report ID: `near_term`
- display name: `Near-Term Report`
- prompt ID: `weather.near_term_report`
- artifact group: `near-term`
- batch output name: `near-term.md`
- default valid-period length: 6 hours
- valid period: `[generation_time, generation_time + nearTermHours)`
The report should use a package-owned constant for the valid-period length, for
example:
```go
const nearTermHours = 6
```
Do not make the duration configurable in the first implementation. The constant
exists so the value can be changed later to 4 or 8 hours without changing
valid-period logic in multiple places.
## Locked Decisions
- The report ID is `near_term`.
- The report is rolling and generation-time anchored, not civil-day anchored.
- The first implementation covers the next 6 hours through a constant.
- The report should be generated explicitly by CLI command before deciding
whether it belongs in scheduled batches.
- The first implementation should declare `rolling_window` comparison policy
but should not emit Recent Changes output for `near_term`.
- Alert and SPC products should be included only when their valid periods
overlap the resolved near-term report period.
- SPC discussion text should keep the existing categorical-risk threshold rule
and should still require overlap with the near-term report period.
- The AFD stanza should include key messages and short term text by default.
- Daily-only modules should not be forced into this report.
## Report Definition
Add a report definition under `internal/report`, preferably in a focused
`near_term_report.go` file.
Definition fields:
- `ID`: `NearTerm`
- `Name`: `Near-Term Report`
- `PromptID`: `weather.near_term_report`
- `ComparisonStrategy`: `rolling_window`
- `ArtifactGroup`: `near-term`
- `BatchOutputName`: `near-term.md`
- `Generated`: `true`
- `CompatiblePriorIDs`: `[]ID{NearTerm}`
- `Modules`: near-term module list below
- `resolve`: rolling near-term resolver
Valid-period resolver:
```go
func resolveNearTerm(req ResolveRequest) (timeutil.Period, error) {
localNow := req.Now.In(req.Location)
return timeutil.Period{
Start: localNow,
End: localNow.Add(nearTermHours * time.Hour),
}, nil
}
```
The implementation should use idiomatic package-local constants and avoid
duplicating duration literals in tests or app code.
## Default Module Composition
Default module order should be:
1. `metadata`
2. `current_conditions`
3. `hourly_forecast`
4. `precip_timing`
5. `alert_digest`
6. `spc_convective_outlooks`
7. `area_forecast_discussion`
8. `spc_convective_discussion`
9. `weather_story`
`area_forecast_discussion` should use options equivalent to:
```yaml
sections:
- key_messages
- short_term
```
Do not include these daily/daypart-oriented modules initially:
- `derived_daily_summary`
- `derived_daypart_summaries`
- `outdoor_windows`
- `tomorrow_planning`
If the report needs deterministic summary facts later, add a purpose-built
module such as `derived_near_term_summary` rather than stretching daily modules
into a rolling sub-daily context.
## Prompt Package Shape
The existing prompt-input category layout should remain unchanged:
- `applicable_risk_products`
- `derived_summaries`
- `narrative_products`
- `raw_data`
Near-term output is expected to emphasize:
- `current_conditions` and `hourly_forecast` under `raw_data`
- `precip_timing` under `derived_summaries`
- `alert_digest` and `spc_convective_outlooks` under
`applicable_risk_products`
- `area_forecast_discussion`, `spc_convective_discussion`, and
`weather_story` under `narrative_products`
Module interval fields should use the existing prompt-facing
`period_begins` / `period_ends` convention.
## Overlap And Filtering Rules
Hourly forecast:
- use only hourly periods overlapping the near-term valid period;
- preserve hourly period order;
- do not include the full daily forecast.
Narrative products:
- AFD key messages and short term text may be included because the AFD is an
official short-term forecast discussion product;
- narrative forecast periods are not included by default unless a later prompt
test shows they improve near-term output.
Alerts:
- include active alert overlaps only when the alert overlaps the near-term
valid period;
- if alerts were checked successfully and no alerts overlap, emit checked empty
alert context through existing module behavior.
SPC outlooks:
- include only retained outlooks whose valid periods overlap the near-term
valid period;
- do not include non-overlapping outlooks even if they are severe;
- keep current prompt-facing field exclusions for geometry, severity rank,
expiration time, and source URL.
SPC discussion:
- include discussion only for SPC days where a retained overlapping categorical
outlook has severity rank at least `3`;
- do not include discussion for low-risk, non-categorical-only, or
non-overlapping outlooks.
Weather story:
- include when available under current optional-source behavior;
- do not require the story valid period to exactly match the near-term period
unless later testing shows stale stories are a problem.
## Implementation Stages
### Stage 1: Report Registry
Goal: add the `near_term` report definition and valid-period resolver.
Files to inspect or update:
- `internal/report/definition.go`
- `internal/report/registry.go`
- `internal/report/period.go`
- new `internal/report/near_term_report.go`
- `internal/report/period_test.go`
- `docs/internal/report-registry.md` after implementation
Acceptance criteria:
- `report.DefaultRegistry().Lookup(report.NearTerm)` succeeds.
- `report.Registry.All()` includes the report in a stable order.
- resolving the report at a fixed generation time produces a half-open
six-hour period.
- report metadata and RunID include `near_term`.
### Stage 2: Module Compatibility And Defaults
Goal: allow existing relevant modules to build for `near_term` and define the
default near-term composition.
Files to inspect or update:
- `internal/briefing/modules.go`
- `internal/report/near_term_report.go`
- `internal/briefing/*_module_test.go`
- `internal/module/module_test.go`
Acceptance criteria:
- all default near-term modules validate and build from appropriate test facts;
- daily-only modules remain incompatible unless intentionally expanded;
- `area_forecast_discussion` defaults to key messages and short term only for
this report.
### Stage 3: Derived Facts
Goal: make `internal/facts` derive report-period facts for `near_term`.
Files to inspect or update:
- `internal/facts/facts.go`
- `internal/facts/facts_test.go`
- `internal/forecast` selection helpers, if needed
Acceptance criteria:
- valid-period hourly periods are sliced to the six-hour window;
- precipitation timing is built from that six-hour hourly slice;
- alert overlaps use the near-term valid period;
- SPC outlook and discussion derivation use the near-term valid period;
- no daily summaries or daypart summaries are required for the default
near-term module set.
### Stage 4: CLI And App Wiring
Goal: add explicit generation support without changing existing commands.
Files to inspect or update:
- `internal/app`
- `internal/cli`
- `cmd/weatherreporter/main.go`
- `docs/cli.md` after implementation
Expected command:
```bash
weatherreporter generate near-term [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
```
Acceptance criteria:
- command maps to `report.NearTerm`;
- no `--date`, `--start`, or `--end` flags are added for this report;
- existing `generate daily`, `generate tomorrow`, `generate three-day`,
`generate weekend`, and `generate storm` behavior is unchanged;
- CLI help lists the new command.
### Stage 5: Prompt Input And State Coverage
Goal: prove the managed module snapshot and data package contain the intended
near-term stanzas.
Files to inspect or update:
- `internal/app/app_test.go`
- `internal/promptinput`
- `internal/state`
Acceptance criteria:
- generated module snapshot includes the near-term default module list in
order;
- data package uses existing categories;
- alert/SPC stanzas are omitted or checked-empty according to current module
behavior and valid-period overlap;
- report metadata preserves canonical `valid_period`;
- module interval labels use `period_begins` and `period_ends`.
### Stage 6: Documentation And Examples
Goal: document implemented behavior only after the code exists.
Files to inspect or update after implementation:
- `docs/cli.md`
- `docs/internal/report-registry.md`
- `docs/internal/module.md`
- `docs/internal/facts.md`
- `docs/internal/briefing.md`
- `docs/config.md` only if report module overrides need an example update
- `examples/config.yml` only if it lists report module overrides
Acceptance criteria:
- non-roadmap docs describe `near_term` only after implementation;
- future or deferred near-term summary modules remain under roadmap docs;
- examples, if updated, load through existing config tests.
## Test Plan
Focused tests:
```bash
go test ./internal/report ./internal/briefing ./internal/facts
go test ./internal/app ./internal/cli ./internal/promptinput
```
Full validation:
```bash
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Important coverage:
- six-hour valid-period resolution from a fixed generation time;
- timezone-aware period start and end;
- no civil-day truncation;
- hourly periods selected by overlap with the rolling window;
- alert overlap filtering;
- SPC outlook filtering by overlap;
- SPC discussion filtering by overlap plus categorical severity threshold;
- AFD key messages and short term included;
- daily-only modules rejected or absent from default near-term composition;
- distributor bundle path templates render sensibly for a sub-daily report,
especially valid start time variables.
## Deferred Work
Do not include these in the first implementation:
- user-configurable near-term duration;
- adding `near_term` to scheduled morning or evening batches;
- a dedicated `derived_near_term_summary` module;
- narrative forecast periods by default;
- separate AFD section modules;
- near-term-specific Recent Changes comparison output;
- CLI flags for custom near-term duration.
## Open Questions
None block the roadmap.
Recommendation: implement the first version as explicit `generate near-term`
only, with no scheduled batch membership. That keeps the user-visible behavior
small while the prompt and generated report are tested.
Viable alternative: add `near_term` to a frequent scheduled batch immediately.
That may be useful operationally, but it should wait until the report cadence
and downstream distributor behavior are clear.

View File

@@ -21,7 +21,7 @@ notify:
token_env: DISTRIBUTOR_UPLOAD_TOKEN token_env: DISTRIBUTOR_UPLOAD_TOKEN
timeout: 30s timeout: 30s
failure_policy: error failure_policy: error
pipeline_id_template: "weatherreporter.{artifact_group}" pipeline_id_template: "weatherreporter.{report_id}"
bundle_id_template: "weatherreporter.{location_id}.{report_id}" bundle_id_template: "weatherreporter.{location_id}.{report_id}"
idempotency_key_template: "{bundle_id}.{run_id}" idempotency_key_template: "{bundle_id}.{run_id}"
report_path_templates: report_path_templates:
@@ -77,6 +77,7 @@ reports:
- derived_daypart_summaries - derived_daypart_summaries
- precip_timing - precip_timing
- alert_digest - alert_digest
- spc_convective_outlooks
- id: area_forecast_discussion - id: area_forecast_discussion
options: options:
sections: sections:
@@ -84,6 +85,7 @@ reports:
- key_messages - key_messages
- short_term - short_term
- long_term - long_term
- spc_convective_discussion
- weather_story - weather_story
- outdoor_windows - outdoor_windows
- hourly_forecast - hourly_forecast

View File

@@ -21,6 +21,11 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
const (
convectiveOutlooksEndpoint = "/outlooks/convective"
sourceSPCConvectiveOutlooks = "spc_convective_outlooks"
)
type Client struct { type Client struct {
baseURL *url.URL baseURL *url.URL
httpClient *http.Client httpClient *http.Client
@@ -112,6 +117,9 @@ func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
if err := builder.fetchWeatherStory(ctx); err != nil { if err := builder.fetchWeatherStory(ctx); err != nil {
return nil, err return nil, err
} }
if err := builder.fetchSPCConvectiveOutlooks(ctx); err != nil {
return nil, err
}
return builder.bundle, nil return builder.bundle, nil
} }
@@ -264,6 +272,29 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
return nil return nil
} }
func (b *bundleBuilder) fetchSPCConvectiveOutlooks(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, sourceSPCConvectiveOutlooks, convectiveOutlooksEndpoint, queryOptions{timezone: true, omitUnits: true})
if err != nil {
return err
}
if raw == nil {
return b.handleMissing(&source, "SPC convective outlook data is missing", false)
}
var run weatherdata.ConvectiveOutlookRun
if err := decodeSource(raw, &run); err != nil {
return b.handleMalformed(&source, err, false)
}
if run.IssuedAt != nil {
source.IssuedAt = run.IssuedAt
} else {
source.IssuedAt = run.AsOf
}
source.UpdatedAt = run.UpdatedAt
b.bundle.SPCConvectiveOutlooks = &run
b.addSource(source)
return nil
}
func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error { func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error {
source.Missing = true source.Missing = true
if required { if required {

View File

@@ -55,8 +55,17 @@ func TestFetchBundleFromFixtures(t *testing.T) {
if bundle.WeatherStory.UpdatedAt == nil { if bundle.WeatherStory.UpdatedAt == nil {
t.Fatalf("WeatherStory.UpdatedAt = nil, want update timestamp") t.Fatalf("WeatherStory.UpdatedAt = nil, want update timestamp")
} }
if len(bundle.Sources) != 7 { if bundle.SPCConvectiveOutlooks == nil || len(bundle.SPCConvectiveOutlooks.Outlooks) != 1 {
t.Fatalf("Sources length = %d, want 7", len(bundle.Sources)) t.Fatalf("SPCConvectiveOutlooks = %#v, want one outlook", bundle.SPCConvectiveOutlooks)
}
if len(bundle.SPCConvectiveOutlooks.Outlooks[0].Geometry) == 0 {
t.Fatalf("SPCConvectiveOutlooks.Outlooks[0].Geometry is empty, want GeoJSON")
}
if len(bundle.SPCConvectiveOutlooks.Discussions) != 1 || bundle.SPCConvectiveOutlooks.Discussions[0].Headline != "Severe storms possible" {
t.Fatalf("SPCConvectiveOutlooks.Discussions = %#v, want one discussion", bundle.SPCConvectiveOutlooks.Discussions)
}
if len(bundle.Sources) != 8 {
t.Fatalf("Sources length = %d, want 8", len(bundle.Sources))
} }
if len(bundle.Warnings) != 0 { if len(bundle.Warnings) != 0 {
t.Fatalf("Warnings length = %d, want no warnings", len(bundle.Warnings)) t.Fatalf("Warnings length = %d, want no warnings", len(bundle.Warnings))
@@ -70,6 +79,9 @@ func TestFetchBundleFromFixtures(t *testing.T) {
if !containsPath(requested, "/weatherstories/latest") { if !containsPath(requested, "/weatherstories/latest") {
t.Fatalf("requested paths = %v, want weather story endpoint", requested) t.Fatalf("requested paths = %v, want weather story endpoint", requested)
} }
if !containsPath(requested, convectiveOutlooksEndpoint) {
t.Fatalf("requested paths = %v, want convective outlook endpoint", requested)
}
} }
func TestFetchBundleBuildsExpectedQueries(t *testing.T) { func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
@@ -92,6 +104,12 @@ func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
} }
continue continue
} }
if strings.HasPrefix(rawURL, convectiveOutlooksEndpoint) {
if strings.Contains(rawURL, "units=") || strings.Contains(rawURL, "precision=") || !strings.Contains(rawURL, "tz=America%2FChicago") {
t.Fatalf("convective outlook request %q should use format and tz only", rawURL)
}
continue
}
if !strings.Contains(rawURL, "units=us") { if !strings.Contains(rawURL, "units=us") {
t.Fatalf("request %q missing units=us", rawURL) t.Fatalf("request %q missing units=us", rawURL)
} }
@@ -127,6 +145,25 @@ func TestFetchBundleRecordsSourceHash(t *testing.T) {
if story.IssuedAt == nil || story.UpdatedAt == nil { if story.IssuedAt == nil || story.UpdatedAt == nil {
t.Fatalf("weather story source timestamps = issued %#v updated %#v, want both", story.IssuedAt, story.UpdatedAt) t.Fatalf("weather story source timestamps = issued %#v updated %#v, want both", story.IssuedAt, story.UpdatedAt)
} }
outlooks := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
if outlooks.Endpoint != convectiveOutlooksEndpoint {
t.Fatalf("convective outlook endpoint = %q, want %s", outlooks.Endpoint, convectiveOutlooksEndpoint)
}
if outlooks.Query["format"] != "json" || outlooks.Query["tz"] != "America/Chicago" || outlooks.Query["units"] != "" || outlooks.Query["precision"] != "" {
t.Fatalf("convective outlook query = %#v, want format and tz only", outlooks.Query)
}
if outlooks.DataSHA256 != hashFixtureData(t, "convective_outlooks.json") {
t.Fatalf("convective outlook DataSHA256 = %q, want fixture hash", outlooks.DataSHA256)
}
if outlooks.Missing {
t.Fatal("convective outlook source Missing = true, want false")
}
if outlooks.IssuedAt == nil || outlooks.IssuedAt.Format(time.RFC3339) != "2026-05-29T15:45:00Z" {
t.Fatalf("convective outlook IssuedAt = %#v, want run issuedAt", outlooks.IssuedAt)
}
if outlooks.UpdatedAt == nil || outlooks.UpdatedAt.Format(time.RFC3339) != "2026-05-29T16:05:00Z" {
t.Fatalf("convective outlook UpdatedAt = %#v, want run updatedAt", outlooks.UpdatedAt)
}
} }
func TestHTTPErrorIsActionable(t *testing.T) { func TestHTTPErrorIsActionable(t *testing.T) {
@@ -189,6 +226,59 @@ func TestNullAlertsMeansNoActiveAlerts(t *testing.T) {
} }
} }
func TestMissingSPCConvectiveOutlooksUsesPolicy(t *testing.T) {
server := fixtureServer(t, map[string]handlerOverride{
convectiveOutlooksEndpoint: {status: http.StatusOK, body: `{"data": null}`},
}, nil)
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
sourceSPCConvectiveOutlooks: config.MissingSourceWarn,
})
bundle, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
if bundle.SPCConvectiveOutlooks != nil {
t.Fatalf("SPCConvectiveOutlooks = %#v, want nil for missing source", bundle.SPCConvectiveOutlooks)
}
source := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
if !source.Missing || len(source.Warnings) != 1 {
t.Fatalf("convective outlook source = %#v, want missing source warning", source)
}
}
func TestEmptySPCConvectiveOutlooksAreCheckedData(t *testing.T) {
server := fixtureServer(t, map[string]handlerOverride{
convectiveOutlooksEndpoint: {status: http.StatusOK, body: `{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`},
}, nil)
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
sourceSPCConvectiveOutlooks: config.MissingSourceWarn,
})
bundle, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
if bundle.SPCConvectiveOutlooks == nil {
t.Fatal("SPCConvectiveOutlooks = nil, want checked empty run")
}
if len(bundle.SPCConvectiveOutlooks.Outlooks) != 0 || len(bundle.SPCConvectiveOutlooks.Discussions) != 0 {
t.Fatalf("SPCConvectiveOutlooks = %#v, want empty arrays", bundle.SPCConvectiveOutlooks)
}
source := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
if source.Missing || len(source.Warnings) != 0 {
t.Fatalf("convective outlook source = %#v, want non-missing source without warnings", source)
}
if source.IssuedAt == nil || source.IssuedAt.Format(time.RFC3339) != "2026-05-29T16:00:00Z" {
t.Fatalf("convective outlook IssuedAt = %#v, want fallback to asOf", source.IssuedAt)
}
for _, warning := range bundle.Warnings {
if warning.Source == sourceSPCConvectiveOutlooks {
t.Fatalf("warnings = %#v, want no convective outlook warning", bundle.Warnings)
}
}
}
func TestMissingSourcePolicyWarnNoneError(t *testing.T) { func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -363,13 +453,14 @@ type handlerOverride struct {
func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server { func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server {
t.Helper() t.Helper()
fixtures := map[string]string{ fixtures := map[string]string{
"/observations": "observations.json", "/observations": "observations.json",
"/conditions/current": "current.json", "/conditions/current": "current.json",
"/forecast/hourly": "hourly.json", "/forecast/hourly": "hourly.json",
"/forecast/narrative": "narrative.json", "/forecast/narrative": "narrative.json",
"/alerts/active": "alerts.json", "/alerts/active": "alerts.json",
"/discussion": "discussion.json", "/discussion": "discussion.json",
"/weatherstories/latest": "weather_story.json", "/weatherstories/latest": "weather_story.json",
convectiveOutlooksEndpoint: "convective_outlooks.json",
} }
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if requested != nil { if requested != nil {

View File

@@ -0,0 +1,50 @@
{
"data": {
"locationId": "nws-lsx-grid-90-74",
"locationName": "St. Louis, MO",
"asOf": "2026-05-29T16:00:00Z",
"issuedAt": "2026-05-29T15:45:00Z",
"updatedAt": "2026-05-29T16:05:00Z",
"product": "convective_outlook",
"outlooks": [
{
"id": "day1-categorical-slight",
"provider": "spc",
"product": "convective_outlook",
"day": 1,
"outlookType": "categorical",
"label": "SLGT",
"labelText": "Slight Risk",
"forecaster": "Smith",
"severityRank": 3,
"validFrom": "2026-05-29T13:00:00-05:00",
"validTo": "2026-05-30T07:00:00-05:00",
"issuedAt": "2026-05-29T15:45:00Z",
"expiresAt": "2026-05-30T07:00:00-05:00",
"sourceUrl": "https://www.spc.noaa.gov/products/outlook/day1otlk.html",
"imageUrl": "https://www.spc.noaa.gov/products/outlook/day1probotlk_2000_torn.gif",
"containsLocation": true,
"geometry": {
"type": "Polygon",
"coordinates": [
[
[-91.0, 38.0],
[-90.0, 38.5],
[-89.5, 37.8],
[-91.0, 38.0]
]
]
}
}
],
"discussions": [
{
"day": 1,
"headline": "Severe storms possible",
"summary": "Scattered severe storms are possible.",
"discussion": "A few storms may become severe during the afternoon.",
"updatedAt": "2026-05-29T16:05:00Z"
}
]
}
}

View File

@@ -30,6 +30,7 @@ type ReportKind string
const ( const (
ReportDaily ReportKind = "daily" ReportDaily ReportKind = "daily"
ReportTomorrow ReportKind = "tomorrow" ReportTomorrow ReportKind = "tomorrow"
ReportNearTerm ReportKind = "near-term"
ReportThreeDay ReportKind = "three-day" ReportThreeDay ReportKind = "three-day"
ReportWeekend ReportKind = "weekend" ReportWeekend ReportKind = "weekend"
ReportStorm ReportKind = "storm" ReportStorm ReportKind = "storm"
@@ -386,6 +387,8 @@ func reportIDForCommand(kind ReportKind) (report.ID, error) {
return report.DailyToday, nil return report.DailyToday, nil
case ReportTomorrow: case ReportTomorrow:
return report.DailyTomorrow, nil return report.DailyTomorrow, nil
case ReportNearTerm:
return report.NearTerm, nil
case ReportThreeDay: case ReportThreeDay:
return report.ThreeDay, nil return report.ThreeDay, nil
case ReportWeekend: case ReportWeekend:

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -39,6 +40,8 @@ func TestFetchAndSaveBundle(t *testing.T) {
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":[],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for saved bundle."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for saved bundle."}}}`)) _, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":[],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for saved bundle."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for saved bundle."}}}`))
case "/weatherstories/latest": case "/weatherstories/latest":
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`)) _, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
case "/outlooks/convective":
_, _ = w.Write([]byte(`{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`))
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@@ -167,9 +170,13 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
!strings.Contains(string(data), "current_conditions:") || !strings.Contains(string(data), "current_conditions:") ||
!strings.Contains(string(data), "narrative_forecast:") || !strings.Contains(string(data), "narrative_forecast:") ||
!strings.Contains(string(data), "hourly_forecast:") || !strings.Contains(string(data), "hourly_forecast:") ||
!strings.Contains(string(data), "area_forecast_discussion:") { !strings.Contains(string(data), "area_forecast_discussion:") ||
!strings.Contains(string(data), "spc_convective_outlooks:") {
t.Fatalf("data package missing expected content:\n%s", string(data)) t.Fatalf("data package missing expected content:\n%s", string(data))
} }
if strings.Contains(string(data), "spc_convective_discussion:") {
t.Fatalf("data package has SPC convective discussion, want omitted for empty checked source:\n%s", string(data))
}
if strings.Contains(string(data), "source_warnings:") { if strings.Contains(string(data), "source_warnings:") {
t.Fatalf("data package has source warnings, want none for complete fetched sources:\n%s", string(data)) t.Fatalf("data package has source warnings, want none for complete fetched sources:\n%s", string(data))
} }
@@ -182,9 +189,10 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
storyIndex := strings.Index(string(data), " weather_story:") storyIndex := strings.Index(string(data), " weather_story:")
currentIndex := strings.Index(string(data), " current_conditions:") currentIndex := strings.Index(string(data), " current_conditions:")
hourlyIndex := strings.Index(string(data), " hourly_forecast:") hourlyIndex := strings.Index(string(data), " hourly_forecast:")
if riskIndex < 0 || derivedIndex < 0 || narrativeIndex < 0 || rawIndex < 0 || alertIndex < 0 || summaryIndex < 0 || storyIndex < 0 || currentIndex < 0 || hourlyIndex < 0 || outlookIndex := strings.Index(string(data), " spc_convective_outlooks:")
if riskIndex < 0 || derivedIndex < 0 || narrativeIndex < 0 || rawIndex < 0 || alertIndex < 0 || outlookIndex < 0 || summaryIndex < 0 || storyIndex < 0 || currentIndex < 0 || hourlyIndex < 0 ||
!(riskIndex < derivedIndex && derivedIndex < narrativeIndex && narrativeIndex < rawIndex) || !(riskIndex < derivedIndex && derivedIndex < narrativeIndex && narrativeIndex < rawIndex) ||
!(riskIndex < alertIndex && derivedIndex < summaryIndex && narrativeIndex < storyIndex && rawIndex < currentIndex && currentIndex < hourlyIndex) { !(riskIndex < alertIndex && alertIndex < outlookIndex && outlookIndex < derivedIndex && derivedIndex < summaryIndex && narrativeIndex < storyIndex && rawIndex < currentIndex && currentIndex < hourlyIndex) {
t.Fatalf("data package grouping is wrong, want categorized prompt stanzas:\n%s", string(data)) t.Fatalf("data package grouping is wrong, want categorized prompt stanzas:\n%s", string(data))
} }
savedDataPackage, err := promptinput.LoadYAML(data) savedDataPackage, err := promptinput.LoadYAML(data)
@@ -197,6 +205,11 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
if _, ok := savedDataPackage.Briefing.Values["metadata"]; !ok { if _, ok := savedDataPackage.Briefing.Values["metadata"]; !ok {
t.Fatal("data package metadata stanza missing") t.Fatal("data package metadata stanza missing")
} }
assertNoStaleModuleIntervalKeys(t, savedDataPackage.Briefing.Values)
spcOutlooks, ok := savedDataPackage.Briefing.Values["spc_convective_outlooks"].(map[string]any)
if !ok || spcOutlooks["checked"] != true || spcOutlooks["outlook_count"] != 0 {
t.Fatalf("data package SPC convective outlooks = %#v, want checked empty source", savedDataPackage.Briefing.Values["spc_convective_outlooks"])
}
current, ok := savedDataPackage.Briefing.Values["current_conditions"].(map[string]any) current, ok := savedDataPackage.Briefing.Values["current_conditions"].(map[string]any)
if !ok || current["condition_text"] != "Clear" { if !ok || current["condition_text"] != "Clear" {
t.Fatalf("data package current conditions = %#v, want current conditions", savedDataPackage.Briefing.Values["current_conditions"]) t.Fatalf("data package current conditions = %#v, want current conditions", savedDataPackage.Briefing.Values["current_conditions"])
@@ -244,6 +257,279 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
} }
} }
func TestGenerateReportIncludesSPCConvectivePromptStanzas(t *testing.T) {
server := dailyBundleServerWithConvectiveResponse(t, qualifyingConvectiveOutlooksResponse)
cfg := dailyTestConfig(t, server)
result := generateDailyReportForTest(t, cfg)
if _, ok := result.ModuleSnapshot.LookupStanza("spc_convective_outlooks"); !ok {
t.Fatal("module snapshot missing spc_convective_outlooks stanza")
}
if _, ok := result.ModuleSnapshot.LookupStanza("spc_convective_discussion"); !ok {
t.Fatal("module snapshot missing spc_convective_discussion stanza")
}
data := readDataPackageForTest(t, result)
text := string(data)
if strings.Contains(text, "geometry:") || strings.Contains(text, "coordinates:") || strings.Contains(text, "Polygon") {
t.Fatalf("data package contains geometry, want prompt-facing fields only:\n%s", text)
}
for _, want := range []string{
" spc_convective_outlooks:",
" spc_convective_discussion:",
" included_because: categorical severity_rank >= 3",
" label_text: Slight Risk",
" period_begins:",
" period_ends:",
" discussion: Severe thunderstorms may produce damaging winds during the afternoon.",
} {
if !strings.Contains(text, want) {
t.Fatalf("data package missing %q:\n%s", want, text)
}
}
for _, omitted := range []string{" severity_rank:", " expires_at:", " source_url:"} {
if strings.Contains(text, omitted) {
t.Fatalf("data package contains %q, want SPC prompt schema without it:\n%s", omitted, text)
}
}
riskIndex := strings.Index(text, " applicable_risk_products:")
alertIndex := strings.Index(text, " alert_digest:")
outlookIndex := strings.Index(text, " spc_convective_outlooks:")
derivedIndex := strings.Index(text, " derived_summaries:")
narrativeIndex := strings.Index(text, " narrative_products:")
forecastIndex := strings.Index(text, " narrative_forecast:")
afdIndex := strings.Index(text, " area_forecast_discussion:")
discussionIndex := strings.Index(text, " spc_convective_discussion:")
storyIndex := strings.Index(text, " weather_story:")
rawIndex := strings.Index(text, " raw_data:")
if riskIndex < 0 || alertIndex < 0 || outlookIndex < 0 || derivedIndex < 0 || narrativeIndex < 0 || forecastIndex < 0 || afdIndex < 0 || discussionIndex < 0 || storyIndex < 0 || rawIndex < 0 ||
!(riskIndex < alertIndex && alertIndex < outlookIndex && outlookIndex < derivedIndex) ||
!(narrativeIndex < forecastIndex && forecastIndex < afdIndex && afdIndex < discussionIndex && discussionIndex < storyIndex && storyIndex < rawIndex) {
t.Fatalf("data package category order is wrong:\n%s", text)
}
loaded, err := promptinput.LoadYAML(data)
if err != nil {
t.Fatalf("LoadYAML() error = %v", err)
}
assertNoStaleModuleIntervalKeys(t, loaded.Briefing.Values)
if _, ok := loaded.Briefing.Values["spc_convective_outlooks"]; !ok {
t.Fatal("loaded package missing spc_convective_outlooks stanza")
}
if _, ok := loaded.Briefing.Values["spc_convective_discussion"]; !ok {
t.Fatal("loaded package missing spc_convective_discussion stanza")
}
}
func TestGenerateReportOmitsSPCConvectiveDiscussionBelowThreshold(t *testing.T) {
server := dailyBundleServerWithConvectiveResponse(t, lowerRiskConvectiveOutlooksResponse)
cfg := dailyTestConfig(t, server)
result := generateDailyReportForTest(t, cfg)
if _, ok := result.ModuleSnapshot.LookupStanza("spc_convective_outlooks"); !ok {
t.Fatal("module snapshot missing spc_convective_outlooks stanza")
}
if _, ok := result.ModuleSnapshot.LookupStanza("spc_convective_discussion"); ok {
t.Fatal("module snapshot has spc_convective_discussion stanza, want omitted below threshold")
}
text := string(readDataPackageForTest(t, result))
if !strings.Contains(text, " spc_convective_outlooks:") || !strings.Contains(text, " label_text: Marginal Risk") {
t.Fatalf("data package missing lower-risk SPC outlook:\n%s", text)
}
for _, omitted := range []string{" severity_rank:", " expires_at:", " source_url:"} {
if strings.Contains(text, omitted) {
t.Fatalf("data package contains %q, want SPC prompt schema without it:\n%s", omitted, text)
}
}
if strings.Contains(text, "spc_convective_discussion:") || strings.Contains(text, "Low-end severe threat discussion.") {
t.Fatalf("data package has SPC convective discussion, want omitted below threshold:\n%s", text)
}
if strings.Contains(text, "geometry:") || strings.Contains(text, "coordinates:") || strings.Contains(text, "Polygon") {
t.Fatalf("data package contains geometry, want prompt-facing fields only:\n%s", text)
}
}
func TestGenerateNearTermReportWritesExpectedArtifacts(t *testing.T) {
server := nearTermBundleServer(t)
cfg := dailyTestConfig(t, server)
cfg.Workspace.Root = t.TempDir()
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportNearTerm,
}, mustParse("2026-05-29T08:30:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
outputPath := filepath.Join(t.TempDir(), "near-term.md")
renderer := successfulRenderer("# Near-Term Report\n")
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: resolved,
OutputPath: outputPath,
Renderer: renderer,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
if result.Metadata.ReportID != report.NearTerm || result.Metadata.PromptID != "weather.near_term_report" {
t.Fatalf("metadata report/prompt = %q/%q, want near-term", result.Metadata.ReportID, result.Metadata.PromptID)
}
if got := result.Metadata.ValidPeriod.Start.Format(time.RFC3339); got != "2026-05-29T08:30:00-05:00" {
t.Fatalf("valid period start = %s, want rolling window start", got)
}
if got := result.Metadata.ValidPeriod.End.Format(time.RFC3339); got != "2026-05-29T14:30:00-05:00" {
t.Fatalf("valid period end = %s, want six-hour rolling window end", got)
}
if renderer.renderRequest.PromptID != "weather.near_term_report" || renderer.runRequest.PromptID != "weather.near_term_report" {
t.Fatalf("renderer prompt IDs = %q/%q, want near-term prompt", renderer.renderRequest.PromptID, renderer.runRequest.PromptID)
}
if !strings.Contains(result.ReportPath, filepath.Join("reports", "near-term")) {
t.Fatalf("ReportPath = %q, want near-term artifact group", result.ReportPath)
}
if !strings.Contains(result.DataPackagePath, filepath.Join("data-packages", "near-term", "2026-05-29")) {
t.Fatalf("DataPackagePath = %q, want near-term artifact group", result.DataPackagePath)
}
assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath, outputPath)
if result.OutputPath != outputPath {
t.Fatalf("OutputPath = %q, want requested output copy %q", result.OutputPath, outputPath)
}
copiedReport, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("read output copy: %v", err)
}
if !strings.Contains(string(copiedReport), "# Near-Term Report") {
t.Fatalf("output copy missing rendered report:\n%s", string(copiedReport))
}
wantModules := []module.ID{
module.Metadata,
module.CurrentConditions,
module.HourlyForecast,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
}
if got := snapshotModuleIDs(result.ModuleSnapshot); strings.Join(moduleIDsForTest(got), ",") != strings.Join(moduleIDsForTest(wantModules), ",") {
t.Fatalf("module snapshot IDs = %#v, want %#v", got, wantModules)
}
hourly, ok, err := module.StanzaValue[briefing.HourlyForecastModule](result.ModuleSnapshot, "hourly_forecast")
if err != nil {
t.Fatalf("decode hourly forecast: %v", err)
}
if !ok || len(hourly.Periods) != 5 {
t.Fatalf("hourly forecast = %#v, want five overlapping near-term periods", hourly)
}
hourlyJSON := mustMarshalString(t, hourly)
if !strings.Contains(hourlyJSON, "Showers entering the area") || !strings.Contains(hourlyJSON, "Heavy rain") {
t.Fatalf("hourly forecast missing selected near-term periods:\n%s", hourlyJSON)
}
if strings.Contains(hourlyJSON, "Before-window storms") || strings.Contains(hourlyJSON, "After-window rain") {
t.Fatalf("hourly forecast contains periods outside valid window:\n%s", hourlyJSON)
}
precip, ok, err := module.StanzaValue[briefing.PrecipTimingModule](result.ModuleSnapshot, "precip_timing")
if err != nil {
t.Fatalf("decode precip timing: %v", err)
}
if !ok || precip.MaxPopPercent == nil || *precip.MaxPopPercent != 80 || len(precip.PrecipitationWindows) != 2 {
t.Fatalf("precip timing = %#v, want near-term precipitation windows", precip)
}
if precip.PrecipitationWindows[0].PeriodBegins != "2026-05-29 at 8:00 AM" || precip.PrecipitationWindows[0].PeriodEnds != "2026-05-29 at 9:00 AM" {
t.Fatalf("first precip window = %#v, want first selected hour only", precip.PrecipitationWindows[0])
}
if precip.PrecipitationWindows[1].PeriodBegins != "2026-05-29 at 10:00 AM" || precip.PrecipitationWindows[1].PeriodEnds != "2026-05-29 at 12:00 PM" {
t.Fatalf("second precip window = %#v, want late-morning near-term rain", precip.PrecipitationWindows[1])
}
alerts, ok, err := module.StanzaValue[briefing.AlertDigestModule](result.ModuleSnapshot, "alert_digest")
if err != nil {
t.Fatalf("decode alert digest: %v", err)
}
if !ok || !alerts.Checked || alerts.ActiveCount != 3 || alerts.RelevantCount != 1 || len(alerts.Relevant) != 1 || alerts.Relevant[0].Event != "Flood Watch" {
t.Fatalf("alert digest = %#v, want only near-term alert overlap relevant", alerts)
}
outlooks, ok, err := module.StanzaValue[briefing.SPCConvectiveOutlooksModule](result.ModuleSnapshot, "spc_convective_outlooks")
if err != nil {
t.Fatalf("decode SPC outlooks: %v", err)
}
if !ok || !outlooks.Checked || outlooks.OutlookCount != 1 || len(outlooks.Outlooks) != 1 || outlooks.Outlooks[0].Label != "SLGT" {
t.Fatalf("SPC outlooks = %#v, want one overlapping near-term outlook", outlooks)
}
discussion, ok, err := module.StanzaValue[briefing.SPCConvectiveDiscussionModule](result.ModuleSnapshot, "spc_convective_discussion")
if err != nil {
t.Fatalf("decode SPC discussion: %v", err)
}
if !ok || len(discussion.Discussions) != 1 || discussion.Discussions[0].Headline != "Near-term severe storms" {
t.Fatalf("SPC discussion = %#v, want discussion for retained overlapping outlook", discussion)
}
afd, ok, err := module.StanzaValue[briefing.AreaForecastDiscussionModule](result.ModuleSnapshot, "area_forecast_discussion")
if err != nil {
t.Fatalf("decode AFD: %v", err)
}
if !ok || len(afd.KeyMessages) != 1 || afd.ShortTerm != "Short-term AFD narrative for near-term report." {
t.Fatalf("AFD = %#v, want key messages and short term", afd)
}
if afd.Product != "" || afd.LongTerm != "" {
t.Fatalf("AFD = %#v, want near-term defaults to omit product and long term", afd)
}
if result.PriorSnapshot != nil || len(result.RecentChanges) != 0 || len(result.DataPackage.RecentChanges.Items) != 0 {
t.Fatalf("prior=%#v recent=%#v package=%#v, want no rolling-window comparison output", result.PriorSnapshot, result.RecentChanges, result.DataPackage.RecentChanges.Items)
}
data := readDataPackageForTest(t, result)
text := string(data)
for _, want := range []string{
"id: near_term",
"prompt_id: weather.near_term_report",
"valid_period:",
"recent_changes:",
" items: []",
" alert_digest:",
" spc_convective_outlooks:",
" precip_timing:",
" area_forecast_discussion:",
" spc_convective_discussion:",
" weather_story:",
" current_conditions:",
" hourly_forecast:",
} {
if !strings.Contains(text, want) {
t.Fatalf("data package missing %q:\n%s", want, text)
}
}
for _, omitted := range []string{"validPeriod:", "Before-window storms", "After-window rain", "Evening Advisory", "Day 2 outlook", "Long-term AFD narrative for near-term report."} {
if strings.Contains(text, omitted) {
t.Fatalf("data package contains %q, want near-term filtered/canonical output:\n%s", omitted, text)
}
}
riskIndex := strings.Index(text, " applicable_risk_products:")
derivedIndex := strings.Index(text, " derived_summaries:")
narrativeIndex := strings.Index(text, " narrative_products:")
rawIndex := strings.Index(text, " raw_data:")
alertIndex := strings.Index(text, " alert_digest:")
precipIndex := strings.Index(text, " precip_timing:")
afdIndex := strings.Index(text, " area_forecast_discussion:")
currentIndex := strings.Index(text, " current_conditions:")
hourlyIndex := strings.Index(text, " hourly_forecast:")
if riskIndex < 0 || derivedIndex < 0 || narrativeIndex < 0 || rawIndex < 0 || alertIndex < 0 || precipIndex < 0 || afdIndex < 0 || currentIndex < 0 || hourlyIndex < 0 ||
!(riskIndex < alertIndex && alertIndex < derivedIndex && derivedIndex < precipIndex && precipIndex < narrativeIndex && narrativeIndex < afdIndex && afdIndex < rawIndex && rawIndex < currentIndex && currentIndex < hourlyIndex) {
t.Fatalf("data package category order is wrong:\n%s", text)
}
loaded, err := promptinput.LoadYAML(data)
if err != nil {
t.Fatalf("LoadYAML() error = %v", err)
}
assertNoStaleModuleIntervalKeys(t, loaded.Briefing.Values)
}
func TestGenerateReportDisabledNotificationDoesNotCallNotifier(t *testing.T) { func TestGenerateReportDisabledNotificationDoesNotCallNotifier(t *testing.T) {
server := dailyBundleServer(t) server := dailyBundleServer(t)
cfg := dailyTestConfig(t, server) cfg := dailyTestConfig(t, server)
@@ -1057,23 +1343,59 @@ func TestInspectMissingMetadata(t *testing.T) {
func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) { func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
cfg := config.Defaults() cfg := config.Defaults()
cfg.WeatherAPI.Timezone = "America/Chicago" cfg.WeatherAPI.Timezone = "America/Chicago"
now := mustParse("2026-05-29T18:00:00-05:00") now := mustParse("2026-05-29T08:00:00-05:00")
resolved, err := ResolveGenerate(GenerateRequest{ tests := []struct {
Config: cfg, name string
Report: ReportTomorrow, kind ReportKind
}, now) wantID report.ID
if err != nil { wantPrompt string
t.Fatalf("ResolveGenerate() error = %v", err) wantStart string
wantEnd string
requestDate time.Time
}{
{
name: "tomorrow",
kind: ReportTomorrow,
wantID: report.DailyTomorrow,
wantPrompt: "weather.daily_report",
wantStart: "2026-05-30T00:00:00-05:00",
wantEnd: "2026-05-31T00:00:00-05:00",
requestDate: time.Time{},
},
{
name: "near-term",
kind: ReportNearTerm,
wantID: report.NearTerm,
wantPrompt: "weather.near_term_report",
wantStart: "2026-05-29T08:00:00-05:00",
wantEnd: "2026-05-29T14:00:00-05:00",
},
} }
if resolved.Definition.ID != report.DailyTomorrow {
t.Fatalf("ID = %q, want daily_tomorrow", resolved.Definition.ID) for _, tt := range tests {
} t.Run(tt.name, func(t *testing.T) {
if resolved.Definition.PromptID != "weather.daily_report" { resolved, err := ResolveGenerate(GenerateRequest{
t.Fatalf("PromptID = %q, want weather.daily_report", resolved.Definition.PromptID) Config: cfg,
} Report: tt.kind,
if got := resolved.ValidPeriod.Start.Format("2006-01-02"); got != "2026-05-30" { Date: tt.requestDate,
t.Fatalf("valid start date = %s, want 2026-05-30", got) }, now)
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
if resolved.Definition.ID != tt.wantID {
t.Fatalf("ID = %q, want %q", resolved.Definition.ID, tt.wantID)
}
if resolved.Definition.PromptID != tt.wantPrompt {
t.Fatalf("PromptID = %q, want %q", resolved.Definition.PromptID, tt.wantPrompt)
}
if got := resolved.ValidPeriod.Start.Format(time.RFC3339); got != tt.wantStart {
t.Fatalf("valid start = %s, want %s", got, tt.wantStart)
}
if got := resolved.ValidPeriod.End.Format(time.RFC3339); got != tt.wantEnd {
t.Fatalf("valid end = %s, want %s", got, tt.wantEnd)
}
})
} }
} }
@@ -1116,7 +1438,29 @@ func moduleIDsForTest(ids []module.ID) []string {
return out return out
} }
func snapshotModuleIDs(snapshot module.Snapshot) []module.ID {
ids := make([]module.ID, 0, len(snapshot.Outputs))
for _, output := range snapshot.Outputs {
ids = append(ids, output.ID)
}
return ids
}
func mustMarshalString(t *testing.T, value any) string {
t.Helper()
data, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal value: %v", err)
}
return string(data)
}
func dailyBundleServer(t *testing.T) *httptest.Server { func dailyBundleServer(t *testing.T) *httptest.Server {
t.Helper()
return dailyBundleServerWithConvectiveResponse(t, emptyConvectiveOutlooksResponse)
}
func dailyBundleServerWithConvectiveResponse(t *testing.T, convectiveResponse string) *httptest.Server {
t.Helper() t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { switch r.URL.Path {
@@ -1134,6 +1478,8 @@ func dailyBundleServer(t *testing.T) *httptest.Server {
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for generated report."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for generated report."}}}`)) _, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for generated report."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for generated report."}}}`))
case "/weatherstories/latest": case "/weatherstories/latest":
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`)) _, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
case "/outlooks/convective":
_, _ = w.Write([]byte(convectiveResponse))
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@@ -1142,6 +1488,40 @@ func dailyBundleServer(t *testing.T) *httptest.Server {
return server return server
} }
func nearTermBundleServer(t *testing.T) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/observations":
_, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T13:20:00Z","conditionCode":3}}`))
case "/conditions/current":
_, _ = w.Write([]byte(`{"data":{"conditionText":"Cloudy","temperatureF":72,"relativeHumidityPercent":70,"windSpeedMph":9}}`))
case "/forecast/hourly":
_, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T08:00:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T07:30:00-05:00","endTime":"2026-05-29T08:30:00-05:00","textDescription":"Before-window storms","temperatureF":68,"probabilityOfPrecipitationPercent":90},{"startTime":"2026-05-29T08:00:00-05:00","endTime":"2026-05-29T09:00:00-05:00","textDescription":"Showers entering the area","temperatureF":70,"probabilityOfPrecipitationPercent":50},{"startTime":"2026-05-29T09:00:00-05:00","endTime":"2026-05-29T10:00:00-05:00","textDescription":"Brief dry break","temperatureF":72,"probabilityOfPrecipitationPercent":20},{"startTime":"2026-05-29T10:00:00-05:00","endTime":"2026-05-29T11:00:00-05:00","textDescription":"Thunderstorms increase","temperatureF":73,"probabilityOfPrecipitationPercent":80},{"startTime":"2026-05-29T11:00:00-05:00","endTime":"2026-05-29T12:00:00-05:00","textDescription":"Heavy rain","temperatureF":74,"probabilityOfPrecipitationPercent":70},{"startTime":"2026-05-29T13:00:00-05:00","endTime":"2026-05-29T14:00:00-05:00","textDescription":"Drying out","temperatureF":76,"probabilityOfPrecipitationPercent":10},{"startTime":"2026-05-29T14:30:00-05:00","endTime":"2026-05-29T15:30:00-05:00","textDescription":"After-window rain","temperatureF":77,"probabilityOfPrecipitationPercent":60}]}}`))
case "/forecast/narrative":
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T08:00:00-05:00","product":"narrative","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T18:00:00-05:00","textDescription":"Storms are possible today."}]}}`))
case "/alerts/active":
_, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Expired Advisory","headline":"Ends at valid start","severity":"Minor","effective":"2026-05-29T06:00:00-05:00","expires":"2026-05-29T08:30:00-05:00"},{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T11:00:00-05:00","expires":"2026-05-29T15:00:00-05:00"},{"event":"Evening Advisory","headline":"Starts at valid end","severity":"Minor","effective":"2026-05-29T14:30:00-05:00","expires":"2026-05-29T18:00:00-05:00"}]}}`))
case "/discussion":
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T08:05:00-05:00","keyMessages":["Storms are most likely late this morning."],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for near-term report."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for near-term report."}}}`))
case "/weatherstories/latest":
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-29T13:00:00Z","endTime":"2026-05-29T20:00:00Z","updatedAt":"2026-05-29T13:05:00Z","title":"Near-Term Storm Chances","description":"Scattered showers and thunderstorms are possible.","altText":"Weather story graphic with rain chances.","priority":true,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/near-term"}}`))
case "/outlooks/convective":
_, _ = w.Write([]byte(`{"data":{"locationId":"home","locationName":"Brentwood","asOf":"2026-05-29T13:30:00Z","issuedAt":"2026-05-29T13:00:00Z","outlooks":[{"id":"day1-near-term","day":1,"outlookType":"categorical","label":"SLGT","labelText":"Slight Risk","severityRank":3,"validFrom":"2026-05-29T10:00:00-05:00","validTo":"2026-05-29T16:00:00-05:00","issuedAt":"2026-05-29T08:00:00-05:00","containsLocation":true},{"id":"day2-outside","day":2,"outlookType":"categorical","label":"ENH","labelText":"Day 2 outlook","severityRank":4,"validFrom":"2026-05-30T10:00:00-05:00","validTo":"2026-05-30T16:00:00-05:00","issuedAt":"2026-05-29T08:00:00-05:00","containsLocation":true}],"discussions":[{"day":1,"headline":"Near-term severe storms","summary":"Scattered severe storms are possible.","discussion":"Damaging winds may occur during the near-term window.","updatedAt":"2026-05-29T08:15:00-05:00"},{"day":2,"headline":"Day 2 discussion","summary":"Later period risk.","discussion":"This day 2 discussion should not be retained.","updatedAt":"2026-05-29T08:20:00-05:00"}]}}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(server.Close)
return server
}
const emptyConvectiveOutlooksResponse = `{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`
const qualifyingConvectiveOutlooksResponse = `{"data":{"locationId":"home","locationName":"Brentwood","asOf":"2026-05-29T16:00:00Z","issuedAt":"2026-05-29T15:45:00Z","updatedAt":"2026-05-29T16:05:00Z","outlooks":[{"id":"day1-categorical","day":1,"outlookType":"categorical","label":"SLGT","labelText":"Slight Risk","severityRank":3,"validFrom":"2026-05-29T11:00:00-05:00","validTo":"2026-05-30T07:00:00-05:00","issuedAt":"2026-05-29T10:45:00-05:00","expiresAt":"2026-05-30T07:00:00-05:00","containsLocation":true,"sourceUrl":"https://www.spc.noaa.gov/products/outlook/day1otlk.html","imageUrl":"https://www.spc.noaa.gov/products/outlook/day1probotlk.gif","geometry":{"type":"Polygon","coordinates":[[[-91.0,38.0],[-90.0,38.0],[-90.0,39.0],[-91.0,39.0],[-91.0,38.0]]]}}],"discussions":[{"day":1,"headline":"Severe storms possible","summary":"Scattered severe storms are possible.","discussion":"Severe thunderstorms may produce damaging winds during the afternoon.","updatedAt":"2026-05-29T11:15:00-05:00"}]}}`
const lowerRiskConvectiveOutlooksResponse = `{"data":{"locationId":"home","locationName":"Brentwood","asOf":"2026-05-29T16:00:00Z","issuedAt":"2026-05-29T15:45:00Z","outlooks":[{"id":"day1-categorical","day":1,"outlookType":"categorical","label":"MRGL","labelText":"Marginal Risk","severityRank":2,"validFrom":"2026-05-29T11:00:00-05:00","validTo":"2026-05-30T07:00:00-05:00","containsLocation":true,"geometry":{"type":"Polygon","coordinates":[[[-91.0,38.0],[-90.0,38.0],[-90.0,39.0],[-91.0,39.0],[-91.0,38.0]]]}}],"discussions":[{"day":1,"headline":"Low-end severe threat","summary":"An isolated severe storm cannot be ruled out.","discussion":"Low-end severe threat discussion.","updatedAt":"2026-05-29T11:15:00-05:00"}]}}`
func TestResolveGenerateStorm(t *testing.T) { func TestResolveGenerateStorm(t *testing.T) {
cfg := config.Defaults() cfg := config.Defaults()
cfg.WeatherAPI.Timezone = "America/Chicago" cfg.WeatherAPI.Timezone = "America/Chicago"
@@ -1335,6 +1715,65 @@ func dailyTestConfig(t *testing.T, server *httptest.Server) config.Config {
return cfg return cfg
} }
func generateDailyReportForTest(t *testing.T, cfg config.Config) *ReportResult {
t.Helper()
cfg.Workspace.Root = t.TempDir()
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, mustParse("2026-05-29T05:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: resolved,
Renderer: successfulRenderer("# Daily Report\n"),
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
return result
}
func readDataPackageForTest(t *testing.T, result *ReportResult) []byte {
t.Helper()
data, err := os.ReadFile(result.DataPackagePath)
if err != nil {
t.Fatalf("read data package: %v", err)
}
return data
}
func assertNoStaleModuleIntervalKeys(t *testing.T, values map[string]any) {
t.Helper()
for name, value := range values {
if name == "metadata" {
continue
}
assertNoStaleIntervalKeys(t, "briefing."+name, value)
}
}
func assertNoStaleIntervalKeys(t *testing.T, path string, value any) {
t.Helper()
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
switch key {
case "start_time", "end_time", "period", "start", "end":
t.Fatalf("%s has stale interval key %q in %#v", path, key, typed)
}
assertNoStaleIntervalKeys(t, path+"."+key, child)
}
case []any:
for i, child := range typed {
assertNoStaleIntervalKeys(t, fmt.Sprintf("%s[%d]", path, i), child)
}
}
}
func assertPathsExist(t *testing.T, paths ...string) { func assertPathsExist(t *testing.T, paths ...string) {
t.Helper() t.Helper()
for _, path := range paths { for _, path := range paths {
@@ -1380,9 +1819,10 @@ func priorDailyModuleSnapshot(t *testing.T, resolved report.Resolved) module.Sna
}}, }},
{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{ {ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{
"morning": map[string]any{ "morning": map[string]any{
"date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout), "date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout),
"period": resolved.ValidPeriod.Start.Add(6*time.Hour).Format("2006-01-02 at 3:04 PM") + " to " + resolved.ValidPeriod.Start.Add(10*time.Hour).Format("2006-01-02 at 3:04 PM"), "period_begins": resolved.ValidPeriod.Start.Add(6 * time.Hour).Format("2006-01-02 at 3:04 PM"),
"temp_range_f": "50-58", "period_ends": resolved.ValidPeriod.Start.Add(10 * time.Hour).Format("2006-01-02 at 3:04 PM"),
"temp_range_f": "50-58",
}, },
}}, }},
{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: map[string]any{ {ID: module.PrecipTiming, StanzaName: "precip_timing", Value: map[string]any{
@@ -1404,7 +1844,8 @@ func priorOutlookModuleSnapshot(t *testing.T, date string) module.Snapshot {
{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{ {ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{
date + "_morning": map[string]any{ date + "_morning": map[string]any{
"date": date, "date": date,
"period": date + " at 6:00 AM to " + date + " at 10:00 AM", "period_begins": date + " at 6:00 AM",
"period_ends": date + " at 10:00 AM",
"temp_range_f": "50-58", "temp_range_f": "50-58",
"max_pop_percent": precip, "max_pop_percent": precip,
"max_pop_time": "6 AM", "max_pop_time": "6 AM",

View File

@@ -62,8 +62,8 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
if period.TextDescription != "Showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 { if period.TextDescription != "Showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 {
t.Fatalf("HourlyForecast period = %#v, want hourly period facts", period) t.Fatalf("HourlyForecast period = %#v, want hourly period facts", period)
} }
if period.StartTime != "2026-05-29 at 8:00 AM" || period.EndTime != "2026-05-29 at 9:00 AM" { if period.PeriodBegins != "2026-05-29 at 8:00 AM" || period.PeriodEnds != "2026-05-29 at 9:00 AM" {
t.Fatalf("HourlyForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime) t.Fatalf("HourlyForecast period times = %q/%q, want friendly local time labels", period.PeriodBegins, period.PeriodEnds)
} }
if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 { if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
t.Fatalf("HourlyForecast period = %#v, want compass wind and precip chance", period) t.Fatalf("HourlyForecast period = %#v, want compass wind and precip chance", period)
@@ -73,7 +73,7 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
t.Fatalf("Marshal hourly forecast: %v", err) t.Fatalf("Marshal hourly forecast: %v", err)
} }
jsonText := string(data) jsonText := string(data)
for _, field := range []string{"source_location_id", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} { for _, field := range []string{"source_location_id", "period_begins", "period_ends", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} {
if !strings.Contains(jsonText, field) { if !strings.Contains(jsonText, field) {
t.Fatalf("hourly json = %s, want field %s", jsonText, field) t.Fatalf("hourly json = %s, want field %s", jsonText, field)
} }
@@ -81,8 +81,8 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
if strings.Contains(jsonText, "wind_direction_degrees") || strings.Contains(jsonText, "Tomorrow") { if strings.Contains(jsonText, "wind_direction_degrees") || strings.Contains(jsonText, "Tomorrow") {
t.Fatalf("hourly json = %s, want valid-period prompt fields only", jsonText) t.Fatalf("hourly json = %s, want valid-period prompt fields only", jsonText)
} }
if strings.Contains(jsonText, `"start_time":"2026-05-29T`) || strings.Contains(jsonText, `"end_time":"2026-05-29T`) { if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) {
t.Fatalf("hourly json = %s, want friendly local start/end times", jsonText) t.Fatalf("hourly json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText)
} }
} }
@@ -97,6 +97,21 @@ func TestHourlyForecastModuleRejectsUnsupportedReports(t *testing.T) {
} }
} }
func TestHourlyForecastModuleBuildsForNearTerm(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.NearTerm)
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[HourlyForecastModule](t, output)
if len(value.Periods) != 1 || value.Periods[0].TextDescription != "Showers likely." {
t.Fatalf("HourlyForecast = %#v, want near-term hourly period", value)
}
}
func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) { func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := testModuleContext() ctx := testModuleContext()
@@ -113,8 +128,8 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
if period.Name != "Today" || period.TextDescription != "Morning storms, then partly sunny." { if period.Name != "Today" || period.TextDescription != "Morning storms, then partly sunny." {
t.Fatalf("NarrativeForecast period = %#v, want Today narrative", period) t.Fatalf("NarrativeForecast period = %#v, want Today narrative", period)
} }
if period.StartTime != "2026-05-29 at 6:00 AM" || period.EndTime != "2026-05-29 at 6:00 PM" { if period.PeriodBegins != "2026-05-29 at 6:00 AM" || period.PeriodEnds != "2026-05-29 at 6:00 PM" {
t.Fatalf("NarrativeForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime) t.Fatalf("NarrativeForecast period times = %q/%q, want friendly local time labels", period.PeriodBegins, period.PeriodEnds)
} }
if period.IsDay == nil || !*period.IsDay || period.TemperatureF == nil || *period.TemperatureF != 81 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 60 { if period.IsDay == nil || !*period.IsDay || period.TemperatureF == nil || *period.TemperatureF != 81 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 60 {
t.Fatalf("NarrativeForecast period = %#v, want day, temperature, and precip values", period) t.Fatalf("NarrativeForecast period = %#v, want day, temperature, and precip values", period)
@@ -127,7 +142,7 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
t.Fatalf("Marshal narrative forecast: %v", err) t.Fatalf("Marshal narrative forecast: %v", err)
} }
jsonText := string(data) jsonText := string(data)
for _, field := range []string{"source_location_id", "text_description", "temperature_f", "wind_speed_mph", "wind_direction", "probability_of_precipitation_percent"} { for _, field := range []string{"source_location_id", "period_begins", "period_ends", "text_description", "temperature_f", "wind_speed_mph", "wind_direction", "probability_of_precipitation_percent"} {
if !strings.Contains(jsonText, field) { if !strings.Contains(jsonText, field) {
t.Fatalf("narrative json = %s, want field %s", jsonText, field) t.Fatalf("narrative json = %s, want field %s", jsonText, field)
} }
@@ -135,8 +150,8 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
if strings.Contains(jsonText, "wind_direction_degrees") { if strings.Contains(jsonText, "wind_direction_degrees") {
t.Fatalf("narrative json = %s, want compass wind_direction without degrees field", jsonText) t.Fatalf("narrative json = %s, want compass wind_direction without degrees field", jsonText)
} }
if strings.Contains(jsonText, `"start_time":"2026-05-29T`) || strings.Contains(jsonText, `"end_time":"2026-05-29T`) { if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) {
t.Fatalf("narrative json = %s, want friendly local start/end times", jsonText) t.Fatalf("narrative json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText)
} }
if strings.Contains(jsonText, "Tomorrow night") { if strings.Contains(jsonText, "Tomorrow night") {
t.Fatalf("narrative json = %s, want only valid-period narrative periods", jsonText) t.Fatalf("narrative json = %s, want only valid-period narrative periods", jsonText)
@@ -285,13 +300,19 @@ func TestAreaForecastDiscussionAndWeatherStoryModules(t *testing.T) {
if !story.Available || story.Title != "Rain Chances" || story.Description != "Scattered showers are possible." { if !story.Available || story.Title != "Rain Chances" || story.Description != "Scattered showers are possible." {
t.Fatalf("WeatherStory = %#v, want structured story fields", story) t.Fatalf("WeatherStory = %#v, want structured story fields", story)
} }
if story.PeriodBegins != "2026-05-29 at 6:00 AM" || story.PeriodEnds != "2026-05-29 at 6:00 PM" {
t.Fatalf("WeatherStory period = %q/%q, want friendly local period labels", story.PeriodBegins, story.PeriodEnds)
}
data, err := json.Marshal(storyOutput.Value) data, err := json.Marshal(storyOutput.Value)
if err != nil { if err != nil {
t.Fatalf("Marshal weather story: %v", err) t.Fatalf("Marshal weather story: %v", err)
} }
if !strings.Contains(string(data), "download_url") { if !strings.Contains(string(data), "download_url") || !strings.Contains(string(data), "period_begins") || !strings.Contains(string(data), "period_ends") {
t.Fatalf("weather story json = %s, want snake_case download_url", string(data)) t.Fatalf("weather story json = %s, want snake_case download_url", string(data))
} }
if strings.Contains(string(data), "start_time") || strings.Contains(string(data), "end_time") {
t.Fatalf("weather story json = %s, want period_begins/period_ends instead of start_time/end_time", string(data))
}
} }
func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) { func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
@@ -314,6 +335,34 @@ func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
} }
} }
func TestAreaForecastDiscussionModuleUsesNearTermDefaultSections(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.NearTerm)
var item module.ConfigItem
for _, candidate := range ctx.Resolved.Definition.Modules {
if candidate.ID == module.AreaForecastDiscussion {
item = candidate
break
}
}
if item.ID == "" {
t.Fatal("near-term default modules missing area_forecast_discussion")
}
output, err := registry.BuildModule(ctx, item)
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
afd := moduleValue[AreaForecastDiscussionModule](t, output)
if len(afd.KeyMessages) != 1 || afd.ShortTerm != "Showers increase this afternoon." {
t.Fatalf("AFD = %#v, want key messages and short term", afd)
}
if afd.Product != "" || afd.LongTerm != "" {
t.Fatalf("AFD = %#v, want product and long term omitted", afd)
}
}
func testModuleContext() ModuleContext { func testModuleContext() ModuleContext {
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00") generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
definition := report.DefaultRegistry().MustLookup(report.DailyToday) definition := report.DefaultRegistry().MustLookup(report.DailyToday)

View File

@@ -12,7 +12,8 @@ import (
type DerivedDaypartSummaryModule struct { type DerivedDaypartSummaryModule struct {
Date string `json:"date,omitempty"` Date string `json:"date,omitempty"`
Period string `json:"period,omitempty"` PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
TempRangeF string `json:"temp_range_f,omitempty"` TempRangeF string `json:"temp_range_f,omitempty"`
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"` ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"` MaxPopPercent *int `json:"max_pop_percent,omitempty"`
@@ -46,7 +47,8 @@ func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Outpu
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule { func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
value := DerivedDaypartSummaryModule{ value := DerivedDaypartSummaryModule{
Date: localDateLabel(daypart.Period.Start, timezone), Date: localDateLabel(daypart.Period.Start, timezone),
Period: friendlyPeriodLabel(daypart.Period, timezone), PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone),
PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone),
TempRangeF: rangeLabel(daypart.Temperature), TempRangeF: rangeLabel(daypart.Temperature),
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature), ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
DominantCondition: daypart.DominantCondition, DominantCondition: daypart.DominantCondition,

View File

@@ -102,10 +102,10 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
if len(rainy.PrecipitationWindows) != 2 { if len(rainy.PrecipitationWindows) != 2 {
t.Fatalf("rainy precipitation windows = %#v, want two windows", rainy.PrecipitationWindows) t.Fatalf("rainy precipitation windows = %#v, want two windows", rainy.PrecipitationWindows)
} }
if rainy.PrecipitationWindows[0].Start != "8 AM" || rainy.PrecipitationWindows[0].End != "9 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 { if rainy.PrecipitationWindows[0].PeriodBegins != "2026-05-29 at 8:00 AM" || rainy.PrecipitationWindows[0].PeriodEnds != "2026-05-29 at 9:00 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 {
t.Fatalf("first precipitation window = %#v, want 8-9 AM at 60%%", rainy.PrecipitationWindows[0]) t.Fatalf("first precipitation window = %#v, want 8-9 AM at 60%%", rainy.PrecipitationWindows[0])
} }
if rainy.PrecipitationWindows[1].Start != "12 PM" || rainy.PrecipitationWindows[1].End != "2 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 { if rainy.PrecipitationWindows[1].PeriodBegins != "2026-05-29 at 12:00 PM" || rainy.PrecipitationWindows[1].PeriodEnds != "2026-05-29 at 2:00 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 {
t.Fatalf("second precipitation window = %#v, want noon-2 PM at 80%%", rainy.PrecipitationWindows[1]) t.Fatalf("second precipitation window = %#v, want noon-2 PM at 80%%", rainy.PrecipitationWindows[1])
} }
data, err := json.Marshal(output.Value) data, err := json.Marshal(output.Value)
@@ -115,6 +115,9 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
if !strings.Contains(string(data), "precipitation_windows") || !strings.Contains(string(data), "probability_threshold") { if !strings.Contains(string(data), "precipitation_windows") || !strings.Contains(string(data), "probability_threshold") {
t.Fatalf("precip timing json = %s, want threshold and windows", string(data)) t.Fatalf("precip timing json = %s, want threshold and windows", string(data))
} }
if strings.Contains(string(data), `"start"`) || strings.Contains(string(data), `"end"`) {
t.Fatalf("precip timing json = %s, want period_begins/period_ends instead of start/end", string(data))
}
if strings.Contains(string(data), "first_precip_hour") || strings.Contains(string(data), "last_precip_hour") { if strings.Contains(string(data), "first_precip_hour") || strings.Contains(string(data), "last_precip_hour") {
t.Fatalf("precip timing json = %s, want no ambiguous first/last fields", string(data)) t.Fatalf("precip timing json = %s, want no ambiguous first/last fields", string(data))
} }
@@ -133,6 +136,22 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
} }
} }
func TestPrecipTimingModuleUsesDerivedTimingWithoutDaypartSummaries(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.NearTerm)
ctx.Derived.DailySummaries = nil
ctx.Derived.DaypartSummaries = nil
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[PrecipTimingModule](t, output)
if value.MaxPopPercent == nil || *value.MaxPopPercent != 80 || len(value.PrecipitationWindows) != 2 {
t.Fatalf("precip timing = %#v, want derived timing without daypart summaries", value)
}
}
func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) { func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday) ctx := derivedModuleContext(report.DailyToday)
@@ -150,8 +169,8 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 { if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 {
t.Fatalf("morning = %#v, want temp range and precip peak", morning) t.Fatalf("morning = %#v, want temp range and precip peak", morning)
} }
if morning.Date != "2026-05-29" || morning.Period != "2026-05-29 at 6:00 AM to 2026-05-29 at 12:00 PM" { if morning.Date != "2026-05-29" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" {
t.Fatalf("morning period = %q/%q, want friendly local date and period labels", morning.Date, morning.Period) t.Fatalf("morning period = %q/%q/%q, want friendly local date and period labels", morning.Date, morning.PeriodBegins, morning.PeriodEnds)
} }
afternoon := value["afternoon"] afternoon := value["afternoon"]
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 { if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 {
@@ -166,12 +185,12 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
t.Fatalf("marshal daypart summaries: %v", err) t.Fatalf("marshal daypart summaries: %v", err)
} }
jsonText := string(data) jsonText := string(data)
for _, field := range []string{"date", "period", "temp_range_f", "max_pop_percent", "max_wind_gust_mph", "dominant_condition"} { for _, field := range []string{"date", "period_begins", "period_ends", "temp_range_f", "max_pop_percent", "max_wind_gust_mph", "dominant_condition"} {
if !strings.Contains(jsonText, field) { if !strings.Contains(jsonText, field) {
t.Fatalf("daypart json = %s, want field %s", jsonText, field) t.Fatalf("daypart json = %s, want field %s", jsonText, field)
} }
} }
if strings.Contains(jsonText, `"period":{"start"`) || strings.Contains(jsonText, `T06:00:00`) { if strings.Contains(jsonText, `"period":`) || strings.Contains(jsonText, `T06:00:00`) {
t.Fatalf("daypart json = %s, want friendly period label instead of raw timestamps", jsonText) t.Fatalf("daypart json = %s, want friendly period label instead of raw timestamps", jsonText)
} }
} }
@@ -191,6 +210,12 @@ func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing
if outdoor.Best.Daypart != "overnight" || outdoor.Worst.Daypart != "afternoon" { if outdoor.Best.Daypart != "overnight" || outdoor.Worst.Daypart != "afternoon" {
t.Fatalf("outdoor windows = %#v, want quiet overnight and stormy afternoon", outdoor) t.Fatalf("outdoor windows = %#v, want quiet overnight and stormy afternoon", outdoor)
} }
if outdoor.Best.PeriodBegins != "2026-05-29 at 12:00 AM" || outdoor.Best.PeriodEnds != "2026-05-29 at 6:00 AM" {
t.Fatalf("best outdoor period = %#v, want overnight period labels", outdoor.Best)
}
if outdoor.Worst.PeriodBegins != "2026-05-29 at 12:00 PM" || outdoor.Worst.PeriodEnds != "2026-05-29 at 6:00 PM" {
t.Fatalf("worst outdoor period = %#v, want afternoon period labels", outdoor.Worst)
}
planningOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TomorrowPlanning}) planningOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TomorrowPlanning})
if err != nil { if err != nil {

View File

@@ -4,6 +4,7 @@ import (
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
@@ -17,8 +18,8 @@ type HourlyForecastModule struct {
} }
type HourlyForecastPeriod struct { type HourlyForecastPeriod struct {
StartTime string `json:"start_time,omitempty"` PeriodBegins string `json:"period_begins,omitempty"`
EndTime string `json:"end_time,omitempty"` PeriodEnds string `json:"period_ends,omitempty"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
IsDay *bool `json:"is_day,omitempty"` IsDay *bool `json:"is_day,omitempty"`
ConditionCode *int `json:"condition_code,omitempty"` ConditionCode *int `json:"condition_code,omitempty"`
@@ -74,9 +75,10 @@ func buildHourlyForecastModule(ctx ModuleContext, _ any) (*module.Output, error)
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod { func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod {
out := make([]HourlyForecastPeriod, 0, len(periods)) out := make([]HourlyForecastPeriod, 0, len(periods))
for _, period := range periods { for _, period := range periods {
validPeriod := timeutil.Period{Start: period.StartTime, End: period.EndTime}
out = append(out, HourlyForecastPeriod{ out = append(out, HourlyForecastPeriod{
StartTime: friendlyDateTimeLabel(period.StartTime, timezone), PeriodBegins: friendlyPeriodBeginsLabel(validPeriod, timezone),
EndTime: friendlyDateTimeLabel(period.EndTime, timezone), PeriodEnds: friendlyPeriodEndsLabel(validPeriod, timezone),
Name: period.Name, Name: period.Name,
IsDay: copyBool(period.IsDay), IsDay: copyBool(period.IsDay),
ConditionCode: copyInt(period.ConditionCode), ConditionCode: copyInt(period.ConditionCode),

View File

@@ -67,18 +67,18 @@ func timedClockLabel(value *forecast.TimedValue, timezone string) string {
return clockLabel(value.Time, timezone) return clockLabel(value.Time, timezone)
} }
func periodClockLabel(period timeutil.Period, timezone string) string { func friendlyPeriodBeginsLabel(period timeutil.Period, timezone string) string {
if !period.IsValid() { if !period.IsValid() {
return "" return ""
} }
return clockLabel(period.Start, timezone) + "-" + clockLabel(period.End, timezone) return friendlyDateTimeLabel(period.Start, timezone)
} }
func friendlyPeriodLabel(period timeutil.Period, timezone string) string { func friendlyPeriodEndsLabel(period timeutil.Period, timezone string) string {
if !period.IsValid() { if !period.IsValid() {
return "" return ""
} }
return friendlyDateTimeLabel(period.Start, timezone) + " to " + friendlyDateTimeLabel(period.End, timezone) return friendlyDateTimeLabel(period.End, timezone)
} }
func friendlyDateTimeLabel(value time.Time, timezone string) string { func friendlyDateTimeLabel(value time.Time, timezone string) string {

View File

@@ -162,6 +162,8 @@ func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContex
return ctx.Collected.Discussion != nil return ctx.Collected.Discussion != nil
case module.CollectedWeatherStory: case module.CollectedWeatherStory:
return ctx.Collected.WeatherStory != nil return ctx.Collected.WeatherStory != nil
case module.CollectedSPCConvectiveOutlooks:
return ctx.Collected.SPCConvectiveOutlooks != nil
case module.CollectedSourceMetadata: case module.CollectedSourceMetadata:
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0 return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
default: default:
@@ -183,6 +185,8 @@ func derivedFactAvailable(requirement module.FactRequirement, ctx ModuleContext)
return len(ctx.Derived.DaypartSummaries) > 0 return len(ctx.Derived.DaypartSummaries) > 0
case module.RequiresDerivedPrecipTiming: case module.RequiresDerivedPrecipTiming:
return true return true
case module.RequiresDerivedSPCConvectiveOutlooks:
return ctx.Derived.SPCConvectiveOutlooks != nil
default: default:
return false return false
} }
@@ -245,7 +249,7 @@ func (d ModuleDefinition) ValidateOptions(options any) error {
} }
func defaultModuleDefinitions() []ModuleDefinition { func defaultModuleDefinitions() []ModuleDefinition {
allReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend, report.Storm} allReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.NearTerm, report.ThreeDay, report.Weekend, report.Storm}
daypartReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend} daypartReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend}
return []ModuleDefinition{ return []ModuleDefinition{
{ {
@@ -282,7 +286,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
DefaultOptions: module.HourlyForecastOptions{}, DefaultOptions: module.HourlyForecastOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast}, RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods}, RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow}, SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow, report.NearTerm},
MissingData: module.MissingDataOmit, MissingData: module.MissingDataOmit,
Builder: buildHourlyForecastModule, Builder: buildHourlyForecastModule,
}, },
@@ -308,7 +312,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.PrecipTiming, ID: module.PrecipTiming,
StanzaName: "precip_timing", StanzaName: "precip_timing",
DefaultOptions: module.PrecipTimingOptions{}, DefaultOptions: module.PrecipTimingOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries}, RequiredDerived: []module.FactRequirement{module.RequiresDerivedPrecipTiming},
SupportedReports: allReports, SupportedReports: allReports,
MissingData: module.MissingDataEmpty, MissingData: module.MissingDataEmpty,
Builder: buildPrecipTimingModule, Builder: buildPrecipTimingModule,
@@ -323,6 +327,16 @@ func defaultModuleDefinitions() []ModuleDefinition {
MissingData: module.MissingDataEmpty, MissingData: module.MissingDataEmpty,
Builder: buildAlertDigestModule, Builder: buildAlertDigestModule,
}, },
{
ID: module.SPCConvectiveOutlooks,
StanzaName: string(module.SPCConvectiveOutlooks),
DefaultOptions: module.SPCConvectiveOutlooksOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
SupportedReports: allReports,
MissingData: module.MissingDataEmpty,
Builder: buildSPCConvectiveOutlooksModule,
},
{ {
ID: module.AreaForecastDiscussion, ID: module.AreaForecastDiscussion,
StanzaName: "area_forecast_discussion", StanzaName: "area_forecast_discussion",
@@ -332,6 +346,16 @@ func defaultModuleDefinitions() []ModuleDefinition {
MissingData: module.MissingDataOmit, MissingData: module.MissingDataOmit,
Builder: buildAreaForecastDiscussionModule, Builder: buildAreaForecastDiscussionModule,
}, },
{
ID: module.SPCConvectiveDiscussion,
StanzaName: string(module.SPCConvectiveDiscussion),
DefaultOptions: module.SPCConvectiveDiscussionOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
SupportedReports: allReports,
MissingData: module.MissingDataOmit,
Builder: buildSPCConvectiveDiscussionModule,
},
{ {
ID: module.WeatherStory, ID: module.WeatherStory,
StanzaName: "weather_story", StanzaName: "weather_story",

View File

@@ -4,8 +4,10 @@ import (
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) { func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) {
@@ -52,6 +54,27 @@ func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
} }
} }
func TestNearTermDefaultModuleOptions(t *testing.T) {
definition := report.DefaultRegistry().MustLookup(report.NearTerm)
var found bool
for _, item := range definition.Modules {
if item.ID != module.AreaForecastDiscussion {
continue
}
found = true
options, ok := item.Options.(module.AreaForecastDiscussionOptions)
if !ok {
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", item.Options)
}
if strings.Join(options.Sections, ",") != "key_messages,short_term" {
t.Fatalf("AFD sections = %#v, want key messages and short term", options.Sections)
}
}
if !found {
t.Fatal("near-term default modules missing area_forecast_discussion")
}
}
func TestModuleRegistryRejectsUnknownModule(t *testing.T) { func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.ID("unknown")}}) err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.ID("unknown")}})
@@ -89,6 +112,24 @@ func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
} }
} }
func TestModuleRegistryRejectsNearTermIncompatibleModules(t *testing.T) {
registry := MustDefaultModuleRegistry()
for _, id := range []module.ID{
module.NarrativeForecast,
module.DerivedDailySummary,
module.DerivedDaypartSummaries,
module.OutdoorWindows,
module.TomorrowPlanning,
} {
t.Run(string(id), func(t *testing.T) {
err := registry.ValidateComposition(report.NearTerm, []module.ConfigItem{{ID: id}})
if err == nil || !strings.Contains(err.Error(), `not compatible with report "near_term"`) {
t.Fatalf("ValidateComposition() error = %v, want incompatible near-term module", err)
}
})
}
}
func TestModuleRegistryRejectsDefinitionsWithoutBuilders(t *testing.T) { func TestModuleRegistryRejectsDefinitionsWithoutBuilders(t *testing.T) {
_, err := NewModuleRegistry([]ModuleDefinition{ _, err := NewModuleRegistry([]ModuleDefinition{
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}}, {ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}},
@@ -128,6 +169,30 @@ func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
} }
} }
func TestSPCConvectiveOutlookCollectedRequirementAvailability(t *testing.T) {
ctx := ModuleContext{}
if collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
t.Fatal("collectedFactAvailable() = true, want false without source")
}
ctx.Collected = facts.CollectedFacts{SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{}}
if !collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
t.Fatal("collectedFactAvailable() = false, want true with checked source")
}
}
func TestSPCConvectiveOutlookDerivedRequirementAvailability(t *testing.T) {
ctx := ModuleContext{}
if derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
t.Fatal("derivedFactAvailable() = true, want false without derived outlooks")
}
ctx.Derived = facts.DerivedFacts{SPCConvectiveOutlooks: []weatherdata.ConvectiveOutlook{}}
if !derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
t.Fatal("derivedFactAvailable() = false, want true for checked empty derived outlooks")
}
}
func noopModuleBuilder(ModuleContext, any) (*module.Output, error) { func noopModuleBuilder(ModuleContext, any) (*module.Output, error) {
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil
} }

View File

@@ -4,6 +4,7 @@ import (
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
@@ -18,8 +19,8 @@ type NarrativeForecastModule struct {
type NarrativeForecastPeriod struct { type NarrativeForecastPeriod struct {
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
StartTime string `json:"start_time,omitempty"` PeriodBegins string `json:"period_begins,omitempty"`
EndTime string `json:"end_time,omitempty"` PeriodEnds string `json:"period_ends,omitempty"`
IsDay *bool `json:"is_day,omitempty"` IsDay *bool `json:"is_day,omitempty"`
TextDescription string `json:"text_description,omitempty"` TextDescription string `json:"text_description,omitempty"`
TemperatureC *float64 `json:"temperature_c,omitempty"` TemperatureC *float64 `json:"temperature_c,omitempty"`
@@ -58,10 +59,11 @@ func buildNarrativeForecastModule(ctx ModuleContext, _ any) (*module.Output, err
func narrativeForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []NarrativeForecastPeriod { func narrativeForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []NarrativeForecastPeriod {
out := make([]NarrativeForecastPeriod, 0, len(periods)) out := make([]NarrativeForecastPeriod, 0, len(periods))
for _, period := range periods { for _, period := range periods {
validPeriod := timeutil.Period{Start: period.StartTime, End: period.EndTime}
out = append(out, NarrativeForecastPeriod{ out = append(out, NarrativeForecastPeriod{
Name: period.Name, Name: period.Name,
StartTime: friendlyDateTimeLabel(period.StartTime, timezone), PeriodBegins: friendlyPeriodBeginsLabel(validPeriod, timezone),
EndTime: friendlyDateTimeLabel(period.EndTime, timezone), PeriodEnds: friendlyPeriodEndsLabel(validPeriod, timezone),
IsDay: copyBool(period.IsDay), IsDay: copyBool(period.IsDay),
TextDescription: period.TextDescription, TextDescription: period.TextDescription,
TemperatureC: copyFloat(period.TemperatureC), TemperatureC: copyFloat(period.TemperatureC),

View File

@@ -8,31 +8,31 @@ type OutdoorWindowsModule struct {
} }
type OutdoorWindowModule struct { type OutdoorWindowModule struct {
Daypart string `json:"daypart"` Daypart string `json:"daypart"`
Start string `json:"start"` PeriodBegins string `json:"period_begins,omitempty"`
End string `json:"end"` PeriodEnds string `json:"period_ends,omitempty"`
Reasons []string `json:"reasons,omitempty"` Reasons []string `json:"reasons,omitempty"`
Score float64 `json:"score"` Score float64 `json:"score"`
} }
func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) { func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) {
windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries) windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries)
value := OutdoorWindowsModule{ value := OutdoorWindowsModule{
Best: outdoorWindowValue(windows.Best), Best: outdoorWindowValue(windows.Best, ctx.Timezone),
Worst: outdoorWindowValue(windows.Worst), Worst: outdoorWindowValue(windows.Worst, ctx.Timezone),
} }
return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil
} }
func outdoorWindowValue(window *OutdoorWindow) *OutdoorWindowModule { func outdoorWindowValue(window *OutdoorWindow, timezone string) *OutdoorWindowModule {
if window == nil { if window == nil {
return nil return nil
} }
return &OutdoorWindowModule{ return &OutdoorWindowModule{
Daypart: window.Daypart, Daypart: window.Daypart,
Start: window.Start, PeriodBegins: friendlyPeriodBeginsLabel(window.Period, timezone),
End: window.End, PeriodEnds: friendlyPeriodEndsLabel(window.Period, timezone),
Reasons: append([]string(nil), window.Reasons...), Reasons: append([]string(nil), window.Reasons...),
Score: window.Score, Score: window.Score,
} }
} }

View File

@@ -14,8 +14,8 @@ type PrecipTimingModule struct {
} }
type PrecipitationWindowModule struct { type PrecipitationWindowModule struct {
Start string `json:"start"` PeriodBegins string `json:"period_begins"`
End string `json:"end,omitempty"` PeriodEnds string `json:"period_ends,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"` MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"` MaxPopTime string `json:"max_pop_time,omitempty"`
} }
@@ -36,10 +36,10 @@ func precipTimingValue(timing forecast.PrecipTiming, timezone string) PrecipTimi
} }
for _, window := range timing.PrecipitationWindows { for _, window := range timing.PrecipitationWindows {
item := PrecipitationWindowModule{ item := PrecipitationWindowModule{
Start: clockLabel(window.Start, timezone), PeriodBegins: friendlyDateTimeLabel(window.Start, timezone),
} }
if window.End != nil { if window.End != nil {
item.End = clockLabel(*window.End, timezone) item.PeriodEnds = friendlyDateTimeLabel(*window.End, timezone)
} }
item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value) item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value)
item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone) item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone)

View File

@@ -0,0 +1,94 @@
package briefing
import (
"fmt"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
const defaultSPCConvectiveDiscussionMinimumSeverityRank = 3
const spcCategoricalOutlookType = "categorical"
type SPCConvectiveDiscussionModule struct {
IncludedBecause string `json:"included_because"`
Discussions []SPCConvectiveDiscussionRecord `json:"discussions"`
}
type SPCConvectiveDiscussionRecord struct {
Day int `json:"day,omitempty"`
PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
Headline string `json:"headline,omitempty"`
Summary string `json:"summary,omitempty"`
Discussion string `json:"discussion,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
func buildSPCConvectiveDiscussionModule(ctx ModuleContext, _ any) (*module.Output, error) {
qualifyingPeriods := spcConvectiveDiscussionQualifyingPeriods(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod)
if len(qualifyingPeriods) == 0 {
return nil, nil
}
records := spcConvectiveDiscussionRecords(qualifyingPeriods, ctx.Derived.SPCConvectiveDiscussions, ctx.Timezone)
if len(records) == 0 {
return nil, nil
}
value := SPCConvectiveDiscussionModule{
IncludedBecause: fmt.Sprintf("%s severity_rank >= %d", spcCategoricalOutlookType, defaultSPCConvectiveDiscussionMinimumSeverityRank),
Discussions: records,
}
return &module.Output{ID: module.SPCConvectiveDiscussion, StanzaName: string(module.SPCConvectiveDiscussion), Value: value}, nil
}
func spcConvectiveDiscussionQualifyingPeriods(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period) map[int]timeutil.Period {
periods := map[int]timeutil.Period{}
for _, outlook := range outlooks {
if outlook.OutlookType != spcCategoricalOutlookType {
continue
}
if outlook.SeverityRank == nil || *outlook.SeverityRank < defaultSPCConvectiveDiscussionMinimumSeverityRank {
continue
}
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(reportPeriod) {
continue
}
if existing, ok := periods[outlook.Day]; ok {
if outlookPeriod.Start.Before(existing.Start) {
existing.Start = outlookPeriod.Start
}
if outlookPeriod.End.After(existing.End) {
existing.End = outlookPeriod.End
}
periods[outlook.Day] = existing
continue
}
periods[outlook.Day] = outlookPeriod
}
return periods
}
func spcConvectiveDiscussionRecords(qualifyingPeriods map[int]timeutil.Period, discussions []weatherdata.ConvectiveOutlookDiscussion, timezone string) []SPCConvectiveDiscussionRecord {
records := make([]SPCConvectiveDiscussionRecord, 0, len(discussions))
for _, discussion := range discussions {
period, ok := qualifyingPeriods[discussion.Day]
if !ok {
continue
}
if discussion.Discussion == "" {
continue
}
records = append(records, SPCConvectiveDiscussionRecord{
Day: discussion.Day,
PeriodBegins: friendlyPeriodBeginsLabel(period, timezone),
PeriodEnds: friendlyPeriodEndsLabel(period, timezone),
Headline: discussion.Headline,
Summary: discussion.Summary,
Discussion: discussion.Discussion,
UpdatedAt: friendlyOptionalTime(discussion.UpdatedAt, timezone),
})
}
return records
}

View File

@@ -0,0 +1,317 @@
package briefing
import (
"encoding/json"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
func TestSPCConvectiveDiscussionModuleOmitsBelowThresholdButOutlookRemains(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := spcConvectiveDiscussionContext(2, []weatherdata.ConvectiveOutlookDiscussion{
spcDiscussion(1, "Lower risk", "General thunderstorms.", "No organized severe weather is expected.", "2026-05-29T08:30:00-05:00"),
})
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
if err != nil {
t.Fatalf("BuildModule(discussion) error = %v", err)
}
if output != nil {
t.Fatalf("discussion output = %#v, want omitted below threshold", output)
}
outlookOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
if err != nil {
t.Fatalf("BuildModule(outlooks) error = %v", err)
}
outlookValue := moduleValue[SPCConvectiveOutlooksModule](t, outlookOutput)
if !outlookValue.Checked || outlookValue.OutlookCount != 1 {
t.Fatalf("outlook value = %#v, want lower-risk outlook still emitted", outlookValue)
}
}
func TestSPCConvectiveDiscussionModuleIncludesEqualThresholdDiscussion(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := spcConvectiveDiscussionContext(3, []weatherdata.ConvectiveOutlookDiscussion{
spcDiscussion(1, "Severe storms possible", "Scattered severe storms are possible.", "A few storms may become severe during the afternoon.", "2026-05-29T08:30:00-05:00"),
})
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
if output == nil || output.ID != module.SPCConvectiveDiscussion || output.StanzaName != "spc_convective_discussion" {
t.Fatalf("output = %#v, want spc convective discussion stanza", output)
}
value := moduleValue[SPCConvectiveDiscussionModule](t, output)
if value.IncludedBecause != "categorical severity_rank >= 3" || len(value.Discussions) != 1 {
t.Fatalf("value = %#v, want threshold reason and one discussion", value)
}
discussion := value.Discussions[0]
if discussion.Day != 1 || discussion.Headline != "Severe storms possible" || discussion.Summary == "" || discussion.Discussion == "" {
t.Fatalf("discussion = %#v, want prompt-facing discussion fields", discussion)
}
if discussion.PeriodBegins != "2026-05-29 at 11:00 AM" || discussion.PeriodEnds != "2026-05-30 at 7:00 AM" {
t.Fatalf("Period = %q/%q, want qualifying outlook valid period", discussion.PeriodBegins, discussion.PeriodEnds)
}
if discussion.UpdatedAt != "2026-05-29 at 8:30 AM" {
t.Fatalf("UpdatedAt = %q, want friendly local time", discussion.UpdatedAt)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
text := string(data)
for _, field := range []string{"included_because", "discussions", "period_begins", "period_ends", "headline", "summary", "discussion", "updated_at"} {
if !strings.Contains(text, field) {
t.Fatalf("json = %s, want field %s", text, field)
}
}
if strings.Contains(text, `"period":`) {
t.Fatalf("json = %s, want period_begins/period_ends instead of period", text)
}
}
func TestSPCConvectiveDiscussionModuleIncludesAboveThresholdDiscussion(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := spcConvectiveDiscussionContext(4, []weatherdata.ConvectiveOutlookDiscussion{
spcDiscussion(1, "Enhanced severe risk", "Numerous severe storms are possible.", "Severe storms may produce damaging winds.", "2026-05-29T09:15:00-05:00"),
})
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[SPCConvectiveDiscussionModule](t, output)
if len(value.Discussions) != 1 || value.Discussions[0].Headline != "Enhanced severe risk" {
t.Fatalf("value = %#v, want above-threshold discussion", value)
}
}
func TestSPCConvectiveDiscussionModuleIgnoresHighRankNonCategoricalOutlook(t *testing.T) {
registry := MustDefaultModuleRegistry()
rank := 30
ctx := testModuleContext()
outlook := weatherdata.ConvectiveOutlook{
ID: "day1-wind-30",
Day: 1,
OutlookType: "wind",
Label: "30%",
LabelText: "30% Wind Risk",
SeverityRank: &rank,
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
}
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
}
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
spcDiscussion(1, "Wind risk discussion", "High wind probabilities.", "This discussion should not be emitted from wind severity rank.", "2026-05-29T08:30:00-05:00"),
}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
if output != nil {
t.Fatalf("output = %#v, want non-categorical outlook ignored for discussion threshold", output)
}
}
func TestSPCConvectiveDiscussionModuleRequiresCategoricalThresholdForMixedSameDayOutlooks(t *testing.T) {
registry := MustDefaultModuleRegistry()
rank2 := 2
rank30 := 30
ctx := testModuleContext()
categorical := weatherdata.ConvectiveOutlook{
ID: "day1-marginal",
Day: 1,
OutlookType: "categorical",
Label: "MRGL",
LabelText: "Marginal Risk",
SeverityRank: &rank2,
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
}
wind := weatherdata.ConvectiveOutlook{
ID: "day1-wind-30",
Day: 1,
OutlookType: "wind",
Label: "30%",
LabelText: "30% Wind Risk",
SeverityRank: &rank30,
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
}
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
Outlooks: []weatherdata.ConvectiveOutlook{categorical, wind},
}
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{categorical, wind}
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
spcDiscussion(1, "Mixed risk discussion", "Only wind is high.", "This discussion should not be emitted without categorical threshold.", "2026-05-29T08:30:00-05:00"),
}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
if output != nil {
t.Fatalf("output = %#v, want mixed day omitted when categorical outlook is below threshold", output)
}
}
func TestSPCConvectiveDiscussionModuleIncludesOnlyQualifyingDays(t *testing.T) {
registry := MustDefaultModuleRegistry()
rank2 := 2
rank4 := 4
ctx := testModuleContext()
ctx.Resolved.ValidPeriod.Start = mustParseModuleTime("2026-05-30T00:00:00-05:00")
ctx.Resolved.ValidPeriod.End = mustParseModuleTime("2026-05-31T00:00:00-05:00")
day1Outlook := weatherdata.ConvectiveOutlook{
ID: "day1-marginal",
Day: 1,
OutlookType: "categorical",
Label: "MRGL",
LabelText: "Marginal Risk",
SeverityRank: &rank2,
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
}
day2Outlook := weatherdata.ConvectiveOutlook{
ID: "day2-enhanced",
Day: 2,
OutlookType: "categorical",
Label: "ENH",
LabelText: "Enhanced Risk",
SeverityRank: &rank4,
ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"),
}
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
Outlooks: []weatherdata.ConvectiveOutlook{day1Outlook, day2Outlook},
}
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{day1Outlook, day2Outlook}
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
spcDiscussion(1, "Day 1 regional discussion", "Marginal risk discussion.", "Day 1 text should not be emitted.", "2026-05-29T08:30:00-05:00"),
spcDiscussion(2, "Day 2 regional discussion", "Enhanced risk discussion.", "Day 2 text should be emitted.", "2026-05-30T08:30:00-05:00"),
}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[SPCConvectiveDiscussionModule](t, output)
if len(value.Discussions) != 1 {
t.Fatalf("Discussions = %#v, want only the qualifying day discussion", value.Discussions)
}
if value.Discussions[0].Day != 2 || value.Discussions[0].Headline != "Day 2 regional discussion" {
t.Fatalf("Discussions[0] = %#v, want day 2 discussion only", value.Discussions[0])
}
if value.Discussions[0].PeriodBegins != "2026-05-30 at 7:00 AM" || value.Discussions[0].PeriodEnds != "2026-05-31 at 7:00 AM" {
t.Fatalf("Period = %q/%q, want qualifying day 2 outlook period", value.Discussions[0].PeriodBegins, value.Discussions[0].PeriodEnds)
}
}
func TestSPCConvectiveDiscussionModuleOmitsNonOverlappingQualifyingOutlook(t *testing.T) {
registry := MustDefaultModuleRegistry()
rank := 5
ctx := testModuleContext()
outlook := weatherdata.ConvectiveOutlook{
ID: "day2-enhanced",
Day: 2,
OutlookType: "categorical",
Label: "ENH",
LabelText: "Enhanced Risk",
SeverityRank: &rank,
ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"),
}
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
}
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
spcDiscussion(2, "Day 2 regional discussion", "Enhanced risk discussion.", "Day 2 text should not be emitted for today.", "2026-05-30T08:30:00-05:00"),
}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
if output != nil {
t.Fatalf("output = %#v, want non-overlapping discussion omitted", output)
}
}
func TestSPCConvectiveDiscussionModuleOmitsMissingDiscussionText(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := spcConvectiveDiscussionContext(3, []weatherdata.ConvectiveOutlookDiscussion{
{Day: 1, Headline: "Severe storms possible", Summary: "Scattered severe storms are possible.", UpdatedAt: ptrModuleTime("2026-05-29T08:30:00-05:00")},
})
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
if output != nil {
t.Fatalf("output = %#v, want omitted when discussion text is missing", output)
}
}
func TestSPCConvectiveDiscussionModuleOmitsMissingSource(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Collected.SPCConvectiveOutlooks = nil
ctx.Derived.SPCConvectiveOutlooks = nil
ctx.Derived.SPCConvectiveDiscussions = nil
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
if output != nil {
t.Fatalf("output = %#v, want omitted for missing optional source", output)
}
}
func spcConvectiveDiscussionContext(rank int, discussions []weatherdata.ConvectiveOutlookDiscussion) ModuleContext {
ctx := testModuleContext()
outlook := weatherdata.ConvectiveOutlook{
ID: "day1-categorical",
Day: 1,
OutlookType: "categorical",
Label: "SLGT",
LabelText: "Slight Risk",
SeverityRank: &rank,
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
}
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
AsOf: ptrModuleTime("2026-05-29T08:00:00-05:00"),
IssuedAt: ptrModuleTime("2026-05-29T07:45:00-05:00"),
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
}
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
ctx.Derived.SPCConvectiveDiscussions = discussions
return ctx
}
func spcDiscussion(day int, headline string, summary string, discussion string, updatedAt string) weatherdata.ConvectiveOutlookDiscussion {
return weatherdata.ConvectiveOutlookDiscussion{
Day: day,
Headline: headline,
Summary: summary,
Discussion: discussion,
UpdatedAt: ptrModuleTime(updatedAt),
}
}
func ptrModuleTime(value string) *time.Time {
parsed := mustParseModuleTime(value)
return &parsed
}

View File

@@ -0,0 +1,92 @@
package briefing
import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type SPCConvectiveOutlooksModule struct {
Checked bool `json:"checked"`
AsOf string `json:"as_of,omitempty"`
IssuedAt string `json:"issued_at,omitempty"`
LocationID string `json:"location_id,omitempty"`
LocationName string `json:"location_name,omitempty"`
OutlookCount int `json:"outlook_count"`
Outlooks []SPCConvectiveOutlookRecord `json:"outlooks,omitempty"`
}
type SPCConvectiveOutlookRecord struct {
Day int `json:"day,omitempty"`
OutlookType string `json:"outlook_type,omitempty"`
Label string `json:"label,omitempty"`
LabelText string `json:"label_text,omitempty"`
PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
IssuedAt string `json:"issued_at,omitempty"`
ContainsLocation bool `json:"contains_location"`
ImageURL string `json:"image_url,omitempty"`
}
func buildSPCConvectiveOutlooksModule(ctx ModuleContext, _ any) (*module.Output, error) {
value := SPCConvectiveOutlooksModule{}
run := ctx.Collected.SPCConvectiveOutlooks
if run != nil {
value.Checked = true
value.AsOf = friendlyOptionalTime(run.AsOf, ctx.Timezone)
value.IssuedAt = friendlyOptionalTime(run.IssuedAt, ctx.Timezone)
value.LocationID = run.LocationID
value.LocationName = run.LocationName
}
source, ok := sourceByName(ctx.Collected.SourceProvenance, string(module.SPCConvectiveOutlooks))
if value.Checked && value.AsOf == "" && ok && !source.FetchedAt.IsZero() {
value.AsOf = friendlyDateTimeLabel(source.FetchedAt, ctx.Timezone)
}
if value.Checked && value.IssuedAt == "" && ok {
value.IssuedAt = friendlyOptionalTime(source.IssuedAt, ctx.Timezone)
}
value.Outlooks = spcConvectiveOutlookRecords(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod, ctx.Timezone)
value.OutlookCount = len(value.Outlooks)
return &module.Output{ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), Value: value}, nil
}
func spcConvectiveOutlookRecords(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period, timezone string) []SPCConvectiveOutlookRecord {
records := make([]SPCConvectiveOutlookRecord, 0, len(outlooks))
for _, outlook := range outlooks {
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(reportPeriod) {
continue
}
records = append(records, SPCConvectiveOutlookRecord{
Day: outlook.Day,
OutlookType: outlook.OutlookType,
Label: outlook.Label,
LabelText: outlook.LabelText,
PeriodBegins: friendlyPeriodBeginsLabel(outlookPeriod, timezone),
PeriodEnds: friendlyPeriodEndsLabel(outlookPeriod, timezone),
IssuedAt: friendlyOptionalTime(outlook.IssuedAt, timezone),
ContainsLocation: outlook.ContainsLocation,
ImageURL: outlook.ImageURL,
})
}
return records
}
func friendlyOptionalTime(value *time.Time, timezone string) string {
if value == nil {
return ""
}
return friendlyDateTimeLabel(*value, timezone)
}
func sourceByName(sources []weatherdata.Source, name string) (weatherdata.Source, bool) {
for _, source := range sources {
if source.Name == name {
return source, true
}
}
return weatherdata.Source{}, false
}

View File

@@ -0,0 +1,169 @@
package briefing
import (
"encoding/json"
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
rank := 3
asOf := mustParseModuleTime("2026-05-29T14:00:00Z")
issuedAt := mustParseModuleTime("2026-05-29T13:45:00Z")
expiresAt := mustParseModuleTime("2026-05-30T07:00:00-05:00")
outlook := weatherdata.ConvectiveOutlook{
ID: "day1-categorical-slight",
Provider: "spc",
Product: "convective_outlook",
Day: 1,
OutlookType: "categorical",
Label: "SLGT",
LabelText: "Slight Risk",
Forecaster: "Smith",
SeverityRank: &rank,
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
IssuedAt: &issuedAt,
ExpiresAt: &expiresAt,
SourceURL: "https://www.spc.noaa.gov/products/outlook/day1otlk.html",
ImageURL: "https://www.spc.noaa.gov/products/outlook/day1probotlk_2000_torn.gif",
ContainsLocation: true,
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[]}`),
}
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
LocationID: "nws-lsx-grid-90-74",
LocationName: "St. Louis, MO",
AsOf: &asOf,
IssuedAt: &issuedAt,
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
}
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
if output == nil || output.ID != module.SPCConvectiveOutlooks || output.StanzaName != "spc_convective_outlooks" {
t.Fatalf("output = %#v, want spc convective outlook output", output)
}
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
if !value.Checked || value.OutlookCount != 1 || value.AsOf != "2026-05-29 at 9:00 AM" || value.IssuedAt != "2026-05-29 at 8:45 AM" {
t.Fatalf("SPCConvectiveOutlooksModule = %#v, want checked source timing and one outlook", value)
}
if value.LocationID != "nws-lsx-grid-90-74" || value.LocationName != "St. Louis, MO" {
t.Fatalf("source location = %q/%q, want Weather API location", value.LocationID, value.LocationName)
}
if len(value.Outlooks) != 1 {
t.Fatalf("Outlooks length = %d, want 1", len(value.Outlooks))
}
got := value.Outlooks[0]
if got.Day != 1 || got.OutlookType != "categorical" || got.Label != "SLGT" || got.LabelText != "Slight Risk" {
t.Fatalf("outlook = %#v, want categorical slight risk fields", got)
}
if got.PeriodBegins != "2026-05-29 at 11:00 AM" || got.PeriodEnds != "2026-05-30 at 7:00 AM" || got.IssuedAt != "2026-05-29 at 8:45 AM" {
t.Fatalf("outlook times = %#v, want friendly local labels", got)
}
if !got.ContainsLocation || got.ImageURL == "" {
t.Fatalf("outlook = %#v, want location flag and image URL", got)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
text := string(data)
for _, field := range []string{"checked", "as_of", "issued_at", "location_id", "location_name", "outlook_count", "outlooks", "period_begins", "period_ends", "contains_location", "image_url"} {
if !strings.Contains(text, field) {
t.Fatalf("json = %s, want field %s", text, field)
}
}
for _, omitted := range []string{"geometry", "coordinates", "forecaster", "provider", "severity_rank", "expires_at", "source_url", "valid_start", "valid_end", `"period":`} {
if strings.Contains(text, omitted) {
t.Fatalf("json = %s, want prompt-safe outlook without %s", text, omitted)
}
}
}
func TestSPCConvectiveOutlooksModuleSkipsNonOverlappingOutlooks(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
rank := 5
outlook := weatherdata.ConvectiveOutlook{
ID: "tomorrow-enhanced",
Day: 2,
OutlookType: "categorical",
Label: "ENH",
LabelText: "Enhanced Risk",
SeverityRank: &rank,
ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"),
ContainsLocation: true,
}
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
AsOf: ptrModuleTime("2026-05-29T14:00:00Z"),
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
}
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
if value.OutlookCount != 0 || len(value.Outlooks) != 0 {
t.Fatalf("value = %#v, want non-overlapping outlook omitted", value)
}
}
func TestSPCConvectiveOutlooksModuleBuildsCheckedEmptyStanza(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
asOf := mustParseModuleTime("2026-05-29T14:00:00Z")
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
LocationID: "nws-lsx-grid-90-74",
LocationName: "St. Louis, MO",
AsOf: &asOf,
Outlooks: []weatherdata.ConvectiveOutlook{},
}
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
if !value.Checked || value.OutlookCount != 0 || len(value.Outlooks) != 0 {
t.Fatalf("checked empty value = %#v, want checked source with no retained outlooks", value)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
if strings.Contains(string(data), "outlooks") {
t.Fatalf("json = %s, want empty outlook list omitted", string(data))
}
}
func TestSPCConvectiveOutlooksModuleBuildsUncheckedStanzaForMissingSource(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Collected.SPCConvectiveOutlooks = nil
ctx.Collected.SourceProvenance = []weatherdata.Source{{
Name: string(module.SPCConvectiveOutlooks),
Missing: true,
}}
ctx.Derived.SPCConvectiveOutlooks = nil
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
if value.Checked || value.OutlookCount != 0 || value.AsOf != "" || value.IssuedAt != "" {
t.Fatalf("missing source value = %#v, want unchecked empty stanza", value)
}
}

View File

@@ -7,6 +7,7 @@ import (
"strings" "strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
) )
type OutdoorWindows struct { type OutdoorWindows struct {
@@ -16,8 +17,7 @@ type OutdoorWindows struct {
type OutdoorWindow struct { type OutdoorWindow struct {
Daypart string Daypart string
Start string Period timeutil.Period
End string
Reasons []string Reasons []string
Score float64 Score float64
} }
@@ -190,8 +190,7 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
} }
return OutdoorWindow{ return OutdoorWindow{
Daypart: daypart.Name, Daypart: daypart.Name,
Start: daypart.Period.Start.Format("15:04"), Period: daypart.Period,
End: daypart.Period.End.Format("15:04"),
Reasons: dedupe(reasons), Reasons: dedupe(reasons),
Score: math.Round(score*10) / 10, Score: math.Round(score*10) / 10,
} }

View File

@@ -4,20 +4,21 @@ import (
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
) )
type WeatherStoryModule struct { type WeatherStoryModule struct {
Available bool `json:"available"` Available bool `json:"available"`
OfficeID string `json:"office_id,omitempty"` OfficeID string `json:"office_id,omitempty"`
StartTime time.Time `json:"start_time"` PeriodBegins string `json:"period_begins,omitempty"`
EndTime time.Time `json:"end_time"` PeriodEnds string `json:"period_ends,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
AltText string `json:"alt_text,omitempty"` AltText string `json:"alt_text,omitempty"`
Priority bool `json:"priority"` Priority bool `json:"priority"`
Order int `json:"order"` Order int `json:"order"`
DownloadURL string `json:"download_url,omitempty"` DownloadURL string `json:"download_url,omitempty"`
} }
func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) { func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
@@ -25,18 +26,19 @@ func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
if story == nil { if story == nil {
return nil, nil return nil, nil
} }
period := timeutil.Period{Start: story.StartTime, End: story.EndTime}
value := WeatherStoryModule{ value := WeatherStoryModule{
Available: true, Available: true,
OfficeID: story.OfficeID, OfficeID: story.OfficeID,
StartTime: story.StartTime, PeriodBegins: friendlyPeriodBeginsLabel(period, ctx.Timezone),
EndTime: story.EndTime, PeriodEnds: friendlyPeriodEndsLabel(period, ctx.Timezone),
UpdatedAt: copyTime(story.UpdatedAt), UpdatedAt: copyTime(story.UpdatedAt),
Title: story.Title, Title: story.Title,
Description: story.Description, Description: story.Description,
AltText: story.AltText, AltText: story.AltText,
Priority: story.Priority, Priority: story.Priority,
Order: story.Order, Order: story.Order,
DownloadURL: story.DownloadURL, DownloadURL: story.DownloadURL,
} }
return &module.Output{ID: module.WeatherStory, StanzaName: "weather_story", Value: value}, nil return &module.Output{ID: module.WeatherStory, StanzaName: "weather_story", Value: value}, nil
} }

View File

@@ -84,7 +84,8 @@ type dailySummaryStanza struct {
type daypartSummaryStanza struct { type daypartSummaryStanza struct {
Date string `json:"date,omitempty"` Date string `json:"date,omitempty"`
Period string `json:"period,omitempty"` PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
TempRangeF string `json:"temp_range_f,omitempty"` TempRangeF string `json:"temp_range_f,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"` MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"` MaxPopTime string `json:"max_pop_time,omitempty"`

View File

@@ -96,7 +96,13 @@ func dailySnapshot(t *testing.T, low int, high int, precip int, precipTime strin
DailyPrecipitationProbability: &precip, DailyPrecipitationProbability: &precip,
}}, }},
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{ module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
"morning": {Date: "2026-05-29", Period: "2026-05-29 at 6:00 AM to 2026-05-29 at 10:00 AM", TempRangeF: "60-70", Snow: snow}, "morning": {
Date: "2026-05-29",
PeriodBegins: "2026-05-29 at 6:00 AM",
PeriodEnds: "2026-05-29 at 10:00 AM",
TempRangeF: "60-70",
Snow: snow,
},
}}, }},
module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: precipTimingStanza{MaxPopPercent: &precip, MaxPopTime: precipTime}}, module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: precipTimingStanza{MaxPopPercent: &precip, MaxPopTime: precipTime}},
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: alertDigestStanza{Relevant: relevant}}, module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: alertDigestStanza{Relevant: relevant}},

View File

@@ -32,7 +32,8 @@ func outlookSnapshot(t *testing.T, date string, tempRange string, precip int, pr
return snapshot(t, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{ return snapshot(t, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
date + "_morning": { date + "_morning": {
Date: date, Date: date,
Period: date + " at 6:00 AM to " + date + " at 10:00 AM", PeriodBegins: date + " at 6:00 AM",
PeriodEnds: date + " at 10:00 AM",
TempRangeF: tempRange, TempRangeF: tempRange,
MaxPopPercent: &precip, MaxPopPercent: &precip,
MaxPopTime: precipTime, MaxPopTime: precipTime,

View File

@@ -19,6 +19,7 @@ Usage:
weatherreporter --help weatherreporter --help
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate near-term [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
@@ -386,6 +387,8 @@ func reportKind(value string) (app.ReportKind, bool) {
return app.ReportDaily, true return app.ReportDaily, true
case string(app.ReportTomorrow): case string(app.ReportTomorrow):
return app.ReportTomorrow, true return app.ReportTomorrow, true
case string(app.ReportNearTerm):
return app.ReportNearTerm, true
case string(app.ReportThreeDay): case string(app.ReportThreeDay):
return app.ReportThreeDay, true return app.ReportThreeDay, true
case string(app.ReportWeekend): case string(app.ReportWeekend):

View File

@@ -28,6 +28,9 @@ func TestRunHelpLongFlag(t *testing.T) {
if !strings.Contains(stdout.String(), "generate daily") { if !strings.Contains(stdout.String(), "generate daily") {
t.Fatalf("help output missing generate command:\n%s", stdout.String()) t.Fatalf("help output missing generate command:\n%s", stdout.String())
} }
if !strings.Contains(stdout.String(), "weatherreporter generate near-term") {
t.Fatalf("help output missing near-term generate command:\n%s", stdout.String())
}
removedInspectCommand := "inspect " + "briefing" removedInspectCommand := "inspect " + "briefing"
if !strings.Contains(stdout.String(), "inspect modules") || strings.Contains(stdout.String(), removedInspectCommand) { if !strings.Contains(stdout.String(), "inspect modules") || strings.Contains(stdout.String(), removedInspectCommand) {
t.Fatalf("help output has wrong inspect commands:\n%s", stdout.String()) t.Fatalf("help output has wrong inspect commands:\n%s", stdout.String())
@@ -809,6 +812,7 @@ func TestResolveGenerateCommands(t *testing.T) {
}{ }{
{name: "daily", args: []string{"daily", "--date", "2026-05-29"}, want: app.ReportDaily}, {name: "daily", args: []string{"daily", "--date", "2026-05-29"}, want: app.ReportDaily},
{name: "tomorrow", args: []string{"tomorrow"}, want: app.ReportTomorrow}, {name: "tomorrow", args: []string{"tomorrow"}, want: app.ReportTomorrow},
{name: "near-term", args: []string{"near-term"}, want: app.ReportNearTerm},
{name: "three-day", args: []string{"three-day"}, want: app.ReportThreeDay}, {name: "three-day", args: []string{"three-day"}, want: app.ReportThreeDay},
{name: "weekend", args: []string{"weekend"}, want: app.ReportWeekend}, {name: "weekend", args: []string{"weekend"}, want: app.ReportWeekend},
{name: "storm", args: []string{"storm", "--start", "2026-05-29T18:00", "--end", "2026-05-30T06:00"}, want: app.ReportStorm}, {name: "storm", args: []string{"storm", "--start", "2026-05-29T18:00", "--end", "2026-05-30T06:00"}, want: app.ReportStorm},
@@ -827,6 +831,53 @@ func TestResolveGenerateCommands(t *testing.T) {
} }
} }
func TestResolveGenerateNearTermAppliesSharedFlags(t *testing.T) {
runner := Runner{Clock: fixedClock()}
configPath := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(configPath, []byte("weather_api:\n units: metric\n timezone: UTC\n"), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
req, err := runner.resolveGenerate([]string{"near-term", "--config", configPath, "--units", "us", "--tz", "America/Chicago", "--out", "./near-term.md"})
if err != nil {
t.Fatalf("resolveGenerate() error = %v", err)
}
if req.Report != app.ReportNearTerm {
t.Fatalf("Report = %q, want near-term", req.Report)
}
if req.Config.WeatherAPI.Units != "us" {
t.Fatalf("Units = %q, want us", req.Config.WeatherAPI.Units)
}
if req.Config.WeatherAPI.Timezone != "America/Chicago" {
t.Fatalf("Timezone = %q, want America/Chicago", req.Config.WeatherAPI.Timezone)
}
if req.OutputPath != "./near-term.md" {
t.Fatalf("OutputPath = %q, want ./near-term.md", req.OutputPath)
}
if !req.Date.IsZero() || !req.StormStart.IsZero() || !req.StormEnd.IsZero() {
t.Fatalf("date/storm bounds = %s/%s/%s, want unset for near-term", req.Date, req.StormStart, req.StormEnd)
}
}
func TestResolveGenerateNearTermRejectsDateAndStormBounds(t *testing.T) {
runner := Runner{Clock: fixedClock()}
for _, args := range [][]string{
{"near-term", "--date", "2026-05-29"},
{"near-term", "--start", "2026-05-29T18:00"},
{"near-term", "--end", "2026-05-29T20:00"},
} {
_, err := runner.resolveGenerate(args)
if err == nil {
t.Fatalf("resolveGenerate(%v) error = nil, want flag error", args)
}
if !strings.Contains(err.Error(), "flag provided but not defined") {
t.Fatalf("resolveGenerate(%v) error = %q, want undefined flag error", args, err.Error())
}
}
}
func TestResolveGenerateDailyDefaultsDateInConfiguredTimezone(t *testing.T) { func TestResolveGenerateDailyDefaultsDateInConfiguredTimezone(t *testing.T) {
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
@@ -996,6 +1047,8 @@ func dailyServer(t *testing.T) *httptest.Server {
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}}`)) _, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}}`))
case "/weatherstories/latest": case "/weatherstories/latest":
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`)) _, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
case "/outlooks/convective":
_, _ = w.Write([]byte(`{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`))
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }

View File

@@ -85,7 +85,7 @@ func TestLoadExampleConfig(t *testing.T) {
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" { if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
t.Fatalf("Location = %#v, want example location", cfg.Location) t.Fatalf("Location = %#v, want example location", cfg.Location)
} }
if cfg.Notify.Distributor.PipelineIDTemplate != "weatherreporter.{artifact_group}" { if cfg.Notify.Distributor.PipelineIDTemplate != "weatherreporter.{report_id}" {
t.Fatalf("PipelineIDTemplate = %q, want example pipeline template", cfg.Notify.Distributor.PipelineIDTemplate) t.Fatalf("PipelineIDTemplate = %q, want example pipeline template", cfg.Notify.Distributor.PipelineIDTemplate)
} }
if len(cfg.Notify.Distributor.ReportPathTemplates) != 1 { if len(cfg.Notify.Distributor.ReportPathTemplates) != 1 {
@@ -126,10 +126,13 @@ reports:
deterministic_modules: deterministic_modules:
- metadata - metadata
- current_conditions - current_conditions
- alert_digest
- spc_convective_outlooks
- id: area_forecast_discussion - id: area_forecast_discussion
options: options:
sections: sections:
- short_term - short_term
- spc_convective_discussion
`) `)
cfg, err := LoadFile(path) cfg, err := LoadFile(path)
@@ -138,21 +141,65 @@ reports:
} }
overrides := cfg.ReportModuleOverrides() overrides := cfg.ReportModuleOverrides()
items := overrides[report.DailyToday] items := overrides[report.DailyToday]
if len(items) != 3 { if len(items) != 6 {
t.Fatalf("daily override length = %d, want 3", len(items)) t.Fatalf("daily override length = %d, want 6", len(items))
} }
if items[0].ID != module.Metadata || items[1].ID != module.CurrentConditions || items[2].ID != module.AreaForecastDiscussion { if items[0].ID != module.Metadata ||
items[1].ID != module.CurrentConditions ||
items[2].ID != module.AlertDigest ||
items[3].ID != module.SPCConvectiveOutlooks ||
items[4].ID != module.AreaForecastDiscussion ||
items[5].ID != module.SPCConvectiveDiscussion {
t.Fatalf("daily override = %#v, want configured module order", items) t.Fatalf("daily override = %#v, want configured module order", items)
} }
options, ok := items[2].Options.(module.AreaForecastDiscussionOptions) options, ok := items[4].Options.(module.AreaForecastDiscussionOptions)
if !ok { if !ok {
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", items[2].Options) t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", items[4].Options)
} }
if strings.Join(options.Sections, ",") != "short_term" { if strings.Join(options.Sections, ",") != "short_term" {
t.Fatalf("AFD sections = %#v, want short_term", options.Sections) t.Fatalf("AFD sections = %#v, want short_term", options.Sections)
} }
} }
func TestLoadNearTermReportModuleOverrides(t *testing.T) {
tests := []struct {
name string
key string
}{
{name: "UnderscoreAlias", key: "near_term"},
{name: "HyphenAlias", key: "near-term"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := writeConfig(t, `
reports:
`+tt.key+`:
deterministic_modules:
- metadata
- hourly_forecast
- precip_timing
- alert_digest
`)
cfg, err := LoadFile(path)
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
overrides := cfg.ReportModuleOverrides()
items := overrides[report.NearTerm]
if len(items) != 4 {
t.Fatalf("near-term override length = %d, want 4", len(items))
}
if items[0].ID != module.Metadata ||
items[1].ID != module.HourlyForecast ||
items[2].ID != module.PrecipTiming ||
items[3].ID != module.AlertDigest {
t.Fatalf("near-term override = %#v, want configured module order", items)
}
})
}
}
func TestReportModuleOverrideValidation(t *testing.T) { func TestReportModuleOverrideValidation(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -236,6 +283,29 @@ reports:
`, `,
wantErr: "duplicates report override", wantErr: "duplicates report override",
}, },
{
name: "DuplicateNearTermAlias",
yaml: `
reports:
near_term:
deterministic_modules:
- metadata
near-term:
deterministic_modules:
- current_conditions
`,
wantErr: "duplicates report override",
},
{
name: "NearTermIncompatibleDailyModule",
yaml: `
reports:
near_term:
deterministic_modules:
- derived_daily_summary
`,
wantErr: `not compatible with report "near_term"`,
},
{ {
name: "UnknownReportField", name: "UnknownReportField",
yaml: ` yaml: `
@@ -599,7 +669,7 @@ func TestLoadFileLoadsSecretsBeforeReturningNotifyConfig(t *testing.T) {
"notify:\n" + "notify:\n" +
" distributor:\n" + " distributor:\n" +
" enabled: true\n" + " enabled: true\n" +
" pipeline_id_template: weatherreporter.{artifact_group}\n" " pipeline_id_template: weatherreporter.{report_id}\n"
if err := os.WriteFile(path, []byte(configYAML), 0o600); err != nil { if err := os.WriteFile(path, []byte(configYAML), 0o600); err != nil {
t.Fatalf("write config fixture: %v", err) t.Fatalf("write config fixture: %v", err)
} }

View File

@@ -113,6 +113,8 @@ func reportIDForConfigKey(key string) (report.ID, error) {
return report.DailyToday, nil return report.DailyToday, nil
case "tomorrow", "daily_tomorrow": case "tomorrow", "daily_tomorrow":
return report.DailyTomorrow, nil return report.DailyTomorrow, nil
case "near_term":
return report.NearTerm, nil
case "three_day", "three_day_outlook": case "three_day", "three_day_outlook":
return report.ThreeDay, nil return report.ThreeDay, nil
case "weekend", "weekend_outlook": case "weekend", "weekend_outlook":

View File

@@ -3,6 +3,7 @@ package facts
import ( import (
"fmt" "fmt"
"sort"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
@@ -12,15 +13,16 @@ import (
) )
type CollectedFacts struct { type CollectedFacts struct {
FetchedAt time.Time FetchedAt time.Time
Observation *weatherdata.Observation Observation *weatherdata.Observation
Current *weatherdata.Current Current *weatherdata.Current
Hourly *weatherdata.ForecastRun Hourly *weatherdata.ForecastRun
Narrative *weatherdata.ForecastRun Narrative *weatherdata.ForecastRun
Alerts *weatherdata.AlertRun Alerts *weatherdata.AlertRun
Discussion *weatherdata.Discussion Discussion *weatherdata.Discussion
Daily *weatherdata.ForecastRun Daily *weatherdata.ForecastRun
WeatherStory *weatherdata.WeatherStory WeatherStory *weatherdata.WeatherStory
SPCConvectiveOutlooks *weatherdata.ConvectiveOutlookRun
SourceProvenance []weatherdata.Source SourceProvenance []weatherdata.Source
SourceWarnings []weatherdata.SourceWarning SourceWarnings []weatherdata.SourceWarning
@@ -31,33 +33,35 @@ func BuildCollected(bundle *weatherdata.Bundle) CollectedFacts {
return CollectedFacts{} return CollectedFacts{}
} }
return CollectedFacts{ return CollectedFacts{
FetchedAt: bundle.FetchedAt, FetchedAt: bundle.FetchedAt,
Observation: bundle.Observation, Observation: bundle.Observation,
Current: bundle.Current, Current: bundle.Current,
Hourly: bundle.Hourly, Hourly: bundle.Hourly,
Narrative: bundle.Narrative, Narrative: bundle.Narrative,
Alerts: bundle.Alerts, Alerts: bundle.Alerts,
Discussion: bundle.Discussion, Discussion: bundle.Discussion,
Daily: bundle.Daily, Daily: bundle.Daily,
WeatherStory: bundle.WeatherStory, WeatherStory: bundle.WeatherStory,
SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...), SPCConvectiveOutlooks: bundle.SPCConvectiveOutlooks,
SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...), SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...),
SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...),
} }
} }
func (f CollectedFacts) Bundle() *weatherdata.Bundle { func (f CollectedFacts) Bundle() *weatherdata.Bundle {
return &weatherdata.Bundle{ return &weatherdata.Bundle{
FetchedAt: f.FetchedAt, FetchedAt: f.FetchedAt,
Observation: f.Observation, Observation: f.Observation,
Current: f.Current, Current: f.Current,
Hourly: f.Hourly, Hourly: f.Hourly,
Narrative: f.Narrative, Narrative: f.Narrative,
Alerts: f.Alerts, Alerts: f.Alerts,
Discussion: f.Discussion, Discussion: f.Discussion,
Daily: f.Daily, Daily: f.Daily,
WeatherStory: f.WeatherStory, WeatherStory: f.WeatherStory,
Sources: append([]weatherdata.Source(nil), f.SourceProvenance...), SPCConvectiveOutlooks: f.SPCConvectiveOutlooks,
Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...), Sources: append([]weatherdata.Source(nil), f.SourceProvenance...),
Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...),
} }
} }
@@ -73,6 +77,8 @@ type DerivedFacts struct {
ValidPeriodNarrativePeriods []weatherdata.ForecastPeriod ValidPeriodNarrativePeriods []weatherdata.ForecastPeriod
ValidPeriodDailyPeriods []weatherdata.ForecastPeriod ValidPeriodDailyPeriods []weatherdata.ForecastPeriod
AlertOverlaps []forecast.AlertOverlap AlertOverlaps []forecast.AlertOverlap
SPCConvectiveOutlooks []weatherdata.ConvectiveOutlook
SPCConvectiveDiscussions []weatherdata.ConvectiveOutlookDiscussion
DailySummaries []forecast.DailySummary DailySummaries []forecast.DailySummary
DaypartSummaries []forecast.DaypartSummary DaypartSummaries []forecast.DaypartSummary
PrecipTiming forecast.PrecipTiming PrecipTiming forecast.PrecipTiming
@@ -96,15 +102,19 @@ func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) {
} }
bundle := req.Collected.Bundle() bundle := req.Collected.Bundle()
period := req.Resolved.ValidPeriod period := req.Resolved.ValidPeriod
spcOutlooks, spcDiscussions := selectSPCConvectiveOutlooks(req.Collected.SPCConvectiveOutlooks, period)
derived := DerivedFacts{ derived := DerivedFacts{
ValidPeriodHourlyPeriods: forecast.SelectHourlyPeriods(req.Collected.Hourly, period), ValidPeriodHourlyPeriods: forecast.SelectHourlyPeriods(req.Collected.Hourly, period),
ValidPeriodNarrativePeriods: forecast.SelectHourlyPeriods(req.Collected.Narrative, period), ValidPeriodNarrativePeriods: forecast.SelectHourlyPeriods(req.Collected.Narrative, period),
ValidPeriodDailyPeriods: forecast.SelectHourlyPeriods(req.Collected.Daily, period), ValidPeriodDailyPeriods: forecast.SelectHourlyPeriods(req.Collected.Daily, period),
AlertOverlaps: forecast.AlertOverlaps(req.Collected.Alerts, period), AlertOverlaps: forecast.AlertOverlaps(req.Collected.Alerts, period),
SPCConvectiveOutlooks: spcOutlooks,
SPCConvectiveDiscussions: spcDiscussions,
} }
derived.PrecipTiming = forecast.BuildPrecipTiming(derived.ValidPeriodHourlyPeriods) derived.PrecipTiming = forecast.BuildPrecipTiming(derived.ValidPeriodHourlyPeriods)
switch req.Resolved.Definition.ID { switch req.Resolved.Definition.ID {
case report.NearTerm:
case report.DailyToday, report.DailyTomorrow: case report.DailyToday, report.DailyTomorrow:
summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts) summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts)
if err != nil { if err != nil {
@@ -139,3 +149,81 @@ func collectDaypartSummaries(derived DerivedFacts) []forecast.DaypartSummary {
} }
return out return out
} }
func selectSPCConvectiveOutlooks(run *weatherdata.ConvectiveOutlookRun, period timeutil.Period) ([]weatherdata.ConvectiveOutlook, []weatherdata.ConvectiveOutlookDiscussion) {
if run == nil {
return nil, nil
}
outlooks := make([]weatherdata.ConvectiveOutlook, 0, len(run.Outlooks))
days := map[int]struct{}{}
for _, outlook := range run.Outlooks {
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(period) {
continue
}
outlooks = append(outlooks, outlook)
days[outlook.Day] = struct{}{}
}
sort.SliceStable(outlooks, func(i, j int) bool {
left := outlooks[i]
right := outlooks[j]
if left.Day != right.Day {
return left.Day < right.Day
}
if left.OutlookType != right.OutlookType {
return left.OutlookType < right.OutlookType
}
leftRank, leftRankOK := severityRank(left)
rightRank, rightRankOK := severityRank(right)
if leftRankOK != rightRankOK {
return leftRankOK
}
if leftRankOK && leftRank != rightRank {
return leftRank > rightRank
}
if !left.ValidFrom.Equal(right.ValidFrom) {
return left.ValidFrom.Before(right.ValidFrom)
}
if left.Label != right.Label {
return left.Label < right.Label
}
return left.ID < right.ID
})
discussions := make([]weatherdata.ConvectiveOutlookDiscussion, 0, len(run.Discussions))
for _, discussion := range run.Discussions {
if _, ok := days[discussion.Day]; ok {
discussions = append(discussions, discussion)
}
}
sort.SliceStable(discussions, func(i, j int) bool {
left := discussions[i]
right := discussions[j]
if left.Day != right.Day {
return left.Day < right.Day
}
if left.UpdatedAt != nil && right.UpdatedAt != nil && !left.UpdatedAt.Equal(*right.UpdatedAt) {
return left.UpdatedAt.Before(*right.UpdatedAt)
}
if (left.UpdatedAt != nil) != (right.UpdatedAt != nil) {
return left.UpdatedAt != nil
}
if left.Headline != right.Headline {
return left.Headline < right.Headline
}
if left.Summary != right.Summary {
return left.Summary < right.Summary
}
return left.Discussion < right.Discussion
})
return outlooks, discussions
}
func severityRank(outlook weatherdata.ConvectiveOutlook) (int, bool) {
if outlook.SeverityRank == nil {
return 0, false
}
return *outlook.SeverityRank, true
}

View File

@@ -2,6 +2,7 @@ package facts
import ( import (
"encoding/json" "encoding/json"
"strings"
"testing" "testing"
"time" "time"
@@ -16,14 +17,24 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
FetchedAt: fetchedAt, FetchedAt: fetchedAt,
Current: &weatherdata.Current{ConditionText: "Clear"}, Current: &weatherdata.Current{ConditionText: "Clear"},
Hourly: &weatherdata.ForecastRun{Product: "hourly"}, Hourly: &weatherdata.ForecastRun{Product: "hourly"},
Sources: []weatherdata.Source{{Name: "hourly"}}, SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{
Warnings: []weatherdata.SourceWarning{{Source: "discussion", Code: "missing_source"}}, Product: "convective_outlook",
Outlooks: []weatherdata.ConvectiveOutlook{{
ID: "day1-categorical-slight",
Label: "SLGT",
}},
},
Sources: []weatherdata.Source{{Name: "hourly"}},
Warnings: []weatherdata.SourceWarning{{Source: "discussion", Code: "missing_source"}},
} }
collected := BuildCollected(bundle) collected := BuildCollected(bundle)
if collected.FetchedAt != fetchedAt || collected.Current.ConditionText != "Clear" || collected.Hourly.Product != "hourly" { if collected.FetchedAt != fetchedAt || collected.Current.ConditionText != "Clear" || collected.Hourly.Product != "hourly" {
t.Fatalf("CollectedFacts = %#v, want source facts copied from bundle", collected) t.Fatalf("CollectedFacts = %#v, want source facts copied from bundle", collected)
} }
if collected.SPCConvectiveOutlooks == nil || collected.SPCConvectiveOutlooks.Outlooks[0].Label != "SLGT" {
t.Fatalf("SPCConvectiveOutlooks = %#v, want source copied from bundle", collected.SPCConvectiveOutlooks)
}
if len(collected.SourceProvenance) != 1 || collected.SourceProvenance[0].Name != "hourly" { if len(collected.SourceProvenance) != 1 || collected.SourceProvenance[0].Name != "hourly" {
t.Fatalf("SourceProvenance = %#v, want hourly source", collected.SourceProvenance) t.Fatalf("SourceProvenance = %#v, want hourly source", collected.SourceProvenance)
} }
@@ -36,6 +47,11 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
if collected.SourceProvenance[0].Name != "hourly" || collected.SourceWarnings[0].Source != "discussion" { if collected.SourceProvenance[0].Name != "hourly" || collected.SourceWarnings[0].Source != "discussion" {
t.Fatalf("collected source slices changed after bundle mutation: %#v %#v", collected.SourceProvenance, collected.SourceWarnings) t.Fatalf("collected source slices changed after bundle mutation: %#v %#v", collected.SourceProvenance, collected.SourceWarnings)
} }
roundTrip := collected.Bundle()
if roundTrip.SPCConvectiveOutlooks == nil || roundTrip.SPCConvectiveOutlooks.Outlooks[0].ID != "day1-categorical-slight" {
t.Fatalf("Bundle().SPCConvectiveOutlooks = %#v, want collected source restored", roundTrip.SPCConvectiveOutlooks)
}
} }
func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) { func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) {
@@ -136,6 +152,63 @@ func TestBuildDerivedWeekendAndTomorrow(t *testing.T) {
} }
} }
func TestBuildDerivedNearTermUsesRollingWindowFacts(t *testing.T) {
location := testLocation()
resolved := resolveForTest(t, report.NearTerm, mustParse("2026-05-29T08:30:00-05:00"), location)
bundle := testBundle(location)
bundle.Alerts = &weatherdata.AlertRun{Alerts: []json.RawMessage{
json.RawMessage(`{"event":"Expired Advisory","headline":"Ends at start","severity":"Minor","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T08:30:00-05:00"}`),
json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T11:00:00-05:00","expires":"2026-05-29T15:00:00-05:00"}`),
json.RawMessage(`{"event":"Evening Advisory","headline":"Starts at end","severity":"Minor","effective":"2026-05-29T14:30:00-05:00","expires":"2026-05-29T18:00:00-05:00"}`),
}}
bundle.SPCConvectiveOutlooks = testConvectiveOutlookRun(location)
derived, err := BuildDerived(BuildDerivedRequest{
Resolved: resolved,
Timezone: location.String(),
Dayparts: testDayparts(),
Collected: BuildCollected(bundle),
})
if err != nil {
t.Fatalf("BuildDerived() error = %v", err)
}
if len(derived.ValidPeriodHourlyPeriods) != 4 {
t.Fatalf("ValidPeriodHourlyPeriods length = %d, want 4 rolling-window hours", len(derived.ValidPeriodHourlyPeriods))
}
if derived.ValidPeriodHourlyPeriods[0].StartTime.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" ||
derived.ValidPeriodHourlyPeriods[3].StartTime.Format(time.RFC3339) != "2026-05-29T14:00:00-05:00" {
t.Fatalf("ValidPeriodHourlyPeriods = %#v, want only hours overlapping 8:30 AM-2:30 PM", derived.ValidPeriodHourlyPeriods)
}
if len(derived.ValidPeriodNarrativePeriods) != 1 {
t.Fatalf("ValidPeriodNarrativePeriods length = %d, want overlapping narrative period", len(derived.ValidPeriodNarrativePeriods))
}
if len(derived.DailySummaries) != 0 || len(derived.DaypartSummaries) != 0 || derived.StormWindowSummary != nil {
t.Fatalf("near-term summaries daily=%#v daypart=%#v storm=%#v, want none", derived.DailySummaries, derived.DaypartSummaries, derived.StormWindowSummary)
}
if derived.PrecipTiming.FirstPrecipitation == nil || derived.PrecipTiming.FirstPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" {
t.Fatalf("PrecipTiming.FirstPrecipitation = %#v, want first selected rainy hour", derived.PrecipTiming.FirstPrecipitation)
}
if derived.PrecipTiming.LastPrecipitation == nil || derived.PrecipTiming.LastPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T14:00:00-05:00" {
t.Fatalf("PrecipTiming.LastPrecipitation = %#v, want final selected rainy window end", derived.PrecipTiming.LastPrecipitation)
}
if len(derived.PrecipTiming.PrecipitationWindows) != 2 {
t.Fatalf("PrecipTiming.PrecipitationWindows = %#v, want two near-term windows", derived.PrecipTiming.PrecipitationWindows)
}
if len(derived.AlertOverlaps) != 1 || derived.AlertOverlaps[0].Event != "Flood Watch" {
t.Fatalf("AlertOverlaps = %#v, want only alert overlapping near-term period", derived.AlertOverlaps)
}
if derived.AlertOverlaps[0].Overlap.End.Format(time.RFC3339) != "2026-05-29T14:30:00-05:00" {
t.Fatalf("Alert overlap end = %s, want clipped to near-term end", derived.AlertOverlaps[0].Overlap.End.Format(time.RFC3339))
}
if got, want := outlookIDs(derived.SPCConvectiveOutlooks), []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic"}; strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("SPCConvectiveOutlooks IDs = %#v, want %#v", got, want)
}
if got, want := discussionHeadlines(derived.SPCConvectiveDiscussions), []string{"day1 early", "day1 late"}; strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("SPCConvectiveDiscussions = %#v, want retained discussions for overlapping SPC day %#v", got, want)
}
}
func TestBuildDerivedStormBuildsWindowSummary(t *testing.T) { func TestBuildDerivedStormBuildsWindowSummary(t *testing.T) {
location := testLocation() location := testLocation()
resolved := resolveStormForTest(t, location) resolved := resolveStormForTest(t, location)
@@ -166,6 +239,124 @@ func TestBuildDerivedStormBuildsWindowSummary(t *testing.T) {
} }
} }
func TestBuildDerivedSelectsSPCConvectiveOutlooksByValidPeriod(t *testing.T) {
location := testLocation()
now := mustParse("2026-05-29T08:00:00-05:00")
bundle := testBundle(location)
bundle.SPCConvectiveOutlooks = testConvectiveOutlookRun(location)
tests := []struct {
name string
resolved report.Resolved
wantOutlookIDs []string
wantDiscussion []string
}{
{
name: "daily today",
resolved: resolveForTest(t, report.DailyToday, now, location),
wantOutlookIDs: []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic"},
wantDiscussion: []string{"day1 early", "day1 late"},
},
{
name: "daily tomorrow",
resolved: resolveForTest(t, report.DailyTomorrow, now, location),
wantOutlookIDs: []string{"sat-enhanced"},
wantDiscussion: []string{"day2"},
},
{
name: "three day",
resolved: resolveForTest(t, report.ThreeDay, now, location),
wantOutlookIDs: []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic", "sat-enhanced", "sun-slight"},
wantDiscussion: []string{"day1 early", "day1 late", "day2", "day3"},
},
{
name: "weekend",
resolved: resolveForTest(t, report.Weekend, now, location),
wantOutlookIDs: []string{"sat-enhanced", "sun-slight"},
wantDiscussion: []string{"day2", "day3"},
},
{
name: "storm",
resolved: resolveStormForTest(t, location),
wantOutlookIDs: []string{"fri-storm", "fri-low"},
wantDiscussion: []string{"day1 early", "day1 late"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
derived, err := BuildDerived(BuildDerivedRequest{
Resolved: tt.resolved,
Timezone: location.String(),
Dayparts: testDayparts(),
Collected: BuildCollected(bundle),
})
if err != nil {
t.Fatalf("BuildDerived() error = %v", err)
}
if got := outlookIDs(derived.SPCConvectiveOutlooks); strings.Join(got, ",") != strings.Join(tt.wantOutlookIDs, ",") {
t.Fatalf("SPCConvectiveOutlooks IDs = %#v, want %#v", got, tt.wantOutlookIDs)
}
if got := discussionHeadlines(derived.SPCConvectiveDiscussions); strings.Join(got, ",") != strings.Join(tt.wantDiscussion, ",") {
t.Fatalf("SPCConvectiveDiscussions = %#v, want %#v", got, tt.wantDiscussion)
}
})
}
}
func TestBuildDerivedUnsupportedReportReturnsActionableError(t *testing.T) {
location := testLocation()
resolved := resolveForTest(t, report.NearTerm, mustParse("2026-05-29T08:00:00-05:00"), location)
resolved.Definition.ID = report.ID("future_report")
_, err := BuildDerived(BuildDerivedRequest{
Resolved: resolved,
Timezone: location.String(),
Dayparts: testDayparts(),
Collected: BuildCollected(testBundle(location)),
})
if err == nil || !strings.Contains(err.Error(), `derived facts are not implemented for report "future_report"`) {
t.Fatalf("BuildDerived() error = %v, want actionable unsupported report error", err)
}
}
func TestBuildDerivedSPCConvectiveOutlooksDistinguishesMissingAndCheckedEmpty(t *testing.T) {
location := testLocation()
resolved := resolveForTest(t, report.DailyToday, mustParse("2026-05-29T08:00:00-05:00"), location)
bundle := testBundle(location)
bundle.SPCConvectiveOutlooks = nil
derived, err := BuildDerived(BuildDerivedRequest{
Resolved: resolved,
Timezone: location.String(),
Dayparts: testDayparts(),
Collected: BuildCollected(bundle),
})
if err != nil {
t.Fatalf("BuildDerived(missing source) error = %v", err)
}
if derived.SPCConvectiveOutlooks != nil || derived.SPCConvectiveDiscussions != nil {
t.Fatalf("missing source derived outlooks=%#v discussions=%#v, want nil slices", derived.SPCConvectiveOutlooks, derived.SPCConvectiveDiscussions)
}
bundle.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{Outlooks: []weatherdata.ConvectiveOutlook{}, Discussions: []weatherdata.ConvectiveOutlookDiscussion{}}
derived, err = BuildDerived(BuildDerivedRequest{
Resolved: resolved,
Timezone: location.String(),
Dayparts: testDayparts(),
Collected: BuildCollected(bundle),
})
if err != nil {
t.Fatalf("BuildDerived(checked empty source) error = %v", err)
}
if derived.SPCConvectiveOutlooks == nil || len(derived.SPCConvectiveOutlooks) != 0 {
t.Fatalf("checked empty outlooks = %#v, want non-nil empty slice", derived.SPCConvectiveOutlooks)
}
if derived.SPCConvectiveDiscussions == nil || len(derived.SPCConvectiveDiscussions) != 0 {
t.Fatalf("checked empty discussions = %#v, want non-nil empty slice", derived.SPCConvectiveDiscussions)
}
}
func testBundle(location *time.Location) *weatherdata.Bundle { func testBundle(location *time.Location) *weatherdata.Bundle {
return &weatherdata.Bundle{ return &weatherdata.Bundle{
FetchedAt: mustParse("2026-05-29T10:00:00Z"), FetchedAt: mustParse("2026-05-29T10:00:00Z"),
@@ -193,6 +384,65 @@ func testBundle(location *time.Location) *weatherdata.Bundle {
} }
} }
func testConvectiveOutlookRun(location *time.Location) *weatherdata.ConvectiveOutlookRun {
rank1 := 1
rank2 := 2
rank3 := 3
rank4 := 4
rank5 := 5
return &weatherdata.ConvectiveOutlookRun{
Outlooks: []weatherdata.ConvectiveOutlook{
convectiveOutlook(location, "mon-outside", 4, "categorical", "MDT", &rank5, "2026-06-01T00:00:00-05:00", "2026-06-02T00:00:00-05:00"),
convectiveOutlook(location, "sun-slight", 3, "categorical", "SLGT", &rank3, "2026-05-31T06:00:00-05:00", "2026-06-01T00:00:00-05:00"),
convectiveOutlook(location, "fri-low", 1, "categorical", "MRGL", &rank1, "2026-05-29T06:00:00-05:00", "2026-05-29T12:00:00-05:00"),
convectiveOutlook(location, "fri-missing-rank", 1, "categorical", "GEN", nil, "2026-05-29T08:00:00-05:00", "2026-05-29T10:00:00-05:00"),
convectiveOutlook(location, "sat-enhanced", 2, "categorical", "ENH", &rank4, "2026-05-30T01:00:00-05:00", "2026-05-30T12:00:00-05:00"),
convectiveOutlook(location, "fri-high", 1, "categorical", "SLGT", &rank3, "2026-05-29T07:00:00-05:00", "2026-05-29T11:00:00-05:00"),
convectiveOutlook(location, "fri-probabilistic", 1, "probabilistic", "5%", &rank2, "2026-05-29T05:00:00-05:00", "2026-05-29T10:00:00-05:00"),
convectiveOutlook(location, "fri-storm", 1, "categorical", "SLGT", &rank2, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00"),
},
Discussions: []weatherdata.ConvectiveOutlookDiscussion{
{Day: 4, Headline: "day4", UpdatedAt: ptrTime(mustParse("2026-05-31T10:00:00-05:00"))},
{Day: 1, Headline: "day1 late", UpdatedAt: ptrTime(mustParse("2026-05-29T09:00:00-05:00"))},
{Day: 3, Headline: "day3", UpdatedAt: ptrTime(mustParse("2026-05-31T08:00:00-05:00"))},
{Day: 1, Headline: "day1 early", UpdatedAt: ptrTime(mustParse("2026-05-29T08:00:00-05:00"))},
{Day: 2, Headline: "day2", UpdatedAt: ptrTime(mustParse("2026-05-30T08:00:00-05:00"))},
},
}
}
func convectiveOutlook(location *time.Location, id string, day int, outlookType string, label string, rank *int, validFrom string, validTo string) weatherdata.ConvectiveOutlook {
return weatherdata.ConvectiveOutlook{
ID: id,
Day: day,
OutlookType: outlookType,
Label: label,
SeverityRank: rank,
ValidFrom: mustParse(validFrom).In(location),
ValidTo: mustParse(validTo).In(location),
}
}
func outlookIDs(outlooks []weatherdata.ConvectiveOutlook) []string {
out := make([]string, 0, len(outlooks))
for _, outlook := range outlooks {
out = append(out, outlook.ID)
}
return out
}
func discussionHeadlines(discussions []weatherdata.ConvectiveOutlookDiscussion) []string {
out := make([]string, 0, len(discussions))
for _, discussion := range discussions {
out = append(out, discussion.Headline)
}
return out
}
func ptrTime(value time.Time) *time.Time {
return &value
}
func hour(location *time.Location, start string, end string, text string, precip float64, gust float64) weatherdata.ForecastPeriod { func hour(location *time.Location, start string, end string, text string, precip float64, gust float64) weatherdata.ForecastPeriod {
temperature := 70.0 temperature := 70.0
return weatherdata.ForecastPeriod{ return weatherdata.ForecastPeriod{

View File

@@ -21,6 +21,8 @@ const (
AlertDigest ID = "alert_digest" AlertDigest ID = "alert_digest"
AreaForecastDiscussion ID = "area_forecast_discussion" AreaForecastDiscussion ID = "area_forecast_discussion"
WeatherStory ID = "weather_story" WeatherStory ID = "weather_story"
SPCConvectiveOutlooks ID = "spc_convective_outlooks"
SPCConvectiveDiscussion ID = "spc_convective_discussion"
OutdoorWindows ID = "outdoor_windows" OutdoorWindows ID = "outdoor_windows"
TomorrowPlanning ID = "tomorrow_planning" TomorrowPlanning ID = "tomorrow_planning"
) )
@@ -105,19 +107,21 @@ func StanzaValue[T any](s Snapshot, name string) (T, bool, error) {
type FactRequirement string type FactRequirement string
const ( const (
CollectedCurrentConditions FactRequirement = "collected.current_conditions" CollectedCurrentConditions FactRequirement = "collected.current_conditions"
CollectedNarrativeForecast FactRequirement = "collected.narrative_forecast" CollectedNarrativeForecast FactRequirement = "collected.narrative_forecast"
CollectedHourlyForecast FactRequirement = "collected.hourly_forecast" CollectedHourlyForecast FactRequirement = "collected.hourly_forecast"
CollectedAlerts FactRequirement = "collected.alerts" CollectedAlerts FactRequirement = "collected.alerts"
CollectedDiscussion FactRequirement = "collected.discussion" CollectedDiscussion FactRequirement = "collected.discussion"
CollectedWeatherStory FactRequirement = "collected.weather_story" CollectedWeatherStory FactRequirement = "collected.weather_story"
CollectedSourceMetadata FactRequirement = "collected.source_metadata" CollectedSPCConvectiveOutlooks FactRequirement = "collected.spc_convective_outlooks"
RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods" CollectedSourceMetadata FactRequirement = "collected.source_metadata"
RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods" RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods"
RequiresDerivedAlertOverlaps FactRequirement = "derived.alert_overlaps" RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods"
RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries" RequiresDerivedAlertOverlaps FactRequirement = "derived.alert_overlaps"
RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries" RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries"
RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing" RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries"
RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing"
RequiresDerivedSPCConvectiveOutlooks FactRequirement = "derived.spc_convective_outlooks"
) )
type MissingDataBehavior string type MissingDataBehavior string
@@ -141,5 +145,7 @@ type AreaForecastDiscussionOptions struct {
Sections []string `json:"sections,omitempty" yaml:"sections,omitempty"` Sections []string `json:"sections,omitempty" yaml:"sections,omitempty"`
} }
type WeatherStoryOptions struct{} type WeatherStoryOptions struct{}
type SPCConvectiveOutlooksOptions struct{}
type SPCConvectiveDiscussionOptions struct{}
type OutdoorWindowsOptions struct{} type OutdoorWindowsOptions struct{}
type TomorrowPlanningOptions struct{} type TomorrowPlanningOptions struct{}

View File

@@ -82,3 +82,21 @@ func TestStanzaValueDecodesTypedOutput(t *testing.T) {
t.Fatal("StanzaValue(missing) found = true, want false") t.Fatal("StanzaValue(missing) found = true, want false")
} }
} }
func TestSPCConvectiveModuleContractsAreStable(t *testing.T) {
if SPCConvectiveOutlooks != ID("spc_convective_outlooks") {
t.Fatalf("SPCConvectiveOutlooks = %q, want stable source/module ID", SPCConvectiveOutlooks)
}
if SPCConvectiveDiscussion != ID("spc_convective_discussion") {
t.Fatalf("SPCConvectiveDiscussion = %q, want stable discussion module ID", SPCConvectiveDiscussion)
}
if CollectedSPCConvectiveOutlooks != FactRequirement("collected.spc_convective_outlooks") {
t.Fatalf("CollectedSPCConvectiveOutlooks = %q, want collected requirement", CollectedSPCConvectiveOutlooks)
}
if RequiresDerivedSPCConvectiveOutlooks != FactRequirement("derived.spc_convective_outlooks") {
t.Fatalf("RequiresDerivedSPCConvectiveOutlooks = %q, want derived requirement", RequiresDerivedSPCConvectiveOutlooks)
}
_ = SPCConvectiveOutlooksOptions{}
_ = SPCConvectiveDiscussionOptions{}
}

View File

@@ -35,6 +35,7 @@ var briefingCategoryOrder = []string{
var briefingStanzaCategories = map[string]string{ var briefingStanzaCategories = map[string]string{
string(module.AlertDigest): categoryApplicableRiskProducts, string(module.AlertDigest): categoryApplicableRiskProducts,
string(module.SPCConvectiveOutlooks): categoryApplicableRiskProducts,
string(module.DerivedDailySummary): categoryDerivedSummaries, string(module.DerivedDailySummary): categoryDerivedSummaries,
string(module.DerivedDaypartSummaries): categoryDerivedSummaries, string(module.DerivedDaypartSummaries): categoryDerivedSummaries,
string(module.PrecipTiming): categoryDerivedSummaries, string(module.PrecipTiming): categoryDerivedSummaries,
@@ -42,6 +43,7 @@ var briefingStanzaCategories = map[string]string{
string(module.TomorrowPlanning): categoryDerivedSummaries, string(module.TomorrowPlanning): categoryDerivedSummaries,
string(module.NarrativeForecast): categoryNarrativeProducts, string(module.NarrativeForecast): categoryNarrativeProducts,
string(module.AreaForecastDiscussion): categoryNarrativeProducts, string(module.AreaForecastDiscussion): categoryNarrativeProducts,
string(module.SPCConvectiveDiscussion): categoryNarrativeProducts,
string(module.WeatherStory): categoryNarrativeProducts, string(module.WeatherStory): categoryNarrativeProducts,
string(module.CurrentConditions): categoryRawData, string(module.CurrentConditions): categoryRawData,
string(module.HourlyForecast): categoryRawData, string(module.HourlyForecast): categoryRawData,

View File

@@ -164,6 +164,50 @@ func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) {
} }
} }
func TestMarshalYAMLPlacesSPCConvectiveStanzasInPromptCategories(t *testing.T) {
req := validBuildRequest(t)
req.Modules = snapshotWithOutputs(t,
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": req.Metadata.RunID}},
module.Output{ID: module.SPCConvectiveDiscussion, StanzaName: string(module.SPCConvectiveDiscussion), Value: map[string]any{"discussions": []string{"day1"}}},
module.Output{ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), Value: map[string]any{"outlook_count": 1}},
)
pkg, err := Build(req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
data, err := MarshalYAML(pkg)
if err != nil {
t.Fatalf("MarshalYAML() error = %v", err)
}
text := string(data)
riskIndex := strings.Index(text, " applicable_risk_products:\n")
outlookIndex := strings.Index(text, " spc_convective_outlooks:\n")
narrativeIndex := strings.Index(text, " narrative_products:\n")
discussionIndex := strings.Index(text, " spc_convective_discussion:\n")
if riskIndex < 0 || outlookIndex < 0 || narrativeIndex < 0 || discussionIndex < 0 {
t.Fatalf("YAML output missing SPC convective category placement:\n%s", text)
}
if !(riskIndex < outlookIndex && outlookIndex < narrativeIndex && narrativeIndex < discussionIndex) {
t.Fatalf("YAML output placed SPC convective stanzas in wrong order:\n%s", text)
}
if strings.Contains(text, " raw_data:\n spc_convective") || strings.Contains(text, " derived_summaries:\n spc_convective") {
t.Fatalf("YAML output placed SPC convective stanzas in wrong category:\n%s", text)
}
loaded, err := LoadYAML(data)
if err != nil {
t.Fatalf("LoadYAML() error = %v", err)
}
if _, ok := loaded.Briefing.Values[string(module.SPCConvectiveOutlooks)]; !ok {
t.Fatal("loaded package missing spc_convective_outlooks stanza")
}
if _, ok := loaded.Briefing.Values[string(module.SPCConvectiveDiscussion)]; !ok {
t.Fatal("loaded package missing spc_convective_discussion stanza")
}
}
func TestLoadYAMLRoundTrip(t *testing.T) { func TestLoadYAMLRoundTrip(t *testing.T) {
pkg, err := Build(validBuildRequest(t)) pkg, err := Build(validBuildRequest(t))
if err != nil { if err != nil {

View File

@@ -46,7 +46,9 @@ func dailyTodayModules() []module.ConfigItem {
module.DerivedDaypartSummaries, module.DerivedDaypartSummaries,
module.PrecipTiming, module.PrecipTiming,
module.AlertDigest, module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion, module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory, module.WeatherStory,
module.OutdoorWindows, module.OutdoorWindows,
module.HourlyForecast, module.HourlyForecast,
@@ -62,7 +64,9 @@ func dailyTomorrowModules() []module.ConfigItem {
module.DerivedDaypartSummaries, module.DerivedDaypartSummaries,
module.PrecipTiming, module.PrecipTiming,
module.AlertDigest, module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion, module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory, module.WeatherStory,
module.OutdoorWindows, module.OutdoorWindows,
module.TomorrowPlanning, module.TomorrowPlanning,

View File

@@ -14,6 +14,7 @@ type ID string
const ( const (
DailyToday ID = "daily_today" DailyToday ID = "daily_today"
DailyTomorrow ID = "daily_tomorrow" DailyTomorrow ID = "daily_tomorrow"
NearTerm ID = "near_term"
ThreeDay ID = "three_day" ThreeDay ID = "three_day"
Weekend ID = "weekend" Weekend ID = "weekend"
Storm ID = "storm" Storm ID = "storm"
@@ -25,6 +26,7 @@ const (
CompareSameValidDate ComparisonStrategy = "same_valid_date" CompareSameValidDate ComparisonStrategy = "same_valid_date"
CompareWeekendWindow ComparisonStrategy = "same_weekend_window" CompareWeekendWindow ComparisonStrategy = "same_weekend_window"
CompareExplicitWindow ComparisonStrategy = "explicit_event_window" CompareExplicitWindow ComparisonStrategy = "explicit_event_window"
CompareRollingWindow ComparisonStrategy = "rolling_window"
) )
type Batch string type Batch string

View File

@@ -0,0 +1,52 @@
package report
import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
const nearTermHours = 6
func nearTermDefinition() Definition {
return Definition{
ID: NearTerm,
Name: "Near-Term Report",
PromptID: "weather.near_term_report",
ComparisonStrategy: CompareRollingWindow,
ArtifactGroup: "near-term",
BatchOutputName: "near-term.md",
Generated: true,
CompatiblePriorIDs: []ID{NearTerm},
Modules: nearTermModules(),
resolve: resolveNearTerm,
}
}
func nearTermModules() []module.ConfigItem {
return []module.ConfigItem{
{ID: module.Metadata},
{ID: module.CurrentConditions},
{ID: module.HourlyForecast},
{ID: module.PrecipTiming},
{ID: module.AlertDigest},
{ID: module.SPCConvectiveOutlooks},
{
ID: module.AreaForecastDiscussion,
Options: module.AreaForecastDiscussionOptions{
Sections: []string{"key_messages", "short_term"},
},
},
{ID: module.SPCConvectiveDiscussion},
{ID: module.WeatherStory},
}
}
func resolveNearTerm(req ResolveRequest) (timeutil.Period, error) {
localNow := req.Now.In(req.Location)
return timeutil.Period{
Start: localNow,
End: localNow.Add(nearTermHours * time.Hour),
}, nil
}

View File

@@ -58,6 +58,51 @@ func TestThreeDayPeriodCalculation(t *testing.T) {
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:00:00-05:00", "2026-06-01T00:00:00-05:00") assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:00:00-05:00", "2026-06-01T00:00:00-05:00")
} }
func TestNearTermLookupAndPeriodCalculation(t *testing.T) {
location := mustLoadLocation(t)
now := mustParse("2026-05-29T05:15:00-05:00")
resolved, err := Resolve(NearTerm, ResolveRequest{Now: now, Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Definition.ID != NearTerm {
t.Fatalf("ID = %q, want near_term", resolved.Definition.ID)
}
if resolved.Definition.PromptID != "weather.near_term_report" {
t.Fatalf("PromptID = %q, want weather.near_term_report", resolved.Definition.PromptID)
}
if resolved.Definition.ComparisonStrategy != CompareRollingWindow {
t.Fatalf("ComparisonStrategy = %q, want rolling_window", resolved.Definition.ComparisonStrategy)
}
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:15:00-05:00", "2026-05-29T11:15:00-05:00")
}
func TestNearTermPeriodUsesEffectiveTimezone(t *testing.T) {
location, err := time.LoadLocation("America/New_York")
if err != nil {
t.Fatalf("load location: %v", err)
}
now := mustParse("2026-05-29T10:15:00Z")
resolved, err := Resolve(NearTerm, ResolveRequest{Now: now, Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T06:15:00-04:00", "2026-05-29T12:15:00-04:00")
}
func TestNearTermPeriodIsNotCivilDayTruncated(t *testing.T) {
location := mustLoadLocation(t)
now := mustParse("2026-05-29T22:30:00-05:00")
resolved, err := Resolve(NearTerm, ResolveRequest{Now: now, Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T22:30:00-05:00", "2026-05-30T04:30:00-05:00")
}
func TestWeekendPeriodCalculation(t *testing.T) { func TestWeekendPeriodCalculation(t *testing.T) {
location := mustLoadLocation(t) location := mustLoadLocation(t)
tests := []struct { tests := []struct {
@@ -158,6 +203,25 @@ func TestEveningBatchIncludesTomorrow(t *testing.T) {
} }
} }
func TestBatchesDoNotIncludeNearTerm(t *testing.T) {
location := mustLoadLocation(t)
req := ResolveRequest{Now: mustParse("2026-05-29T06:00:00-05:00"), Location: location}
morning, err := DefaultRegistry().BatchReports(Morning, req)
if err != nil {
t.Fatalf("BatchReports(morning) error = %v", err)
}
evening, err := DefaultRegistry().BatchReports(Evening, req)
if err != nil {
t.Fatalf("BatchReports(evening) error = %v", err)
}
for _, resolved := range append(morning, evening...) {
if resolved.Definition.ID == NearTerm {
t.Fatalf("batch included %q, want near-term excluded", resolved.Definition.ID)
}
}
}
func TestRegistryLookupErrorIsActionable(t *testing.T) { func TestRegistryLookupErrorIsActionable(t *testing.T) {
_, err := DefaultRegistry().Lookup(ID("unknown")) _, err := DefaultRegistry().Lookup(ID("unknown"))
if err == nil { if err == nil {
@@ -168,6 +232,14 @@ func TestRegistryLookupErrorIsActionable(t *testing.T) {
} }
} }
func TestRegistryAllIncludesNearTermInStableOrder(t *testing.T) {
ids := resolvedDefinitionIDs(DefaultRegistry().All())
want := []string{"daily_today", "daily_tomorrow", "near_term", "three_day", "weekend", "storm"}
if strings.Join(ids, ",") != strings.Join(want, ",") {
t.Fatalf("All() ids = %#v, want %#v", ids, want)
}
}
func TestRegistryDefinitionsHavePromptIDsAndComparisonStrategies(t *testing.T) { func TestRegistryDefinitionsHavePromptIDsAndComparisonStrategies(t *testing.T) {
for _, definition := range DefaultRegistry().All() { for _, definition := range DefaultRegistry().All() {
if definition.PromptID == "" { if definition.PromptID == "" {
@@ -186,6 +258,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
batchOutputName string batchOutputName string
generated bool generated bool
compatiblePriorIDs []ID compatiblePriorIDs []ID
comparisonStrategy ComparisonStrategy
}{ }{
{ {
id: DailyToday, id: DailyToday,
@@ -193,6 +266,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
batchOutputName: "daily.md", batchOutputName: "daily.md",
generated: true, generated: true,
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow}, compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
comparisonStrategy: CompareSameValidDate,
}, },
{ {
id: DailyTomorrow, id: DailyTomorrow,
@@ -200,6 +274,15 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
batchOutputName: "tomorrow.md", batchOutputName: "tomorrow.md",
generated: true, generated: true,
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow}, compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
comparisonStrategy: CompareSameValidDate,
},
{
id: NearTerm,
artifactGroup: "near-term",
batchOutputName: "near-term.md",
generated: true,
compatiblePriorIDs: []ID{NearTerm},
comparisonStrategy: CompareRollingWindow,
}, },
{ {
id: ThreeDay, id: ThreeDay,
@@ -207,6 +290,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
batchOutputName: "three-day.md", batchOutputName: "three-day.md",
generated: true, generated: true,
compatiblePriorIDs: []ID{ThreeDay}, compatiblePriorIDs: []ID{ThreeDay},
comparisonStrategy: CompareSameValidDate,
}, },
{ {
id: Weekend, id: Weekend,
@@ -214,6 +298,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
batchOutputName: "weekend.md", batchOutputName: "weekend.md",
generated: true, generated: true,
compatiblePriorIDs: []ID{Weekend}, compatiblePriorIDs: []ID{Weekend},
comparisonStrategy: CompareWeekendWindow,
}, },
{ {
id: Storm, id: Storm,
@@ -221,6 +306,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
batchOutputName: "storm.md", batchOutputName: "storm.md",
generated: true, generated: true,
compatiblePriorIDs: []ID{Storm}, compatiblePriorIDs: []ID{Storm},
comparisonStrategy: CompareExplicitWindow,
}, },
} }
@@ -240,6 +326,9 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
if definition.Generated != tt.generated { if definition.Generated != tt.generated {
t.Fatalf("Generated = %t, want %t", definition.Generated, tt.generated) t.Fatalf("Generated = %t, want %t", definition.Generated, tt.generated)
} }
if definition.ComparisonStrategy != tt.comparisonStrategy {
t.Fatalf("ComparisonStrategy = %q, want %q", definition.ComparisonStrategy, tt.comparisonStrategy)
}
if !reflect.DeepEqual(definition.CompatiblePriorIDs, tt.compatiblePriorIDs) { if !reflect.DeepEqual(definition.CompatiblePriorIDs, tt.compatiblePriorIDs) {
t.Fatalf("CompatiblePriorIDs = %#v, want %#v", definition.CompatiblePriorIDs, tt.compatiblePriorIDs) t.Fatalf("CompatiblePriorIDs = %#v, want %#v", definition.CompatiblePriorIDs, tt.compatiblePriorIDs)
} }
@@ -267,7 +356,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
module.DerivedDaypartSummaries, module.DerivedDaypartSummaries,
module.PrecipTiming, module.PrecipTiming,
module.AlertDigest, module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion, module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory, module.WeatherStory,
module.OutdoorWindows, module.OutdoorWindows,
module.HourlyForecast, module.HourlyForecast,
@@ -283,13 +374,29 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
module.DerivedDaypartSummaries, module.DerivedDaypartSummaries,
module.PrecipTiming, module.PrecipTiming,
module.AlertDigest, module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion, module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory, module.WeatherStory,
module.OutdoorWindows, module.OutdoorWindows,
module.TomorrowPlanning, module.TomorrowPlanning,
module.HourlyForecast, module.HourlyForecast,
}, },
}, },
{
id: NearTerm,
want: []module.ID{
module.Metadata,
module.CurrentConditions,
module.HourlyForecast,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
},
},
{ {
id: ThreeDay, id: ThreeDay,
want: []module.ID{ want: []module.ID{
@@ -298,7 +405,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
module.DerivedDaypartSummaries, module.DerivedDaypartSummaries,
module.PrecipTiming, module.PrecipTiming,
module.AlertDigest, module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion, module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory, module.WeatherStory,
module.OutdoorWindows, module.OutdoorWindows,
}, },
@@ -311,7 +420,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
module.DerivedDaypartSummaries, module.DerivedDaypartSummaries,
module.PrecipTiming, module.PrecipTiming,
module.AlertDigest, module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion, module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory, module.WeatherStory,
module.OutdoorWindows, module.OutdoorWindows,
}, },
@@ -323,7 +434,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
module.CurrentConditions, module.CurrentConditions,
module.PrecipTiming, module.PrecipTiming,
module.AlertDigest, module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion, module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory, module.WeatherStory,
}, },
}, },
@@ -402,6 +515,24 @@ func TestResolvedMetadata(t *testing.T) {
} }
} }
func TestNearTermMetadataRunIDIncludesReportID(t *testing.T) {
location := mustLoadLocation(t)
resolved, err := Resolve(NearTerm, ResolveRequest{Now: mustParse("2026-05-29T05:15:00-05:00"), Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
metadata := resolved.Metadata()
if metadata.ReportID != NearTerm {
t.Fatalf("ReportID = %q, want near_term", metadata.ReportID)
}
if metadata.PromptID != "weather.near_term_report" {
t.Fatalf("PromptID = %q, want weather.near_term_report", metadata.PromptID)
}
if !strings.Contains(metadata.RunID, "near_term") {
t.Fatalf("RunID = %q, want report id", metadata.RunID)
}
}
func assertPeriod(t *testing.T, period timeutil.Period, wantStart string, wantEnd string) { func assertPeriod(t *testing.T, period timeutil.Period, wantStart string, wantEnd string) {
t.Helper() t.Helper()
if !period.IsValid() { if !period.IsValid() {
@@ -423,6 +554,14 @@ func resolvedIDs(resolved []Resolved) []string {
return ids return ids
} }
func resolvedDefinitionIDs(definitions []Definition) []string {
ids := make([]string, 0, len(definitions))
for _, definition := range definitions {
ids = append(ids, string(definition.ID))
}
return ids
}
func mustLoadLocation(t *testing.T) *time.Location { func mustLoadLocation(t *testing.T) *time.Location {
t.Helper() t.Helper()
location, err := time.LoadLocation("America/Chicago") location, err := time.LoadLocation("America/Chicago")

View File

@@ -14,6 +14,7 @@ func DefaultRegistry() Registry {
definitions := []Definition{ definitions := []Definition{
dailyTodayDefinition(), dailyTodayDefinition(),
dailyTomorrowDefinition(), dailyTomorrowDefinition(),
nearTermDefinition(),
threeDayDefinition(), threeDayDefinition(),
weekendDefinition(), weekendDefinition(),
stormDefinition(), stormDefinition(),
@@ -73,7 +74,7 @@ func (r Registry) MustLookup(id ID) Definition {
} }
func (r Registry) All() []Definition { func (r Registry) All() []Definition {
ids := []ID{DailyToday, DailyTomorrow, ThreeDay, Weekend, Storm} ids := []ID{DailyToday, DailyTomorrow, NearTerm, ThreeDay, Weekend, Storm}
out := make([]Definition, 0, len(ids)) out := make([]Definition, 0, len(ids))
for _, id := range ids { for _, id := range ids {
if definition, ok := r.definitions[id]; ok { if definition, ok := r.definitions[id]; ok {

View File

@@ -29,7 +29,9 @@ func stormModules() []module.ConfigItem {
module.CurrentConditions, module.CurrentConditions,
module.PrecipTiming, module.PrecipTiming,
module.AlertDigest, module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion, module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory, module.WeatherStory,
) )
} }

View File

@@ -30,7 +30,9 @@ func threeDayModules() []module.ConfigItem {
module.DerivedDaypartSummaries, module.DerivedDaypartSummaries,
module.PrecipTiming, module.PrecipTiming,
module.AlertDigest, module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion, module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory, module.WeatherStory,
module.OutdoorWindows, module.OutdoorWindows,
) )

View File

@@ -31,7 +31,9 @@ func weekendModules() []module.ConfigItem {
module.DerivedDaypartSummaries, module.DerivedDaypartSummaries,
module.PrecipTiming, module.PrecipTiming,
module.AlertDigest, module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion, module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory, module.WeatherStory,
module.OutdoorWindows, module.OutdoorWindows,
) )

View File

@@ -305,6 +305,21 @@ func TestFindPriorSnapshotSupportsNarrowedWeekendPeriod(t *testing.T) {
} }
} }
func TestFindPriorSnapshotIgnoresRollingWindowReports(t *testing.T) {
store := newTestStore(t)
first := resolveNearTermAt(t, "2026-05-29T05:00:00-05:00")
second := resolveNearTermAt(t, "2026-05-29T06:00:00-05:00")
savePriorMetadata(t, store, first, stateBriefingMetadata(first))
prior, err := store.FindPriorSnapshot(context.Background(), second)
if err != nil {
t.Fatalf("FindPriorSnapshot() error = %v", err)
}
if prior != nil {
t.Fatalf("FindPriorSnapshot() = %#v, want nil for rolling-window comparison", prior)
}
}
func TestFilesystemStoreRejectsUnsafeDirs(t *testing.T) { func TestFilesystemStoreRejectsUnsafeDirs(t *testing.T) {
cfg := config.Defaults().Workspace cfg := config.Defaults().Workspace
cfg.Root = t.TempDir() cfg.Root = t.TempDir()
@@ -390,6 +405,26 @@ func resolveWeekendAt(t *testing.T, value string) report.Resolved {
return resolved return resolved
} }
func resolveNearTermAt(t *testing.T, value string) report.Resolved {
t.Helper()
location, err := timeutil.LoadLocation("America/Chicago")
if err != nil {
t.Fatalf("LoadLocation() error = %v", err)
}
now, err := time.Parse(time.RFC3339, value)
if err != nil {
t.Fatalf("parse time: %v", err)
}
resolved, err := report.DefaultRegistry().Resolve(report.NearTerm, report.ResolveRequest{
Now: now,
Location: location,
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
return resolved
}
func stateBriefingMetadata(resolved report.Resolved) briefing.Metadata { func stateBriefingMetadata(resolved report.Resolved) briefing.Metadata {
return briefing.Metadata{ return briefing.Metadata{
RunID: resolved.Metadata().RunID, RunID: resolved.Metadata().RunID,

View File

@@ -7,17 +7,18 @@ import (
) )
type Bundle struct { type Bundle struct {
FetchedAt time.Time `json:"fetchedAt"` FetchedAt time.Time `json:"fetchedAt"`
Observation *Observation `json:"observation,omitempty"` Observation *Observation `json:"observation,omitempty"`
Current *Current `json:"current,omitempty"` Current *Current `json:"current,omitempty"`
Hourly *ForecastRun `json:"hourly,omitempty"` Hourly *ForecastRun `json:"hourly,omitempty"`
Narrative *ForecastRun `json:"narrative,omitempty"` Narrative *ForecastRun `json:"narrative,omitempty"`
Alerts *AlertRun `json:"alerts,omitempty"` Alerts *AlertRun `json:"alerts,omitempty"`
Discussion *Discussion `json:"discussion,omitempty"` Discussion *Discussion `json:"discussion,omitempty"`
Daily *ForecastRun `json:"daily,omitempty"` Daily *ForecastRun `json:"daily,omitempty"`
WeatherStory *WeatherStory `json:"weatherStory,omitempty"` WeatherStory *WeatherStory `json:"weatherStory,omitempty"`
Sources []Source `json:"sources"` SPCConvectiveOutlooks *ConvectiveOutlookRun `json:"spcConvectiveOutlooks,omitempty"`
Warnings []SourceWarning `json:"warnings,omitempty"` Sources []Source `json:"sources"`
Warnings []SourceWarning `json:"warnings,omitempty"`
} }
type Source struct { type Source struct {
@@ -166,3 +167,42 @@ type WeatherStory struct {
Order int `json:"order"` Order int `json:"order"`
DownloadURL string `json:"downloadUrl,omitempty"` DownloadURL string `json:"downloadUrl,omitempty"`
} }
type ConvectiveOutlookRun struct {
LocationID string `json:"locationId,omitempty"`
LocationName string `json:"locationName,omitempty"`
AsOf *time.Time `json:"asOf,omitempty"`
IssuedAt *time.Time `json:"issuedAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
Product string `json:"product,omitempty"`
Outlooks []ConvectiveOutlook `json:"outlooks,omitempty"`
Discussions []ConvectiveOutlookDiscussion `json:"discussions,omitempty"`
}
type ConvectiveOutlook struct {
ID string `json:"id,omitempty"`
Provider string `json:"provider,omitempty"`
Product string `json:"product,omitempty"`
Day int `json:"day,omitempty"`
OutlookType string `json:"outlookType,omitempty"`
Label string `json:"label,omitempty"`
LabelText string `json:"labelText,omitempty"`
Forecaster string `json:"forecaster,omitempty"`
SeverityRank *int `json:"severityRank,omitempty"`
ValidFrom time.Time `json:"validFrom"`
ValidTo time.Time `json:"validTo"`
IssuedAt *time.Time `json:"issuedAt,omitempty"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
SourceURL string `json:"sourceUrl,omitempty"`
ImageURL string `json:"imageUrl,omitempty"`
ContainsLocation bool `json:"containsLocation"`
Geometry json.RawMessage `json:"geometry,omitempty"`
}
type ConvectiveOutlookDiscussion struct {
Day int `json:"day,omitempty"`
Headline string `json:"headline,omitempty"`
Summary string `json:"summary,omitempty"`
Discussion string `json:"discussion,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
}

View File

@@ -0,0 +1,78 @@
package weatherdata
import (
"encoding/json"
"testing"
"time"
)
func TestConvectiveOutlookRunJSONPreservesGeometry(t *testing.T) {
severity := 3
validFrom := mustParseBundleTime("2026-06-12T13:00:00Z")
validTo := mustParseBundleTime("2026-06-13T12:00:00Z")
issuedAt := mustParseBundleTime("2026-06-12T12:30:00Z")
updatedAt := mustParseBundleTime("2026-06-12T12:45:00Z")
run := ConvectiveOutlookRun{
LocationID: "test-grid",
LocationName: "Brentwood",
AsOf: &updatedAt,
IssuedAt: &issuedAt,
Product: "convective_outlook",
Outlooks: []ConvectiveOutlook{{
ID: "day1-categorical-slight",
Provider: "spc",
Product: "convective_outlook",
Day: 1,
OutlookType: "categorical",
Label: "SLGT",
LabelText: "Slight Risk",
Forecaster: "Smith",
SeverityRank: &severity,
ValidFrom: validFrom,
ValidTo: validTo,
IssuedAt: &issuedAt,
ExpiresAt: &validTo,
SourceURL: "https://example.test/source",
ImageURL: "https://example.test/image.png",
ContainsLocation: true,
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[-90.1,38.1],[-90.0,38.2],[-90.1,38.1]]]}`),
}},
Discussions: []ConvectiveOutlookDiscussion{{
Day: 1,
Headline: "Severe storms possible",
Summary: "Scattered severe storms are possible.",
Discussion: "Discussion text.",
UpdatedAt: &updatedAt,
}},
}
data, err := json.Marshal(run)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
var decoded ConvectiveOutlookRun
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(decoded.Outlooks) != 1 || string(decoded.Outlooks[0].Geometry) == "" {
t.Fatalf("decoded outlooks = %#v, want preserved geometry", decoded.Outlooks)
}
if got := string(decoded.Outlooks[0].Geometry); got != `{"type":"Polygon","coordinates":[[[-90.1,38.1],[-90.0,38.2],[-90.1,38.1]]]}` {
t.Fatalf("Geometry = %s, want preserved GeoJSON coordinates", got)
}
if decoded.Outlooks[0].SeverityRank == nil || *decoded.Outlooks[0].SeverityRank != severity {
t.Fatalf("SeverityRank = %#v, want %d", decoded.Outlooks[0].SeverityRank, severity)
}
if len(decoded.Discussions) != 1 || decoded.Discussions[0].Headline != "Severe storms possible" {
t.Fatalf("Discussions = %#v, want decoded discussion", decoded.Discussions)
}
}
func mustParseBundleTime(value string) time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
panic(err)
}
return parsed
}