Compare commits
36 Commits
v0.7.0
...
9d6502460e
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d6502460e | |||
| fcf1108641 | |||
| 185605fbf0 | |||
| 662d906997 | |||
| f6e20d1412 | |||
| 9e14a9b7c7 | |||
| 422430613c | |||
| 2a4ce64d6f | |||
| 316ab8f3fc | |||
| f6a68426e1 | |||
| bed2b84100 | |||
| b39ec1e4d3 | |||
| dd92e9b061 | |||
| 8d737395dc | |||
| b3f7c9c1f2 | |||
| d425132ae7 | |||
| 925d351341 | |||
| c4107490df | |||
| b38230bc35 | |||
| 1f5f9964e0 | |||
| b0d8c6983d | |||
| 55f0180599 | |||
| 5284033fb8 | |||
| 0b1423c90d | |||
| e6a4bb2d16 | |||
| c3a051d372 | |||
| 3482551360 | |||
| d7a72f8581 | |||
| c09b7410ce | |||
| 96ce0edd46 | |||
| 7cd68ff222 | |||
| 16680e3f61 | |||
| 3389d4fa93 | |||
| 0041845935 | |||
| 3bcccb4a7b | |||
| 2aba52f552 |
22
docs/cli.md
22
docs/cli.md
@@ -21,6 +21,7 @@ the managed Markdown report after final metadata is saved.
|
|||||||
weatherreporter --help
|
weatherreporter --help
|
||||||
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
||||||
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
|
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
|
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
|
||||||
@@ -34,11 +35,16 @@ weatherreporter inspect prior [--config PATH] RUN_ID
|
|||||||
weatherreporter inspect sources [--config PATH] RUN_ID
|
weatherreporter inspect sources [--config PATH] RUN_ID
|
||||||
```
|
```
|
||||||
|
|
||||||
`generate` commands write a JSON module snapshot, YAML data package, preflight
|
Implemented `generate` commands write a JSON module snapshot, YAML data package,
|
||||||
artifact, managed Markdown report, and metadata under the configured workspace.
|
preflight artifact, managed Markdown report, and metadata under the configured
|
||||||
`--out` writes an extra Markdown copy for the operator; distributor
|
workspace. `--out` writes an extra Markdown copy for the operator; distributor
|
||||||
notification uses the managed report path, not the extra copy. `generate storm`
|
notification uses the managed report path, not the extra copy. `generate
|
||||||
requires explicit event-window bounds with `--start` and `--end`.
|
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
|
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
||||||
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch
|
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch
|
||||||
@@ -50,6 +56,9 @@ notification is enabled, batch summaries and status lines include notification
|
|||||||
status, accepted distributor run ID, or notification error fields for each
|
status, accepted distributor run ID, or notification error fields for each
|
||||||
attempted report.
|
attempted report.
|
||||||
|
|
||||||
|
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.
|
`inspect` commands read existing workspace artifacts and emit JSON to stdout.
|
||||||
They do not fetch weather data or invoke `scriptorium`.
|
They do not fetch weather data or invoke `scriptorium`.
|
||||||
|
|
||||||
@@ -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`.
|
- `--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`.
|
- `--units VALUE`: override configured Weather API units for `generate` and `run`.
|
||||||
- `--tz NAME`: override configured Weather API timezone 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`.
|
- `--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.
|
- `--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`.
|
- `--start TIME`: required start time for `generate storm`.
|
||||||
@@ -76,6 +85,7 @@ are no distributor-specific CLI flags.
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
weatherreporter generate tomorrow --out ./tomorrow.md
|
weatherreporter generate tomorrow --out ./tomorrow.md
|
||||||
|
weatherreporter generate hourly
|
||||||
weatherreporter generate three-day --out ./three-day.md
|
weatherreporter generate three-day --out ./three-day.md
|
||||||
weatherreporter generate weekend --out ./weekend.md
|
weatherreporter generate weekend --out ./weekend.md
|
||||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
|
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ missing directories, and unreadable files fail config loading.
|
|||||||
`notify.distributor` controls distributor notification after successful report
|
`notify.distributor` controls distributor notification after successful report
|
||||||
generation. It is disabled by default and does not add CLI flags. When enabled,
|
generation. It is disabled by default and does not add CLI flags. When enabled,
|
||||||
weatherreporter uploads one distributor bundle per generated report after
|
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:
|
- `enabled`: whether distributor notification config is active. Default:
|
||||||
`false`.
|
`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.
|
- `sources`: optional map of source-specific overrides, using the same policy values.
|
||||||
|
|
||||||
Hourly forecast data is required for generated reports. Optional sources use
|
Hourly forecast data is required for generated reports. Optional sources use
|
||||||
the missing-source policy.
|
the missing-source policy. Source override keys include `observations`,
|
||||||
|
`current`, `narrative`, `alerts`, `discussion`, `weather_story`, and
|
||||||
|
`spc_convective_outlooks`.
|
||||||
|
|
||||||
### `scriptorium`
|
### `scriptorium`
|
||||||
|
|
||||||
@@ -179,9 +181,9 @@ snapshot exists and a threshold is crossed.
|
|||||||
`reports` optionally overrides the ordered deterministic modules declared by
|
`reports` optionally overrides the ordered deterministic modules declared by
|
||||||
report definitions. Omit a report entry to use its default module order.
|
report definitions. Omit a report entry to use its default module order.
|
||||||
|
|
||||||
Supported report keys are `daily`, `tomorrow`, `three_day`, `weekend`, and
|
Supported report keys are `daily`, `tomorrow`, `hourly`, `three_day`,
|
||||||
`storm`. Canonical report IDs such as `daily_today` and `daily_tomorrow` are
|
`weekend`, and `storm`. Canonical report IDs such as `daily_today` and
|
||||||
also accepted.
|
`daily_tomorrow` are also accepted.
|
||||||
|
|
||||||
Each report entry supports:
|
Each report entry supports:
|
||||||
|
|
||||||
@@ -197,11 +199,29 @@ reports:
|
|||||||
- metadata
|
- metadata
|
||||||
- current_conditions
|
- current_conditions
|
||||||
- narrative_forecast
|
- narrative_forecast
|
||||||
|
- alert_digest
|
||||||
|
- spc_convective_outlooks
|
||||||
- id: area_forecast_discussion
|
- id: area_forecast_discussion
|
||||||
options:
|
options:
|
||||||
sections:
|
sections:
|
||||||
- short_term
|
- short_term
|
||||||
|
- spc_convective_discussion
|
||||||
- hourly_forecast
|
- hourly_forecast
|
||||||
|
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
|
Unknown reports, unknown modules, duplicate modules, incompatible report/module
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ This document describes the external Scriptorium CLI contract used by
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`weatherreporter` invokes Scriptorium as a subprocess to preflight prompt input
|
`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.
|
uses, not the full Scriptorium product.
|
||||||
|
|
||||||
## Commands Used
|
## Commands Used
|
||||||
@@ -29,11 +29,25 @@ scriptorium run \
|
|||||||
--out <artifact_path>
|
--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
|
`weatherreporter` always passes prompt input as
|
||||||
`--input data_package=<path>`. The data package is structured YAML created by
|
`--input data_package=<path>`. The data package is structured YAML created by
|
||||||
`internal/promptinput`; module snapshots remain separate JSON artifacts for
|
`internal/promptinput`; module snapshots remain separate JSON artifacts for
|
||||||
inspection and Recent Changes.
|
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
|
## Configured Arguments
|
||||||
|
|
||||||
The adapter can prepend configured flags before prompt-specific arguments:
|
The adapter can prepend configured flags before prompt-specific arguments:
|
||||||
@@ -68,11 +82,16 @@ Render results include:
|
|||||||
- exit code
|
- exit code
|
||||||
- truncation flags when applicable
|
- 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
|
`weatherreporter` persists render preflight JSON when orchestration reaches the
|
||||||
preflight save point. The final Markdown artifact is written by Scriptorium to
|
preflight save point. Markdown report artifacts are written by Scriptorium to
|
||||||
the `--out` path.
|
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
|
## Failure Behavior
|
||||||
|
|
||||||
@@ -80,7 +99,7 @@ The adapter validates required request fields before starting Scriptorium:
|
|||||||
|
|
||||||
- prompt ID
|
- prompt ID
|
||||||
- data package path
|
- 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
|
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
|
code and stderr. A `run` exit code such as `2` is still treated as an error by
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ generation.
|
|||||||
means the endpoint was checked and there are no current active alerts. The
|
means the endpoint was checked and there are no current active alerts. The
|
||||||
adapter records a non-missing alerts source and an empty alert run.
|
adapter records a non-missing alerts source and an empty alert run.
|
||||||
|
|
||||||
|
For `/outlooks/convective`, `data: null` means no latest run is available and
|
||||||
|
follows missing-source policy. A non-null run with empty `outlooks` and
|
||||||
|
`discussions` arrays is checked empty data, not a missing source.
|
||||||
|
|
||||||
Malformed JSON envelopes, non-2xx statuses, and response read failures include
|
Malformed JSON envelopes, non-2xx statuses, and response read failures include
|
||||||
endpoint context in returned errors. Decode errors include source context when
|
endpoint context in returned errors. Decode errors include source context when
|
||||||
they fail the fetch; optional malformed sources follow the missing-source policy.
|
they fail the fetch; optional malformed sources follow the missing-source policy.
|
||||||
@@ -49,11 +53,66 @@ The adapter sends these query parameters:
|
|||||||
- `units`: from `weather_api.units`
|
- `units`: from `weather_api.units`
|
||||||
- `precision`: from `weather_api.precision` on observations, current
|
- `precision`: from `weather_api.precision` on observations, current
|
||||||
conditions, hourly forecast, and narrative forecast requests
|
conditions, hourly forecast, and narrative forecast requests
|
||||||
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast, and
|
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast,
|
||||||
discussion requests
|
discussion, and SPC convective outlook requests
|
||||||
|
|
||||||
Alerts do not receive `precision` or `tz`. Weather story requests receive only
|
Alerts do not receive `precision` or `tz`. Weather story requests receive only
|
||||||
`format=json`.
|
`format=json`. SPC convective outlook requests receive only `format=json` and
|
||||||
|
`tz`; they do not receive `units` or `precision`.
|
||||||
|
|
||||||
|
## SPC Convective Outlooks
|
||||||
|
|
||||||
|
The adapter fetches SPC convective outlook data from:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /outlooks/convective?format=json&tz=<weather_api.timezone>
|
||||||
|
```
|
||||||
|
|
||||||
|
The response uses the standard `data` envelope. `data: null` means no latest
|
||||||
|
run is available and follows missing-source policy. A non-null object with
|
||||||
|
empty `outlooks` and `discussions` arrays is accepted as checked empty data.
|
||||||
|
|
||||||
|
Run fields consumed by weatherreporter:
|
||||||
|
|
||||||
|
- `locationId`
|
||||||
|
- `locationName`
|
||||||
|
- `asOf`
|
||||||
|
- `issuedAt`
|
||||||
|
- `updatedAt`
|
||||||
|
- `product`
|
||||||
|
- `outlooks`
|
||||||
|
- `discussions`
|
||||||
|
|
||||||
|
Outlook fields consumed:
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `provider`
|
||||||
|
- `product`
|
||||||
|
- `day`
|
||||||
|
- `outlookType`
|
||||||
|
- `label`
|
||||||
|
- `labelText`
|
||||||
|
- `forecaster`
|
||||||
|
- `severityRank`
|
||||||
|
- `validFrom`
|
||||||
|
- `validTo`
|
||||||
|
- `issuedAt`
|
||||||
|
- `expiresAt`
|
||||||
|
- `sourceUrl`
|
||||||
|
- `imageUrl`
|
||||||
|
- `containsLocation`
|
||||||
|
- `geometry`
|
||||||
|
|
||||||
|
Discussion fields consumed:
|
||||||
|
|
||||||
|
- `day`
|
||||||
|
- `headline`
|
||||||
|
- `summary`
|
||||||
|
- `discussion`
|
||||||
|
- `updatedAt`
|
||||||
|
|
||||||
|
GeoJSON `geometry` is decoded into collected weather facts and persisted in
|
||||||
|
bundle/debug artifacts, but prompt-facing SPC module output omits geometry.
|
||||||
|
|
||||||
## Endpoints Used
|
## Endpoints Used
|
||||||
|
|
||||||
@@ -66,6 +125,7 @@ The adapter fetches these endpoints once per bundle:
|
|||||||
- `/alerts/active`
|
- `/alerts/active`
|
||||||
- `/discussion`
|
- `/discussion`
|
||||||
- `/weatherstories/latest`
|
- `/weatherstories/latest`
|
||||||
|
- `/outlooks/convective`
|
||||||
|
|
||||||
`weatherreporter` does not call day-slice forecast endpoints or discussion
|
`weatherreporter` does not call day-slice forecast endpoints or discussion
|
||||||
subsection endpoints. Report-period selection and daypart summarization happen
|
subsection endpoints. Report-period selection and daypart summarization happen
|
||||||
@@ -88,6 +148,7 @@ source-specific `missing_source.sources` policy:
|
|||||||
- `alerts` for `/alerts/active`
|
- `alerts` for `/alerts/active`
|
||||||
- `discussion` for `/discussion`
|
- `discussion` for `/discussion`
|
||||||
- `weather_story` for `/weatherstories/latest`
|
- `weather_story` for `/weatherstories/latest`
|
||||||
|
- `spc_convective_outlooks` for `/outlooks/convective`
|
||||||
|
|
||||||
Policy behavior:
|
Policy behavior:
|
||||||
|
|
||||||
@@ -99,6 +160,9 @@ For `/alerts/active`, an HTTP error or missing `data` field still fails or
|
|||||||
follows the relevant error path, but explicit `data: null` is not a
|
follows the relevant error path, but explicit `data: null` is not a
|
||||||
missing-source condition.
|
missing-source condition.
|
||||||
|
|
||||||
|
For `/outlooks/convective`, a non-null data object with empty outlook and
|
||||||
|
discussion arrays is accepted as checked empty data.
|
||||||
|
|
||||||
## Source Identity
|
## Source Identity
|
||||||
|
|
||||||
For source payloads accepted into the bundle, including the explicit `null`
|
For source payloads accepted into the bundle, including the explicit `null`
|
||||||
@@ -116,8 +180,8 @@ warnings list.
|
|||||||
|
|
||||||
## Compatibility Assumptions
|
## Compatibility Assumptions
|
||||||
|
|
||||||
The adapter expects payload fields compatible with the internal forecast bundle
|
The adapter expects payload fields compatible with the internal weather data
|
||||||
types in `internal/forecast/bundle.go`, including:
|
bundle types in `internal/weatherdata/bundle.go`, including:
|
||||||
|
|
||||||
- observation timestamps and observation values
|
- observation timestamps and observation values
|
||||||
- current condition values
|
- current condition values
|
||||||
@@ -126,6 +190,8 @@ types in `internal/forecast/bundle.go`, including:
|
|||||||
- discussion metadata, key messages, and short/long-term section text
|
- discussion metadata, key messages, and short/long-term section text
|
||||||
- latest weather story title, description, timing, priority, order, alt text,
|
- latest weather story title, description, timing, priority, order, alt text,
|
||||||
and download URL
|
and download URL
|
||||||
|
- SPC convective outlook run metadata, outlooks, discussions, and GeoJSON
|
||||||
|
geometry
|
||||||
|
|
||||||
The adapter intentionally keeps upstream transport and envelope details inside
|
The adapter intentionally keeps upstream transport and envelope details inside
|
||||||
`internal/adapters/weatherapi`; downstream packages consume the normalized
|
`internal/adapters/weatherapi`; downstream packages consume the normalized
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ Outputs:
|
|||||||
|
|
||||||
- generated report results with JSON module snapshot, YAML data package,
|
- generated report results with JSON module snapshot, YAML data package,
|
||||||
preflight, report, metadata, prior snapshot, Recent Changes, Scriptorium
|
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
|
- batch summaries with per-report status, artifact paths, error text, and
|
||||||
notification outcome when attempted
|
notification outcome when attempted
|
||||||
- saved Weather API bundle JSON for fetch workflows
|
- 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
|
upload behavior stays in `internal/adapters/distributor`. Filesystem layout and
|
||||||
persisted metadata stay in `internal/state`.
|
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
|
## Config Fields Used
|
||||||
|
|
||||||
- `weather_api.*` for Weather API client construction and module metadata
|
- `weather_api.*` for Weather API client construction and module metadata
|
||||||
@@ -63,7 +77,7 @@ defaults.
|
|||||||
|
|
||||||
## Generation Workflow
|
## Generation Workflow
|
||||||
|
|
||||||
Single-report generation follows this order:
|
Single-report generation shares this setup:
|
||||||
|
|
||||||
1. Resolve the command report to a `report.Resolved` value.
|
1. Resolve the command report to a `report.Resolved` value.
|
||||||
2. Create or use a filesystem store.
|
2. Create or use a filesystem store.
|
||||||
@@ -76,13 +90,31 @@ Single-report generation follows this order:
|
|||||||
9. Run Scriptorium render preflight.
|
9. Run Scriptorium render preflight.
|
||||||
10. Save preflight JSON when a render result is available.
|
10. Save preflight JSON when a render result is available.
|
||||||
11. Save metadata for inspection.
|
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.
|
For `scriptorium_markdown` reports, generation then:
|
||||||
14. Save metadata with the managed report path.
|
|
||||||
15. If distributor notification is enabled, notify using the managed report
|
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.
|
path as the source file.
|
||||||
16. Save a distributor notification debug artifact and update metadata with its
|
16. Saves a distributor notification debug artifact and updates metadata with
|
||||||
path.
|
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
|
If render preflight returns both a result and an error, preflight JSON and
|
||||||
metadata are persisted before the error is returned. If Scriptorium report
|
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,
|
metadata remain inspectable. Notification is not attempted after Weather API,
|
||||||
module snapshot, prompt input, render, Scriptorium run, or metadata-save
|
module snapshot, prompt input, render, Scriptorium run, or metadata-save
|
||||||
failures.
|
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,
|
When notification is attempted, the debug artifact records request identity,
|
||||||
including rendered pipeline ID, bundle paths, accepted upload fields,
|
including rendered pipeline ID, bundle paths, accepted upload fields,
|
||||||
distributor status fields, raw status report JSON when available, and redacted
|
distributor status fields, raw status report JSON when available, and redacted
|
||||||
@@ -122,6 +158,8 @@ inspection view.
|
|||||||
runs.
|
runs.
|
||||||
- Prompt input validation fails before render preflight.
|
- Prompt input validation fails before render preflight.
|
||||||
- Render and run errors preserve Scriptorium stderr and exit-code context.
|
- 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
|
- Notification errors are wrapped with report ID, RunID, and managed report path
|
||||||
context and are recorded separately in batch results.
|
context and are recorded separately in batch results.
|
||||||
- Metadata and artifact path errors include filesystem context.
|
- 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
|
- Generated reports use the same app request and result types regardless of
|
||||||
report ID.
|
report ID.
|
||||||
- Render preflight precedes Scriptorium report generation.
|
- 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.
|
- Recent Changes are computed from structured module snapshots.
|
||||||
- Metadata links artifacts produced for a run.
|
- Metadata links artifacts produced for a run.
|
||||||
- Distributor notification maps the managed Markdown report path to configured
|
- Distributor notification maps the managed Markdown report path to configured
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ Outputs:
|
|||||||
supported reports, fact requirements, missing-data behavior, and builder
|
supported reports, fact requirements, missing-data behavior, and builder
|
||||||
- `module.Output` values for source-oriented stanzas:
|
- `module.Output` values for source-oriented stanzas:
|
||||||
`metadata`, `current_conditions`, `narrative_forecast`, `hourly_forecast`,
|
`metadata`, `current_conditions`, `narrative_forecast`, `hourly_forecast`,
|
||||||
`alert_digest`, `area_forecast_discussion`, and `weather_story`
|
`alert_digest`, `spc_convective_outlooks`,
|
||||||
|
`area_forecast_discussion`, `spc_convective_discussion`, and
|
||||||
|
`weather_story`
|
||||||
- `module.Output` values for derived stanzas:
|
- `module.Output` values for derived stanzas:
|
||||||
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
|
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
|
||||||
`outdoor_windows`, and `tomorrow_planning`
|
`outdoor_windows`, and `tomorrow_planning`
|
||||||
@@ -36,6 +38,14 @@ Outputs:
|
|||||||
Every registered composition entry has a builder. Unknown or unimplemented
|
Every registered composition entry has a builder. Unknown or unimplemented
|
||||||
module IDs fail validation instead of being skipped.
|
module IDs fail validation instead of being skipped.
|
||||||
|
|
||||||
|
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
|
Prompt-facing module values use local, human-readable date and time labels
|
||||||
where the LLM is expected to reason about report content. Canonical timestamps
|
where the LLM is expected to reason about report content. Canonical timestamps
|
||||||
remain in report metadata, source provenance, and integration artifacts.
|
remain in report metadata, source provenance, and integration artifacts.
|
||||||
@@ -57,7 +67,24 @@ builders run. Configured `location` values are prompt context only; Weather API
|
|||||||
`sourceLocationId` and `sourceLocation` remain source provenance.
|
`sourceLocationId` and `sourceLocation` remain source provenance.
|
||||||
|
|
||||||
`area_forecast_discussion` uses optional `sections` configuration to include a
|
`area_forecast_discussion` uses optional `sections` configuration to include a
|
||||||
subset of discussion fields.
|
subset of discussion fields. 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
|
## External Adapters Used
|
||||||
|
|
||||||
@@ -86,6 +113,11 @@ return an error for invalid required inputs.
|
|||||||
forecast discussion, and weather story stanzas.
|
forecast discussion, and weather story stanzas.
|
||||||
- Alert digest output distinguishes checked empty alert data from missing alert
|
- Alert digest output distinguishes checked empty alert data from missing alert
|
||||||
source data.
|
source data.
|
||||||
|
- SPC convective outlook output distinguishes checked empty outlook data from
|
||||||
|
missing outlook source data and omits GeoJSON geometry from prompt-facing
|
||||||
|
fields.
|
||||||
|
- SPC convective discussion output is omitted unless a retained outlook has
|
||||||
|
severity rank `3` or higher and matching discussion text is available.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
@@ -20,14 +20,29 @@ Inputs:
|
|||||||
Outputs:
|
Outputs:
|
||||||
|
|
||||||
- `facts.CollectedFacts` with normalized source facts plus separate source
|
- `facts.CollectedFacts` with normalized source facts plus separate source
|
||||||
provenance and warnings
|
provenance and warnings. SPC convective outlook source data is carried
|
||||||
|
through when present in the bundle, including upstream geometry and source
|
||||||
|
provenance.
|
||||||
- `facts.DerivedFacts` with valid-period forecast slices, alert overlaps,
|
- `facts.DerivedFacts` with valid-period forecast slices, alert overlaps,
|
||||||
daily summaries, daypart summaries, and Storm Report window summary
|
report-period SPC convective outlooks and discussions, daily summaries,
|
||||||
|
daypart summaries, and Storm Report window summary
|
||||||
|
|
||||||
|
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
|
## Boundaries
|
||||||
|
|
||||||
- This package owns fact assembly and reusable deterministic derivation for a
|
- This package owns fact assembly and reusable deterministic derivation for a
|
||||||
report run.
|
report run.
|
||||||
|
- SPC convective outlook derivation selects already-collected outlooks whose
|
||||||
|
half-open valid intervals overlap the resolved report period and retains
|
||||||
|
discussions for represented outlook days.
|
||||||
|
- Derived SPC outlook records preserve the collected outlook fields, including
|
||||||
|
geometry, for downstream components that need source-level facts. Prompt
|
||||||
|
modules decide which fields are exposed to Scriptorium.
|
||||||
- It does not fetch upstream data, build prompt wording, compare prior
|
- It does not fetch upstream data, build prompt wording, compare prior
|
||||||
snapshots, write workflow state, invoke Scriptorium, or define modules.
|
snapshots, write workflow state, invoke Scriptorium, or define modules.
|
||||||
|
|
||||||
@@ -53,8 +68,13 @@ and inspection.
|
|||||||
- Invalid timezone names return an error.
|
- Invalid timezone names return an error.
|
||||||
- Missing required hourly forecast data returns the underlying forecast
|
- Missing required hourly forecast data returns the underlying forecast
|
||||||
derivation error for reports that require daily summaries.
|
derivation error for reports that require daily summaries.
|
||||||
|
- Hourly Report can derive its default module facts without daily or
|
||||||
|
daypart summaries.
|
||||||
- Missing optional narrative, alert, discussion, daily, or weather story data
|
- Missing optional narrative, alert, discussion, daily, or weather story data
|
||||||
produces empty or nil derived fields.
|
produces empty or nil derived fields.
|
||||||
|
- Missing optional SPC convective outlook data produces a nil collected field.
|
||||||
|
- A present SPC convective outlook source with no report-period matches
|
||||||
|
produces non-nil empty derived outlook and discussion slices.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@@ -67,6 +87,8 @@ Inspect:
|
|||||||
|
|
||||||
- Collected facts are built once from a fetched bundle.
|
- Collected facts are built once from a fetched bundle.
|
||||||
- Derived facts are scoped to one resolved report.
|
- Derived facts are scoped to one resolved report.
|
||||||
|
- SPC convective outlook selection uses the resolved report period and the
|
||||||
|
already-collected outlook run.
|
||||||
- Source provenance and warnings stay separate from ordinary fact fields.
|
- Source provenance and warnings stay separate from ordinary fact fields.
|
||||||
- Prompt-specific wording and one-off presentation decisions stay outside this
|
- Prompt-specific wording and one-off presentation decisions stay outside this
|
||||||
package.
|
package.
|
||||||
|
|||||||
73
docs/internal/generatedtext.md
Normal file
73
docs/internal/generatedtext.md
Normal 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.
|
||||||
@@ -37,7 +37,9 @@ The registry recognizes these IDs:
|
|||||||
- `derived_daypart_summaries`
|
- `derived_daypart_summaries`
|
||||||
- `precip_timing`
|
- `precip_timing`
|
||||||
- `alert_digest`
|
- `alert_digest`
|
||||||
|
- `spc_convective_outlooks`
|
||||||
- `area_forecast_discussion`
|
- `area_forecast_discussion`
|
||||||
|
- `spc_convective_discussion`
|
||||||
- `weather_story`
|
- `weather_story`
|
||||||
- `outdoor_windows`
|
- `outdoor_windows`
|
||||||
- `tomorrow_planning`
|
- `tomorrow_planning`
|
||||||
@@ -45,9 +47,29 @@ The registry recognizes these IDs:
|
|||||||
Every registered module has a builder. Report composition entries that refer to
|
Every registered module has a builder. Report composition entries that refer to
|
||||||
unknown or unimplemented module IDs fail validation instead of being skipped.
|
unknown or unimplemented module IDs fail validation instead of being skipped.
|
||||||
|
|
||||||
|
## 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
|
## Options
|
||||||
|
|
||||||
Most modules use an empty options struct. `area_forecast_discussion` accepts:
|
Most modules use an empty options struct, including
|
||||||
|
`spc_convective_outlooks` and `spc_convective_discussion`.
|
||||||
|
`area_forecast_discussion` accepts:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
sections:
|
sections:
|
||||||
@@ -61,6 +83,31 @@ An omitted or empty `sections` list includes all available discussion sections.
|
|||||||
Invalid option shapes fail during config normalization or composition
|
Invalid option shapes fail during config normalization or composition
|
||||||
validation.
|
validation.
|
||||||
|
|
||||||
|
## SPC Convective Module Outputs
|
||||||
|
|
||||||
|
`spc_convective_outlooks` emits a prompt-facing risk-product stanza with:
|
||||||
|
|
||||||
|
- `checked`
|
||||||
|
- `as_of`
|
||||||
|
- `issued_at`
|
||||||
|
- `location_id`
|
||||||
|
- `location_name`
|
||||||
|
- `outlook_count`
|
||||||
|
- `outlooks`
|
||||||
|
|
||||||
|
Each outlook entry may include `day`, `outlook_type`, `label`, `label_text`,
|
||||||
|
`period_begins`, `period_ends`, `issued_at`, `contains_location`, and
|
||||||
|
`image_url`. It omits GeoJSON geometry, source URL, expiration time, and
|
||||||
|
severity rank.
|
||||||
|
|
||||||
|
`spc_convective_discussion` emits a narrative stanza only when a retained
|
||||||
|
report-period categorical outlook has severity rank `3` or higher and matching
|
||||||
|
discussion text is available. Its output includes `included_because` and
|
||||||
|
`discussions`; each discussion may include `day`, `period_begins`,
|
||||||
|
`period_ends`, `headline`, `summary`, `discussion`, and `updated_at`.
|
||||||
|
Discussions are included only for SPC days whose retained categorical outlooks
|
||||||
|
meet the severity threshold.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
- This package owns module identifiers, config item envelopes, output
|
- This package owns module identifiers, config item envelopes, output
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ briefing:
|
|||||||
metadata: {}
|
metadata: {}
|
||||||
applicable_risk_products:
|
applicable_risk_products:
|
||||||
alert_digest: {}
|
alert_digest: {}
|
||||||
|
spc_convective_outlooks: {}
|
||||||
derived_summaries:
|
derived_summaries:
|
||||||
derived_daily_summary: {}
|
derived_daily_summary: {}
|
||||||
derived_daypart_summaries: {}
|
derived_daypart_summaries: {}
|
||||||
@@ -49,6 +50,7 @@ briefing:
|
|||||||
narrative_products:
|
narrative_products:
|
||||||
narrative_forecast: {}
|
narrative_forecast: {}
|
||||||
area_forecast_discussion: {}
|
area_forecast_discussion: {}
|
||||||
|
spc_convective_discussion: {}
|
||||||
weather_story: {}
|
weather_story: {}
|
||||||
raw_data:
|
raw_data:
|
||||||
current_conditions: {}
|
current_conditions: {}
|
||||||
@@ -62,13 +64,29 @@ weather module stanzas under prompt-facing categories. This grouping is a YAML
|
|||||||
presentation concern only: module snapshots remain flat, and loaded
|
presentation concern only: module snapshots remain flat, and loaded
|
||||||
`promptinput.Package` values expose flat stanza names in `Briefing.Values`.
|
`promptinput.Package` values expose flat stanza names in `Briefing.Values`.
|
||||||
Within each category, stanza order follows the module snapshot output order.
|
Within each category, stanza order follows the module snapshot output order.
|
||||||
|
Prompt-facing module intervals use local `period_begins` and `period_ends`
|
||||||
|
labels; canonical report metadata and source timestamps remain structured
|
||||||
|
timestamps where applicable.
|
||||||
|
|
||||||
|
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:
|
Current categories are:
|
||||||
|
|
||||||
- `applicable_risk_products`: location-applicable alerts, warnings, outlooks,
|
- `applicable_risk_products`: location-applicable alerts, warnings, outlooks,
|
||||||
discussions, and similar risk products.
|
and similar risk products. Current stanzas include `alert_digest` and
|
||||||
|
`spc_convective_outlooks`.
|
||||||
- `derived_summaries`: deterministic summaries and calculated report facts.
|
- `derived_summaries`: deterministic summaries and calculated report facts.
|
||||||
- `narrative_products`: official narrative text products and forecast stories.
|
- `narrative_products`: official narrative text products and forecast stories.
|
||||||
|
Current stanzas include `narrative_forecast`,
|
||||||
|
`area_forecast_discussion`, `spc_convective_discussion`, and
|
||||||
|
`weather_story`.
|
||||||
- `raw_data`: minimally transformed underlying weather data.
|
- `raw_data`: minimally transformed underlying weather data.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ Each report definition declares:
|
|||||||
|
|
||||||
- report ID and display name
|
- report ID and display name
|
||||||
- Scriptorium prompt ID
|
- Scriptorium prompt ID
|
||||||
|
- generation mode
|
||||||
- valid-period resolver
|
- valid-period resolver
|
||||||
- comparison strategy
|
- comparison strategy
|
||||||
- managed artifact group
|
- managed artifact group
|
||||||
@@ -25,15 +26,22 @@ Each report definition declares:
|
|||||||
- morning or evening batch membership
|
- morning or evening batch membership
|
||||||
- default ordered module composition
|
- 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
|
## Reports
|
||||||
|
|
||||||
| Report | ID | Prompt | Artifact group | Batch copy | Prior compatibility |
|
| Report | ID | Prompt | Generation mode | Artifact group | Batch copy | Prior compatibility |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
| Daily Today | `daily_today` | `weather.daily_report` | `daily` | `daily.md` | Daily Today, Daily Tomorrow |
|
| Daily Today | `daily_today` | `weather.daily_report` | `scriptorium_markdown` | `daily` | `daily.md` | Daily Today, Daily Tomorrow |
|
||||||
| Daily Tomorrow | `daily_tomorrow` | `weather.daily_report` | `daily` | `tomorrow.md` | Daily Today, Daily Tomorrow |
|
| Daily Tomorrow | `daily_tomorrow` | `weather.daily_report` | `scriptorium_markdown` | `daily` | `tomorrow.md` | Daily Today, Daily Tomorrow |
|
||||||
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `three-day` | `three-day.md` | 3-Day Outlook |
|
| Hourly Report | `hourly` | `weather.hourly_generated_text` | `generated_text_template` | `hourly` | `hourly.md` | Hourly Report |
|
||||||
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `weekend` | `weekend.md` | Weekend Outlook |
|
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `scriptorium_markdown` | `three-day` | `three-day.md` | 3-Day Outlook |
|
||||||
| Storm Report | `storm` | `weather.storm_report` | `storm` | `storm.md` | Storm Report |
|
| 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.
|
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
|
- Daily Today covers the selected local civil day, or the current local civil
|
||||||
day when no date override is supplied.
|
day when no date override is supplied.
|
||||||
- Daily Tomorrow covers the next local civil day from generation time.
|
- Daily Tomorrow covers the next local civil day from generation time.
|
||||||
|
- 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
|
- 3-Day Outlook covers the interval from generation time through local midnight
|
||||||
three days later.
|
three days later.
|
||||||
- Weekend Outlook covers the upcoming weekend window and is not scheduled for
|
- Weekend Outlook covers the upcoming weekend window and is not scheduled for
|
||||||
@@ -64,7 +75,14 @@ IDs, then uses the registry for report policy.
|
|||||||
## Config Fields Used
|
## Config Fields Used
|
||||||
|
|
||||||
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
|
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
|
||||||
output path copying uses batch output names from report definitions.
|
output path copying uses batch output names from report definitions. Report
|
||||||
|
module overrides can use the `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
|
## State And App Usage
|
||||||
|
|
||||||
|
|||||||
94
docs/internal/reporttemplate.md
Normal file
94
docs/internal/reporttemplate.md
Normal 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.
|
||||||
@@ -6,9 +6,9 @@ This document describes the subprocess adapter in
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
The adapter runs `scriptorium render` for prompt preflight and `scriptorium run`
|
The adapter runs `scriptorium render` for prompt preflight and `scriptorium run`
|
||||||
for Markdown report generation. It isolates subprocess execution, argv
|
for Markdown report generation or structured generated-text output. It isolates
|
||||||
construction, timeout handling, output capture, and exit-code interpretation
|
subprocess execution, argv construction, timeout handling, output capture, and
|
||||||
from app and domain packages.
|
exit-code interpretation from app and domain packages.
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ Inputs:
|
|||||||
- prompt ID
|
- prompt ID
|
||||||
- YAML prompt input data package path
|
- YAML prompt input data package path
|
||||||
- report output path for `run`
|
- report output path for `run`
|
||||||
|
- raw generated-text output path for structured `run`
|
||||||
- configured binary, config path, profile, timeout, and extra arguments
|
- configured binary, config path, profile, timeout, and extra arguments
|
||||||
- context for cancellation
|
- context for cancellation
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ Outputs:
|
|||||||
- truncation flags for captured output
|
- truncation flags for captured output
|
||||||
- exit code
|
- exit code
|
||||||
- report output path for `run`
|
- report output path for `run`
|
||||||
|
- raw generated-text output path for structured `run`
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
@@ -34,9 +36,9 @@ Outputs:
|
|||||||
subprocess execution. It does not choose report types, build prompt input,
|
subprocess execution. It does not choose report types, build prompt input,
|
||||||
fetch weather data, decide workflow order, or persist workflow metadata.
|
fetch weather data, decide workflow order, or persist workflow metadata.
|
||||||
|
|
||||||
The adapter exposes request and result structs for render and run operations.
|
The adapter exposes request and result structs for render, Markdown run, and
|
||||||
State persistence uses a state-owned preflight artifact shape; app
|
structured generated-text run operations. State persistence uses state-owned
|
||||||
orchestration converts render results before saving.
|
artifact shapes; app orchestration converts adapter results before saving.
|
||||||
|
|
||||||
## Config Fields Used
|
## Config Fields Used
|
||||||
|
|
||||||
@@ -60,6 +62,12 @@ Report generation argv starts with:
|
|||||||
scriptorium run --prompt <prompt_id> --input data_package=<path> --out <path>
|
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
|
Configured `--config` and `--profile` flags are inserted after the subcommand
|
||||||
and before prompt-specific arguments. Extra arguments are appended after the
|
and before prompt-specific arguments. Extra arguments are appended after the
|
||||||
built-in arguments.
|
built-in arguments.
|
||||||
@@ -67,8 +75,8 @@ built-in arguments.
|
|||||||
## Execution Behavior
|
## Execution Behavior
|
||||||
|
|
||||||
The adapter runs commands without shell interpolation. The same private
|
The adapter runs commands without shell interpolation. The same private
|
||||||
execution path is used by render and run after command-specific request
|
execution path is used by render, Markdown run, and structured run after
|
||||||
validation and argv construction.
|
command-specific request validation and argv construction.
|
||||||
|
|
||||||
When `scriptorium.timeout` is greater than zero, each subprocess call uses a
|
When `scriptorium.timeout` is greater than zero, each subprocess call uses a
|
||||||
context with that timeout. Stdout and stderr are captured separately, capped at
|
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.
|
- Missing run output path returns an error before subprocess execution.
|
||||||
- Subprocess start errors, context cancellation, and timeouts are wrapped with
|
- Subprocess start errors, context cancellation, and timeouts are wrapped with
|
||||||
operation context by the caller-facing method.
|
operation context by the caller-facing method.
|
||||||
- Nonzero render and run exits return the captured result plus an error
|
- Nonzero render, Markdown run, and structured run exits return the captured
|
||||||
containing the exit code and stderr.
|
result plus an error containing the exit code and stderr.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@@ -97,5 +105,6 @@ Inspect:
|
|||||||
- No shell interpolation is used.
|
- No shell interpolation is used.
|
||||||
- The Scriptorium input name is `data_package`.
|
- The Scriptorium input name is `data_package`.
|
||||||
- The file at the data package path is YAML produced by `internal/promptinput`.
|
- 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.
|
- Scriptorium-specific flags stay inside adapter and config boundaries.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ Inputs:
|
|||||||
- module snapshot
|
- module snapshot
|
||||||
- prompt input data package
|
- prompt input data package
|
||||||
- preflight artifact
|
- preflight artifact
|
||||||
|
- generated-text raw, run-result, validated text, and render-context artifacts
|
||||||
- rendered report path preparation request
|
- rendered report path preparation request
|
||||||
- RunID for inspection lookups
|
- RunID for inspection lookups
|
||||||
|
|
||||||
@@ -24,10 +25,15 @@ Outputs:
|
|||||||
- module snapshot JSON path
|
- module snapshot JSON path
|
||||||
- prompt input data package YAML path
|
- prompt input data package YAML path
|
||||||
- render preflight JSON 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
|
- managed Markdown report path
|
||||||
- metadata JSON path
|
- metadata JSON path
|
||||||
- prior comparable snapshot metadata
|
- 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
|
- recent report records for inspection
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
@@ -61,6 +67,10 @@ valid-period start date for dated artifacts, and the RunID.
|
|||||||
<workspace.root>/
|
<workspace.root>/
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.modules.json
|
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>.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
|
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.yaml
|
||||||
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json
|
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json
|
||||||
notifications/<artifact_group>/<YYYY-MM-DD>/<run_id>.distributor.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
|
Metadata is stored beside module snapshots and links the module snapshot, data
|
||||||
package, preflight, report paths, notification path when attempted, and
|
package, preflight, report paths, notification path when attempted, and
|
||||||
configured prompt location. Report listing walks metadata files under the
|
configured prompt location. For generated-text-template reports, metadata also
|
||||||
snapshots directory.
|
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
|
## Prior Lookup
|
||||||
|
|
||||||
@@ -84,22 +97,27 @@ current report definition.
|
|||||||
date.
|
date.
|
||||||
- Weekend Outlook compares with prior Weekend snapshots for the same weekend
|
- Weekend Outlook compares with prior Weekend snapshots for the same weekend
|
||||||
window.
|
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
|
- Storm Report has no prior lookup because explicit event-window comparison is
|
||||||
not searched by the filesystem store.
|
not searched by the filesystem store.
|
||||||
|
|
||||||
## Writes And Inspection
|
## Writes And Inspection
|
||||||
|
|
||||||
Durable JSON writes use shared atomic file helpers. Managed Markdown reports are
|
Durable JSON writes use shared atomic file helpers. Generated-text raw and
|
||||||
prepared by creating their parent directory; Scriptorium writes the report body
|
validated JSON artifacts are written atomically as bytes; generated-text run
|
||||||
to the prepared path. Extra Markdown copies are handled by app orchestration.
|
result and render context artifacts are written atomically as JSON. Managed
|
||||||
Distributor notification debug artifacts are written atomically when
|
Markdown reports are prepared by creating their parent directory; Scriptorium
|
||||||
notification is attempted and include rendered distributor pipeline ID, bundle
|
writes the report body to the prepared path. Extra Markdown copies are handled
|
||||||
ID, idempotency key, bundle paths, upload status, latest run status, and
|
by app orchestration. Distributor notification debug artifacts are written
|
||||||
redacted errors.
|
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
|
Inspection helpers read existing metadata, module snapshot, data package,
|
||||||
files. Missing metadata directories return no inspection records or no prior
|
generated text, generated-text run result, and render context files. Missing
|
||||||
snapshot rather than creating state.
|
metadata directories return no inspection records or no prior snapshot rather
|
||||||
|
than creating state.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
@@ -121,4 +139,6 @@ Inspect:
|
|||||||
- Managed paths stay under the configured workspace root.
|
- Managed paths stay under the configured workspace root.
|
||||||
- Artifact grouping comes from report definitions.
|
- Artifact grouping comes from report definitions.
|
||||||
- Metadata links artifacts produced for a run.
|
- 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.
|
- Prior lookup is based on structured metadata, not rendered report text.
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ This document describes Weather API ingestion into `weatherdata.Bundle`.
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/adapters/weatherapi` fetches normalized weather data from one
|
`internal/adapters/weatherapi` fetches normalized weather data from the
|
||||||
configured Weather API endpoint and assembles the bundle consumed by forecast
|
configured Weather API and assembles the bundle consumed by forecast derivation
|
||||||
derivation and module builders. Module builders expose normalized current
|
and module builders. Module builders expose normalized current conditions and
|
||||||
conditions and weather story context when those sources are available.
|
weather story context when those sources are available.
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
@@ -21,7 +21,8 @@ Outputs:
|
|||||||
|
|
||||||
- `weatherdata.Bundle` with observation, current conditions, hourly forecast,
|
- `weatherdata.Bundle` with observation, current conditions, hourly forecast,
|
||||||
narrative forecast, active alerts, discussion, latest weather story, source
|
narrative forecast, active alerts, discussion, latest weather story, source
|
||||||
records, and source warnings
|
records, source warnings, and typed SPC convective outlook data when that
|
||||||
|
optional source is available
|
||||||
- optional saved bundle JSON through app fetch helpers
|
- optional saved bundle JSON through app fetch helpers
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
@@ -54,9 +55,19 @@ contract used by this project.
|
|||||||
The adapter records source name, endpoint, query, fetch time, source timestamps
|
The adapter records source name, endpoint, query, fetch time, source timestamps
|
||||||
when available, SHA-256 hash over compact raw `data` JSON, missing status, and
|
when available, SHA-256 hash over compact raw `data` JSON, missing status, and
|
||||||
source warnings. Successful `data: null` responses from `/alerts/active`
|
source warnings. Successful `data: null` responses from `/alerts/active`
|
||||||
represent a checked empty active-alert list, not a missing source.
|
represent a checked empty active-alert list, not a missing source. Successful
|
||||||
|
non-null `/outlooks/convective` responses with empty outlook and discussion
|
||||||
|
arrays represent checked empty outlook data.
|
||||||
`app.FetchAndSaveBundle` can write bundle JSON atomically for inspection.
|
`app.FetchAndSaveBundle` can write bundle JSON atomically for inspection.
|
||||||
|
|
||||||
|
SPC convective outlook data is stored on
|
||||||
|
`weatherdata.Bundle.SPCConvectiveOutlooks`. The collected run keeps upstream
|
||||||
|
run metadata, location identifiers, ordered outlook records, discussion
|
||||||
|
records, and each outlook's raw GeoJSON geometry. Source provenance for this
|
||||||
|
payload uses the `spc_convective_outlooks` source name, endpoint
|
||||||
|
`/outlooks/convective`, the query sent by the adapter, timestamps, and a hash
|
||||||
|
of the raw `data` object.
|
||||||
|
|
||||||
## Skip And Resume Behavior
|
## Skip And Resume Behavior
|
||||||
|
|
||||||
No resume behavior. Optional missing or malformed sources may be omitted,
|
No resume behavior. Optional missing or malformed sources may be omitted,
|
||||||
@@ -72,6 +83,8 @@ data is required and cannot be skipped.
|
|||||||
- Optional sources follow missing-source policy.
|
- Optional sources follow missing-source policy.
|
||||||
- Explicit `data: null` from `/alerts/active` produces an empty, non-missing
|
- Explicit `data: null` from `/alerts/active` produces an empty, non-missing
|
||||||
alert run.
|
alert run.
|
||||||
|
- Explicit `data: null` from `/outlooks/convective` follows optional
|
||||||
|
missing-source policy.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
@@ -11,18 +11,28 @@ Generation commands:
|
|||||||
```text
|
```text
|
||||||
weatherreporter generate daily --date 2026-05-29
|
weatherreporter generate daily --date 2026-05-29
|
||||||
weatherreporter generate tomorrow
|
weatherreporter generate tomorrow
|
||||||
|
weatherreporter generate hourly
|
||||||
weatherreporter generate three-day
|
weatherreporter generate three-day
|
||||||
weatherreporter generate weekend
|
weatherreporter generate weekend
|
||||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
|
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
|
||||||
```
|
```
|
||||||
|
|
||||||
Each command resolves a report period, fetches a Weather API bundle, builds a
|
Markdown-path generation commands resolve a report period, fetch a Weather API
|
||||||
JSON module snapshot, builds a YAML prompt input data package, runs
|
bundle, build a JSON module snapshot, build a YAML prompt input data package,
|
||||||
`scriptorium render`, runs `scriptorium run`, and writes managed artifacts under
|
run `scriptorium render`, run `scriptorium run`, and write managed artifacts
|
||||||
the configured workspace.
|
under the configured workspace. When distributor notification is enabled,
|
||||||
When distributor notification is enabled, weatherreporter uploads the managed
|
weatherreporter uploads the managed Markdown report after report rendering
|
||||||
Markdown report after `scriptorium run` succeeds and final metadata is saved.
|
succeeds and final metadata is saved. `--out PATH` writes an extra Markdown
|
||||||
`--out PATH` writes an extra Markdown copy for the current generated report; it
|
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.
|
is not used as the distributor upload source.
|
||||||
|
|
||||||
Batch commands:
|
Batch commands:
|
||||||
@@ -62,6 +72,14 @@ workspace/
|
|||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.modules.json
|
<run_id>.modules.json
|
||||||
<run_id>.metadata.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/
|
storm/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.modules.json
|
<run_id>.modules.json
|
||||||
@@ -76,6 +94,9 @@ workspace/
|
|||||||
weekend/
|
weekend/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.data_package.yaml
|
<run_id>.data_package.yaml
|
||||||
|
hourly/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.data_package.yaml
|
||||||
storm/
|
storm/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.data_package.yaml
|
<run_id>.data_package.yaml
|
||||||
@@ -89,6 +110,9 @@ workspace/
|
|||||||
weekend/
|
weekend/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.render.json
|
<run_id>.render.json
|
||||||
|
hourly/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.render.json
|
||||||
storm/
|
storm/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.render.json
|
<run_id>.render.json
|
||||||
@@ -102,6 +126,9 @@ workspace/
|
|||||||
weekend/
|
weekend/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.distributor.json
|
<run_id>.distributor.json
|
||||||
|
hourly/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.distributor.json
|
||||||
storm/
|
storm/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.distributor.json
|
<run_id>.distributor.json
|
||||||
@@ -112,6 +139,8 @@ workspace/
|
|||||||
<run_id>.md
|
<run_id>.md
|
||||||
weekend/
|
weekend/
|
||||||
<run_id>.md
|
<run_id>.md
|
||||||
|
hourly/
|
||||||
|
<run_id>.md
|
||||||
storm/
|
storm/
|
||||||
<run_id>.md
|
<run_id>.md
|
||||||
```
|
```
|
||||||
@@ -136,6 +165,7 @@ Each generated report writes metadata that links:
|
|||||||
- prompt input data package path
|
- prompt input data package path
|
||||||
- preflight output path
|
- preflight output path
|
||||||
- managed Markdown report 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
|
- distributor notification debug artifact path, when notification is attempted
|
||||||
|
|
||||||
Batch summaries include report status, error text when applicable, notification
|
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
|
the valid-period start date, artifact group, and RunID. Distributor owns
|
||||||
destination merge, retention, and derived snapshot behavior such as `latest`.
|
destination merge, retention, and derived snapshot behavior such as `latest`.
|
||||||
|
|
||||||
Notification happens after final metadata save. Weather API, module snapshot,
|
Notification happens after final metadata save for generated reports. Weather
|
||||||
data-package, render preflight, Scriptorium run, and metadata-save failures do
|
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.
|
not trigger notification. A notification failure fails that report.
|
||||||
In a batch, other reports continue, the failed report includes notification
|
In a batch, other reports continue, the failed report includes notification
|
||||||
fields in the JSON summary, and the batch returns nonzero.
|
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
|
Daily Today and Daily Tomorrow can compare with each other when they cover the
|
||||||
same valid local date. 3-Day Outlook compares with prior compatible 3-Day
|
same valid local date. 3-Day Outlook compares with prior compatible 3-Day
|
||||||
snapshots for the same valid local date. Weekend Outlook compares with prior
|
snapshots for the same valid local date. Weekend Outlook compares with prior
|
||||||
compatible Weekend snapshots for the same weekend window. Storm Report leaves
|
compatible Weekend snapshots for the same weekend window. Hourly Report and
|
||||||
Recent Changes empty.
|
Storm Report leave Recent Changes empty.
|
||||||
|
|
||||||
When no prior comparable snapshot exists, or no configured threshold is crossed,
|
When no prior comparable snapshot exists, or no configured threshold is crossed,
|
||||||
`recentChanges.items` is empty.
|
`recentChanges.items` is empty.
|
||||||
@@ -232,6 +263,10 @@ A failed generation run may still leave useful artifacts:
|
|||||||
preflight JSON and metadata are written for inspection.
|
preflight JSON and metadata are written for inspection.
|
||||||
- If `scriptorium run` exits nonzero after writing a report, the managed report
|
- If `scriptorium run` exits nonzero after writing a report, the managed report
|
||||||
and metadata remain available.
|
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
|
- If distributor notification fails, report artifacts and final metadata remain
|
||||||
available, but the report or batch command returns nonzero.
|
available, but the report or batch command returns nonzero.
|
||||||
- For batch commands, inspect the stdout JSON summary first, then inspect the
|
- For batch commands, inspect the stdout JSON summary first, then inspect the
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ add only modules backed by implemented upstream facts and clear report needs.
|
|||||||
|
|
||||||
Possible future report types:
|
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;
|
- event-specific reports with stable event IDs;
|
||||||
- storm review or yesterday-style reports using historical observations;
|
- storm review or yesterday-style reports using historical observations;
|
||||||
- archive-focused report variants if generated report history becomes a
|
- archive-focused report variants if generated report history becomes a
|
||||||
@@ -84,7 +85,6 @@ These enhancements are not current behavior:
|
|||||||
|
|
||||||
- `failure_policy: warn`;
|
- `failure_policy: warn`;
|
||||||
- uploading metadata, module snapshots, data packages, or preflight artifacts;
|
- uploading metadata, module snapshots, data packages, or preflight artifacts;
|
||||||
- polling distributor status after upload acceptance;
|
|
||||||
- durable upload retry queues;
|
- durable upload retry queues;
|
||||||
- distributor-specific CLI flags;
|
- distributor-specific CLI flags;
|
||||||
- making distributor scan the weatherreporter workspace;
|
- making distributor scan the weatherreporter workspace;
|
||||||
|
|||||||
342
docs/roadmap/generated-text.md
Normal file
342
docs/roadmap/generated-text.md
Normal 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.
|
||||||
782
docs/roadmap/implementation.md
Normal file
782
docs/roadmap/implementation.md
Normal 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.
|
||||||
@@ -21,7 +21,7 @@ notify:
|
|||||||
token_env: DISTRIBUTOR_UPLOAD_TOKEN
|
token_env: DISTRIBUTOR_UPLOAD_TOKEN
|
||||||
timeout: 30s
|
timeout: 30s
|
||||||
failure_policy: error
|
failure_policy: error
|
||||||
pipeline_id_template: "weatherreporter.{artifact_group}"
|
pipeline_id_template: "weatherreporter.{report_id}"
|
||||||
bundle_id_template: "weatherreporter.{location_id}.{report_id}"
|
bundle_id_template: "weatherreporter.{location_id}.{report_id}"
|
||||||
idempotency_key_template: "{bundle_id}.{run_id}"
|
idempotency_key_template: "{bundle_id}.{run_id}"
|
||||||
report_path_templates:
|
report_path_templates:
|
||||||
@@ -77,6 +77,7 @@ reports:
|
|||||||
- derived_daypart_summaries
|
- derived_daypart_summaries
|
||||||
- precip_timing
|
- precip_timing
|
||||||
- alert_digest
|
- alert_digest
|
||||||
|
- spc_convective_outlooks
|
||||||
- id: area_forecast_discussion
|
- id: area_forecast_discussion
|
||||||
options:
|
options:
|
||||||
sections:
|
sections:
|
||||||
@@ -84,6 +85,22 @@ reports:
|
|||||||
- key_messages
|
- key_messages
|
||||||
- short_term
|
- short_term
|
||||||
- long_term
|
- long_term
|
||||||
|
- spc_convective_discussion
|
||||||
- weather_story
|
- weather_story
|
||||||
- outdoor_windows
|
- outdoor_windows
|
||||||
- hourly_forecast
|
- hourly_forecast
|
||||||
|
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
|
||||||
|
|||||||
@@ -79,6 +79,12 @@ type RunRequest struct {
|
|||||||
OutputPath string
|
OutputPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StructuredRunRequest struct {
|
||||||
|
PromptID string
|
||||||
|
DataPackagePath string
|
||||||
|
OutputPath string
|
||||||
|
}
|
||||||
|
|
||||||
type RenderResult struct {
|
type RenderResult struct {
|
||||||
Command []string `json:"command"`
|
Command []string `json:"command"`
|
||||||
Stdout string `json:"stdout"`
|
Stdout string `json:"stdout"`
|
||||||
@@ -98,6 +104,16 @@ type RunResult struct {
|
|||||||
OutputPath string `json:"outputPath"`
|
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) {
|
func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, error) {
|
||||||
if req.PromptID == "" {
|
if req.PromptID == "" {
|
||||||
return nil, fmt.Errorf("prompt id is required")
|
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
|
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 {
|
type execution struct {
|
||||||
binary string
|
binary string
|
||||||
args []string
|
args []string
|
||||||
@@ -212,6 +257,14 @@ func (r Runner) runArgs(req RunRequest) []string {
|
|||||||
return args
|
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 {
|
type limitedBuffer struct {
|
||||||
data []byte
|
data []byte
|
||||||
limit int
|
limit int
|
||||||
|
|||||||
@@ -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 {
|
type fakeCommands struct {
|
||||||
name string
|
name string
|
||||||
args []string
|
args []string
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
result CommandResult
|
result CommandResult
|
||||||
err error
|
err error
|
||||||
|
calls int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeCommands) Run(_ context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) {
|
func (f *fakeCommands) Run(_ context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) {
|
||||||
|
f.calls++
|
||||||
f.name = name
|
f.name = name
|
||||||
f.args = append([]string{}, args...)
|
f.args = append([]string{}, args...)
|
||||||
f.timeout = timeout
|
f.timeout = timeout
|
||||||
return f.result, f.err
|
return f.result, f.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func containsArg(args []string, want string) bool {
|
||||||
|
for _, arg := range args {
|
||||||
|
if arg == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
convectiveOutlooksEndpoint = "/outlooks/convective"
|
||||||
|
sourceSPCConvectiveOutlooks = "spc_convective_outlooks"
|
||||||
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
baseURL *url.URL
|
baseURL *url.URL
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
@@ -112,6 +117,9 @@ func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
|
|||||||
if err := builder.fetchWeatherStory(ctx); err != nil {
|
if err := builder.fetchWeatherStory(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := builder.fetchSPCConvectiveOutlooks(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return builder.bundle, nil
|
return builder.bundle, nil
|
||||||
}
|
}
|
||||||
@@ -264,6 +272,29 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *bundleBuilder) fetchSPCConvectiveOutlooks(ctx context.Context) error {
|
||||||
|
raw, source, err := b.client.fetch(ctx, sourceSPCConvectiveOutlooks, convectiveOutlooksEndpoint, queryOptions{timezone: true, omitUnits: true})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if raw == nil {
|
||||||
|
return b.handleMissing(&source, "SPC convective outlook data is missing", false)
|
||||||
|
}
|
||||||
|
var run weatherdata.ConvectiveOutlookRun
|
||||||
|
if err := decodeSource(raw, &run); err != nil {
|
||||||
|
return b.handleMalformed(&source, err, false)
|
||||||
|
}
|
||||||
|
if run.IssuedAt != nil {
|
||||||
|
source.IssuedAt = run.IssuedAt
|
||||||
|
} else {
|
||||||
|
source.IssuedAt = run.AsOf
|
||||||
|
}
|
||||||
|
source.UpdatedAt = run.UpdatedAt
|
||||||
|
b.bundle.SPCConvectiveOutlooks = &run
|
||||||
|
b.addSource(source)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error {
|
func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error {
|
||||||
source.Missing = true
|
source.Missing = true
|
||||||
if required {
|
if required {
|
||||||
|
|||||||
@@ -55,8 +55,17 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
|||||||
if bundle.WeatherStory.UpdatedAt == nil {
|
if bundle.WeatherStory.UpdatedAt == nil {
|
||||||
t.Fatalf("WeatherStory.UpdatedAt = nil, want update timestamp")
|
t.Fatalf("WeatherStory.UpdatedAt = nil, want update timestamp")
|
||||||
}
|
}
|
||||||
if len(bundle.Sources) != 7 {
|
if bundle.SPCConvectiveOutlooks == nil || len(bundle.SPCConvectiveOutlooks.Outlooks) != 1 {
|
||||||
t.Fatalf("Sources length = %d, want 7", len(bundle.Sources))
|
t.Fatalf("SPCConvectiveOutlooks = %#v, want one outlook", bundle.SPCConvectiveOutlooks)
|
||||||
|
}
|
||||||
|
if len(bundle.SPCConvectiveOutlooks.Outlooks[0].Geometry) == 0 {
|
||||||
|
t.Fatalf("SPCConvectiveOutlooks.Outlooks[0].Geometry is empty, want GeoJSON")
|
||||||
|
}
|
||||||
|
if len(bundle.SPCConvectiveOutlooks.Discussions) != 1 || bundle.SPCConvectiveOutlooks.Discussions[0].Headline != "Severe storms possible" {
|
||||||
|
t.Fatalf("SPCConvectiveOutlooks.Discussions = %#v, want one discussion", bundle.SPCConvectiveOutlooks.Discussions)
|
||||||
|
}
|
||||||
|
if len(bundle.Sources) != 8 {
|
||||||
|
t.Fatalf("Sources length = %d, want 8", len(bundle.Sources))
|
||||||
}
|
}
|
||||||
if len(bundle.Warnings) != 0 {
|
if len(bundle.Warnings) != 0 {
|
||||||
t.Fatalf("Warnings length = %d, want no warnings", len(bundle.Warnings))
|
t.Fatalf("Warnings length = %d, want no warnings", len(bundle.Warnings))
|
||||||
@@ -70,6 +79,9 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
|||||||
if !containsPath(requested, "/weatherstories/latest") {
|
if !containsPath(requested, "/weatherstories/latest") {
|
||||||
t.Fatalf("requested paths = %v, want weather story endpoint", requested)
|
t.Fatalf("requested paths = %v, want weather story endpoint", requested)
|
||||||
}
|
}
|
||||||
|
if !containsPath(requested, convectiveOutlooksEndpoint) {
|
||||||
|
t.Fatalf("requested paths = %v, want convective outlook endpoint", requested)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
||||||
@@ -92,6 +104,12 @@ func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if strings.HasPrefix(rawURL, convectiveOutlooksEndpoint) {
|
||||||
|
if strings.Contains(rawURL, "units=") || strings.Contains(rawURL, "precision=") || !strings.Contains(rawURL, "tz=America%2FChicago") {
|
||||||
|
t.Fatalf("convective outlook request %q should use format and tz only", rawURL)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
if !strings.Contains(rawURL, "units=us") {
|
if !strings.Contains(rawURL, "units=us") {
|
||||||
t.Fatalf("request %q missing units=us", rawURL)
|
t.Fatalf("request %q missing units=us", rawURL)
|
||||||
}
|
}
|
||||||
@@ -127,6 +145,25 @@ func TestFetchBundleRecordsSourceHash(t *testing.T) {
|
|||||||
if story.IssuedAt == nil || story.UpdatedAt == nil {
|
if story.IssuedAt == nil || story.UpdatedAt == nil {
|
||||||
t.Fatalf("weather story source timestamps = issued %#v updated %#v, want both", story.IssuedAt, story.UpdatedAt)
|
t.Fatalf("weather story source timestamps = issued %#v updated %#v, want both", story.IssuedAt, story.UpdatedAt)
|
||||||
}
|
}
|
||||||
|
outlooks := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
|
||||||
|
if outlooks.Endpoint != convectiveOutlooksEndpoint {
|
||||||
|
t.Fatalf("convective outlook endpoint = %q, want %s", outlooks.Endpoint, convectiveOutlooksEndpoint)
|
||||||
|
}
|
||||||
|
if outlooks.Query["format"] != "json" || outlooks.Query["tz"] != "America/Chicago" || outlooks.Query["units"] != "" || outlooks.Query["precision"] != "" {
|
||||||
|
t.Fatalf("convective outlook query = %#v, want format and tz only", outlooks.Query)
|
||||||
|
}
|
||||||
|
if outlooks.DataSHA256 != hashFixtureData(t, "convective_outlooks.json") {
|
||||||
|
t.Fatalf("convective outlook DataSHA256 = %q, want fixture hash", outlooks.DataSHA256)
|
||||||
|
}
|
||||||
|
if outlooks.Missing {
|
||||||
|
t.Fatal("convective outlook source Missing = true, want false")
|
||||||
|
}
|
||||||
|
if outlooks.IssuedAt == nil || outlooks.IssuedAt.Format(time.RFC3339) != "2026-05-29T15:45:00Z" {
|
||||||
|
t.Fatalf("convective outlook IssuedAt = %#v, want run issuedAt", outlooks.IssuedAt)
|
||||||
|
}
|
||||||
|
if outlooks.UpdatedAt == nil || outlooks.UpdatedAt.Format(time.RFC3339) != "2026-05-29T16:05:00Z" {
|
||||||
|
t.Fatalf("convective outlook UpdatedAt = %#v, want run updatedAt", outlooks.UpdatedAt)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHTTPErrorIsActionable(t *testing.T) {
|
func TestHTTPErrorIsActionable(t *testing.T) {
|
||||||
@@ -189,6 +226,59 @@ func TestNullAlertsMeansNoActiveAlerts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMissingSPCConvectiveOutlooksUsesPolicy(t *testing.T) {
|
||||||
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
|
convectiveOutlooksEndpoint: {status: http.StatusOK, body: `{"data": null}`},
|
||||||
|
}, nil)
|
||||||
|
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
|
||||||
|
sourceSPCConvectiveOutlooks: config.MissingSourceWarn,
|
||||||
|
})
|
||||||
|
|
||||||
|
bundle, err := client.FetchBundle(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchBundle() error = %v", err)
|
||||||
|
}
|
||||||
|
if bundle.SPCConvectiveOutlooks != nil {
|
||||||
|
t.Fatalf("SPCConvectiveOutlooks = %#v, want nil for missing source", bundle.SPCConvectiveOutlooks)
|
||||||
|
}
|
||||||
|
source := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
|
||||||
|
if !source.Missing || len(source.Warnings) != 1 {
|
||||||
|
t.Fatalf("convective outlook source = %#v, want missing source warning", source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptySPCConvectiveOutlooksAreCheckedData(t *testing.T) {
|
||||||
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
|
convectiveOutlooksEndpoint: {status: http.StatusOK, body: `{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`},
|
||||||
|
}, nil)
|
||||||
|
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
|
||||||
|
sourceSPCConvectiveOutlooks: config.MissingSourceWarn,
|
||||||
|
})
|
||||||
|
|
||||||
|
bundle, err := client.FetchBundle(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchBundle() error = %v", err)
|
||||||
|
}
|
||||||
|
if bundle.SPCConvectiveOutlooks == nil {
|
||||||
|
t.Fatal("SPCConvectiveOutlooks = nil, want checked empty run")
|
||||||
|
}
|
||||||
|
if len(bundle.SPCConvectiveOutlooks.Outlooks) != 0 || len(bundle.SPCConvectiveOutlooks.Discussions) != 0 {
|
||||||
|
t.Fatalf("SPCConvectiveOutlooks = %#v, want empty arrays", bundle.SPCConvectiveOutlooks)
|
||||||
|
}
|
||||||
|
source := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
|
||||||
|
if source.Missing || len(source.Warnings) != 0 {
|
||||||
|
t.Fatalf("convective outlook source = %#v, want non-missing source without warnings", source)
|
||||||
|
}
|
||||||
|
if source.IssuedAt == nil || source.IssuedAt.Format(time.RFC3339) != "2026-05-29T16:00:00Z" {
|
||||||
|
t.Fatalf("convective outlook IssuedAt = %#v, want fallback to asOf", source.IssuedAt)
|
||||||
|
}
|
||||||
|
for _, warning := range bundle.Warnings {
|
||||||
|
if warning.Source == sourceSPCConvectiveOutlooks {
|
||||||
|
t.Fatalf("warnings = %#v, want no convective outlook warning", bundle.Warnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
|
func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -370,6 +460,7 @@ func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested
|
|||||||
"/alerts/active": "alerts.json",
|
"/alerts/active": "alerts.json",
|
||||||
"/discussion": "discussion.json",
|
"/discussion": "discussion.json",
|
||||||
"/weatherstories/latest": "weather_story.json",
|
"/weatherstories/latest": "weather_story.json",
|
||||||
|
convectiveOutlooksEndpoint: "convective_outlooks.json",
|
||||||
}
|
}
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if requested != nil {
|
if requested != nil {
|
||||||
|
|||||||
50
internal/adapters/weatherapi/testdata/convective_outlooks.json
vendored
Normal file
50
internal/adapters/weatherapi/testdata/convective_outlooks.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,9 +17,11 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"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/module"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"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/state"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
@@ -30,6 +32,7 @@ type ReportKind string
|
|||||||
const (
|
const (
|
||||||
ReportDaily ReportKind = "daily"
|
ReportDaily ReportKind = "daily"
|
||||||
ReportTomorrow ReportKind = "tomorrow"
|
ReportTomorrow ReportKind = "tomorrow"
|
||||||
|
ReportHourly ReportKind = "hourly"
|
||||||
ReportThreeDay ReportKind = "three-day"
|
ReportThreeDay ReportKind = "three-day"
|
||||||
ReportWeekend ReportKind = "weekend"
|
ReportWeekend ReportKind = "weekend"
|
||||||
ReportStorm ReportKind = "storm"
|
ReportStorm ReportKind = "storm"
|
||||||
@@ -102,6 +105,11 @@ type ReportResult struct {
|
|||||||
RecentChanges []changes.Change
|
RecentChanges []changes.Change
|
||||||
RenderResult *scriptorium.RenderResult
|
RenderResult *scriptorium.RenderResult
|
||||||
RunResult *scriptorium.RunResult
|
RunResult *scriptorium.RunResult
|
||||||
|
StructuredRunResult *scriptorium.StructuredRunResult
|
||||||
|
GeneratedTextRawPath string
|
||||||
|
GeneratedTextResultPath string
|
||||||
|
GeneratedTextPath string
|
||||||
|
RenderContextPath string
|
||||||
Notification *NotificationResult
|
Notification *NotificationResult
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +158,7 @@ func (e BatchError) Error() string {
|
|||||||
type Renderer interface {
|
type Renderer interface {
|
||||||
Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error)
|
Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error)
|
||||||
Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error)
|
Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error)
|
||||||
|
StructuredRun(context.Context, scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Notifier interface {
|
type Notifier interface {
|
||||||
@@ -386,6 +395,8 @@ func reportIDForCommand(kind ReportKind) (report.ID, error) {
|
|||||||
return report.DailyToday, nil
|
return report.DailyToday, nil
|
||||||
case ReportTomorrow:
|
case ReportTomorrow:
|
||||||
return report.DailyTomorrow, nil
|
return report.DailyTomorrow, nil
|
||||||
|
case ReportHourly:
|
||||||
|
return report.Hourly, nil
|
||||||
case ReportThreeDay:
|
case ReportThreeDay:
|
||||||
return report.ThreeDay, nil
|
return report.ThreeDay, nil
|
||||||
case ReportWeekend:
|
case ReportWeekend:
|
||||||
@@ -487,6 +498,10 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
|||||||
DataPackage: paths.DataPackage,
|
DataPackage: paths.DataPackage,
|
||||||
Preflight: paths.Preflight,
|
Preflight: paths.Preflight,
|
||||||
RenderedReport: paths.RenderedReport,
|
RenderedReport: paths.RenderedReport,
|
||||||
|
GeneratedTextRaw: paths.GeneratedTextRaw,
|
||||||
|
GeneratedTextResult: paths.GeneratedTextResult,
|
||||||
|
GeneratedText: paths.GeneratedText,
|
||||||
|
RenderContext: paths.RenderContext,
|
||||||
})
|
})
|
||||||
dataPackage, err := promptinput.Build(promptinput.BuildRequest{
|
dataPackage, err := promptinput.Build(promptinput.BuildRequest{
|
||||||
Metadata: promptMetadata(metadata),
|
Metadata: promptMetadata(metadata),
|
||||||
@@ -531,9 +546,34 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
|||||||
return nil, metadataErr
|
return nil, metadataErr
|
||||||
}
|
}
|
||||||
if renderErr != nil {
|
if renderErr != nil {
|
||||||
|
if req.Resolved.Definition.GenerationMode == report.GenerationModeGeneratedTextTemplate {
|
||||||
|
return nil, generatedReportError(req.Resolved, metadata.RunID, "render preflight", renderErr)
|
||||||
|
}
|
||||||
return nil, 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)
|
reportPath, err := store.PrepareRenderedReport(ctx, req.Resolved)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -592,6 +632,138 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
|||||||
}, nil
|
}, 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) {
|
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)
|
notifier, enabled := reportNotifier(cfg, notifier)
|
||||||
if !enabled {
|
if !enabled {
|
||||||
@@ -935,3 +1107,28 @@ func preflightArtifact(result *scriptorium.RenderResult) state.PreflightArtifact
|
|||||||
ExitCode: result.ExitCode,
|
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
@@ -62,8 +62,8 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
|
|||||||
if period.TextDescription != "Showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 {
|
if period.TextDescription != "Showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 {
|
||||||
t.Fatalf("HourlyForecast period = %#v, want hourly period facts", period)
|
t.Fatalf("HourlyForecast period = %#v, want hourly period facts", period)
|
||||||
}
|
}
|
||||||
if period.StartTime != "2026-05-29 at 8:00 AM" || period.EndTime != "2026-05-29 at 9:00 AM" {
|
if period.PeriodBegins != "2026-05-29 at 8:00 AM" || period.PeriodEnds != "2026-05-29 at 9:00 AM" {
|
||||||
t.Fatalf("HourlyForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime)
|
t.Fatalf("HourlyForecast period times = %q/%q, want friendly local time labels", period.PeriodBegins, period.PeriodEnds)
|
||||||
}
|
}
|
||||||
if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
|
if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
|
||||||
t.Fatalf("HourlyForecast period = %#v, want compass wind and precip chance", period)
|
t.Fatalf("HourlyForecast period = %#v, want compass wind and precip chance", period)
|
||||||
@@ -73,7 +73,7 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
|
|||||||
t.Fatalf("Marshal hourly forecast: %v", err)
|
t.Fatalf("Marshal hourly forecast: %v", err)
|
||||||
}
|
}
|
||||||
jsonText := string(data)
|
jsonText := string(data)
|
||||||
for _, field := range []string{"source_location_id", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} {
|
for _, field := range []string{"source_location_id", "period_begins", "period_ends", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} {
|
||||||
if !strings.Contains(jsonText, field) {
|
if !strings.Contains(jsonText, field) {
|
||||||
t.Fatalf("hourly json = %s, want field %s", jsonText, field)
|
t.Fatalf("hourly json = %s, want field %s", jsonText, field)
|
||||||
}
|
}
|
||||||
@@ -81,8 +81,8 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
|
|||||||
if strings.Contains(jsonText, "wind_direction_degrees") || strings.Contains(jsonText, "Tomorrow") {
|
if strings.Contains(jsonText, "wind_direction_degrees") || strings.Contains(jsonText, "Tomorrow") {
|
||||||
t.Fatalf("hourly json = %s, want valid-period prompt fields only", jsonText)
|
t.Fatalf("hourly json = %s, want valid-period prompt fields only", jsonText)
|
||||||
}
|
}
|
||||||
if strings.Contains(jsonText, `"start_time":"2026-05-29T`) || strings.Contains(jsonText, `"end_time":"2026-05-29T`) {
|
if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) {
|
||||||
t.Fatalf("hourly json = %s, want friendly local start/end times", jsonText)
|
t.Fatalf("hourly json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +97,21 @@ func TestHourlyForecastModuleRejectsUnsupportedReports(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func 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) {
|
func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := testModuleContext()
|
ctx := testModuleContext()
|
||||||
@@ -113,8 +128,8 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
|||||||
if period.Name != "Today" || period.TextDescription != "Morning storms, then partly sunny." {
|
if period.Name != "Today" || period.TextDescription != "Morning storms, then partly sunny." {
|
||||||
t.Fatalf("NarrativeForecast period = %#v, want Today narrative", period)
|
t.Fatalf("NarrativeForecast period = %#v, want Today narrative", period)
|
||||||
}
|
}
|
||||||
if period.StartTime != "2026-05-29 at 6:00 AM" || period.EndTime != "2026-05-29 at 6:00 PM" {
|
if period.PeriodBegins != "2026-05-29 at 6:00 AM" || period.PeriodEnds != "2026-05-29 at 6:00 PM" {
|
||||||
t.Fatalf("NarrativeForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime)
|
t.Fatalf("NarrativeForecast period times = %q/%q, want friendly local time labels", period.PeriodBegins, period.PeriodEnds)
|
||||||
}
|
}
|
||||||
if period.IsDay == nil || !*period.IsDay || period.TemperatureF == nil || *period.TemperatureF != 81 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 60 {
|
if period.IsDay == nil || !*period.IsDay || period.TemperatureF == nil || *period.TemperatureF != 81 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 60 {
|
||||||
t.Fatalf("NarrativeForecast period = %#v, want day, temperature, and precip values", period)
|
t.Fatalf("NarrativeForecast period = %#v, want day, temperature, and precip values", period)
|
||||||
@@ -127,7 +142,7 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
|||||||
t.Fatalf("Marshal narrative forecast: %v", err)
|
t.Fatalf("Marshal narrative forecast: %v", err)
|
||||||
}
|
}
|
||||||
jsonText := string(data)
|
jsonText := string(data)
|
||||||
for _, field := range []string{"source_location_id", "text_description", "temperature_f", "wind_speed_mph", "wind_direction", "probability_of_precipitation_percent"} {
|
for _, field := range []string{"source_location_id", "period_begins", "period_ends", "text_description", "temperature_f", "wind_speed_mph", "wind_direction", "probability_of_precipitation_percent"} {
|
||||||
if !strings.Contains(jsonText, field) {
|
if !strings.Contains(jsonText, field) {
|
||||||
t.Fatalf("narrative json = %s, want field %s", jsonText, field)
|
t.Fatalf("narrative json = %s, want field %s", jsonText, field)
|
||||||
}
|
}
|
||||||
@@ -135,8 +150,8 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
|||||||
if strings.Contains(jsonText, "wind_direction_degrees") {
|
if strings.Contains(jsonText, "wind_direction_degrees") {
|
||||||
t.Fatalf("narrative json = %s, want compass wind_direction without degrees field", jsonText)
|
t.Fatalf("narrative json = %s, want compass wind_direction without degrees field", jsonText)
|
||||||
}
|
}
|
||||||
if strings.Contains(jsonText, `"start_time":"2026-05-29T`) || strings.Contains(jsonText, `"end_time":"2026-05-29T`) {
|
if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) {
|
||||||
t.Fatalf("narrative json = %s, want friendly local start/end times", jsonText)
|
t.Fatalf("narrative json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText)
|
||||||
}
|
}
|
||||||
if strings.Contains(jsonText, "Tomorrow night") {
|
if strings.Contains(jsonText, "Tomorrow night") {
|
||||||
t.Fatalf("narrative json = %s, want only valid-period narrative periods", jsonText)
|
t.Fatalf("narrative json = %s, want only valid-period narrative periods", jsonText)
|
||||||
@@ -285,13 +300,19 @@ func TestAreaForecastDiscussionAndWeatherStoryModules(t *testing.T) {
|
|||||||
if !story.Available || story.Title != "Rain Chances" || story.Description != "Scattered showers are possible." {
|
if !story.Available || story.Title != "Rain Chances" || story.Description != "Scattered showers are possible." {
|
||||||
t.Fatalf("WeatherStory = %#v, want structured story fields", story)
|
t.Fatalf("WeatherStory = %#v, want structured story fields", story)
|
||||||
}
|
}
|
||||||
|
if story.PeriodBegins != "2026-05-29 at 6:00 AM" || story.PeriodEnds != "2026-05-29 at 6:00 PM" {
|
||||||
|
t.Fatalf("WeatherStory period = %q/%q, want friendly local period labels", story.PeriodBegins, story.PeriodEnds)
|
||||||
|
}
|
||||||
data, err := json.Marshal(storyOutput.Value)
|
data, err := json.Marshal(storyOutput.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Marshal weather story: %v", err)
|
t.Fatalf("Marshal weather story: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(data), "download_url") {
|
if !strings.Contains(string(data), "download_url") || !strings.Contains(string(data), "period_begins") || !strings.Contains(string(data), "period_ends") {
|
||||||
t.Fatalf("weather story json = %s, want snake_case download_url", string(data))
|
t.Fatalf("weather story json = %s, want snake_case download_url", string(data))
|
||||||
}
|
}
|
||||||
|
if strings.Contains(string(data), "start_time") || strings.Contains(string(data), "end_time") {
|
||||||
|
t.Fatalf("weather story json = %s, want period_begins/period_ends instead of start_time/end_time", string(data))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
|
func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
|
||||||
@@ -314,6 +335,34 @@ func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func 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 {
|
func testModuleContext() ModuleContext {
|
||||||
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
|
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
|
||||||
definition := report.DefaultRegistry().MustLookup(report.DailyToday)
|
definition := report.DefaultRegistry().MustLookup(report.DailyToday)
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import (
|
|||||||
|
|
||||||
type DerivedDaypartSummaryModule struct {
|
type DerivedDaypartSummaryModule struct {
|
||||||
Date string `json:"date,omitempty"`
|
Date string `json:"date,omitempty"`
|
||||||
Period string `json:"period,omitempty"`
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
TempRangeF string `json:"temp_range_f,omitempty"`
|
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||||
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
||||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||||
@@ -46,7 +47,8 @@ func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Outpu
|
|||||||
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
|
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
|
||||||
value := DerivedDaypartSummaryModule{
|
value := DerivedDaypartSummaryModule{
|
||||||
Date: localDateLabel(daypart.Period.Start, timezone),
|
Date: localDateLabel(daypart.Period.Start, timezone),
|
||||||
Period: friendlyPeriodLabel(daypart.Period, timezone),
|
PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone),
|
||||||
|
PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone),
|
||||||
TempRangeF: rangeLabel(daypart.Temperature),
|
TempRangeF: rangeLabel(daypart.Temperature),
|
||||||
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
|
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
|
||||||
DominantCondition: daypart.DominantCondition,
|
DominantCondition: daypart.DominantCondition,
|
||||||
|
|||||||
@@ -102,10 +102,10 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
|||||||
if len(rainy.PrecipitationWindows) != 2 {
|
if len(rainy.PrecipitationWindows) != 2 {
|
||||||
t.Fatalf("rainy precipitation windows = %#v, want two windows", rainy.PrecipitationWindows)
|
t.Fatalf("rainy precipitation windows = %#v, want two windows", rainy.PrecipitationWindows)
|
||||||
}
|
}
|
||||||
if rainy.PrecipitationWindows[0].Start != "8 AM" || rainy.PrecipitationWindows[0].End != "9 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 {
|
if rainy.PrecipitationWindows[0].PeriodBegins != "2026-05-29 at 8:00 AM" || rainy.PrecipitationWindows[0].PeriodEnds != "2026-05-29 at 9:00 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 {
|
||||||
t.Fatalf("first precipitation window = %#v, want 8-9 AM at 60%%", rainy.PrecipitationWindows[0])
|
t.Fatalf("first precipitation window = %#v, want 8-9 AM at 60%%", rainy.PrecipitationWindows[0])
|
||||||
}
|
}
|
||||||
if rainy.PrecipitationWindows[1].Start != "12 PM" || rainy.PrecipitationWindows[1].End != "2 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 {
|
if rainy.PrecipitationWindows[1].PeriodBegins != "2026-05-29 at 12:00 PM" || rainy.PrecipitationWindows[1].PeriodEnds != "2026-05-29 at 2:00 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 {
|
||||||
t.Fatalf("second precipitation window = %#v, want noon-2 PM at 80%%", rainy.PrecipitationWindows[1])
|
t.Fatalf("second precipitation window = %#v, want noon-2 PM at 80%%", rainy.PrecipitationWindows[1])
|
||||||
}
|
}
|
||||||
data, err := json.Marshal(output.Value)
|
data, err := json.Marshal(output.Value)
|
||||||
@@ -115,6 +115,9 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
|||||||
if !strings.Contains(string(data), "precipitation_windows") || !strings.Contains(string(data), "probability_threshold") {
|
if !strings.Contains(string(data), "precipitation_windows") || !strings.Contains(string(data), "probability_threshold") {
|
||||||
t.Fatalf("precip timing json = %s, want threshold and windows", string(data))
|
t.Fatalf("precip timing json = %s, want threshold and windows", string(data))
|
||||||
}
|
}
|
||||||
|
if strings.Contains(string(data), `"start"`) || strings.Contains(string(data), `"end"`) {
|
||||||
|
t.Fatalf("precip timing json = %s, want period_begins/period_ends instead of start/end", string(data))
|
||||||
|
}
|
||||||
if strings.Contains(string(data), "first_precip_hour") || strings.Contains(string(data), "last_precip_hour") {
|
if strings.Contains(string(data), "first_precip_hour") || strings.Contains(string(data), "last_precip_hour") {
|
||||||
t.Fatalf("precip timing json = %s, want no ambiguous first/last fields", string(data))
|
t.Fatalf("precip timing json = %s, want no ambiguous first/last fields", string(data))
|
||||||
}
|
}
|
||||||
@@ -133,6 +136,22 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPrecipTimingModuleUsesDerivedTimingWithoutDaypartSummaries(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := derivedModuleContext(report.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) {
|
func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.DailyToday)
|
ctx := derivedModuleContext(report.DailyToday)
|
||||||
@@ -150,8 +169,8 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
|||||||
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 {
|
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 {
|
||||||
t.Fatalf("morning = %#v, want temp range and precip peak", morning)
|
t.Fatalf("morning = %#v, want temp range and precip peak", morning)
|
||||||
}
|
}
|
||||||
if morning.Date != "2026-05-29" || morning.Period != "2026-05-29 at 6:00 AM to 2026-05-29 at 12:00 PM" {
|
if morning.Date != "2026-05-29" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" {
|
||||||
t.Fatalf("morning period = %q/%q, want friendly local date and period labels", morning.Date, morning.Period)
|
t.Fatalf("morning period = %q/%q/%q, want friendly local date and period labels", morning.Date, morning.PeriodBegins, morning.PeriodEnds)
|
||||||
}
|
}
|
||||||
afternoon := value["afternoon"]
|
afternoon := value["afternoon"]
|
||||||
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 {
|
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 {
|
||||||
@@ -166,12 +185,12 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
|||||||
t.Fatalf("marshal daypart summaries: %v", err)
|
t.Fatalf("marshal daypart summaries: %v", err)
|
||||||
}
|
}
|
||||||
jsonText := string(data)
|
jsonText := string(data)
|
||||||
for _, field := range []string{"date", "period", "temp_range_f", "max_pop_percent", "max_wind_gust_mph", "dominant_condition"} {
|
for _, field := range []string{"date", "period_begins", "period_ends", "temp_range_f", "max_pop_percent", "max_wind_gust_mph", "dominant_condition"} {
|
||||||
if !strings.Contains(jsonText, field) {
|
if !strings.Contains(jsonText, field) {
|
||||||
t.Fatalf("daypart json = %s, want field %s", jsonText, field)
|
t.Fatalf("daypart json = %s, want field %s", jsonText, field)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.Contains(jsonText, `"period":{"start"`) || strings.Contains(jsonText, `T06:00:00`) {
|
if strings.Contains(jsonText, `"period":`) || strings.Contains(jsonText, `T06:00:00`) {
|
||||||
t.Fatalf("daypart json = %s, want friendly period label instead of raw timestamps", jsonText)
|
t.Fatalf("daypart json = %s, want friendly period label instead of raw timestamps", jsonText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,6 +210,12 @@ func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing
|
|||||||
if outdoor.Best.Daypart != "overnight" || outdoor.Worst.Daypart != "afternoon" {
|
if outdoor.Best.Daypart != "overnight" || outdoor.Worst.Daypart != "afternoon" {
|
||||||
t.Fatalf("outdoor windows = %#v, want quiet overnight and stormy afternoon", outdoor)
|
t.Fatalf("outdoor windows = %#v, want quiet overnight and stormy afternoon", outdoor)
|
||||||
}
|
}
|
||||||
|
if outdoor.Best.PeriodBegins != "2026-05-29 at 12:00 AM" || outdoor.Best.PeriodEnds != "2026-05-29 at 6:00 AM" {
|
||||||
|
t.Fatalf("best outdoor period = %#v, want overnight period labels", outdoor.Best)
|
||||||
|
}
|
||||||
|
if outdoor.Worst.PeriodBegins != "2026-05-29 at 12:00 PM" || outdoor.Worst.PeriodEnds != "2026-05-29 at 6:00 PM" {
|
||||||
|
t.Fatalf("worst outdoor period = %#v, want afternoon period labels", outdoor.Worst)
|
||||||
|
}
|
||||||
|
|
||||||
planningOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TomorrowPlanning})
|
planningOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TomorrowPlanning})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,8 +18,8 @@ type HourlyForecastModule struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type HourlyForecastPeriod struct {
|
type HourlyForecastPeriod struct {
|
||||||
StartTime string `json:"start_time,omitempty"`
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
EndTime string `json:"end_time,omitempty"`
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
IsDay *bool `json:"is_day,omitempty"`
|
IsDay *bool `json:"is_day,omitempty"`
|
||||||
ConditionCode *int `json:"condition_code,omitempty"`
|
ConditionCode *int `json:"condition_code,omitempty"`
|
||||||
@@ -74,9 +75,10 @@ func buildHourlyForecastModule(ctx ModuleContext, _ any) (*module.Output, error)
|
|||||||
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod {
|
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod {
|
||||||
out := make([]HourlyForecastPeriod, 0, len(periods))
|
out := make([]HourlyForecastPeriod, 0, len(periods))
|
||||||
for _, period := range periods {
|
for _, period := range periods {
|
||||||
|
validPeriod := timeutil.Period{Start: period.StartTime, End: period.EndTime}
|
||||||
out = append(out, HourlyForecastPeriod{
|
out = append(out, HourlyForecastPeriod{
|
||||||
StartTime: friendlyDateTimeLabel(period.StartTime, timezone),
|
PeriodBegins: friendlyPeriodBeginsLabel(validPeriod, timezone),
|
||||||
EndTime: friendlyDateTimeLabel(period.EndTime, timezone),
|
PeriodEnds: friendlyPeriodEndsLabel(validPeriod, timezone),
|
||||||
Name: period.Name,
|
Name: period.Name,
|
||||||
IsDay: copyBool(period.IsDay),
|
IsDay: copyBool(period.IsDay),
|
||||||
ConditionCode: copyInt(period.ConditionCode),
|
ConditionCode: copyInt(period.ConditionCode),
|
||||||
|
|||||||
@@ -67,18 +67,18 @@ func timedClockLabel(value *forecast.TimedValue, timezone string) string {
|
|||||||
return clockLabel(value.Time, timezone)
|
return clockLabel(value.Time, timezone)
|
||||||
}
|
}
|
||||||
|
|
||||||
func periodClockLabel(period timeutil.Period, timezone string) string {
|
func friendlyPeriodBeginsLabel(period timeutil.Period, timezone string) string {
|
||||||
if !period.IsValid() {
|
if !period.IsValid() {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return clockLabel(period.Start, timezone) + "-" + clockLabel(period.End, timezone)
|
return friendlyDateTimeLabel(period.Start, timezone)
|
||||||
}
|
}
|
||||||
|
|
||||||
func friendlyPeriodLabel(period timeutil.Period, timezone string) string {
|
func friendlyPeriodEndsLabel(period timeutil.Period, timezone string) string {
|
||||||
if !period.IsValid() {
|
if !period.IsValid() {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return friendlyDateTimeLabel(period.Start, timezone) + " to " + friendlyDateTimeLabel(period.End, timezone)
|
return friendlyDateTimeLabel(period.End, timezone)
|
||||||
}
|
}
|
||||||
|
|
||||||
func friendlyDateTimeLabel(value time.Time, timezone string) string {
|
func friendlyDateTimeLabel(value time.Time, timezone string) string {
|
||||||
|
|||||||
@@ -162,6 +162,8 @@ func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContex
|
|||||||
return ctx.Collected.Discussion != nil
|
return ctx.Collected.Discussion != nil
|
||||||
case module.CollectedWeatherStory:
|
case module.CollectedWeatherStory:
|
||||||
return ctx.Collected.WeatherStory != nil
|
return ctx.Collected.WeatherStory != nil
|
||||||
|
case module.CollectedSPCConvectiveOutlooks:
|
||||||
|
return ctx.Collected.SPCConvectiveOutlooks != nil
|
||||||
case module.CollectedSourceMetadata:
|
case module.CollectedSourceMetadata:
|
||||||
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
|
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
|
||||||
default:
|
default:
|
||||||
@@ -183,6 +185,8 @@ func derivedFactAvailable(requirement module.FactRequirement, ctx ModuleContext)
|
|||||||
return len(ctx.Derived.DaypartSummaries) > 0
|
return len(ctx.Derived.DaypartSummaries) > 0
|
||||||
case module.RequiresDerivedPrecipTiming:
|
case module.RequiresDerivedPrecipTiming:
|
||||||
return true
|
return true
|
||||||
|
case module.RequiresDerivedSPCConvectiveOutlooks:
|
||||||
|
return ctx.Derived.SPCConvectiveOutlooks != nil
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -245,7 +249,7 @@ func (d ModuleDefinition) ValidateOptions(options any) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func defaultModuleDefinitions() []ModuleDefinition {
|
func defaultModuleDefinitions() []ModuleDefinition {
|
||||||
allReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend, report.Storm}
|
allReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm}
|
||||||
daypartReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend}
|
daypartReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend}
|
||||||
return []ModuleDefinition{
|
return []ModuleDefinition{
|
||||||
{
|
{
|
||||||
@@ -282,7 +286,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
DefaultOptions: module.HourlyForecastOptions{},
|
DefaultOptions: module.HourlyForecastOptions{},
|
||||||
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
|
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
|
||||||
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow},
|
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow, report.Hourly},
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildHourlyForecastModule,
|
Builder: buildHourlyForecastModule,
|
||||||
},
|
},
|
||||||
@@ -308,7 +312,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
ID: module.PrecipTiming,
|
ID: module.PrecipTiming,
|
||||||
StanzaName: "precip_timing",
|
StanzaName: "precip_timing",
|
||||||
DefaultOptions: module.PrecipTimingOptions{},
|
DefaultOptions: module.PrecipTimingOptions{},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedPrecipTiming},
|
||||||
SupportedReports: allReports,
|
SupportedReports: allReports,
|
||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildPrecipTimingModule,
|
Builder: buildPrecipTimingModule,
|
||||||
@@ -323,6 +327,16 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildAlertDigestModule,
|
Builder: buildAlertDigestModule,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: module.SPCConvectiveOutlooks,
|
||||||
|
StanzaName: string(module.SPCConvectiveOutlooks),
|
||||||
|
DefaultOptions: module.SPCConvectiveOutlooksOptions{},
|
||||||
|
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
|
||||||
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
|
||||||
|
SupportedReports: allReports,
|
||||||
|
MissingData: module.MissingDataEmpty,
|
||||||
|
Builder: buildSPCConvectiveOutlooksModule,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ID: module.AreaForecastDiscussion,
|
ID: module.AreaForecastDiscussion,
|
||||||
StanzaName: "area_forecast_discussion",
|
StanzaName: "area_forecast_discussion",
|
||||||
@@ -332,6 +346,16 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildAreaForecastDiscussionModule,
|
Builder: buildAreaForecastDiscussionModule,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: module.SPCConvectiveDiscussion,
|
||||||
|
StanzaName: string(module.SPCConvectiveDiscussion),
|
||||||
|
DefaultOptions: module.SPCConvectiveDiscussionOptions{},
|
||||||
|
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
|
||||||
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
|
||||||
|
SupportedReports: allReports,
|
||||||
|
MissingData: module.MissingDataOmit,
|
||||||
|
Builder: buildSPCConvectiveDiscussionModule,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ID: module.WeatherStory,
|
ID: module.WeatherStory,
|
||||||
StanzaName: "weather_story",
|
StanzaName: "weather_story",
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) {
|
func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) {
|
||||||
@@ -52,6 +54,27 @@ func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func 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) {
|
func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.ID("unknown")}})
|
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.ID("unknown")}})
|
||||||
@@ -89,6 +112,24 @@ func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func 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) {
|
func TestModuleRegistryRejectsDefinitionsWithoutBuilders(t *testing.T) {
|
||||||
_, err := NewModuleRegistry([]ModuleDefinition{
|
_, err := NewModuleRegistry([]ModuleDefinition{
|
||||||
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}},
|
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}},
|
||||||
@@ -128,6 +169,30 @@ func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveOutlookCollectedRequirementAvailability(t *testing.T) {
|
||||||
|
ctx := ModuleContext{}
|
||||||
|
if collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
|
||||||
|
t.Fatal("collectedFactAvailable() = true, want false without source")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Collected = facts.CollectedFacts{SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{}}
|
||||||
|
if !collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
|
||||||
|
t.Fatal("collectedFactAvailable() = false, want true with checked source")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveOutlookDerivedRequirementAvailability(t *testing.T) {
|
||||||
|
ctx := ModuleContext{}
|
||||||
|
if derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
|
||||||
|
t.Fatal("derivedFactAvailable() = true, want false without derived outlooks")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Derived = facts.DerivedFacts{SPCConvectiveOutlooks: []weatherdata.ConvectiveOutlook{}}
|
||||||
|
if !derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
|
||||||
|
t.Fatal("derivedFactAvailable() = false, want true for checked empty derived outlooks")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func noopModuleBuilder(ModuleContext, any) (*module.Output, error) {
|
func noopModuleBuilder(ModuleContext, any) (*module.Output, error) {
|
||||||
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil
|
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,8 +19,8 @@ type NarrativeForecastModule struct {
|
|||||||
|
|
||||||
type NarrativeForecastPeriod struct {
|
type NarrativeForecastPeriod struct {
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
StartTime string `json:"start_time,omitempty"`
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
EndTime string `json:"end_time,omitempty"`
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
IsDay *bool `json:"is_day,omitempty"`
|
IsDay *bool `json:"is_day,omitempty"`
|
||||||
TextDescription string `json:"text_description,omitempty"`
|
TextDescription string `json:"text_description,omitempty"`
|
||||||
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||||
@@ -58,10 +59,11 @@ func buildNarrativeForecastModule(ctx ModuleContext, _ any) (*module.Output, err
|
|||||||
func narrativeForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []NarrativeForecastPeriod {
|
func narrativeForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []NarrativeForecastPeriod {
|
||||||
out := make([]NarrativeForecastPeriod, 0, len(periods))
|
out := make([]NarrativeForecastPeriod, 0, len(periods))
|
||||||
for _, period := range periods {
|
for _, period := range periods {
|
||||||
|
validPeriod := timeutil.Period{Start: period.StartTime, End: period.EndTime}
|
||||||
out = append(out, NarrativeForecastPeriod{
|
out = append(out, NarrativeForecastPeriod{
|
||||||
Name: period.Name,
|
Name: period.Name,
|
||||||
StartTime: friendlyDateTimeLabel(period.StartTime, timezone),
|
PeriodBegins: friendlyPeriodBeginsLabel(validPeriod, timezone),
|
||||||
EndTime: friendlyDateTimeLabel(period.EndTime, timezone),
|
PeriodEnds: friendlyPeriodEndsLabel(validPeriod, timezone),
|
||||||
IsDay: copyBool(period.IsDay),
|
IsDay: copyBool(period.IsDay),
|
||||||
TextDescription: period.TextDescription,
|
TextDescription: period.TextDescription,
|
||||||
TemperatureC: copyFloat(period.TemperatureC),
|
TemperatureC: copyFloat(period.TemperatureC),
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ type OutdoorWindowsModule struct {
|
|||||||
|
|
||||||
type OutdoorWindowModule struct {
|
type OutdoorWindowModule struct {
|
||||||
Daypart string `json:"daypart"`
|
Daypart string `json:"daypart"`
|
||||||
Start string `json:"start"`
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
End string `json:"end"`
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
Reasons []string `json:"reasons,omitempty"`
|
Reasons []string `json:"reasons,omitempty"`
|
||||||
Score float64 `json:"score"`
|
Score float64 `json:"score"`
|
||||||
}
|
}
|
||||||
@@ -18,20 +18,20 @@ type OutdoorWindowModule struct {
|
|||||||
func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries)
|
windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries)
|
||||||
value := OutdoorWindowsModule{
|
value := OutdoorWindowsModule{
|
||||||
Best: outdoorWindowValue(windows.Best),
|
Best: outdoorWindowValue(windows.Best, ctx.Timezone),
|
||||||
Worst: outdoorWindowValue(windows.Worst),
|
Worst: outdoorWindowValue(windows.Worst, ctx.Timezone),
|
||||||
}
|
}
|
||||||
return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil
|
return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func outdoorWindowValue(window *OutdoorWindow) *OutdoorWindowModule {
|
func outdoorWindowValue(window *OutdoorWindow, timezone string) *OutdoorWindowModule {
|
||||||
if window == nil {
|
if window == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &OutdoorWindowModule{
|
return &OutdoorWindowModule{
|
||||||
Daypart: window.Daypart,
|
Daypart: window.Daypart,
|
||||||
Start: window.Start,
|
PeriodBegins: friendlyPeriodBeginsLabel(window.Period, timezone),
|
||||||
End: window.End,
|
PeriodEnds: friendlyPeriodEndsLabel(window.Period, timezone),
|
||||||
Reasons: append([]string(nil), window.Reasons...),
|
Reasons: append([]string(nil), window.Reasons...),
|
||||||
Score: window.Score,
|
Score: window.Score,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ type PrecipTimingModule struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PrecipitationWindowModule struct {
|
type PrecipitationWindowModule struct {
|
||||||
Start string `json:"start"`
|
PeriodBegins string `json:"period_begins"`
|
||||||
End string `json:"end,omitempty"`
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -36,10 +36,10 @@ func precipTimingValue(timing forecast.PrecipTiming, timezone string) PrecipTimi
|
|||||||
}
|
}
|
||||||
for _, window := range timing.PrecipitationWindows {
|
for _, window := range timing.PrecipitationWindows {
|
||||||
item := PrecipitationWindowModule{
|
item := PrecipitationWindowModule{
|
||||||
Start: clockLabel(window.Start, timezone),
|
PeriodBegins: friendlyDateTimeLabel(window.Start, timezone),
|
||||||
}
|
}
|
||||||
if window.End != nil {
|
if window.End != nil {
|
||||||
item.End = clockLabel(*window.End, timezone)
|
item.PeriodEnds = friendlyDateTimeLabel(*window.End, timezone)
|
||||||
}
|
}
|
||||||
item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value)
|
item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value)
|
||||||
item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone)
|
item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone)
|
||||||
|
|||||||
94
internal/briefing/spc_convective_discussion_module.go
Normal file
94
internal/briefing/spc_convective_discussion_module.go
Normal 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
|
||||||
|
}
|
||||||
317
internal/briefing/spc_convective_discussion_module_test.go
Normal file
317
internal/briefing/spc_convective_discussion_module_test.go
Normal 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
|
||||||
|
}
|
||||||
92
internal/briefing/spc_convective_outlooks_module.go
Normal file
92
internal/briefing/spc_convective_outlooks_module.go
Normal 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
|
||||||
|
}
|
||||||
169
internal/briefing/spc_convective_outlooks_module_test.go
Normal file
169
internal/briefing/spc_convective_outlooks_module_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
type OutdoorWindows struct {
|
type OutdoorWindows struct {
|
||||||
@@ -16,8 +17,7 @@ type OutdoorWindows struct {
|
|||||||
|
|
||||||
type OutdoorWindow struct {
|
type OutdoorWindow struct {
|
||||||
Daypart string
|
Daypart string
|
||||||
Start string
|
Period timeutil.Period
|
||||||
End string
|
|
||||||
Reasons []string
|
Reasons []string
|
||||||
Score float64
|
Score float64
|
||||||
}
|
}
|
||||||
@@ -190,8 +190,7 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
|
|||||||
}
|
}
|
||||||
return OutdoorWindow{
|
return OutdoorWindow{
|
||||||
Daypart: daypart.Name,
|
Daypart: daypart.Name,
|
||||||
Start: daypart.Period.Start.Format("15:04"),
|
Period: daypart.Period,
|
||||||
End: daypart.Period.End.Format("15:04"),
|
|
||||||
Reasons: dedupe(reasons),
|
Reasons: dedupe(reasons),
|
||||||
Score: math.Round(score*10) / 10,
|
Score: math.Round(score*10) / 10,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WeatherStoryModule struct {
|
type WeatherStoryModule struct {
|
||||||
Available bool `json:"available"`
|
Available bool `json:"available"`
|
||||||
OfficeID string `json:"office_id,omitempty"`
|
OfficeID string `json:"office_id,omitempty"`
|
||||||
StartTime time.Time `json:"start_time"`
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
EndTime time.Time `json:"end_time"`
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
@@ -25,11 +26,12 @@ func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
|||||||
if story == nil {
|
if story == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
period := timeutil.Period{Start: story.StartTime, End: story.EndTime}
|
||||||
value := WeatherStoryModule{
|
value := WeatherStoryModule{
|
||||||
Available: true,
|
Available: true,
|
||||||
OfficeID: story.OfficeID,
|
OfficeID: story.OfficeID,
|
||||||
StartTime: story.StartTime,
|
PeriodBegins: friendlyPeriodBeginsLabel(period, ctx.Timezone),
|
||||||
EndTime: story.EndTime,
|
PeriodEnds: friendlyPeriodEndsLabel(period, ctx.Timezone),
|
||||||
UpdatedAt: copyTime(story.UpdatedAt),
|
UpdatedAt: copyTime(story.UpdatedAt),
|
||||||
Title: story.Title,
|
Title: story.Title,
|
||||||
Description: story.Description,
|
Description: story.Description,
|
||||||
|
|||||||
@@ -84,7 +84,8 @@ type dailySummaryStanza struct {
|
|||||||
|
|
||||||
type daypartSummaryStanza struct {
|
type daypartSummaryStanza struct {
|
||||||
Date string `json:"date,omitempty"`
|
Date string `json:"date,omitempty"`
|
||||||
Period string `json:"period,omitempty"`
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
TempRangeF string `json:"temp_range_f,omitempty"`
|
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||||
|
|||||||
@@ -96,7 +96,13 @@ func dailySnapshot(t *testing.T, low int, high int, precip int, precipTime strin
|
|||||||
DailyPrecipitationProbability: &precip,
|
DailyPrecipitationProbability: &precip,
|
||||||
}},
|
}},
|
||||||
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
|
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
|
||||||
"morning": {Date: "2026-05-29", Period: "2026-05-29 at 6:00 AM to 2026-05-29 at 10:00 AM", TempRangeF: "60-70", Snow: snow},
|
"morning": {
|
||||||
|
Date: "2026-05-29",
|
||||||
|
PeriodBegins: "2026-05-29 at 6:00 AM",
|
||||||
|
PeriodEnds: "2026-05-29 at 10:00 AM",
|
||||||
|
TempRangeF: "60-70",
|
||||||
|
Snow: snow,
|
||||||
|
},
|
||||||
}},
|
}},
|
||||||
module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: precipTimingStanza{MaxPopPercent: &precip, MaxPopTime: precipTime}},
|
module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: precipTimingStanza{MaxPopPercent: &precip, MaxPopTime: precipTime}},
|
||||||
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: alertDigestStanza{Relevant: relevant}},
|
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: alertDigestStanza{Relevant: relevant}},
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ func outlookSnapshot(t *testing.T, date string, tempRange string, precip int, pr
|
|||||||
return snapshot(t, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
|
return snapshot(t, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
|
||||||
date + "_morning": {
|
date + "_morning": {
|
||||||
Date: date,
|
Date: date,
|
||||||
Period: date + " at 6:00 AM to " + date + " at 10:00 AM",
|
PeriodBegins: date + " at 6:00 AM",
|
||||||
|
PeriodEnds: date + " at 10:00 AM",
|
||||||
TempRangeF: tempRange,
|
TempRangeF: tempRange,
|
||||||
MaxPopPercent: &precip,
|
MaxPopPercent: &precip,
|
||||||
MaxPopTime: precipTime,
|
MaxPopTime: precipTime,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ Usage:
|
|||||||
weatherreporter --help
|
weatherreporter --help
|
||||||
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
||||||
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
|
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
|
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
|
||||||
@@ -36,7 +37,7 @@ Options:
|
|||||||
--config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml.
|
--config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml.
|
||||||
--units VALUE Override weather API units.
|
--units VALUE Override weather API units.
|
||||||
--tz NAME Override weather API timezone.
|
--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.
|
--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
|
return app.ReportDaily, true
|
||||||
case string(app.ReportTomorrow):
|
case string(app.ReportTomorrow):
|
||||||
return app.ReportTomorrow, true
|
return app.ReportTomorrow, true
|
||||||
|
case string(app.ReportHourly):
|
||||||
|
return app.ReportHourly, true
|
||||||
case string(app.ReportThreeDay):
|
case string(app.ReportThreeDay):
|
||||||
return app.ReportThreeDay, true
|
return app.ReportThreeDay, true
|
||||||
case string(app.ReportWeekend):
|
case string(app.ReportWeekend):
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ func TestRunHelpLongFlag(t *testing.T) {
|
|||||||
if !strings.Contains(stdout.String(), "generate daily") {
|
if !strings.Contains(stdout.String(), "generate daily") {
|
||||||
t.Fatalf("help output missing generate command:\n%s", stdout.String())
|
t.Fatalf("help output missing generate command:\n%s", stdout.String())
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "weatherreporter generate 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"
|
removedInspectCommand := "inspect " + "briefing"
|
||||||
if !strings.Contains(stdout.String(), "inspect modules") || strings.Contains(stdout.String(), removedInspectCommand) {
|
if !strings.Contains(stdout.String(), "inspect modules") || strings.Contains(stdout.String(), removedInspectCommand) {
|
||||||
t.Fatalf("help output has wrong inspect commands:\n%s", stdout.String())
|
t.Fatalf("help output has wrong inspect commands:\n%s", stdout.String())
|
||||||
@@ -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) {
|
func TestRunInspectGeneratedArtifacts(t *testing.T) {
|
||||||
server := dailyServer(t)
|
server := dailyServer(t)
|
||||||
tempDir := t.TempDir()
|
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: "daily", args: []string{"daily", "--date", "2026-05-29"}, want: app.ReportDaily},
|
||||||
{name: "tomorrow", args: []string{"tomorrow"}, want: app.ReportTomorrow},
|
{name: "tomorrow", args: []string{"tomorrow"}, want: app.ReportTomorrow},
|
||||||
|
{name: "hourly", args: []string{"hourly"}, want: app.ReportHourly},
|
||||||
{name: "three-day", args: []string{"three-day"}, want: app.ReportThreeDay},
|
{name: "three-day", args: []string{"three-day"}, want: app.ReportThreeDay},
|
||||||
{name: "weekend", args: []string{"weekend"}, want: app.ReportWeekend},
|
{name: "weekend", args: []string{"weekend"}, want: app.ReportWeekend},
|
||||||
{name: "storm", args: []string{"storm", "--start", "2026-05-29T18:00", "--end", "2026-05-30T06:00"}, want: app.ReportStorm},
|
{name: "storm", args: []string{"storm", "--start", "2026-05-29T18:00", "--end", "2026-05-30T06:00"}, want: app.ReportStorm},
|
||||||
@@ -827,6 +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) {
|
func TestResolveGenerateDailyDefaultsDateInConfiguredTimezone(t *testing.T) {
|
||||||
runner := Runner{Clock: fixedClock()}
|
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) {
|
func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) {
|
||||||
runner := Runner{Clock: fixedClock()}
|
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."]}}`))
|
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}}`))
|
||||||
case "/weatherstories/latest":
|
case "/weatherstories/latest":
|
||||||
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
|
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
|
||||||
|
case "/outlooks/convective":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`))
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
@@ -1026,6 +1151,17 @@ func oneArtifact(t *testing.T, root string, parts ...string) string {
|
|||||||
return matches[0]
|
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 {
|
func writeFakeScriptorium(t *testing.T, dir string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
path := filepath.Join(dir, "scriptorium")
|
path := filepath.Join(dir, "scriptorium")
|
||||||
@@ -1056,6 +1192,52 @@ exit 1
|
|||||||
return path
|
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 {
|
func writeFailingScriptorium(t *testing.T, dir string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
path := filepath.Join(dir, "scriptorium")
|
path := filepath.Join(dir, "scriptorium")
|
||||||
|
|||||||
@@ -85,12 +85,28 @@ func TestLoadExampleConfig(t *testing.T) {
|
|||||||
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
|
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
|
||||||
t.Fatalf("Location = %#v, want example location", cfg.Location)
|
t.Fatalf("Location = %#v, want example location", cfg.Location)
|
||||||
}
|
}
|
||||||
if cfg.Notify.Distributor.PipelineIDTemplate != "weatherreporter.{artifact_group}" {
|
if cfg.Notify.Distributor.PipelineIDTemplate != "weatherreporter.{report_id}" {
|
||||||
t.Fatalf("PipelineIDTemplate = %q, want example pipeline template", cfg.Notify.Distributor.PipelineIDTemplate)
|
t.Fatalf("PipelineIDTemplate = %q, want example pipeline template", cfg.Notify.Distributor.PipelineIDTemplate)
|
||||||
}
|
}
|
||||||
if len(cfg.Notify.Distributor.ReportPathTemplates) != 1 {
|
if len(cfg.Notify.Distributor.ReportPathTemplates) != 1 {
|
||||||
t.Fatalf("ReportPathTemplates = %#v, want example archive path", cfg.Notify.Distributor.ReportPathTemplates)
|
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) {
|
func TestLoadMinimalExampleConfig(t *testing.T) {
|
||||||
@@ -126,10 +142,13 @@ reports:
|
|||||||
deterministic_modules:
|
deterministic_modules:
|
||||||
- metadata
|
- metadata
|
||||||
- current_conditions
|
- current_conditions
|
||||||
|
- alert_digest
|
||||||
|
- spc_convective_outlooks
|
||||||
- id: area_forecast_discussion
|
- id: area_forecast_discussion
|
||||||
options:
|
options:
|
||||||
sections:
|
sections:
|
||||||
- short_term
|
- short_term
|
||||||
|
- spc_convective_discussion
|
||||||
`)
|
`)
|
||||||
|
|
||||||
cfg, err := LoadFile(path)
|
cfg, err := LoadFile(path)
|
||||||
@@ -138,21 +157,54 @@ reports:
|
|||||||
}
|
}
|
||||||
overrides := cfg.ReportModuleOverrides()
|
overrides := cfg.ReportModuleOverrides()
|
||||||
items := overrides[report.DailyToday]
|
items := overrides[report.DailyToday]
|
||||||
if len(items) != 3 {
|
if len(items) != 6 {
|
||||||
t.Fatalf("daily override length = %d, want 3", len(items))
|
t.Fatalf("daily override length = %d, want 6", len(items))
|
||||||
}
|
}
|
||||||
if items[0].ID != module.Metadata || items[1].ID != module.CurrentConditions || items[2].ID != module.AreaForecastDiscussion {
|
if items[0].ID != module.Metadata ||
|
||||||
|
items[1].ID != module.CurrentConditions ||
|
||||||
|
items[2].ID != module.AlertDigest ||
|
||||||
|
items[3].ID != module.SPCConvectiveOutlooks ||
|
||||||
|
items[4].ID != module.AreaForecastDiscussion ||
|
||||||
|
items[5].ID != module.SPCConvectiveDiscussion {
|
||||||
t.Fatalf("daily override = %#v, want configured module order", items)
|
t.Fatalf("daily override = %#v, want configured module order", items)
|
||||||
}
|
}
|
||||||
options, ok := items[2].Options.(module.AreaForecastDiscussionOptions)
|
options, ok := items[4].Options.(module.AreaForecastDiscussionOptions)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", items[2].Options)
|
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", items[4].Options)
|
||||||
}
|
}
|
||||||
if strings.Join(options.Sections, ",") != "short_term" {
|
if strings.Join(options.Sections, ",") != "short_term" {
|
||||||
t.Fatalf("AFD sections = %#v, want short_term", options.Sections)
|
t.Fatalf("AFD sections = %#v, want short_term", options.Sections)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func 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) {
|
func TestReportModuleOverrideValidation(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -236,6 +288,16 @@ reports:
|
|||||||
`,
|
`,
|
||||||
wantErr: "duplicates report override",
|
wantErr: "duplicates report override",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "HourlyIncompatibleDailyModule",
|
||||||
|
yaml: `
|
||||||
|
reports:
|
||||||
|
hourly:
|
||||||
|
deterministic_modules:
|
||||||
|
- derived_daily_summary
|
||||||
|
`,
|
||||||
|
wantErr: `not compatible with report "hourly"`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "UnknownReportField",
|
name: "UnknownReportField",
|
||||||
yaml: `
|
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) {
|
func TestExplicitMissingConfigReturnsError(t *testing.T) {
|
||||||
_, err := LoadFile(filepath.Join(t.TempDir(), "missing.yml"))
|
_, err := LoadFile(filepath.Join(t.TempDir(), "missing.yml"))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -599,7 +683,7 @@ func TestLoadFileLoadsSecretsBeforeReturningNotifyConfig(t *testing.T) {
|
|||||||
"notify:\n" +
|
"notify:\n" +
|
||||||
" distributor:\n" +
|
" distributor:\n" +
|
||||||
" enabled: true\n" +
|
" enabled: true\n" +
|
||||||
" pipeline_id_template: weatherreporter.{artifact_group}\n"
|
" pipeline_id_template: weatherreporter.{report_id}\n"
|
||||||
if err := os.WriteFile(path, []byte(configYAML), 0o600); err != nil {
|
if err := os.WriteFile(path, []byte(configYAML), 0o600); err != nil {
|
||||||
t.Fatalf("write config fixture: %v", err)
|
t.Fatalf("write config fixture: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ func reportIDForConfigKey(key string) (report.ID, error) {
|
|||||||
return report.DailyToday, nil
|
return report.DailyToday, nil
|
||||||
case "tomorrow", "daily_tomorrow":
|
case "tomorrow", "daily_tomorrow":
|
||||||
return report.DailyTomorrow, nil
|
return report.DailyTomorrow, nil
|
||||||
|
case "hourly":
|
||||||
|
return report.Hourly, nil
|
||||||
case "three_day", "three_day_outlook":
|
case "three_day", "three_day_outlook":
|
||||||
return report.ThreeDay, nil
|
return report.ThreeDay, nil
|
||||||
case "weekend", "weekend_outlook":
|
case "weekend", "weekend_outlook":
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package facts
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
@@ -21,6 +22,7 @@ type CollectedFacts struct {
|
|||||||
Discussion *weatherdata.Discussion
|
Discussion *weatherdata.Discussion
|
||||||
Daily *weatherdata.ForecastRun
|
Daily *weatherdata.ForecastRun
|
||||||
WeatherStory *weatherdata.WeatherStory
|
WeatherStory *weatherdata.WeatherStory
|
||||||
|
SPCConvectiveOutlooks *weatherdata.ConvectiveOutlookRun
|
||||||
|
|
||||||
SourceProvenance []weatherdata.Source
|
SourceProvenance []weatherdata.Source
|
||||||
SourceWarnings []weatherdata.SourceWarning
|
SourceWarnings []weatherdata.SourceWarning
|
||||||
@@ -40,6 +42,7 @@ func BuildCollected(bundle *weatherdata.Bundle) CollectedFacts {
|
|||||||
Discussion: bundle.Discussion,
|
Discussion: bundle.Discussion,
|
||||||
Daily: bundle.Daily,
|
Daily: bundle.Daily,
|
||||||
WeatherStory: bundle.WeatherStory,
|
WeatherStory: bundle.WeatherStory,
|
||||||
|
SPCConvectiveOutlooks: bundle.SPCConvectiveOutlooks,
|
||||||
SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...),
|
SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...),
|
||||||
SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...),
|
SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...),
|
||||||
}
|
}
|
||||||
@@ -56,6 +59,7 @@ func (f CollectedFacts) Bundle() *weatherdata.Bundle {
|
|||||||
Discussion: f.Discussion,
|
Discussion: f.Discussion,
|
||||||
Daily: f.Daily,
|
Daily: f.Daily,
|
||||||
WeatherStory: f.WeatherStory,
|
WeatherStory: f.WeatherStory,
|
||||||
|
SPCConvectiveOutlooks: f.SPCConvectiveOutlooks,
|
||||||
Sources: append([]weatherdata.Source(nil), f.SourceProvenance...),
|
Sources: append([]weatherdata.Source(nil), f.SourceProvenance...),
|
||||||
Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...),
|
Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...),
|
||||||
}
|
}
|
||||||
@@ -73,6 +77,8 @@ type DerivedFacts struct {
|
|||||||
ValidPeriodNarrativePeriods []weatherdata.ForecastPeriod
|
ValidPeriodNarrativePeriods []weatherdata.ForecastPeriod
|
||||||
ValidPeriodDailyPeriods []weatherdata.ForecastPeriod
|
ValidPeriodDailyPeriods []weatherdata.ForecastPeriod
|
||||||
AlertOverlaps []forecast.AlertOverlap
|
AlertOverlaps []forecast.AlertOverlap
|
||||||
|
SPCConvectiveOutlooks []weatherdata.ConvectiveOutlook
|
||||||
|
SPCConvectiveDiscussions []weatherdata.ConvectiveOutlookDiscussion
|
||||||
DailySummaries []forecast.DailySummary
|
DailySummaries []forecast.DailySummary
|
||||||
DaypartSummaries []forecast.DaypartSummary
|
DaypartSummaries []forecast.DaypartSummary
|
||||||
PrecipTiming forecast.PrecipTiming
|
PrecipTiming forecast.PrecipTiming
|
||||||
@@ -96,15 +102,19 @@ func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) {
|
|||||||
}
|
}
|
||||||
bundle := req.Collected.Bundle()
|
bundle := req.Collected.Bundle()
|
||||||
period := req.Resolved.ValidPeriod
|
period := req.Resolved.ValidPeriod
|
||||||
|
spcOutlooks, spcDiscussions := selectSPCConvectiveOutlooks(req.Collected.SPCConvectiveOutlooks, period)
|
||||||
derived := DerivedFacts{
|
derived := DerivedFacts{
|
||||||
ValidPeriodHourlyPeriods: forecast.SelectHourlyPeriods(req.Collected.Hourly, period),
|
ValidPeriodHourlyPeriods: forecast.SelectHourlyPeriods(req.Collected.Hourly, period),
|
||||||
ValidPeriodNarrativePeriods: forecast.SelectHourlyPeriods(req.Collected.Narrative, period),
|
ValidPeriodNarrativePeriods: forecast.SelectHourlyPeriods(req.Collected.Narrative, period),
|
||||||
ValidPeriodDailyPeriods: forecast.SelectHourlyPeriods(req.Collected.Daily, period),
|
ValidPeriodDailyPeriods: forecast.SelectHourlyPeriods(req.Collected.Daily, period),
|
||||||
AlertOverlaps: forecast.AlertOverlaps(req.Collected.Alerts, period),
|
AlertOverlaps: forecast.AlertOverlaps(req.Collected.Alerts, period),
|
||||||
|
SPCConvectiveOutlooks: spcOutlooks,
|
||||||
|
SPCConvectiveDiscussions: spcDiscussions,
|
||||||
}
|
}
|
||||||
derived.PrecipTiming = forecast.BuildPrecipTiming(derived.ValidPeriodHourlyPeriods)
|
derived.PrecipTiming = forecast.BuildPrecipTiming(derived.ValidPeriodHourlyPeriods)
|
||||||
|
|
||||||
switch req.Resolved.Definition.ID {
|
switch req.Resolved.Definition.ID {
|
||||||
|
case report.Hourly:
|
||||||
case report.DailyToday, report.DailyTomorrow:
|
case report.DailyToday, report.DailyTomorrow:
|
||||||
summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts)
|
summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -139,3 +149,81 @@ func collectDaypartSummaries(derived DerivedFacts) []forecast.DaypartSummary {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func selectSPCConvectiveOutlooks(run *weatherdata.ConvectiveOutlookRun, period timeutil.Period) ([]weatherdata.ConvectiveOutlook, []weatherdata.ConvectiveOutlookDiscussion) {
|
||||||
|
if run == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
outlooks := make([]weatherdata.ConvectiveOutlook, 0, len(run.Outlooks))
|
||||||
|
days := map[int]struct{}{}
|
||||||
|
for _, outlook := range run.Outlooks {
|
||||||
|
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
|
||||||
|
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(period) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
outlooks = append(outlooks, outlook)
|
||||||
|
days[outlook.Day] = struct{}{}
|
||||||
|
}
|
||||||
|
sort.SliceStable(outlooks, func(i, j int) bool {
|
||||||
|
left := outlooks[i]
|
||||||
|
right := outlooks[j]
|
||||||
|
if left.Day != right.Day {
|
||||||
|
return left.Day < right.Day
|
||||||
|
}
|
||||||
|
if left.OutlookType != right.OutlookType {
|
||||||
|
return left.OutlookType < right.OutlookType
|
||||||
|
}
|
||||||
|
leftRank, leftRankOK := severityRank(left)
|
||||||
|
rightRank, rightRankOK := severityRank(right)
|
||||||
|
if leftRankOK != rightRankOK {
|
||||||
|
return leftRankOK
|
||||||
|
}
|
||||||
|
if leftRankOK && leftRank != rightRank {
|
||||||
|
return leftRank > rightRank
|
||||||
|
}
|
||||||
|
if !left.ValidFrom.Equal(right.ValidFrom) {
|
||||||
|
return left.ValidFrom.Before(right.ValidFrom)
|
||||||
|
}
|
||||||
|
if left.Label != right.Label {
|
||||||
|
return left.Label < right.Label
|
||||||
|
}
|
||||||
|
return left.ID < right.ID
|
||||||
|
})
|
||||||
|
|
||||||
|
discussions := make([]weatherdata.ConvectiveOutlookDiscussion, 0, len(run.Discussions))
|
||||||
|
for _, discussion := range run.Discussions {
|
||||||
|
if _, ok := days[discussion.Day]; ok {
|
||||||
|
discussions = append(discussions, discussion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(discussions, func(i, j int) bool {
|
||||||
|
left := discussions[i]
|
||||||
|
right := discussions[j]
|
||||||
|
if left.Day != right.Day {
|
||||||
|
return left.Day < right.Day
|
||||||
|
}
|
||||||
|
if left.UpdatedAt != nil && right.UpdatedAt != nil && !left.UpdatedAt.Equal(*right.UpdatedAt) {
|
||||||
|
return left.UpdatedAt.Before(*right.UpdatedAt)
|
||||||
|
}
|
||||||
|
if (left.UpdatedAt != nil) != (right.UpdatedAt != nil) {
|
||||||
|
return left.UpdatedAt != nil
|
||||||
|
}
|
||||||
|
if left.Headline != right.Headline {
|
||||||
|
return left.Headline < right.Headline
|
||||||
|
}
|
||||||
|
if left.Summary != right.Summary {
|
||||||
|
return left.Summary < right.Summary
|
||||||
|
}
|
||||||
|
return left.Discussion < right.Discussion
|
||||||
|
})
|
||||||
|
|
||||||
|
return outlooks, discussions
|
||||||
|
}
|
||||||
|
|
||||||
|
func severityRank(outlook weatherdata.ConvectiveOutlook) (int, bool) {
|
||||||
|
if outlook.SeverityRank == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return *outlook.SeverityRank, true
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package facts
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,6 +17,13 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
|
|||||||
FetchedAt: fetchedAt,
|
FetchedAt: fetchedAt,
|
||||||
Current: &weatherdata.Current{ConditionText: "Clear"},
|
Current: &weatherdata.Current{ConditionText: "Clear"},
|
||||||
Hourly: &weatherdata.ForecastRun{Product: "hourly"},
|
Hourly: &weatherdata.ForecastRun{Product: "hourly"},
|
||||||
|
SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{
|
||||||
|
Product: "convective_outlook",
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{{
|
||||||
|
ID: "day1-categorical-slight",
|
||||||
|
Label: "SLGT",
|
||||||
|
}},
|
||||||
|
},
|
||||||
Sources: []weatherdata.Source{{Name: "hourly"}},
|
Sources: []weatherdata.Source{{Name: "hourly"}},
|
||||||
Warnings: []weatherdata.SourceWarning{{Source: "discussion", Code: "missing_source"}},
|
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" {
|
if collected.FetchedAt != fetchedAt || collected.Current.ConditionText != "Clear" || collected.Hourly.Product != "hourly" {
|
||||||
t.Fatalf("CollectedFacts = %#v, want source facts copied from bundle", collected)
|
t.Fatalf("CollectedFacts = %#v, want source facts copied from bundle", collected)
|
||||||
}
|
}
|
||||||
|
if collected.SPCConvectiveOutlooks == nil || collected.SPCConvectiveOutlooks.Outlooks[0].Label != "SLGT" {
|
||||||
|
t.Fatalf("SPCConvectiveOutlooks = %#v, want source copied from bundle", collected.SPCConvectiveOutlooks)
|
||||||
|
}
|
||||||
if len(collected.SourceProvenance) != 1 || collected.SourceProvenance[0].Name != "hourly" {
|
if len(collected.SourceProvenance) != 1 || collected.SourceProvenance[0].Name != "hourly" {
|
||||||
t.Fatalf("SourceProvenance = %#v, want hourly source", collected.SourceProvenance)
|
t.Fatalf("SourceProvenance = %#v, want hourly source", collected.SourceProvenance)
|
||||||
}
|
}
|
||||||
@@ -36,6 +47,11 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
|
|||||||
if collected.SourceProvenance[0].Name != "hourly" || collected.SourceWarnings[0].Source != "discussion" {
|
if collected.SourceProvenance[0].Name != "hourly" || collected.SourceWarnings[0].Source != "discussion" {
|
||||||
t.Fatalf("collected source slices changed after bundle mutation: %#v %#v", collected.SourceProvenance, collected.SourceWarnings)
|
t.Fatalf("collected source slices changed after bundle mutation: %#v %#v", collected.SourceProvenance, collected.SourceWarnings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
roundTrip := collected.Bundle()
|
||||||
|
if roundTrip.SPCConvectiveOutlooks == nil || roundTrip.SPCConvectiveOutlooks.Outlooks[0].ID != "day1-categorical-slight" {
|
||||||
|
t.Fatalf("Bundle().SPCConvectiveOutlooks = %#v, want collected source restored", roundTrip.SPCConvectiveOutlooks)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) {
|
func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) {
|
||||||
@@ -136,6 +152,63 @@ func TestBuildDerivedWeekendAndTomorrow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func 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) {
|
func TestBuildDerivedStormBuildsWindowSummary(t *testing.T) {
|
||||||
location := testLocation()
|
location := testLocation()
|
||||||
resolved := resolveStormForTest(t, location)
|
resolved := resolveStormForTest(t, location)
|
||||||
@@ -166,6 +239,124 @@ func TestBuildDerivedStormBuildsWindowSummary(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildDerivedSelectsSPCConvectiveOutlooksByValidPeriod(t *testing.T) {
|
||||||
|
location := testLocation()
|
||||||
|
now := mustParse("2026-05-29T08:00:00-05:00")
|
||||||
|
bundle := testBundle(location)
|
||||||
|
bundle.SPCConvectiveOutlooks = testConvectiveOutlookRun(location)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
resolved report.Resolved
|
||||||
|
wantOutlookIDs []string
|
||||||
|
wantDiscussion []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "daily today",
|
||||||
|
resolved: resolveForTest(t, report.DailyToday, now, location),
|
||||||
|
wantOutlookIDs: []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic"},
|
||||||
|
wantDiscussion: []string{"day1 early", "day1 late"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "daily tomorrow",
|
||||||
|
resolved: resolveForTest(t, report.DailyTomorrow, now, location),
|
||||||
|
wantOutlookIDs: []string{"sat-enhanced"},
|
||||||
|
wantDiscussion: []string{"day2"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "three day",
|
||||||
|
resolved: resolveForTest(t, report.ThreeDay, now, location),
|
||||||
|
wantOutlookIDs: []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic", "sat-enhanced", "sun-slight"},
|
||||||
|
wantDiscussion: []string{"day1 early", "day1 late", "day2", "day3"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "weekend",
|
||||||
|
resolved: resolveForTest(t, report.Weekend, now, location),
|
||||||
|
wantOutlookIDs: []string{"sat-enhanced", "sun-slight"},
|
||||||
|
wantDiscussion: []string{"day2", "day3"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "storm",
|
||||||
|
resolved: resolveStormForTest(t, location),
|
||||||
|
wantOutlookIDs: []string{"fri-storm", "fri-low"},
|
||||||
|
wantDiscussion: []string{"day1 early", "day1 late"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
derived, err := BuildDerived(BuildDerivedRequest{
|
||||||
|
Resolved: tt.resolved,
|
||||||
|
Timezone: location.String(),
|
||||||
|
Dayparts: testDayparts(),
|
||||||
|
Collected: BuildCollected(bundle),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildDerived() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := outlookIDs(derived.SPCConvectiveOutlooks); strings.Join(got, ",") != strings.Join(tt.wantOutlookIDs, ",") {
|
||||||
|
t.Fatalf("SPCConvectiveOutlooks IDs = %#v, want %#v", got, tt.wantOutlookIDs)
|
||||||
|
}
|
||||||
|
if got := discussionHeadlines(derived.SPCConvectiveDiscussions); strings.Join(got, ",") != strings.Join(tt.wantDiscussion, ",") {
|
||||||
|
t.Fatalf("SPCConvectiveDiscussions = %#v, want %#v", got, tt.wantDiscussion)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDerivedUnsupportedReportReturnsActionableError(t *testing.T) {
|
||||||
|
location := testLocation()
|
||||||
|
resolved := resolveForTest(t, report.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 {
|
func testBundle(location *time.Location) *weatherdata.Bundle {
|
||||||
return &weatherdata.Bundle{
|
return &weatherdata.Bundle{
|
||||||
FetchedAt: mustParse("2026-05-29T10:00:00Z"),
|
FetchedAt: mustParse("2026-05-29T10:00:00Z"),
|
||||||
@@ -193,6 +384,65 @@ func testBundle(location *time.Location) *weatherdata.Bundle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testConvectiveOutlookRun(location *time.Location) *weatherdata.ConvectiveOutlookRun {
|
||||||
|
rank1 := 1
|
||||||
|
rank2 := 2
|
||||||
|
rank3 := 3
|
||||||
|
rank4 := 4
|
||||||
|
rank5 := 5
|
||||||
|
return &weatherdata.ConvectiveOutlookRun{
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{
|
||||||
|
convectiveOutlook(location, "mon-outside", 4, "categorical", "MDT", &rank5, "2026-06-01T00:00:00-05:00", "2026-06-02T00:00:00-05:00"),
|
||||||
|
convectiveOutlook(location, "sun-slight", 3, "categorical", "SLGT", &rank3, "2026-05-31T06:00:00-05:00", "2026-06-01T00:00:00-05:00"),
|
||||||
|
convectiveOutlook(location, "fri-low", 1, "categorical", "MRGL", &rank1, "2026-05-29T06:00:00-05:00", "2026-05-29T12:00:00-05:00"),
|
||||||
|
convectiveOutlook(location, "fri-missing-rank", 1, "categorical", "GEN", nil, "2026-05-29T08:00:00-05:00", "2026-05-29T10:00:00-05:00"),
|
||||||
|
convectiveOutlook(location, "sat-enhanced", 2, "categorical", "ENH", &rank4, "2026-05-30T01:00:00-05:00", "2026-05-30T12:00:00-05:00"),
|
||||||
|
convectiveOutlook(location, "fri-high", 1, "categorical", "SLGT", &rank3, "2026-05-29T07:00:00-05:00", "2026-05-29T11:00:00-05:00"),
|
||||||
|
convectiveOutlook(location, "fri-probabilistic", 1, "probabilistic", "5%", &rank2, "2026-05-29T05:00:00-05:00", "2026-05-29T10:00:00-05:00"),
|
||||||
|
convectiveOutlook(location, "fri-storm", 1, "categorical", "SLGT", &rank2, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00"),
|
||||||
|
},
|
||||||
|
Discussions: []weatherdata.ConvectiveOutlookDiscussion{
|
||||||
|
{Day: 4, Headline: "day4", UpdatedAt: ptrTime(mustParse("2026-05-31T10:00:00-05:00"))},
|
||||||
|
{Day: 1, Headline: "day1 late", UpdatedAt: ptrTime(mustParse("2026-05-29T09:00:00-05:00"))},
|
||||||
|
{Day: 3, Headline: "day3", UpdatedAt: ptrTime(mustParse("2026-05-31T08:00:00-05:00"))},
|
||||||
|
{Day: 1, Headline: "day1 early", UpdatedAt: ptrTime(mustParse("2026-05-29T08:00:00-05:00"))},
|
||||||
|
{Day: 2, Headline: "day2", UpdatedAt: ptrTime(mustParse("2026-05-30T08:00:00-05:00"))},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func convectiveOutlook(location *time.Location, id string, day int, outlookType string, label string, rank *int, validFrom string, validTo string) weatherdata.ConvectiveOutlook {
|
||||||
|
return weatherdata.ConvectiveOutlook{
|
||||||
|
ID: id,
|
||||||
|
Day: day,
|
||||||
|
OutlookType: outlookType,
|
||||||
|
Label: label,
|
||||||
|
SeverityRank: rank,
|
||||||
|
ValidFrom: mustParse(validFrom).In(location),
|
||||||
|
ValidTo: mustParse(validTo).In(location),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func outlookIDs(outlooks []weatherdata.ConvectiveOutlook) []string {
|
||||||
|
out := make([]string, 0, len(outlooks))
|
||||||
|
for _, outlook := range outlooks {
|
||||||
|
out = append(out, outlook.ID)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func discussionHeadlines(discussions []weatherdata.ConvectiveOutlookDiscussion) []string {
|
||||||
|
out := make([]string, 0, len(discussions))
|
||||||
|
for _, discussion := range discussions {
|
||||||
|
out = append(out, discussion.Headline)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptrTime(value time.Time) *time.Time {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
func hour(location *time.Location, start string, end string, text string, precip float64, gust float64) weatherdata.ForecastPeriod {
|
func hour(location *time.Location, start string, end string, text string, precip float64, gust float64) weatherdata.ForecastPeriod {
|
||||||
temperature := 70.0
|
temperature := 70.0
|
||||||
return weatherdata.ForecastPeriod{
|
return weatherdata.ForecastPeriod{
|
||||||
|
|||||||
55
internal/generatedtext/hourly.go
Normal file
55
internal/generatedtext/hourly.go
Normal 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
|
||||||
|
}
|
||||||
86
internal/generatedtext/hourly_test.go
Normal file
86
internal/generatedtext/hourly_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
360
internal/generatedtext/render_context.go
Normal file
360
internal/generatedtext/render_context.go
Normal 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 ""
|
||||||
|
}
|
||||||
249
internal/generatedtext/render_context_test.go
Normal file
249
internal/generatedtext/render_context_test.go
Normal 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
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ const (
|
|||||||
AlertDigest ID = "alert_digest"
|
AlertDigest ID = "alert_digest"
|
||||||
AreaForecastDiscussion ID = "area_forecast_discussion"
|
AreaForecastDiscussion ID = "area_forecast_discussion"
|
||||||
WeatherStory ID = "weather_story"
|
WeatherStory ID = "weather_story"
|
||||||
|
SPCConvectiveOutlooks ID = "spc_convective_outlooks"
|
||||||
|
SPCConvectiveDiscussion ID = "spc_convective_discussion"
|
||||||
OutdoorWindows ID = "outdoor_windows"
|
OutdoorWindows ID = "outdoor_windows"
|
||||||
TomorrowPlanning ID = "tomorrow_planning"
|
TomorrowPlanning ID = "tomorrow_planning"
|
||||||
)
|
)
|
||||||
@@ -111,6 +113,7 @@ const (
|
|||||||
CollectedAlerts FactRequirement = "collected.alerts"
|
CollectedAlerts FactRequirement = "collected.alerts"
|
||||||
CollectedDiscussion FactRequirement = "collected.discussion"
|
CollectedDiscussion FactRequirement = "collected.discussion"
|
||||||
CollectedWeatherStory FactRequirement = "collected.weather_story"
|
CollectedWeatherStory FactRequirement = "collected.weather_story"
|
||||||
|
CollectedSPCConvectiveOutlooks FactRequirement = "collected.spc_convective_outlooks"
|
||||||
CollectedSourceMetadata FactRequirement = "collected.source_metadata"
|
CollectedSourceMetadata FactRequirement = "collected.source_metadata"
|
||||||
RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods"
|
RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods"
|
||||||
RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods"
|
RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods"
|
||||||
@@ -118,6 +121,7 @@ const (
|
|||||||
RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries"
|
RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries"
|
||||||
RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries"
|
RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries"
|
||||||
RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing"
|
RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing"
|
||||||
|
RequiresDerivedSPCConvectiveOutlooks FactRequirement = "derived.spc_convective_outlooks"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MissingDataBehavior string
|
type MissingDataBehavior string
|
||||||
@@ -141,5 +145,7 @@ type AreaForecastDiscussionOptions struct {
|
|||||||
Sections []string `json:"sections,omitempty" yaml:"sections,omitempty"`
|
Sections []string `json:"sections,omitempty" yaml:"sections,omitempty"`
|
||||||
}
|
}
|
||||||
type WeatherStoryOptions struct{}
|
type WeatherStoryOptions struct{}
|
||||||
|
type SPCConvectiveOutlooksOptions struct{}
|
||||||
|
type SPCConvectiveDiscussionOptions struct{}
|
||||||
type OutdoorWindowsOptions struct{}
|
type OutdoorWindowsOptions struct{}
|
||||||
type TomorrowPlanningOptions struct{}
|
type TomorrowPlanningOptions struct{}
|
||||||
|
|||||||
@@ -82,3 +82,21 @@ func TestStanzaValueDecodesTypedOutput(t *testing.T) {
|
|||||||
t.Fatal("StanzaValue(missing) found = true, want false")
|
t.Fatal("StanzaValue(missing) found = true, want false")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveModuleContractsAreStable(t *testing.T) {
|
||||||
|
if SPCConvectiveOutlooks != ID("spc_convective_outlooks") {
|
||||||
|
t.Fatalf("SPCConvectiveOutlooks = %q, want stable source/module ID", SPCConvectiveOutlooks)
|
||||||
|
}
|
||||||
|
if SPCConvectiveDiscussion != ID("spc_convective_discussion") {
|
||||||
|
t.Fatalf("SPCConvectiveDiscussion = %q, want stable discussion module ID", SPCConvectiveDiscussion)
|
||||||
|
}
|
||||||
|
if CollectedSPCConvectiveOutlooks != FactRequirement("collected.spc_convective_outlooks") {
|
||||||
|
t.Fatalf("CollectedSPCConvectiveOutlooks = %q, want collected requirement", CollectedSPCConvectiveOutlooks)
|
||||||
|
}
|
||||||
|
if RequiresDerivedSPCConvectiveOutlooks != FactRequirement("derived.spc_convective_outlooks") {
|
||||||
|
t.Fatalf("RequiresDerivedSPCConvectiveOutlooks = %q, want derived requirement", RequiresDerivedSPCConvectiveOutlooks)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = SPCConvectiveOutlooksOptions{}
|
||||||
|
_ = SPCConvectiveDiscussionOptions{}
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ var briefingCategoryOrder = []string{
|
|||||||
|
|
||||||
var briefingStanzaCategories = map[string]string{
|
var briefingStanzaCategories = map[string]string{
|
||||||
string(module.AlertDigest): categoryApplicableRiskProducts,
|
string(module.AlertDigest): categoryApplicableRiskProducts,
|
||||||
|
string(module.SPCConvectiveOutlooks): categoryApplicableRiskProducts,
|
||||||
string(module.DerivedDailySummary): categoryDerivedSummaries,
|
string(module.DerivedDailySummary): categoryDerivedSummaries,
|
||||||
string(module.DerivedDaypartSummaries): categoryDerivedSummaries,
|
string(module.DerivedDaypartSummaries): categoryDerivedSummaries,
|
||||||
string(module.PrecipTiming): categoryDerivedSummaries,
|
string(module.PrecipTiming): categoryDerivedSummaries,
|
||||||
@@ -42,6 +43,7 @@ var briefingStanzaCategories = map[string]string{
|
|||||||
string(module.TomorrowPlanning): categoryDerivedSummaries,
|
string(module.TomorrowPlanning): categoryDerivedSummaries,
|
||||||
string(module.NarrativeForecast): categoryNarrativeProducts,
|
string(module.NarrativeForecast): categoryNarrativeProducts,
|
||||||
string(module.AreaForecastDiscussion): categoryNarrativeProducts,
|
string(module.AreaForecastDiscussion): categoryNarrativeProducts,
|
||||||
|
string(module.SPCConvectiveDiscussion): categoryNarrativeProducts,
|
||||||
string(module.WeatherStory): categoryNarrativeProducts,
|
string(module.WeatherStory): categoryNarrativeProducts,
|
||||||
string(module.CurrentConditions): categoryRawData,
|
string(module.CurrentConditions): categoryRawData,
|
||||||
string(module.HourlyForecast): categoryRawData,
|
string(module.HourlyForecast): categoryRawData,
|
||||||
|
|||||||
@@ -164,6 +164,50 @@ func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMarshalYAMLPlacesSPCConvectiveStanzasInPromptCategories(t *testing.T) {
|
||||||
|
req := validBuildRequest(t)
|
||||||
|
req.Modules = snapshotWithOutputs(t,
|
||||||
|
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": req.Metadata.RunID}},
|
||||||
|
module.Output{ID: module.SPCConvectiveDiscussion, StanzaName: string(module.SPCConvectiveDiscussion), Value: map[string]any{"discussions": []string{"day1"}}},
|
||||||
|
module.Output{ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), Value: map[string]any{"outlook_count": 1}},
|
||||||
|
)
|
||||||
|
|
||||||
|
pkg, err := Build(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Build() error = %v", err)
|
||||||
|
}
|
||||||
|
data, err := MarshalYAML(pkg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarshalYAML() error = %v", err)
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
|
||||||
|
riskIndex := strings.Index(text, " applicable_risk_products:\n")
|
||||||
|
outlookIndex := strings.Index(text, " spc_convective_outlooks:\n")
|
||||||
|
narrativeIndex := strings.Index(text, " narrative_products:\n")
|
||||||
|
discussionIndex := strings.Index(text, " spc_convective_discussion:\n")
|
||||||
|
if riskIndex < 0 || outlookIndex < 0 || narrativeIndex < 0 || discussionIndex < 0 {
|
||||||
|
t.Fatalf("YAML output missing SPC convective category placement:\n%s", text)
|
||||||
|
}
|
||||||
|
if !(riskIndex < outlookIndex && outlookIndex < narrativeIndex && narrativeIndex < discussionIndex) {
|
||||||
|
t.Fatalf("YAML output placed SPC convective stanzas in wrong order:\n%s", text)
|
||||||
|
}
|
||||||
|
if strings.Contains(text, " raw_data:\n spc_convective") || strings.Contains(text, " derived_summaries:\n spc_convective") {
|
||||||
|
t.Fatalf("YAML output placed SPC convective stanzas in wrong category:\n%s", text)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := LoadYAML(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadYAML() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := loaded.Briefing.Values[string(module.SPCConvectiveOutlooks)]; !ok {
|
||||||
|
t.Fatal("loaded package missing spc_convective_outlooks stanza")
|
||||||
|
}
|
||||||
|
if _, ok := loaded.Briefing.Values[string(module.SPCConvectiveDiscussion)]; !ok {
|
||||||
|
t.Fatal("loaded package missing spc_convective_discussion stanza")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadYAMLRoundTrip(t *testing.T) {
|
func TestLoadYAMLRoundTrip(t *testing.T) {
|
||||||
pkg, err := Build(validBuildRequest(t))
|
pkg, err := Build(validBuildRequest(t))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ func dailyTodayDefinition() Definition {
|
|||||||
ID: DailyToday,
|
ID: DailyToday,
|
||||||
Name: "Daily Report",
|
Name: "Daily Report",
|
||||||
PromptID: "weather.daily_report",
|
PromptID: "weather.daily_report",
|
||||||
|
GenerationMode: GenerationModeScriptoriumMarkdown,
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
ArtifactGroup: "daily",
|
ArtifactGroup: "daily",
|
||||||
BatchOutputName: "daily.md",
|
BatchOutputName: "daily.md",
|
||||||
@@ -26,6 +27,7 @@ func dailyTomorrowDefinition() Definition {
|
|||||||
ID: DailyTomorrow,
|
ID: DailyTomorrow,
|
||||||
Name: "Tomorrow Planning Brief",
|
Name: "Tomorrow Planning Brief",
|
||||||
PromptID: "weather.daily_report",
|
PromptID: "weather.daily_report",
|
||||||
|
GenerationMode: GenerationModeScriptoriumMarkdown,
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
ArtifactGroup: "daily",
|
ArtifactGroup: "daily",
|
||||||
BatchOutputName: "tomorrow.md",
|
BatchOutputName: "tomorrow.md",
|
||||||
@@ -46,7 +48,9 @@ func dailyTodayModules() []module.ConfigItem {
|
|||||||
module.DerivedDaypartSummaries,
|
module.DerivedDaypartSummaries,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
module.HourlyForecast,
|
module.HourlyForecast,
|
||||||
@@ -62,7 +66,9 @@ func dailyTomorrowModules() []module.ConfigItem {
|
|||||||
module.DerivedDaypartSummaries,
|
module.DerivedDaypartSummaries,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
module.TomorrowPlanning,
|
module.TomorrowPlanning,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type ID string
|
|||||||
const (
|
const (
|
||||||
DailyToday ID = "daily_today"
|
DailyToday ID = "daily_today"
|
||||||
DailyTomorrow ID = "daily_tomorrow"
|
DailyTomorrow ID = "daily_tomorrow"
|
||||||
|
Hourly ID = "hourly"
|
||||||
ThreeDay ID = "three_day"
|
ThreeDay ID = "three_day"
|
||||||
Weekend ID = "weekend"
|
Weekend ID = "weekend"
|
||||||
Storm ID = "storm"
|
Storm ID = "storm"
|
||||||
@@ -25,6 +26,14 @@ const (
|
|||||||
CompareSameValidDate ComparisonStrategy = "same_valid_date"
|
CompareSameValidDate ComparisonStrategy = "same_valid_date"
|
||||||
CompareWeekendWindow ComparisonStrategy = "same_weekend_window"
|
CompareWeekendWindow ComparisonStrategy = "same_weekend_window"
|
||||||
CompareExplicitWindow ComparisonStrategy = "explicit_event_window"
|
CompareExplicitWindow ComparisonStrategy = "explicit_event_window"
|
||||||
|
CompareRollingWindow ComparisonStrategy = "rolling_window"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GenerationMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
GenerationModeScriptoriumMarkdown GenerationMode = "scriptorium_markdown"
|
||||||
|
GenerationModeGeneratedTextTemplate GenerationMode = "generated_text_template"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Batch string
|
type Batch string
|
||||||
@@ -38,6 +47,9 @@ type Definition struct {
|
|||||||
ID ID
|
ID ID
|
||||||
Name string
|
Name string
|
||||||
PromptID string
|
PromptID string
|
||||||
|
GenerationMode GenerationMode
|
||||||
|
TemplateID string
|
||||||
|
GeneratedTextSchemaID string
|
||||||
ComparisonStrategy ComparisonStrategy
|
ComparisonStrategy ComparisonStrategy
|
||||||
ArtifactGroup string
|
ArtifactGroup string
|
||||||
BatchOutputName string
|
BatchOutputName string
|
||||||
|
|||||||
55
internal/report/hourly_report.go
Normal file
55
internal/report/hourly_report.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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")
|
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) {
|
func TestWeekendPeriodCalculation(t *testing.T) {
|
||||||
location := mustLoadLocation(t)
|
location := mustLoadLocation(t)
|
||||||
tests := []struct {
|
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) {
|
func TestRegistryLookupErrorIsActionable(t *testing.T) {
|
||||||
_, err := DefaultRegistry().Lookup(ID("unknown"))
|
_, err := DefaultRegistry().Lookup(ID("unknown"))
|
||||||
if err == nil {
|
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) {
|
func TestRegistryDefinitionsHavePromptIDsAndComparisonStrategies(t *testing.T) {
|
||||||
for _, definition := range DefaultRegistry().All() {
|
for _, definition := range DefaultRegistry().All() {
|
||||||
if definition.PromptID == "" {
|
if definition.PromptID == "" {
|
||||||
t.Fatalf("%s PromptID is empty", definition.ID)
|
t.Fatalf("%s PromptID is empty", definition.ID)
|
||||||
}
|
}
|
||||||
|
if definition.Generated && definition.GenerationMode == "" {
|
||||||
|
t.Fatalf("%s GenerationMode is empty", definition.ID)
|
||||||
|
}
|
||||||
if definition.ComparisonStrategy == "" {
|
if definition.ComparisonStrategy == "" {
|
||||||
t.Fatalf("%s ComparisonStrategy is empty", definition.ID)
|
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) {
|
func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
id ID
|
id ID
|
||||||
@@ -186,6 +299,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
|||||||
batchOutputName string
|
batchOutputName string
|
||||||
generated bool
|
generated bool
|
||||||
compatiblePriorIDs []ID
|
compatiblePriorIDs []ID
|
||||||
|
comparisonStrategy ComparisonStrategy
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
id: DailyToday,
|
id: DailyToday,
|
||||||
@@ -193,6 +307,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
|||||||
batchOutputName: "daily.md",
|
batchOutputName: "daily.md",
|
||||||
generated: true,
|
generated: true,
|
||||||
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
||||||
|
comparisonStrategy: CompareSameValidDate,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: DailyTomorrow,
|
id: DailyTomorrow,
|
||||||
@@ -200,6 +315,15 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
|||||||
batchOutputName: "tomorrow.md",
|
batchOutputName: "tomorrow.md",
|
||||||
generated: true,
|
generated: true,
|
||||||
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
||||||
|
comparisonStrategy: CompareSameValidDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: Hourly,
|
||||||
|
artifactGroup: "hourly",
|
||||||
|
batchOutputName: "hourly.md",
|
||||||
|
generated: true,
|
||||||
|
compatiblePriorIDs: []ID{Hourly},
|
||||||
|
comparisonStrategy: CompareRollingWindow,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: ThreeDay,
|
id: ThreeDay,
|
||||||
@@ -207,6 +331,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
|||||||
batchOutputName: "three-day.md",
|
batchOutputName: "three-day.md",
|
||||||
generated: true,
|
generated: true,
|
||||||
compatiblePriorIDs: []ID{ThreeDay},
|
compatiblePriorIDs: []ID{ThreeDay},
|
||||||
|
comparisonStrategy: CompareSameValidDate,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: Weekend,
|
id: Weekend,
|
||||||
@@ -214,6 +339,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
|||||||
batchOutputName: "weekend.md",
|
batchOutputName: "weekend.md",
|
||||||
generated: true,
|
generated: true,
|
||||||
compatiblePriorIDs: []ID{Weekend},
|
compatiblePriorIDs: []ID{Weekend},
|
||||||
|
comparisonStrategy: CompareWeekendWindow,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: Storm,
|
id: Storm,
|
||||||
@@ -221,6 +347,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
|||||||
batchOutputName: "storm.md",
|
batchOutputName: "storm.md",
|
||||||
generated: true,
|
generated: true,
|
||||||
compatiblePriorIDs: []ID{Storm},
|
compatiblePriorIDs: []ID{Storm},
|
||||||
|
comparisonStrategy: CompareExplicitWindow,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,6 +367,9 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
|||||||
if definition.Generated != tt.generated {
|
if definition.Generated != tt.generated {
|
||||||
t.Fatalf("Generated = %t, want %t", definition.Generated, tt.generated)
|
t.Fatalf("Generated = %t, want %t", definition.Generated, tt.generated)
|
||||||
}
|
}
|
||||||
|
if definition.ComparisonStrategy != tt.comparisonStrategy {
|
||||||
|
t.Fatalf("ComparisonStrategy = %q, want %q", definition.ComparisonStrategy, tt.comparisonStrategy)
|
||||||
|
}
|
||||||
if !reflect.DeepEqual(definition.CompatiblePriorIDs, tt.compatiblePriorIDs) {
|
if !reflect.DeepEqual(definition.CompatiblePriorIDs, tt.compatiblePriorIDs) {
|
||||||
t.Fatalf("CompatiblePriorIDs = %#v, want %#v", definition.CompatiblePriorIDs, tt.compatiblePriorIDs)
|
t.Fatalf("CompatiblePriorIDs = %#v, want %#v", definition.CompatiblePriorIDs, tt.compatiblePriorIDs)
|
||||||
}
|
}
|
||||||
@@ -267,7 +397,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
|||||||
module.DerivedDaypartSummaries,
|
module.DerivedDaypartSummaries,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
module.HourlyForecast,
|
module.HourlyForecast,
|
||||||
@@ -283,13 +415,29 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
|||||||
module.DerivedDaypartSummaries,
|
module.DerivedDaypartSummaries,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
module.TomorrowPlanning,
|
module.TomorrowPlanning,
|
||||||
module.HourlyForecast,
|
module.HourlyForecast,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: Hourly,
|
||||||
|
want: []module.ID{
|
||||||
|
module.Metadata,
|
||||||
|
module.CurrentConditions,
|
||||||
|
module.HourlyForecast,
|
||||||
|
module.PrecipTiming,
|
||||||
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
|
module.WeatherStory,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: ThreeDay,
|
id: ThreeDay,
|
||||||
want: []module.ID{
|
want: []module.ID{
|
||||||
@@ -298,7 +446,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
|||||||
module.DerivedDaypartSummaries,
|
module.DerivedDaypartSummaries,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
},
|
},
|
||||||
@@ -311,7 +461,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
|||||||
module.DerivedDaypartSummaries,
|
module.DerivedDaypartSummaries,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
},
|
},
|
||||||
@@ -323,7 +475,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
|||||||
module.CurrentConditions,
|
module.CurrentConditions,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -402,6 +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) {
|
func assertPeriod(t *testing.T, period timeutil.Period, wantStart string, wantEnd string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if !period.IsValid() {
|
if !period.IsValid() {
|
||||||
@@ -423,6 +595,14 @@ func resolvedIDs(resolved []Resolved) []string {
|
|||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolvedDefinitionIDs(definitions []Definition) []string {
|
||||||
|
ids := make([]string, 0, len(definitions))
|
||||||
|
for _, definition := range definitions {
|
||||||
|
ids = append(ids, string(definition.ID))
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
func mustLoadLocation(t *testing.T) *time.Location {
|
func mustLoadLocation(t *testing.T) *time.Location {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
location, err := time.LoadLocation("America/Chicago")
|
location, err := time.LoadLocation("America/Chicago")
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ func DefaultRegistry() Registry {
|
|||||||
definitions := []Definition{
|
definitions := []Definition{
|
||||||
dailyTodayDefinition(),
|
dailyTodayDefinition(),
|
||||||
dailyTomorrowDefinition(),
|
dailyTomorrowDefinition(),
|
||||||
|
hourlyDefinition(),
|
||||||
threeDayDefinition(),
|
threeDayDefinition(),
|
||||||
weekendDefinition(),
|
weekendDefinition(),
|
||||||
stormDefinition(),
|
stormDefinition(),
|
||||||
@@ -73,7 +74,7 @@ func (r Registry) MustLookup(id ID) Definition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r Registry) All() []Definition {
|
func (r Registry) All() []Definition {
|
||||||
ids := []ID{DailyToday, DailyTomorrow, ThreeDay, Weekend, Storm}
|
ids := []ID{DailyToday, DailyTomorrow, Hourly, ThreeDay, Weekend, Storm}
|
||||||
out := make([]Definition, 0, len(ids))
|
out := make([]Definition, 0, len(ids))
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if definition, ok := r.definitions[id]; ok {
|
if definition, ok := r.definitions[id]; ok {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ func stormDefinition() Definition {
|
|||||||
ID: Storm,
|
ID: Storm,
|
||||||
Name: "Storm Report",
|
Name: "Storm Report",
|
||||||
PromptID: "weather.storm_report",
|
PromptID: "weather.storm_report",
|
||||||
|
GenerationMode: GenerationModeScriptoriumMarkdown,
|
||||||
ComparisonStrategy: CompareExplicitWindow,
|
ComparisonStrategy: CompareExplicitWindow,
|
||||||
ArtifactGroup: "storm",
|
ArtifactGroup: "storm",
|
||||||
BatchOutputName: "storm.md",
|
BatchOutputName: "storm.md",
|
||||||
@@ -29,7 +30,9 @@ func stormModules() []module.ConfigItem {
|
|||||||
module.CurrentConditions,
|
module.CurrentConditions,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ func threeDayDefinition() Definition {
|
|||||||
ID: ThreeDay,
|
ID: ThreeDay,
|
||||||
Name: "3-Day Outlook",
|
Name: "3-Day Outlook",
|
||||||
PromptID: "weather.three_day_outlook",
|
PromptID: "weather.three_day_outlook",
|
||||||
|
GenerationMode: GenerationModeScriptoriumMarkdown,
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
ArtifactGroup: "three-day",
|
ArtifactGroup: "three-day",
|
||||||
BatchOutputName: "three-day.md",
|
BatchOutputName: "three-day.md",
|
||||||
@@ -30,7 +31,9 @@ func threeDayModules() []module.ConfigItem {
|
|||||||
module.DerivedDaypartSummaries,
|
module.DerivedDaypartSummaries,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ func weekendDefinition() Definition {
|
|||||||
ID: Weekend,
|
ID: Weekend,
|
||||||
Name: "Weekend Outlook",
|
Name: "Weekend Outlook",
|
||||||
PromptID: "weather.weekend_outlook",
|
PromptID: "weather.weekend_outlook",
|
||||||
|
GenerationMode: GenerationModeScriptoriumMarkdown,
|
||||||
ComparisonStrategy: CompareWeekendWindow,
|
ComparisonStrategy: CompareWeekendWindow,
|
||||||
ArtifactGroup: "weekend",
|
ArtifactGroup: "weekend",
|
||||||
BatchOutputName: "weekend.md",
|
BatchOutputName: "weekend.md",
|
||||||
@@ -31,7 +32,9 @@ func weekendModules() []module.ConfigItem {
|
|||||||
module.DerivedDaypartSummaries,
|
module.DerivedDaypartSummaries,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
)
|
)
|
||||||
|
|||||||
60
internal/reporttemplate/reporttemplate.go
Normal file
60
internal/reporttemplate/reporttemplate.go
Normal 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
|
||||||
|
}
|
||||||
187
internal/reporttemplate/reporttemplate_test.go
Normal file
187
internal/reporttemplate/reporttemplate_test.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
71
internal/reporttemplate/templates/hourly.md.tmpl
Normal file
71
internal/reporttemplate/templates/hourly.md.tmpl
Normal 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 }}
|
||||||
@@ -33,6 +33,10 @@ type ArtifactPaths struct {
|
|||||||
Preflight string `json:"preflight"`
|
Preflight string `json:"preflight"`
|
||||||
Notification string `json:"notification,omitempty"`
|
Notification string `json:"notification,omitempty"`
|
||||||
RenderedReport string `json:"renderedReport,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 {
|
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"),
|
Preflight: s.join(s.preflightDir, group, validDate, filenameBase+".render.json"),
|
||||||
Notification: s.join(s.notificationsDir, group, validDate, filenameBase+".distributor.json"),
|
Notification: s.join(s.notificationsDir, group, validDate, filenameBase+".distributor.json"),
|
||||||
RenderedReport: s.join(s.reportsDir, group, filenameBase+".md"),
|
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
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +156,50 @@ func (s *FilesystemStore) SaveDistributorNotification(_ context.Context, resolve
|
|||||||
return paths.Notification, nil
|
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) {
|
func (s *FilesystemStore) PrepareRenderedReport(_ context.Context, resolved report.Resolved) (string, error) {
|
||||||
paths, err := s.Paths(resolved)
|
paths, err := s.Paths(resolved)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -325,6 +377,37 @@ func (s *FilesystemStore) LoadModuleSnapshot(_ context.Context, path string) (mo
|
|||||||
return snapshot, nil
|
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) {
|
func (s *FilesystemStore) reportRecord(path string) (ReportRecord, error) {
|
||||||
var metadata Metadata
|
var metadata Metadata
|
||||||
if err := readJSON(path, &metadata); err != nil {
|
if err := readJSON(path, &metadata); err != nil {
|
||||||
|
|||||||
@@ -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) {
|
func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
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") {
|
if strings.Contains(string(data), "MetadataPath") || strings.Contains(string(data), "metadataPath") {
|
||||||
t.Fatalf("metadata JSON includes runtime-only MetadataPath:\n%s", string(data))
|
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) {
|
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) {
|
func TestFilesystemStoreRejectsUnsafeDirs(t *testing.T) {
|
||||||
cfg := config.Defaults().Workspace
|
cfg := config.Defaults().Workspace
|
||||||
cfg.Root = t.TempDir()
|
cfg.Root = t.TempDir()
|
||||||
@@ -390,6 +545,26 @@ func resolveWeekendAt(t *testing.T, value string) report.Resolved {
|
|||||||
return 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 {
|
func stateBriefingMetadata(resolved report.Resolved) briefing.Metadata {
|
||||||
return briefing.Metadata{
|
return briefing.Metadata{
|
||||||
RunID: resolved.Metadata().RunID,
|
RunID: resolved.Metadata().RunID,
|
||||||
@@ -436,5 +611,9 @@ func pathsString(paths ArtifactPaths) string {
|
|||||||
paths.Preflight,
|
paths.Preflight,
|
||||||
paths.Notification,
|
paths.Notification,
|
||||||
paths.RenderedReport,
|
paths.RenderedReport,
|
||||||
|
paths.GeneratedTextRaw,
|
||||||
|
paths.GeneratedTextResult,
|
||||||
|
paths.GeneratedText,
|
||||||
|
paths.RenderContext,
|
||||||
}, "\n")
|
}, "\n")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,11 +31,16 @@ type Metadata struct {
|
|||||||
PreflightPath string `json:"preflightPath"`
|
PreflightPath string `json:"preflightPath"`
|
||||||
NotificationPath string `json:"notificationPath,omitempty"`
|
NotificationPath string `json:"notificationPath,omitempty"`
|
||||||
RenderedReportPath string `json:"renderedReportPath,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 {
|
func BuildMetadataFromBriefingMetadata(resolved report.Resolved, briefingMetadata briefing.Metadata, paths ArtifactPaths) Metadata {
|
||||||
metadata := resolved.Metadata()
|
metadata := resolved.Metadata()
|
||||||
return Metadata{
|
out := Metadata{
|
||||||
SchemaVersion: MetadataSchemaVersion,
|
SchemaVersion: MetadataSchemaVersion,
|
||||||
RunID: metadata.RunID,
|
RunID: metadata.RunID,
|
||||||
MetadataPath: paths.Metadata,
|
MetadataPath: paths.Metadata,
|
||||||
@@ -55,6 +60,14 @@ func BuildMetadataFromBriefingMetadata(resolved report.Resolved, briefingMetadat
|
|||||||
PreflightPath: paths.Preflight,
|
PreflightPath: paths.Preflight,
|
||||||
RenderedReportPath: paths.RenderedReport,
|
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 {
|
func copyLocation(location *briefing.LocationContext) *briefing.LocationContext {
|
||||||
|
|||||||
@@ -17,10 +17,17 @@ type Store interface {
|
|||||||
SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error)
|
SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error)
|
||||||
SavePreflight(context.Context, report.Resolved, PreflightArtifact) (string, error)
|
SavePreflight(context.Context, report.Resolved, PreflightArtifact) (string, error)
|
||||||
SaveDistributorNotification(context.Context, report.Resolved, DistributorNotificationArtifact) (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)
|
PrepareRenderedReport(context.Context, report.Resolved) (string, error)
|
||||||
SaveMetadata(context.Context, Metadata) (string, error)
|
SaveMetadata(context.Context, Metadata) (string, error)
|
||||||
FindPriorSnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
|
FindPriorSnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
|
||||||
LoadModuleSnapshot(context.Context, string) (module.Snapshot, 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 {
|
type PriorSnapshot struct {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type Bundle struct {
|
|||||||
Discussion *Discussion `json:"discussion,omitempty"`
|
Discussion *Discussion `json:"discussion,omitempty"`
|
||||||
Daily *ForecastRun `json:"daily,omitempty"`
|
Daily *ForecastRun `json:"daily,omitempty"`
|
||||||
WeatherStory *WeatherStory `json:"weatherStory,omitempty"`
|
WeatherStory *WeatherStory `json:"weatherStory,omitempty"`
|
||||||
|
SPCConvectiveOutlooks *ConvectiveOutlookRun `json:"spcConvectiveOutlooks,omitempty"`
|
||||||
Sources []Source `json:"sources"`
|
Sources []Source `json:"sources"`
|
||||||
Warnings []SourceWarning `json:"warnings,omitempty"`
|
Warnings []SourceWarning `json:"warnings,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -166,3 +167,42 @@ type WeatherStory struct {
|
|||||||
Order int `json:"order"`
|
Order int `json:"order"`
|
||||||
DownloadURL string `json:"downloadUrl,omitempty"`
|
DownloadURL string `json:"downloadUrl,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ConvectiveOutlookRun struct {
|
||||||
|
LocationID string `json:"locationId,omitempty"`
|
||||||
|
LocationName string `json:"locationName,omitempty"`
|
||||||
|
AsOf *time.Time `json:"asOf,omitempty"`
|
||||||
|
IssuedAt *time.Time `json:"issuedAt,omitempty"`
|
||||||
|
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||||
|
Product string `json:"product,omitempty"`
|
||||||
|
Outlooks []ConvectiveOutlook `json:"outlooks,omitempty"`
|
||||||
|
Discussions []ConvectiveOutlookDiscussion `json:"discussions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConvectiveOutlook struct {
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Provider string `json:"provider,omitempty"`
|
||||||
|
Product string `json:"product,omitempty"`
|
||||||
|
Day int `json:"day,omitempty"`
|
||||||
|
OutlookType string `json:"outlookType,omitempty"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
LabelText string `json:"labelText,omitempty"`
|
||||||
|
Forecaster string `json:"forecaster,omitempty"`
|
||||||
|
SeverityRank *int `json:"severityRank,omitempty"`
|
||||||
|
ValidFrom time.Time `json:"validFrom"`
|
||||||
|
ValidTo time.Time `json:"validTo"`
|
||||||
|
IssuedAt *time.Time `json:"issuedAt,omitempty"`
|
||||||
|
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
||||||
|
SourceURL string `json:"sourceUrl,omitempty"`
|
||||||
|
ImageURL string `json:"imageUrl,omitempty"`
|
||||||
|
ContainsLocation bool `json:"containsLocation"`
|
||||||
|
Geometry json.RawMessage `json:"geometry,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConvectiveOutlookDiscussion struct {
|
||||||
|
Day int `json:"day,omitempty"`
|
||||||
|
Headline string `json:"headline,omitempty"`
|
||||||
|
Summary string `json:"summary,omitempty"`
|
||||||
|
Discussion string `json:"discussion,omitempty"`
|
||||||
|
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||||
|
}
|
||||||
|
|||||||
78
internal/weatherdata/bundle_test.go
Normal file
78
internal/weatherdata/bundle_test.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user