36 Commits

Author SHA1 Message Date
9d6502460e Update buildRenderContext to use Definition.TemplateID instead of GeneratedTextSchemaID 2026-06-14 12:23:50 +00:00
fcf1108641 Strengthen final hourly workflow validation 2026-06-14 05:24:25 +00:00
185605fbf0 Document hourly generated text behavior 2026-06-14 05:21:15 +00:00
662d906997 Cover hourly CLI generation workflow 2026-06-14 05:17:05 +00:00
f6e20d1412 Notify hourly generated reports after success 2026-06-14 05:13:40 +00:00
9e14a9b7c7 Preserve generated text failure artifacts 2026-06-14 05:08:26 +00:00
422430613c Wire hourly generated text rendering 2026-06-14 05:01:26 +00:00
2a4ce64d6f Add structured Scriptorium run support 2026-06-14 04:55:07 +00:00
316ab8f3fc Add generated text state artifacts 2026-06-14 04:51:52 +00:00
f6a68426e1 Add hourly generated text contract 2026-06-14 04:46:53 +00:00
bed2b84100 Add embedded hourly report template assets 2026-06-14 04:38:48 +00:00
b39ec1e4d3 Add hourly generated-text report shell 2026-06-14 04:35:52 +00:00
dd92e9b061 Add report generation mode metadata 2026-06-14 04:28:30 +00:00
8d737395dc Rename rolling report to hourly 2026-06-14 04:26:05 +00:00
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
78 changed files with 6791 additions and 341 deletions

View File

@@ -21,6 +21,7 @@ the managed Markdown report after final metadata is saved.
weatherreporter --help
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 hourly [--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 storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
@@ -34,11 +35,16 @@ weatherreporter inspect prior [--config PATH] RUN_ID
weatherreporter inspect sources [--config PATH] RUN_ID
```
`generate` commands write a JSON module snapshot, YAML data package, preflight
artifact, managed Markdown report, and metadata under the configured workspace.
`--out` writes an extra Markdown copy for the operator; distributor
notification uses the managed report path, not the extra copy. `generate storm`
requires explicit event-window bounds with `--start` and `--end`.
Implemented `generate` commands write a JSON module snapshot, YAML data package,
preflight artifact, managed Markdown report, and metadata under the configured
workspace. `--out` writes an extra Markdown copy for the operator; distributor
notification uses the managed report path, not the extra copy. `generate
hourly` covers the next six hours in the effective report timezone, does not
accept date or event window flags, writes managed generated-text artifacts,
validates the structured text, and renders the managed Markdown report from the
embedded hourly template.
`generate storm` requires explicit event-window bounds with `--start` and
`--end`.
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch
@@ -50,6 +56,9 @@ notification is enabled, batch summaries and status lines include notification
status, accepted distributor run ID, or notification error fields for each
attempted report.
Hourly Report is explicit only; it is not included in `run morning` or `run
evening`.
`inspect` commands read existing workspace artifacts and emit JSON to stdout.
They do not fetch weather data or invoke `scriptorium`.
@@ -59,7 +68,7 @@ They do not fetch weather data or invoke `scriptorium`.
- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`.
- `--units VALUE`: override configured Weather API units for `generate` and `run`.
- `--tz NAME`: override configured Weather API timezone for `generate` and `run`.
- `--out PATH`: write an extra Markdown report copy for `generate` commands.
- `--out PATH`: write an extra Markdown report copy where supported by the `generate` command.
- `--out-dir PATH`: write extra Markdown report copies for `run morning` and `run evening`.
- `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone.
- `--start TIME`: required start time for `generate storm`.
@@ -76,6 +85,7 @@ are no distributor-specific CLI flags.
```sh
weatherreporter generate tomorrow --out ./tomorrow.md
weatherreporter generate hourly
weatherreporter generate three-day --out ./three-day.md
weatherreporter generate weekend --out ./weekend.md
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md

View File

@@ -81,7 +81,7 @@ missing directories, and unreadable files fail config loading.
`notify.distributor` controls distributor notification after successful report
generation. It is disabled by default and does not add CLI flags. When enabled,
weatherreporter uploads one distributor bundle per generated report after
`scriptorium run` succeeds and final metadata is saved.
report rendering succeeds and final metadata is saved.
- `enabled`: whether distributor notification config is active. Default:
`false`.
@@ -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.
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`
@@ -179,9 +181,9 @@ snapshot exists and a threshold is crossed.
`reports` optionally overrides the ordered deterministic modules declared by
report definitions. Omit a report entry to use its default module order.
Supported report keys are `daily`, `tomorrow`, `three_day`, `weekend`, and
`storm`. Canonical report IDs such as `daily_today` and `daily_tomorrow` are
also accepted.
Supported report keys are `daily`, `tomorrow`, `hourly`, `three_day`,
`weekend`, and `storm`. Canonical report IDs such as `daily_today` and
`daily_tomorrow` are also accepted.
Each report entry supports:
@@ -197,11 +199,29 @@ reports:
- metadata
- current_conditions
- narrative_forecast
- alert_digest
- spc_convective_outlooks
- id: area_forecast_discussion
options:
sections:
- short_term
- spc_convective_discussion
- hourly_forecast
hourly:
deterministic_modules:
- metadata
- current_conditions
- hourly_forecast
- precip_timing
- alert_digest
- spc_convective_outlooks
- id: area_forecast_discussion
options:
sections:
- key_messages
- short_term
- spc_convective_discussion
- weather_story
```
Unknown reports, unknown modules, duplicate modules, incompatible report/module

View File

@@ -6,7 +6,7 @@ This document describes the external Scriptorium CLI contract used by
## Purpose
`weatherreporter` invokes Scriptorium as a subprocess to preflight prompt input
and generate Markdown reports. This page documents the CLI surface the adapter
and generate report artifacts. This page documents the CLI surface the adapter
uses, not the full Scriptorium product.
## Commands Used
@@ -29,11 +29,25 @@ scriptorium run \
--out <artifact_path>
```
Structured generated-text report generation uses the same command shape:
```bash
scriptorium run \
--prompt <prompt_id> \
--input data_package=<path> \
--out <generated_text_raw_path>
```
`weatherreporter` always passes prompt input as
`--input data_package=<path>`. The data package is structured YAML created by
`internal/promptinput`; module snapshots remain separate JSON artifacts for
inspection and Recent Changes.
For generated-text reports, Scriptorium selects the structured output schema
from the prompt configuration associated with the prompt ID. `weatherreporter`
does not pass `--format`, schema path, or JSON Schema flags for structured
generation.
## Configured Arguments
The adapter can prepend configured flags before prompt-specific arguments:
@@ -68,11 +82,16 @@ Render results include:
- exit code
- truncation flags when applicable
Run results include the same fields plus the requested output path.
Run results include the same fields plus the requested output path. Structured
generated-text run results use the same captured fields and output-path
recording, with the output path pointing at the raw generated-text JSON
artifact.
`weatherreporter` persists render preflight JSON when orchestration reaches the
preflight save point. The final Markdown artifact is written by Scriptorium to
the `--out` path.
preflight save point. Markdown report artifacts are written by Scriptorium to
the `--out` path. Generated-text raw JSON artifacts are also written by
Scriptorium to the `--out` path; later weatherreporter workflow steps validate
and render those bytes.
## Failure Behavior
@@ -80,7 +99,7 @@ The adapter validates required request fields before starting Scriptorium:
- prompt ID
- data package path
- output path for `run`
- output path for `run` and structured generated-text `run`
Nonzero exits return both the captured result and an error containing the exit
code and stderr. A `run` exit code such as `2` is still treated as an error by

View File

@@ -37,6 +37,10 @@ generation.
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.
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
endpoint context in returned errors. Decode errors include source context when
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`
- `precision`: from `weather_api.precision` on observations, current
conditions, hourly forecast, and narrative forecast requests
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast, and
discussion requests
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast,
discussion, and SPC convective outlook requests
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
@@ -66,6 +125,7 @@ The adapter fetches these endpoints once per bundle:
- `/alerts/active`
- `/discussion`
- `/weatherstories/latest`
- `/outlooks/convective`
`weatherreporter` does not call day-slice forecast endpoints or discussion
subsection endpoints. Report-period selection and daypart summarization happen
@@ -88,6 +148,7 @@ source-specific `missing_source.sources` policy:
- `alerts` for `/alerts/active`
- `discussion` for `/discussion`
- `weather_story` for `/weatherstories/latest`
- `spc_convective_outlooks` for `/outlooks/convective`
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
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
For source payloads accepted into the bundle, including the explicit `null`
@@ -116,8 +180,8 @@ warnings list.
## Compatibility Assumptions
The adapter expects payload fields compatible with the internal forecast bundle
types in `internal/forecast/bundle.go`, including:
The adapter expects payload fields compatible with the internal weather data
bundle types in `internal/weatherdata/bundle.go`, including:
- observation timestamps and observation 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
- latest weather story title, description, timing, priority, order, alt text,
and download URL
- SPC convective outlook run metadata, outlooks, discussions, and GeoJSON
geometry
The adapter intentionally keeps upstream transport and envelope details inside
`internal/adapters/weatherapi`; downstream packages consume the normalized

View File

@@ -28,7 +28,8 @@ Outputs:
- generated report results with JSON module snapshot, YAML data package,
preflight, report, metadata, prior snapshot, Recent Changes, Scriptorium
result details, and notification result when attempted
result details, generated-text artifact paths when applicable, and
notification result when attempted
- batch summaries with per-report status, artifact paths, error text, and
notification outcome when attempted
- saved Weather API bundle JSON for fetch workflows
@@ -49,6 +50,19 @@ subprocess behavior stays in `internal/adapters/scriptorium`. Distributor
upload behavior stays in `internal/adapters/distributor`. Filesystem layout and
persisted metadata stay in `internal/state`.
## Data Flow Terms
- `CollectedFacts` are normalized source facts fetched once from Weather API
and made available to derivation and module builders.
- `DerivedFacts` are deterministic calculations over collected facts, the
resolved valid period, daypart configuration, and report-specific windows.
- `module.Output` values are ordered deterministic stanzas built from collected
and derived facts for prompt input and inspection.
- `GeneratedText` is structured prose returned by Scriptorium for
generated-text-template reports and validated by `internal/generatedtext`.
- `RenderContext` is the typed template input built from report metadata,
module outputs, and validated generated text before Markdown rendering.
## Config Fields Used
- `weather_api.*` for Weather API client construction and module metadata
@@ -63,7 +77,7 @@ defaults.
## Generation Workflow
Single-report generation follows this order:
Single-report generation shares this setup:
1. Resolve the command report to a `report.Resolved` value.
2. Create or use a filesystem store.
@@ -76,13 +90,31 @@ Single-report generation follows this order:
9. Run Scriptorium render preflight.
10. Save preflight JSON when a render result is available.
11. Save metadata for inspection.
12. Run Scriptorium report generation to the managed report path.
13. Copy the managed report to the requested `--out` path when provided.
14. Save metadata with the managed report path.
15. If distributor notification is enabled, notify using the managed report
For `scriptorium_markdown` reports, generation then:
12. Runs Scriptorium report generation to the managed report path.
13. Copies the managed report to the requested `--out` path when provided.
14. Saves metadata with the managed report path.
15. If distributor notification is enabled, notifies using the managed report
path as the source file.
16. Save a distributor notification debug artifact and update metadata with its
path.
16. Saves a distributor notification debug artifact and updates metadata with
its path.
For `generated_text_template` reports, generation then:
12. Runs structured Scriptorium generation to the raw generated-text JSON path.
13. Saves the structured Scriptorium run result.
14. Validates and saves normalized generated text.
15. Builds and saves a typed render context.
16. Renders Markdown from the embedded template to the managed report path.
17. Saves final metadata with generated-text paths, render context path, schema
ID, and managed report path.
18. Copies the managed report to the requested `--out` path when provided.
19. If distributor notification is enabled, notifies using the managed report
path as the source file.
20. Saves a distributor notification debug artifact and updates metadata with
its path.
If render preflight returns both a result and an error, preflight JSON and
metadata are persisted before the error is returned. If Scriptorium report
@@ -90,6 +122,10 @@ generation returns an error after writing output, the managed report and
metadata remain inspectable. Notification is not attempted after Weather API,
module snapshot, prompt input, render, Scriptorium run, or metadata-save
failures.
Generated-text report failures are returned with report ID, RunID, and the
failed operation. When available, the app preserves the latest generated-text
artifacts already reached by the workflow: preflight output, structured run
result, raw generated text, validated generated text, and render context.
When notification is attempted, the debug artifact records request identity,
including rendered pipeline ID, bundle paths, accepted upload fields,
distributor status fields, raw status report JSON when available, and redacted
@@ -122,6 +158,8 @@ inspection view.
runs.
- Prompt input validation fails before render preflight.
- Render and run errors preserve Scriptorium stderr and exit-code context.
- Generated-text report errors preserve available intermediate artifacts and do
not create extra output copies.
- Notification errors are wrapped with report ID, RunID, and managed report path
context and are recorded separately in batch results.
- Metadata and artifact path errors include filesystem context.
@@ -142,6 +180,8 @@ Inspect:
- Generated reports use the same app request and result types regardless of
report ID.
- Render preflight precedes Scriptorium report generation.
- Generated-text reports render Markdown from a curated render context, not from
a raw data package.
- Recent Changes are computed from structured module snapshots.
- Metadata links artifacts produced for a run.
- Distributor notification maps the managed Markdown report path to configured

View File

@@ -28,7 +28,9 @@ Outputs:
supported reports, fact requirements, missing-data behavior, and builder
- `module.Output` values for source-oriented stanzas:
`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:
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
`outdoor_windows`, and `tomorrow_planning`
@@ -36,6 +38,14 @@ Outputs:
Every registered composition entry has a builder. Unknown or unimplemented
module IDs fail validation instead of being skipped.
Hourly 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
where the LLM is expected to reason about report content. Canonical timestamps
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.
`area_forecast_discussion` uses optional `sections` configuration to include a
subset of discussion fields.
subset of discussion fields. Hourly 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
@@ -86,6 +113,11 @@ return an error for invalid required inputs.
forecast discussion, and weather story stanzas.
- Alert digest output distinguishes checked empty alert data from missing alert
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

View File

@@ -20,14 +20,29 @@ Inputs:
Outputs:
- `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,
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
Hourly 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
- This package owns fact assembly and reusable deterministic derivation for a
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
snapshots, write workflow state, invoke Scriptorium, or define modules.
@@ -53,8 +68,13 @@ and inspection.
- Invalid timezone names return an error.
- Missing required hourly forecast data returns the underlying forecast
derivation error for reports that require daily summaries.
- Hourly Report can derive its default module facts without daily or
daypart summaries.
- Missing optional narrative, alert, discussion, daily, or weather story data
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
@@ -67,6 +87,8 @@ Inspect:
- Collected facts are built once from a fetched bundle.
- 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.
- Prompt-specific wording and one-off presentation decisions stay outside this
package.

View File

@@ -0,0 +1,73 @@
# Generated Text Internals
This document describes structured generated-text handling in
`internal/generatedtext`.
## Purpose
`internal/generatedtext` validates structured text returned for
generated-text-template reports and builds curated render contexts for
templates. The first implemented contract is the Hourly Report.
## Inputs And Outputs
Inputs:
- raw Hourly Report GeneratedText JSON
- report metadata from `internal/briefing`
- a module snapshot from `internal/module`
- validated hourly generated text
Outputs:
- typed `Hourly` generated text
- normalized stable JSON for validated hourly generated text
- typed `HourlyRenderContext` values for `internal/reporttemplate`
The hourly generated text JSON accepts:
```json
{
"summary": "string",
"timing": "string",
"impacts": "string",
"confidence": "string"
}
```
`summary`, `timing`, and `impacts` are required after trimming whitespace.
`confidence` is optional and omitted from normalized JSON when blank.
## Boundaries
- This package owns typed generated-text validation and render-context shaping.
- It uses typed module snapshot decoding through `module.StanzaValue`.
- It does not invoke Scriptorium, write state artifacts, choose report
definitions, compare snapshots, or render templates directly in production
workflows.
- It does not use a Go JSON Schema dependency; schema enforcement in Go is
limited to typed JSON decoding, unknown-field rejection, and required-field
checks.
## Failure Behavior
- Malformed generated-text JSON fails with decode context.
- Unknown generated-text JSON fields fail during decoding.
- Empty required hourly fields fail after trimming whitespace.
- Missing required render-context stanzas fail with the stanza name.
- Invalid render metadata, including missing timezone, missing generated time,
or invalid valid period, fails before template rendering.
## Tests
Inspect:
- `internal/generatedtext/hourly_test.go`
- `internal/generatedtext/render_context_test.go`
## Invariants
- Render contexts are curated structs, not raw prompt-input packages.
- Required generated text is normalized before downstream artifact storage.
- Missing optional weather narrative stanzas produce empty or fallback render
context fields rather than forcing raw module data into templates.

View File

@@ -37,7 +37,9 @@ The registry recognizes these IDs:
- `derived_daypart_summaries`
- `precip_timing`
- `alert_digest`
- `spc_convective_outlooks`
- `area_forecast_discussion`
- `spc_convective_discussion`
- `weather_story`
- `outdoor_windows`
- `tomorrow_planning`
@@ -45,9 +47,29 @@ The registry recognizes these IDs:
Every registered module has a builder. Report composition entries that refer to
unknown or unimplemented module IDs fail validation instead of being skipped.
## Hourly Composition
The default Hourly 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`
Hourly 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
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
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
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
- This package owns module identifiers, config item envelopes, output

View File

@@ -41,6 +41,7 @@ briefing:
metadata: {}
applicable_risk_products:
alert_digest: {}
spc_convective_outlooks: {}
derived_summaries:
derived_daily_summary: {}
derived_daypart_summaries: {}
@@ -49,6 +50,7 @@ briefing:
narrative_products:
narrative_forecast: {}
area_forecast_discussion: {}
spc_convective_discussion: {}
weather_story: {}
raw_data:
current_conditions: {}
@@ -62,13 +64,29 @@ weather module stanzas under prompt-facing categories. This grouping is a YAML
presentation concern only: module snapshots remain flat, and loaded
`promptinput.Package` values expose flat stanza names in `Briefing.Values`.
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.
Hourly Report module snapshots use the same package schema and categories when
converted into prompt input. The default hourly module list places
`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 civil-day summary stanzas. Generated-text and render context artifacts
are produced later in app orchestration and are not part of the YAML data
package.
Current categories are:
- `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.
- `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.
## Boundaries

View File

@@ -16,6 +16,7 @@ Each report definition declares:
- report ID and display name
- Scriptorium prompt ID
- generation mode
- valid-period resolver
- comparison strategy
- managed artifact group
@@ -25,15 +26,22 @@ Each report definition declares:
- morning or evening batch membership
- default ordered module composition
Markdown report definitions use the `scriptorium_markdown` generation mode.
Their template and structured-text schema identifiers are empty. Hourly Report
declares `generated_text_template` with template ID `hourly` and schema ID
`hourly`; the app uses those identifiers to validate generated text and render
the embedded hourly template.
## Reports
| Report | ID | Prompt | Artifact group | Batch copy | Prior compatibility |
| --- | --- | --- | --- | --- | --- |
| 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 |
| 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 |
| Storm Report | `storm` | `weather.storm_report` | `storm` | `storm.md` | Storm Report |
| Report | ID | Prompt | Generation mode | Artifact group | Batch copy | Prior compatibility |
| --- | --- | --- | --- | --- | --- | --- |
| Daily Today | `daily_today` | `weather.daily_report` | `scriptorium_markdown` | `daily` | `daily.md` | Daily Today, Daily Tomorrow |
| Daily Tomorrow | `daily_tomorrow` | `weather.daily_report` | `scriptorium_markdown` | `daily` | `tomorrow.md` | Daily Today, Daily Tomorrow |
| Hourly Report | `hourly` | `weather.hourly_generated_text` | `generated_text_template` | `hourly` | `hourly.md` | Hourly Report |
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `scriptorium_markdown` | `three-day` | `three-day.md` | 3-Day Outlook |
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `scriptorium_markdown` | `weekend` | `weekend.md` | Weekend Outlook |
| Storm Report | `storm` | `weather.storm_report` | `scriptorium_markdown` | `storm` | `storm.md` | Storm Report |
All report definitions are eligible for generation.
@@ -42,6 +50,9 @@ All report definitions are eligible for generation.
- Daily Today covers the selected local civil day, or the current local civil
day when no date override is supplied.
- Daily Tomorrow covers the next local civil day from generation time.
- Hourly 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
three days later.
- Weekend Outlook covers the upcoming weekend window and is not scheduled for
@@ -64,7 +75,14 @@ IDs, then uses the registry for report policy.
## Config Fields Used
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 `hourly` key for Hourly Report.
## Batch Membership
Morning batches include Daily Today, 3-Day Outlook, and Weekend Outlook except
on Sunday. Evening batches include Daily Tomorrow. Hourly Report is not part
of a scheduled batch.
## State And App Usage

View File

@@ -0,0 +1,94 @@
# Report Template Internals
This document describes embedded Markdown templates and GeneratedText schemas
in `internal/reporttemplate`.
## Purpose
`internal/reporttemplate` owns repository-native report templates and companion
GeneratedText JSON schemas. The first implemented template contract is the
Hourly Report.
The package embeds assets from:
- `internal/reporttemplate/templates/*.md.tmpl`
- `internal/reporttemplate/schemas/*.schema.json`
## Inputs And Outputs
Inputs:
- template ID from a report definition
- typed render context built by `internal/generatedtext`
Outputs:
- template source for inspection and tests
- GeneratedText schema bytes for prompt/schema configuration
- rendered Markdown bytes for app orchestration to persist
The implemented template ID is `hourly`. The implemented schema ID is also
`hourly`, backed by `hourly.generated_text.schema.json`.
## Boundaries
This package owns embedded asset lookup, Go template parsing, and Markdown
template execution. It does not fetch weather data, build module outputs,
validate GeneratedText, construct render contexts, choose report definitions,
write artifacts, invoke Scriptorium, or notify distributor.
GeneratedText validation is owned by `internal/generatedtext`. App
orchestration decides which template and schema IDs apply to a report through
`internal/report` definitions.
## Template Contract
Hourly rendering uses a typed render context with:
- report metadata labels such as title, location, valid period, and generation
time
- validated hourly GeneratedText prose slots
- deterministic labels derived from module outputs, including current
conditions, hourly forecast rows, precipitation timing, alerts, SPC outlooks,
forecast discussion, SPC discussion, and weather story
Templates use `text/template` with `missingkey=error`, so missing context fields
fail rendering instead of producing incomplete Markdown.
## Schema Contract
The hourly GeneratedText schema describes the structured prose Scriptorium is
expected to write for the prompt. It requires:
- `summary`
- `timing`
- `impacts`
It allows optional `confidence` and rejects additional properties. Weather truth
remains in module outputs; GeneratedText is limited to prose slots consumed by
the template.
## Failure Behavior
- Unknown template IDs return actionable lookup errors.
- Unknown schema IDs return actionable lookup errors.
- Template parse errors include the template ID.
- Template execution errors include the template ID and usually identify the
missing context field.
## Tests
Inspect:
- `internal/reporttemplate/reporttemplate_test.go`
- `internal/generatedtext/render_context_test.go`
- `internal/app/app_test.go`
- `internal/cli/root_test.go`
## Invariants
- Embedded templates and schemas live as separate files, not inline Go strings.
- Report definitions select templates by ID.
- Templates render from curated render contexts, not raw data packages.
- GeneratedText schemas describe LLM prose slots, not deterministic weather
facts.

View File

@@ -6,9 +6,9 @@ This document describes the subprocess adapter in
## Purpose
The adapter runs `scriptorium render` for prompt preflight and `scriptorium run`
for Markdown report generation. It isolates subprocess execution, argv
construction, timeout handling, output capture, and exit-code interpretation
from app and domain packages.
for Markdown report generation or structured generated-text output. It isolates
subprocess execution, argv construction, timeout handling, output capture, and
exit-code interpretation from app and domain packages.
## Inputs And Outputs
@@ -17,6 +17,7 @@ Inputs:
- prompt ID
- YAML prompt input data package path
- report output path for `run`
- raw generated-text output path for structured `run`
- configured binary, config path, profile, timeout, and extra arguments
- context for cancellation
@@ -27,6 +28,7 @@ Outputs:
- truncation flags for captured output
- exit code
- report output path for `run`
- raw generated-text output path for structured `run`
## Boundaries
@@ -34,9 +36,9 @@ Outputs:
subprocess execution. It does not choose report types, build prompt input,
fetch weather data, decide workflow order, or persist workflow metadata.
The adapter exposes request and result structs for render and run operations.
State persistence uses a state-owned preflight artifact shape; app
orchestration converts render results before saving.
The adapter exposes request and result structs for render, Markdown run, and
structured generated-text run operations. State persistence uses state-owned
artifact shapes; app orchestration converts adapter results before saving.
## Config Fields Used
@@ -60,6 +62,12 @@ Report generation argv starts with:
scriptorium run --prompt <prompt_id> --input data_package=<path> --out <path>
```
Structured generated-text argv uses the same `scriptorium run` form, with the
`--out` value set to the raw generated-text JSON artifact path. The adapter
does not add `--format`, schema path, or JSON Schema flags for structured
generation; Scriptorium selects the structured output schema from prompt
configuration.
Configured `--config` and `--profile` flags are inserted after the subcommand
and before prompt-specific arguments. Extra arguments are appended after the
built-in arguments.
@@ -67,8 +75,8 @@ built-in arguments.
## Execution Behavior
The adapter runs commands without shell interpolation. The same private
execution path is used by render and run after command-specific request
validation and argv construction.
execution path is used by render, Markdown run, and structured run after
command-specific request validation and argv construction.
When `scriptorium.timeout` is greater than zero, each subprocess call uses a
context with that timeout. Stdout and stderr are captured separately, capped at
@@ -81,8 +89,8 @@ context with that timeout. Stdout and stderr are captured separately, capped at
- Missing run output path returns an error before subprocess execution.
- Subprocess start errors, context cancellation, and timeouts are wrapped with
operation context by the caller-facing method.
- Nonzero render and run exits return the captured result plus an error
containing the exit code and stderr.
- Nonzero render, Markdown run, and structured run exits return the captured
result plus an error containing the exit code and stderr.
## Tests
@@ -97,5 +105,6 @@ Inspect:
- No shell interpolation is used.
- The Scriptorium input name is `data_package`.
- The file at the data package path is YAML produced by `internal/promptinput`.
- Render and run preserve command-specific result structs.
- Render, Markdown run, and structured run preserve command-specific result
structs.
- Scriptorium-specific flags stay inside adapter and config boundaries.

View File

@@ -16,6 +16,7 @@ Inputs:
- module snapshot
- prompt input data package
- preflight artifact
- generated-text raw, run-result, validated text, and render-context artifacts
- rendered report path preparation request
- RunID for inspection lookups
@@ -24,10 +25,15 @@ Outputs:
- module snapshot JSON path
- prompt input data package YAML path
- render preflight JSON path
- generated-text raw JSON path
- generated-text run-result JSON path
- validated generated-text JSON path
- render context JSON path
- managed Markdown report path
- metadata JSON path
- prior comparable snapshot metadata
- loaded module snapshot or data package
- loaded module snapshot, data package, generated text, generated-text run
result, or render context
- recent report records for inspection
## Boundaries
@@ -61,6 +67,10 @@ valid-period start date for dated artifacts, and the RunID.
<workspace.root>/
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.modules.json
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.metadata.json
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
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.yaml
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json
notifications/<artifact_group>/<YYYY-MM-DD>/<run_id>.distributor.json
@@ -69,8 +79,11 @@ valid-period start date for dated artifacts, and the RunID.
Metadata is stored beside module snapshots and links the module snapshot, data
package, preflight, report paths, notification path when attempted, and
configured prompt location. Report listing walks metadata files under the
snapshots directory.
configured prompt location. For generated-text-template reports, metadata also
records the generated text schema ID and links the raw generated text,
Scriptorium run result, validated generated text, and render context artifacts.
Markdown-report metadata omits those generated-text fields. Report listing
walks metadata files under the snapshots directory.
## Prior Lookup
@@ -84,22 +97,27 @@ current report definition.
date.
- Weekend Outlook compares with prior Weekend snapshots for the same weekend
window.
- Hourly Report uses the rolling-window comparison strategy and currently
returns no prior snapshot from filesystem lookup.
- Storm Report has no prior lookup because explicit event-window comparison is
not searched by the filesystem store.
## Writes And Inspection
Durable JSON writes use shared atomic file helpers. Managed Markdown reports are
prepared by creating their parent directory; Scriptorium writes the report body
to the prepared path. Extra Markdown copies are handled by app orchestration.
Distributor notification debug artifacts are written atomically when
notification is attempted and include rendered distributor pipeline ID, bundle
ID, idempotency key, bundle paths, upload status, latest run status, and
redacted errors.
Durable JSON writes use shared atomic file helpers. Generated-text raw and
validated JSON artifacts are written atomically as bytes; generated-text run
result and render context artifacts are written atomically as JSON. Managed
Markdown reports are prepared by creating their parent directory; Scriptorium
writes the report body to the prepared path. Extra Markdown copies are handled
by app orchestration. Distributor notification debug artifacts are written
atomically when notification is attempted and include rendered distributor
pipeline ID, bundle ID, idempotency key, bundle paths, upload status, latest
run status, and redacted errors.
Inspection helpers read existing metadata, module snapshot, and data package
files. Missing metadata directories return no inspection records or no prior
snapshot rather than creating state.
Inspection helpers read existing metadata, module snapshot, data package,
generated text, generated-text run result, and render context files. Missing
metadata directories return no inspection records or no prior snapshot rather
than creating state.
## Failure Behavior
@@ -121,4 +139,6 @@ Inspect:
- Managed paths stay under the configured workspace root.
- Artifact grouping comes from report definitions.
- Metadata links artifacts produced for a run.
- Generated-text artifacts live under the snapshots tree beside module
snapshots and metadata.
- Prior lookup is based on structured metadata, not rendered report text.

View File

@@ -4,10 +4,10 @@ This document describes Weather API ingestion into `weatherdata.Bundle`.
## Purpose
`internal/adapters/weatherapi` fetches normalized weather data from one
configured Weather API endpoint and assembles the bundle consumed by forecast
derivation and module builders. Module builders expose normalized current
conditions and weather story context when those sources are available.
`internal/adapters/weatherapi` fetches normalized weather data from the
configured Weather API and assembles the bundle consumed by forecast derivation
and module builders. Module builders expose normalized current conditions and
weather story context when those sources are available.
## Inputs And Outputs
@@ -21,7 +21,8 @@ Outputs:
- `weatherdata.Bundle` with observation, current conditions, hourly forecast,
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
## Boundaries
@@ -54,9 +55,19 @@ contract used by this project.
The adapter records source name, endpoint, query, fetch time, source timestamps
when available, SHA-256 hash over compact raw `data` JSON, missing status, and
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.
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
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.
- Explicit `data: null` from `/alerts/active` produces an empty, non-missing
alert run.
- Explicit `data: null` from `/outlooks/convective` follows optional
missing-source policy.
## Tests

View File

@@ -11,18 +11,28 @@ Generation commands:
```text
weatherreporter generate daily --date 2026-05-29
weatherreporter generate tomorrow
weatherreporter generate hourly
weatherreporter generate three-day
weatherreporter generate weekend
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
```
Each command resolves a report period, fetches a Weather API bundle, builds a
JSON module snapshot, builds a YAML prompt input data package, runs
`scriptorium render`, runs `scriptorium run`, and writes managed artifacts under
the configured workspace.
When distributor notification is enabled, weatherreporter uploads the managed
Markdown report after `scriptorium run` succeeds and final metadata is saved.
`--out PATH` writes an extra Markdown copy for the current generated report; it
Markdown-path generation commands resolve a report period, fetch a Weather API
bundle, build a JSON module snapshot, build a YAML prompt input data package,
run `scriptorium render`, run `scriptorium run`, and write managed artifacts
under the configured workspace. When distributor notification is enabled,
weatherreporter uploads the managed Markdown report after report rendering
succeeds and final metadata is saved. `--out PATH` writes an extra Markdown
copy for generated reports; it is not used as the distributor upload source.
`generate hourly` covers the six-hour rolling period from generation time in
the effective report timezone and is not part of scheduled morning or evening
batches. It builds the same module snapshot and data package, runs
`scriptorium render` as preflight, runs structured `scriptorium run` to raw
GeneratedText JSON, validates the structured text, saves a render context, and
renders the managed Markdown report from the embedded hourly template. When
distributor notification is enabled, hourly uploads the managed Markdown report
after final metadata is saved. `--out PATH` writes an extra Markdown copy and
is not used as the distributor upload source.
Batch commands:
@@ -62,6 +72,14 @@ workspace/
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
hourly/
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
<run_id>.generated_text.raw.json
<run_id>.generated_text.run.json
<run_id>.generated_text.json
<run_id>.render_context.json
storm/
YYYY-MM-DD/
<run_id>.modules.json
@@ -76,6 +94,9 @@ workspace/
weekend/
YYYY-MM-DD/
<run_id>.data_package.yaml
hourly/
YYYY-MM-DD/
<run_id>.data_package.yaml
storm/
YYYY-MM-DD/
<run_id>.data_package.yaml
@@ -89,6 +110,9 @@ workspace/
weekend/
YYYY-MM-DD/
<run_id>.render.json
hourly/
YYYY-MM-DD/
<run_id>.render.json
storm/
YYYY-MM-DD/
<run_id>.render.json
@@ -102,6 +126,9 @@ workspace/
weekend/
YYYY-MM-DD/
<run_id>.distributor.json
hourly/
YYYY-MM-DD/
<run_id>.distributor.json
storm/
YYYY-MM-DD/
<run_id>.distributor.json
@@ -112,6 +139,8 @@ workspace/
<run_id>.md
weekend/
<run_id>.md
hourly/
<run_id>.md
storm/
<run_id>.md
```
@@ -136,6 +165,7 @@ Each generated report writes metadata that links:
- prompt input data package path
- preflight output path
- managed Markdown report path
- generated text schema ID and generated-text artifact paths for Hourly Report
- distributor notification debug artifact path, when notification is attempted
Batch summaries include report status, error text when applicable, notification
@@ -165,8 +195,9 @@ report generation has a distinct retry identity. The default bundle path uses
the valid-period start date, artifact group, and RunID. Distributor owns
destination merge, retention, and derived snapshot behavior such as `latest`.
Notification happens after final metadata save. Weather API, module snapshot,
data-package, render preflight, Scriptorium run, and metadata-save failures do
Notification happens after final metadata save for generated reports. Weather
API, module snapshot, data-package, render preflight, Scriptorium run,
generated-text validation, template rendering, and metadata-save failures do
not trigger notification. A notification failure fails that report.
In a batch, other reports continue, the failed report includes notification
fields in the JSON summary, and the batch returns nonzero.
@@ -218,8 +249,8 @@ Markdown or YAML text.
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
snapshots for the same valid local date. Weekend Outlook compares with prior
compatible Weekend snapshots for the same weekend window. Storm Report leaves
Recent Changes empty.
compatible Weekend snapshots for the same weekend window. Hourly Report and
Storm Report leave Recent Changes empty.
When no prior comparable snapshot exists, or no configured threshold is crossed,
`recentChanges.items` is empty.
@@ -232,6 +263,10 @@ A failed generation run may still leave useful artifacts:
preflight JSON and metadata are written for inspection.
- If `scriptorium run` exits nonzero after writing a report, the managed report
and metadata remain available.
- Hourly generated-text failures preserve available intermediate artifacts,
such as the structured run result, raw generated-text JSON, validated
generated text, and render context. Metadata links those paths when it can be
safely written.
- If distributor notification fails, report artifacts and final metadata remain
available, but the report or batch command returns nonzero.
- For batch commands, inspect the stdout JSON summary first, then inspect the

View File

@@ -41,7 +41,8 @@ add only modules backed by implemented upstream facts and clear report needs.
Possible future report types:
- `next_6_hours` or another short-fuse planning report;
- another short-fuse planning report distinct from the implemented Hourly
Report, if a separate product is needed;
- event-specific reports with stable event IDs;
- storm review or yesterday-style reports using historical observations;
- archive-focused report variants if generated report history becomes a
@@ -84,7 +85,6 @@ These enhancements are not current behavior:
- `failure_policy: warn`;
- uploading metadata, module snapshots, data packages, or preflight artifacts;
- polling distributor status after upload acceptance;
- durable upload retry queues;
- distributor-specific CLI flags;
- making distributor scan the weatherreporter workspace;

View File

@@ -0,0 +1,342 @@
# 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, named
`hourly`.
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.
- Use `hourly` as the only public report ID for the rolling next-hours
report.
- Do not keep compatibility aliases from retired roadmap terminology.
- Do not add a generic workflow engine or plugin system.
## Hourly Report Target
The rolling next-hours report target identity is:
- 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 hourly 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,782 @@
# 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 other
unimplemented GeneratedText behavior as available.
## 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`.
- Use `hourly` as the only planned public name for the rolling next-hours
report; do not keep compatibility aliases from retired roadmap terminology.
- 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`
- the superseded roadmap file for the retired rolling-report name
- `docs/roadmap/future.md`
- `docs/roadmap/implementation.md`
- `internal/app/app.go`
- `internal/report/definition.go`
- `internal/report/registry.go`
Implementation:
- Replace the superseded roadmap file for the retired rolling-report name with
a short 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 retired public
identifiers for the rolling report.
- If partial code using retired identifiers already exists, rename it to
`hourly` rather than adding aliases:
- app report kind -> `ReportHourly`;
- report ID constant -> `report.Hourly`;
- CLI command -> `hourly`;
- artifact group -> `hourly`;
- prompt IDs and tests updated to hourly names.
- Do not add compatibility aliases for retired command or config names.
Acceptance criteria:
- Active roadmap docs use `hourly` for the rolling report.
- No active roadmap describes a retired identifier as the target public report
ID.
- Existing code compiles after any rename work.
Suggested validation:
```bash
rg -n 'near[_-]term|Near''Term' 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 retired rolling-report config keys.
- 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 the retired rolling-report command name.
- Reject hourly `--date`, `--start`, `--end`, and duration-like flags.
- Add config override tests for `reports.hourly.deterministic_modules`.
- Ensure retired rolling-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`;
- the retired rolling-report command name 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`
- the superseded roadmap file for the retired rolling-report name
- `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 the retired command name.
- `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.
- the superseded roadmap file for the retired rolling-report name 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 retired rolling-report identifiers as the target
public report.
- Maintained examples remain valid.
Suggested validation:
```bash
go test ./internal/config
rg -n 'near[_-]term|Near''Term' 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 the retired rolling-report command.
- 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.

View File

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

View File

@@ -79,6 +79,12 @@ type RunRequest struct {
OutputPath string
}
type StructuredRunRequest struct {
PromptID string
DataPackagePath string
OutputPath string
}
type RenderResult struct {
Command []string `json:"command"`
Stdout string `json:"stdout"`
@@ -98,6 +104,16 @@ type RunResult struct {
OutputPath string `json:"outputPath"`
}
type StructuredRunResult struct {
Command []string `json:"command"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
StdoutTruncated bool `json:"stdoutTruncated,omitempty"`
StderrTruncated bool `json:"stderrTruncated,omitempty"`
ExitCode int `json:"exitCode"`
OutputPath string `json:"outputPath"`
}
func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, error) {
if req.PromptID == "" {
return nil, fmt.Errorf("prompt id is required")
@@ -152,6 +168,35 @@ func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
return result, nil
}
func (r Runner) StructuredRun(ctx context.Context, req StructuredRunRequest) (*StructuredRunResult, error) {
if req.PromptID == "" {
return nil, fmt.Errorf("prompt id is required")
}
if req.DataPackagePath == "" {
return nil, fmt.Errorf("data package path is required")
}
if req.OutputPath == "" {
return nil, fmt.Errorf("output path is required")
}
execution, err := r.execute(ctx, r.structuredRunArgs(req))
if err != nil {
return nil, fmt.Errorf("run scriptorium structured output: %w", err)
}
result := &StructuredRunResult{
Command: execution.argv(),
Stdout: string(execution.result.Stdout),
Stderr: string(execution.result.Stderr),
StdoutTruncated: execution.result.StdoutTruncated,
StderrTruncated: execution.result.StderrTruncated,
ExitCode: execution.result.ExitCode,
OutputPath: req.OutputPath,
}
if execution.result.ExitCode != 0 {
return result, fmt.Errorf("scriptorium structured run exited with code %d: %s", execution.result.ExitCode, result.Stderr)
}
return result, nil
}
type execution struct {
binary string
args []string
@@ -212,6 +257,14 @@ func (r Runner) runArgs(req RunRequest) []string {
return args
}
func (r Runner) structuredRunArgs(req StructuredRunRequest) []string {
return r.runArgs(RunRequest{
PromptID: req.PromptID,
DataPackagePath: req.DataPackagePath,
OutputPath: req.OutputPath,
})
}
type limitedBuffer struct {
data []byte
limit int

View File

@@ -147,17 +147,169 @@ func TestRunReturnsResultForValidationExit(t *testing.T) {
}
}
func TestStructuredRunConstructsCommandWithoutSchemaFlags(t *testing.T) {
commands := &fakeCommands{result: CommandResult{
Stdout: []byte(`{"summary":"ok"}`),
Stderr: []byte("wrote generated text"),
StdoutTruncated: true,
}}
runner := Runner{
Binary: "/usr/local/bin/scriptorium",
ConfigPath: "/etc/scriptorium.yml",
Profile: "weather",
Timeout: 30 * time.Second,
Commands: commands,
}
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
PromptID: "weather.hourly_generated_text",
DataPackagePath: "/tmp/hourly.data_package.yaml",
OutputPath: "/tmp/hourly.generated_text.raw.json",
})
if err != nil {
t.Fatalf("StructuredRun() error = %v", err)
}
wantArgs := []string{
"run",
"--config", "/etc/scriptorium.yml",
"--profile", "weather",
"--prompt", "weather.hourly_generated_text",
"--input", "data_package=/tmp/hourly.data_package.yaml",
"--out", "/tmp/hourly.generated_text.raw.json",
}
if commands.name != "/usr/local/bin/scriptorium" {
t.Fatalf("command name = %q, want custom binary", commands.name)
}
if !reflect.DeepEqual(commands.args, wantArgs) {
t.Fatalf("args = %#v, want %#v", commands.args, wantArgs)
}
for _, disallowed := range []string{"--format", "--schema", "--schema-path", "--json-schema"} {
if containsArg(commands.args, disallowed) {
t.Fatalf("args = %#v, should not include %q", commands.args, disallowed)
}
}
if commands.timeout != 30*time.Second {
t.Fatalf("timeout = %s, want 30s", commands.timeout)
}
if !reflect.DeepEqual(result.Command, append([]string{"/usr/local/bin/scriptorium"}, wantArgs...)) {
t.Fatalf("result command = %#v, want full argv", result.Command)
}
if result.Stdout != `{"summary":"ok"}` || result.Stderr != "wrote generated text" || !result.StdoutTruncated {
t.Fatalf("result = %#v, want captured output and truncation flags", result)
}
if result.OutputPath != "/tmp/hourly.generated_text.raw.json" {
t.Fatalf("OutputPath = %q, want generated text raw path", result.OutputPath)
}
}
func TestStructuredRunReturnsResultForNonzeroExit(t *testing.T) {
runner := Runner{
Commands: &fakeCommands{
result: CommandResult{
Stdout: []byte(`{"summary":"partial"}`),
Stderr: []byte("structured output failed"),
ExitCode: 3,
},
},
}
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
PromptID: "weather.hourly_generated_text",
DataPackagePath: "/tmp/hourly.data_package.yaml",
OutputPath: "/tmp/hourly.generated_text.raw.json",
})
if err == nil {
t.Fatal("StructuredRun() error = nil, want nonzero exit error")
}
if result == nil {
t.Fatal("StructuredRun() result = nil, want captured result")
}
if result.ExitCode != 3 {
t.Fatalf("ExitCode = %d, want 3", result.ExitCode)
}
if result.Stdout != `{"summary":"partial"}` || result.OutputPath != "/tmp/hourly.generated_text.raw.json" {
t.Fatalf("result = %#v, want captured result fields", result)
}
if !strings.Contains(err.Error(), "structured output failed") {
t.Fatalf("error = %q, want stderr context", err.Error())
}
}
func TestStructuredRunValidatesRequiredFieldsBeforeExecution(t *testing.T) {
tests := []struct {
name string
req StructuredRunRequest
want string
}{
{
name: "prompt id",
req: StructuredRunRequest{
DataPackagePath: "/tmp/hourly.data_package.yaml",
OutputPath: "/tmp/hourly.generated_text.raw.json",
},
want: "prompt id is required",
},
{
name: "data package path",
req: StructuredRunRequest{
PromptID: "weather.hourly_generated_text",
OutputPath: "/tmp/hourly.generated_text.raw.json",
},
want: "data package path is required",
},
{
name: "output path",
req: StructuredRunRequest{
PromptID: "weather.hourly_generated_text",
DataPackagePath: "/tmp/hourly.data_package.yaml",
},
want: "output path is required",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
commands := &fakeCommands{}
runner := Runner{Commands: commands}
result, err := runner.StructuredRun(context.Background(), test.req)
if err == nil {
t.Fatal("StructuredRun() error = nil, want validation error")
}
if result != nil {
t.Fatalf("StructuredRun() result = %#v, want nil", result)
}
if !strings.Contains(err.Error(), test.want) {
t.Fatalf("StructuredRun() error = %v, want %q", err, test.want)
}
if commands.calls != 0 {
t.Fatalf("commands calls = %d, want no subprocess execution", commands.calls)
}
})
}
}
type fakeCommands struct {
name string
args []string
timeout time.Duration
result CommandResult
err error
calls int
}
func (f *fakeCommands) Run(_ context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) {
f.calls++
f.name = name
f.args = append([]string{}, args...)
f.timeout = timeout
return f.result, f.err
}
func containsArg(args []string, want string) bool {
for _, arg := range args {
if arg == want {
return true
}
}
return false
}

View File

@@ -21,6 +21,11 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
const (
convectiveOutlooksEndpoint = "/outlooks/convective"
sourceSPCConvectiveOutlooks = "spc_convective_outlooks"
)
type Client struct {
baseURL *url.URL
httpClient *http.Client
@@ -112,6 +117,9 @@ func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
if err := builder.fetchWeatherStory(ctx); err != nil {
return nil, err
}
if err := builder.fetchSPCConvectiveOutlooks(ctx); err != nil {
return nil, err
}
return builder.bundle, nil
}
@@ -264,6 +272,29 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
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 {
source.Missing = true
if required {

View File

@@ -55,8 +55,17 @@ func TestFetchBundleFromFixtures(t *testing.T) {
if bundle.WeatherStory.UpdatedAt == nil {
t.Fatalf("WeatherStory.UpdatedAt = nil, want update timestamp")
}
if len(bundle.Sources) != 7 {
t.Fatalf("Sources length = %d, want 7", len(bundle.Sources))
if bundle.SPCConvectiveOutlooks == nil || len(bundle.SPCConvectiveOutlooks.Outlooks) != 1 {
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 {
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") {
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) {
@@ -92,6 +104,12 @@ func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
}
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") {
t.Fatalf("request %q missing units=us", rawURL)
}
@@ -127,6 +145,25 @@ func TestFetchBundleRecordsSourceHash(t *testing.T) {
if story.IssuedAt == nil || story.UpdatedAt == nil {
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) {
@@ -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) {
tests := []struct {
name string
@@ -370,6 +460,7 @@ func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested
"/alerts/active": "alerts.json",
"/discussion": "discussion.json",
"/weatherstories/latest": "weather_story.json",
convectiveOutlooksEndpoint: "convective_outlooks.json",
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
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

@@ -17,9 +17,11 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/reporttemplate"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
@@ -30,6 +32,7 @@ type ReportKind string
const (
ReportDaily ReportKind = "daily"
ReportTomorrow ReportKind = "tomorrow"
ReportHourly ReportKind = "hourly"
ReportThreeDay ReportKind = "three-day"
ReportWeekend ReportKind = "weekend"
ReportStorm ReportKind = "storm"
@@ -102,6 +105,11 @@ type ReportResult struct {
RecentChanges []changes.Change
RenderResult *scriptorium.RenderResult
RunResult *scriptorium.RunResult
StructuredRunResult *scriptorium.StructuredRunResult
GeneratedTextRawPath string
GeneratedTextResultPath string
GeneratedTextPath string
RenderContextPath string
Notification *NotificationResult
}
@@ -150,6 +158,7 @@ func (e BatchError) Error() string {
type Renderer interface {
Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error)
Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error)
StructuredRun(context.Context, scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error)
}
type Notifier interface {
@@ -386,6 +395,8 @@ func reportIDForCommand(kind ReportKind) (report.ID, error) {
return report.DailyToday, nil
case ReportTomorrow:
return report.DailyTomorrow, nil
case ReportHourly:
return report.Hourly, nil
case ReportThreeDay:
return report.ThreeDay, nil
case ReportWeekend:
@@ -487,6 +498,10 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
DataPackage: paths.DataPackage,
Preflight: paths.Preflight,
RenderedReport: paths.RenderedReport,
GeneratedTextRaw: paths.GeneratedTextRaw,
GeneratedTextResult: paths.GeneratedTextResult,
GeneratedText: paths.GeneratedText,
RenderContext: paths.RenderContext,
})
dataPackage, err := promptinput.Build(promptinput.BuildRequest{
Metadata: promptMetadata(metadata),
@@ -531,9 +546,34 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
return nil, metadataErr
}
if renderErr != nil {
if req.Resolved.Definition.GenerationMode == report.GenerationModeGeneratedTextTemplate {
return nil, generatedReportError(req.Resolved, metadata.RunID, "render preflight", renderErr)
}
return nil, renderErr
}
if req.Resolved.Definition.GenerationMode == report.GenerationModeGeneratedTextTemplate {
return generateTextTemplateReport(ctx, generatedReportRequest{
ReportRequest: req,
store: store,
paths: paths,
moduleSnapshot: moduleSnapshot,
moduleSnapshotPath: moduleSnapshotPath,
dataPackage: dataPackage,
dataPackagePath: dataPackagePath,
briefingMetadata: briefingMetadata,
metadata: metadata,
metadataPath: metadataPath,
preflightPath: preflightPath,
recentChanges: recentChanges,
renderResult: renderResult,
renderer: renderer,
})
}
if req.Resolved.Definition.GenerationMode != report.GenerationModeScriptoriumMarkdown {
return nil, fmt.Errorf("generation mode %q is not supported for report %q", req.Resolved.Definition.GenerationMode, req.Resolved.Definition.ID)
}
reportPath, err := store.PrepareRenderedReport(ctx, req.Resolved)
if err != nil {
return nil, err
@@ -592,6 +632,138 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
}, nil
}
type generatedReportRequest struct {
ReportRequest
store state.Store
paths state.ArtifactPaths
moduleSnapshot module.Snapshot
moduleSnapshotPath string
dataPackage promptinput.Package
dataPackagePath string
briefingMetadata briefing.Metadata
metadata state.Metadata
metadataPath string
preflightPath string
recentChanges []changes.Change
renderResult *scriptorium.RenderResult
renderer Renderer
}
func generateTextTemplateReport(ctx context.Context, req generatedReportRequest) (*ReportResult, error) {
structuredResult, runErr := req.renderer.StructuredRun(ctx, scriptorium.StructuredRunRequest{
PromptID: req.Resolved.Definition.PromptID,
DataPackagePath: req.dataPackagePath,
OutputPath: req.paths.GeneratedTextRaw,
})
generatedTextResultPath := req.paths.GeneratedTextResult
if structuredResult != nil {
var err error
generatedTextResultPath, err = req.store.SaveGeneratedTextResult(ctx, req.Resolved, structuredResult)
if err != nil {
return nil, err
}
req.metadata.GeneratedTextResultPath = generatedTextResultPath
req.metadataPath, err = req.store.SaveMetadata(ctx, req.metadata)
if err != nil {
return nil, err
}
}
if runErr != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "structured generated text", runErr)
}
rawGeneratedText, err := req.store.LoadGeneratedText(ctx, req.paths.GeneratedTextRaw)
if err != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "load raw generated text", err)
}
hourlyText, normalizedGeneratedText, err := validateGeneratedText(req.Resolved.Definition, rawGeneratedText)
if err != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "validate generated text", err)
}
generatedTextPath, err := req.store.SaveGeneratedText(ctx, req.Resolved, normalizedGeneratedText)
if err != nil {
return nil, err
}
req.metadata.GeneratedTextPath = generatedTextPath
req.metadataPath, err = req.store.SaveMetadata(ctx, req.metadata)
if err != nil {
return nil, err
}
renderContext, err := buildRenderContext(req.Resolved.Definition, req.briefingMetadata, req.moduleSnapshot, hourlyText)
if err != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "build render context", err)
}
renderContextPath, err := req.store.SaveRenderContext(ctx, req.Resolved, renderContext)
if err != nil {
return nil, err
}
req.metadata.RenderContextPath = renderContextPath
req.metadataPath, err = req.store.SaveMetadata(ctx, req.metadata)
if err != nil {
return nil, err
}
rendered, err := reporttemplate.Render(req.Resolved.Definition.TemplateID, renderContext)
if err != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "render template", err)
}
reportPath, err := req.store.PrepareRenderedReport(ctx, req.Resolved)
if err != nil {
return nil, err
}
if err := fileutil.WriteFileAtomic(reportPath, rendered); err != nil {
return nil, err
}
req.metadata.RenderedReportPath = reportPath
metadataPath, err := req.store.SaveMetadata(ctx, req.metadata)
if err != nil {
return nil, err
}
outputPath := reportPath
if req.OutputPath != "" {
outputPath = req.OutputPath
if req.OutputPath != reportPath {
if err := fileutil.CopyFileAtomic(reportPath, req.OutputPath); err != nil {
return nil, err
}
}
}
notification, notificationPath, notificationErr := notifyReport(ctx, req.Config, req.Resolved, reportPath, req.metadata, req.Notifier, req.store)
if notificationPath != "" {
req.metadata.NotificationPath = notificationPath
metadataPath, err = req.store.SaveMetadata(ctx, req.metadata)
if err != nil {
return nil, err
}
}
if notificationErr != nil {
return nil, notificationErr
}
return &ReportResult{
ModuleSnapshot: req.moduleSnapshot,
ModuleSnapshotPath: req.moduleSnapshotPath,
DataPackage: req.dataPackage,
DataPackagePath: req.dataPackagePath,
PreflightPath: req.preflightPath,
ReportPath: reportPath,
OutputPath: outputPath,
NotificationPath: notificationPath,
Metadata: req.metadata,
MetadataPath: metadataPath,
RecentChanges: req.recentChanges,
RenderResult: req.renderResult,
StructuredRunResult: structuredResult,
GeneratedTextRawPath: req.paths.GeneratedTextRaw,
GeneratedTextResultPath: generatedTextResultPath,
GeneratedTextPath: generatedTextPath,
RenderContextPath: renderContextPath,
Notification: notification,
}, nil
}
func notifyReport(ctx context.Context, cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata, notifier Notifier, store state.Store) (*NotificationResult, string, error) {
notifier, enabled := reportNotifier(cfg, notifier)
if !enabled {
@@ -935,3 +1107,28 @@ func preflightArtifact(result *scriptorium.RenderResult) state.PreflightArtifact
ExitCode: result.ExitCode,
}
}
func validateGeneratedText(definition report.Definition, data []byte) (generatedtext.Hourly, []byte, error) {
switch definition.GeneratedTextSchemaID {
case "hourly":
return generatedtext.ValidateHourly(data)
default:
return generatedtext.Hourly{}, nil, fmt.Errorf("generated text schema %q is not supported for report %q", definition.GeneratedTextSchemaID, definition.ID)
}
}
func buildRenderContext(definition report.Definition, metadata briefing.Metadata, snapshot module.Snapshot, hourly generatedtext.Hourly) (any, error) {
switch definition.TemplateID {
case "hourly":
return generatedtext.BuildHourlyRenderContext(metadata, snapshot, hourly)
default:
return nil, fmt.Errorf("report template %q is not supported for report %q", definition.TemplateID, definition.ID)
}
}
func generatedReportError(resolved report.Resolved, runID string, operation string, err error) error {
if err == nil {
return nil
}
return fmt.Errorf("generate report %q run %q: %s: %w", resolved.Definition.ID, runID, operation, err)
}

File diff suppressed because it is too large Load Diff

View File

@@ -62,8 +62,8 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
if period.TextDescription != "Showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 {
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" {
t.Fatalf("HourlyForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime)
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.PeriodBegins, period.PeriodEnds)
}
if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
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)
}
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) {
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") {
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`) {
t.Fatalf("hourly json = %s, want friendly local start/end times", jsonText)
if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) {
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 TestHourlyForecastModuleBuildsForHourly(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Hourly)
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 hourly report period", value)
}
}
func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
@@ -113,8 +128,8 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
if period.Name != "Today" || period.TextDescription != "Morning storms, then partly sunny." {
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" {
t.Fatalf("NarrativeForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime)
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.PeriodBegins, period.PeriodEnds)
}
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)
@@ -127,7 +142,7 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
t.Fatalf("Marshal narrative forecast: %v", err)
}
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) {
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") {
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`) {
t.Fatalf("narrative json = %s, want friendly local start/end times", jsonText)
if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) {
t.Fatalf("narrative json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText)
}
if strings.Contains(jsonText, "Tomorrow night") {
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." {
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)
if err != nil {
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))
}
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) {
@@ -314,6 +335,34 @@ func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
}
}
func TestAreaForecastDiscussionModuleUsesHourlyDefaultSections(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Hourly)
var item module.ConfigItem
for _, candidate := range ctx.Resolved.Definition.Modules {
if candidate.ID == module.AreaForecastDiscussion {
item = candidate
break
}
}
if item.ID == "" {
t.Fatal("hourly 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 {
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
definition := report.DefaultRegistry().MustLookup(report.DailyToday)

View File

@@ -12,7 +12,8 @@ import (
type DerivedDaypartSummaryModule struct {
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"`
ApparentTempRangeF string `json:"apparent_temp_range_f,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 {
value := DerivedDaypartSummaryModule{
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),
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
DominantCondition: daypart.DominantCondition,

View File

@@ -102,10 +102,10 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
if len(rainy.PrecipitationWindows) != 2 {
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])
}
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])
}
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") {
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") {
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.Hourly)
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) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday)
@@ -150,8 +169,8 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 {
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" {
t.Fatalf("morning period = %q/%q, want friendly local date and period labels", morning.Date, morning.Period)
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/%q, want friendly local date and period labels", morning.Date, morning.PeriodBegins, morning.PeriodEnds)
}
afternoon := value["afternoon"]
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)
}
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) {
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)
}
}
@@ -191,6 +210,12 @@ func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing
if outdoor.Best.Daypart != "overnight" || outdoor.Worst.Daypart != "afternoon" {
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})
if err != nil {

View File

@@ -4,6 +4,7 @@ import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
@@ -17,8 +18,8 @@ type HourlyForecastModule struct {
}
type HourlyForecastPeriod struct {
StartTime string `json:"start_time,omitempty"`
EndTime string `json:"end_time,omitempty"`
PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
Name string `json:"name,omitempty"`
IsDay *bool `json:"is_day,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 {
out := make([]HourlyForecastPeriod, 0, len(periods))
for _, period := range periods {
validPeriod := timeutil.Period{Start: period.StartTime, End: period.EndTime}
out = append(out, HourlyForecastPeriod{
StartTime: friendlyDateTimeLabel(period.StartTime, timezone),
EndTime: friendlyDateTimeLabel(period.EndTime, timezone),
PeriodBegins: friendlyPeriodBeginsLabel(validPeriod, timezone),
PeriodEnds: friendlyPeriodEndsLabel(validPeriod, timezone),
Name: period.Name,
IsDay: copyBool(period.IsDay),
ConditionCode: copyInt(period.ConditionCode),

View File

@@ -67,18 +67,18 @@ func timedClockLabel(value *forecast.TimedValue, timezone string) string {
return clockLabel(value.Time, timezone)
}
func periodClockLabel(period timeutil.Period, timezone string) string {
func friendlyPeriodBeginsLabel(period timeutil.Period, timezone string) string {
if !period.IsValid() {
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() {
return ""
}
return friendlyDateTimeLabel(period.Start, timezone) + " to " + friendlyDateTimeLabel(period.End, timezone)
return friendlyDateTimeLabel(period.End, timezone)
}
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
case module.CollectedWeatherStory:
return ctx.Collected.WeatherStory != nil
case module.CollectedSPCConvectiveOutlooks:
return ctx.Collected.SPCConvectiveOutlooks != nil
case module.CollectedSourceMetadata:
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
default:
@@ -183,6 +185,8 @@ func derivedFactAvailable(requirement module.FactRequirement, ctx ModuleContext)
return len(ctx.Derived.DaypartSummaries) > 0
case module.RequiresDerivedPrecipTiming:
return true
case module.RequiresDerivedSPCConvectiveOutlooks:
return ctx.Derived.SPCConvectiveOutlooks != nil
default:
return false
}
@@ -245,7 +249,7 @@ func (d ModuleDefinition) ValidateOptions(options any) error {
}
func defaultModuleDefinitions() []ModuleDefinition {
allReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend, report.Storm}
allReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm}
daypartReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend}
return []ModuleDefinition{
{
@@ -282,7 +286,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
DefaultOptions: module.HourlyForecastOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow},
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow, report.Hourly},
MissingData: module.MissingDataOmit,
Builder: buildHourlyForecastModule,
},
@@ -308,7 +312,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.PrecipTiming,
StanzaName: "precip_timing",
DefaultOptions: module.PrecipTimingOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedPrecipTiming},
SupportedReports: allReports,
MissingData: module.MissingDataEmpty,
Builder: buildPrecipTimingModule,
@@ -323,6 +327,16 @@ func defaultModuleDefinitions() []ModuleDefinition {
MissingData: module.MissingDataEmpty,
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,
StanzaName: "area_forecast_discussion",
@@ -332,6 +346,16 @@ func defaultModuleDefinitions() []ModuleDefinition {
MissingData: module.MissingDataOmit,
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,
StanzaName: "weather_story",

View File

@@ -4,8 +4,10 @@ import (
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) {
@@ -52,6 +54,27 @@ func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
}
}
func TestHourlyDefaultModuleOptions(t *testing.T) {
definition := report.DefaultRegistry().MustLookup(report.Hourly)
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("hourly default modules missing area_forecast_discussion")
}
}
func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.ID("unknown")}})
@@ -89,6 +112,24 @@ func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
}
}
func TestModuleRegistryRejectsHourlyIncompatibleModules(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.Hourly, []module.ConfigItem{{ID: id}})
if err == nil || !strings.Contains(err.Error(), `not compatible with report "hourly"`) {
t.Fatalf("ValidateComposition() error = %v, want incompatible hourly module", err)
}
})
}
}
func TestModuleRegistryRejectsDefinitionsWithoutBuilders(t *testing.T) {
_, err := NewModuleRegistry([]ModuleDefinition{
{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) {
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil
}

View File

@@ -4,6 +4,7 @@ import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
@@ -18,8 +19,8 @@ type NarrativeForecastModule struct {
type NarrativeForecastPeriod struct {
Name string `json:"name,omitempty"`
StartTime string `json:"start_time,omitempty"`
EndTime string `json:"end_time,omitempty"`
PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
IsDay *bool `json:"is_day,omitempty"`
TextDescription string `json:"text_description,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 {
out := make([]NarrativeForecastPeriod, 0, len(periods))
for _, period := range periods {
validPeriod := timeutil.Period{Start: period.StartTime, End: period.EndTime}
out = append(out, NarrativeForecastPeriod{
Name: period.Name,
StartTime: friendlyDateTimeLabel(period.StartTime, timezone),
EndTime: friendlyDateTimeLabel(period.EndTime, timezone),
PeriodBegins: friendlyPeriodBeginsLabel(validPeriod, timezone),
PeriodEnds: friendlyPeriodEndsLabel(validPeriod, timezone),
IsDay: copyBool(period.IsDay),
TextDescription: period.TextDescription,
TemperatureC: copyFloat(period.TemperatureC),

View File

@@ -9,8 +9,8 @@ type OutdoorWindowsModule struct {
type OutdoorWindowModule struct {
Daypart string `json:"daypart"`
Start string `json:"start"`
End string `json:"end"`
PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
Reasons []string `json:"reasons,omitempty"`
Score float64 `json:"score"`
}
@@ -18,20 +18,20 @@ type OutdoorWindowModule struct {
func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) {
windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries)
value := OutdoorWindowsModule{
Best: outdoorWindowValue(windows.Best),
Worst: outdoorWindowValue(windows.Worst),
Best: outdoorWindowValue(windows.Best, ctx.Timezone),
Worst: outdoorWindowValue(windows.Worst, ctx.Timezone),
}
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 {
return nil
}
return &OutdoorWindowModule{
Daypart: window.Daypart,
Start: window.Start,
End: window.End,
PeriodBegins: friendlyPeriodBeginsLabel(window.Period, timezone),
PeriodEnds: friendlyPeriodEndsLabel(window.Period, timezone),
Reasons: append([]string(nil), window.Reasons...),
Score: window.Score,
}

View File

@@ -14,8 +14,8 @@ type PrecipTimingModule struct {
}
type PrecipitationWindowModule struct {
Start string `json:"start"`
End string `json:"end,omitempty"`
PeriodBegins string `json:"period_begins"`
PeriodEnds string `json:"period_ends,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,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 {
item := PrecipitationWindowModule{
Start: clockLabel(window.Start, timezone),
PeriodBegins: friendlyDateTimeLabel(window.Start, timezone),
}
if window.End != nil {
item.End = clockLabel(*window.End, timezone)
item.PeriodEnds = friendlyDateTimeLabel(*window.End, timezone)
}
item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value)
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"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
type OutdoorWindows struct {
@@ -16,8 +17,7 @@ type OutdoorWindows struct {
type OutdoorWindow struct {
Daypart string
Start string
End string
Period timeutil.Period
Reasons []string
Score float64
}
@@ -190,8 +190,7 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
}
return OutdoorWindow{
Daypart: daypart.Name,
Start: daypart.Period.Start.Format("15:04"),
End: daypart.Period.End.Format("15:04"),
Period: daypart.Period,
Reasons: dedupe(reasons),
Score: math.Round(score*10) / 10,
}

View File

@@ -4,13 +4,14 @@ import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
type WeatherStoryModule struct {
Available bool `json:"available"`
OfficeID string `json:"office_id,omitempty"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
@@ -25,11 +26,12 @@ func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
if story == nil {
return nil, nil
}
period := timeutil.Period{Start: story.StartTime, End: story.EndTime}
value := WeatherStoryModule{
Available: true,
OfficeID: story.OfficeID,
StartTime: story.StartTime,
EndTime: story.EndTime,
PeriodBegins: friendlyPeriodBeginsLabel(period, ctx.Timezone),
PeriodEnds: friendlyPeriodEndsLabel(period, ctx.Timezone),
UpdatedAt: copyTime(story.UpdatedAt),
Title: story.Title,
Description: story.Description,

View File

@@ -84,7 +84,8 @@ type dailySummaryStanza struct {
type daypartSummaryStanza struct {
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"`
MaxPopPercent *int `json:"max_pop_percent,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,
}},
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.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{
date + "_morning": {
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,
MaxPopPercent: &precip,
MaxPopTime: precipTime,

View File

@@ -19,6 +19,7 @@ Usage:
weatherreporter --help
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 hourly [--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 storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
@@ -36,7 +37,7 @@ Options:
--config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml.
--units VALUE Override weather API units.
--tz NAME Override weather API timezone.
--out PATH Write an extra Markdown report copy for generate commands.
--out PATH Write an extra Markdown report copy where supported by the generate command.
--out-dir PATH Write extra Markdown report copies for run commands.
`
@@ -386,6 +387,8 @@ func reportKind(value string) (app.ReportKind, bool) {
return app.ReportDaily, true
case string(app.ReportTomorrow):
return app.ReportTomorrow, true
case string(app.ReportHourly):
return app.ReportHourly, true
case string(app.ReportThreeDay):
return app.ReportThreeDay, true
case string(app.ReportWeekend):

View File

@@ -28,6 +28,13 @@ func TestRunHelpLongFlag(t *testing.T) {
if !strings.Contains(stdout.String(), "generate daily") {
t.Fatalf("help output missing generate command:\n%s", stdout.String())
}
if !strings.Contains(stdout.String(), "weatherreporter generate hourly") {
t.Fatalf("help output missing hourly generate command:\n%s", stdout.String())
}
removedGenerateCommand := "generate " + strings.Join([]string{"near", "term"}, "-")
if strings.Contains(stdout.String(), removedGenerateCommand) {
t.Fatalf("help output includes retired generate command:\n%s", stdout.String())
}
removedInspectCommand := "inspect " + "briefing"
if !strings.Contains(stdout.String(), "inspect modules") || strings.Contains(stdout.String(), removedInspectCommand) {
t.Fatalf("help output has wrong inspect commands:\n%s", stdout.String())
@@ -682,6 +689,59 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
}
}
func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()
scriptoriumPath := writeStructuredOutputScriptorium(t, tempDir)
workspaceRoot := filepath.Join(tempDir, "workspace")
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
outPath := filepath.Join(tempDir, "hourly.md")
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 11, 0, 0, 0, time.UTC)}}
err := runner.Run(context.Background(), []string{
"generate", "hourly",
"--config", configPath,
"--out", outPath,
}, &stdout, &stderr)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
report, err := os.ReadFile(outPath)
if err != nil {
t.Fatalf("read report: %v", err)
}
for _, want := range []string{
"# Hourly Report",
"Storm chances increase through late morning.",
"The main window is 10 AM to noon.",
"Brief downpours may slow travel.",
} {
if !strings.Contains(string(report), want) {
t.Fatalf("report output missing %q:\n%s", want, string(report))
}
}
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "hourly", "2026-05-29", "*.data_package.yaml")
dataPackage, err := os.ReadFile(dataPackagePath)
if err != nil {
t.Fatalf("read managed data package: %v", err)
}
if !strings.Contains(string(dataPackage), "id: hourly") ||
!strings.Contains(string(dataPackage), "prompt_id: weather.hourly_generated_text") ||
!strings.Contains(string(dataPackage), "hourly_forecast:") {
t.Fatalf("data package output missing hourly content:\n%s", string(dataPackage))
}
rawGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.generated_text.raw.json")
validatedGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.generated_text.json")
renderContextPath := oneArtifact(t, workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.render_context.json")
managedReportPath := oneArtifact(t, workspaceRoot, "reports", "hourly", "*.md")
assertFileContains(t, rawGeneratedTextPath, `"summary": " Storm chances increase through late morning. "`)
assertFileContains(t, validatedGeneratedTextPath, `"summary":"Storm chances increase through late morning."`)
assertFileContains(t, renderContextPath, `"ReportTitle": "Hourly Report"`)
assertFileContains(t, managedReportPath, "# Hourly Report")
}
func TestRunInspectGeneratedArtifacts(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()
@@ -809,6 +869,7 @@ func TestResolveGenerateCommands(t *testing.T) {
}{
{name: "daily", args: []string{"daily", "--date", "2026-05-29"}, want: app.ReportDaily},
{name: "tomorrow", args: []string{"tomorrow"}, want: app.ReportTomorrow},
{name: "hourly", args: []string{"hourly"}, want: app.ReportHourly},
{name: "three-day", args: []string{"three-day"}, want: app.ReportThreeDay},
{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},
@@ -827,6 +888,55 @@ func TestResolveGenerateCommands(t *testing.T) {
}
}
func TestResolveGenerateHourlyAppliesSharedFlags(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{"hourly", "--config", configPath, "--units", "us", "--tz", "America/Chicago", "--out", "./hourly.md"})
if err != nil {
t.Fatalf("resolveGenerate() error = %v", err)
}
if req.Report != app.ReportHourly {
t.Fatalf("Report = %q, want hourly", 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 != "./hourly.md" {
t.Fatalf("OutputPath = %q, want ./hourly.md", req.OutputPath)
}
if !req.Date.IsZero() || !req.StormStart.IsZero() || !req.StormEnd.IsZero() {
t.Fatalf("date/storm bounds = %s/%s/%s, want unset for hourly", req.Date, req.StormStart, req.StormEnd)
}
}
func TestResolveGenerateHourlyRejectsDateAndStormBounds(t *testing.T) {
runner := Runner{Clock: fixedClock()}
for _, args := range [][]string{
{"hourly", "--date", "2026-05-29"},
{"hourly", "--start", "2026-05-29T18:00"},
{"hourly", "--end", "2026-05-29T20:00"},
{"hourly", "--hours", "6"},
{"hourly", "--duration", "6h"},
} {
_, 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) {
runner := Runner{Clock: fixedClock()}
@@ -859,6 +969,19 @@ func TestResolveGenerateAppliesSharedFlags(t *testing.T) {
}
}
func TestResolveGenerateRejectsRetiredHourlyCommand(t *testing.T) {
runner := Runner{Clock: fixedClock()}
retired := strings.Join([]string{"near", "term"}, "-")
_, err := runner.resolveGenerate([]string{retired})
if err == nil {
t.Fatal("resolveGenerate() error = nil, want unknown report")
}
if !strings.Contains(err.Error(), "unknown generate report") {
t.Fatalf("error = %q, want unknown generate report", err.Error())
}
}
func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) {
runner := Runner{Clock: fixedClock()}
@@ -996,6 +1119,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."]}}`))
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"}}`))
case "/outlooks/convective":
_, _ = w.Write([]byte(`{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`))
default:
http.NotFound(w, r)
}
@@ -1026,6 +1151,17 @@ func oneArtifact(t *testing.T, root string, parts ...string) string {
return matches[0]
}
func assertFileContains(t *testing.T, path string, want string) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
if !strings.Contains(string(data), want) {
t.Fatalf("%s missing %q:\n%s", path, want, string(data))
}
}
func writeFakeScriptorium(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, "scriptorium")
@@ -1056,6 +1192,52 @@ exit 1
return path
}
func writeStructuredOutputScriptorium(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, "scriptorium")
body := `#!/bin/sh
if [ "$1" = "render" ]; then
printf '{"ok":true,"argv":"%s"}' "$*"
exit 0
fi
if [ "$1" = "run" ]; then
out=""
prompt=""
while [ "$#" -gt 0 ]; do
if [ "$1" = "--out" ]; then
shift
out="$1"
elif [ "$1" = "--prompt" ]; then
shift
prompt="$1"
fi
shift
done
if [ "$prompt" = "weather.hourly_generated_text" ]; then
cat > "$out" <<'JSON'
{
"summary": " Storm chances increase through late morning. ",
"timing": "The main window is 10 AM to noon.",
"impacts": "Brief downpours may slow travel.",
"confidence": "Medium"
}
JSON
printf 'wrote generated text\n' >&2
exit 0
fi
printf '# Daily Report\n\nGenerated by fake scriptorium.\n' > "$out"
printf 'wrote report\n' >&2
exit 0
fi
printf 'unexpected command\n' >&2
exit 1
`
if err := os.WriteFile(path, []byte(body), 0o700); err != nil {
t.Fatalf("write fake scriptorium: %v", err)
}
return path
}
func writeFailingScriptorium(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, "scriptorium")

View File

@@ -85,12 +85,28 @@ func TestLoadExampleConfig(t *testing.T) {
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
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)
}
if len(cfg.Notify.Distributor.ReportPathTemplates) != 1 {
t.Fatalf("ReportPathTemplates = %#v, want example archive path", cfg.Notify.Distributor.ReportPathTemplates)
}
overrides := cfg.ReportModuleOverrides()
hourly := overrides[report.Hourly]
if len(hourly) != 9 {
t.Fatalf("hourly example override length = %d, want 9", len(hourly))
}
if hourly[0].ID != module.Metadata ||
hourly[1].ID != module.CurrentConditions ||
hourly[2].ID != module.HourlyForecast ||
hourly[3].ID != module.PrecipTiming ||
hourly[4].ID != module.AlertDigest ||
hourly[5].ID != module.SPCConvectiveOutlooks ||
hourly[6].ID != module.AreaForecastDiscussion ||
hourly[7].ID != module.SPCConvectiveDiscussion ||
hourly[8].ID != module.WeatherStory {
t.Fatalf("hourly example override = %#v, want configured module order", hourly)
}
}
func TestLoadMinimalExampleConfig(t *testing.T) {
@@ -126,10 +142,13 @@ reports:
deterministic_modules:
- metadata
- current_conditions
- alert_digest
- spc_convective_outlooks
- id: area_forecast_discussion
options:
sections:
- short_term
- spc_convective_discussion
`)
cfg, err := LoadFile(path)
@@ -138,21 +157,54 @@ reports:
}
overrides := cfg.ReportModuleOverrides()
items := overrides[report.DailyToday]
if len(items) != 3 {
t.Fatalf("daily override length = %d, want 3", len(items))
if len(items) != 6 {
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)
}
options, ok := items[2].Options.(module.AreaForecastDiscussionOptions)
options, ok := items[4].Options.(module.AreaForecastDiscussionOptions)
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" {
t.Fatalf("AFD sections = %#v, want short_term", options.Sections)
}
}
func TestLoadHourlyReportModuleOverrides(t *testing.T) {
path := writeConfig(t, `
reports:
hourly:
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.Hourly]
if len(items) != 4 {
t.Fatalf("hourly 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("hourly override = %#v, want configured module order", items)
}
}
func TestReportModuleOverrideValidation(t *testing.T) {
tests := []struct {
name string
@@ -236,6 +288,16 @@ reports:
`,
wantErr: "duplicates report override",
},
{
name: "HourlyIncompatibleDailyModule",
yaml: `
reports:
hourly:
deterministic_modules:
- derived_daily_summary
`,
wantErr: `not compatible with report "hourly"`,
},
{
name: "UnknownReportField",
yaml: `
@@ -261,6 +323,28 @@ reports:
}
}
func TestReportModuleOverrideRejectsRetiredHourlyKeys(t *testing.T) {
for _, key := range []string{
strings.Join([]string{"near", "term"}, "_"),
strings.Join([]string{"near", "term"}, "-"),
} {
t.Run(key, func(t *testing.T) {
_, err := LoadFile(writeConfig(t, `
reports:
`+key+`:
deterministic_modules:
- metadata
`))
if err == nil {
t.Fatal("LoadFile() error = nil, want unknown report")
}
if !strings.Contains(err.Error(), "is not a known report") {
t.Fatalf("error = %q, want unknown report", err.Error())
}
})
}
}
func TestExplicitMissingConfigReturnsError(t *testing.T) {
_, err := LoadFile(filepath.Join(t.TempDir(), "missing.yml"))
if err == nil {
@@ -599,7 +683,7 @@ func TestLoadFileLoadsSecretsBeforeReturningNotifyConfig(t *testing.T) {
"notify:\n" +
" distributor:\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 {
t.Fatalf("write config fixture: %v", err)
}

View File

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

View File

@@ -3,6 +3,7 @@ package facts
import (
"fmt"
"sort"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
@@ -21,6 +22,7 @@ type CollectedFacts struct {
Discussion *weatherdata.Discussion
Daily *weatherdata.ForecastRun
WeatherStory *weatherdata.WeatherStory
SPCConvectiveOutlooks *weatherdata.ConvectiveOutlookRun
SourceProvenance []weatherdata.Source
SourceWarnings []weatherdata.SourceWarning
@@ -40,6 +42,7 @@ func BuildCollected(bundle *weatherdata.Bundle) CollectedFacts {
Discussion: bundle.Discussion,
Daily: bundle.Daily,
WeatherStory: bundle.WeatherStory,
SPCConvectiveOutlooks: bundle.SPCConvectiveOutlooks,
SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...),
SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...),
}
@@ -56,6 +59,7 @@ func (f CollectedFacts) Bundle() *weatherdata.Bundle {
Discussion: f.Discussion,
Daily: f.Daily,
WeatherStory: f.WeatherStory,
SPCConvectiveOutlooks: f.SPCConvectiveOutlooks,
Sources: append([]weatherdata.Source(nil), f.SourceProvenance...),
Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...),
}
@@ -73,6 +77,8 @@ type DerivedFacts struct {
ValidPeriodNarrativePeriods []weatherdata.ForecastPeriod
ValidPeriodDailyPeriods []weatherdata.ForecastPeriod
AlertOverlaps []forecast.AlertOverlap
SPCConvectiveOutlooks []weatherdata.ConvectiveOutlook
SPCConvectiveDiscussions []weatherdata.ConvectiveOutlookDiscussion
DailySummaries []forecast.DailySummary
DaypartSummaries []forecast.DaypartSummary
PrecipTiming forecast.PrecipTiming
@@ -96,15 +102,19 @@ func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) {
}
bundle := req.Collected.Bundle()
period := req.Resolved.ValidPeriod
spcOutlooks, spcDiscussions := selectSPCConvectiveOutlooks(req.Collected.SPCConvectiveOutlooks, period)
derived := DerivedFacts{
ValidPeriodHourlyPeriods: forecast.SelectHourlyPeriods(req.Collected.Hourly, period),
ValidPeriodNarrativePeriods: forecast.SelectHourlyPeriods(req.Collected.Narrative, period),
ValidPeriodDailyPeriods: forecast.SelectHourlyPeriods(req.Collected.Daily, period),
AlertOverlaps: forecast.AlertOverlaps(req.Collected.Alerts, period),
SPCConvectiveOutlooks: spcOutlooks,
SPCConvectiveDiscussions: spcDiscussions,
}
derived.PrecipTiming = forecast.BuildPrecipTiming(derived.ValidPeriodHourlyPeriods)
switch req.Resolved.Definition.ID {
case report.Hourly:
case report.DailyToday, report.DailyTomorrow:
summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts)
if err != nil {
@@ -139,3 +149,81 @@ func collectDaypartSummaries(derived DerivedFacts) []forecast.DaypartSummary {
}
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 (
"encoding/json"
"strings"
"testing"
"time"
@@ -16,6 +17,13 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
FetchedAt: fetchedAt,
Current: &weatherdata.Current{ConditionText: "Clear"},
Hourly: &weatherdata.ForecastRun{Product: "hourly"},
SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{
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"}},
}
@@ -24,6 +32,9 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
if collected.FetchedAt != fetchedAt || collected.Current.ConditionText != "Clear" || collected.Hourly.Product != "hourly" {
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" {
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" {
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) {
@@ -136,6 +152,63 @@ func TestBuildDerivedWeekendAndTomorrow(t *testing.T) {
}
}
func TestBuildDerivedHourlyUsesRollingWindowFacts(t *testing.T) {
location := testLocation()
resolved := resolveForTest(t, report.Hourly, 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("hourly 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 hourly windows", derived.PrecipTiming.PrecipitationWindows)
}
if len(derived.AlertOverlaps) != 1 || derived.AlertOverlaps[0].Event != "Flood Watch" {
t.Fatalf("AlertOverlaps = %#v, want only alert overlapping hourly 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 hourly 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) {
location := testLocation()
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.Hourly, 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 {
return &weatherdata.Bundle{
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 {
temperature := 70.0
return weatherdata.ForecastPeriod{

View File

@@ -0,0 +1,55 @@
// Package generatedtext validates structured LLM text and render contexts.
package generatedtext
import (
"bytes"
"encoding/json"
"fmt"
"io"
"strings"
)
type Hourly struct {
Summary string `json:"summary"`
Timing string `json:"timing"`
Impacts string `json:"impacts"`
Confidence string `json:"confidence,omitempty"`
}
func ValidateHourly(data []byte) (Hourly, []byte, error) {
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
var value Hourly
if err := decoder.Decode(&value); err != nil {
return Hourly{}, nil, fmt.Errorf("decode hourly generated text: %w", err)
}
var extra any
if err := decoder.Decode(&extra); err != nil {
if err != io.EOF {
return Hourly{}, nil, fmt.Errorf("decode hourly generated text: %w", err)
}
} else {
return Hourly{}, nil, fmt.Errorf("decode hourly generated text: multiple JSON values")
}
value.Summary = strings.TrimSpace(value.Summary)
value.Timing = strings.TrimSpace(value.Timing)
value.Impacts = strings.TrimSpace(value.Impacts)
value.Confidence = strings.TrimSpace(value.Confidence)
if value.Summary == "" {
return Hourly{}, nil, fmt.Errorf("hourly generated text summary is required")
}
if value.Timing == "" {
return Hourly{}, nil, fmt.Errorf("hourly generated text timing is required")
}
if value.Impacts == "" {
return Hourly{}, nil, fmt.Errorf("hourly generated text impacts is required")
}
normalized, err := json.Marshal(value)
if err != nil {
return Hourly{}, nil, fmt.Errorf("normalize hourly generated text: %w", err)
}
return value, normalized, nil
}

View File

@@ -0,0 +1,86 @@
package generatedtext
import (
"strings"
"testing"
)
func TestValidateHourlyNormalizesJSON(t *testing.T) {
value, normalized, err := ValidateHourly([]byte(`{
"timing": " main window late morning ",
"summary": " Storm chances increase. ",
"confidence": " Medium ",
"impacts": " Brief downpours. "
}`))
if err != nil {
t.Fatalf("ValidateHourly() error = %v", err)
}
if value.Summary != "Storm chances increase." {
t.Fatalf("Summary = %q, want trimmed summary", value.Summary)
}
want := `{"summary":"Storm chances increase.","timing":"main window late morning","impacts":"Brief downpours.","confidence":"Medium"}`
if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want)
}
}
func TestValidateHourlyOmitsEmptyConfidence(t *testing.T) {
_, normalized, err := ValidateHourly([]byte(`{
"summary": "Storm chances increase.",
"timing": "Late morning.",
"impacts": "Brief downpours.",
"confidence": " "
}`))
if err != nil {
t.Fatalf("ValidateHourly() error = %v", err)
}
want := `{"summary":"Storm chances increase.","timing":"Late morning.","impacts":"Brief downpours."}`
if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want)
}
}
func TestValidateHourlyRejectsInvalidInput(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{
name: "malformed",
in: `{`,
want: "decode hourly generated text",
},
{
name: "unknown field",
in: `{"summary":"Storm chances increase.","timing":"Late morning.","impacts":"Brief downpours.","extra":"value"}`,
want: `unknown field "extra"`,
},
{
name: "missing summary",
in: `{"timing":"Late morning.","impacts":"Brief downpours."}`,
want: "summary is required",
},
{
name: "blank timing",
in: `{"summary":"Storm chances increase.","timing":" ","impacts":"Brief downpours."}`,
want: "timing is required",
},
{
name: "multiple values",
in: `{"summary":"Storm chances increase.","timing":"Late morning.","impacts":"Brief downpours."} {}`,
want: "multiple JSON values",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, _, err := ValidateHourly([]byte(test.in))
if err == nil {
t.Fatal("ValidateHourly() error = nil, want error")
}
if !strings.Contains(err.Error(), test.want) {
t.Fatalf("ValidateHourly() error = %v, want %q", err, test.want)
}
})
}
}

View File

@@ -0,0 +1,360 @@
package generatedtext
import (
"fmt"
"strings"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
type HourlyRenderContext struct {
ReportTitle string
LocationName string
ValidPeriod string
GeneratedAt string
GeneratedText Hourly
CurrentConditions string
HourlyForecast []HourlyForecastRow
PrecipitationTiming string
Alerts []string
SPCOutlooks []string
ForecastDiscussion ForecastDiscussion
SPCDiscussions []string
WeatherStory string
}
type HourlyForecastRow struct {
Time string
Summary string
Temperature string
Precipitation string
Wind string
}
type ForecastDiscussion struct {
KeyMessages []string
ShortTerm string
}
func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Hourly) (HourlyRenderContext, error) {
location, err := timeutil.LoadLocation(metadata.Timezone)
if err != nil {
return HourlyRenderContext{}, fmt.Errorf("build hourly render context: %w", err)
}
if metadata.GeneratedAt.IsZero() {
return HourlyRenderContext{}, fmt.Errorf("build hourly render context: generatedAt is required")
}
if !metadata.ValidPeriod.IsValid() {
return HourlyRenderContext{}, fmt.Errorf("build hourly render context: valid period is required")
}
current, err := requiredStanza[briefing.CurrentConditionsModule](snapshot, string(module.CurrentConditions))
if err != nil {
return HourlyRenderContext{}, err
}
hourly, err := requiredStanza[briefing.HourlyForecastModule](snapshot, string(module.HourlyForecast))
if err != nil {
return HourlyRenderContext{}, err
}
precip, err := requiredStanza[briefing.PrecipTimingModule](snapshot, string(module.PrecipTiming))
if err != nil {
return HourlyRenderContext{}, err
}
alerts, err := requiredStanza[briefing.AlertDigestModule](snapshot, string(module.AlertDigest))
if err != nil {
return HourlyRenderContext{}, err
}
outlooks, err := requiredStanza[briefing.SPCConvectiveOutlooksModule](snapshot, string(module.SPCConvectiveOutlooks))
if err != nil {
return HourlyRenderContext{}, err
}
discussion, err := optionalStanza[briefing.AreaForecastDiscussionModule](snapshot, string(module.AreaForecastDiscussion))
if err != nil {
return HourlyRenderContext{}, err
}
spcDiscussion, err := optionalStanza[briefing.SPCConvectiveDiscussionModule](snapshot, string(module.SPCConvectiveDiscussion))
if err != nil {
return HourlyRenderContext{}, err
}
story, err := optionalStanza[briefing.WeatherStoryModule](snapshot, string(module.WeatherStory))
if err != nil {
return HourlyRenderContext{}, err
}
return HourlyRenderContext{
ReportTitle: "Hourly Report",
LocationName: locationName(metadata),
ValidPeriod: periodLabel(metadata.ValidPeriod, location),
GeneratedAt: timeLabel(metadata.GeneratedAt, location),
GeneratedText: generated,
CurrentConditions: currentConditionsLabel(current),
HourlyForecast: hourlyForecastRows(hourly),
PrecipitationTiming: precipitationTimingLabel(precip),
Alerts: alertLabels(alerts),
SPCOutlooks: outlookLabels(outlooks),
ForecastDiscussion: ForecastDiscussion{
KeyMessages: append([]string(nil), discussion.KeyMessages...),
ShortTerm: discussion.ShortTerm,
},
SPCDiscussions: spcDiscussionLabels(spcDiscussion),
WeatherStory: weatherStoryLabel(story),
}, nil
}
func requiredStanza[T any](snapshot module.Snapshot, name string) (T, error) {
value, ok, err := module.StanzaValue[T](snapshot, name)
if err != nil {
var zero T
return zero, fmt.Errorf("build hourly render context: %w", err)
}
if !ok {
var zero T
return zero, fmt.Errorf("build hourly render context requires stanza %q", name)
}
return value, nil
}
func optionalStanza[T any](snapshot module.Snapshot, name string) (T, error) {
value, _, err := module.StanzaValue[T](snapshot, name)
if err != nil {
var zero T
return zero, fmt.Errorf("build hourly render context: %w", err)
}
return value, nil
}
func locationName(metadata briefing.Metadata) string {
if metadata.Location != nil {
if metadata.Location.Name != "" && metadata.Location.Region != "" {
return metadata.Location.Name + ", " + metadata.Location.Region
}
if metadata.Location.Name != "" {
return metadata.Location.Name
}
}
if metadata.SourceLocation != "" {
return metadata.SourceLocation
}
if metadata.SourceLocationID != "" {
return metadata.SourceLocationID
}
return "Unknown location"
}
func periodLabel(period timeutil.Period, location *time.Location) string {
return fmt.Sprintf("%s to %s", timeLabel(period.Start, location), timeLabel(period.End, location))
}
func timeLabel(value time.Time, location *time.Location) string {
return value.In(location).Format("2006-01-02 at 3:04 PM")
}
func currentConditionsLabel(current briefing.CurrentConditionsModule) string {
parts := []string{}
if current.ConditionText != "" {
parts = append(parts, current.ConditionText)
}
if temperature := temperatureLabel(current.TemperatureF, current.TemperatureC); temperature != "" {
parts = append(parts, temperature)
}
if apparent := temperatureLabel(current.ApparentTemperatureF, current.ApparentTemperatureC); apparent != "" {
parts = append(parts, "feels like "+apparent)
}
if current.RelativeHumidityPercent != nil {
parts = append(parts, fmt.Sprintf("humidity %d%%", rounded(*current.RelativeHumidityPercent)))
}
if wind := windLabel(current.WindDirection, current.WindSpeedMph, current.WindSpeedKmh, nil, nil); wind != "" {
parts = append(parts, wind)
}
if len(parts) == 0 {
return "No current conditions available."
}
return strings.Join(parts, "; ") + "."
}
func hourlyForecastRows(hourly briefing.HourlyForecastModule) []HourlyForecastRow {
rows := make([]HourlyForecastRow, 0, len(hourly.Periods))
for _, period := range hourly.Periods {
summary := period.TextDescription
if summary == "" {
summary = period.Name
}
rows = append(rows, HourlyForecastRow{
Time: firstNonEmpty(period.PeriodBegins, period.Name),
Summary: summary,
Temperature: temperatureLabel(period.TemperatureF, period.TemperatureC),
Precipitation: precipitationLabel(period.ProbabilityOfPrecipitationPercent),
Wind: windLabel(period.WindDirection, period.WindSpeedMph, period.WindSpeedKmh, period.WindGustMph, period.WindGustKmh),
})
}
return rows
}
func precipitationTimingLabel(timing briefing.PrecipTimingModule) string {
parts := []string{}
if timing.MaxPopPercent != nil {
max := fmt.Sprintf("Peak precipitation probability %d%%", *timing.MaxPopPercent)
if timing.MaxPopTime != "" {
max += " at " + timing.MaxPopTime
}
parts = append(parts, max)
}
for _, window := range timing.PrecipitationWindows {
label := window.PeriodBegins
if window.PeriodEnds != "" {
label += " to " + window.PeriodEnds
}
if window.MaxPopPercent != nil {
label += fmt.Sprintf(" (max %d%%", *window.MaxPopPercent)
if window.MaxPopTime != "" {
label += " at " + window.MaxPopTime
}
label += ")"
}
parts = append(parts, label)
}
if timing.ThunderMentioned {
parts = append(parts, "Thunder is mentioned in the forecast.")
}
if len(parts) == 0 {
return "No precipitation timing signal above threshold."
}
return strings.Join(parts, "; ")
}
func alertLabels(alerts briefing.AlertDigestModule) []string {
if alerts.Missing {
return []string{"Alert source missing."}
}
out := make([]string, 0, len(alerts.Relevant))
for _, alert := range alerts.Relevant {
main := firstNonEmpty(alert.Event, alert.Headline)
if main == "" {
continue
}
if alert.Headline != "" && alert.Headline != main {
main += ": " + alert.Headline
}
if alert.Severity != "" {
main += " (" + alert.Severity + ")"
}
out = append(out, main)
}
return out
}
func outlookLabels(outlooks briefing.SPCConvectiveOutlooksModule) []string {
out := make([]string, 0, len(outlooks.Outlooks))
for _, outlook := range outlooks.Outlooks {
label := firstNonEmpty(outlook.LabelText, outlook.Label, outlook.OutlookType)
if label == "" {
continue
}
if outlook.PeriodBegins != "" {
label += " from " + outlook.PeriodBegins
if outlook.PeriodEnds != "" {
label += " to " + outlook.PeriodEnds
}
}
out = append(out, label)
}
return out
}
func spcDiscussionLabels(discussion briefing.SPCConvectiveDiscussionModule) []string {
out := make([]string, 0, len(discussion.Discussions))
for _, record := range discussion.Discussions {
label := firstNonEmpty(record.Headline, record.Summary, record.Discussion)
if label == "" {
continue
}
if record.Summary != "" && record.Summary != label {
label += ": " + record.Summary
}
out = append(out, label)
}
return out
}
func weatherStoryLabel(story briefing.WeatherStoryModule) string {
if !story.Available {
return "No weather story available."
}
parts := []string{}
if story.Title != "" {
parts = append(parts, story.Title)
}
if story.Description != "" {
parts = append(parts, story.Description)
}
if len(parts) == 0 {
return "Weather story is available."
}
return strings.Join(parts, " - ")
}
func temperatureLabel(fahrenheit *float64, celsius *float64) string {
if fahrenheit != nil {
return fmt.Sprintf("%d F", rounded(*fahrenheit))
}
if celsius != nil {
return fmt.Sprintf("%d C", rounded(*celsius))
}
return ""
}
func precipitationLabel(percent *float64) string {
if percent == nil {
return ""
}
return fmt.Sprintf("%d%% precipitation", rounded(*percent))
}
func windLabel(direction string, mph *float64, kmh *float64, gustMph *float64, gustKmh *float64) string {
speed := ""
if mph != nil {
speed = fmt.Sprintf("%d mph", rounded(*mph))
} else if kmh != nil {
speed = fmt.Sprintf("%d km/h", rounded(*kmh))
}
if direction != "" && speed != "" {
speed = direction + " " + speed
} else if direction != "" {
speed = direction + " wind"
}
gust := ""
if gustMph != nil {
gust = fmt.Sprintf("gusts %d mph", rounded(*gustMph))
} else if gustKmh != nil {
gust = fmt.Sprintf("gusts %d km/h", rounded(*gustKmh))
}
switch {
case speed != "" && gust != "":
return "wind " + speed + ", " + gust
case speed != "":
return "wind " + speed
case gust != "":
return "wind " + gust
default:
return ""
}
}
func rounded(value float64) int {
if value < 0 {
return int(value - 0.5)
}
return int(value + 0.5)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}

View File

@@ -0,0 +1,249 @@
package generatedtext
import (
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/reporttemplate"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func TestBuildHourlyRenderContext(t *testing.T) {
metadata := testMetadata()
snapshot := testSnapshot(t)
generated := Hourly{
Summary: "Storm chances increase through late morning.",
Timing: "The main window is 10 AM to noon.",
Impacts: "Brief downpours may slow travel.",
Confidence: "Medium confidence in timing.",
}
ctx, err := BuildHourlyRenderContext(metadata, snapshot, generated)
if err != nil {
t.Fatalf("BuildHourlyRenderContext() error = %v", err)
}
if ctx.ReportTitle != "Hourly Report" {
t.Fatalf("ReportTitle = %q, want Hourly Report", ctx.ReportTitle)
}
if ctx.LocationName != "Brentwood, MO" {
t.Fatalf("LocationName = %q, want Brentwood, MO", ctx.LocationName)
}
if ctx.ValidPeriod != "2026-05-29 at 8:30 AM to 2026-05-29 at 2:30 PM" {
t.Fatalf("ValidPeriod = %q, want friendly period", ctx.ValidPeriod)
}
if ctx.CurrentConditions != "Partly cloudy; 74 F; feels like 76 F; humidity 71%; wind S 8 mph." {
t.Fatalf("CurrentConditions = %q, want deterministic summary", ctx.CurrentConditions)
}
if len(ctx.HourlyForecast) != 2 {
t.Fatalf("HourlyForecast length = %d, want 2", len(ctx.HourlyForecast))
}
if row := ctx.HourlyForecast[1]; row.Time != "2026-05-29 at 10:00 AM" || row.Summary != "Showers" || row.Precipitation != "70% precipitation" {
t.Fatalf("HourlyForecast[1] = %#v, want 10 AM showers row", row)
}
if !strings.Contains(ctx.PrecipitationTiming, "Peak precipitation probability 70% at 10 AM") {
t.Fatalf("PrecipitationTiming = %q, want peak probability", ctx.PrecipitationTiming)
}
if strings.Join(ctx.Alerts, "|") != "Flood Watch: Flood Watch until early afternoon (Moderate)" {
t.Fatalf("Alerts = %#v, want alert label", ctx.Alerts)
}
if strings.Join(ctx.SPCOutlooks, "|") != "Slight Risk from 2026-05-29 at 7:00 AM to 2026-05-29 at 3:00 PM" {
t.Fatalf("SPCOutlooks = %#v, want outlook label", ctx.SPCOutlooks)
}
if ctx.ForecastDiscussion.ShortTerm != "Short-term discussion favors increasing rain coverage." {
t.Fatalf("ForecastDiscussion.ShortTerm = %q, want discussion text", ctx.ForecastDiscussion.ShortTerm)
}
if strings.Join(ctx.SPCDiscussions, "|") != "Mesoscale discussion: Strong storms may develop late morning." {
t.Fatalf("SPCDiscussions = %#v, want discussion label", ctx.SPCDiscussions)
}
if ctx.WeatherStory != "Morning storms - Morning storms remain the main story." {
t.Fatalf("WeatherStory = %q, want story label", ctx.WeatherStory)
}
rendered, err := reporttemplate.Render("hourly", ctx)
if err != nil {
t.Fatalf("Render() error = %v", err)
}
text := string(rendered)
for _, want := range []string{
"# Hourly Report",
"Storm chances increase through late morning.",
"- 2026-05-29 at 10:00 AM: Showers; 75 F; 70% precipitation; wind S 10 mph, gusts 18 mph",
"- Flood Watch: Flood Watch until early afternoon (Moderate)",
"Morning storms - Morning storms remain the main story.",
} {
if !strings.Contains(text, want) {
t.Fatalf("rendered template missing %q:\n%s", want, text)
}
}
}
func TestBuildHourlyRenderContextRequiresCoreStanzas(t *testing.T) {
snapshot, err := module.NewSnapshot([]module.Output{
{ID: module.CurrentConditions, StanzaName: string(module.CurrentConditions), Value: briefing.CurrentConditionsModule{}},
})
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
_, err = BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{
Summary: "Storm chances increase.",
Timing: "Late morning.",
Impacts: "Brief downpours.",
})
if err == nil {
t.Fatal("BuildHourlyRenderContext() error = nil, want missing stanza error")
}
if !strings.Contains(err.Error(), `requires stanza "hourly_forecast"`) {
t.Fatalf("BuildHourlyRenderContext() error = %v, want missing hourly forecast stanza", err)
}
}
func testMetadata() briefing.Metadata {
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
return briefing.Metadata{
RunID: "run-1",
ReportID: report.Hourly,
PromptID: "weather.hourly_generated_text",
GeneratedAt: generatedAt,
Units: "imperial",
Timezone: "America/Chicago",
ValidPeriod: timeutil.Period{
Start: generatedAt,
End: generatedAt.Add(6 * time.Hour),
},
Location: &briefing.LocationContext{
Name: "Brentwood",
Region: "MO",
},
}
}
func testSnapshot(t *testing.T) module.Snapshot {
t.Helper()
snapshot, err := module.NewSnapshot([]module.Output{
{
ID: module.CurrentConditions,
StanzaName: string(module.CurrentConditions),
Value: briefing.CurrentConditionsModule{
ConditionText: "Partly cloudy",
TemperatureF: floatPtr(74),
ApparentTemperatureF: floatPtr(76),
RelativeHumidityPercent: floatPtr(71),
WindSpeedMph: floatPtr(8),
WindDirection: "S",
},
},
{
ID: module.HourlyForecast,
StanzaName: string(module.HourlyForecast),
Value: briefing.HourlyForecastModule{
Periods: []briefing.HourlyForecastPeriod{
{
PeriodBegins: "2026-05-29 at 9:00 AM",
TextDescription: "Cloudy",
TemperatureF: floatPtr(74),
ProbabilityOfPrecipitationPercent: floatPtr(30),
WindSpeedMph: floatPtr(8),
WindDirection: "S",
},
{
PeriodBegins: "2026-05-29 at 10:00 AM",
TextDescription: "Showers",
TemperatureF: floatPtr(75),
ProbabilityOfPrecipitationPercent: floatPtr(70),
WindSpeedMph: floatPtr(10),
WindGustMph: floatPtr(18),
WindDirection: "S",
},
},
},
},
{
ID: module.PrecipTiming,
StanzaName: string(module.PrecipTiming),
Value: briefing.PrecipTimingModule{
MaxPopPercent: intPtr(70),
MaxPopTime: "10 AM",
ProbabilityThreshold: 50,
PrecipitationWindows: []briefing.PrecipitationWindowModule{
{
PeriodBegins: "2026-05-29 at 10:00 AM",
PeriodEnds: "2026-05-29 at 12:00 PM",
MaxPopPercent: intPtr(70),
MaxPopTime: "10 AM",
},
},
ThunderMentioned: true,
},
},
{
ID: module.AlertDigest,
StanzaName: string(module.AlertDigest),
Value: briefing.AlertDigestModule{
Checked: true,
ActiveCount: 1,
RelevantCount: 1,
Relevant: []briefing.AlertSummary{
{Event: "Flood Watch", Headline: "Flood Watch until early afternoon", Severity: "Moderate"},
},
},
},
{
ID: module.SPCConvectiveOutlooks,
StanzaName: string(module.SPCConvectiveOutlooks),
Value: briefing.SPCConvectiveOutlooksModule{
Checked: true,
OutlookCount: 1,
Outlooks: []briefing.SPCConvectiveOutlookRecord{
{
Label: "SLGT",
LabelText: "Slight Risk",
PeriodBegins: "2026-05-29 at 7:00 AM",
PeriodEnds: "2026-05-29 at 3:00 PM",
},
},
},
},
{
ID: module.AreaForecastDiscussion,
StanzaName: string(module.AreaForecastDiscussion),
Value: briefing.AreaForecastDiscussionModule{
KeyMessages: []string{"Storms are most likely late morning."},
ShortTerm: "Short-term discussion favors increasing rain coverage.",
},
},
{
ID: module.SPCConvectiveDiscussion,
StanzaName: string(module.SPCConvectiveDiscussion),
Value: briefing.SPCConvectiveDiscussionModule{
IncludedBecause: "categorical severity_rank >= 3",
Discussions: []briefing.SPCConvectiveDiscussionRecord{
{Headline: "Mesoscale discussion", Summary: "Strong storms may develop late morning."},
},
},
},
{
ID: module.WeatherStory,
StanzaName: string(module.WeatherStory),
Value: briefing.WeatherStoryModule{
Available: true,
Title: "Morning storms",
Description: "Morning storms remain the main story.",
},
},
})
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
return snapshot
}
func floatPtr(value float64) *float64 {
return &value
}
func intPtr(value int) *int {
return &value
}

View File

@@ -21,6 +21,8 @@ const (
AlertDigest ID = "alert_digest"
AreaForecastDiscussion ID = "area_forecast_discussion"
WeatherStory ID = "weather_story"
SPCConvectiveOutlooks ID = "spc_convective_outlooks"
SPCConvectiveDiscussion ID = "spc_convective_discussion"
OutdoorWindows ID = "outdoor_windows"
TomorrowPlanning ID = "tomorrow_planning"
)
@@ -111,6 +113,7 @@ const (
CollectedAlerts FactRequirement = "collected.alerts"
CollectedDiscussion FactRequirement = "collected.discussion"
CollectedWeatherStory FactRequirement = "collected.weather_story"
CollectedSPCConvectiveOutlooks FactRequirement = "collected.spc_convective_outlooks"
CollectedSourceMetadata FactRequirement = "collected.source_metadata"
RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods"
RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods"
@@ -118,6 +121,7 @@ const (
RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries"
RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries"
RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing"
RequiresDerivedSPCConvectiveOutlooks FactRequirement = "derived.spc_convective_outlooks"
)
type MissingDataBehavior string
@@ -141,5 +145,7 @@ type AreaForecastDiscussionOptions struct {
Sections []string `json:"sections,omitempty" yaml:"sections,omitempty"`
}
type WeatherStoryOptions struct{}
type SPCConvectiveOutlooksOptions struct{}
type SPCConvectiveDiscussionOptions struct{}
type OutdoorWindowsOptions struct{}
type TomorrowPlanningOptions struct{}

View File

@@ -82,3 +82,21 @@ func TestStanzaValueDecodesTypedOutput(t *testing.T) {
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{
string(module.AlertDigest): categoryApplicableRiskProducts,
string(module.SPCConvectiveOutlooks): categoryApplicableRiskProducts,
string(module.DerivedDailySummary): categoryDerivedSummaries,
string(module.DerivedDaypartSummaries): categoryDerivedSummaries,
string(module.PrecipTiming): categoryDerivedSummaries,
@@ -42,6 +43,7 @@ var briefingStanzaCategories = map[string]string{
string(module.TomorrowPlanning): categoryDerivedSummaries,
string(module.NarrativeForecast): categoryNarrativeProducts,
string(module.AreaForecastDiscussion): categoryNarrativeProducts,
string(module.SPCConvectiveDiscussion): categoryNarrativeProducts,
string(module.WeatherStory): categoryNarrativeProducts,
string(module.CurrentConditions): 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) {
pkg, err := Build(validBuildRequest(t))
if err != nil {

View File

@@ -10,6 +10,7 @@ func dailyTodayDefinition() Definition {
ID: DailyToday,
Name: "Daily Report",
PromptID: "weather.daily_report",
GenerationMode: GenerationModeScriptoriumMarkdown,
ComparisonStrategy: CompareSameValidDate,
ArtifactGroup: "daily",
BatchOutputName: "daily.md",
@@ -26,6 +27,7 @@ func dailyTomorrowDefinition() Definition {
ID: DailyTomorrow,
Name: "Tomorrow Planning Brief",
PromptID: "weather.daily_report",
GenerationMode: GenerationModeScriptoriumMarkdown,
ComparisonStrategy: CompareSameValidDate,
ArtifactGroup: "daily",
BatchOutputName: "tomorrow.md",
@@ -46,7 +48,9 @@ func dailyTodayModules() []module.ConfigItem {
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
module.OutdoorWindows,
module.HourlyForecast,
@@ -62,7 +66,9 @@ func dailyTomorrowModules() []module.ConfigItem {
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
module.OutdoorWindows,
module.TomorrowPlanning,

View File

@@ -14,6 +14,7 @@ type ID string
const (
DailyToday ID = "daily_today"
DailyTomorrow ID = "daily_tomorrow"
Hourly ID = "hourly"
ThreeDay ID = "three_day"
Weekend ID = "weekend"
Storm ID = "storm"
@@ -25,6 +26,14 @@ const (
CompareSameValidDate ComparisonStrategy = "same_valid_date"
CompareWeekendWindow ComparisonStrategy = "same_weekend_window"
CompareExplicitWindow ComparisonStrategy = "explicit_event_window"
CompareRollingWindow ComparisonStrategy = "rolling_window"
)
type GenerationMode string
const (
GenerationModeScriptoriumMarkdown GenerationMode = "scriptorium_markdown"
GenerationModeGeneratedTextTemplate GenerationMode = "generated_text_template"
)
type Batch string
@@ -38,6 +47,9 @@ type Definition struct {
ID ID
Name string
PromptID string
GenerationMode GenerationMode
TemplateID string
GeneratedTextSchemaID string
ComparisonStrategy ComparisonStrategy
ArtifactGroup string
BatchOutputName string

View File

@@ -0,0 +1,55 @@
package report
import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
const hourlyReportHours = 6
func hourlyDefinition() Definition {
return Definition{
ID: Hourly,
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},
Modules: hourlyModules(),
resolve: resolveHourly,
}
}
func hourlyModules() []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 resolveHourly(req ResolveRequest) (timeutil.Period, error) {
localNow := req.Now.In(req.Location)
return timeutil.Period{
Start: localNow,
End: localNow.Add(hourlyReportHours * time.Hour),
}, nil
}

View File

@@ -58,6 +58,60 @@ func TestThreeDayPeriodCalculation(t *testing.T) {
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:00:00-05:00", "2026-06-01T00:00:00-05:00")
}
func TestHourlyLookupAndPeriodCalculation(t *testing.T) {
location := mustLoadLocation(t)
now := mustParse("2026-05-29T05:15:00-05:00")
resolved, err := Resolve(Hourly, ResolveRequest{Now: now, Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Definition.ID != Hourly {
t.Fatalf("ID = %q, want hourly", resolved.Definition.ID)
}
if resolved.Definition.PromptID != "weather.hourly_generated_text" {
t.Fatalf("PromptID = %q, want weather.hourly_generated_text", resolved.Definition.PromptID)
}
if resolved.Definition.GenerationMode != GenerationModeGeneratedTextTemplate {
t.Fatalf("GenerationMode = %q, want generated_text_template", resolved.Definition.GenerationMode)
}
if resolved.Definition.TemplateID != "hourly" {
t.Fatalf("TemplateID = %q, want hourly", resolved.Definition.TemplateID)
}
if resolved.Definition.GeneratedTextSchemaID != "hourly" {
t.Fatalf("GeneratedTextSchemaID = %q, want hourly", resolved.Definition.GeneratedTextSchemaID)
}
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 TestHourlyPeriodUsesEffectiveTimezone(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(Hourly, 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 TestHourlyPeriodIsNotCivilDayTruncated(t *testing.T) {
location := mustLoadLocation(t)
now := mustParse("2026-05-29T22:30:00-05:00")
resolved, err := Resolve(Hourly, 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) {
location := mustLoadLocation(t)
tests := []struct {
@@ -158,6 +212,25 @@ func TestEveningBatchIncludesTomorrow(t *testing.T) {
}
}
func TestBatchesDoNotIncludeHourly(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 == Hourly {
t.Fatalf("batch included %q, want hourly excluded", resolved.Definition.ID)
}
}
}
func TestRegistryLookupErrorIsActionable(t *testing.T) {
_, err := DefaultRegistry().Lookup(ID("unknown"))
if err == nil {
@@ -168,17 +241,57 @@ func TestRegistryLookupErrorIsActionable(t *testing.T) {
}
}
func TestRegistryAllIncludesHourlyInStableOrder(t *testing.T) {
ids := resolvedDefinitionIDs(DefaultRegistry().All())
want := []string{"daily_today", "daily_tomorrow", "hourly", "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) {
for _, definition := range DefaultRegistry().All() {
if definition.PromptID == "" {
t.Fatalf("%s PromptID is empty", definition.ID)
}
if definition.Generated && definition.GenerationMode == "" {
t.Fatalf("%s GenerationMode is empty", definition.ID)
}
if definition.ComparisonStrategy == "" {
t.Fatalf("%s ComparisonStrategy is empty", definition.ID)
}
}
}
func TestRegistryDefinitionsDeclareGenerationMetadata(t *testing.T) {
for _, definition := range DefaultRegistry().All() {
if !definition.Generated {
continue
}
if definition.ID == Hourly {
if definition.GenerationMode != GenerationModeGeneratedTextTemplate {
t.Fatalf("%s GenerationMode = %q, want %q", definition.ID, definition.GenerationMode, GenerationModeGeneratedTextTemplate)
}
if definition.TemplateID != "hourly" {
t.Fatalf("%s TemplateID = %q, want hourly", definition.ID, definition.TemplateID)
}
if definition.GeneratedTextSchemaID != "hourly" {
t.Fatalf("%s GeneratedTextSchemaID = %q, want hourly", definition.ID, definition.GeneratedTextSchemaID)
}
continue
}
if definition.GenerationMode != GenerationModeScriptoriumMarkdown {
t.Fatalf("%s GenerationMode = %q, want %q", definition.ID, definition.GenerationMode, GenerationModeScriptoriumMarkdown)
}
if definition.TemplateID != "" {
t.Fatalf("%s TemplateID = %q, want empty for Markdown report", definition.ID, definition.TemplateID)
}
if definition.GeneratedTextSchemaID != "" {
t.Fatalf("%s GeneratedTextSchemaID = %q, want empty for Markdown report", definition.ID, definition.GeneratedTextSchemaID)
}
}
}
func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
tests := []struct {
id ID
@@ -186,6 +299,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
batchOutputName string
generated bool
compatiblePriorIDs []ID
comparisonStrategy ComparisonStrategy
}{
{
id: DailyToday,
@@ -193,6 +307,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
batchOutputName: "daily.md",
generated: true,
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
comparisonStrategy: CompareSameValidDate,
},
{
id: DailyTomorrow,
@@ -200,6 +315,15 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
batchOutputName: "tomorrow.md",
generated: true,
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
comparisonStrategy: CompareSameValidDate,
},
{
id: Hourly,
artifactGroup: "hourly",
batchOutputName: "hourly.md",
generated: true,
compatiblePriorIDs: []ID{Hourly},
comparisonStrategy: CompareRollingWindow,
},
{
id: ThreeDay,
@@ -207,6 +331,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
batchOutputName: "three-day.md",
generated: true,
compatiblePriorIDs: []ID{ThreeDay},
comparisonStrategy: CompareSameValidDate,
},
{
id: Weekend,
@@ -214,6 +339,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
batchOutputName: "weekend.md",
generated: true,
compatiblePriorIDs: []ID{Weekend},
comparisonStrategy: CompareWeekendWindow,
},
{
id: Storm,
@@ -221,6 +347,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
batchOutputName: "storm.md",
generated: true,
compatiblePriorIDs: []ID{Storm},
comparisonStrategy: CompareExplicitWindow,
},
}
@@ -240,6 +367,9 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
if 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) {
t.Fatalf("CompatiblePriorIDs = %#v, want %#v", definition.CompatiblePriorIDs, tt.compatiblePriorIDs)
}
@@ -267,7 +397,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
module.OutdoorWindows,
module.HourlyForecast,
@@ -283,13 +415,29 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
module.OutdoorWindows,
module.TomorrowPlanning,
module.HourlyForecast,
},
},
{
id: Hourly,
want: []module.ID{
module.Metadata,
module.CurrentConditions,
module.HourlyForecast,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
},
},
{
id: ThreeDay,
want: []module.ID{
@@ -298,7 +446,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
module.OutdoorWindows,
},
@@ -311,7 +461,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
module.OutdoorWindows,
},
@@ -323,7 +475,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
module.CurrentConditions,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
},
},
@@ -402,6 +556,24 @@ func TestResolvedMetadata(t *testing.T) {
}
}
func TestHourlyMetadataRunIDIncludesReportID(t *testing.T) {
location := mustLoadLocation(t)
resolved, err := Resolve(Hourly, 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 != Hourly {
t.Fatalf("ReportID = %q, want hourly", metadata.ReportID)
}
if metadata.PromptID != "weather.hourly_generated_text" {
t.Fatalf("PromptID = %q, want weather.hourly_generated_text", metadata.PromptID)
}
if !strings.Contains(metadata.RunID, "hourly") {
t.Fatalf("RunID = %q, want report id", metadata.RunID)
}
}
func assertPeriod(t *testing.T, period timeutil.Period, wantStart string, wantEnd string) {
t.Helper()
if !period.IsValid() {
@@ -423,6 +595,14 @@ func resolvedIDs(resolved []Resolved) []string {
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 {
t.Helper()
location, err := time.LoadLocation("America/Chicago")

View File

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

View File

@@ -13,6 +13,7 @@ func stormDefinition() Definition {
ID: Storm,
Name: "Storm Report",
PromptID: "weather.storm_report",
GenerationMode: GenerationModeScriptoriumMarkdown,
ComparisonStrategy: CompareExplicitWindow,
ArtifactGroup: "storm",
BatchOutputName: "storm.md",
@@ -29,7 +30,9 @@ func stormModules() []module.ConfigItem {
module.CurrentConditions,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
)
}

View File

@@ -12,6 +12,7 @@ func threeDayDefinition() Definition {
ID: ThreeDay,
Name: "3-Day Outlook",
PromptID: "weather.three_day_outlook",
GenerationMode: GenerationModeScriptoriumMarkdown,
ComparisonStrategy: CompareSameValidDate,
ArtifactGroup: "three-day",
BatchOutputName: "three-day.md",
@@ -30,7 +31,9 @@ func threeDayModules() []module.ConfigItem {
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
module.OutdoorWindows,
)

View File

@@ -13,6 +13,7 @@ func weekendDefinition() Definition {
ID: Weekend,
Name: "Weekend Outlook",
PromptID: "weather.weekend_outlook",
GenerationMode: GenerationModeScriptoriumMarkdown,
ComparisonStrategy: CompareWeekendWindow,
ArtifactGroup: "weekend",
BatchOutputName: "weekend.md",
@@ -31,7 +32,9 @@ func weekendModules() []module.ConfigItem {
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
module.OutdoorWindows,
)

View File

@@ -0,0 +1,60 @@
// Package reporttemplate provides embedded Markdown templates and schemas.
package reporttemplate
import (
"bytes"
"embed"
"fmt"
"text/template"
)
//go:embed templates/*.md.tmpl schemas/*.schema.json
var assets embed.FS
var templates = map[string]string{
"hourly": "templates/hourly.md.tmpl",
}
var schemas = map[string]string{
"hourly": "schemas/hourly.generated_text.schema.json",
}
func Template(id string) (string, error) {
path, ok := templates[id]
if !ok {
return "", fmt.Errorf("unknown report template %q", id)
}
data, err := assets.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read report template %q: %w", id, err)
}
return string(data), nil
}
func Schema(id string) ([]byte, error) {
path, ok := schemas[id]
if !ok {
return nil, fmt.Errorf("unknown generated text schema %q", id)
}
data, err := assets.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read generated text schema %q: %w", id, err)
}
return append([]byte(nil), data...), nil
}
func Render(id string, data any) ([]byte, error) {
source, err := Template(id)
if err != nil {
return nil, err
}
tmpl, err := template.New(id).Option("missingkey=error").Parse(source)
if err != nil {
return nil, fmt.Errorf("parse report template %q: %w", id, err)
}
var out bytes.Buffer
if err := tmpl.Execute(&out, data); err != nil {
return nil, fmt.Errorf("render report template %q: %w", id, err)
}
return out.Bytes(), nil
}

View File

@@ -0,0 +1,187 @@
package reporttemplate
import (
"encoding/json"
"strings"
"testing"
)
func TestTemplateLookup(t *testing.T) {
source, err := Template("hourly")
if err != nil {
t.Fatalf("Template() error = %v", err)
}
for _, want := range []string{"# {{ .ReportTitle }}", "## Summary", "## Hourly Forecast", "## Weather Story"} {
if !strings.Contains(source, want) {
t.Fatalf("template missing %q:\n%s", want, source)
}
}
}
func TestSchemaLookup(t *testing.T) {
data, err := Schema("hourly")
if err != nil {
t.Fatalf("Schema() error = %v", err)
}
var schema struct {
Type string `json:"type"`
AdditionalProperties bool `json:"additionalProperties"`
Required []string `json:"required"`
Properties map[string]any `json:"properties"`
}
if err := json.Unmarshal(data, &schema); err != nil {
t.Fatalf("schema is invalid JSON: %v", err)
}
if schema.Type != "object" {
t.Fatalf("schema type = %q, want object", schema.Type)
}
if schema.AdditionalProperties {
t.Fatal("additionalProperties = true, want false")
}
if strings.Join(schema.Required, ",") != "summary,timing,impacts" {
t.Fatalf("required = %#v, want summary/timing/impacts", schema.Required)
}
for _, field := range []string{"summary", "timing", "impacts", "confidence"} {
property, ok := schema.Properties[field].(map[string]any)
if !ok {
t.Fatalf("schema property %q missing or invalid", field)
}
if property["type"] != "string" {
t.Fatalf("schema property %q type = %v, want string", field, property["type"])
}
}
}
func TestRenderHourly(t *testing.T) {
rendered, err := Render("hourly", testRenderContext{
ReportTitle: "Hourly Report",
LocationName: "Brentwood",
ValidPeriod: "May 29, 8:30 AM to 2:30 PM",
GeneratedAt: "May 29, 8:30 AM",
CurrentConditions: "74 F, light south wind.",
PrecipitationTiming: "Showers are most likely late morning.",
WeatherStory: "Morning storms remain the main story.",
GeneratedText: testGeneratedText{
Summary: "Storm chances increase through late morning.",
Timing: "The main window is 10 AM to noon.",
Impacts: "Brief downpours may slow travel.",
Confidence: "Medium confidence in timing.",
},
HourlyForecast: []testHourlyRow{
{Time: "9 AM", Summary: "Cloudy", Temperature: "74 F", Precipitation: "30% showers", Wind: "S 8 mph"},
{Time: "10 AM", Summary: "Showers", Temperature: "75 F", Precipitation: "70% showers", Wind: "S 10 mph"},
},
Alerts: []string{"Flood Watch until 2:30 PM"},
SPCOutlooks: []string{"Slight Risk through afternoon"},
SPCDiscussions: []string{"Strong storms may develop late morning."},
ForecastDiscussion: testForecastDiscussion{
KeyMessages: []string{"Storms are most likely late morning."},
ShortTerm: "Short-term discussion favors increasing rain coverage.",
},
})
if err != nil {
t.Fatalf("Render() error = %v", err)
}
text := string(rendered)
for _, want := range []string{
"# Hourly Report",
"Valid: May 29, 8:30 AM to 2:30 PM",
"Storm chances increase through late morning.",
"## Confidence",
"- 10 AM: Showers; 75 F; 70% showers; S 10 mph",
"- Flood Watch until 2:30 PM",
"Short-term discussion favors increasing rain coverage.",
} {
if !strings.Contains(text, want) {
t.Fatalf("rendered template missing %q:\n%s", want, text)
}
}
assertOrderedText(t, text, []string{
"# Hourly Report",
"## Summary",
"## Timing",
"## Impacts",
"## Confidence",
"## Current Conditions",
"## Hourly Forecast",
"## Precipitation Timing",
"## Alerts",
"## SPC Outlooks",
"## Forecast Discussion",
"## SPC Discussion",
"## Weather Story",
})
}
func TestUnknownAssetsReturnActionableErrors(t *testing.T) {
if _, err := Template("daily"); err == nil || !strings.Contains(err.Error(), `unknown report template "daily"`) {
t.Fatalf("Template() error = %v, want unknown template", err)
}
if _, err := Schema("daily"); err == nil || !strings.Contains(err.Error(), `unknown generated text schema "daily"`) {
t.Fatalf("Schema() error = %v, want unknown schema", err)
}
if _, err := Render("daily", testRenderContext{}); err == nil || !strings.Contains(err.Error(), `unknown report template "daily"`) {
t.Fatalf("Render() error = %v, want unknown template", err)
}
}
func TestRenderFailsForMissingContextFields(t *testing.T) {
_, err := Render("hourly", map[string]any{"ReportTitle": "Hourly Report"})
if err == nil {
t.Fatal("Render() error = nil, want missing field error")
}
if !strings.Contains(err.Error(), `render report template "hourly"`) {
t.Fatalf("Render() error = %v, want render context", err)
}
}
type testRenderContext struct {
ReportTitle string
LocationName string
ValidPeriod string
GeneratedAt string
CurrentConditions string
PrecipitationTiming string
WeatherStory string
GeneratedText testGeneratedText
HourlyForecast []testHourlyRow
Alerts []string
SPCOutlooks []string
SPCDiscussions []string
ForecastDiscussion testForecastDiscussion
}
type testGeneratedText struct {
Summary string
Timing string
Impacts string
Confidence string
}
type testHourlyRow struct {
Time string
Summary string
Temperature string
Precipitation string
Wind string
}
type testForecastDiscussion struct {
KeyMessages []string
ShortTerm string
}
func assertOrderedText(t *testing.T, text string, ordered []string) {
t.Helper()
previousIndex := -1
for _, want := range ordered {
index := strings.Index(text, want)
if index < 0 {
t.Fatalf("text missing %q:\n%s", want, text)
}
if index <= previousIndex {
t.Fatalf("%q appears out of order in:\n%s", want, text)
}
previousIndex = index
}
}

View File

@@ -0,0 +1,26 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "weatherreporter.hourly.generated_text.schema.json",
"title": "Hourly GeneratedText",
"type": "object",
"additionalProperties": false,
"required": [
"summary",
"timing",
"impacts"
],
"properties": {
"summary": {
"type": "string"
},
"timing": {
"type": "string"
},
"impacts": {
"type": "string"
},
"confidence": {
"type": "string"
}
}
}

View File

@@ -0,0 +1,71 @@
# {{ .ReportTitle }}
{{ .LocationName }}
Valid: {{ .ValidPeriod }}
Generated: {{ .GeneratedAt }}
## Summary
{{ .GeneratedText.Summary }}
## Timing
{{ .GeneratedText.Timing }}
## Impacts
{{ .GeneratedText.Impacts }}
{{ with .GeneratedText.Confidence }}
## Confidence
{{ . }}
{{ end }}
## Current Conditions
{{ .CurrentConditions }}
## Hourly Forecast
{{ range .HourlyForecast }}
- {{ .Time }}: {{ .Summary }}{{ with .Temperature }}; {{ . }}{{ end }}{{ with .Precipitation }}; {{ . }}{{ end }}{{ with .Wind }}; {{ . }}{{ end }}
{{ else }}
- No hourly forecast rows available.
{{ end }}
## Precipitation Timing
{{ .PrecipitationTiming }}
## Alerts
{{ range .Alerts }}
- {{ . }}
{{ else }}
- No active alert overlaps for this report period.
{{ end }}
## SPC Outlooks
{{ range .SPCOutlooks }}
- {{ . }}
{{ else }}
- No overlapping SPC outlooks.
{{ end }}
## Forecast Discussion
{{ range .ForecastDiscussion.KeyMessages }}
- {{ . }}
{{ end }}{{ with .ForecastDiscussion.ShortTerm }}
{{ . }}
{{ end }}
## SPC Discussion
{{ range .SPCDiscussions }}
- {{ . }}
{{ else }}
- No overlapping SPC discussion.
{{ end }}
## Weather Story
{{ .WeatherStory }}

View File

@@ -33,6 +33,10 @@ type ArtifactPaths struct {
Preflight string `json:"preflight"`
Notification string `json:"notification,omitempty"`
RenderedReport string `json:"renderedReport,omitempty"`
GeneratedTextRaw string `json:"generatedTextRaw,omitempty"`
GeneratedTextResult string `json:"generatedTextResult,omitempty"`
GeneratedText string `json:"generatedText,omitempty"`
RenderContext string `json:"renderContext,omitempty"`
}
type ReportRecord struct {
@@ -95,6 +99,10 @@ func (s *FilesystemStore) Paths(resolved report.Resolved) (ArtifactPaths, error)
Preflight: s.join(s.preflightDir, group, validDate, filenameBase+".render.json"),
Notification: s.join(s.notificationsDir, group, validDate, filenameBase+".distributor.json"),
RenderedReport: s.join(s.reportsDir, group, filenameBase+".md"),
GeneratedTextRaw: s.join(s.snapshotsDir, group, validDate, filenameBase+".generated_text.raw.json"),
GeneratedTextResult: s.join(s.snapshotsDir, group, validDate, filenameBase+".generated_text.run.json"),
GeneratedText: s.join(s.snapshotsDir, group, validDate, filenameBase+".generated_text.json"),
RenderContext: s.join(s.snapshotsDir, group, validDate, filenameBase+".render_context.json"),
}, nil
}
@@ -148,6 +156,50 @@ func (s *FilesystemStore) SaveDistributorNotification(_ context.Context, resolve
return paths.Notification, nil
}
func (s *FilesystemStore) SaveGeneratedTextRaw(_ context.Context, resolved report.Resolved, data []byte) (string, error) {
paths, err := s.Paths(resolved)
if err != nil {
return "", err
}
if err := fileutil.WriteFileAtomic(paths.GeneratedTextRaw, data); err != nil {
return "", err
}
return paths.GeneratedTextRaw, nil
}
func (s *FilesystemStore) SaveGeneratedTextResult(_ context.Context, resolved report.Resolved, value any) (string, error) {
paths, err := s.Paths(resolved)
if err != nil {
return "", err
}
if err := fileutil.WriteJSONAtomic(paths.GeneratedTextResult, value); err != nil {
return "", err
}
return paths.GeneratedTextResult, nil
}
func (s *FilesystemStore) SaveGeneratedText(_ context.Context, resolved report.Resolved, data []byte) (string, error) {
paths, err := s.Paths(resolved)
if err != nil {
return "", err
}
if err := fileutil.WriteFileAtomic(paths.GeneratedText, data); err != nil {
return "", err
}
return paths.GeneratedText, nil
}
func (s *FilesystemStore) SaveRenderContext(_ context.Context, resolved report.Resolved, value any) (string, error) {
paths, err := s.Paths(resolved)
if err != nil {
return "", err
}
if err := fileutil.WriteJSONAtomic(paths.RenderContext, value); err != nil {
return "", err
}
return paths.RenderContext, nil
}
func (s *FilesystemStore) PrepareRenderedReport(_ context.Context, resolved report.Resolved) (string, error) {
paths, err := s.Paths(resolved)
if err != nil {
@@ -325,6 +377,37 @@ func (s *FilesystemStore) LoadModuleSnapshot(_ context.Context, path string) (mo
return snapshot, nil
}
func (s *FilesystemStore) LoadGeneratedText(_ context.Context, path string) ([]byte, error) {
if path == "" {
return nil, fmt.Errorf("generated text path is required")
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read %q: %w", path, err)
}
return data, nil
}
func (s *FilesystemStore) LoadGeneratedTextResult(_ context.Context, path string, target any) error {
if path == "" {
return fmt.Errorf("generated text result path is required")
}
if target == nil {
return fmt.Errorf("generated text result target is required")
}
return readJSON(path, target)
}
func (s *FilesystemStore) LoadRenderContext(_ context.Context, path string, target any) error {
if path == "" {
return fmt.Errorf("render context path is required")
}
if target == nil {
return fmt.Errorf("render context target is required")
}
return readJSON(path, target)
}
func (s *FilesystemStore) reportRecord(path string) (ReportRecord, error) {
var metadata Metadata
if err := readJSON(path, &metadata); err != nil {

View File

@@ -40,6 +40,35 @@ func TestPathsUseRunIDAndWorkspace(t *testing.T) {
}
}
func TestGeneratedTextArtifactPathsUseSnapshotTree(t *testing.T) {
store := newTestStore(t)
resolved := resolveHourlyAt(t, "2026-05-29T05:00:00-05:00")
paths, err := store.Paths(resolved)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
runID := "20260529T100000.000000000Z_hourly"
wants := map[string]string{
"GeneratedTextRaw": filepath.Join("snapshots", "hourly", "2026-05-29", runID+".generated_text.raw.json"),
"GeneratedTextResult": filepath.Join("snapshots", "hourly", "2026-05-29", runID+".generated_text.run.json"),
"GeneratedText": filepath.Join("snapshots", "hourly", "2026-05-29", runID+".generated_text.json"),
"RenderContext": filepath.Join("snapshots", "hourly", "2026-05-29", runID+".render_context.json"),
}
got := map[string]string{
"GeneratedTextRaw": paths.GeneratedTextRaw,
"GeneratedTextResult": paths.GeneratedTextResult,
"GeneratedText": paths.GeneratedText,
"RenderContext": paths.RenderContext,
}
for name, want := range wants {
if !strings.Contains(got[name], want) {
t.Fatalf("%s path = %q, want component %q", name, got[name], want)
}
}
}
func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
store := newTestStore(t)
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
@@ -180,6 +209,117 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
if strings.Contains(string(data), "MetadataPath") || strings.Contains(string(data), "metadataPath") {
t.Fatalf("metadata JSON includes runtime-only MetadataPath:\n%s", string(data))
}
for _, unexpected := range []string{"generatedTextSchemaId", "generatedTextRawPath", "generatedTextResultPath", "generatedTextPath", "renderContextPath"} {
if strings.Contains(string(data), unexpected) {
t.Fatalf("metadata JSON includes generated-text field %q for Markdown report:\n%s", unexpected, string(data))
}
}
}
func TestSaveGeneratedTextArtifactsAndMetadataRoundTrip(t *testing.T) {
store := newTestStore(t)
resolved := resolveHourlyAt(t, "2026-05-29T05:00:00-05:00")
briefingMetadata := stateBriefingMetadata(resolved)
rawPath, err := store.SaveGeneratedTextRaw(context.Background(), resolved, []byte(`{"summary":"raw"}`))
if err != nil {
t.Fatalf("SaveGeneratedTextRaw() error = %v", err)
}
resultPath, err := store.SaveGeneratedTextResult(context.Background(), resolved, map[string]any{
"command": []string{"scriptorium", "run"},
"status": "succeeded",
})
if err != nil {
t.Fatalf("SaveGeneratedTextResult() error = %v", err)
}
generatedPath, err := store.SaveGeneratedText(context.Background(), resolved, []byte(`{"summary":"Storm chances increase.","timing":"Late morning.","impacts":"Brief downpours."}`))
if err != nil {
t.Fatalf("SaveGeneratedText() error = %v", err)
}
contextPath, err := store.SaveRenderContext(context.Background(), resolved, struct {
ReportTitle string `json:"reportTitle"`
Location string `json:"location"`
}{
ReportTitle: "Hourly Report",
Location: "Brentwood",
})
if err != nil {
t.Fatalf("SaveRenderContext() error = %v", err)
}
for _, path := range []string{rawPath, resultPath, generatedPath, contextPath} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected generated-text artifact %q: %v", path, err)
}
}
rawData, err := store.LoadGeneratedText(context.Background(), rawPath)
if err != nil {
t.Fatalf("LoadGeneratedText() raw error = %v", err)
}
if string(rawData) != `{"summary":"raw"}` {
t.Fatalf("raw data = %q, want saved bytes", rawData)
}
generatedData, err := store.LoadGeneratedText(context.Background(), generatedPath)
if err != nil {
t.Fatalf("LoadGeneratedText() generated error = %v", err)
}
if !strings.Contains(string(generatedData), `"timing":"Late morning."`) {
t.Fatalf("generated text = %q, want saved normalized JSON", generatedData)
}
var runResult struct {
Command []string `json:"command"`
Status string `json:"status"`
}
if err := store.LoadGeneratedTextResult(context.Background(), resultPath, &runResult); err != nil {
t.Fatalf("LoadGeneratedTextResult() error = %v", err)
}
if runResult.Status != "succeeded" || strings.Join(runResult.Command, " ") != "scriptorium run" {
t.Fatalf("run result = %#v, want saved result", runResult)
}
var renderContext struct {
ReportTitle string `json:"reportTitle"`
Location string `json:"location"`
}
if err := store.LoadRenderContext(context.Background(), contextPath, &renderContext); err != nil {
t.Fatalf("LoadRenderContext() error = %v", err)
}
if renderContext.ReportTitle != "Hourly Report" || renderContext.Location != "Brentwood" {
t.Fatalf("render context = %#v, want saved context", renderContext)
}
paths, err := store.Paths(resolved)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
metadata := BuildMetadataFromBriefingMetadata(resolved, briefingMetadata, ArtifactPaths{
ModuleSnapshot: paths.ModuleSnapshot,
Metadata: paths.Metadata,
DataPackage: paths.DataPackage,
Preflight: paths.Preflight,
RenderedReport: paths.RenderedReport,
GeneratedTextRaw: rawPath,
GeneratedTextResult: resultPath,
GeneratedText: generatedPath,
RenderContext: contextPath,
})
metadataPath, err := store.SaveMetadata(context.Background(), metadata)
if err != nil {
t.Fatalf("SaveMetadata() error = %v", err)
}
var decoded Metadata
metadataData, err := os.ReadFile(metadataPath)
if err != nil {
t.Fatalf("read metadata: %v", err)
}
if err := json.Unmarshal(metadataData, &decoded); err != nil {
t.Fatalf("decode metadata: %v", err)
}
if decoded.GeneratedTextSchemaID != "hourly" {
t.Fatalf("GeneratedTextSchemaID = %q, want hourly", decoded.GeneratedTextSchemaID)
}
if decoded.GeneratedTextRawPath != rawPath || decoded.GeneratedTextResultPath != resultPath || decoded.GeneratedTextPath != generatedPath || decoded.RenderContextPath != contextPath {
t.Fatalf("metadata generated-text paths = %#v, want saved artifact paths", decoded)
}
}
func TestSaveMetadataUsesExplicitMetadataPath(t *testing.T) {
@@ -305,6 +445,21 @@ func TestFindPriorSnapshotSupportsNarrowedWeekendPeriod(t *testing.T) {
}
}
func TestFindPriorSnapshotIgnoresRollingWindowReports(t *testing.T) {
store := newTestStore(t)
first := resolveHourlyAt(t, "2026-05-29T05:00:00-05:00")
second := resolveHourlyAt(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) {
cfg := config.Defaults().Workspace
cfg.Root = t.TempDir()
@@ -390,6 +545,26 @@ func resolveWeekendAt(t *testing.T, value string) report.Resolved {
return resolved
}
func resolveHourlyAt(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.Hourly, report.ResolveRequest{
Now: now,
Location: location,
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
return resolved
}
func stateBriefingMetadata(resolved report.Resolved) briefing.Metadata {
return briefing.Metadata{
RunID: resolved.Metadata().RunID,
@@ -436,5 +611,9 @@ func pathsString(paths ArtifactPaths) string {
paths.Preflight,
paths.Notification,
paths.RenderedReport,
paths.GeneratedTextRaw,
paths.GeneratedTextResult,
paths.GeneratedText,
paths.RenderContext,
}, "\n")
}

View File

@@ -31,11 +31,16 @@ type Metadata struct {
PreflightPath string `json:"preflightPath"`
NotificationPath string `json:"notificationPath,omitempty"`
RenderedReportPath string `json:"renderedReportPath,omitempty"`
GeneratedTextSchemaID string `json:"generatedTextSchemaId,omitempty"`
GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"`
GeneratedTextResultPath string `json:"generatedTextResultPath,omitempty"`
GeneratedTextPath string `json:"generatedTextPath,omitempty"`
RenderContextPath string `json:"renderContextPath,omitempty"`
}
func BuildMetadataFromBriefingMetadata(resolved report.Resolved, briefingMetadata briefing.Metadata, paths ArtifactPaths) Metadata {
metadata := resolved.Metadata()
return Metadata{
out := Metadata{
SchemaVersion: MetadataSchemaVersion,
RunID: metadata.RunID,
MetadataPath: paths.Metadata,
@@ -55,6 +60,14 @@ func BuildMetadataFromBriefingMetadata(resolved report.Resolved, briefingMetadat
PreflightPath: paths.Preflight,
RenderedReportPath: paths.RenderedReport,
}
if resolved.Definition.GenerationMode == report.GenerationModeGeneratedTextTemplate {
out.GeneratedTextSchemaID = resolved.Definition.GeneratedTextSchemaID
out.GeneratedTextRawPath = paths.GeneratedTextRaw
out.GeneratedTextResultPath = paths.GeneratedTextResult
out.GeneratedTextPath = paths.GeneratedText
out.RenderContextPath = paths.RenderContext
}
return out
}
func copyLocation(location *briefing.LocationContext) *briefing.LocationContext {

View File

@@ -17,10 +17,17 @@ type Store interface {
SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error)
SavePreflight(context.Context, report.Resolved, PreflightArtifact) (string, error)
SaveDistributorNotification(context.Context, report.Resolved, DistributorNotificationArtifact) (string, error)
SaveGeneratedTextRaw(context.Context, report.Resolved, []byte) (string, error)
SaveGeneratedTextResult(context.Context, report.Resolved, any) (string, error)
SaveGeneratedText(context.Context, report.Resolved, []byte) (string, error)
SaveRenderContext(context.Context, report.Resolved, any) (string, error)
PrepareRenderedReport(context.Context, report.Resolved) (string, error)
SaveMetadata(context.Context, Metadata) (string, error)
FindPriorSnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
LoadModuleSnapshot(context.Context, string) (module.Snapshot, error)
LoadGeneratedText(context.Context, string) ([]byte, error)
LoadGeneratedTextResult(context.Context, string, any) error
LoadRenderContext(context.Context, string, any) error
}
type PriorSnapshot struct {

View File

@@ -16,6 +16,7 @@ type Bundle struct {
Discussion *Discussion `json:"discussion,omitempty"`
Daily *ForecastRun `json:"daily,omitempty"`
WeatherStory *WeatherStory `json:"weatherStory,omitempty"`
SPCConvectiveOutlooks *ConvectiveOutlookRun `json:"spcConvectiveOutlooks,omitempty"`
Sources []Source `json:"sources"`
Warnings []SourceWarning `json:"warnings,omitempty"`
}
@@ -166,3 +167,42 @@ type WeatherStory struct {
Order int `json:"order"`
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
}