Compare commits
155 Commits
v0.7.0
...
bdbab48d10
| Author | SHA1 | Date | |
|---|---|---|---|
| bdbab48d10 | |||
| 16cc4b3f63 | |||
| 0ef861ed8f | |||
| 6ae7eb44cf | |||
| 8f6aa8aa8b | |||
| b8e889ad13 | |||
| 15ee4af1a1 | |||
| 4c606eb39f | |||
| 8d2ac163ae | |||
| 8709b5f4d8 | |||
| fd48ebecb8 | |||
| 021e5dd8b1 | |||
| 7adf5e1b08 | |||
| 455cc67d4c | |||
| dd3133ee2a | |||
| b3637cddd6 | |||
| 662db5e511 | |||
| 2ef91cf1b1 | |||
| 1d2f176977 | |||
| 2b3bcdd4f1 | |||
| a82f03feb8 | |||
| f1d4e38414 | |||
| 32060bd370 | |||
| 133f83f4ce | |||
| 42f0e16b02 | |||
| a2f0a2fc36 | |||
| 9f552cff6b | |||
| b913194fb4 | |||
| 3eccafad6b | |||
| a9d87bdbaa | |||
| 6f9255105d | |||
| c04e3c5599 | |||
| f15315f1b9 | |||
| 0ef6cd567e | |||
| b308ff4d6b | |||
| 5ecbc06c85 | |||
| 21e97f5d4e | |||
| 3900b3313b | |||
| 5d416cfc4a | |||
| 6532e8824a | |||
| d321492995 | |||
| b57110e5c8 | |||
| 482e83903c | |||
| 21a7748b2c | |||
| b36e198bfe | |||
| f9d6d42b1b | |||
| d9ab1e47ec | |||
| 4f755704d9 | |||
| a13f04fce5 | |||
| 1f5b347cd2 | |||
| 3639636813 | |||
| ca27d81163 | |||
| d74ba0f259 | |||
| 121f28fd29 | |||
| e3bcecc5c1 | |||
| 0884eb0ce5 | |||
| a27e870522 | |||
| d90801cff5 | |||
| 0f63159482 | |||
| 2792933833 | |||
| 9261431329 | |||
| 1bfd865333 | |||
| e5af7477af | |||
| 92fcbfcc05 | |||
| ff6aade42c | |||
| 4fac69c9f0 | |||
| 90ab6973e6 | |||
| 90a502f50e | |||
| 273f462e09 | |||
| 5361d5647b | |||
| d2e90da148 | |||
| 047ce32ac6 | |||
| 5896168a93 | |||
| fe9c40741a | |||
| 88004a1827 | |||
| a515b7e7d9 | |||
| 696454cf34 | |||
| 8c97788682 | |||
| 5203440ba0 | |||
| 3d452a120a | |||
| 4eece7cc8a | |||
| d0d0b698f9 | |||
| 4c4b01f265 | |||
| 58fe794227 | |||
| 63dfc0b55a | |||
| 1ddc33eb17 | |||
| 4a0238909b | |||
| 3d5f71e72d | |||
| 8ff5c44324 | |||
| 4e704e4f51 | |||
| 7b4c73d1e6 | |||
| 7efd8b5855 | |||
| 7cbc59d8a7 | |||
| 67b30dbad6 | |||
| cd8d77b37c | |||
| bb79232e3e | |||
| b4e0aadbef | |||
| 4fe0f40cef | |||
| 40b42f4bf3 | |||
| e8f1aa5caf | |||
| a02af0bce0 | |||
| ff2f8c16a3 | |||
| ace5577402 | |||
| 473f252aee | |||
| 74bd69834c | |||
| 98cab70b53 | |||
| dc0172ff82 | |||
| 0b41773017 | |||
| ddda42453f | |||
| 295f06915f | |||
| 120bce3391 | |||
| 386263784c | |||
| afe6803a63 | |||
| 5344c7880a | |||
| 74e32eb18e | |||
| ceb00ad45e | |||
| 7cd07c429b | |||
| 28b8391d53 | |||
| bb8de054dc | |||
| 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 |
@@ -9,7 +9,7 @@ Markdown reports to a configured `distributor` HTTP upload endpoint.
|
|||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
weatherreporter generate today --out ./today.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|||||||
83
docs/cli.md
83
docs/cli.md
@@ -6,21 +6,24 @@ batches, and inspects stored artifacts.
|
|||||||
## Shortest Useful Command
|
## Shortest Useful Command
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
weatherreporter generate today --out ./today.md
|
||||||
```
|
```
|
||||||
|
|
||||||
This loads configuration, fetches weather data, writes managed workspace
|
This loads configuration, collects weather data, writes managed workspace
|
||||||
artifacts, runs `scriptorium render` as a preflight check, runs
|
artifacts, runs `scriptorium render` as a preflight check, runs structured
|
||||||
`scriptorium run`, and writes an extra Markdown copy to `./daily.md`. If
|
`scriptorium run`, validates generated text, renders the embedded Today
|
||||||
distributor notification is enabled in configuration, the command also uploads
|
template, and writes an extra Markdown copy to `./today.md`. If distributor
|
||||||
the managed Markdown report after final metadata is saved.
|
notification is enabled in configuration, the command also uploads the managed
|
||||||
|
Markdown report after final metadata is saved.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```text
|
```text
|
||||||
weatherreporter --help
|
weatherreporter --help
|
||||||
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
|
weatherreporter generate today [--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,24 +37,42 @@ 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 daily`,
|
||||||
requires explicit event-window bounds with `--start` and `--end`.
|
`generate today`, `generate tomorrow`, and `generate hourly` write managed
|
||||||
|
generated-text artifacts, validate structured text from Scriptorium, and render
|
||||||
|
the managed Markdown report from embedded templates. `generate daily` requires
|
||||||
|
`--date YYYY-MM-DD` for the selected local civil day; omitting `--date` is a
|
||||||
|
command error and stops before weather data is collected. `generate hourly`
|
||||||
|
covers the next six hours in the effective report timezone and does not accept
|
||||||
|
date or event window flags. `generate storm` requires explicit event-window
|
||||||
|
bounds with `--start` and `--end`.
|
||||||
|
|
||||||
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
`run morning` generates Today Report, Tomorrow Report, and a dated Daily Report
|
||||||
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch
|
for each later future local civil day with complete hourly forecast coverage.
|
||||||
runs continue independent reports after a failure, print a JSON summary to
|
`run evening` generates Tomorrow Report and the same eligible future Daily
|
||||||
stdout, write compact status lines to stderr, and return nonzero when any report
|
reports. Future Daily expansion starts with the day after tomorrow and skips
|
||||||
failed. `--out-dir` writes extra Markdown copies for the operator; distributor
|
days that do not have every hourly forecast period for the local civil day.
|
||||||
notification uses each managed report path, not the extra copies. When
|
Batch commands collect weather data once before planning; a collection failure
|
||||||
notification is enabled, batch summaries and status lines include notification
|
stops the batch before any report is generated. Batch runs continue independent
|
||||||
status, accepted distributor run ID, or notification error fields for each
|
reports after a later report failure, print a JSON summary to stdout, write
|
||||||
attempted report.
|
compact status lines to stderr, and return nonzero when any report failed.
|
||||||
|
`--out-dir` writes extra Markdown copies for the operator; distributor
|
||||||
|
notification uses managed report paths, not the extra copies. Today and
|
||||||
|
Tomorrow use their report default copy names, and dynamic Daily copies use
|
||||||
|
`daily-YYYY-MM-DD.md`. When distributor and batch notification are enabled, a
|
||||||
|
fully successful batch uploads one distributor bundle after report generation
|
||||||
|
finishes. The JSON summary exposes that upload as a top-level `notification`
|
||||||
|
object, and stderr includes one `batchNotification` status line. If any planned
|
||||||
|
report fails, the batch notification is skipped for the whole batch.
|
||||||
|
|
||||||
|
Hourly Report, 3-Day Outlook, and Weekend Outlook are explicit only; they are
|
||||||
|
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 collect weather data or invoke `scriptorium`.
|
||||||
|
|
||||||
## Flags
|
## Flags
|
||||||
|
|
||||||
@@ -59,9 +80,9 @@ 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`: required date for `generate daily`; optional date for `generate today`, defaulting 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`.
|
||||||
- `--end TIME`: required end time for `generate storm`.
|
- `--end TIME`: required end time for `generate storm`.
|
||||||
- `--limit N`: maximum records for `inspect reports`; defaults to `20`, and `0` means no limit.
|
- `--limit N`: maximum records for `inspect reports`; defaults to `20`, and `0` means no limit.
|
||||||
@@ -75,7 +96,10 @@ are no distributor-specific CLI flags.
|
|||||||
## Common Workflows
|
## Common Workflows
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
weatherreporter generate today --out ./today.md
|
||||||
|
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
||||||
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
|
||||||
@@ -87,11 +111,12 @@ weatherreporter run evening --out-dir ./reports
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
weatherreporter inspect reports --limit 10
|
weatherreporter inspect reports --limit 10
|
||||||
weatherreporter inspect metadata 20260529T100000.000000000Z_daily_today
|
weatherreporter inspect metadata 20260529T100000.000000000Z_today
|
||||||
weatherreporter inspect modules 20260529T100000.000000000Z_daily_today
|
weatherreporter inspect modules 20260529T100000.000000000Z_today
|
||||||
weatherreporter inspect data-package 20260529T100000.000000000Z_daily_today
|
weatherreporter inspect data-package 20260529T100000.000000000Z_today
|
||||||
weatherreporter inspect prior 20260529T100000.000000000Z_daily_today
|
weatherreporter inspect prior 20260529T100000.000000000Z_today
|
||||||
weatherreporter inspect sources 20260529T100000.000000000Z_daily_today
|
weatherreporter inspect sources 20260529T100000.000000000Z_today
|
||||||
|
weatherreporter inspect metadata 20260529T100000.000000000Z_daily
|
||||||
```
|
```
|
||||||
|
|
||||||
`inspect reports` lists recent generated runs with artifact paths and source
|
`inspect reports` lists recent generated runs with artifact paths and source
|
||||||
|
|||||||
142
docs/config.md
142
docs/config.md
@@ -29,8 +29,8 @@ weather_api:
|
|||||||
base_url: https://weather.api.example.com/
|
base_url: https://weather.api.example.com/
|
||||||
```
|
```
|
||||||
|
|
||||||
`weather_api.base_url` is required for commands that fetch weather data. Other
|
`weather_api.base_url` is required for commands that collect weather data.
|
||||||
fields fall back to defaults.
|
Other fields fall back to defaults.
|
||||||
|
|
||||||
## Production-Oriented Config
|
## Production-Oriented Config
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ config test suite.
|
|||||||
|
|
||||||
### `weather_api`
|
### `weather_api`
|
||||||
|
|
||||||
- `base_url`: absolute base URL for the Weather API. Required for generation and fetch workflows.
|
- `base_url`: absolute base URL for the Weather API. Required for generation and collection workflows.
|
||||||
- `timeout`: HTTP timeout duration. Default: `10s`.
|
- `timeout`: HTTP timeout duration. Default: `10s`.
|
||||||
- `precision`: numeric precision query value. Default: `1`.
|
- `precision`: numeric precision query value. Default: `1`.
|
||||||
- `units`: Weather API units query value. Default: `us`.
|
- `units`: Weather API units query value. Default: `us`.
|
||||||
@@ -78,10 +78,13 @@ missing directories, and unreadable files fail config loading.
|
|||||||
|
|
||||||
### `notify`
|
### `notify`
|
||||||
|
|
||||||
`notify.distributor` controls distributor notification after successful report
|
`notify.distributor` controls distributor uploads after successful report
|
||||||
generation. It is disabled by default and does not add CLI flags. When enabled,
|
rendering. It is disabled by default and does not add CLI flags. When enabled,
|
||||||
weatherreporter uploads one distributor bundle per generated report after
|
`generate <report>` uploads one distributor bundle for the generated report
|
||||||
`scriptorium run` succeeds and final metadata is saved.
|
after final metadata is saved. `run morning` and `run evening` use
|
||||||
|
`notify.distributor.batch`: when batch notification is enabled and every
|
||||||
|
planned report succeeds, weatherreporter uploads one distributor bundle that
|
||||||
|
contains all managed Markdown reports from that batch.
|
||||||
|
|
||||||
- `enabled`: whether distributor notification config is active. Default:
|
- `enabled`: whether distributor notification config is active. Default:
|
||||||
`false`.
|
`false`.
|
||||||
@@ -92,33 +95,63 @@ weatherreporter uploads one distributor bundle per generated report after
|
|||||||
- `timeout`: distributor operation timeout. Must be greater than zero when
|
- `timeout`: distributor operation timeout. Must be greater than zero when
|
||||||
enabled. Default: `30s`.
|
enabled. Default: `30s`.
|
||||||
- `failure_policy`: must be `error` when enabled. Default: `error`.
|
- `failure_policy`: must be `error` when enabled. Default: `error`.
|
||||||
- `pipeline_id_template`: template for the distributor pipeline ID. Required
|
- `pipeline_id_template`: template for single-report distributor pipeline IDs.
|
||||||
when enabled. Default: empty.
|
Required when enabled. Default: empty.
|
||||||
- `bundle_id_template`: template for distributor bundle IDs. Default:
|
- `bundle_id_template`: template for single-report distributor bundle IDs.
|
||||||
`weatherreporter.{location_id}.{report_id}`.
|
Default: `weatherreporter.{location_id}.{report_id}`.
|
||||||
- `idempotency_key_template`: template for distributor idempotency keys.
|
- `idempotency_key_template`: template for single-report distributor
|
||||||
Default: `{bundle_id}.{run_id}`.
|
idempotency keys. Default: `{bundle_id}.{run_id}`.
|
||||||
- `report_path_templates`: ordered list of templates for Markdown report paths
|
- `batch.enabled`: whether batch distributor notification config is active
|
||||||
inside the distributor bundle. Each rendered path maps to the same managed
|
when distributor notification is enabled. Default: `true`.
|
||||||
Markdown report source. Default:
|
- `batch.pipeline_id_template`: template for batch distributor pipeline IDs.
|
||||||
```yaml
|
Required when distributor notification and batch notification are enabled.
|
||||||
- "{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md"
|
Default: `weatherreporter`.
|
||||||
```
|
- `batch.bundle_id_template`: template for batch distributor bundle IDs.
|
||||||
|
Required when distributor notification and batch notification are enabled.
|
||||||
|
Default: `weatherreporter.{location_id}.{batch}`.
|
||||||
|
- `batch.idempotency_key_template`: template for batch distributor idempotency
|
||||||
|
keys. Required when distributor notification and batch notification are
|
||||||
|
enabled. Default: `{bundle_id}.{batch_run_id}`.
|
||||||
|
|
||||||
Supported template variables are `location_id`, `report_id`, `run_id`,
|
Single-report templates support `location_id`, `report_id`, `run_id`,
|
||||||
`artifact_group`, `batch_output_name`, `valid_start_date`, `valid_end_date`,
|
`artifact_group`, `batch_output_name`, `valid_start_date`, `valid_end_date`,
|
||||||
`valid_start_time`, `valid_end_time`, `valid_start_stamp`, and
|
`valid_start_time`, `valid_end_time`, `valid_start_stamp`, `valid_end_stamp`,
|
||||||
`valid_end_stamp`. Date values use `YYYY-MM-DD`, time values use `HHMM`, and
|
and `storm_id`. Date values use `YYYY-MM-DD`, time values use `HHMM`, and
|
||||||
stamp values use `YYYY-MM-DDTHHMM` in the effective report timezone.
|
stamp values use `YYYY-MM-DDTHHMM` in the effective report timezone.
|
||||||
`pipeline_id_template` and `idempotency_key_template` may also use `bundle_id`.
|
`storm_id` is derived from the storm report valid period as
|
||||||
|
`{valid_start_stamp}-{valid_end_stamp}`; it renders empty for non-storm
|
||||||
|
reports. `pipeline_id_template` and `idempotency_key_template` may also use
|
||||||
|
`bundle_id`.
|
||||||
|
|
||||||
The rendered pipeline ID selects the configured distributor `http_upload`
|
The rendered pipeline ID selects the configured distributor `http_upload`
|
||||||
workflow. The rendered bundle ID is the stable logical source identity for the
|
workflow. The rendered bundle ID is the stable logical source identity for the
|
||||||
report stream. The rendered idempotency key is the per-run retry identity.
|
report stream. The rendered idempotency key is the per-run retry identity.
|
||||||
|
|
||||||
|
Batch templates support `location_id`, `batch`, `batch_run_id`, and
|
||||||
|
`batch_started_date`. Batch idempotency templates may also use `bundle_id`.
|
||||||
|
`batch_started_date` is the batch start date in the effective report timezone.
|
||||||
|
Batch bundle IDs identify a logical batch stream; batch idempotency keys
|
||||||
|
identify a specific retryable batch attempt.
|
||||||
|
|
||||||
Rendered report paths must be unique relative paths with `/` separators. They
|
Rendered report paths must be unique relative paths with `/` separators. They
|
||||||
must not contain backslashes, empty path segments, `.`, `..`, `manifest.json`,
|
must not contain backslashes, empty path segments, `.`, `..`, `manifest.json`,
|
||||||
or `.distributor.json`.
|
or the reserved distributor sidecar basename, formed from a leading dot plus
|
||||||
|
`distributor.json`. In a batch upload, uniqueness is checked across every
|
||||||
|
rendered bundle path for every included report before distributor is called.
|
||||||
|
Managed Markdown report paths are the only upload source files; copies written
|
||||||
|
with `--out` or `--out-dir` are never uploaded.
|
||||||
|
|
||||||
|
Distributor bundle paths are report-specific. Weatherreporter uses
|
||||||
|
`reports.<report>.distributor.path_templates` when that override is configured;
|
||||||
|
otherwise it uses the report definition defaults:
|
||||||
|
|
||||||
|
- `hourly`: `hourly/index.md`
|
||||||
|
- `daily`: `daily/{valid_start_date}/{run_id}.md`, `daily/{valid_start_date}/index.md`
|
||||||
|
- `today`: `daily/{valid_start_date}/{run_id}.md`, `daily/{valid_start_date}/index.md`, `today/index.md`
|
||||||
|
- `tomorrow`: `daily/{valid_start_date}/{run_id}.md`, `daily/{valid_start_date}/index.md`, `tomorrow/index.md`
|
||||||
|
- `three_day`: `three-day/{valid_start_date}/{run_id}.md`, `three-day/{valid_start_date}/index.md`
|
||||||
|
- `weekend`: `weekend/{valid_start_date}/{run_id}.md`, `weekend/{valid_start_date}/index.md`
|
||||||
|
- `storm`: `storm/{storm_id}/{run_id}.md`, `storm/{storm_id}/index.md`
|
||||||
|
|
||||||
The upload token is read from the environment variable named by `token_env`
|
The upload token is read from the environment variable named by `token_env`
|
||||||
after config loading and `secrets.directory` processing. Config files should
|
after config loading and `secrets.directory` processing. Config files should
|
||||||
@@ -130,7 +163,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`
|
||||||
|
|
||||||
@@ -150,7 +185,9 @@ the missing-source policy.
|
|||||||
- `notifications_dir`: distributor notification debug artifact directory under `workspace.root`. Default: `notifications`.
|
- `notifications_dir`: distributor notification debug artifact directory under `workspace.root`. Default: `notifications`.
|
||||||
|
|
||||||
Workspace subdirectories must be relative paths that stay inside
|
Workspace subdirectories must be relative paths that stay inside
|
||||||
`workspace.root`.
|
`workspace.root`. Managed artifact paths below those directories are grouped by
|
||||||
|
artifact group and valid-period start date; the path template is not
|
||||||
|
configurable.
|
||||||
|
|
||||||
### `dayparts`
|
### `dayparts`
|
||||||
|
|
||||||
@@ -179,29 +216,76 @@ 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`, `today`, `tomorrow`, `hourly`,
|
||||||
`storm`. Canonical report IDs such as `daily_today` and `daily_tomorrow` are
|
`three_day`, `weekend`, and `storm`. Canonical report IDs and accepted aliases
|
||||||
also accepted.
|
are also valid, including `three_day_outlook`, `weekend_outlook`, and
|
||||||
|
`storm_report`. Hyphens and underscores are treated equivalently in report
|
||||||
|
keys. Retired report keys are not supported.
|
||||||
|
|
||||||
|
`reports.today` applies only to the Today Report. `reports.daily` applies only
|
||||||
|
to the dated Daily Report.
|
||||||
|
|
||||||
Each report entry supports:
|
Each report entry supports:
|
||||||
|
|
||||||
- `deterministic_modules`: ordered module list. Entries may be string module
|
- `deterministic_modules`: ordered module list. Entries may be string module
|
||||||
IDs or objects with `id` and optional `options`.
|
IDs or objects with `id` and optional `options`.
|
||||||
|
- `distributor.path_templates`: optional ordered distributor bundle path
|
||||||
|
templates for this report. If omitted, the report definition defaults are
|
||||||
|
used. If present, the list must contain at least one template.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
reports:
|
reports:
|
||||||
daily:
|
daily:
|
||||||
|
distributor:
|
||||||
|
path_templates:
|
||||||
|
- "daily/{valid_start_date}/{run_id}.md"
|
||||||
|
- "daily/{valid_start_date}/index.md"
|
||||||
deterministic_modules:
|
deterministic_modules:
|
||||||
- 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
|
||||||
|
- daily_planning
|
||||||
- hourly_forecast
|
- hourly_forecast
|
||||||
|
today:
|
||||||
|
deterministic_modules:
|
||||||
|
- metadata
|
||||||
|
- current_conditions
|
||||||
|
- narrative_forecast
|
||||||
|
- derived_daily_summary
|
||||||
|
- derived_daypart_summaries
|
||||||
|
- precip_timing
|
||||||
|
- alert_digest
|
||||||
|
- spc_convective_outlooks
|
||||||
|
- area_forecast_discussion
|
||||||
|
- spc_convective_discussion
|
||||||
|
- weather_story
|
||||||
|
- outdoor_windows
|
||||||
|
- hourly_forecast
|
||||||
|
- today_planning
|
||||||
|
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
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ func SubmitReport(reportPath, summaryPath string) error {
|
|||||||
- Keep file contents stable after upload inputs are selected. Bundle digests are calculated from file bytes.
|
- Keep file contents stable after upload inputs are selected. Bundle digests are calculated from file bytes.
|
||||||
- Treat upload success as admission only. `UploadFiles` and `UploadBundle` return after the server accepts and validates the upload, not after all destinations publish.
|
- Treat upload success as admission only. `UploadFiles` and `UploadBundle` return after the server accepts and validates the upload, not after all destinations publish.
|
||||||
|
|
||||||
Valid bundle paths are relative slash paths. They must not be empty, absolute, contain backslashes, contain `.` or `..` path segments, contain empty path segments, or use reserved basenames `manifest.json` or `.distributor.json`.
|
Valid bundle paths are relative slash paths. They must not be empty, absolute, contain backslashes, contain `.` or `..` path segments, contain empty path segments, or use reserved basenames such as `manifest.json` and the distributor sidecar basename formed from a leading dot plus `distributor.json`.
|
||||||
|
|
||||||
## Idempotency And Status
|
## Idempotency And Status
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,8 @@ Invalid paths include:
|
|||||||
- paths containing backslashes;
|
- paths containing backslashes;
|
||||||
- `.` or `..` path segments;
|
- `.` or `..` path segments;
|
||||||
- empty path segments;
|
- empty path segments;
|
||||||
- any basename of `manifest.json` or `.distributor.json`.
|
- any reserved basename, including `manifest.json` and the distributor sidecar
|
||||||
|
basename formed from a leading dot plus `distributor.json`.
|
||||||
|
|
||||||
Explicit file lists preserve caller order. File order is part of the bundle digest, so producers should choose it deliberately and keep it stable.
|
Explicit file lists preserve caller order. File order is part of the bundle digest, so producers should choose it deliberately and keep it stable.
|
||||||
|
|
||||||
|
|||||||
@@ -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,17 @@ 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. For direct Markdown reports, Scriptorium writes the
|
||||||
the `--out` path.
|
managed Markdown artifact to the `--out` path. For generated-text-template
|
||||||
|
reports, Scriptorium writes raw JSON to the `--out` path; later
|
||||||
|
weatherreporter workflow steps validate those bytes and render Markdown from an
|
||||||
|
embedded template.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
@@ -80,7 +100,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
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ This document describes the workflow coordinator in `internal/app`.
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/app` coordinates the top-level use cases after CLI parsing and config
|
`internal/app` coordinates the top-level use cases after CLI parsing and config
|
||||||
loading are complete. It resolves report definitions, fetches weather data,
|
loading are complete. It resolves report definitions, collects weather data
|
||||||
builds collected and derived facts, builds module snapshots and prompt-input
|
through `internal/collect`, builds collected and derived facts, builds module
|
||||||
artifacts, invokes Scriptorium through the adapter boundary, optionally
|
snapshots and prompt-input artifacts, invokes Scriptorium through the adapter
|
||||||
notifies distributor through an app-owned notifier boundary, persists managed
|
boundary, optionally notifies distributor through an app-owned notifier
|
||||||
state, runs batches, and reads existing artifacts for inspection.
|
boundary, persists managed state, runs batches, and reads existing artifacts
|
||||||
|
for inspection.
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
@@ -17,21 +18,22 @@ Inputs:
|
|||||||
|
|
||||||
- `GenerateRequest` for one report command
|
- `GenerateRequest` for one report command
|
||||||
- `BatchRequest` for morning or evening batch commands
|
- `BatchRequest` for morning or evening batch commands
|
||||||
- `FetchBundleRequest` for explicit bundle fetch and save workflows
|
- `FetchBundleRequest` for explicit bundle collection and save workflows
|
||||||
- `ReportRequest` for single-report generation
|
- `ReportRequest` for single-report generation
|
||||||
- resolved report definitions from `internal/report`
|
- resolved report definitions from `internal/report`
|
||||||
- weather data bundles from `internal/adapters/weatherapi`
|
- collection results from `internal/collect`
|
||||||
- prior snapshots loaded from `internal/state`
|
- prior snapshots loaded from `internal/state`
|
||||||
- optional renderer, notifier, and state-store fakes for tests
|
- optional collector, renderer, notifier, and state-store fakes for tests
|
||||||
|
|
||||||
Outputs:
|
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
|
one top-level batch notification result when attempted or skipped
|
||||||
- saved Weather API bundle JSON for fetch workflows
|
- saved Weather API bundle JSON for explicit bundle collection workflows
|
||||||
- inspection JSON values for reports, metadata, module snapshots, data
|
- inspection JSON values for reports, metadata, module snapshots, data
|
||||||
packages, prior snapshots, and source provenance
|
packages, prior snapshots, and source provenance
|
||||||
|
|
||||||
@@ -44,10 +46,26 @@ construct Scriptorium argv.
|
|||||||
|
|
||||||
Report selection and report identity policy come from `internal/report`.
|
Report selection and report identity policy come from `internal/report`.
|
||||||
Collected and derived fact contracts come from `internal/facts`.
|
Collected and derived fact contracts come from `internal/facts`.
|
||||||
Weather API transport stays in `internal/adapters/weatherapi`. Scriptorium
|
Weather API transport stays in `internal/adapters/weatherapi`, and app-facing
|
||||||
subprocess behavior stays in `internal/adapters/scriptorium`. Distributor
|
upstream collection stays in `internal/collect`. Scriptorium subprocess
|
||||||
upload behavior stays in `internal/adapters/distributor`. Filesystem layout and
|
behavior stays in `internal/adapters/scriptorium`. Distributor upload behavior
|
||||||
persisted metadata stay in `internal/state`.
|
stays in `internal/adapters/distributor`. Filesystem layout and persisted
|
||||||
|
metadata stay in `internal/state`.
|
||||||
|
|
||||||
|
## Data Flow Terms
|
||||||
|
|
||||||
|
- `collect.Result` is the app-facing upstream collection result. It carries the
|
||||||
|
normalized `weatherdata.Bundle` used by report generation.
|
||||||
|
- `CollectedFacts` are normalized source facts derived from a collected Weather
|
||||||
|
API bundle 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
|
||||||
|
|
||||||
@@ -56,40 +74,65 @@ persisted metadata stay in `internal/state`.
|
|||||||
- `workspace.*` for filesystem state
|
- `workspace.*` for filesystem state
|
||||||
- `dayparts` for daily and outlook summarization
|
- `dayparts` for daily and outlook summarization
|
||||||
- `recent_change.*` for structured Recent Changes thresholds
|
- `recent_change.*` for structured Recent Changes thresholds
|
||||||
- `notify.distributor.*` for optional notification after report generation
|
- `notify.distributor.*` for optional single-report and batch notification
|
||||||
|
after report generation
|
||||||
|
|
||||||
Output copy flags are command request fields. They are not configuration
|
Output copy flags are command request fields. They are not configuration
|
||||||
defaults.
|
defaults.
|
||||||
|
|
||||||
## Generation Workflow
|
## Generation Workflow
|
||||||
|
|
||||||
Single-report generation follows this order:
|
Single-report commands validate the report command, collect once through
|
||||||
|
`internal/collect`, resolve the requested report, and pass the resolved report
|
||||||
|
plus explicit collection into `GenerateReport`.
|
||||||
|
|
||||||
1. Resolve the command report to a `report.Resolved` value.
|
`GenerateReport` then uses this setup:
|
||||||
2. Create or use a filesystem store.
|
|
||||||
3. Locate any prior compatible snapshot through `internal/state`.
|
1. Create or use a filesystem store.
|
||||||
4. Fetch a Weather API bundle.
|
2. Locate any prior compatible snapshot through `internal/state`.
|
||||||
5. Build collected and derived facts once.
|
3. Build collected and derived facts from the supplied collection.
|
||||||
6. Execute configured modules and save the module snapshot.
|
4. Execute configured modules and save the module snapshot.
|
||||||
7. Compute Recent Changes from structured prior and current module snapshots.
|
5. Compute Recent Changes from structured prior and current module snapshots.
|
||||||
8. Build and save the YAML Scriptorium `data_package`.
|
6. Build and save the YAML Scriptorium `data_package`.
|
||||||
9. Run Scriptorium render preflight.
|
7. Run Scriptorium render preflight.
|
||||||
10. Save preflight JSON when a render result is available.
|
8. Save preflight JSON when a render result is available.
|
||||||
11. Save metadata for inspection.
|
9. 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
|
10. Runs Scriptorium report generation to the managed report path.
|
||||||
path as the source file.
|
|
||||||
16. Save a distributor notification debug artifact and update metadata with its
|
For `generated_text_template` reports, generation then:
|
||||||
path.
|
|
||||||
|
10. Looks up the generated-text catalog entry for the report schema/template
|
||||||
|
IDs.
|
||||||
|
11. Runs structured Scriptorium generation to the raw generated-text JSON path.
|
||||||
|
12. Saves the structured Scriptorium run result.
|
||||||
|
13. Validates and saves normalized generated text.
|
||||||
|
14. Builds and saves a typed render context.
|
||||||
|
15. Renders Markdown from the embedded template to the managed report path.
|
||||||
|
|
||||||
|
After either mode has produced a managed Markdown report, shared finalization:
|
||||||
|
|
||||||
|
1. Copies the managed report to the requested `--out` or `--out-dir` path when
|
||||||
|
provided.
|
||||||
|
2. Saves final metadata with the managed report path and any generated-text
|
||||||
|
artifact paths already produced.
|
||||||
|
3. If distributor notification is enabled, notifies using the managed report
|
||||||
|
path as the source file.
|
||||||
|
4. 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
|
||||||
generation returns an error after writing output, the managed report and
|
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 collection,
|
||||||
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
|
||||||
@@ -98,15 +141,38 @@ failure context.
|
|||||||
|
|
||||||
## Batch Workflow
|
## Batch Workflow
|
||||||
|
|
||||||
`run morning` resolves Daily Today, 3-Day Outlook, and Weekend Outlook except
|
`run morning` collects once, plans Today Report, Tomorrow Report, and eligible
|
||||||
on Sunday. `run evening` resolves Daily Tomorrow. Batch output copy names come
|
future Daily Reports from the collected hourly forecast, then passes the same
|
||||||
from report definitions. Batch generation continues independent reports after a
|
collection into each report generation. `run evening` uses the same collection
|
||||||
|
and planning rules, but starts with Tomorrow Report. Future Daily reports start
|
||||||
|
with the day after tomorrow and require complete hourly forecast coverage for
|
||||||
|
the target local civil day. Dynamic Daily `--out-dir` copies use
|
||||||
|
`daily-YYYY-MM-DD.md`; other batch copies use report definition output names.
|
||||||
|
A collection failure stops the batch before planning or report generation.
|
||||||
|
After planning succeeds, batch generation continues independent reports after a
|
||||||
failure, records each result, writes compact status lines to stderr, emits a
|
failure, records each result, writes compact status lines to stderr, emits a
|
||||||
JSON summary to stdout, and returns an aggregate error when any report failed.
|
JSON summary to stdout, and returns an aggregate error when any report failed.
|
||||||
When notification is enabled, each successfully generated report is notified
|
|
||||||
independently. Notification failure marks that report failed, records
|
Batch report generation suppresses per-report distributor notification. After
|
||||||
notification fields in the batch result, and does not stop later reports.
|
all planned reports finish, app orchestration evaluates batch notification:
|
||||||
`--out-dir` copies are never used as notification source files.
|
|
||||||
|
1. If distributor notification is disabled, the batch notification result is
|
||||||
|
omitted.
|
||||||
|
2. If batch notification is disabled, the batch notification result is omitted
|
||||||
|
and there is no per-report fallback upload.
|
||||||
|
3. If any planned report failed, the batch notification result is `skipped`
|
||||||
|
with reason `one or more reports failed`, and distributor is not called.
|
||||||
|
4. If every report succeeded, app orchestration renders batch pipeline, bundle
|
||||||
|
ID, and idempotency key templates, renders report-specific distributor
|
||||||
|
paths for each included report, validates every managed source path and
|
||||||
|
bundle path, checks duplicate bundle paths across the batch, calls the
|
||||||
|
notifier once with a multi-file request, and saves a batch notification
|
||||||
|
debug artifact.
|
||||||
|
|
||||||
|
Batch notification failure records a top-level failed notification, increments
|
||||||
|
the aggregate batch failure count, and returns an aggregate batch error without
|
||||||
|
marking individual report items failed. `--out-dir` copies are never used as
|
||||||
|
notification source files.
|
||||||
|
|
||||||
## Inspection Workflow
|
## Inspection Workflow
|
||||||
|
|
||||||
@@ -117,13 +183,17 @@ inspection view.
|
|||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
- Resolve errors stop the requested workflow before fetching weather data.
|
- Resolve errors stop the requested workflow before collection.
|
||||||
- Weather API and module execution errors stop that report before Scriptorium
|
- Collection and module execution errors stop that report before Scriptorium
|
||||||
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.
|
||||||
- Notification errors are wrapped with report ID, RunID, and managed report path
|
- Generated-text report errors preserve available intermediate artifacts and do
|
||||||
context and are recorded separately in batch results.
|
not create extra output copies.
|
||||||
|
- Single-report notification errors are wrapped with report ID, RunID, and
|
||||||
|
managed report path context.
|
||||||
|
- Batch notification errors are recorded on the top-level batch notification
|
||||||
|
result and do not change individual report item status.
|
||||||
- Metadata and artifact path errors include filesystem context.
|
- Metadata and artifact path errors include filesystem context.
|
||||||
- Batch failures are recorded per report and surfaced through an aggregate
|
- Batch failures are recorded per report and surfaced through an aggregate
|
||||||
batch error.
|
batch error.
|
||||||
@@ -133,16 +203,27 @@ inspection view.
|
|||||||
Inspect:
|
Inspect:
|
||||||
|
|
||||||
- `internal/app/app_test.go`
|
- `internal/app/app_test.go`
|
||||||
|
- `internal/app/batch_plan_test.go`
|
||||||
|
- `internal/collect/collect_test.go`
|
||||||
- `internal/cli/root_test.go`
|
- `internal/cli/root_test.go`
|
||||||
- `internal/state/filesystem_test.go`
|
- `internal/state/filesystem_test.go`
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
- Report behavior is resolved through `internal/report`.
|
- Report behavior is resolved through `internal/report`.
|
||||||
|
- Generate and run commands collect once before report generation.
|
||||||
|
- Batch planning is app-owned because future Daily membership depends on
|
||||||
|
collected hourly forecast coverage.
|
||||||
- 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
|
- Single-report distributor notification maps the managed Markdown report path
|
||||||
bundle paths; extra output copies are not upload sources.
|
to configured bundle paths.
|
||||||
|
- Batch distributor notification maps each included managed Markdown report
|
||||||
|
path to bundle paths rendered for that report and uploads once for the
|
||||||
|
batch.
|
||||||
|
- Extra output copies are not upload sources.
|
||||||
|
|||||||
@@ -28,14 +28,46 @@ 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`, `today_planning`, `tomorrow_planning`, and
|
||||||
|
`daily_planning`
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
Daily Report supports the Daily-style civil-day modules plus `daily_planning`
|
||||||
|
and `hourly_forecast`; those outputs feed the dated Daily GeneratedText prompt
|
||||||
|
package and embedded Markdown template.
|
||||||
|
|
||||||
|
Tomorrow Report supports the Daily-style civil-day modules plus
|
||||||
|
`tomorrow_planning` and `hourly_forecast`; those outputs feed the Tomorrow
|
||||||
|
GeneratedText prompt package and embedded Markdown template.
|
||||||
|
|
||||||
|
Today Report supports the Daily-style civil-day modules plus `today_planning`
|
||||||
|
and `hourly_forecast`; those outputs feed the Today GeneratedText prompt
|
||||||
|
package and embedded Markdown template.
|
||||||
|
|
||||||
|
`today_planning` is a Today-specific deterministic planning stanza with
|
||||||
|
morning readiness, commute/school/workday concerns, outdoor planning, and
|
||||||
|
late-day change-watch fields. It is compatible with `report.Today` only.
|
||||||
|
|
||||||
|
`daily_planning` is a dated Daily deterministic planning stanza with morning
|
||||||
|
readiness, commute/school/workday concerns, and overnight change-watch fields.
|
||||||
|
It is compatible only with the `daily` report ID value. The default Daily
|
||||||
|
Report composition includes it.
|
||||||
|
|
||||||
|
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`,
|
||||||
|
`today_planning`, `tomorrow_planning`, or `daily_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.
|
||||||
@@ -45,7 +77,7 @@ remain in report metadata, source provenance, and integration artifacts.
|
|||||||
- This package selects and shapes already-collected weather facts for prompts.
|
- This package selects and shapes already-collected weather facts for prompts.
|
||||||
- It validates module composition against report compatibility and option
|
- It validates module composition against report compatibility and option
|
||||||
types.
|
types.
|
||||||
- It does not fetch weather data, compare prior snapshots, write module
|
- It does not collect weather data, compare prior snapshots, write module
|
||||||
snapshots, build YAML data packages, invoke Scriptorium, or write workflow
|
snapshots, build YAML data packages, invoke Scriptorium, or write workflow
|
||||||
metadata.
|
metadata.
|
||||||
|
|
||||||
@@ -57,7 +89,26 @@ 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 also emits a curated `risk_digest`
|
||||||
|
for categorical outlooks that overlap the report period, contain the location,
|
||||||
|
and meet the configured-in-code minimum severity for report rendering. 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 +137,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
|
||||||
|
|
||||||
|
|||||||
58
docs/internal/collect.md
Normal file
58
docs/internal/collect.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Collection Internals
|
||||||
|
|
||||||
|
This document describes the app-facing upstream collection boundary in
|
||||||
|
`internal/collect`.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
`internal/collect` is the canonical package used by app workflows to collect
|
||||||
|
upstream Weather API data. It constructs the Weather API adapter, fetches a
|
||||||
|
normalized bundle, and returns that bundle without applying report selection or
|
||||||
|
batch policy.
|
||||||
|
|
||||||
|
## Contract
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
|
||||||
|
- `collect.Request`, containing the effective `config.Config`
|
||||||
|
- `context.Context` for cancellation
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
- `collect.Result`, containing `*weatherdata.Bundle`
|
||||||
|
|
||||||
|
`Run` returns an actionable error when Weather API adapter construction or
|
||||||
|
bundle fetch fails. The package does not derive `facts.CollectedFacts`, build
|
||||||
|
modules, resolve report periods, select reports, write state, invoke
|
||||||
|
Scriptorium, or notify distributor.
|
||||||
|
|
||||||
|
## App Usage
|
||||||
|
|
||||||
|
`internal/app` owns a narrow `Collector` interface for orchestration tests. The
|
||||||
|
default implementation calls `collect.Run`.
|
||||||
|
|
||||||
|
Single-report generation collects once, resolves the requested report, and
|
||||||
|
passes the explicit collection into report generation. Batch generation
|
||||||
|
collects once before planning and passes the same collection into each planned
|
||||||
|
report. If collection returns no bundle, app orchestration returns an error
|
||||||
|
before report generation.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
Weather API HTTP details stay in `internal/adapters/weatherapi`. The collection
|
||||||
|
package returns normalized `weatherdata` only. It must not know about report
|
||||||
|
IDs, prompt IDs, batch names, Daily eligibility, module composition, Recent
|
||||||
|
Changes, state paths, or Scriptorium arguments.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Inspect:
|
||||||
|
|
||||||
|
- `internal/collect/collect_test.go`
|
||||||
|
- `internal/app/app_test.go`
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
- App-facing Weather API collection goes through `internal/collect`.
|
||||||
|
- Collection returns normalized source data, not report facts or prompt input.
|
||||||
|
- Report and batch policy belongs outside `internal/collect`.
|
||||||
@@ -6,10 +6,10 @@ This document describes the distributor upload adapter in
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
The adapter submits generated weatherreporter Markdown reports to a configured
|
The adapter submits generated weatherreporter Markdown reports to a configured
|
||||||
distributor HTTP upload endpoint. It isolates distributor package types,
|
distributor HTTP upload endpoint. It supports one or more file mappings per
|
||||||
token-env lookup, upload client construction, source-bundle file mapping,
|
upload request. It isolates distributor package types, token-env lookup, upload
|
||||||
timeout handling, status polling, and upload error wrapping from app
|
client construction, source-bundle file mapping, timeout handling, status
|
||||||
orchestration.
|
polling, and upload error wrapping from app orchestration.
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ Inputs:
|
|||||||
- pipeline ID
|
- pipeline ID
|
||||||
- bundle ID
|
- bundle ID
|
||||||
- idempotency key
|
- idempotency key
|
||||||
- source Markdown report path and bundle-relative path mappings
|
- source Markdown report paths and bundle-relative path mappings
|
||||||
- bundle created timestamp
|
- bundle created timestamp
|
||||||
- context for cancellation
|
- context for cancellation
|
||||||
|
|
||||||
@@ -40,8 +40,9 @@ Outputs:
|
|||||||
|
|
||||||
The app layer passes weatherreporter-owned request values to the adapter. The
|
The app layer passes weatherreporter-owned request values to the adapter. The
|
||||||
adapter does not choose report types, render templates, select output copies,
|
adapter does not choose report types, render templates, select output copies,
|
||||||
configure destinations, wait for downstream publication, transform Markdown, or
|
decide whether an upload represents one report or a batch, configure
|
||||||
persist notification state.
|
destinations, wait for downstream publication, transform Markdown, or persist
|
||||||
|
notification state.
|
||||||
|
|
||||||
Full upstream distributor package and HTTP contract details stay under
|
Full upstream distributor package and HTTP contract details stay under
|
||||||
`docs/integrations/distributor/`.
|
`docs/integrations/distributor/`.
|
||||||
@@ -54,13 +55,22 @@ The adapter is built from `notify.distributor` config:
|
|||||||
- `token_env`
|
- `token_env`
|
||||||
- `timeout`
|
- `timeout`
|
||||||
|
|
||||||
The app layer renders pipeline ID, bundle ID, idempotency key, and bundle paths
|
The app layer renders single-report pipeline ID, bundle ID, idempotency key,
|
||||||
from:
|
and bundle paths from:
|
||||||
|
|
||||||
- `pipeline_id_template`
|
- `pipeline_id_template`
|
||||||
- `bundle_id_template`
|
- `bundle_id_template`
|
||||||
- `idempotency_key_template`
|
- `idempotency_key_template`
|
||||||
- `report_path_templates`
|
- report-specific path templates
|
||||||
|
|
||||||
|
For batch uploads, the app layer renders pipeline ID, bundle ID, and
|
||||||
|
idempotency key from `notify.distributor.batch.*`, resolves report-specific
|
||||||
|
path templates once per included report, and passes the resulting multi-file
|
||||||
|
request to this adapter.
|
||||||
|
|
||||||
|
Report-specific path resolution happens entirely in the app layer. Explicit
|
||||||
|
`reports.<report>.distributor.path_templates` overrides take precedence over
|
||||||
|
report definition defaults.
|
||||||
|
|
||||||
The token value is read from the environment variable named by `token_env`
|
The token value is read from the environment variable named by `token_env`
|
||||||
after config loading and `secrets.directory` processing.
|
after config loading and `secrets.directory` processing.
|
||||||
@@ -70,9 +80,9 @@ after config loading and `secrets.directory` processing.
|
|||||||
The adapter calls distributor `UploadFiles` with one or more file mappings:
|
The adapter calls distributor `UploadFiles` with one or more file mappings:
|
||||||
|
|
||||||
- pipeline ID: the rendered distributor workflow selector
|
- pipeline ID: the rendered distributor workflow selector
|
||||||
- source path: the managed Markdown report path selected by app orchestration
|
- source paths: managed Markdown report paths selected by app orchestration
|
||||||
- bundle paths: rendered bundle-relative report paths
|
- bundle paths: rendered bundle-relative report paths for each source
|
||||||
- created: the report generation timestamp
|
- created: the report or batch generation timestamp
|
||||||
|
|
||||||
The adapter creates a distributor upload client with the configured endpoint,
|
The adapter creates a distributor upload client with the configured endpoint,
|
||||||
bearer token, and timeout-backed HTTP client. It also wraps the upload context
|
bearer token, and timeout-backed HTTP client. It also wraps the upload context
|
||||||
@@ -115,7 +125,8 @@ distributor service.
|
|||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
- Distributor package types do not leak outside the adapter.
|
- Distributor package types do not leak outside the adapter.
|
||||||
- Only the managed Markdown report is uploaded.
|
- Only managed Markdown report paths selected by app orchestration are
|
||||||
|
uploaded.
|
||||||
- The adapter never scans the workspace.
|
- The adapter never scans the workspace.
|
||||||
- Token values are not included in errors, CLI output, metadata, docs, or
|
- Token values are not included in errors, CLI output, metadata, docs, or
|
||||||
examples.
|
examples.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
149
docs/internal/generatedtext.md
Normal file
149
docs/internal/generatedtext.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# 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. It also owns the generated-text catalog that connects report
|
||||||
|
definitions to validators, render-context builders, schema assets, and template
|
||||||
|
assets.
|
||||||
|
|
||||||
|
## Inputs And Outputs
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
|
||||||
|
- raw GeneratedText JSON for Daily, Today, Tomorrow Report, or Hourly Report
|
||||||
|
- report metadata from `internal/briefing`
|
||||||
|
- a module snapshot from `internal/module`
|
||||||
|
- validated generated text
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
|
||||||
|
- typed `Daily` generated text
|
||||||
|
- typed `Today` generated text
|
||||||
|
- typed `Tomorrow` generated text
|
||||||
|
- typed `Hourly` generated text
|
||||||
|
- normalized stable JSON for validated generated text
|
||||||
|
- typed `DailyRenderContext` values for `internal/reporttemplate`
|
||||||
|
- typed `TodayRenderContext` values for `internal/reporttemplate`
|
||||||
|
- typed `TomorrowRenderContext` values for `internal/reporttemplate`
|
||||||
|
- typed `HourlyRenderContext` values for `internal/reporttemplate`
|
||||||
|
- generated-text catalog handlers for report definitions that use
|
||||||
|
`generated_text_template`
|
||||||
|
|
||||||
|
## JSON Contracts
|
||||||
|
|
||||||
|
Daily, Today, and Tomorrow use the same day-style generated-text JSON shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"summary": "string",
|
||||||
|
"forecast_discussion": ["string"],
|
||||||
|
"precipitation_timing": "string",
|
||||||
|
"confidence": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The day-style contract requires `summary` after trimming whitespace.
|
||||||
|
`forecast_discussion` must contain at least one nonblank paragraph after
|
||||||
|
trimming blank items. `precipitation_timing` and `confidence` are optional and
|
||||||
|
omitted from normalized JSON when blank. Unknown fields are rejected.
|
||||||
|
|
||||||
|
The report-specific Go API is:
|
||||||
|
|
||||||
|
| Report | Type | Validator | Schema ID | Template ID | Prompt ID |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| Daily Report | `Daily` | `ValidateDaily` | `daily` | `daily` | `weather.daily_generated_text` |
|
||||||
|
| Today Report | `Today` | `ValidateToday` | `today` | `today` | `weather.today_generated_text` |
|
||||||
|
| Tomorrow Report | `Tomorrow` | `ValidateTomorrow` | `tomorrow` | `tomorrow` | `weather.tomorrow_generated_text` |
|
||||||
|
|
||||||
|
Hourly generated text uses the same top-level field names, but
|
||||||
|
`forecast_discussion` is a single string:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"summary": "string",
|
||||||
|
"forecast_discussion": "string",
|
||||||
|
"precipitation_timing": "string",
|
||||||
|
"confidence": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Hourly `summary` and `forecast_discussion` are required after trimming
|
||||||
|
whitespace. `precipitation_timing` and `confidence` are optional and omitted
|
||||||
|
from normalized JSON when blank. Unknown fields are rejected. The Hourly catalog
|
||||||
|
entry uses type `Hourly`, validator `ValidateHourly`, schema ID `hourly`,
|
||||||
|
template ID `hourly`, and prompt ID `weather.hourly_generated_text`.
|
||||||
|
|
||||||
|
## Render Contexts
|
||||||
|
|
||||||
|
Daily, Today, Tomorrow, and Hourly render contexts all include:
|
||||||
|
|
||||||
|
- display metadata derived from report metadata;
|
||||||
|
- validated generated text;
|
||||||
|
- typed module outputs decoded from the module snapshot;
|
||||||
|
- collected facts;
|
||||||
|
- derived facts.
|
||||||
|
|
||||||
|
Daily, Today, and Tomorrow share common civil-day render-context fields such as
|
||||||
|
forecast date labels, valid period, generated-at labels, current conditions,
|
||||||
|
hourly forecast, precipitation timing, alert digest, SPC outlooks, AFD, SPC
|
||||||
|
discussion, weather story, daily summary, and ordered daypart summaries.
|
||||||
|
|
||||||
|
Each civil-day report keeps its report-specific planning module:
|
||||||
|
|
||||||
|
- Daily exposes `DailyPlanning`.
|
||||||
|
- Today exposes `TodayPlanning`.
|
||||||
|
- Tomorrow exposes `TomorrowPlanning`.
|
||||||
|
|
||||||
|
Today's ordered daypart context omits unavailable or elapsed dayparts according
|
||||||
|
to Today report rules. Daily and Tomorrow use fallback daypart behavior.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- This package owns typed generated-text validation and render-context shaping.
|
||||||
|
- It owns generated-text catalog lookup for schema/template combinations.
|
||||||
|
- It uses typed module snapshot decoding through `module.StanzaValue`.
|
||||||
|
- It does not invoke Scriptorium, write state artifacts, choose report
|
||||||
|
definitions, compare snapshots, or own embedded template/schema files.
|
||||||
|
- It renders through `internal/reporttemplate`; embedded asset lookup remains
|
||||||
|
in `internal/reporttemplate`.
|
||||||
|
- 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 fields fail after trimming whitespace.
|
||||||
|
- Daily, Today, and Tomorrow forecast discussion fails when no nonblank
|
||||||
|
paragraphs remain.
|
||||||
|
- Missing optional render-context stanzas become nil module pointers.
|
||||||
|
- Invalid render metadata, including missing timezone, missing generated time,
|
||||||
|
or invalid valid period, fails before template rendering.
|
||||||
|
- Unsupported generated-text schema IDs, template IDs, or schema/template
|
||||||
|
combinations fail during catalog lookup with report ID context.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Inspect:
|
||||||
|
|
||||||
|
- `internal/generatedtext/hourly_test.go`
|
||||||
|
- `internal/generatedtext/daily_test.go`
|
||||||
|
- `internal/generatedtext/today_test.go`
|
||||||
|
- `internal/generatedtext/tomorrow_test.go`
|
||||||
|
- `internal/generatedtext/catalog_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.
|
||||||
|
- Generated-text-template reports must have one catalog entry matching their
|
||||||
|
report definition schema and template IDs.
|
||||||
|
- Missing optional weather narrative stanzas produce empty or fallback render
|
||||||
|
context fields rather than forcing raw module data into templates.
|
||||||
@@ -5,9 +5,9 @@ This document describes the module contract in `internal/module`.
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/module` defines the shared identifiers and data envelopes used for
|
`internal/module` defines the shared identifiers and data envelopes used for
|
||||||
prompt-facing modules. Report definitions use module IDs for composition,
|
report modules. Report definitions use module IDs for composition, module
|
||||||
module builders produce outputs with stanza names, prompt input packages consume
|
builders produce rich outputs with stanza names, prompt input packages consume
|
||||||
snapshots, and Recent Changes compares snapshot stanzas.
|
runtime prompt export values, and Recent Changes compares snapshot stanzas.
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
@@ -23,8 +23,38 @@ Outputs:
|
|||||||
- typed option structs for registered modules
|
- typed option structs for registered modules
|
||||||
- `module.Snapshot` with schema version `weatherreporter.modules.v1`
|
- `module.Snapshot` with schema version `weatherreporter.modules.v1`
|
||||||
- ordered snapshot outputs with module ID, stanza name, and typed value
|
- ordered snapshot outputs with module ID, stanza name, and typed value
|
||||||
|
- runtime-only prompt export values on module outputs
|
||||||
|
- `module.Output.DataPackageValue`, which selects the prompt export value and
|
||||||
|
falls back to the rich value for hand-built or loaded snapshots
|
||||||
- typed stanza lookup through `module.StanzaValue`
|
- typed stanza lookup through `module.StanzaValue`
|
||||||
|
|
||||||
|
## Rich Values And Prompt Exports
|
||||||
|
|
||||||
|
Each `module.Output` has two value surfaces:
|
||||||
|
|
||||||
|
- `Value`: the rich module value used by templates, module snapshots,
|
||||||
|
inspection, Recent Changes, and render contexts.
|
||||||
|
- `PromptValue`: the runtime-only prompt export used when building Scriptorium
|
||||||
|
data packages.
|
||||||
|
|
||||||
|
`PromptValue` is deliberately excluded from module snapshot JSON. Persisted
|
||||||
|
module snapshots keep only the rich `value` field so inspection and
|
||||||
|
render-context reconstruction keep the full deterministic template surface.
|
||||||
|
|
||||||
|
The `internal/briefing` module registry attaches prompt export values when it
|
||||||
|
builds module outputs. Modules without a custom exporter use default
|
||||||
|
pass-through behavior, so their prompt value is the same as their rich value.
|
||||||
|
Modules with custom prompt export policy own typed prompt export structs near
|
||||||
|
the module builder. Custom prompt exports are:
|
||||||
|
|
||||||
|
- `current_conditions`
|
||||||
|
- `hourly_forecast`
|
||||||
|
- `derived_daypart_summaries`
|
||||||
|
|
||||||
|
Custom exporters remove template-only helpers or confusing duplicates from the
|
||||||
|
data package without shrinking the rich module structs used by templates.
|
||||||
|
Exporter failures include module ID and stanza context.
|
||||||
|
|
||||||
## Registered Module IDs
|
## Registered Module IDs
|
||||||
|
|
||||||
The registry recognizes these IDs:
|
The registry recognizes these IDs:
|
||||||
@@ -37,17 +67,140 @@ 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`
|
||||||
|
- `today_planning`
|
||||||
- `tomorrow_planning`
|
- `tomorrow_planning`
|
||||||
|
- `daily_planning`
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
## Daily Composition
|
||||||
|
|
||||||
|
The default Daily Report module order is:
|
||||||
|
|
||||||
|
1. `metadata`
|
||||||
|
2. `current_conditions`
|
||||||
|
3. `narrative_forecast`
|
||||||
|
4. `derived_daily_summary`
|
||||||
|
5. `derived_daypart_summaries`
|
||||||
|
6. `precip_timing`
|
||||||
|
7. `alert_digest`
|
||||||
|
8. `spc_convective_outlooks`
|
||||||
|
9. `area_forecast_discussion`
|
||||||
|
10. `spc_convective_discussion`
|
||||||
|
11. `weather_story`
|
||||||
|
12. `outdoor_windows`
|
||||||
|
13. `daily_planning`
|
||||||
|
14. `hourly_forecast`
|
||||||
|
|
||||||
|
The embedded Daily template uses selected deterministic fields from these
|
||||||
|
module outputs after GeneratedText validation.
|
||||||
|
|
||||||
|
## Today Composition
|
||||||
|
|
||||||
|
The default Today Report module order is:
|
||||||
|
|
||||||
|
1. `metadata`
|
||||||
|
2. `current_conditions`
|
||||||
|
3. `narrative_forecast`
|
||||||
|
4. `derived_daily_summary`
|
||||||
|
5. `derived_daypart_summaries`
|
||||||
|
6. `precip_timing`
|
||||||
|
7. `alert_digest`
|
||||||
|
8. `spc_convective_outlooks`
|
||||||
|
9. `area_forecast_discussion`
|
||||||
|
10. `spc_convective_discussion`
|
||||||
|
11. `weather_story`
|
||||||
|
12. `outdoor_windows`
|
||||||
|
13. `hourly_forecast`
|
||||||
|
14. `today_planning`
|
||||||
|
|
||||||
|
The embedded Today template uses selected deterministic fields from these
|
||||||
|
module outputs after GeneratedText validation.
|
||||||
|
|
||||||
|
## Tomorrow Composition
|
||||||
|
|
||||||
|
The default Tomorrow Report module order is:
|
||||||
|
|
||||||
|
1. `metadata`
|
||||||
|
2. `current_conditions`
|
||||||
|
3. `narrative_forecast`
|
||||||
|
4. `derived_daily_summary`
|
||||||
|
5. `derived_daypart_summaries`
|
||||||
|
6. `precip_timing`
|
||||||
|
7. `alert_digest`
|
||||||
|
8. `spc_convective_outlooks`
|
||||||
|
9. `area_forecast_discussion`
|
||||||
|
10. `spc_convective_discussion`
|
||||||
|
11. `weather_story`
|
||||||
|
12. `outdoor_windows`
|
||||||
|
13. `tomorrow_planning`
|
||||||
|
14. `hourly_forecast`
|
||||||
|
|
||||||
|
The embedded Tomorrow template uses selected deterministic fields from these
|
||||||
|
module outputs after GeneratedText validation.
|
||||||
|
|
||||||
|
## Daily Planning
|
||||||
|
|
||||||
|
`daily_planning` emits dated daily planning facts for the `daily` report ID.
|
||||||
|
Its output stanza is also named `daily_planning`. The module is supported only
|
||||||
|
by that report ID and depends on daily summaries for the selected local civil
|
||||||
|
day. The default Daily Report composition includes it.
|
||||||
|
|
||||||
|
The output uses this shape:
|
||||||
|
|
||||||
|
- `morning_readiness`
|
||||||
|
- `commute_school_workday_concerns`
|
||||||
|
- `overnight_change_watch`
|
||||||
|
|
||||||
|
The type is `briefing.DailyPlanningModule`; it is independent from
|
||||||
|
`briefing.TomorrowPlanningModule`.
|
||||||
|
|
||||||
|
## Today Planning
|
||||||
|
|
||||||
|
`today_planning` emits current-day planning facts for Today Report. Its output
|
||||||
|
stanza is also named `today_planning`. The module is supported only by Today
|
||||||
|
Report and depends on daily and daypart summaries for the current local civil
|
||||||
|
day.
|
||||||
|
|
||||||
|
The output uses this shape:
|
||||||
|
|
||||||
|
- `morning_readiness`
|
||||||
|
- `commute_school_workday_concerns`
|
||||||
|
- `outdoor_planning`
|
||||||
|
- `late_day_change_watch`
|
||||||
|
|
||||||
|
The type is `briefing.TodayPlanningModule`; it is independent from
|
||||||
|
`briefing.TomorrowPlanningModule`.
|
||||||
|
|
||||||
|
## 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,11 +214,43 @@ 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 risk-product stanza with:
|
||||||
|
|
||||||
|
- `checked`
|
||||||
|
- `as_of`
|
||||||
|
- `issued_at`
|
||||||
|
- `location_id`
|
||||||
|
- `location_name`
|
||||||
|
- `outlook_count`
|
||||||
|
- `outlooks`
|
||||||
|
- `risk_digest`
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
The optional `risk_digest` list is a curated report-rendering subset of
|
||||||
|
categorical outlooks that overlap the report period, contain the configured
|
||||||
|
location, and meet the minimum severity threshold. Entries include `label_text`,
|
||||||
|
`risk_label`, `period_begins`, and `period_ends`; they do not expose 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
|
||||||
envelopes, snapshot validation, and typed stanza lookup.
|
envelopes, snapshot validation, and typed stanza lookup.
|
||||||
- It does not define report IDs, execute builders, fetch weather data, derive
|
- It does not define report IDs, execute builders, collect weather data, derive
|
||||||
forecast facts, write state, or invoke Scriptorium.
|
forecast facts, write state, or invoke Scriptorium.
|
||||||
|
|
||||||
## State Or Manifest Behavior
|
## State Or Manifest Behavior
|
||||||
@@ -73,7 +258,8 @@ validation.
|
|||||||
`module.Snapshot` values are persisted by `internal/state` as JSON. Snapshot
|
`module.Snapshot` values are persisted by `internal/state` as JSON. Snapshot
|
||||||
validation rejects missing schema version, missing module IDs, missing stanza
|
validation rejects missing schema version, missing module IDs, missing stanza
|
||||||
names, duplicate module outputs, and duplicate stanza names while preserving
|
names, duplicate module outputs, and duplicate stanza names while preserving
|
||||||
output order.
|
output order. Snapshot JSON contains rich module values only; runtime prompt
|
||||||
|
export values are not persisted.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
@@ -94,5 +280,8 @@ Inspect:
|
|||||||
|
|
||||||
- `internal/module` does not import `internal/report`.
|
- `internal/module` does not import `internal/report`.
|
||||||
- Module IDs are stable strings.
|
- Module IDs are stable strings.
|
||||||
- Each emitted module output has exactly one stanza name and one typed value.
|
- Each emitted module output has exactly one stanza name and one rich typed
|
||||||
|
value.
|
||||||
|
- Built module outputs have a data-package value, either from a custom prompt
|
||||||
|
exporter or from default pass-through behavior.
|
||||||
- Snapshot output order is caller-owned and preserved.
|
- Snapshot output order is caller-owned and preserved.
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ This document describes YAML prompt data package construction in
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/promptinput` converts report metadata, an ordered module snapshot,
|
`internal/promptinput` converts report metadata, ordered module outputs, Recent
|
||||||
Recent Changes, and source warnings into the `data_package` file passed to
|
Changes, and source warnings into the `data_package` file passed to
|
||||||
Scriptorium.
|
Scriptorium.
|
||||||
|
|
||||||
The persisted data package is YAML with schema version
|
The persisted data package is YAML with schema version
|
||||||
`weatherreporter.data_package.v2`. It is separate from the JSON module snapshot
|
`weatherreporter.data_package.v3`. It is separate from the JSON module snapshot
|
||||||
used for inspection and comparison.
|
used for inspection and comparison. Data packages serialize each module
|
||||||
|
output's prompt export value, not necessarily the full rich module value saved
|
||||||
|
in the module snapshot.
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
@@ -32,7 +34,7 @@ Outputs:
|
|||||||
The YAML shape includes:
|
The YAML shape includes:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
schema_version: weatherreporter.data_package.v2
|
schema_version: weatherreporter.data_package.v3
|
||||||
run_id: <run_id>
|
run_id: <run_id>
|
||||||
report:
|
report:
|
||||||
id: <report_id>
|
id: <report_id>
|
||||||
@@ -41,6 +43,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 +52,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,22 +66,71 @@ 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.
|
||||||
|
|
||||||
|
## Module Export Boundary
|
||||||
|
|
||||||
|
Data packages are curated prompt inputs. They are not full template render
|
||||||
|
contexts and should not be treated as a dump of every field available to Go
|
||||||
|
templates.
|
||||||
|
|
||||||
|
When module outputs are built by `internal/briefing`, the registry attaches a
|
||||||
|
runtime prompt export value. `internal/promptinput` serializes
|
||||||
|
`output.DataPackageValue()` for each stanza. That helper prefers the runtime
|
||||||
|
prompt export and falls back to the rich `Value` when no prompt export is set,
|
||||||
|
which keeps loaded snapshots and hand-built tests usable.
|
||||||
|
|
||||||
|
Modules without custom export policy use pass-through behavior. Modules with
|
||||||
|
custom exports currently include:
|
||||||
|
|
||||||
|
- `current_conditions`: omits lower-case condition text and duplicate
|
||||||
|
wind-direction text.
|
||||||
|
- `hourly_forecast`: omits hour labels, lower-case description text, and the
|
||||||
|
template precipitation-mention helper while keeping forecast facts.
|
||||||
|
- `derived_daypart_summaries`: omits deterministic sentence-construction
|
||||||
|
helpers while keeping daypart period, condition, temperature trend,
|
||||||
|
precipitation, wind, notable-condition, hazard, and alert-relevance facts.
|
||||||
|
|
||||||
|
The rich module snapshot and generated-template render context still contain
|
||||||
|
the helper fields used by deterministic Markdown templates.
|
||||||
|
|
||||||
|
Daily Report, Today Report, Tomorrow Report, and 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.
|
||||||
|
|
||||||
|
The default Daily, Today, and Tomorrow module lists include civil-day summary
|
||||||
|
stanzas, planning stanzas, and `hourly_forecast` in the data package before
|
||||||
|
structured GeneratedText is requested from Scriptorium. Daily uses
|
||||||
|
`daily_planning`, Today uses `today_planning`, and Tomorrow uses
|
||||||
|
`tomorrow_planning`.
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
- This package owns prompt package schema, YAML marshaling, YAML loading, and
|
- This package owns prompt package schema, YAML marshaling, YAML loading, and
|
||||||
validation.
|
validation.
|
||||||
- It does not fetch weather data, derive forecast summaries, execute modules,
|
- It does not collect weather data, derive forecast summaries, execute modules,
|
||||||
find prior snapshots, compare changes, choose artifact paths, or invoke
|
choose module prompt export shapes, find prior snapshots, compare changes,
|
||||||
Scriptorium.
|
choose artifact paths, or invoke Scriptorium.
|
||||||
|
|
||||||
## Config Fields Used
|
## Config Fields Used
|
||||||
|
|
||||||
@@ -117,5 +170,8 @@ Inspect:
|
|||||||
- Scriptorium receives structured YAML through `--input data_package=<path>`.
|
- Scriptorium receives structured YAML through `--input data_package=<path>`.
|
||||||
- Module stanza order is deterministic within each prompt-facing category.
|
- Module stanza order is deterministic within each prompt-facing category.
|
||||||
- Every non-metadata module stanza has exactly one prompt-input category.
|
- Every non-metadata module stanza has exactly one prompt-input category.
|
||||||
|
- Data-package stanzas use curated module prompt exports when present and rich
|
||||||
|
values only as pass-through or fallback values.
|
||||||
|
- Data packages are narrower than generated-template render contexts.
|
||||||
- Recent Changes are provided by `internal/changes`; this package does not
|
- Recent Changes are provided by `internal/changes`; this package does not
|
||||||
infer changes from rendered report text.
|
infer changes from rendered report text.
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
# Report Registry Internals
|
# Report Registry Internals
|
||||||
|
|
||||||
This document describes report identity, valid-period resolution, batch
|
This document describes report identity, valid-period resolution, output
|
||||||
membership, output naming, artifact grouping, and comparison declarations in
|
naming, artifact grouping, batch command names, and comparison declarations in
|
||||||
`internal/report`.
|
`internal/report`.
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/report` is the canonical source for report definitions. App, state,
|
`internal/report` is the canonical source for report definitions, public
|
||||||
module building, and CLI wiring consume resolved definitions instead of owning
|
command names, config-key aliases, and batch command names. App, config, state,
|
||||||
report identity policy themselves.
|
module building, and CLI wiring consume report-owned helpers and resolved
|
||||||
|
definitions instead of owning report identity policy themselves.
|
||||||
|
|
||||||
## Definition Fields
|
## Definition Fields
|
||||||
|
|
||||||
@@ -16,36 +17,58 @@ 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
|
||||||
- batch output copy filename
|
- batch output copy filename
|
||||||
- generated-report eligibility
|
- generated-report eligibility
|
||||||
- prior-report compatibility list
|
- prior-report compatibility list
|
||||||
- morning or evening batch membership
|
|
||||||
- default ordered module composition
|
- default ordered module composition
|
||||||
|
|
||||||
|
Report-owned helpers map public command names and config keys to report IDs.
|
||||||
|
The generate command names are `daily`, `today`, `tomorrow`, `hourly`,
|
||||||
|
`three-day`, `weekend`, and `storm`. Config keys also accept selected
|
||||||
|
underscore and descriptive aliases such as `three_day_outlook`,
|
||||||
|
`weekend_outlook`, and `storm_report`.
|
||||||
|
|
||||||
|
`daily` resolves to the dated Daily Report ID `daily`. `today` resolves to the
|
||||||
|
independent Today report ID `today`. `reports.today` is not an alias for
|
||||||
|
`reports.daily`, and retired report keys are not supported.
|
||||||
|
|
||||||
|
Markdown report definitions use the `scriptorium_markdown` generation mode.
|
||||||
|
Their template and structured-text schema identifiers are empty. Daily Report,
|
||||||
|
Today Report, Tomorrow Report, and Hourly Report declare
|
||||||
|
`generated_text_template`; the app uses their template and schema identifiers
|
||||||
|
to validate generated text and render embedded Markdown templates.
|
||||||
|
|
||||||
## 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 Report | `daily` | `weather.daily_generated_text` | `generated_text_template` | `daily` | `daily.md` | Daily Report |
|
||||||
| Daily Tomorrow | `daily_tomorrow` | `weather.daily_report` | `daily` | `tomorrow.md` | Daily Today, Daily Tomorrow |
|
| Today Report | `today` | `weather.today_generated_text` | `generated_text_template` | `today` | `today.md` | Today Report |
|
||||||
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `three-day` | `three-day.md` | 3-Day Outlook |
|
| Tomorrow Report | `tomorrow` | `weather.tomorrow_generated_text` | `generated_text_template` | `tomorrow` | `tomorrow.md` | Tomorrow Report |
|
||||||
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `weekend` | `weekend.md` | Weekend Outlook |
|
| Hourly Report | `hourly` | `weather.hourly_generated_text` | `generated_text_template` | `hourly` | `hourly.md` | Hourly Report |
|
||||||
| Storm Report | `storm` | `weather.storm_report` | `storm` | `storm.md` | Storm Report |
|
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `scriptorium_markdown` | `three-day` | `three-day.md` | 3-Day Outlook |
|
||||||
|
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `scriptorium_markdown` | `weekend` | `weekend.md` | Weekend Outlook |
|
||||||
|
| Storm Report | `storm` | `weather.storm_report` | `scriptorium_markdown` | `storm` | `storm.md` | Storm Report |
|
||||||
|
|
||||||
All report definitions are eligible for generation.
|
All report definitions are eligible for generation.
|
||||||
|
|
||||||
## Valid Periods
|
## Valid Periods
|
||||||
|
|
||||||
- Daily Today covers the selected local civil day, or the current local civil
|
- Daily Report covers the selected local civil day and requires an explicit
|
||||||
|
date.
|
||||||
|
- Today Report 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.
|
- Tomorrow Report 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.
|
||||||
Sunday morning batch resolution.
|
|
||||||
- Storm Report covers an explicit event window supplied by the caller.
|
- Storm Report covers an explicit event window supplied by the caller.
|
||||||
|
|
||||||
Storm event windows can be parsed from local `YYYY-MM-DDTHH:MM` timestamps in
|
Storm event windows can be parsed from local `YYYY-MM-DDTHH:MM` timestamps in
|
||||||
@@ -54,17 +77,32 @@ must be after start time.
|
|||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
`internal/report` defines report metadata and time coverage. It does not fetch
|
`internal/report` defines report metadata, public report names, batch command
|
||||||
weather data, build module values, compare snapshot contents, write state,
|
names, output naming, and time coverage. It does not collect weather data, plan
|
||||||
|
batch membership, build module values, compare snapshot contents, write state,
|
||||||
parse CLI flags, or invoke Scriptorium.
|
parse CLI flags, or invoke Scriptorium.
|
||||||
|
|
||||||
The CLI owns public command names. The app maps those command names to report
|
The CLI parses flags and command structure, then uses report-owned helpers for
|
||||||
IDs, then uses the registry for report policy.
|
report and batch command names. Config loading uses report-owned helpers for
|
||||||
|
report override keys.
|
||||||
|
|
||||||
## 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 short keys such as `daily`, `today`, `tomorrow`, and
|
||||||
|
`hourly`, or descriptive names such as `three_day_outlook`.
|
||||||
|
|
||||||
|
## Batch Commands
|
||||||
|
|
||||||
|
`internal/report` owns the public batch command names `morning` and `evening`
|
||||||
|
and validates them through `BatchForCommandName`. Data-dependent batch
|
||||||
|
membership is owned by `internal/app`, because it depends on collected hourly
|
||||||
|
forecast coverage.
|
||||||
|
|
||||||
|
Report definitions still declare default batch output copy filenames. App
|
||||||
|
batch planning uses those filenames for fixed report entries and supplies
|
||||||
|
date-qualified names for dynamic Daily entries.
|
||||||
|
|
||||||
## State And App Usage
|
## State And App Usage
|
||||||
|
|
||||||
@@ -92,8 +130,14 @@ Inspect:
|
|||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
- Report selection goes through the registry.
|
- Report selection goes through the registry.
|
||||||
- Daily Today and Daily Tomorrow both use `weather.daily_report`.
|
- Public command names, config-key aliases, and batch command names are owned
|
||||||
|
by `internal/report`.
|
||||||
|
- Direct Markdown reports have empty template and generated-text schema IDs.
|
||||||
|
- Generated-text-template reports declare prompt, template, and schema IDs in
|
||||||
|
their report definition.
|
||||||
- Valid periods are half-open intervals independent of rendered report text.
|
- Valid periods are half-open intervals independent of rendered report text.
|
||||||
- Artifact grouping, batch output filenames, generated-report eligibility,
|
- Artifact grouping, batch output filenames, generated-report eligibility,
|
||||||
default module composition, comparison compatibility, and comparison strategy
|
default module composition, comparison compatibility, and comparison strategy
|
||||||
are declared by report definition.
|
are declared by report definition.
|
||||||
|
- App-owned batch planning uses report definitions but does not live in the
|
||||||
|
report registry.
|
||||||
|
|||||||
125
docs/internal/reporttemplate.md
Normal file
125
docs/internal/reporttemplate.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# 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 implemented template assets are Daily, Today,
|
||||||
|
Tomorrow, and Hourly.
|
||||||
|
|
||||||
|
The package embeds assets from:
|
||||||
|
|
||||||
|
- `internal/reporttemplate/templates/*.md.tmpl`
|
||||||
|
- `internal/reporttemplate/templates/partials/*.md.tmpl`
|
||||||
|
- `internal/reporttemplate/schemas/*.schema.json`
|
||||||
|
|
||||||
|
Generated-text prompt source files live under
|
||||||
|
`internal/reporttemplate/prompts/`. They are repository assets for prompt
|
||||||
|
registration, not embedded lookup APIs.
|
||||||
|
|
||||||
|
## 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 IDs are `daily`, `today`, `tomorrow`, and `hourly`.
|
||||||
|
The implemented schema IDs are also `daily`, `today`, `tomorrow`, and
|
||||||
|
`hourly`, backed by matching `*.generated_text.schema.json` files.
|
||||||
|
|
||||||
|
Generated-text prompt sources are maintained under
|
||||||
|
`internal/reporttemplate/prompts/`, including Daily's
|
||||||
|
`daily.generated_text.md` source for prompt ID `weather.daily_generated_text`.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
This package owns embedded asset lookup, Go template parsing, and Markdown
|
||||||
|
template execution. It does not collect 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 uses `internal/generatedtext` catalog lookup to connect
|
||||||
|
`internal/report` definition schema/template IDs to the matching validator,
|
||||||
|
render-context builder, and embedded assets.
|
||||||
|
|
||||||
|
## Template Contracts
|
||||||
|
|
||||||
|
Daily, Today, Tomorrow, and Hourly rendering use typed render contexts with:
|
||||||
|
|
||||||
|
- report metadata labels such as title, location, valid period, and generation
|
||||||
|
time
|
||||||
|
- validated 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
|
||||||
|
|
||||||
|
Daily, Today, and Tomorrow additionally expose forecast-date labels, ordered
|
||||||
|
daypart forecast rows, daily/daypart summaries, planning facts, and a
|
||||||
|
multi-paragraph forecast discussion generated-text slot. The ordered daypart
|
||||||
|
slice is built in Go so templates do not range over maps.
|
||||||
|
|
||||||
|
The Daily template asset uses the same Markdown structure as Tomorrow's
|
||||||
|
template and renders from `generatedtext.DailyRenderContext`.
|
||||||
|
|
||||||
|
Templates use `text/template` with `missingkey=error`, so missing context fields
|
||||||
|
fail rendering instead of producing incomplete Markdown.
|
||||||
|
|
||||||
|
Daily and Tomorrow call the shared `daypart_forecast` partial. Today calls
|
||||||
|
`today_daypart_forecast` so it can omit elapsed or missing dayparts. Daily,
|
||||||
|
Today, Tomorrow, and Hourly call the shared `alert_digest` and
|
||||||
|
`precipitation_timing` partials. Partial files are parsed with each top-level
|
||||||
|
template at render time and receive the same typed render context as the
|
||||||
|
caller. The `alert_digest` partial renders the combined Alerts and Risk
|
||||||
|
Products section from relevant NWS alerts and curated SPC outlook digest
|
||||||
|
records.
|
||||||
|
|
||||||
|
## Schema Contract
|
||||||
|
|
||||||
|
The GeneratedText schemas describe the structured prose Scriptorium is expected
|
||||||
|
to write for each generated-text prompt. Hourly requires:
|
||||||
|
|
||||||
|
- `summary`
|
||||||
|
- `forecast_discussion`
|
||||||
|
|
||||||
|
Daily, Today, and Tomorrow require `summary` and a nonempty
|
||||||
|
`forecast_discussion` array. All generated-text schemas allow optional
|
||||||
|
`precipitation_timing` and `confidence`, and reject 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.
|
||||||
|
- Partial read or parse errors include the partial path.
|
||||||
|
- 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.
|
||||||
|
- Shared Markdown partials live under `templates/partials/`.
|
||||||
|
- 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,16 +28,17 @@ 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
|
||||||
|
|
||||||
`internal/adapters/scriptorium` owns Scriptorium command construction and
|
`internal/adapters/scriptorium` owns Scriptorium command construction and
|
||||||
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.
|
collect 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,16 @@ 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
|
||||||
|
- distributor notification debug artifact paths
|
||||||
- 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
|
||||||
@@ -55,22 +62,35 @@ Workspace subdirectories must be relative paths that stay under
|
|||||||
## Managed Layout
|
## Managed Layout
|
||||||
|
|
||||||
Paths are derived from the resolved report definition's artifact group, the
|
Paths are derived from the resolved report definition's artifact group, the
|
||||||
valid-period start date for dated artifacts, and the RunID.
|
valid-period start date, and the RunID. Filenames put the artifact kind before
|
||||||
|
the RunID.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<workspace.root>/
|
<workspace.root>/
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.modules.json
|
reports/<artifact_group>/<YYYY-MM-DD>/report.<run_id>.md
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.metadata.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/modules.<run_id>.json
|
||||||
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.yaml
|
snapshots/<artifact_group>/<YYYY-MM-DD>/metadata.<run_id>.json
|
||||||
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_raw.<run_id>.json
|
||||||
notifications/<artifact_group>/<YYYY-MM-DD>/<run_id>.distributor.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_result.<run_id>.json
|
||||||
reports/<artifact_group>/<run_id>.md
|
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text.<run_id>.json
|
||||||
|
snapshots/<artifact_group>/<YYYY-MM-DD>/render_context.<run_id>.json
|
||||||
|
data-packages/<artifact_group>/<YYYY-MM-DD>/data_package.<run_id>.yaml
|
||||||
|
preflight/<artifact_group>/<YYYY-MM-DD>/render.<run_id>.json
|
||||||
|
notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json
|
||||||
|
notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
Batch notification artifacts are stored under the notifications tree rather
|
||||||
|
than report metadata because they describe a batch-level upload. The date
|
||||||
|
directory is the batch start date in the effective report timezone.
|
||||||
|
|
||||||
## Prior Lookup
|
## Prior Lookup
|
||||||
|
|
||||||
@@ -78,28 +98,58 @@ Prior snapshot lookup reads stored metadata through the shared lookup path and
|
|||||||
selects the latest earlier snapshot whose report ID is compatible with the
|
selects the latest earlier snapshot whose report ID is compatible with the
|
||||||
current report definition.
|
current report definition.
|
||||||
|
|
||||||
- Daily Today and Daily Tomorrow are compatible with each other for the same
|
- Daily Report compares with prior Daily Report snapshots for the same valid
|
||||||
|
local date.
|
||||||
|
- Today Report compares with prior Today Report snapshots for the same valid
|
||||||
|
local date.
|
||||||
|
- Tomorrow Report compares with prior Tomorrow Report snapshots for the same
|
||||||
valid local date.
|
valid local date.
|
||||||
- 3-Day Outlook compares with prior 3-Day snapshots for the same valid local
|
- 3-Day Outlook compares with prior 3-Day snapshots for the same valid local
|
||||||
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
|
Single-report notification artifacts use schema version
|
||||||
files. Missing metadata directories return no inspection records or no prior
|
`weatherreporter.distributor_notification.v1` and record one managed source
|
||||||
snapshot rather than creating state.
|
path plus that source's bundle paths. Batch notification artifacts use schema
|
||||||
|
version `weatherreporter.batch_distributor_notification.v1` and record:
|
||||||
|
|
||||||
|
- `batch`
|
||||||
|
- `batchRunId`
|
||||||
|
- `attemptedAt`
|
||||||
|
- `endpoint`
|
||||||
|
- `pipelineId`
|
||||||
|
- `bundleId`
|
||||||
|
- `idempotencyKey`
|
||||||
|
- `bundleCreated`
|
||||||
|
- `includedReports`, each with `reportId`, `runId`, `sourcePath`, and
|
||||||
|
`bundlePaths`
|
||||||
|
- `status`
|
||||||
|
- `upload`
|
||||||
|
- `runStatus`
|
||||||
|
- `statusError`
|
||||||
|
- `error`
|
||||||
|
|
||||||
|
Inspection helpers read existing metadata, module snapshot, data package,
|
||||||
|
generated text, generated-text run result, and render context files. Missing
|
||||||
|
metadata directories return no inspection records or no prior snapshot rather
|
||||||
|
than creating state.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
@@ -121,4 +171,8 @@ 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.
|
||||||
|
- Batch notification artifacts live under `notifications/batches` and are not
|
||||||
|
linked from report 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
|
||||||
|
|
||||||
|
|||||||
@@ -10,20 +10,34 @@ Generation commands:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
weatherreporter generate daily --date 2026-05-29
|
weatherreporter generate daily --date 2026-05-29
|
||||||
|
weatherreporter generate today
|
||||||
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
|
Generation commands resolve a report period, collect a Weather API bundle,
|
||||||
JSON module snapshot, builds a YAML prompt input data package, runs
|
build a rich JSON module snapshot, build a curated YAML prompt input data
|
||||||
`scriptorium render`, runs `scriptorium run`, and writes managed artifacts under
|
package, run `scriptorium render`, and write managed artifacts under the
|
||||||
the configured workspace.
|
configured workspace. Markdown-path reports then run `scriptorium run` directly
|
||||||
|
to the managed Markdown report path.
|
||||||
|
|
||||||
|
`generate daily`, `generate today`, `generate tomorrow`, and `generate hourly`
|
||||||
|
use the generated-text-template workflow. They run structured `scriptorium run`
|
||||||
|
to raw GeneratedText JSON, validate the structured text, save a render context,
|
||||||
|
and render the managed Markdown report from embedded templates. `generate
|
||||||
|
daily` requires `--date YYYY-MM-DD` for the selected local civil day.
|
||||||
|
`generate today` covers the selected or current local civil day. `generate
|
||||||
|
hourly` covers the six-hour rolling period from generation time in the
|
||||||
|
effective report timezone and is not included in `run morning` or
|
||||||
|
`run evening`.
|
||||||
|
|
||||||
When distributor notification is enabled, weatherreporter uploads the managed
|
When distributor notification is enabled, weatherreporter uploads the managed
|
||||||
Markdown report after `scriptorium run` succeeds and final metadata is saved.
|
Markdown report after report rendering succeeds and final metadata is saved.
|
||||||
`--out PATH` writes an extra Markdown copy for the current generated report; it
|
`--out PATH` writes an extra Markdown copy for generated reports; it is not used
|
||||||
is not used as the distributor upload source.
|
as the distributor upload source.
|
||||||
|
|
||||||
Batch commands:
|
Batch commands:
|
||||||
|
|
||||||
@@ -32,15 +46,24 @@ weatherreporter run morning
|
|||||||
weatherreporter run evening
|
weatherreporter run evening
|
||||||
```
|
```
|
||||||
|
|
||||||
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
`run morning` generates Today Report, Tomorrow Report, and a dated Daily Report
|
||||||
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch
|
for each later future local civil day with complete hourly forecast coverage.
|
||||||
commands print a JSON summary to stdout, write compact per-report status lines
|
`run evening` generates Tomorrow Report and the same eligible future Daily
|
||||||
to stderr, continue independent reports after one report fails, and return
|
reports. Future Daily expansion starts with the day after tomorrow. A Daily
|
||||||
nonzero when any report failed. When notification is configured, the summary and
|
report is eligible only when the collected hourly forecast contains every
|
||||||
status lines include notification status, accepted distributor run ID, or
|
hourly period for that local civil day; partial days are skipped. Batch commands
|
||||||
notification error fields for each attempted report. `--out-dir PATH` writes
|
collect weather data once before planning, and a collection failure stops the
|
||||||
extra Markdown copies using report default filenames such as `daily.md`,
|
batch before any report is generated.
|
||||||
`three-day.md`, `weekend.md`, and `tomorrow.md`; these copies are not used as
|
|
||||||
|
After planning succeeds, batch commands print a JSON summary to stdout, write
|
||||||
|
compact per-report status lines to stderr, continue independent reports after
|
||||||
|
one report fails, and return nonzero when any report failed. Batch commands do
|
||||||
|
not upload each report independently. When distributor notification and batch
|
||||||
|
notification are enabled, weatherreporter uploads one distributor bundle only
|
||||||
|
after every planned report succeeds. If any report fails, the batch upload is
|
||||||
|
skipped for the whole batch. `--out-dir PATH` writes extra Markdown copies
|
||||||
|
using report default filenames such as `today.md` and `tomorrow.md`; dynamic
|
||||||
|
Daily copies use `daily-YYYY-MM-DD.md`. These copies are not used as
|
||||||
distributor upload sources.
|
distributor upload sources.
|
||||||
|
|
||||||
## Filesystem Layout
|
## Filesystem Layout
|
||||||
@@ -49,82 +72,45 @@ The default workspace root is `workspace`.
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
workspace/
|
workspace/
|
||||||
snapshots/
|
reports/<artifact_group>/<YYYY-MM-DD>/report.<run_id>.md
|
||||||
daily/
|
|
||||||
YYYY-MM-DD/
|
snapshots/<artifact_group>/<YYYY-MM-DD>/modules.<run_id>.json
|
||||||
<run_id>.modules.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/metadata.<run_id>.json
|
||||||
<run_id>.metadata.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_raw.<run_id>.json
|
||||||
three-day/
|
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_result.<run_id>.json
|
||||||
YYYY-MM-DD/
|
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text.<run_id>.json
|
||||||
<run_id>.modules.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/render_context.<run_id>.json
|
||||||
<run_id>.metadata.json
|
|
||||||
weekend/
|
data-packages/<artifact_group>/<YYYY-MM-DD>/data_package.<run_id>.yaml
|
||||||
YYYY-MM-DD/
|
preflight/<artifact_group>/<YYYY-MM-DD>/render.<run_id>.json
|
||||||
<run_id>.modules.json
|
|
||||||
<run_id>.metadata.json
|
notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json
|
||||||
storm/
|
notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.modules.json
|
|
||||||
<run_id>.metadata.json
|
|
||||||
data-packages/
|
|
||||||
daily/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.data_package.yaml
|
|
||||||
three-day/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.data_package.yaml
|
|
||||||
weekend/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.data_package.yaml
|
|
||||||
storm/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.data_package.yaml
|
|
||||||
preflight/
|
|
||||||
daily/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.render.json
|
|
||||||
three-day/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.render.json
|
|
||||||
weekend/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.render.json
|
|
||||||
storm/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.render.json
|
|
||||||
notifications/
|
|
||||||
daily/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.distributor.json
|
|
||||||
three-day/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.distributor.json
|
|
||||||
weekend/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.distributor.json
|
|
||||||
storm/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.distributor.json
|
|
||||||
reports/
|
|
||||||
daily/
|
|
||||||
<run_id>.md
|
|
||||||
three-day/
|
|
||||||
<run_id>.md
|
|
||||||
weekend/
|
|
||||||
<run_id>.md
|
|
||||||
storm/
|
|
||||||
<run_id>.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Managed artifact filenames use the RunID, so repeated runs for the same valid
|
Managed artifact filenames use the artifact kind and RunID, so repeated runs
|
||||||
period do not overwrite each other.
|
for the same valid period do not overwrite each other. The date directory is
|
||||||
|
the valid-period start date in the effective report timezone. Generated-text
|
||||||
|
artifacts are written only for Daily, Today, Tomorrow, and Hourly reports.
|
||||||
|
|
||||||
## RunID And Metadata
|
## RunID And Metadata
|
||||||
|
|
||||||
RunIDs are based on generation time plus report ID:
|
RunIDs are based on generation time plus report ID. Reports that can be
|
||||||
|
generated more than once in a single command may append a report-specific
|
||||||
|
disambiguator. Daily appends the local valid date so multiple dynamic Daily
|
||||||
|
reports in one batch have distinct managed artifacts:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
20260529T100000.123456789Z_daily_today
|
20260529T100000.123456789Z_daily_2026-05-31
|
||||||
|
20260529T100000.123456789Z_today
|
||||||
|
```
|
||||||
|
|
||||||
|
Batch notification RunIDs use the batch start timestamp plus the batch command
|
||||||
|
name:
|
||||||
|
|
||||||
|
```text
|
||||||
|
20260529T100000.123456789Z_morning
|
||||||
|
20260529T220000.123456789Z_evening
|
||||||
```
|
```
|
||||||
|
|
||||||
Each generated report writes metadata that links:
|
Each generated report writes metadata that links:
|
||||||
@@ -136,21 +122,36 @@ 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
|
||||||
|
generated-text-template reports
|
||||||
- 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, valid
|
||||||
outcome when attempted, valid period, and known artifact paths for each
|
period, and known artifact paths for each attempted report. Single-report
|
||||||
attempted report. Notification fields are `notificationStatus`,
|
notification fields on report items are empty for batch commands. When a batch
|
||||||
`notificationRunId`, and `notificationError`.
|
notification is attempted, skipped, or fails, the summary includes one
|
||||||
|
top-level `notification` object with fields such as `status`, `reason`,
|
||||||
|
`runId`, `pipelineId`, `bundleId`, `idempotencyKey`, `path`,
|
||||||
|
`includedReports`, and `error`.
|
||||||
|
|
||||||
## Distributor Notification
|
## Distributor Notification
|
||||||
|
|
||||||
Distributor notification is configured with `notify.distributor` and is
|
Distributor notification is configured with `notify.distributor` and is
|
||||||
disabled by default. When enabled, weatherreporter uploads the managed Markdown
|
disabled by default. For `generate <report>`, weatherreporter uploads the
|
||||||
report path recorded in the report result and metadata. That single source file
|
managed Markdown report path recorded in the report result and metadata. That
|
||||||
can be mapped to one or more configured bundle paths. By default, it is mapped
|
single source file is mapped to report-specific bundle paths. Extra copies
|
||||||
to one dated report path. Extra copies written by `--out` or `--out-dir` are
|
written by `--out` or `--out-dir` are operator conveniences only.
|
||||||
operator conveniences only.
|
|
||||||
|
For `run morning` and `run evening`, per-report notification is suppressed. If
|
||||||
|
`notify.distributor.enabled` and `notify.distributor.batch.enabled` are both
|
||||||
|
true, the batch uploads once after all reports finish successfully. The upload
|
||||||
|
contains one file mapping set per included report. Each mapping uses the
|
||||||
|
managed Markdown report as the source and report-specific path templates for
|
||||||
|
that report. All rendered bundle paths across the batch must be unique. If any
|
||||||
|
report fails, weatherreporter records a top-level
|
||||||
|
notification status of `skipped` with reason `one or more reports failed` and
|
||||||
|
does not call distributor. If batch notification is disabled, run commands do
|
||||||
|
not fall back to per-report uploads.
|
||||||
|
|
||||||
The rendered pipeline ID selects the configured distributor `http_upload`
|
The rendered pipeline ID selects the configured distributor `http_upload`
|
||||||
workflow. The default bundle ID is a stable logical source identity derived from
|
workflow. The default bundle ID is a stable logical source identity derived from
|
||||||
@@ -160,26 +161,40 @@ producer name, location ID, and report ID:
|
|||||||
weatherreporter.{location_id}.{report_id}
|
weatherreporter.{location_id}.{report_id}
|
||||||
```
|
```
|
||||||
|
|
||||||
The default idempotency key appends RunID to the rendered bundle ID so each
|
The default single-report idempotency key appends RunID to the rendered bundle
|
||||||
report generation has a distinct retry identity. The default bundle path uses
|
ID so each report generation has a distinct retry identity. The default bundle
|
||||||
the valid-period start date, artifact group, and RunID. Distributor owns
|
path uses the valid-period start date, artifact group, and RunID. Batch bundle
|
||||||
destination merge, retention, and derived snapshot behavior such as `latest`.
|
IDs default to `weatherreporter.{location_id}.{batch}`, and batch idempotency
|
||||||
|
keys default to `{bundle_id}.{batch_run_id}`. Distributor owns destination
|
||||||
|
merge, retention, and derived snapshot behavior such as `latest`. For Daily,
|
||||||
|
the default report ID and artifact group values are both `daily`, and the
|
||||||
|
default output filename value is `daily.md`. For Today, the default report ID
|
||||||
|
and artifact group values are both `today`, and the batch output filename value
|
||||||
|
is `today.md`.
|
||||||
|
|
||||||
Notification happens after final metadata save. Weather API, module snapshot,
|
Single-report notification happens after final metadata save for generated
|
||||||
data-package, render preflight, Scriptorium run, and metadata-save failures do
|
reports. Batch notification happens after all planned reports have finished and
|
||||||
not trigger notification. A notification failure fails that report.
|
only when all report generations succeeded. Collection, module snapshot,
|
||||||
In a batch, other reports continue, the failed report includes notification
|
data-package, render preflight, Scriptorium run, generated-text validation,
|
||||||
fields in the JSON summary, and the batch returns nonzero.
|
template rendering, and metadata-save failures do not trigger notification. A
|
||||||
|
single-report notification failure fails that report. A batch notification
|
||||||
|
failure makes the batch return nonzero and increments the aggregate failure
|
||||||
|
count, but individual report items remain succeeded.
|
||||||
|
|
||||||
Each notification attempt writes a debug artifact under `notifications/`. The
|
Each notification attempt writes a debug artifact under `notifications/`.
|
||||||
artifact records the rendered pipeline ID, bundle ID, idempotency key, managed
|
Single-report artifacts live under
|
||||||
source path, bundle-relative paths, bundle created timestamp, accepted upload
|
`notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json`. Batch
|
||||||
response, and the latest distributor run status response when available.
|
artifacts live under
|
||||||
Weatherreporter polls status until distributor reports `succeeded` or `failed`,
|
`notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json`,
|
||||||
or until the configured notification timeout expires. The run status includes
|
where the date directory is the batch start date in the effective report
|
||||||
the distributor status, error text, and raw run report JSON, which can show
|
timezone. The artifact records the rendered pipeline ID, bundle ID,
|
||||||
actions such as `replace_older`, `skip_same`, `skip_destination_newer`, or
|
idempotency key, managed source paths, bundle-relative paths, bundle created
|
||||||
`failed`. Token values are not written.
|
timestamp, accepted upload response, and the latest distributor run status
|
||||||
|
response when available. Weatherreporter polls status until distributor reports
|
||||||
|
`succeeded` or `failed`, or until the configured notification timeout expires.
|
||||||
|
The run status includes the distributor status, error text, and raw run report
|
||||||
|
JSON, which can show actions such as `replace_older`, `skip_same`,
|
||||||
|
`skip_destination_newer`, or `failed`. Token values are not written.
|
||||||
|
|
||||||
Weatherreporter is responsible for selecting the managed Markdown report,
|
Weatherreporter is responsible for selecting the managed Markdown report,
|
||||||
constructing a source bundle, and submitting it to the configured distributor
|
constructing a source bundle, and submitting it to the configured distributor
|
||||||
@@ -191,7 +206,7 @@ bundle, so existing uploaded dated report paths can remain available.
|
|||||||
## Inspection
|
## Inspection
|
||||||
|
|
||||||
Inspection commands read existing workspace artifacts and emit JSON to stdout.
|
Inspection commands read existing workspace artifacts and emit JSON to stdout.
|
||||||
They do not fetch weather data or run `scriptorium`.
|
They do not collect weather data or run `scriptorium`.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
weatherreporter inspect reports --limit 10
|
weatherreporter inspect reports --limit 10
|
||||||
@@ -204,22 +219,25 @@ weatherreporter inspect sources RUN_ID
|
|||||||
|
|
||||||
Use `inspect reports` to find RunIDs and artifact paths. Use
|
Use `inspect reports` to find RunIDs and artifact paths. Use
|
||||||
`inspect metadata` to see the artifact links recorded for a run. Use
|
`inspect metadata` to see the artifact links recorded for a run. Use
|
||||||
`inspect modules` to review the persisted ordered module snapshot, and
|
`inspect modules` to review the persisted ordered module snapshot with rich
|
||||||
`inspect data-package` to review the structured prompt package used for
|
template-facing values, and `inspect data-package` to review the curated prompt
|
||||||
rendering. Use `inspect prior` to see the prior comparable snapshot selected for
|
package passed to Scriptorium. Use `inspect prior` to see the prior comparable
|
||||||
Recent Changes, or `null` when none exists. Use `inspect sources` to review
|
snapshot selected for Recent Changes, or `null` when none exists. Use
|
||||||
source provenance and warnings without dumping full weather payloads.
|
`inspect sources` to review source provenance and warnings without dumping full
|
||||||
|
weather payloads.
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
|
||||||
Recent Changes are computed from structured module snapshots, not rendered
|
Recent Changes are computed from structured module snapshots, not rendered
|
||||||
Markdown or YAML text.
|
Markdown or YAML text.
|
||||||
|
|
||||||
Daily Today and Daily Tomorrow can compare with each other when they cover the
|
Daily Report compares with prior Daily Report snapshots for the same valid
|
||||||
same valid local date. 3-Day Outlook compares with prior compatible 3-Day
|
local date. Today Report compares with prior Today Report snapshots for the
|
||||||
snapshots for the same valid local date. Weekend Outlook compares with prior
|
same valid local date. Tomorrow Report compares with prior Tomorrow Report
|
||||||
compatible Weekend snapshots for the same weekend window. Storm Report leaves
|
snapshots for the same valid local date. 3-Day Outlook compares with prior
|
||||||
Recent Changes empty.
|
compatible 3-Day snapshots for the same valid local date. Weekend Outlook
|
||||||
|
compares with prior compatible Weekend snapshots for the same weekend window.
|
||||||
|
Hourly Report and Storm Report leave Recent Changes empty.
|
||||||
|
|
||||||
When no prior comparable snapshot exists, or no configured threshold is crossed,
|
When no prior comparable snapshot exists, or no configured threshold is crossed,
|
||||||
`recentChanges.items` is empty.
|
`recentChanges.items` is empty.
|
||||||
@@ -232,10 +250,17 @@ 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.
|
||||||
- If distributor notification fails, report artifacts and final metadata remain
|
- Generated-text failures for Daily, Today, Tomorrow, and Hourly reports preserve
|
||||||
available, but the report or batch command returns nonzero.
|
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 single-report distributor notification fails, report artifacts and final
|
||||||
|
metadata remain available, but the report command returns nonzero.
|
||||||
|
- If batch distributor notification fails, report artifacts and final metadata
|
||||||
|
remain available, the top-level batch notification links the debug artifact,
|
||||||
|
and the 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
|
||||||
artifact paths for each failed report.
|
artifact paths for each failed report or the top-level notification path.
|
||||||
|
|
||||||
For a bad report, start with:
|
For a bad report, start with:
|
||||||
|
|
||||||
|
|||||||
@@ -5,18 +5,22 @@ documented outside `docs/roadmap/`.
|
|||||||
|
|
||||||
## Automatic Storm Monitoring
|
## Automatic Storm Monitoring
|
||||||
|
|
||||||
Manual Storm Report generation is implemented through
|
Manual Storm Report generation is available through:
|
||||||
`weatherreporter generate storm --start TIME --end TIME`. Automatic storm-event
|
|
||||||
evaluation remains deferred.
|
|
||||||
|
|
||||||
Proposed direction:
|
```sh
|
||||||
|
weatherreporter generate storm --start TIME --end TIME
|
||||||
|
```
|
||||||
|
|
||||||
1. detect candidate events deterministically from alerts, forecast discussion,
|
Automatic storm-event evaluation is not implemented.
|
||||||
weather story context, hourly thresholds, and material forecast changes;
|
|
||||||
2. evaluate candidates through Scriptorium or another narrow evaluator adapter;
|
Possible direction:
|
||||||
3. persist storm lifecycle state;
|
|
||||||
4. generate or update Storm Reports only when a meaningful event is present;
|
1. Detect candidate storm events from alerts, forecast discussion, weather
|
||||||
5. suppress ordinary low-impact thunder or rain chances.
|
story context, hourly thresholds, and material forecast changes.
|
||||||
|
2. Evaluate candidates through Scriptorium or another narrow evaluator adapter.
|
||||||
|
3. Persist storm lifecycle state.
|
||||||
|
4. Generate or update Storm Reports only when a meaningful event is present.
|
||||||
|
5. Suppress ordinary low-impact thunder or rain chances.
|
||||||
|
|
||||||
Possible lifecycle states:
|
Possible lifecycle states:
|
||||||
|
|
||||||
@@ -27,40 +31,41 @@ Possible lifecycle states:
|
|||||||
- `deescalating`
|
- `deescalating`
|
||||||
- `resolved`
|
- `resolved`
|
||||||
|
|
||||||
Acceptance criteria before implementation:
|
Before implementation, the design must preserve scheduled report behavior,
|
||||||
|
manual Storm Report generation, inspectable evaluator failures, and fixture
|
||||||
|
coverage for deterministic candidate detection.
|
||||||
|
|
||||||
- scheduled reports and manual Storm Reports remain stable;
|
## Future Report Types
|
||||||
- candidate detection has fixture coverage;
|
|
||||||
- evaluator failures are inspectable and do not create noisy report output;
|
|
||||||
- manual Storm Report generation remains available.
|
|
||||||
|
|
||||||
## Future Report Types And Modules
|
|
||||||
|
|
||||||
The module-based prompt package architecture is implemented. Future work should
|
|
||||||
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;
|
- a short-fuse planning report distinct from the implemented Hourly Report, if
|
||||||
- event-specific reports with stable event IDs;
|
a separate product is needed
|
||||||
- storm review or yesterday-style reports using historical observations;
|
- event-specific reports with stable event IDs
|
||||||
|
- storm review or yesterday-style reports using historical observations
|
||||||
- archive-focused report variants if generated report history becomes a
|
- archive-focused report variants if generated report history becomes a
|
||||||
first-class product.
|
first-class product
|
||||||
|
|
||||||
|
New reports should keep report identity, prompt IDs, templates, valid-period
|
||||||
|
resolution, artifact grouping, batch output names, and comparison policy inside
|
||||||
|
`internal/report`.
|
||||||
|
|
||||||
|
## Future Modules
|
||||||
|
|
||||||
Possible future modules:
|
Possible future modules:
|
||||||
|
|
||||||
- `hourly_table` for compact valid-period hourly facts;
|
- `hourly_table` for compact valid-period hourly facts
|
||||||
- `forecast_delta` if a separate stanza is useful beyond current Recent
|
- `forecast_delta` if a separate stanza is useful beyond current Recent
|
||||||
Changes;
|
Changes
|
||||||
- `weekend_planning` if weekend-specific planning guidance needs a dedicated
|
- `weekend_planning` if weekend-specific planning guidance needs a dedicated
|
||||||
deterministic stanza;
|
deterministic stanza
|
||||||
- `storm_window_summary` if manual or automatic Storm Reports need a dedicated
|
- `storm_window_summary` if manual or automatic Storm Reports need a dedicated
|
||||||
prompt-facing storm-window module;
|
prompt-facing storm-window module
|
||||||
- separate AFD section aliases, such as `afd_key_messages`,
|
- separate AFD section aliases, such as `afd_key_messages`,
|
||||||
`afd_short_term_text`, and `afd_long_term_text`, if separate stanzas prove
|
`afd_short_term_text`, and `afd_long_term_text`, if separate stanzas prove
|
||||||
more useful than `area_forecast_discussion.options.sections`;
|
more useful than `area_forecast_discussion.options.sections`
|
||||||
- SPC, radar, QPF, snow/rain total, or historical-observation modules once
|
- SPC, radar, QPF, snow/rain total, or historical-observation modules once
|
||||||
upstream sources and report requirements exist.
|
upstream sources and report requirements exist
|
||||||
|
|
||||||
QPF fields such as `measurable_qpf_total_in` and `max_hourly_qpf_in` should
|
QPF fields such as `measurable_qpf_total_in` and `max_hourly_qpf_in` should
|
||||||
remain omitted until a real upstream quantitative precipitation source is
|
remain omitted until a real upstream quantitative precipitation source is
|
||||||
@@ -68,80 +73,61 @@ represented in `CollectedFacts`.
|
|||||||
|
|
||||||
Future module work should preserve these boundaries:
|
Future module work should preserve these boundaries:
|
||||||
|
|
||||||
- collect upstream facts once per report run;
|
- keep upstream collection in app orchestration
|
||||||
- keep upstream fetching out of modules;
|
- keep upstream collection out of modules
|
||||||
- keep broad reusable calculations in `DerivedFacts`;
|
- keep broad reusable calculations in `DerivedFacts`
|
||||||
- keep prompt-facing field shape inside module builders;
|
- keep prompt-facing field shape inside module builders
|
||||||
- use typed options for configurable module behavior;
|
- use typed options for configurable module behavior
|
||||||
- keep module snapshots structured and deterministic for Recent Changes.
|
- keep module snapshots structured and deterministic for Recent Changes
|
||||||
|
|
||||||
## Distributor Notification Enhancements
|
## Distributor Notification Enhancements
|
||||||
|
|
||||||
Distributor notification currently uploads one managed Markdown report per
|
Distributor notification uploads one managed Markdown report per successful
|
||||||
successful generated report through the configured HTTP upload pipeline.
|
generated report through the configured HTTP upload pipeline. The following
|
||||||
|
enhancements are not implemented:
|
||||||
|
|
||||||
These enhancements are not current behavior:
|
- `failure_policy: warn`
|
||||||
|
- uploading metadata, module snapshots, data packages, or preflight artifacts
|
||||||
|
- durable upload retry queues
|
||||||
|
- distributor-specific CLI flags
|
||||||
|
- distributor workspace scanning
|
||||||
|
- destination routing, Markdown-to-HTML transformation, public URLs, or nginx
|
||||||
|
layout inside weatherreporter
|
||||||
|
|
||||||
- `failure_policy: warn`;
|
Any distributor enhancement should preserve the adapter boundary:
|
||||||
- uploading metadata, module snapshots, data packages, or preflight artifacts;
|
|
||||||
- polling distributor status after upload acceptance;
|
|
||||||
- durable upload retry queues;
|
|
||||||
- distributor-specific CLI flags;
|
|
||||||
- making distributor scan the weatherreporter workspace;
|
|
||||||
- handling destination routing, Markdown-to-HTML transformation, public URLs,
|
|
||||||
or nginx layout inside weatherreporter.
|
|
||||||
|
|
||||||
Any distributor enhancement should preserve the existing adapter boundary:
|
|
||||||
weatherreporter selects explicit generated files and submits source bundles,
|
weatherreporter selects explicit generated files and submits source bundles,
|
||||||
while distributor owns destination routing and publication behavior.
|
while distributor owns destination routing and publication behavior.
|
||||||
|
|
||||||
## Alternate Runtime Integrations
|
## Alternate Runtime Integrations
|
||||||
|
|
||||||
These ideas are not current behavior:
|
These ideas are not implemented:
|
||||||
|
|
||||||
- native LLM client inside `weatherreporter`;
|
- native LLM client inside `weatherreporter`
|
||||||
- database-backed state;
|
- database-backed state
|
||||||
- public HTTP API;
|
- public HTTP API
|
||||||
- multi-location selection;
|
- multi-location selection
|
||||||
- daemon mode;
|
- daemon mode
|
||||||
- multi-user authorization;
|
- multi-user authorization
|
||||||
- plugin system;
|
- plugin system
|
||||||
- dynamic module loading;
|
- dynamic module loading
|
||||||
- user-defined module code;
|
- user-defined module code
|
||||||
- YAML-defined module schemas;
|
- YAML-defined module schemas
|
||||||
- module-owned Weather API fetching.
|
- module-owned Weather API fetching
|
||||||
|
|
||||||
Each item needs its own design note before implementation. Non-roadmap docs
|
Each item needs its own design note before implementation. Non-roadmap docs
|
||||||
must not describe these as available behavior.
|
must not describe these as available behavior.
|
||||||
|
|
||||||
## Cleanup Refactors
|
## Deferred Refactors
|
||||||
|
|
||||||
The initial cleanup pass intentionally left these refactors out because the
|
These refactors should remain deferred until new requirements or recurring
|
||||||
current implementation does not yet make them worth the added abstraction.
|
maintenance costs make the added abstraction worthwhile:
|
||||||
|
|
||||||
Revisit these only when new source types, report types, operational
|
- broad briefing weather-signal consolidation
|
||||||
requirements, or recurring maintenance costs make the duplication materially
|
- generic workflow engine
|
||||||
more expensive:
|
- Cobra migration
|
||||||
|
- manifest, resume, or progress system
|
||||||
- Weather API optional-source specification/helper refactor: consider when
|
- global test helper package
|
||||||
additional Weather API sources make per-source fan-out, policy handling, and
|
- logging subsystem
|
||||||
provenance wiring repetitive enough to obscure adapter behavior.
|
|
||||||
- Broad briefing weather-signal consolidation: consider when multiple module
|
|
||||||
builders repeatedly derive the same weather signals and tests begin to need
|
|
||||||
coordinated fixture updates.
|
|
||||||
- Generic workflow engine: defer unless generation, inspection, recovery, or
|
|
||||||
future background workflows gain enough shared step semantics to justify a
|
|
||||||
declared execution model.
|
|
||||||
- Cobra migration: defer while the standard-library CLI remains small,
|
|
||||||
explicit, and covered by parser tests.
|
|
||||||
- Manifest, resume, or progress system: defer until operators need resumable
|
|
||||||
runs, checkpoint recovery, or richer audit trails than the current durable
|
|
||||||
artifacts and metadata provide.
|
|
||||||
- Global test helper package: defer while package-local helpers keep tests
|
|
||||||
clear; revisit only if setup duplication starts to hide behavior.
|
|
||||||
- Logging subsystem: defer until there are recurring operator diagnostics that
|
|
||||||
cannot be handled with current errors, metadata, inspection commands, and
|
|
||||||
artifact output.
|
|
||||||
|
|
||||||
Any future implementation should preserve the existing public CLI, artifact
|
Any future implementation should preserve the existing public CLI, artifact
|
||||||
paths, report identities, module boundaries, and adapter boundaries unless a
|
paths, report identities, module boundaries, and adapter boundaries unless a
|
||||||
|
|||||||
194
docs/roadmap/implementation.md
Normal file
194
docs/roadmap/implementation.md
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
# Workspace Layout Implementation Plan
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document is the staged implementation plan for
|
||||||
|
[workspace.md](workspace.md). It is written for an LLM coding agent that will
|
||||||
|
implement the workspace artifact layout refactor in order.
|
||||||
|
|
||||||
|
The feature is complete when every managed local run artifact uses the
|
||||||
|
kind-first, date-scoped layout defined in `docs/roadmap/workspace.md`, metadata
|
||||||
|
and inspection link those paths correctly, and distributor upload behavior is
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
## Ground Rules
|
||||||
|
|
||||||
|
- Review `docs/policy/architecture.md`, `docs/policy/development.md`, and
|
||||||
|
`docs/policy/documentation.md` before editing code.
|
||||||
|
- Keep path construction owned by `internal/state`.
|
||||||
|
- Do not add user-visible workspace path template configuration.
|
||||||
|
- Do not use distributor bundle paths for managed workspace paths.
|
||||||
|
- Do not implement cleanup, migration tooling, or a local publication mirror in
|
||||||
|
this work.
|
||||||
|
- Keep optional `--out` and `--out-dir` copies separate from managed workspace
|
||||||
|
artifacts.
|
||||||
|
|
||||||
|
## Target Path Contract
|
||||||
|
|
||||||
|
State-managed run artifacts must use these relative path shapes under
|
||||||
|
`workspace.root`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
reports/<artifact_group>/<valid_start_date>/report.<run_id>.md
|
||||||
|
|
||||||
|
snapshots/<artifact_group>/<valid_start_date>/modules.<run_id>.json
|
||||||
|
snapshots/<artifact_group>/<valid_start_date>/metadata.<run_id>.json
|
||||||
|
snapshots/<artifact_group>/<valid_start_date>/generated_text_raw.<run_id>.json
|
||||||
|
snapshots/<artifact_group>/<valid_start_date>/generated_text_result.<run_id>.json
|
||||||
|
snapshots/<artifact_group>/<valid_start_date>/generated_text.<run_id>.json
|
||||||
|
snapshots/<artifact_group>/<valid_start_date>/render_context.<run_id>.json
|
||||||
|
|
||||||
|
data-packages/<artifact_group>/<valid_start_date>/data_package.<run_id>.yaml
|
||||||
|
preflight/<artifact_group>/<valid_start_date>/render.<run_id>.json
|
||||||
|
|
||||||
|
notifications/<artifact_group>/<valid_start_date>/distributor.<run_id>.json
|
||||||
|
notifications/batches/<batch>/<batch_start_date>/distributor.<batch_run_id>.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`valid_start_date` and `batch_start_date` are local dates in the effective
|
||||||
|
report timezone already carried by the resolved report or batch reference.
|
||||||
|
|
||||||
|
## Stage 1: State Path Construction
|
||||||
|
|
||||||
|
Goal: switch the canonical managed path builder to the target layout.
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
|
||||||
|
- Update `internal/state.FilesystemStore.Paths` to build all `ArtifactPaths`
|
||||||
|
using the target path contract above.
|
||||||
|
- Keep existing validation for workspace subdirectories, RunID, and report
|
||||||
|
artifact group.
|
||||||
|
- Keep `PrepareRenderedReport` responsible for creating the parent report
|
||||||
|
directory before Scriptorium or template rendering writes Markdown.
|
||||||
|
- Update `BatchDistributorNotificationPath` to use
|
||||||
|
`distributor.<batch_run_id>.json`.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
|
||||||
|
- Update state path tests to assert the exact new relative paths for at least
|
||||||
|
`daily`, `today`, `tomorrow`, and `hourly`.
|
||||||
|
- Update batch notification path tests for
|
||||||
|
`notifications/batches/<batch>/<date>/distributor.<batch_run_id>.json`.
|
||||||
|
- Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/state
|
||||||
|
```
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
|
||||||
|
- `FilesystemStore.Paths` returns only target-layout managed paths.
|
||||||
|
- Existing save methods write to the new paths through the existing
|
||||||
|
`ArtifactPaths` fields.
|
||||||
|
|
||||||
|
## Stage 2: Metadata Discovery And Prior Lookup
|
||||||
|
|
||||||
|
Goal: make inspection and prior lookup discover the new metadata filenames.
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
|
||||||
|
- Replace metadata filename checks that currently rely on the old
|
||||||
|
`.metadata.json` suffix with a helper that matches only
|
||||||
|
`metadata.<run_id>.json`.
|
||||||
|
- Use that helper in `ListReports` and `FindPriorSnapshot`.
|
||||||
|
- Do not add legacy metadata filename fallback in this refactor; migration is
|
||||||
|
explicitly deferred by the feature roadmap.
|
||||||
|
- Keep `LoadMetadataByRunID` behavior based on `ListReports`.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
|
||||||
|
- Update report listing tests to create/read `metadata.<run_id>.json`.
|
||||||
|
- Update prior lookup tests to confirm comparable metadata is found under the
|
||||||
|
new filename.
|
||||||
|
- Add or adjust one negative test so non-metadata JSON in the snapshot tree is
|
||||||
|
ignored.
|
||||||
|
- Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/state
|
||||||
|
```
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
|
||||||
|
- Inspection and prior lookup work with new metadata names.
|
||||||
|
- Generated-text, module, and render-context JSON files are not mistaken for
|
||||||
|
metadata.
|
||||||
|
|
||||||
|
## Stage 3: App Workflow Expectations
|
||||||
|
|
||||||
|
Goal: update app-level tests and any path assumptions outside `internal/state`.
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
|
||||||
|
- Do not duplicate path-building logic in `internal/app`; continue asking the
|
||||||
|
state store for managed paths.
|
||||||
|
- Update app tests that assert managed report paths, data package paths,
|
||||||
|
generated-text paths, render context paths, notification source paths, batch
|
||||||
|
included report source paths, and result JSON paths.
|
||||||
|
- Preserve the invariant that distributor uploads use the managed Markdown
|
||||||
|
report path as the upload source and map it to separate distributor bundle
|
||||||
|
paths.
|
||||||
|
- Preserve `--out` and `--out-dir` behavior as extra copies outside the managed
|
||||||
|
report path.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
|
||||||
|
- Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/app ./internal/cli
|
||||||
|
```
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
|
||||||
|
- App workflows still generate, save metadata, notify distributor, and report
|
||||||
|
paths using state-owned managed paths.
|
||||||
|
- No app code constructs the new managed path layout manually.
|
||||||
|
|
||||||
|
## Stage 4: Documentation
|
||||||
|
|
||||||
|
Goal: move the implemented layout into maintained docs after code behavior has
|
||||||
|
changed.
|
||||||
|
|
||||||
|
Documentation changes:
|
||||||
|
|
||||||
|
- Update `docs/internal/state.md` with the new target managed layout and
|
||||||
|
filename convention.
|
||||||
|
- Update `docs/operations.md` filesystem layout examples and artifact wording.
|
||||||
|
- Update `docs/config.md` only if workspace field descriptions need
|
||||||
|
clarification.
|
||||||
|
- Keep cleanup, migration, and local publication mirror details in roadmap
|
||||||
|
docs until those features are implemented.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/state ./internal/app ./internal/config
|
||||||
|
go test ./...
|
||||||
|
go run ./cmd/weatherreporter --help
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
|
||||||
|
- Implemented docs match the new behavior.
|
||||||
|
- Non-roadmap docs do not describe deferred cleanup or local publication mirror
|
||||||
|
behavior as implemented.
|
||||||
|
|
||||||
|
## Final Verification
|
||||||
|
|
||||||
|
Before considering the feature complete, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./...
|
||||||
|
go run ./cmd/weatherreporter --help
|
||||||
|
git diff --check
|
||||||
|
rg "\\.metadata\\.json|\\.modules\\.json|\\.data_package\\.yaml|\\.render\\.json|\\.distributor\\.json|generated_text\\.raw|generated_text\\.run" internal docs examples --glob '!docs/roadmap/**'
|
||||||
|
```
|
||||||
|
|
||||||
|
The final `rg` should find no old managed filename patterns in implemented
|
||||||
|
code, maintained docs, or examples.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None. The feature roadmap decisions are sufficient for implementation.
|
||||||
119
docs/roadmap/workspace.md
Normal file
119
docs/roadmap/workspace.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# Workspace Artifact Layout Roadmap
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This roadmap defines the target local workspace layout for artifacts generated
|
||||||
|
by weatherreporter.
|
||||||
|
|
||||||
|
Weatherreporter currently writes managed local artifacts for each run and also
|
||||||
|
maps the managed Markdown report into distributor bundle paths. Distributor
|
||||||
|
bundle paths are publication paths and may include overwrite-oriented aliases
|
||||||
|
such as `hourly/index.md`, `today/index.md`, and
|
||||||
|
`daily/<valid_start_date>/index.md`. Local workspace paths have a different
|
||||||
|
job: they preserve run-specific source artifacts for inspection, troubleshooting,
|
||||||
|
retry diagnosis, and future cleanup.
|
||||||
|
|
||||||
|
## Locked Decisions
|
||||||
|
|
||||||
|
- Keep managed local workspace paths separate from distributor bundle paths.
|
||||||
|
- Do not use distributor-style `index.md` aliases as the canonical managed
|
||||||
|
local artifact paths.
|
||||||
|
- Store managed local artifacts as immutable run artifacts.
|
||||||
|
- Group every durable run artifact by artifact family, report artifact group,
|
||||||
|
and local `valid_start_date`.
|
||||||
|
- Use `valid_start_date` in the effective report timezone as the directory key.
|
||||||
|
- Include the RunID in every durable run artifact filename.
|
||||||
|
- Prefer artifact-kind-first filenames: `<kind>.<run_id>.<extension>`.
|
||||||
|
- Keep optional `--out` and `--out-dir` copies separate from managed workspace
|
||||||
|
artifacts.
|
||||||
|
- Future cleanup should delete old managed artifact date directories by
|
||||||
|
`valid_start_date`; cleanup should be narrow and opt-in.
|
||||||
|
- If a local publication preview is added later, put it in a separate mirror
|
||||||
|
tree and treat it as rebuildable output, not canonical run state.
|
||||||
|
|
||||||
|
## Target Managed Layout
|
||||||
|
|
||||||
|
The target managed workspace layout is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<workspace.root>/
|
||||||
|
reports/<artifact_group>/<valid_start_date>/report.<run_id>.md
|
||||||
|
|
||||||
|
snapshots/<artifact_group>/<valid_start_date>/modules.<run_id>.json
|
||||||
|
snapshots/<artifact_group>/<valid_start_date>/metadata.<run_id>.json
|
||||||
|
snapshots/<artifact_group>/<valid_start_date>/generated_text_raw.<run_id>.json
|
||||||
|
snapshots/<artifact_group>/<valid_start_date>/generated_text_result.<run_id>.json
|
||||||
|
snapshots/<artifact_group>/<valid_start_date>/generated_text.<run_id>.json
|
||||||
|
snapshots/<artifact_group>/<valid_start_date>/render_context.<run_id>.json
|
||||||
|
|
||||||
|
data-packages/<artifact_group>/<valid_start_date>/data_package.<run_id>.yaml
|
||||||
|
preflight/<artifact_group>/<valid_start_date>/render.<run_id>.json
|
||||||
|
|
||||||
|
notifications/<artifact_group>/<valid_start_date>/distributor.<run_id>.json
|
||||||
|
notifications/batches/<batch>/<batch_start_date>/distributor.<batch_run_id>.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`artifact_group` comes from the report definition. `valid_start_date` is the
|
||||||
|
resolved report valid period start date in the effective report timezone.
|
||||||
|
`batch_start_date` is the batch start date in the effective report timezone.
|
||||||
|
|
||||||
|
Generated-text-template reports use the generated-text artifacts. Markdown-path
|
||||||
|
reports omit generated-text artifacts they do not produce.
|
||||||
|
|
||||||
|
## Cleanup Compatibility
|
||||||
|
|
||||||
|
The target layout is designed to support a future cleanup command that removes
|
||||||
|
managed local artifacts where `valid_start_date` is more than two days before
|
||||||
|
the current local date.
|
||||||
|
|
||||||
|
Cleanup can operate by deleting matching date directories across managed
|
||||||
|
artifact families:
|
||||||
|
|
||||||
|
```text
|
||||||
|
reports/<artifact_group>/<old_date>/
|
||||||
|
snapshots/<artifact_group>/<old_date>/
|
||||||
|
data-packages/<artifact_group>/<old_date>/
|
||||||
|
preflight/<artifact_group>/<old_date>/
|
||||||
|
notifications/<artifact_group>/<old_date>/
|
||||||
|
```
|
||||||
|
|
||||||
|
The command should not infer dates from RunIDs when the directory date is
|
||||||
|
available. It should use the effective configured report timezone for "current
|
||||||
|
date" and for interpreting `valid_start_date` directories.
|
||||||
|
|
||||||
|
Batch notification cleanup should use `batch_start_date` under
|
||||||
|
`notifications/batches/<batch>/`. A future local publication mirror, if added,
|
||||||
|
should be ignored by cleanup or rebuilt after cleanup; it should not be treated
|
||||||
|
as run history.
|
||||||
|
|
||||||
|
## Intended Final State
|
||||||
|
|
||||||
|
`internal/state` owns the managed workspace layout and path construction. The
|
||||||
|
app layer continues to ask the state store for managed paths and records those
|
||||||
|
paths in metadata and command results.
|
||||||
|
|
||||||
|
Metadata links every artifact produced for a run using the new managed paths.
|
||||||
|
Inspection and prior-snapshot lookup continue to read metadata rather than
|
||||||
|
rendered Markdown text. Distributor uploads continue to use the managed
|
||||||
|
Markdown report as the source file and map it to separately resolved
|
||||||
|
distributor bundle paths.
|
||||||
|
|
||||||
|
Existing workspace configuration fields remain meaningful:
|
||||||
|
|
||||||
|
- `workspace.root`
|
||||||
|
- `workspace.snapshots_dir`
|
||||||
|
- `workspace.reports_dir`
|
||||||
|
- `workspace.data_packages_dir`
|
||||||
|
- `workspace.preflight_dir`
|
||||||
|
- `workspace.notifications_dir`
|
||||||
|
|
||||||
|
This roadmap does not require a new user-visible workspace path template config
|
||||||
|
surface.
|
||||||
|
|
||||||
|
## Deferred Work
|
||||||
|
|
||||||
|
- A cleanup command for managed local artifacts.
|
||||||
|
- Cleanup dry-run output, retention configuration, and deletion confirmation
|
||||||
|
behavior.
|
||||||
|
- A rebuildable local publication mirror that uses distributor-style paths.
|
||||||
|
- Migration tooling for existing pre-refactor workspace directories.
|
||||||
442
docs/templates.md
Normal file
442
docs/templates.md
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
# Report Templates
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This guide describes the implemented Markdown report template surface for
|
||||||
|
`weatherreporter`. It is for maintainers editing embedded report templates,
|
||||||
|
especially generated-text-template reports.
|
||||||
|
|
||||||
|
Templates are Go `text/template` files. The implemented top-level templates
|
||||||
|
are:
|
||||||
|
|
||||||
|
- `internal/reporttemplate/templates/daily.md.tmpl`
|
||||||
|
- `internal/reporttemplate/templates/today.md.tmpl`
|
||||||
|
- `internal/reporttemplate/templates/tomorrow.md.tmpl`
|
||||||
|
- `internal/reporttemplate/templates/hourly.md.tmpl`
|
||||||
|
|
||||||
|
Shared named partials live under `internal/reporttemplate/templates/partials/`:
|
||||||
|
|
||||||
|
- `alert_digest.md.tmpl`, used by Daily, Today, Tomorrow, and Hourly for the
|
||||||
|
combined Alerts and Risk Products section
|
||||||
|
- `daypart_forecast.md.tmpl`, used by Daily and Tomorrow
|
||||||
|
- `today_daypart_forecast.md.tmpl`, used by Today
|
||||||
|
- `precipitation_timing.md.tmpl`, used by Daily, Today, Tomorrow, and Hourly
|
||||||
|
|
||||||
|
Templates are rendered from structured contexts such as `DailyRenderContext`,
|
||||||
|
`TodayRenderContext`, `TomorrowRenderContext`, and `HourlyRenderContext`.
|
||||||
|
Weather data collection, derivation, module execution, generated text
|
||||||
|
validation, and artifact paths are handled before template rendering.
|
||||||
|
|
||||||
|
## Editing Rules
|
||||||
|
|
||||||
|
- Use Go `text/template` syntax.
|
||||||
|
- Keep templates focused on Markdown layout, headings, ordering, and simple
|
||||||
|
conditional display.
|
||||||
|
- Do not put weather derivation, source selection, or path construction logic in
|
||||||
|
templates.
|
||||||
|
- Missing template keys are errors. A misspelled variable will fail rendering.
|
||||||
|
- No custom template functions are registered.
|
||||||
|
- Named partials are invoked with `{{ template "name" . }}`. Pass the current
|
||||||
|
render context (`.`) unless the partial is intentionally designed for a
|
||||||
|
narrower value.
|
||||||
|
- Optional module stanzas are pointers and should be guarded with
|
||||||
|
`{{ with .Modules.WeatherStory }}...{{ end }}`.
|
||||||
|
- Slices can be rendered with `{{ range .Items }}...{{ else }}...{{ end }}`.
|
||||||
|
|
||||||
|
## Hourly Context
|
||||||
|
|
||||||
|
The hourly template receives five top-level values:
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Report` | HourlyReportContext | Display metadata and friendly labels for the rendered report. |
|
||||||
|
| `.GeneratedText` | Hourly | Structured text returned by Scriptorium. |
|
||||||
|
| `.Modules` | HourlyTemplateModules | Preferred deterministic template surface, keyed by module purpose. |
|
||||||
|
| `.Collected` | facts.CollectedFacts | Normalized upstream facts for advanced template use. |
|
||||||
|
| `.Derived` | facts.DerivedFacts | Shared derived facts for advanced template use. |
|
||||||
|
|
||||||
|
Prefer `.Modules` for normal template edits. `.Collected` and `.Derived` are
|
||||||
|
available when a template needs lower-level facts, but templates should still
|
||||||
|
avoid nontrivial derivation.
|
||||||
|
|
||||||
|
## Report
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Report.Title` | string | Display title. Currently `Hourly Report`. |
|
||||||
|
| `.Report.LocationName` | string | Prompt/report location label, such as `Brentwood, MO`. |
|
||||||
|
| `.Report.GeneratedAt` | time.Time | Canonical generation timestamp. |
|
||||||
|
| `.Report.GeneratedAtLabel` | string | Friendly local generation time label. |
|
||||||
|
| `.Report.ValidPeriod` | timeutil.Period | Canonical valid period. |
|
||||||
|
| `.Report.ValidPeriodLabel` | string | Friendly local valid period label, such as `2026-05-29 at 8:30 AM to 2026-05-29 at 2:30 PM`. |
|
||||||
|
| `.Report.Timezone` | string | Effective report timezone. |
|
||||||
|
|
||||||
|
## GeneratedText
|
||||||
|
|
||||||
|
These fields are written by Scriptorium as structured JSON, validated by
|
||||||
|
weatherreporter, and then inserted into the render context.
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.GeneratedText.Summary` | string | Required short prose summary. |
|
||||||
|
| `.GeneratedText.ForecastDiscussion` | string | Required prose for the Forecast Discussion section. |
|
||||||
|
| `.GeneratedText.PrecipitationTiming` | string | Optional prose rendered after deterministic precipitation windows. |
|
||||||
|
| `.GeneratedText.Confidence` | string | Optional confidence or uncertainty note. Empty when omitted by the LLM; not rendered by the current hourly template. |
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```gotemplate
|
||||||
|
{{ .GeneratedText.Summary }}
|
||||||
|
|
||||||
|
## Forecast Discussion
|
||||||
|
|
||||||
|
{{ .GeneratedText.ForecastDiscussion }}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tomorrow Context
|
||||||
|
|
||||||
|
The Tomorrow template receives five top-level values:
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Report` | TomorrowReportContext | Display metadata and friendly labels for the rendered report. |
|
||||||
|
| `.GeneratedText` | Tomorrow | Structured text returned by Scriptorium. |
|
||||||
|
| `.Modules` | TomorrowTemplateModules | Preferred deterministic template surface, keyed by module purpose. |
|
||||||
|
| `.Collected` | facts.CollectedFacts | Normalized upstream facts for advanced template use. |
|
||||||
|
| `.Derived` | facts.DerivedFacts | Shared derived facts for advanced template use. |
|
||||||
|
|
||||||
|
Tomorrow report metadata includes `.Report.Title`, `.Report.ForecastDate`,
|
||||||
|
`.Report.ForecastDateLabel`, `.Report.ForecastDayName`,
|
||||||
|
`.Report.GeneratedAt`, `.Report.GeneratedAtLabel`, `.Report.ValidPeriod`, and
|
||||||
|
`.Report.Timezone`.
|
||||||
|
|
||||||
|
Tomorrow generated text uses the same `.GeneratedText.Summary`,
|
||||||
|
`.GeneratedText.PrecipitationTiming`, and `.GeneratedText.Confidence` fields as
|
||||||
|
Hourly. `.GeneratedText.ForecastDiscussion` is a slice of paragraphs and should
|
||||||
|
be rendered with `range`.
|
||||||
|
|
||||||
|
Tomorrow uses the shared `alert_digest`, `daypart_forecast`, and
|
||||||
|
`precipitation_timing` partials.
|
||||||
|
|
||||||
|
Tomorrow modules include the Hourly module fields plus:
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Modules.DerivedDailySummary` | *briefing.DerivedDailySummaryModule | Daily summary facts for the forecast date. |
|
||||||
|
| `.Modules.DerivedDaypartSummaries` | *map[string]briefing.DerivedDaypartSummaryModule | Raw daypart summary map, when direct keyed access is needed. |
|
||||||
|
| `.Modules.Dayparts` | []generatedtext.TomorrowDaypartContext | Ordered daypart summaries for deterministic template rendering. |
|
||||||
|
| `.Modules.TomorrowPlanning` | *briefing.TomorrowPlanningModule | Planning facts for the next local civil day. |
|
||||||
|
|
||||||
|
Prefer `.Modules.Dayparts` over ranging through
|
||||||
|
`.Modules.DerivedDaypartSummaries`; it follows configured daypart order and
|
||||||
|
falls back to sorted keys for any unmatched entries.
|
||||||
|
|
||||||
|
## Daily Context
|
||||||
|
|
||||||
|
The Daily template receives the same five top-level values as Tomorrow, using
|
||||||
|
`DailyReportContext`, `Daily`, and `DailyTemplateModules`.
|
||||||
|
|
||||||
|
Daily report metadata includes `.Report.Title`, `.Report.ForecastDate`,
|
||||||
|
`.Report.ForecastDateLabel`, `.Report.ForecastDayName`,
|
||||||
|
`.Report.GeneratedAt`, `.Report.GeneratedAtLabel`, `.Report.ValidPeriod`, and
|
||||||
|
`.Report.Timezone`.
|
||||||
|
|
||||||
|
Daily generated text uses `.GeneratedText.Summary`,
|
||||||
|
`.GeneratedText.ForecastDiscussion`, `.GeneratedText.PrecipitationTiming`, and
|
||||||
|
`.GeneratedText.Confidence`. Forecast discussion is a slice of paragraphs and
|
||||||
|
should be rendered with `range`.
|
||||||
|
|
||||||
|
Daily uses the shared `alert_digest`, `daypart_forecast`, and
|
||||||
|
`precipitation_timing` partials.
|
||||||
|
|
||||||
|
Daily uses template ID `daily`, generated-text schema ID `daily`, and prompt
|
||||||
|
source `internal/reporttemplate/prompts/daily.generated_text.md`.
|
||||||
|
|
||||||
|
Daily modules include the Hourly module fields plus:
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Modules.DerivedDailySummary` | *briefing.DerivedDailySummaryModule | Daily summary facts for the forecast date. |
|
||||||
|
| `.Modules.DerivedDaypartSummaries` | *map[string]briefing.DerivedDaypartSummaryModule | Raw daypart summary map, when direct keyed access is needed. |
|
||||||
|
| `.Modules.Dayparts` | []generatedtext.DailyDaypartContext | Ordered daypart summaries for deterministic template rendering. |
|
||||||
|
| `.Modules.DailyPlanning` | *briefing.DailyPlanningModule | Planning facts for the selected local civil day. |
|
||||||
|
|
||||||
|
Prefer `.Modules.Dayparts` over ranging through
|
||||||
|
`.Modules.DerivedDaypartSummaries`; it follows configured daypart order and
|
||||||
|
falls back to sorted keys for any unmatched entries.
|
||||||
|
|
||||||
|
## Today Context
|
||||||
|
|
||||||
|
The Today template receives the same five top-level values as Tomorrow, using
|
||||||
|
`TodayReportContext`, `Today`, and `TodayTemplateModules`.
|
||||||
|
|
||||||
|
Today report metadata includes `.Report.Title`, `.Report.ForecastDate`,
|
||||||
|
`.Report.ForecastDateLabel`, `.Report.ForecastDayName`,
|
||||||
|
`.Report.GeneratedAt`, `.Report.GeneratedAtLabel`, `.Report.ValidPeriod`, and
|
||||||
|
`.Report.Timezone`.
|
||||||
|
|
||||||
|
Today generated text uses `.GeneratedText.Summary`,
|
||||||
|
`.GeneratedText.ForecastDiscussion`, `.GeneratedText.PrecipitationTiming`, and
|
||||||
|
`.GeneratedText.Confidence`. Forecast discussion is a slice of paragraphs and
|
||||||
|
should be rendered with `range`.
|
||||||
|
|
||||||
|
Today uses the `today_daypart_forecast` partial so elapsed or missing dayparts
|
||||||
|
can be omitted while Daily and Tomorrow keep their fallback row. It also uses
|
||||||
|
the shared `alert_digest` and `precipitation_timing` partials.
|
||||||
|
|
||||||
|
Today uses template ID `today`, generated-text schema ID `today`, and prompt
|
||||||
|
source `internal/reporttemplate/prompts/today.generated_text.md`.
|
||||||
|
|
||||||
|
Today modules include the Hourly module fields plus:
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Modules.DerivedDailySummary` | *briefing.DerivedDailySummaryModule | Daily summary facts for the forecast date. |
|
||||||
|
| `.Modules.DerivedDaypartSummaries` | *map[string]briefing.DerivedDaypartSummaryModule | Raw daypart summary map, when direct keyed access is needed. |
|
||||||
|
| `.Modules.Dayparts` | []generatedtext.TodayDaypartContext | Ordered daypart summaries for deterministic template rendering. |
|
||||||
|
| `.Modules.TodayPlanning` | *briefing.TodayPlanningModule | Planning facts for the current local civil day. |
|
||||||
|
|
||||||
|
Prefer `.Modules.Dayparts` over ranging through
|
||||||
|
`.Modules.DerivedDaypartSummaries`; it follows configured daypart order and
|
||||||
|
falls back to sorted keys for any unmatched entries.
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
`.Modules` exposes typed outputs from the same module pipeline used for the
|
||||||
|
prompt data package. Module fields are pointers because missing-data policy may
|
||||||
|
omit a stanza.
|
||||||
|
|
||||||
|
Templates render from rich module values, not from the curated YAML data
|
||||||
|
package. Some fields documented below are deterministic wording helpers for
|
||||||
|
Markdown templates and are intentionally omitted from data packages passed to
|
||||||
|
Scriptorium. The data package is a prompt input, while the render context is the
|
||||||
|
template surface.
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Modules.Metadata` | *briefing.MetadataModule | Report metadata module output, when present. |
|
||||||
|
| `.Modules.CurrentConditions` | *briefing.CurrentConditionsModule | Current conditions from `/conditions/current`. |
|
||||||
|
| `.Modules.HourlyForecast` | *briefing.HourlyForecastModule | Hourly forecast periods overlapping the report valid period. |
|
||||||
|
| `.Modules.PrecipTiming` | *briefing.PrecipTimingModule | Derived precipitation timing facts and threshold windows. |
|
||||||
|
| `.Modules.AlertDigest` | *briefing.AlertDigestModule | Active alert status and relevant alert overlaps. |
|
||||||
|
| `.Modules.SPCConvectiveOutlooks` | *briefing.SPCConvectiveOutlooksModule | SPC outlooks that overlap the report valid period. |
|
||||||
|
| `.Modules.AreaForecastDiscussion` | *briefing.AreaForecastDiscussionModule | AFD key messages and configured discussion sections. |
|
||||||
|
| `.Modules.SPCConvectiveDiscussion` | *briefing.SPCConvectiveDiscussionModule | SPC discussions retained for qualifying overlapping categorical risk days. |
|
||||||
|
| `.Modules.WeatherStory` | *briefing.WeatherStoryModule | Latest NWS weather story, when available. |
|
||||||
|
|
||||||
|
### Current Conditions
|
||||||
|
|
||||||
|
Common fields:
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Modules.CurrentConditions.ConditionText` | string | Current condition text. |
|
||||||
|
| `.Modules.CurrentConditions.ConditionTextLower` | string | Lower-case current condition text for inline sentences. |
|
||||||
|
| `.Modules.CurrentConditions.TemperatureF` | *int | Rounded current temperature. |
|
||||||
|
| `.Modules.CurrentConditions.ApparentTemperatureF` | *int | Rounded apparent temperature. |
|
||||||
|
| `.Modules.CurrentConditions.RelativeHumidityPercent` | *int | Rounded relative humidity. |
|
||||||
|
| `.Modules.CurrentConditions.WindDirection` | string | 16-point compass wind direction. |
|
||||||
|
| `.Modules.CurrentConditions.WindDirectionText` | string | Lower-case full wind direction text, such as `northwest`. |
|
||||||
|
| `.Modules.CurrentConditions.WindSpeedMph` | *int | Rounded wind speed. |
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```gotemplate
|
||||||
|
{{ with .Modules.CurrentConditions }}
|
||||||
|
{{ .ConditionText }}{{ with .TemperatureF }}; {{ . }} F{{ end }}{{ with .WindDirection }}; wind {{ . }}{{ end }}{{ with .WindSpeedMph }} {{ . }} mph{{ end }}
|
||||||
|
{{ else }}
|
||||||
|
No current conditions available.
|
||||||
|
{{ end }}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hourly Forecast
|
||||||
|
|
||||||
|
Common period fields:
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Modules.HourlyForecast.Periods` | []briefing.HourlyForecastPeriod | Ordered periods for the hourly report valid period. |
|
||||||
|
| `.Modules.HourlyForecast.Periods[].HourLabel` | string | Friendly hour label such as `4:00 PM`. |
|
||||||
|
| `.Modules.HourlyForecast.Periods[].PeriodBegins` | string | Friendly local period start label. |
|
||||||
|
| `.Modules.HourlyForecast.Periods[].PeriodEnds` | string | Friendly local period end label. |
|
||||||
|
| `.Modules.HourlyForecast.Periods[].Name` | string | Source period name. |
|
||||||
|
| `.Modules.HourlyForecast.Periods[].TextDescription` | string | Hourly forecast text. |
|
||||||
|
| `.Modules.HourlyForecast.Periods[].TextDescriptionLower` | string | Lower-case hourly forecast text for inline sentences. |
|
||||||
|
| `.Modules.HourlyForecast.Periods[].TemperatureF` | *float64 | Forecast temperature. |
|
||||||
|
| `.Modules.HourlyForecast.Periods[].ProbabilityOfPrecipitationPercent` | *float64 | Forecast precipitation probability. |
|
||||||
|
| `.Modules.HourlyForecast.Periods[].MentionPrecipitation` | bool | True when precipitation probability meets the hourly mention threshold. |
|
||||||
|
| `.Modules.HourlyForecast.Periods[].WindDirection` | string | 16-point compass wind direction. |
|
||||||
|
| `.Modules.HourlyForecast.Periods[].WindSpeedMph` | *float64 | Wind speed. |
|
||||||
|
| `.Modules.HourlyForecast.Periods[].WindGustMph` | *float64 | Wind gust. |
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```gotemplate
|
||||||
|
{{ with .Modules.HourlyForecast }}{{ range .Periods }}
|
||||||
|
- **{{ .HourLabel }}:**{{ with .TemperatureF }} {{ . }}°F{{ end }} and {{ .TextDescriptionLower }}.{{ if .MentionPrecipitation }}{{ with .ProbabilityOfPrecipitationPercent }} Probability of precipitation is {{ . }}%.{{ end }}{{ end }}
|
||||||
|
{{ else }}
|
||||||
|
- No hourly forecast rows available.
|
||||||
|
{{ end }}{{ end }}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Precipitation Timing
|
||||||
|
|
||||||
|
Common fields:
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Modules.PrecipTiming.MaxPopPercent` | *int | Highest hourly precipitation probability in the valid period. |
|
||||||
|
| `.Modules.PrecipTiming.MaxPopTime` | string | Friendly local time for the highest hourly precipitation probability. |
|
||||||
|
| `.Modules.PrecipTiming.ProbabilityThreshold` | float64 | Threshold used to define precipitation windows. |
|
||||||
|
| `.Modules.PrecipTiming.PrecipitationWindows` | []briefing.PrecipitationWindowModule | One or more threshold precipitation windows. |
|
||||||
|
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodBegins` | string | Friendly local window start. |
|
||||||
|
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodBeginsHourLabel` | string | Friendly window start hour, such as `4:00 PM`. |
|
||||||
|
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodEnds` | string | Friendly local window end; omitted for open windows. |
|
||||||
|
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodEndsHourLabel` | string | Friendly window end hour; omitted for open windows. |
|
||||||
|
| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopPercent` | *int | Highest precipitation probability inside the window. |
|
||||||
|
| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopTime` | string | Friendly local time for the window maximum. |
|
||||||
|
| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopHourLabel` | string | Friendly hour label for the window maximum. |
|
||||||
|
| `.Modules.PrecipTiming.PrecipitationWindows[].PrecipitationType` | string | Conservatively inferred precipitation type, such as `showers and thunderstorms`. |
|
||||||
|
| `.Modules.PrecipTiming.PrecipitationWindows[].ExpectationPhrase` | string | Probability-based sentence used by precipitation timing templates. |
|
||||||
|
| `.Modules.PrecipTiming.ThunderMentioned` | bool | Whether thunder is mentioned in the forecast text. |
|
||||||
|
|
||||||
|
### Daypart Summaries
|
||||||
|
|
||||||
|
Daily, Today, and Tomorrow templates should use `.Modules.Dayparts` for
|
||||||
|
ordered daypart rendering. Each item has `Key` and `Summary`; `Summary` is a
|
||||||
|
rich `briefing.DerivedDaypartSummaryModule`.
|
||||||
|
|
||||||
|
The shared daypart partials render from these same `.Modules.Dayparts` values.
|
||||||
|
Edit `daypart_forecast.md.tmpl` for common Daily/Tomorrow wording, and edit
|
||||||
|
`today_daypart_forecast.md.tmpl` for Today-specific omission behavior.
|
||||||
|
|
||||||
|
Common rich daypart fields:
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Modules.Dayparts[].Summary.DisplayName` | string | Human-readable daypart label. |
|
||||||
|
| `.Modules.Dayparts[].Summary.PeriodBegins` | string | Friendly local daypart start. |
|
||||||
|
| `.Modules.Dayparts[].Summary.PeriodEnds` | string | Friendly local daypart end. |
|
||||||
|
| `.Modules.Dayparts[].Summary.TempRangeF` | string | Rounded temperature range or single temperature. |
|
||||||
|
| `.Modules.Dayparts[].Summary.TemperaturePhraseF` | string | Temperature phrase used for steady template wording. |
|
||||||
|
| `.Modules.Dayparts[].Summary.TemperatureTrend` | string | Trend category such as `rising`, `falling`, `peaking`, or `steady`. |
|
||||||
|
| `.Modules.Dayparts[].Summary.TemperatureStartPhraseF` | string | Starting temperature phrase for rising/falling wording. |
|
||||||
|
| `.Modules.Dayparts[].Summary.TemperatureEndPhraseF` | string | Ending temperature phrase for rising/falling wording. |
|
||||||
|
| `.Modules.Dayparts[].Summary.TemperaturePeakPhraseF` | string | Peak temperature phrase for peaking wording. |
|
||||||
|
| `.Modules.Dayparts[].Summary.TemperatureSteadyPhraseF` | string | Steady temperature phrase. |
|
||||||
|
| `.Modules.Dayparts[].Summary.MaxPopPercent` | *int | Highest precipitation probability in the daypart. |
|
||||||
|
| `.Modules.Dayparts[].Summary.MaxPopTime` | string | Friendly local time for the highest precipitation probability. |
|
||||||
|
| `.Modules.Dayparts[].Summary.MaxPopTimeLabel` | string | Clock-style label for deterministic precipitation timing text. |
|
||||||
|
| `.Modules.Dayparts[].Summary.MentionPrecipitation` | bool | True when precipitation probability should be mentioned by the template. |
|
||||||
|
| `.Modules.Dayparts[].Summary.DominantCondition` | string | Dominant condition text. |
|
||||||
|
| `.Modules.Dayparts[].Summary.DominantConditionLower` | string | Lower-case condition text for inline sentences. |
|
||||||
|
| `.Modules.Dayparts[].Summary.DominantConditionDisplay` | string | Display-case condition text for bullet starts. |
|
||||||
|
| `.Modules.Dayparts[].Summary.NotableConditions` | []string | Notable condition labels retained for the daypart. |
|
||||||
|
|
||||||
|
Template-only daypart helpers such as `TemperaturePhraseF`,
|
||||||
|
`DominantConditionLower`, `DominantConditionDisplay`, and `MaxPopTimeLabel`
|
||||||
|
remain available here even though they are not serialized into data-package
|
||||||
|
YAML.
|
||||||
|
|
||||||
|
### Alert Digest
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Modules.AlertDigest.Checked` | bool | Whether alert data was checked successfully. |
|
||||||
|
| `.Modules.AlertDigest.ActiveCount` | int | Active alert count from the source. |
|
||||||
|
| `.Modules.AlertDigest.RelevantCount` | int | Alert count overlapping the report period. |
|
||||||
|
| `.Modules.AlertDigest.Missing` | bool | True when alert data is unavailable. |
|
||||||
|
| `.Modules.AlertDigest.Relevant` | []briefing.AlertSummary | Relevant alert summaries. |
|
||||||
|
| `.Modules.AlertDigest.Relevant[].Event` | string | Alert event name. |
|
||||||
|
| `.Modules.AlertDigest.Relevant[].Headline` | string | Alert headline. |
|
||||||
|
| `.Modules.AlertDigest.Relevant[].Severity` | string | Alert severity. |
|
||||||
|
| `.Modules.AlertDigest.Relevant[].PeriodBegins` | string | Friendly local alert applicability start. |
|
||||||
|
| `.Modules.AlertDigest.Relevant[].PeriodEnds` | string | Friendly local alert applicability end. |
|
||||||
|
| `.Modules.AlertDigest.Relevant[].Instruction` | string | Alert instruction text, when provided. |
|
||||||
|
| `.Modules.AlertDigest.Relevant[].Description` | string | Alert description text, when provided. |
|
||||||
|
|
||||||
|
### SPC Outlooks And Discussion
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Modules.SPCConvectiveOutlooks.Checked` | bool | Whether SPC outlook data was checked successfully. |
|
||||||
|
| `.Modules.SPCConvectiveOutlooks.AsOf` | string | Friendly source as-of time. |
|
||||||
|
| `.Modules.SPCConvectiveOutlooks.IssuedAt` | string | Friendly source issue time. |
|
||||||
|
| `.Modules.SPCConvectiveOutlooks.Outlooks` | []briefing.SPCConvectiveOutlookRecord | Overlapping outlook records. |
|
||||||
|
| `.Modules.SPCConvectiveOutlooks.Outlooks[].Day` | int | SPC day number. |
|
||||||
|
| `.Modules.SPCConvectiveOutlooks.Outlooks[].OutlookType` | string | Outlook type, such as `categorical`. |
|
||||||
|
| `.Modules.SPCConvectiveOutlooks.Outlooks[].Label` | string | Short outlook label. |
|
||||||
|
| `.Modules.SPCConvectiveOutlooks.Outlooks[].LabelText` | string | Human-readable outlook label. |
|
||||||
|
| `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodBegins` | string | Friendly outlook period start. |
|
||||||
|
| `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodEnds` | string | Friendly outlook period end. |
|
||||||
|
| `.Modules.SPCConvectiveOutlooks.Outlooks[].ImageURL` | string | Source image URL. |
|
||||||
|
| `.Modules.SPCConvectiveOutlooks.RiskDigest` | []briefing.SPCConvectiveOutlookDigest | Curated categorical outlooks for the shared Alerts and Risk Products section. |
|
||||||
|
| `.Modules.SPCConvectiveOutlooks.RiskDigest[].LabelText` | string | Human-readable outlook label. |
|
||||||
|
| `.Modules.SPCConvectiveOutlooks.RiskDigest[].RiskLabel` | string | Sentence-style risk label for report rendering. |
|
||||||
|
| `.Modules.SPCConvectiveOutlooks.RiskDigest[].PeriodBegins` | string | Friendly outlook period start. |
|
||||||
|
| `.Modules.SPCConvectiveOutlooks.RiskDigest[].PeriodEnds` | string | Friendly outlook period end. |
|
||||||
|
| `.Modules.SPCConvectiveDiscussion.IncludedBecause` | string | Criterion used to include discussions. |
|
||||||
|
| `.Modules.SPCConvectiveDiscussion.Discussions` | []briefing.SPCConvectiveDiscussionRecord | Retained discussion records. |
|
||||||
|
| `.Modules.SPCConvectiveDiscussion.Discussions[].Headline` | string | Discussion headline. |
|
||||||
|
| `.Modules.SPCConvectiveDiscussion.Discussions[].Summary` | string | Discussion summary. |
|
||||||
|
| `.Modules.SPCConvectiveDiscussion.Discussions[].Discussion` | string | Full discussion text. |
|
||||||
|
|
||||||
|
### Area Forecast Discussion
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Modules.AreaForecastDiscussion.Product` | string | Source product identifier. |
|
||||||
|
| `.Modules.AreaForecastDiscussion.KeyMessages` | []string | AFD key messages. |
|
||||||
|
| `.Modules.AreaForecastDiscussion.ShortTerm` | string | AFD short-term section text. |
|
||||||
|
| `.Modules.AreaForecastDiscussion.LongTerm` | string | AFD long-term section text. |
|
||||||
|
|
||||||
|
### Weather Story
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Modules.WeatherStory.Available` | bool | True when a story is available. |
|
||||||
|
| `.Modules.WeatherStory.OfficeID` | string | Source office ID. |
|
||||||
|
| `.Modules.WeatherStory.PeriodBegins` | string | Friendly story period start. |
|
||||||
|
| `.Modules.WeatherStory.PeriodEnds` | string | Friendly story period end. |
|
||||||
|
| `.Modules.WeatherStory.UpdatedAt` | *time.Time | Canonical update timestamp. |
|
||||||
|
| `.Modules.WeatherStory.Title` | string | Story title. |
|
||||||
|
| `.Modules.WeatherStory.Description` | string | Story description. |
|
||||||
|
| `.Modules.WeatherStory.AltText` | string | Story image alt text. |
|
||||||
|
| `.Modules.WeatherStory.Priority` | bool | Source priority flag. |
|
||||||
|
| `.Modules.WeatherStory.Order` | int | Source order. |
|
||||||
|
| `.Modules.WeatherStory.DownloadURL` | string | Source download URL. |
|
||||||
|
|
||||||
|
## Collected And Derived Facts
|
||||||
|
|
||||||
|
The template also receives the full `facts.CollectedFacts` and
|
||||||
|
`facts.DerivedFacts` structs:
|
||||||
|
|
||||||
|
- `.Collected` contains normalized source data and provenance from upstream
|
||||||
|
Weather API fetches.
|
||||||
|
- `.Derived` contains shared slices and calculations used across modules, such
|
||||||
|
as valid-period hourly periods, precipitation timing, alert overlaps, and SPC
|
||||||
|
filtering inputs.
|
||||||
|
|
||||||
|
These values are intentionally lower-level than `.Modules`. Use them when a
|
||||||
|
template needs a specific field that is not exposed by a module, but keep
|
||||||
|
calculation-heavy changes in Go.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
After editing a template, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/reporttemplate ./internal/generatedtext ./internal/app
|
||||||
|
```
|
||||||
|
|
||||||
|
For a full check, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./...
|
||||||
|
go run ./cmd/weatherreporter --help
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Template render tests exercise the Daily, Today, Tomorrow, and Hourly
|
||||||
|
templates through `internal/generatedtext/render_context_test.go` and
|
||||||
|
`internal/reporttemplate/reporttemplate_test.go`.
|
||||||
@@ -6,7 +6,7 @@ fixes. See [CLI reference](cli.md), [Configuration reference](config.md), and
|
|||||||
|
|
||||||
## `weather_api.base_url is required`
|
## `weather_api.base_url is required`
|
||||||
|
|
||||||
Symptom: a generation command fails before fetching weather data.
|
Symptom: a generation command fails before collecting weather data.
|
||||||
|
|
||||||
Likely cause: no Weather API base URL is configured.
|
Likely cause: no Weather API base URL is configured.
|
||||||
|
|
||||||
@@ -185,20 +185,158 @@ prompt, profile, model configuration, or validation issue.
|
|||||||
Relevant docs: [Operations guide](operations.md),
|
Relevant docs: [Operations guide](operations.md),
|
||||||
[Scriptorium integration](integrations/scriptorium.md).
|
[Scriptorium integration](integrations/scriptorium.md).
|
||||||
|
|
||||||
|
## Generated Text Validation Fails
|
||||||
|
|
||||||
|
Symptom: Daily, Today, Tomorrow, or Hourly generation fails with generated-text
|
||||||
|
decode, unknown-field, required-field, or multiple-JSON-values context.
|
||||||
|
|
||||||
|
Likely cause: Scriptorium wrote structured JSON that does not match the
|
||||||
|
GeneratedText contract for the selected report.
|
||||||
|
|
||||||
|
Diagnostic:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
weatherreporter inspect metadata RUN_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
Then inspect the generated-text raw path recorded in metadata, if present.
|
||||||
|
|
||||||
|
Safe fix: update the Scriptorium prompt or schema configuration so the prompt
|
||||||
|
writes the expected structured JSON for the report.
|
||||||
|
|
||||||
|
Relevant docs: [Operations guide](operations.md),
|
||||||
|
[Generated Text internals](internal/generatedtext.md),
|
||||||
|
[Scriptorium integration](integrations/scriptorium.md).
|
||||||
|
|
||||||
|
## Template Rendering Fails
|
||||||
|
|
||||||
|
Symptom: Daily, Today, Tomorrow, or Hourly generation fails with report template
|
||||||
|
parsing or execution context after generated text validation succeeds.
|
||||||
|
|
||||||
|
Likely cause: an embedded template references a missing context field or
|
||||||
|
receives a value shape that does not match its typed render context.
|
||||||
|
|
||||||
|
Diagnostic:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
weatherreporter inspect metadata RUN_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
If metadata records generated-text and render-context paths, inspect those
|
||||||
|
artifacts along with the template named by the report definition.
|
||||||
|
|
||||||
|
Safe fix: update the embedded template or render-context builder so the
|
||||||
|
template uses the implemented typed context.
|
||||||
|
|
||||||
|
Relevant docs: [Report Templates](templates.md),
|
||||||
|
[Report Template internals](internal/reporttemplate.md).
|
||||||
|
|
||||||
## Batch Command Returns Nonzero
|
## Batch Command Returns Nonzero
|
||||||
|
|
||||||
Symptom: `run morning` or `run evening` returns nonzero.
|
Symptom: `run morning` or `run evening` returns nonzero.
|
||||||
|
|
||||||
Likely cause: at least one report in the batch failed.
|
Likely cause: weather collection failed before planning, or at least one
|
||||||
|
planned report failed after planning succeeded, or every report succeeded but
|
||||||
|
the top-level batch distributor notification failed.
|
||||||
|
|
||||||
Diagnostic: inspect stdout for the JSON summary and stderr for compact status
|
Diagnostic: if stdout contains a JSON summary, inspect each failed report item
|
||||||
lines.
|
and the top-level `notification` object. Stderr includes one
|
||||||
|
`batchNotification` line when batch notification is attempted, skipped, or
|
||||||
|
fails. If no summary was emitted, inspect the command error; configuration,
|
||||||
|
Weather API collection, or batch validation failed before any report artifacts
|
||||||
|
were created.
|
||||||
|
|
||||||
Safe fix: use the failed report's artifact paths from the summary, then inspect
|
Safe fix: for collection failures, fix the configuration or upstream Weather
|
||||||
metadata, sources, module snapshot, and data package for that RunID.
|
API availability and rerun the batch. For report failures, use the failed
|
||||||
|
report's artifact paths from the summary, then inspect metadata, sources,
|
||||||
|
module snapshot, and data package for that RunID. For a batch notification
|
||||||
|
failure, inspect the notification artifact path from the top-level
|
||||||
|
`notification.path`.
|
||||||
|
|
||||||
Relevant docs: [CLI reference](cli.md), [Operations guide](operations.md).
|
Relevant docs: [CLI reference](cli.md), [Operations guide](operations.md).
|
||||||
|
|
||||||
|
## Batch Upload Skipped
|
||||||
|
|
||||||
|
Symptom: a batch JSON summary contains
|
||||||
|
`"notification":{"status":"skipped","reason":"one or more reports failed"}`.
|
||||||
|
|
||||||
|
Likely cause: at least one planned report failed, so weatherreporter did not
|
||||||
|
call distributor for the batch.
|
||||||
|
|
||||||
|
Diagnostic: inspect the failed report items in the batch JSON summary and the
|
||||||
|
matching stderr report lines. A skipped batch notification has no distributor
|
||||||
|
run ID and no notification artifact path.
|
||||||
|
|
||||||
|
Safe fix: fix the report-generation failure first, then rerun the batch. The
|
||||||
|
batch upload is all-or-nothing.
|
||||||
|
|
||||||
|
Relevant docs: [Operations guide](operations.md).
|
||||||
|
|
||||||
|
## Batch Upload Fails
|
||||||
|
|
||||||
|
Symptom: every report item in a batch summary is succeeded, but the batch
|
||||||
|
returns nonzero and the top-level notification has `status: "failed"`.
|
||||||
|
|
||||||
|
Likely cause: the distributor upload was rejected, the distributor service was
|
||||||
|
unavailable, status polling reached a terminal distributor failure, or
|
||||||
|
weatherreporter rejected the batch file mapping before upload.
|
||||||
|
|
||||||
|
Diagnostic: inspect `notification.error`, `notification.pipelineId`,
|
||||||
|
`notification.bundleId`, `notification.idempotencyKey`, and
|
||||||
|
`notification.path` in stdout. Then inspect the notification artifact; it
|
||||||
|
records included report source paths, bundle paths, upload status, distributor
|
||||||
|
run status, status lookup error, and raw status report JSON when available.
|
||||||
|
|
||||||
|
Safe fix: fix the endpoint, token, distributor pipeline, batch identity
|
||||||
|
templates, or report path templates indicated by the error, then rerun the
|
||||||
|
batch. Individual report artifacts from the failed batch notification remain
|
||||||
|
available and do not need to be regenerated for diagnosis.
|
||||||
|
|
||||||
|
Relevant docs: [Configuration reference](config.md),
|
||||||
|
[Operations guide](operations.md).
|
||||||
|
|
||||||
|
## Duplicate Batch Bundle Path
|
||||||
|
|
||||||
|
Symptom: a batch returns nonzero with duplicate bundle path context before a
|
||||||
|
distributor run ID is accepted.
|
||||||
|
|
||||||
|
Likely cause: report-specific distributor path templates rendered the same
|
||||||
|
bundle-relative path for two included reports in the same batch.
|
||||||
|
|
||||||
|
Diagnostic: inspect the error in stdout or stderr. The validation error
|
||||||
|
includes the duplicate bundle path plus the report IDs, RunIDs, and managed
|
||||||
|
source paths involved.
|
||||||
|
|
||||||
|
Safe fix: configure a per-report distributor path override so every report in a
|
||||||
|
batch renders a unique path. Include values such as `{artifact_group}`,
|
||||||
|
`{valid_start_date}`, `{batch_output_name}`, or `{run_id}` when needed.
|
||||||
|
|
||||||
|
Relevant docs: [Configuration reference](config.md),
|
||||||
|
[Operations guide](operations.md).
|
||||||
|
|
||||||
|
## Distributor Source Conflict
|
||||||
|
|
||||||
|
Symptom: distributor accepts or rejects an upload with conflict context for a
|
||||||
|
source, destination, digest, or idempotency key.
|
||||||
|
|
||||||
|
Likely cause: the rendered bundle ID or idempotency key does not match the
|
||||||
|
intended producer identity. A bundle ID identifies the logical source stream;
|
||||||
|
an idempotency key identifies a retry of the same upload request.
|
||||||
|
|
||||||
|
Diagnostic: inspect the report notification artifact linked from metadata or
|
||||||
|
the batch notification artifact linked from the top-level notification path.
|
||||||
|
Compare the rendered pipeline ID, bundle ID, idempotency key, included source
|
||||||
|
paths, and bundle paths with `notify.distributor.*` templates and distributor
|
||||||
|
pipeline state.
|
||||||
|
|
||||||
|
Safe fix: keep bundle ID templates stable for the source stream that should be
|
||||||
|
updated, and keep idempotency keys stable only for retries of the same generated
|
||||||
|
content. Do not reuse one idempotency key for different report or batch
|
||||||
|
content.
|
||||||
|
|
||||||
|
Relevant docs: [Operations guide](operations.md),
|
||||||
|
[Distributor adapter internals](internal/distributor-adapter.md).
|
||||||
|
|
||||||
## Invalid Secrets Directory
|
## Invalid Secrets Directory
|
||||||
|
|
||||||
Symptom: config loading fails with `read secrets directory`, `secret file`, or
|
Symptom: config loading fails with `read secrets directory`, `secret file`, or
|
||||||
@@ -245,12 +383,14 @@ within the same distributor token and pipeline. By default the bundle ID is a
|
|||||||
stable report-stream identity and the idempotency key appends RunID.
|
stable report-stream identity and the idempotency key appends RunID.
|
||||||
|
|
||||||
Diagnostic: inspect the failed batch JSON or stderr line for pipeline, bundle,
|
Diagnostic: inspect the failed batch JSON or stderr line for pipeline, bundle,
|
||||||
and idempotency context. Compare the configured templates with the report RunID
|
and idempotency context. For batch commands, use the top-level notification
|
||||||
and report path.
|
object rather than per-report notification fields. Compare the configured
|
||||||
|
templates with the report RunID or batch RunID and report path.
|
||||||
|
|
||||||
Also inspect the notification artifact linked from metadata. It records the
|
Also inspect the notification artifact linked from metadata or from the
|
||||||
rendered pipeline ID, bundle ID, idempotency key, upload result, distributor run
|
top-level batch notification path. It records the rendered pipeline ID, bundle
|
||||||
status, status error, and raw run report JSON when available.
|
ID, idempotency key, upload result, distributor run status, status error, and
|
||||||
|
raw run report JSON when available.
|
||||||
|
|
||||||
Safe fix: keep idempotency templates stable for retries of the same generated
|
Safe fix: keep idempotency templates stable for retries of the same generated
|
||||||
report, but do not reuse the same rendered key for different generated report
|
report, but do not reuse the same rendered key for different generated report
|
||||||
@@ -268,9 +408,10 @@ Likely cause: the distributor endpoint rejected the token, pipeline ID, bundle
|
|||||||
ID, idempotency key, source file, or one of the rendered bundle paths.
|
ID, idempotency key, source file, or one of the rendered bundle paths.
|
||||||
|
|
||||||
Diagnostic: inspect stdout JSON or stderr status lines for
|
Diagnostic: inspect stdout JSON or stderr status lines for
|
||||||
`notificationError`. Confirm `notify.distributor.endpoint`,
|
`notificationError` or the top-level batch notification `error`. Confirm
|
||||||
|
`notify.distributor.endpoint`,
|
||||||
`notify.distributor.pipeline_id_template`,
|
`notify.distributor.pipeline_id_template`,
|
||||||
`notify.distributor.report_path_templates`, and token configuration. Token
|
report-specific distributor paths, and token configuration. Token
|
||||||
values are redacted from weatherreporter errors.
|
values are redacted from weatherreporter errors.
|
||||||
|
|
||||||
If the upload was accepted but destination output did not change, inspect the
|
If the upload was accepted but destination output did not change, inspect the
|
||||||
@@ -295,8 +436,8 @@ Likely cause: the configured distributor endpoint is unreachable, slow, or
|
|||||||
temporarily unavailable.
|
temporarily unavailable.
|
||||||
|
|
||||||
Diagnostic: check network access from the weatherreporter host to
|
Diagnostic: check network access from the weatherreporter host to
|
||||||
`notify.distributor.endpoint`. For batch runs, inspect which reports have
|
`notify.distributor.endpoint`. For batch runs, inspect the top-level
|
||||||
`notificationStatus: "failed"`.
|
notification object and the artifact linked by `notification.path`.
|
||||||
|
|
||||||
Safe fix: restore distributor service availability and rerun the affected
|
Safe fix: restore distributor service availability and rerun the affected
|
||||||
report or batch. Stable idempotency keys make retrying the same generated report
|
report or batch. Stable idempotency keys make retrying the same generated report
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
weather_api:
|
weather_api:
|
||||||
base_url: https://weather.api.rakestrawhome.com/
|
base_url: https://weather.api.example.com/
|
||||||
timeout: 15s
|
timeout: 15s
|
||||||
precision: 1
|
precision: 1
|
||||||
units: us
|
units: us
|
||||||
@@ -21,11 +21,14 @@ 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:
|
batch:
|
||||||
- "{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md"
|
enabled: true
|
||||||
|
pipeline_id_template: "weatherreporter"
|
||||||
|
bundle_id_template: "weatherreporter.{location_id}.{batch}"
|
||||||
|
idempotency_key_template: "{bundle_id}.{batch_run_id}"
|
||||||
|
|
||||||
missing_source:
|
missing_source:
|
||||||
default: warn
|
default: warn
|
||||||
@@ -69,6 +72,10 @@ recent_change:
|
|||||||
|
|
||||||
reports:
|
reports:
|
||||||
daily:
|
daily:
|
||||||
|
distributor:
|
||||||
|
path_templates:
|
||||||
|
- "daily/{valid_start_date}/{run_id}.md"
|
||||||
|
- "daily/{valid_start_date}/index.md"
|
||||||
deterministic_modules:
|
deterministic_modules:
|
||||||
- metadata
|
- metadata
|
||||||
- current_conditions
|
- current_conditions
|
||||||
@@ -77,6 +84,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 +92,45 @@ reports:
|
|||||||
- key_messages
|
- key_messages
|
||||||
- short_term
|
- short_term
|
||||||
- long_term
|
- long_term
|
||||||
|
- spc_convective_discussion
|
||||||
|
- weather_story
|
||||||
|
- outdoor_windows
|
||||||
|
- daily_planning
|
||||||
|
- hourly_forecast
|
||||||
|
today:
|
||||||
|
deterministic_modules:
|
||||||
|
- metadata
|
||||||
|
- current_conditions
|
||||||
|
- narrative_forecast
|
||||||
|
- derived_daily_summary
|
||||||
|
- derived_daypart_summaries
|
||||||
|
- precip_timing
|
||||||
|
- alert_digest
|
||||||
|
- spc_convective_outlooks
|
||||||
|
- id: area_forecast_discussion
|
||||||
|
options:
|
||||||
|
sections:
|
||||||
|
- product
|
||||||
|
- key_messages
|
||||||
|
- short_term
|
||||||
|
- long_term
|
||||||
|
- spc_convective_discussion
|
||||||
- weather_story
|
- weather_story
|
||||||
- outdoor_windows
|
- outdoor_windows
|
||||||
- hourly_forecast
|
- hourly_forecast
|
||||||
|
- today_planning
|
||||||
|
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")
|
||||||
@@ -124,6 +140,82 @@ func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
||||||
|
result, err := r.executeRun(ctx, outputRunRequest{
|
||||||
|
PromptID: req.PromptID,
|
||||||
|
DataPackagePath: req.DataPackagePath,
|
||||||
|
OutputPath: req.OutputPath,
|
||||||
|
}, "run scriptorium", "scriptorium run")
|
||||||
|
if err != nil {
|
||||||
|
if result == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result.runResult(), err
|
||||||
|
}
|
||||||
|
return result.runResult(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Runner) StructuredRun(ctx context.Context, req StructuredRunRequest) (*StructuredRunResult, error) {
|
||||||
|
result, err := r.executeRun(ctx, outputRunRequest{
|
||||||
|
PromptID: req.PromptID,
|
||||||
|
DataPackagePath: req.DataPackagePath,
|
||||||
|
OutputPath: req.OutputPath,
|
||||||
|
}, "run scriptorium structured output", "scriptorium structured run")
|
||||||
|
if err != nil {
|
||||||
|
if result == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result.structuredRunResult(), err
|
||||||
|
}
|
||||||
|
return result.structuredRunResult(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (result outputRunResult) runResult() *RunResult {
|
||||||
|
return &RunResult{
|
||||||
|
Command: result.Command,
|
||||||
|
Stdout: result.Stdout,
|
||||||
|
Stderr: result.Stderr,
|
||||||
|
StdoutTruncated: result.StdoutTruncated,
|
||||||
|
StderrTruncated: result.StderrTruncated,
|
||||||
|
ExitCode: result.ExitCode,
|
||||||
|
OutputPath: result.OutputPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (result outputRunResult) structuredRunResult() *StructuredRunResult {
|
||||||
|
return &StructuredRunResult{
|
||||||
|
Command: result.Command,
|
||||||
|
Stdout: result.Stdout,
|
||||||
|
Stderr: result.Stderr,
|
||||||
|
StdoutTruncated: result.StdoutTruncated,
|
||||||
|
StderrTruncated: result.StderrTruncated,
|
||||||
|
ExitCode: result.ExitCode,
|
||||||
|
OutputPath: result.OutputPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type execution struct {
|
||||||
|
binary string
|
||||||
|
args []string
|
||||||
|
result CommandResult
|
||||||
|
}
|
||||||
|
|
||||||
|
type outputRunRequest struct {
|
||||||
|
PromptID string
|
||||||
|
DataPackagePath string
|
||||||
|
OutputPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type outputRunResult struct {
|
||||||
|
Command []string
|
||||||
|
Stdout string
|
||||||
|
Stderr string
|
||||||
|
StdoutTruncated bool
|
||||||
|
StderrTruncated bool
|
||||||
|
ExitCode int
|
||||||
|
OutputPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Runner) executeRun(ctx context.Context, req outputRunRequest, executeContext string, exitContext string) (*outputRunResult, error) {
|
||||||
if req.PromptID == "" {
|
if req.PromptID == "" {
|
||||||
return nil, fmt.Errorf("prompt id is required")
|
return nil, fmt.Errorf("prompt id is required")
|
||||||
}
|
}
|
||||||
@@ -133,11 +225,15 @@ func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
|||||||
if req.OutputPath == "" {
|
if req.OutputPath == "" {
|
||||||
return nil, fmt.Errorf("output path is required")
|
return nil, fmt.Errorf("output path is required")
|
||||||
}
|
}
|
||||||
execution, err := r.execute(ctx, r.runArgs(req))
|
execution, err := r.execute(ctx, r.runArgs(RunRequest{
|
||||||
|
PromptID: req.PromptID,
|
||||||
|
DataPackagePath: req.DataPackagePath,
|
||||||
|
OutputPath: req.OutputPath,
|
||||||
|
}))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("run scriptorium: %w", err)
|
return nil, fmt.Errorf("%s: %w", executeContext, err)
|
||||||
}
|
}
|
||||||
result := &RunResult{
|
result := &outputRunResult{
|
||||||
Command: execution.argv(),
|
Command: execution.argv(),
|
||||||
Stdout: string(execution.result.Stdout),
|
Stdout: string(execution.result.Stdout),
|
||||||
Stderr: string(execution.result.Stderr),
|
Stderr: string(execution.result.Stderr),
|
||||||
@@ -147,17 +243,11 @@ func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
|||||||
OutputPath: req.OutputPath,
|
OutputPath: req.OutputPath,
|
||||||
}
|
}
|
||||||
if execution.result.ExitCode != 0 {
|
if execution.result.ExitCode != 0 {
|
||||||
return result, fmt.Errorf("scriptorium run exited with code %d: %s", execution.result.ExitCode, result.Stderr)
|
return result, fmt.Errorf("%s exited with code %d: %s", exitContext, execution.result.ExitCode, result.Stderr)
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type execution struct {
|
|
||||||
binary string
|
|
||||||
args []string
|
|
||||||
result CommandResult
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r Runner) execute(ctx context.Context, args []string) (execution, error) {
|
func (r Runner) execute(ctx context.Context, args []string) (execution, error) {
|
||||||
binary := r.Binary
|
binary := r.Binary
|
||||||
if binary == "" {
|
if binary == "" {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package scriptorium
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -19,7 +20,7 @@ func TestRenderConstructsCommand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result, err := runner.Render(context.Background(), RenderRequest{
|
result, err := runner.Render(context.Background(), RenderRequest{
|
||||||
PromptID: "weather.daily_report",
|
PromptID: "weather.markdown_report",
|
||||||
DataPackagePath: "/tmp/data_package.yaml",
|
DataPackagePath: "/tmp/data_package.yaml",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -30,7 +31,7 @@ func TestRenderConstructsCommand(t *testing.T) {
|
|||||||
"render",
|
"render",
|
||||||
"--config", "/etc/scriptorium.yml",
|
"--config", "/etc/scriptorium.yml",
|
||||||
"--profile", "weather",
|
"--profile", "weather",
|
||||||
"--prompt", "weather.daily_report",
|
"--prompt", "weather.markdown_report",
|
||||||
"--input", "data_package=/tmp/data_package.yaml",
|
"--input", "data_package=/tmp/data_package.yaml",
|
||||||
"--format", "json",
|
"--format", "json",
|
||||||
}
|
}
|
||||||
@@ -56,7 +57,7 @@ func TestRenderReturnsResultForNonzeroExit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result, err := runner.Render(context.Background(), RenderRequest{
|
result, err := runner.Render(context.Background(), RenderRequest{
|
||||||
PromptID: "weather.daily_report",
|
PromptID: "weather.markdown_report",
|
||||||
DataPackagePath: "/tmp/data_package.yaml",
|
DataPackagePath: "/tmp/data_package.yaml",
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -84,7 +85,7 @@ func TestRunConstructsCommand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result, err := runner.Run(context.Background(), RunRequest{
|
result, err := runner.Run(context.Background(), RunRequest{
|
||||||
PromptID: "weather.daily_report",
|
PromptID: "weather.markdown_report",
|
||||||
DataPackagePath: "/tmp/data_package.yaml",
|
DataPackagePath: "/tmp/data_package.yaml",
|
||||||
OutputPath: "/tmp/daily.md",
|
OutputPath: "/tmp/daily.md",
|
||||||
})
|
})
|
||||||
@@ -96,7 +97,7 @@ func TestRunConstructsCommand(t *testing.T) {
|
|||||||
"run",
|
"run",
|
||||||
"--config", "/etc/scriptorium.yml",
|
"--config", "/etc/scriptorium.yml",
|
||||||
"--profile", "weather",
|
"--profile", "weather",
|
||||||
"--prompt", "weather.daily_report",
|
"--prompt", "weather.markdown_report",
|
||||||
"--input", "data_package=/tmp/data_package.yaml",
|
"--input", "data_package=/tmp/data_package.yaml",
|
||||||
"--out", "/tmp/daily.md",
|
"--out", "/tmp/daily.md",
|
||||||
}
|
}
|
||||||
@@ -129,7 +130,7 @@ func TestRunReturnsResultForValidationExit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result, err := runner.Run(context.Background(), RunRequest{
|
result, err := runner.Run(context.Background(), RunRequest{
|
||||||
PromptID: "weather.daily_report",
|
PromptID: "weather.markdown_report",
|
||||||
DataPackagePath: "/tmp/data_package.yaml",
|
DataPackagePath: "/tmp/data_package.yaml",
|
||||||
OutputPath: "/tmp/daily.md",
|
OutputPath: "/tmp/daily.md",
|
||||||
})
|
})
|
||||||
@@ -147,17 +148,397 @@ 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/data_package.hourly.yaml",
|
||||||
|
OutputPath: "/tmp/generated_text_raw.hourly.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/data_package.hourly.yaml",
|
||||||
|
"--out", "/tmp/generated_text_raw.hourly.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/generated_text_raw.hourly.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/data_package.hourly.yaml",
|
||||||
|
OutputPath: "/tmp/generated_text_raw.hourly.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/generated_text_raw.hourly.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 TestOutputRunsPreserveCapturedResultFields(t *testing.T) {
|
||||||
|
type commonResult struct {
|
||||||
|
Command []string
|
||||||
|
Stdout string
|
||||||
|
Stderr string
|
||||||
|
StdoutTruncated bool
|
||||||
|
StderrTruncated bool
|
||||||
|
ExitCode int
|
||||||
|
OutputPath string
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
run func(Runner) (*commonResult, error)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Run",
|
||||||
|
run: func(runner Runner) (*commonResult, error) {
|
||||||
|
result, err := runner.Run(context.Background(), RunRequest{
|
||||||
|
PromptID: "weather.markdown_report",
|
||||||
|
DataPackagePath: "/tmp/data_package.yaml",
|
||||||
|
OutputPath: "/tmp/report.md",
|
||||||
|
})
|
||||||
|
if result == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &commonResult{
|
||||||
|
Command: result.Command,
|
||||||
|
Stdout: result.Stdout,
|
||||||
|
Stderr: result.Stderr,
|
||||||
|
StdoutTruncated: result.StdoutTruncated,
|
||||||
|
StderrTruncated: result.StderrTruncated,
|
||||||
|
ExitCode: result.ExitCode,
|
||||||
|
OutputPath: result.OutputPath,
|
||||||
|
}, err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "StructuredRun",
|
||||||
|
run: func(runner Runner) (*commonResult, error) {
|
||||||
|
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
|
||||||
|
PromptID: "weather.markdown_report",
|
||||||
|
DataPackagePath: "/tmp/data_package.yaml",
|
||||||
|
OutputPath: "/tmp/report.md",
|
||||||
|
})
|
||||||
|
if result == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &commonResult{
|
||||||
|
Command: result.Command,
|
||||||
|
Stdout: result.Stdout,
|
||||||
|
Stderr: result.Stderr,
|
||||||
|
StdoutTruncated: result.StdoutTruncated,
|
||||||
|
StderrTruncated: result.StderrTruncated,
|
||||||
|
ExitCode: result.ExitCode,
|
||||||
|
OutputPath: result.OutputPath,
|
||||||
|
}, err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
commands := &fakeCommands{result: CommandResult{
|
||||||
|
Stdout: []byte("captured stdout"),
|
||||||
|
Stderr: []byte("captured stderr"),
|
||||||
|
StdoutTruncated: true,
|
||||||
|
StderrTruncated: true,
|
||||||
|
}}
|
||||||
|
runner := Runner{
|
||||||
|
Binary: "/usr/local/bin/scriptorium",
|
||||||
|
ConfigPath: "/etc/scriptorium.yml",
|
||||||
|
Profile: "weather",
|
||||||
|
Timeout: 15 * time.Second,
|
||||||
|
Commands: commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := test.run(runner)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s error = %v", test.name, err)
|
||||||
|
}
|
||||||
|
wantArgs := []string{
|
||||||
|
"run",
|
||||||
|
"--config", "/etc/scriptorium.yml",
|
||||||
|
"--profile", "weather",
|
||||||
|
"--prompt", "weather.markdown_report",
|
||||||
|
"--input", "data_package=/tmp/data_package.yaml",
|
||||||
|
"--out", "/tmp/report.md",
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(commands.args, wantArgs) {
|
||||||
|
t.Fatalf("args = %#v, want %#v", commands.args, wantArgs)
|
||||||
|
}
|
||||||
|
if commands.timeout != 15*time.Second {
|
||||||
|
t.Fatalf("timeout = %s, want 15s", commands.timeout)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(result.Command, append([]string{"/usr/local/bin/scriptorium"}, wantArgs...)) {
|
||||||
|
t.Fatalf("Command = %#v, want full argv", result.Command)
|
||||||
|
}
|
||||||
|
if result.Stdout != "captured stdout" || result.Stderr != "captured stderr" {
|
||||||
|
t.Fatalf("captured output = %q/%q, want stdout/stderr", result.Stdout, result.Stderr)
|
||||||
|
}
|
||||||
|
if !result.StdoutTruncated || !result.StderrTruncated {
|
||||||
|
t.Fatalf("truncation flags = %t/%t, want both true", result.StdoutTruncated, result.StderrTruncated)
|
||||||
|
}
|
||||||
|
if result.ExitCode != 0 || result.OutputPath != "/tmp/report.md" {
|
||||||
|
t.Fatalf("result = %#v, want exit 0 and output path", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutputRunsReturnCapturedResultForNonzeroExit(t *testing.T) {
|
||||||
|
type commonResult struct {
|
||||||
|
Stdout string
|
||||||
|
Stderr string
|
||||||
|
StderrTruncated bool
|
||||||
|
ExitCode int
|
||||||
|
OutputPath string
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
run func(Runner) (*commonResult, error)
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Run",
|
||||||
|
run: func(runner Runner) (*commonResult, error) {
|
||||||
|
result, err := runner.Run(context.Background(), RunRequest{
|
||||||
|
PromptID: "weather.markdown_report",
|
||||||
|
DataPackagePath: "/tmp/data_package.yaml",
|
||||||
|
OutputPath: "/tmp/report.md",
|
||||||
|
})
|
||||||
|
if result == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &commonResult{
|
||||||
|
Stdout: result.Stdout,
|
||||||
|
Stderr: result.Stderr,
|
||||||
|
StderrTruncated: result.StderrTruncated,
|
||||||
|
ExitCode: result.ExitCode,
|
||||||
|
OutputPath: result.OutputPath,
|
||||||
|
}, err
|
||||||
|
},
|
||||||
|
wantErr: "scriptorium run exited with code 7: captured stderr",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "StructuredRun",
|
||||||
|
run: func(runner Runner) (*commonResult, error) {
|
||||||
|
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
|
||||||
|
PromptID: "weather.markdown_report",
|
||||||
|
DataPackagePath: "/tmp/data_package.yaml",
|
||||||
|
OutputPath: "/tmp/report.md",
|
||||||
|
})
|
||||||
|
if result == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &commonResult{
|
||||||
|
Stdout: result.Stdout,
|
||||||
|
Stderr: result.Stderr,
|
||||||
|
StderrTruncated: result.StderrTruncated,
|
||||||
|
ExitCode: result.ExitCode,
|
||||||
|
OutputPath: result.OutputPath,
|
||||||
|
}, err
|
||||||
|
},
|
||||||
|
wantErr: "scriptorium structured run exited with code 7: captured stderr",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
runner := Runner{
|
||||||
|
Commands: &fakeCommands{result: CommandResult{
|
||||||
|
Stdout: []byte("captured stdout"),
|
||||||
|
Stderr: []byte("captured stderr"),
|
||||||
|
StderrTruncated: true,
|
||||||
|
ExitCode: 7,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := test.run(runner)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("%s error = nil, want nonzero exit error", test.name)
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
t.Fatalf("%s result = nil, want captured result", test.name)
|
||||||
|
}
|
||||||
|
if err.Error() != test.wantErr {
|
||||||
|
t.Fatalf("%s error = %q, want %q", test.name, err.Error(), test.wantErr)
|
||||||
|
}
|
||||||
|
if result.Stdout != "captured stdout" || result.Stderr != "captured stderr" || !result.StderrTruncated {
|
||||||
|
t.Fatalf("captured result = %#v, want stdout/stderr/truncation", result)
|
||||||
|
}
|
||||||
|
if result.ExitCode != 7 || result.OutputPath != "/tmp/report.md" {
|
||||||
|
t.Fatalf("result = %#v, want exit 7 and output path", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutputRunsValidateRequiredFieldsBeforeExecution(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
run func(Runner, string, string, string) error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Run",
|
||||||
|
run: func(runner Runner, promptID string, dataPackagePath string, outputPath string) error {
|
||||||
|
result, err := runner.Run(context.Background(), RunRequest{
|
||||||
|
PromptID: promptID,
|
||||||
|
DataPackagePath: dataPackagePath,
|
||||||
|
OutputPath: outputPath,
|
||||||
|
})
|
||||||
|
if result != nil {
|
||||||
|
return fmt.Errorf("result = %#v, want nil", result)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "StructuredRun",
|
||||||
|
run: func(runner Runner, promptID string, dataPackagePath string, outputPath string) error {
|
||||||
|
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
|
||||||
|
PromptID: promptID,
|
||||||
|
DataPackagePath: dataPackagePath,
|
||||||
|
OutputPath: outputPath,
|
||||||
|
})
|
||||||
|
if result != nil {
|
||||||
|
return fmt.Errorf("result = %#v, want nil", result)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
promptID string
|
||||||
|
dataPackagePath string
|
||||||
|
outputPath string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "prompt id",
|
||||||
|
dataPackagePath: "/tmp/data_package.yaml",
|
||||||
|
outputPath: "/tmp/report.md",
|
||||||
|
want: "prompt id is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "data package path",
|
||||||
|
promptID: "weather.markdown_report",
|
||||||
|
outputPath: "/tmp/report.md",
|
||||||
|
want: "data package path is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "output path",
|
||||||
|
promptID: "weather.markdown_report",
|
||||||
|
dataPackagePath: "/tmp/data_package.yaml",
|
||||||
|
want: "output path is required",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
commands := &fakeCommands{}
|
||||||
|
err := test.run(Runner{Commands: commands}, tc.promptID, tc.dataPackagePath, tc.outputPath)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("%s error = nil, want validation error", test.name)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tc.want) {
|
||||||
|
t.Fatalf("%s error = %v, want %q", test.name, err, tc.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
|
||||||
}
|
}
|
||||||
@@ -122,18 +130,32 @@ type bundleBuilder struct {
|
|||||||
fetchedAt time.Time
|
fetchedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type sourceRequest struct {
|
||||||
|
name string
|
||||||
|
endpoint string
|
||||||
|
query queryOptions
|
||||||
|
missingMessage string
|
||||||
|
required bool
|
||||||
|
decodeLabel string
|
||||||
|
}
|
||||||
|
|
||||||
|
type fetchedSource struct {
|
||||||
|
raw json.RawMessage
|
||||||
|
source weatherdata.Source
|
||||||
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
|
func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
|
||||||
raw, source, err := b.client.fetch(ctx, "observations", "/observations", queryOptions{precision: true})
|
var observation weatherdata.Observation
|
||||||
if err != nil {
|
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||||
|
name: "observations",
|
||||||
|
endpoint: "/observations",
|
||||||
|
query: queryOptions{precision: true},
|
||||||
|
missingMessage: "observation data is missing",
|
||||||
|
}, &observation)
|
||||||
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if raw == nil {
|
source := fetched.source
|
||||||
return b.handleMissing(&source, "observation data is missing", false)
|
|
||||||
}
|
|
||||||
var observation weatherdata.Observation
|
|
||||||
if err := decodeSource(raw, &observation); err != nil {
|
|
||||||
return b.handleMalformed(&source, err, false)
|
|
||||||
}
|
|
||||||
source.IssuedAt = &observation.Timestamp
|
source.IssuedAt = &observation.Timestamp
|
||||||
b.bundle.Observation = &observation
|
b.bundle.Observation = &observation
|
||||||
b.addSource(source)
|
b.addSource(source)
|
||||||
@@ -141,34 +163,36 @@ func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
|
func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
|
||||||
raw, source, err := b.client.fetch(ctx, "current", "/conditions/current", queryOptions{precision: true})
|
var current weatherdata.Current
|
||||||
if err != nil {
|
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||||
|
name: "current",
|
||||||
|
endpoint: "/conditions/current",
|
||||||
|
query: queryOptions{precision: true},
|
||||||
|
missingMessage: "current conditions data is missing",
|
||||||
|
}, ¤t)
|
||||||
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if raw == nil {
|
source := fetched.source
|
||||||
return b.handleMissing(&source, "current conditions data is missing", false)
|
|
||||||
}
|
|
||||||
var current weatherdata.Current
|
|
||||||
if err := decodeSource(raw, ¤t); err != nil {
|
|
||||||
return b.handleMalformed(&source, err, false)
|
|
||||||
}
|
|
||||||
b.bundle.Current = ¤t
|
b.bundle.Current = ¤t
|
||||||
b.addSource(source)
|
b.addSource(source)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
|
func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
|
||||||
raw, source, err := b.client.fetch(ctx, "hourly", "/forecast/hourly", queryOptions{precision: true, timezone: true})
|
var hourly weatherdata.ForecastRun
|
||||||
if err != nil {
|
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||||
|
name: "hourly",
|
||||||
|
endpoint: "/forecast/hourly",
|
||||||
|
query: queryOptions{precision: true, timezone: true},
|
||||||
|
missingMessage: "hourly forecast data is missing",
|
||||||
|
required: true,
|
||||||
|
decodeLabel: "hourly forecast",
|
||||||
|
}, &hourly)
|
||||||
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if raw == nil {
|
source := fetched.source
|
||||||
return b.handleMissing(&source, "hourly forecast data is missing", true)
|
|
||||||
}
|
|
||||||
var hourly weatherdata.ForecastRun
|
|
||||||
if err := decodeSource(raw, &hourly); err != nil {
|
|
||||||
return fmt.Errorf("decode hourly forecast from %s: %w", source.Endpoint, err)
|
|
||||||
}
|
|
||||||
if len(hourly.Periods) == 0 {
|
if len(hourly.Periods) == 0 {
|
||||||
return fmt.Errorf("hourly forecast from %s contains no periods", source.Endpoint)
|
return fmt.Errorf("hourly forecast from %s contains no periods", source.Endpoint)
|
||||||
}
|
}
|
||||||
@@ -180,17 +204,17 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
|
func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
|
||||||
raw, source, err := b.client.fetch(ctx, "narrative", "/forecast/narrative", queryOptions{precision: true, timezone: true})
|
var narrative weatherdata.ForecastRun
|
||||||
if err != nil {
|
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||||
|
name: "narrative",
|
||||||
|
endpoint: "/forecast/narrative",
|
||||||
|
query: queryOptions{precision: true, timezone: true},
|
||||||
|
missingMessage: "narrative forecast data is missing",
|
||||||
|
}, &narrative)
|
||||||
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if raw == nil {
|
source := fetched.source
|
||||||
return b.handleMissing(&source, "narrative forecast data is missing", false)
|
|
||||||
}
|
|
||||||
var narrative weatherdata.ForecastRun
|
|
||||||
if err := decodeSource(raw, &narrative); err != nil {
|
|
||||||
return b.handleMalformed(&source, err, false)
|
|
||||||
}
|
|
||||||
source.IssuedAt = &narrative.IssuedAt
|
source.IssuedAt = &narrative.IssuedAt
|
||||||
source.UpdatedAt = narrative.UpdatedAt
|
source.UpdatedAt = narrative.UpdatedAt
|
||||||
b.bundle.Narrative = &narrative
|
b.bundle.Narrative = &narrative
|
||||||
@@ -213,7 +237,7 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
var alerts weatherdata.AlertRun
|
var alerts weatherdata.AlertRun
|
||||||
if err := decodeSource(raw, &alerts); err != nil {
|
if err := decodeSource(raw, &alerts); err != nil {
|
||||||
return b.handleMalformed(&source, err, false)
|
return b.handleMalformed(&source, err, sourceRequest{name: "alerts"})
|
||||||
}
|
}
|
||||||
alerts.Raw = append(json.RawMessage(nil), raw...)
|
alerts.Raw = append(json.RawMessage(nil), raw...)
|
||||||
if alerts.AsOf != nil {
|
if alerts.AsOf != nil {
|
||||||
@@ -225,17 +249,17 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
||||||
raw, source, err := b.client.fetch(ctx, "discussion", "/discussion", queryOptions{timezone: true})
|
var discussion weatherdata.Discussion
|
||||||
if err != nil {
|
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||||
|
name: "discussion",
|
||||||
|
endpoint: "/discussion",
|
||||||
|
query: queryOptions{timezone: true},
|
||||||
|
missingMessage: "forecast discussion data is missing",
|
||||||
|
}, &discussion)
|
||||||
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if raw == nil {
|
source := fetched.source
|
||||||
return b.handleMissing(&source, "forecast discussion data is missing", false)
|
|
||||||
}
|
|
||||||
var discussion weatherdata.Discussion
|
|
||||||
if err := decodeSource(raw, &discussion); err != nil {
|
|
||||||
return b.handleMalformed(&source, err, false)
|
|
||||||
}
|
|
||||||
source.IssuedAt = &discussion.IssuedAt
|
source.IssuedAt = &discussion.IssuedAt
|
||||||
source.UpdatedAt = discussion.UpdatedAt
|
source.UpdatedAt = discussion.UpdatedAt
|
||||||
b.bundle.Discussion = &discussion
|
b.bundle.Discussion = &discussion
|
||||||
@@ -244,17 +268,17 @@ func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
||||||
raw, source, err := b.client.fetch(ctx, "weather_story", "/weatherstories/latest", queryOptions{omitUnits: true})
|
var story weatherdata.WeatherStory
|
||||||
if err != nil {
|
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||||
|
name: "weather_story",
|
||||||
|
endpoint: "/weatherstories/latest",
|
||||||
|
query: queryOptions{omitUnits: true},
|
||||||
|
missingMessage: "NWS weather story data is missing",
|
||||||
|
}, &story)
|
||||||
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if raw == nil {
|
source := fetched.source
|
||||||
return b.handleMissing(&source, "NWS weather story data is missing", false)
|
|
||||||
}
|
|
||||||
var story weatherdata.WeatherStory
|
|
||||||
if err := decodeSource(raw, &story); err != nil {
|
|
||||||
return b.handleMalformed(&source, err, false)
|
|
||||||
}
|
|
||||||
if !story.StartTime.IsZero() {
|
if !story.StartTime.IsZero() {
|
||||||
source.IssuedAt = &story.StartTime
|
source.IssuedAt = &story.StartTime
|
||||||
}
|
}
|
||||||
@@ -264,6 +288,51 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *bundleBuilder) fetchSPCConvectiveOutlooks(ctx context.Context) error {
|
||||||
|
var run weatherdata.ConvectiveOutlookRun
|
||||||
|
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||||
|
name: sourceSPCConvectiveOutlooks,
|
||||||
|
endpoint: convectiveOutlooksEndpoint,
|
||||||
|
query: queryOptions{timezone: true, omitUnits: true},
|
||||||
|
missingMessage: "SPC convective outlook data is missing",
|
||||||
|
}, &run)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
source := fetched.source
|
||||||
|
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) fetchDecodedSource(ctx context.Context, request sourceRequest, target any) (fetchedSource, bool, error) {
|
||||||
|
fetched, ok, err := b.fetchSource(ctx, request)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return fetchedSource{}, false, err
|
||||||
|
}
|
||||||
|
if err := decodeSource(fetched.raw, target); err != nil {
|
||||||
|
return fetchedSource{}, false, b.handleMalformed(&fetched.source, err, request)
|
||||||
|
}
|
||||||
|
return fetched, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bundleBuilder) fetchSource(ctx context.Context, request sourceRequest) (fetchedSource, bool, error) {
|
||||||
|
raw, source, err := b.client.fetch(ctx, request.name, request.endpoint, request.query)
|
||||||
|
if err != nil {
|
||||||
|
return fetchedSource{}, false, err
|
||||||
|
}
|
||||||
|
if raw == nil {
|
||||||
|
return fetchedSource{}, false, b.handleMissing(&source, request.missingMessage, request.required)
|
||||||
|
}
|
||||||
|
return fetchedSource{raw: raw, source: source}, true, 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 {
|
||||||
@@ -272,9 +341,13 @@ func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string
|
|||||||
return b.applyMissingPolicy(source, "missing_source", message)
|
return b.applyMissingPolicy(source, "missing_source", message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) handleMalformed(source *weatherdata.Source, err error, required bool) error {
|
func (b *bundleBuilder) handleMalformed(source *weatherdata.Source, err error, request sourceRequest) error {
|
||||||
if required {
|
if request.required {
|
||||||
return fmt.Errorf("decode %s from %s: %w", source.Name, source.Endpoint, err)
|
label := request.name
|
||||||
|
if request.decodeLabel != "" {
|
||||||
|
label = request.decodeLabel
|
||||||
|
}
|
||||||
|
return fmt.Errorf("decode %s from %s: %w", label, source.Endpoint, err)
|
||||||
}
|
}
|
||||||
source.Missing = true
|
source.Missing = true
|
||||||
return b.applyMissingPolicy(source, "malformed_source", fmt.Sprintf("malformed %s data: %v", source.Name, err))
|
return b.applyMissingPolicy(source, "malformed_source", fmt.Sprintf("malformed %s data: %v", source.Name, err))
|
||||||
|
|||||||
@@ -55,12 +55,39 @@ 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))
|
||||||
}
|
}
|
||||||
|
wantPaths := []string{
|
||||||
|
"/observations",
|
||||||
|
"/conditions/current",
|
||||||
|
"/forecast/hourly",
|
||||||
|
"/forecast/narrative",
|
||||||
|
"/alerts/active",
|
||||||
|
"/discussion",
|
||||||
|
"/weatherstories/latest",
|
||||||
|
convectiveOutlooksEndpoint,
|
||||||
|
}
|
||||||
|
if len(requested) != len(wantPaths) {
|
||||||
|
t.Fatalf("requested paths = %v, want %d source endpoints", requested, len(wantPaths))
|
||||||
|
}
|
||||||
|
for _, want := range wantPaths {
|
||||||
|
if !containsPath(requested, want) {
|
||||||
|
t.Fatalf("requested paths = %v, want %s", requested, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
|
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
|
||||||
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
|
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
|
||||||
}
|
}
|
||||||
@@ -70,6 +97,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 +122,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 +163,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 +244,59 @@ func TestNullAlertsMeansNoActiveAlerts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMissingSPCConvectiveOutlooksUsesPolicy(t *testing.T) {
|
||||||
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
|
convectiveOutlooksEndpoint: {status: http.StatusOK, body: `{"data": null}`},
|
||||||
|
}, nil)
|
||||||
|
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
|
||||||
|
sourceSPCConvectiveOutlooks: config.MissingSourceWarn,
|
||||||
|
})
|
||||||
|
|
||||||
|
bundle, err := client.FetchBundle(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchBundle() error = %v", err)
|
||||||
|
}
|
||||||
|
if bundle.SPCConvectiveOutlooks != nil {
|
||||||
|
t.Fatalf("SPCConvectiveOutlooks = %#v, want nil for missing source", bundle.SPCConvectiveOutlooks)
|
||||||
|
}
|
||||||
|
source := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
|
||||||
|
if !source.Missing || len(source.Warnings) != 1 {
|
||||||
|
t.Fatalf("convective outlook source = %#v, want missing source warning", source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptySPCConvectiveOutlooksAreCheckedData(t *testing.T) {
|
||||||
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
|
convectiveOutlooksEndpoint: {status: http.StatusOK, body: `{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`},
|
||||||
|
}, nil)
|
||||||
|
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
|
||||||
|
sourceSPCConvectiveOutlooks: config.MissingSourceWarn,
|
||||||
|
})
|
||||||
|
|
||||||
|
bundle, err := client.FetchBundle(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchBundle() error = %v", err)
|
||||||
|
}
|
||||||
|
if bundle.SPCConvectiveOutlooks == nil {
|
||||||
|
t.Fatal("SPCConvectiveOutlooks = nil, want checked empty run")
|
||||||
|
}
|
||||||
|
if len(bundle.SPCConvectiveOutlooks.Outlooks) != 0 || len(bundle.SPCConvectiveOutlooks.Discussions) != 0 {
|
||||||
|
t.Fatalf("SPCConvectiveOutlooks = %#v, want empty arrays", bundle.SPCConvectiveOutlooks)
|
||||||
|
}
|
||||||
|
source := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
|
||||||
|
if source.Missing || len(source.Warnings) != 0 {
|
||||||
|
t.Fatalf("convective outlook source = %#v, want non-missing source without warnings", source)
|
||||||
|
}
|
||||||
|
if source.IssuedAt == nil || source.IssuedAt.Format(time.RFC3339) != "2026-05-29T16:00:00Z" {
|
||||||
|
t.Fatalf("convective outlook IssuedAt = %#v, want fallback to asOf", source.IssuedAt)
|
||||||
|
}
|
||||||
|
for _, warning := range bundle.Warnings {
|
||||||
|
if warning.Source == sourceSPCConvectiveOutlooks {
|
||||||
|
t.Fatalf("warnings = %#v, want no convective outlook warning", bundle.Warnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
|
func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -363,13 +471,14 @@ type handlerOverride struct {
|
|||||||
func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server {
|
func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
fixtures := map[string]string{
|
fixtures := map[string]string{
|
||||||
"/observations": "observations.json",
|
"/observations": "observations.json",
|
||||||
"/conditions/current": "current.json",
|
"/conditions/current": "current.json",
|
||||||
"/forecast/hourly": "hourly.json",
|
"/forecast/hourly": "hourly.json",
|
||||||
"/forecast/narrative": "narrative.json",
|
"/forecast/narrative": "narrative.json",
|
||||||
"/alerts/active": "alerts.json",
|
"/alerts/active": "alerts.json",
|
||||||
"/discussion": "discussion.json",
|
"/discussion": "discussion.json",
|
||||||
"/weatherstories/latest": "weather_story.json",
|
"/weatherstories/latest": "weather_story.json",
|
||||||
|
convectiveOutlooksEndpoint: "convective_outlooks.json",
|
||||||
}
|
}
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if requested != nil {
|
if requested != nil {
|
||||||
|
|||||||
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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,13 +10,14 @@ import (
|
|||||||
|
|
||||||
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
|
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/weatherapi"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
"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"
|
||||||
@@ -28,18 +29,20 @@ import (
|
|||||||
type ReportKind string
|
type ReportKind string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ReportDaily ReportKind = "daily"
|
ReportDaily ReportKind = ReportKind(report.CommandNameDaily)
|
||||||
ReportTomorrow ReportKind = "tomorrow"
|
ReportToday ReportKind = ReportKind(report.CommandNameToday)
|
||||||
ReportThreeDay ReportKind = "three-day"
|
ReportTomorrow ReportKind = ReportKind(report.CommandNameTomorrow)
|
||||||
ReportWeekend ReportKind = "weekend"
|
ReportHourly ReportKind = ReportKind(report.CommandNameHourly)
|
||||||
ReportStorm ReportKind = "storm"
|
ReportThreeDay ReportKind = ReportKind(report.CommandNameThreeDay)
|
||||||
|
ReportWeekend ReportKind = ReportKind(report.CommandNameWeekend)
|
||||||
|
ReportStorm ReportKind = ReportKind(report.CommandNameStorm)
|
||||||
)
|
)
|
||||||
|
|
||||||
type BatchKind string
|
type BatchKind string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
BatchMorning BatchKind = "morning"
|
BatchMorning BatchKind = BatchKind(report.BatchNameMorning)
|
||||||
BatchEvening BatchKind = "evening"
|
BatchEvening BatchKind = BatchKind(report.BatchNameEvening)
|
||||||
)
|
)
|
||||||
|
|
||||||
type GenerateRequest struct {
|
type GenerateRequest struct {
|
||||||
@@ -50,6 +53,7 @@ type GenerateRequest struct {
|
|||||||
Date time.Time
|
Date time.Time
|
||||||
StormStart time.Time
|
StormStart time.Time
|
||||||
StormEnd time.Time
|
StormEnd time.Time
|
||||||
|
Collector Collector
|
||||||
Notifier Notifier
|
Notifier Notifier
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +62,7 @@ type BatchRequest struct {
|
|||||||
Batch BatchKind
|
Batch BatchKind
|
||||||
Now time.Time
|
Now time.Time
|
||||||
OutputDir string
|
OutputDir string
|
||||||
|
Collector Collector
|
||||||
Renderer Renderer
|
Renderer Renderer
|
||||||
Store state.Store
|
Store state.Store
|
||||||
Notifier Notifier
|
Notifier Notifier
|
||||||
@@ -82,37 +87,64 @@ type ReportRequest struct {
|
|||||||
Config config.Config
|
Config config.Config
|
||||||
Resolved report.Resolved
|
Resolved report.Resolved
|
||||||
OutputPath string
|
OutputPath string
|
||||||
|
Collection collect.Result
|
||||||
Renderer Renderer
|
Renderer Renderer
|
||||||
Store state.Store
|
Store state.Store
|
||||||
Notifier Notifier
|
Notifier Notifier
|
||||||
|
noNotify bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReportResult struct {
|
type ReportResult struct {
|
||||||
ModuleSnapshot module.Snapshot
|
ModuleSnapshot module.Snapshot
|
||||||
ModuleSnapshotPath string
|
ModuleSnapshotPath string
|
||||||
DataPackage promptinput.Package
|
DataPackage promptinput.Package
|
||||||
DataPackagePath string
|
DataPackagePath string
|
||||||
PreflightPath string
|
PreflightPath string
|
||||||
ReportPath string
|
ReportPath string
|
||||||
OutputPath string
|
OutputPath string
|
||||||
NotificationPath string
|
NotificationPath string
|
||||||
Metadata state.Metadata
|
Metadata state.Metadata
|
||||||
MetadataPath string
|
MetadataPath string
|
||||||
PriorSnapshot *state.PriorSnapshot
|
PriorSnapshot *state.PriorSnapshot
|
||||||
RecentChanges []changes.Change
|
RecentChanges []changes.Change
|
||||||
RenderResult *scriptorium.RenderResult
|
RenderResult *scriptorium.RenderResult
|
||||||
RunResult *scriptorium.RunResult
|
RunResult *scriptorium.RunResult
|
||||||
Notification *NotificationResult
|
StructuredRunResult *scriptorium.StructuredRunResult
|
||||||
|
GeneratedTextRawPath string
|
||||||
|
GeneratedTextResultPath string
|
||||||
|
GeneratedTextPath string
|
||||||
|
RenderContextPath string
|
||||||
|
Notification *NotificationResult
|
||||||
}
|
}
|
||||||
|
|
||||||
type BatchResult struct {
|
type BatchResult struct {
|
||||||
Batch BatchKind `json:"batch"`
|
Batch BatchKind `json:"batch"`
|
||||||
StartedAt time.Time `json:"startedAt"`
|
StartedAt time.Time `json:"startedAt"`
|
||||||
FinishedAt time.Time `json:"finishedAt"`
|
FinishedAt time.Time `json:"finishedAt"`
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
Succeeded int `json:"succeeded"`
|
Succeeded int `json:"succeeded"`
|
||||||
Failed int `json:"failed"`
|
Failed int `json:"failed"`
|
||||||
Reports []BatchReportResult `json:"reports"`
|
Notification *BatchNotificationResult `json:"notification,omitempty"`
|
||||||
|
Reports []BatchReportResult `json:"reports"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BatchNotificationResult struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
RunID string `json:"runId,omitempty"`
|
||||||
|
PipelineID string `json:"pipelineId,omitempty"`
|
||||||
|
BundleID string `json:"bundleId,omitempty"`
|
||||||
|
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
IncludedReports []BatchNotificationReport `json:"includedReports,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BatchNotificationReport struct {
|
||||||
|
ReportID report.ID `json:"reportId"`
|
||||||
|
RunID string `json:"runId"`
|
||||||
|
SourcePath string `json:"sourcePath"`
|
||||||
|
BundlePaths []string `json:"bundlePaths"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type BatchReportResult struct {
|
type BatchReportResult struct {
|
||||||
@@ -144,12 +176,46 @@ func (e BatchError) Error() string {
|
|||||||
if e.Result == nil {
|
if e.Result == nil {
|
||||||
return "batch failed"
|
return "batch failed"
|
||||||
}
|
}
|
||||||
|
if batchNotificationFailed(e.Result) && batchReportFailures(e.Result) == 0 {
|
||||||
|
if e.Result.Notification.Error != "" {
|
||||||
|
return fmt.Sprintf("batch %s notification failed: %s", e.Result.Batch, e.Result.Notification.Error)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("batch %s notification failed", e.Result.Batch)
|
||||||
|
}
|
||||||
return fmt.Sprintf("batch %s failed: %d of %d reports failed", e.Result.Batch, e.Result.Failed, e.Result.Total)
|
return fmt.Sprintf("batch %s failed: %d of %d reports failed", e.Result.Batch, e.Result.Failed, e.Result.Total)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func batchNotificationFailed(result *BatchResult) bool {
|
||||||
|
return result != nil && result.Notification != nil && result.Notification.Status == "failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchReportFailures(result *BatchResult) int {
|
||||||
|
if result == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
failures := 0
|
||||||
|
for _, item := range result.Reports {
|
||||||
|
if item.Status == "failed" {
|
||||||
|
failures++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return failures
|
||||||
|
}
|
||||||
|
|
||||||
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 Collector interface {
|
||||||
|
Run(context.Context, collect.Request) (*collect.Result, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type defaultCollector struct{}
|
||||||
|
|
||||||
|
func (defaultCollector) Run(ctx context.Context, req collect.Request) (*collect.Result, error) {
|
||||||
|
return collect.Run(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Notifier interface {
|
type Notifier interface {
|
||||||
@@ -206,6 +272,10 @@ func Generate(ctx context.Context, req GenerateRequest) error {
|
|||||||
if now.IsZero() {
|
if now.IsZero() {
|
||||||
now = time.Now()
|
now = time.Now()
|
||||||
}
|
}
|
||||||
|
collection, err := collectWeather(ctx, req.Config, req.Collector)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
resolved, err := ResolveGenerate(req, now)
|
resolved, err := ResolveGenerate(req, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -215,6 +285,7 @@ func Generate(ctx context.Context, req GenerateRequest) error {
|
|||||||
Config: req.Config,
|
Config: req.Config,
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
OutputPath: req.OutputPath,
|
OutputPath: req.OutputPath,
|
||||||
|
Collection: *collection,
|
||||||
Notifier: req.Notifier,
|
Notifier: req.Notifier,
|
||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
@@ -238,7 +309,14 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
|||||||
if now.IsZero() {
|
if now.IsZero() {
|
||||||
now = time.Now()
|
now = time.Now()
|
||||||
}
|
}
|
||||||
resolvedReports, err := ResolveBatch(req, now)
|
if _, err := report.BatchForCommandName(string(req.Batch)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
collection, err := collectWeather(ctx, req.Config, req.Collector)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
plannedReports, err := planBatchRun(req, now, *collection)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -253,27 +331,31 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
|||||||
}
|
}
|
||||||
startedAt := now
|
startedAt := now
|
||||||
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
|
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
|
||||||
for _, resolved := range resolvedReports {
|
for _, planned := range plannedReports {
|
||||||
|
resolved := planned.Resolved
|
||||||
if !resolved.Definition.Generated {
|
if !resolved.Definition.Generated {
|
||||||
return nil, fmt.Errorf("run is not implemented")
|
return nil, fmt.Errorf("run is not implemented")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, resolved := range resolvedReports {
|
for _, planned := range plannedReports {
|
||||||
item := batchReportResult(resolved)
|
resolved := planned.Resolved
|
||||||
|
item := batchReportResult(planned)
|
||||||
if paths, err := store.Paths(resolved); err == nil {
|
if paths, err := store.Paths(resolved); err == nil {
|
||||||
item.DataPackagePath = paths.DataPackage
|
item.DataPackagePath = paths.DataPackage
|
||||||
item.PreflightPath = paths.Preflight
|
item.PreflightPath = paths.Preflight
|
||||||
item.ReportPath = paths.RenderedReport
|
item.ReportPath = paths.RenderedReport
|
||||||
item.MetadataPath = paths.Metadata
|
item.MetadataPath = paths.Metadata
|
||||||
}
|
}
|
||||||
outputPath := batchOutputPath(req.OutputDir, resolved.Definition)
|
outputPath := plannedBatchOutputPath(req.OutputDir, planned)
|
||||||
reportResult, err := GenerateReport(ctx, ReportRequest{
|
reportResult, err := GenerateReport(ctx, ReportRequest{
|
||||||
Config: req.Config,
|
Config: req.Config,
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
OutputPath: outputPath,
|
OutputPath: outputPath,
|
||||||
|
Collection: *collection,
|
||||||
Renderer: req.Renderer,
|
Renderer: req.Renderer,
|
||||||
Store: store,
|
Store: store,
|
||||||
Notifier: req.Notifier,
|
Notifier: req.Notifier,
|
||||||
|
noNotify: true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
item.Status = "failed"
|
item.Status = "failed"
|
||||||
@@ -306,13 +388,21 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
|||||||
result.Reports = append(result.Reports, item)
|
result.Reports = append(result.Reports, item)
|
||||||
}
|
}
|
||||||
result.Total = len(result.Reports)
|
result.Total = len(result.Reports)
|
||||||
|
batchNotification, err := notifyBatch(ctx, req.Config, req.Batch, batchRunID(startedAt, req.Batch), startedAt, result, plannedReports, store, req.Notifier)
|
||||||
|
if batchNotification != nil {
|
||||||
|
result.Notification = batchNotification
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
result.Failed++
|
||||||
|
}
|
||||||
result.FinishedAt = time.Now()
|
result.FinishedAt = time.Now()
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("run is not implemented")
|
return nil, fmt.Errorf("run is not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
func batchReportResult(resolved report.Resolved) BatchReportResult {
|
func batchReportResult(planned plannedBatchReport) BatchReportResult {
|
||||||
|
resolved := planned.Resolved
|
||||||
metadata := resolved.Metadata()
|
metadata := resolved.Metadata()
|
||||||
return BatchReportResult{
|
return BatchReportResult{
|
||||||
ReportID: resolved.Definition.ID,
|
ReportID: resolved.Definition.ID,
|
||||||
@@ -324,11 +414,18 @@ func batchReportResult(resolved report.Resolved) BatchReportResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func batchOutputPath(outputDir string, definition report.Definition) string {
|
func plannedBatchOutputPath(outputDir string, planned plannedBatchReport) string {
|
||||||
if outputDir == "" || definition.BatchOutputName == "" {
|
if outputDir == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return filepath.Join(outputDir, definition.BatchOutputName)
|
outputCopyName := planned.OutputCopyName
|
||||||
|
if outputCopyName == "" {
|
||||||
|
outputCopyName = planned.Resolved.Definition.BatchOutputName
|
||||||
|
}
|
||||||
|
if outputCopyName == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Join(outputDir, outputCopyName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) {
|
func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) {
|
||||||
@@ -336,7 +433,7 @@ func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return report.Resolved{}, err
|
return report.Resolved{}, err
|
||||||
}
|
}
|
||||||
id, err := reportIDForCommand(req.Report)
|
id, err := report.IDForCommandName(string(req.Report))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return report.Resolved{}, err
|
return report.Resolved{}, err
|
||||||
}
|
}
|
||||||
@@ -353,71 +450,41 @@ func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func ResolveBatch(req BatchRequest, now time.Time) ([]report.Resolved, error) {
|
|
||||||
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
batch, err := reportBatchForCommand(req.Batch)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
registry, err := reportRegistry(req.Config)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return registry.BatchReports(batch, report.ResolveRequest{
|
|
||||||
Now: now,
|
|
||||||
Location: location,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func reportRegistry(cfg config.Config) (report.Registry, error) {
|
func reportRegistry(cfg config.Config) (report.Registry, error) {
|
||||||
registry, err := report.DefaultRegistry().WithModuleOverrides(cfg.ReportModuleOverrides())
|
overrides, err := cfg.ReportModuleOverrides()
|
||||||
|
if err != nil {
|
||||||
|
return report.Registry{}, err
|
||||||
|
}
|
||||||
|
registry, err := report.DefaultRegistry().WithModuleOverrides(overrides)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return report.Registry{}, err
|
return report.Registry{}, err
|
||||||
}
|
}
|
||||||
return registry, nil
|
return registry, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func reportIDForCommand(kind ReportKind) (report.ID, error) {
|
|
||||||
switch kind {
|
|
||||||
case ReportDaily:
|
|
||||||
return report.DailyToday, nil
|
|
||||||
case ReportTomorrow:
|
|
||||||
return report.DailyTomorrow, nil
|
|
||||||
case ReportThreeDay:
|
|
||||||
return report.ThreeDay, nil
|
|
||||||
case ReportWeekend:
|
|
||||||
return report.Weekend, nil
|
|
||||||
case ReportStorm:
|
|
||||||
return report.Storm, nil
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("unknown report command %q", kind)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func reportBatchForCommand(kind BatchKind) (report.Batch, error) {
|
|
||||||
switch kind {
|
|
||||||
case BatchMorning:
|
|
||||||
return report.Morning, nil
|
|
||||||
case BatchEvening:
|
|
||||||
return report.Evening, nil
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("unknown batch command %q", kind)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func FetchBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) {
|
func FetchBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) {
|
||||||
client, err := weatherapi.New(req.Config)
|
result, err := collectWeather(ctx, req.Config, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
bundle, err := client.FetchBundle(ctx)
|
return result.Bundle, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectWeather(ctx context.Context, cfg config.Config, collector Collector) (*collect.Result, error) {
|
||||||
|
if collector == nil {
|
||||||
|
collector = defaultCollector{}
|
||||||
|
}
|
||||||
|
result, err := collector.Run(ctx, collect.Request{Config: cfg})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return bundle, nil
|
if result == nil {
|
||||||
|
return nil, fmt.Errorf("collect weather bundle: collector returned nil result")
|
||||||
|
}
|
||||||
|
if result.Bundle == nil {
|
||||||
|
return nil, fmt.Errorf("collect weather bundle: collector returned nil bundle")
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) {
|
func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) {
|
||||||
@@ -428,13 +495,18 @@ func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherda
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := weatherapi.SaveBundle(req.OutputPath, bundle); err != nil {
|
if err := fileutil.WriteJSONAtomic(req.OutputPath, bundle); err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("save bundle: %w", err)
|
||||||
}
|
}
|
||||||
return bundle, nil
|
return bundle, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) {
|
func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) {
|
||||||
|
bundle := req.Collection.Bundle
|
||||||
|
if bundle == nil {
|
||||||
|
return nil, fmt.Errorf("collected weather bundle is required")
|
||||||
|
}
|
||||||
|
|
||||||
store := req.Store
|
store := req.Store
|
||||||
if store == nil {
|
if store == nil {
|
||||||
defaultStore, err := defaultStore(req.Config)
|
defaultStore, err := defaultStore(req.Config)
|
||||||
@@ -452,10 +524,6 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
reportFacts, err := BuildReportFacts(ModuleSnapshotRequest{
|
reportFacts, err := BuildReportFacts(ModuleSnapshotRequest{
|
||||||
Config: req.Config,
|
Config: req.Config,
|
||||||
Resolved: req.Resolved,
|
Resolved: req.Resolved,
|
||||||
@@ -482,11 +550,15 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
|||||||
}
|
}
|
||||||
briefingMetadata := briefing.BuildMetadata(briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected))
|
briefingMetadata := briefing.BuildMetadata(briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected))
|
||||||
metadata := state.BuildMetadataFromBriefingMetadata(req.Resolved, briefingMetadata, state.ArtifactPaths{
|
metadata := state.BuildMetadataFromBriefingMetadata(req.Resolved, briefingMetadata, state.ArtifactPaths{
|
||||||
ModuleSnapshot: moduleSnapshotPath,
|
ModuleSnapshot: moduleSnapshotPath,
|
||||||
Metadata: paths.Metadata,
|
Metadata: paths.Metadata,
|
||||||
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 +603,36 @@ 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,
|
||||||
|
reportFacts: reportFacts,
|
||||||
|
dataPackage: dataPackage,
|
||||||
|
dataPackagePath: dataPackagePath,
|
||||||
|
briefingMetadata: briefingMetadata,
|
||||||
|
metadata: metadata,
|
||||||
|
metadataPath: metadataPath,
|
||||||
|
preflightPath: preflightPath,
|
||||||
|
priorSnapshot: priorSnapshot,
|
||||||
|
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
|
||||||
@@ -543,32 +642,17 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
|||||||
DataPackagePath: dataPackagePath,
|
DataPackagePath: dataPackagePath,
|
||||||
OutputPath: reportPath,
|
OutputPath: reportPath,
|
||||||
})
|
})
|
||||||
if runErr == nil && req.OutputPath != "" && req.OutputPath != reportPath {
|
finalized, err := finalizeRenderedReport(ctx, finalizeRenderedReportRequest{
|
||||||
if err := fileutil.CopyFileAtomic(reportPath, req.OutputPath); err != nil {
|
Config: req.Config,
|
||||||
return nil, err
|
Store: store,
|
||||||
}
|
Resolved: req.Resolved,
|
||||||
}
|
Metadata: metadata,
|
||||||
outputPath := reportPath
|
ManagedReportPath: reportPath,
|
||||||
if req.OutputPath != "" {
|
OutputPath: req.OutputPath,
|
||||||
outputPath = req.OutputPath
|
Notifier: req.Notifier,
|
||||||
}
|
GenerationErr: runErr,
|
||||||
metadata.RenderedReportPath = reportPath
|
noNotify: req.noNotify,
|
||||||
metadataPath, metadataErr = store.SaveMetadata(ctx, metadata)
|
})
|
||||||
if metadataErr != nil {
|
|
||||||
return nil, metadataErr
|
|
||||||
}
|
|
||||||
if runErr != nil {
|
|
||||||
return nil, runErr
|
|
||||||
}
|
|
||||||
|
|
||||||
notification, notificationPath, err := notifyReport(ctx, req.Config, req.Resolved, reportPath, metadata, req.Notifier, store)
|
|
||||||
if notificationPath != "" {
|
|
||||||
metadata.NotificationPath = notificationPath
|
|
||||||
metadataPath, metadataErr = store.SaveMetadata(ctx, metadata)
|
|
||||||
if metadataErr != nil {
|
|
||||||
return nil, metadataErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -580,18 +664,219 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
|||||||
DataPackagePath: dataPackagePath,
|
DataPackagePath: dataPackagePath,
|
||||||
PreflightPath: preflightPath,
|
PreflightPath: preflightPath,
|
||||||
ReportPath: reportPath,
|
ReportPath: reportPath,
|
||||||
OutputPath: outputPath,
|
OutputPath: finalized.OutputPath,
|
||||||
NotificationPath: notificationPath,
|
NotificationPath: finalized.NotificationPath,
|
||||||
Metadata: metadata,
|
Metadata: finalized.Metadata,
|
||||||
MetadataPath: metadataPath,
|
MetadataPath: finalized.MetadataPath,
|
||||||
PriorSnapshot: priorSnapshot,
|
PriorSnapshot: priorSnapshot,
|
||||||
RecentChanges: recentChanges,
|
RecentChanges: recentChanges,
|
||||||
RenderResult: renderResult,
|
RenderResult: renderResult,
|
||||||
RunResult: runResult,
|
RunResult: runResult,
|
||||||
Notification: notification,
|
Notification: finalized.Notification,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type generatedReportRequest struct {
|
||||||
|
ReportRequest
|
||||||
|
store state.Store
|
||||||
|
paths state.ArtifactPaths
|
||||||
|
moduleSnapshot module.Snapshot
|
||||||
|
moduleSnapshotPath string
|
||||||
|
reportFacts ReportFacts
|
||||||
|
dataPackage promptinput.Package
|
||||||
|
dataPackagePath string
|
||||||
|
briefingMetadata briefing.Metadata
|
||||||
|
metadata state.Metadata
|
||||||
|
metadataPath string
|
||||||
|
preflightPath string
|
||||||
|
priorSnapshot *state.PriorSnapshot
|
||||||
|
recentChanges []changes.Change
|
||||||
|
renderResult *scriptorium.RenderResult
|
||||||
|
renderer Renderer
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateTextTemplateReport(ctx context.Context, req generatedReportRequest) (*ReportResult, error) {
|
||||||
|
handler, err := generatedtext.LookupDefinition(req.Resolved.Definition)
|
||||||
|
if err != nil {
|
||||||
|
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "lookup generated text catalog", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
generatedText, normalizedGeneratedText, err := handler.Validate(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 := handler.BuildRenderContext(req.briefingMetadata, req.moduleSnapshot, req.reportFacts.Collected, req.reportFacts.Derived, generatedText)
|
||||||
|
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 := handler.Render(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
|
||||||
|
}
|
||||||
|
finalized, err := finalizeRenderedReport(ctx, finalizeRenderedReportRequest{
|
||||||
|
Config: req.Config,
|
||||||
|
Store: req.store,
|
||||||
|
Resolved: req.Resolved,
|
||||||
|
Metadata: req.metadata,
|
||||||
|
ManagedReportPath: reportPath,
|
||||||
|
OutputPath: req.OutputPath,
|
||||||
|
Notifier: req.Notifier,
|
||||||
|
noNotify: req.noNotify,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ReportResult{
|
||||||
|
ModuleSnapshot: req.moduleSnapshot,
|
||||||
|
ModuleSnapshotPath: req.moduleSnapshotPath,
|
||||||
|
DataPackage: req.dataPackage,
|
||||||
|
DataPackagePath: req.dataPackagePath,
|
||||||
|
PreflightPath: req.preflightPath,
|
||||||
|
ReportPath: reportPath,
|
||||||
|
OutputPath: finalized.OutputPath,
|
||||||
|
NotificationPath: finalized.NotificationPath,
|
||||||
|
Metadata: finalized.Metadata,
|
||||||
|
MetadataPath: finalized.MetadataPath,
|
||||||
|
PriorSnapshot: req.priorSnapshot,
|
||||||
|
RecentChanges: req.recentChanges,
|
||||||
|
RenderResult: req.renderResult,
|
||||||
|
StructuredRunResult: structuredResult,
|
||||||
|
GeneratedTextRawPath: req.paths.GeneratedTextRaw,
|
||||||
|
GeneratedTextResultPath: generatedTextResultPath,
|
||||||
|
GeneratedTextPath: generatedTextPath,
|
||||||
|
RenderContextPath: renderContextPath,
|
||||||
|
Notification: finalized.Notification,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type finalizeRenderedReportRequest struct {
|
||||||
|
Config config.Config
|
||||||
|
Store state.Store
|
||||||
|
Resolved report.Resolved
|
||||||
|
Metadata state.Metadata
|
||||||
|
ManagedReportPath string
|
||||||
|
OutputPath string
|
||||||
|
Notifier Notifier
|
||||||
|
GenerationErr error
|
||||||
|
noNotify bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type finalizeRenderedReportResult struct {
|
||||||
|
OutputPath string
|
||||||
|
NotificationPath string
|
||||||
|
Metadata state.Metadata
|
||||||
|
MetadataPath string
|
||||||
|
Notification *NotificationResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizeRenderedReport(ctx context.Context, req finalizeRenderedReportRequest) (finalizeRenderedReportResult, error) {
|
||||||
|
if req.Store == nil {
|
||||||
|
return finalizeRenderedReportResult{}, fmt.Errorf("state store is required")
|
||||||
|
}
|
||||||
|
if req.ManagedReportPath == "" {
|
||||||
|
return finalizeRenderedReportResult{}, fmt.Errorf("managed report path is required for report %q", req.Resolved.Definition.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := req.Metadata
|
||||||
|
metadata.RenderedReportPath = req.ManagedReportPath
|
||||||
|
outputPath := req.ManagedReportPath
|
||||||
|
if req.OutputPath != "" {
|
||||||
|
outputPath = req.OutputPath
|
||||||
|
if req.GenerationErr == nil && req.OutputPath != req.ManagedReportPath {
|
||||||
|
if err := fileutil.CopyFileAtomic(req.ManagedReportPath, req.OutputPath); err != nil {
|
||||||
|
return finalizeRenderedReportResult{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataPath, err := req.Store.SaveMetadata(ctx, metadata)
|
||||||
|
if err != nil {
|
||||||
|
return finalizeRenderedReportResult{}, err
|
||||||
|
}
|
||||||
|
result := finalizeRenderedReportResult{
|
||||||
|
OutputPath: outputPath,
|
||||||
|
Metadata: metadata,
|
||||||
|
MetadataPath: metadataPath,
|
||||||
|
}
|
||||||
|
if req.GenerationErr != nil {
|
||||||
|
return result, req.GenerationErr
|
||||||
|
}
|
||||||
|
if req.noNotify {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
notification, notificationPath, err := notifyReport(ctx, req.Config, req.Resolved, req.ManagedReportPath, metadata, req.Notifier, req.Store)
|
||||||
|
if notificationPath != "" {
|
||||||
|
metadata.NotificationPath = notificationPath
|
||||||
|
metadataPath, saveErr := req.Store.SaveMetadata(ctx, metadata)
|
||||||
|
if saveErr != nil {
|
||||||
|
return finalizeRenderedReportResult{}, saveErr
|
||||||
|
}
|
||||||
|
result.Metadata = metadata
|
||||||
|
result.MetadataPath = metadataPath
|
||||||
|
result.NotificationPath = notificationPath
|
||||||
|
}
|
||||||
|
result.Notification = notification
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
return result, 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 {
|
||||||
@@ -632,14 +917,8 @@ func reportNotifier(cfg config.Config, notifier Notifier) (Notifier, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildNotificationRequest(cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata) (NotificationRequest, error) {
|
func buildNotificationRequest(cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata) (NotificationRequest, error) {
|
||||||
values := config.DistributorTemplateValues{
|
values, err := distributorTemplateValuesForReport(cfg, resolved, metadata.RunID, resolved.Definition.BatchOutputName)
|
||||||
LocationID: cfg.Location.ID,
|
if err != nil {
|
||||||
ReportID: string(resolved.Definition.ID),
|
|
||||||
RunID: metadata.RunID,
|
|
||||||
ArtifactGroup: resolved.Definition.ArtifactGroup,
|
|
||||||
BatchOutputName: resolved.Definition.BatchOutputName,
|
|
||||||
}
|
|
||||||
if err := addDistributorValidPeriodValues(&values, resolved.ValidPeriod, cfg.WeatherAPI.Timezone); err != nil {
|
|
||||||
return NotificationRequest{}, err
|
return NotificationRequest{}, err
|
||||||
}
|
}
|
||||||
bundleID, err := config.RenderDistributorBundleID(cfg.Notify.Distributor.BundleIDTemplate, values)
|
bundleID, err := config.RenderDistributorBundleID(cfg.Notify.Distributor.BundleIDTemplate, values)
|
||||||
@@ -655,7 +934,7 @@ func buildNotificationRequest(cfg config.Config, resolved report.Resolved, repor
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return NotificationRequest{}, err
|
return NotificationRequest{}, err
|
||||||
}
|
}
|
||||||
bundlePaths, err := config.RenderDistributorReportPaths(cfg.Notify.Distributor.ReportPathTemplates, values)
|
bundlePaths, err := renderDistributorReportBundlePaths(cfg, resolved, metadata.RunID, reportPath, values)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return NotificationRequest{}, err
|
return NotificationRequest{}, err
|
||||||
}
|
}
|
||||||
@@ -671,6 +950,59 @@ func buildNotificationRequest(cfg config.Config, resolved report.Resolved, repor
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func distributorTemplateValuesForReport(cfg config.Config, resolved report.Resolved, runID string, batchOutputName string) (config.DistributorTemplateValues, error) {
|
||||||
|
values := config.DistributorTemplateValues{
|
||||||
|
LocationID: cfg.Location.ID,
|
||||||
|
ReportID: string(resolved.Definition.ID),
|
||||||
|
RunID: runID,
|
||||||
|
ArtifactGroup: resolved.Definition.ArtifactGroup,
|
||||||
|
BatchOutputName: batchOutputName,
|
||||||
|
}
|
||||||
|
if values.BatchOutputName == "" {
|
||||||
|
values.BatchOutputName = resolved.Definition.BatchOutputName
|
||||||
|
}
|
||||||
|
if err := addDistributorValidPeriodValues(&values, resolved.ValidPeriod, cfg.WeatherAPI.Timezone); err != nil {
|
||||||
|
return config.DistributorTemplateValues{}, err
|
||||||
|
}
|
||||||
|
if resolved.Definition.ID == report.Storm {
|
||||||
|
values.StormID = values.ValidStartStamp + "-" + values.ValidEndStamp
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderDistributorReportBundlePaths(cfg config.Config, resolved report.Resolved, runID string, sourcePath string, values config.DistributorTemplateValues) ([]string, error) {
|
||||||
|
templates, name, err := distributorPathTemplatesForReport(cfg, resolved.Definition)
|
||||||
|
if err != nil {
|
||||||
|
return nil, distributorReportPathError(resolved.Definition.ID, runID, sourcePath, err)
|
||||||
|
}
|
||||||
|
paths, err := config.RenderDistributorReportPaths(name, templates, values)
|
||||||
|
if err != nil {
|
||||||
|
return nil, distributorReportPathError(resolved.Definition.ID, runID, sourcePath, err)
|
||||||
|
}
|
||||||
|
return paths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func distributorPathTemplatesForReport(cfg config.Config, definition report.Definition) ([]string, string, error) {
|
||||||
|
overrides, err := cfg.ReportDistributorPathOverrides()
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if templates, ok := overrides[definition.ID]; ok {
|
||||||
|
return append([]string(nil), templates...), fmt.Sprintf("reports.%s.distributor.path_templates", definition.ID), nil
|
||||||
|
}
|
||||||
|
if len(definition.DistributorPathTemplates) > 0 {
|
||||||
|
return append([]string(nil), definition.DistributorPathTemplates...), fmt.Sprintf("report.%s.distributor_path_templates", definition.ID), nil
|
||||||
|
}
|
||||||
|
return nil, "", fmt.Errorf("no distributor path templates configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
func distributorReportPathError(id report.ID, runID string, sourcePath string, err error) error {
|
||||||
|
if sourcePath != "" {
|
||||||
|
return fmt.Errorf("report %q run %q source path %q: %w", id, runID, sourcePath, err)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("report %q run %q: %w", id, runID, err)
|
||||||
|
}
|
||||||
|
|
||||||
func addDistributorValidPeriodValues(values *config.DistributorTemplateValues, period timeutil.Period, timezone string) error {
|
func addDistributorValidPeriodValues(values *config.DistributorTemplateValues, period timeutil.Period, timezone string) error {
|
||||||
location, err := timeutil.LoadLocation(timezone)
|
location, err := timeutil.LoadLocation(timezone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -778,6 +1110,38 @@ func (n distributorNotifier) Notify(ctx context.Context, req NotificationRequest
|
|||||||
return notification, nil
|
return notification, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (n distributorNotifier) NotifyBatch(ctx context.Context, req batchNotificationRequest) (*NotificationResult, error) {
|
||||||
|
result, err := n.client.Upload(ctx, batchDistributorUploadRequest(req))
|
||||||
|
notification := notificationResultFromUpload(req.PipelineID, req.BundleID, req.IdempotencyKey, result)
|
||||||
|
if err != nil {
|
||||||
|
return notification, err
|
||||||
|
}
|
||||||
|
return notification, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func notificationResultFromUpload(pipelineID string, bundleID string, idempotencyKey string, result distributoradapter.UploadResult) *NotificationResult {
|
||||||
|
notification := &NotificationResult{
|
||||||
|
PipelineID: pipelineID,
|
||||||
|
BundleID: bundleID,
|
||||||
|
IdempotencyKey: idempotencyKey,
|
||||||
|
RunID: result.RunID,
|
||||||
|
Status: result.Status,
|
||||||
|
UploadStatus: result.UploadStatus,
|
||||||
|
StatusError: result.StatusError,
|
||||||
|
}
|
||||||
|
if result.RunStatus != nil {
|
||||||
|
if result.RunStatus.PipelineID != "" {
|
||||||
|
notification.PipelineID = result.RunStatus.PipelineID
|
||||||
|
}
|
||||||
|
notification.AcceptedAt = result.RunStatus.AcceptedAt
|
||||||
|
notification.StartedAt = result.RunStatus.StartedAt
|
||||||
|
notification.FinishedAt = result.RunStatus.FinishedAt
|
||||||
|
notification.Report = append([]byte(nil), result.RunStatus.Report...)
|
||||||
|
notification.Error = result.RunStatus.Error
|
||||||
|
}
|
||||||
|
return notification
|
||||||
|
}
|
||||||
|
|
||||||
func distributorUploadFiles(sourcePath string, bundlePaths []string) []distributoradapter.UploadFile {
|
func distributorUploadFiles(sourcePath string, bundlePaths []string) []distributoradapter.UploadFile {
|
||||||
files := make([]distributoradapter.UploadFile, 0, len(bundlePaths))
|
files := make([]distributoradapter.UploadFile, 0, len(bundlePaths))
|
||||||
for _, bundlePath := range bundlePaths {
|
for _, bundlePath := range bundlePaths {
|
||||||
@@ -911,7 +1275,7 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.
|
|||||||
PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes,
|
PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes,
|
||||||
}
|
}
|
||||||
switch reportID {
|
switch reportID {
|
||||||
case report.DailyToday, report.DailyTomorrow:
|
case report.Daily, report.Today, report.Tomorrow:
|
||||||
return changes.CompareDaily(previous, current, thresholds)
|
return changes.CompareDaily(previous, current, thresholds)
|
||||||
case report.ThreeDay:
|
case report.ThreeDay:
|
||||||
return changes.CompareThreeDay(previous, current, thresholds)
|
return changes.CompareThreeDay(previous, current, thresholds)
|
||||||
@@ -935,3 +1299,10 @@ func preflightArtifact(result *scriptorium.RenderResult) state.PreflightArtifact
|
|||||||
ExitCode: result.ExitCode,
|
ExitCode: result.ExitCode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
379
internal/app/batch_notification.go
Normal file
379
internal/app/batch_notification.go
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
const runIDTimestampLayout = "20060102T150405.000000000Z"
|
||||||
|
|
||||||
|
type batchNotificationIdentity struct {
|
||||||
|
PipelineID string
|
||||||
|
BundleID string
|
||||||
|
IdempotencyKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
type batchNotificationRequest struct {
|
||||||
|
Batch BatchKind
|
||||||
|
RunID string
|
||||||
|
PipelineID string
|
||||||
|
BundleID string
|
||||||
|
IdempotencyKey string
|
||||||
|
Files []batchNotificationFile
|
||||||
|
IncludedReports []BatchNotificationReport
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type batchNotificationFile struct {
|
||||||
|
ReportID report.ID
|
||||||
|
RunID string
|
||||||
|
SourcePath string
|
||||||
|
BundlePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type batchNotifier interface {
|
||||||
|
NotifyBatch(context.Context, batchNotificationRequest) (*NotificationResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchRunID(startedAt time.Time, batch BatchKind) string {
|
||||||
|
return startedAt.UTC().Format(runIDTimestampLayout) + "_" + string(batch)
|
||||||
|
}
|
||||||
|
|
||||||
|
func notifyBatch(ctx context.Context, cfg config.Config, batch BatchKind, runID string, startedAt time.Time, result *BatchResult, planned []plannedBatchReport, store state.Store, notifier Notifier) (*BatchNotificationResult, error) {
|
||||||
|
if !cfg.Notify.Distributor.Enabled {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if !cfg.Notify.Distributor.Batch.Enabled {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
return nil, fmt.Errorf("batch result is required")
|
||||||
|
}
|
||||||
|
if result.Failed > 0 {
|
||||||
|
return &BatchNotificationResult{
|
||||||
|
Status: "skipped",
|
||||||
|
Reason: "one or more reports failed",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := buildBatchNotificationRequest(cfg, batch, runID, startedAt, result.Reports, planned)
|
||||||
|
if err != nil {
|
||||||
|
path, saveErr := saveBatchNotificationArtifact(ctx, store, cfg, batch, runID, startedAt, batchNotificationRequest{}, nil, err)
|
||||||
|
if saveErr != nil {
|
||||||
|
return nil, saveErr
|
||||||
|
}
|
||||||
|
return failedBatchNotificationResult(batchNotificationRequest{}, path, err), err
|
||||||
|
}
|
||||||
|
|
||||||
|
batchNotifier, err := resolveBatchNotifier(cfg, notifier)
|
||||||
|
if err != nil {
|
||||||
|
path, saveErr := saveBatchNotificationArtifact(ctx, store, cfg, batch, runID, startedAt, req, nil, err)
|
||||||
|
if saveErr != nil {
|
||||||
|
return nil, saveErr
|
||||||
|
}
|
||||||
|
return failedBatchNotificationResult(req, path, err), err
|
||||||
|
}
|
||||||
|
|
||||||
|
notification, notifyErr := batchNotifier.NotifyBatch(ctx, req)
|
||||||
|
wrappedErr := notifyErr
|
||||||
|
if notifyErr != nil {
|
||||||
|
wrappedErr = fmt.Errorf("notify batch %q run %q bundle %q: %w", batch, runID, req.BundleID, notifyErr)
|
||||||
|
}
|
||||||
|
path, saveErr := saveBatchNotificationArtifact(ctx, store, cfg, batch, runID, startedAt, req, notification, wrappedErr)
|
||||||
|
if saveErr != nil {
|
||||||
|
return nil, saveErr
|
||||||
|
}
|
||||||
|
|
||||||
|
batchResult := batchNotificationResult(req, notification, path)
|
||||||
|
if wrappedErr != nil {
|
||||||
|
batchResult.Status = "failed"
|
||||||
|
batchResult.Error = wrappedErr.Error()
|
||||||
|
return batchResult, wrappedErr
|
||||||
|
}
|
||||||
|
return batchResult, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveBatchNotifier(cfg config.Config, notifier Notifier) (batchNotifier, error) {
|
||||||
|
if notifier != nil {
|
||||||
|
if batchNotifier, ok := notifier.(batchNotifier); ok {
|
||||||
|
return batchNotifier, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("batch distributor notifier is required")
|
||||||
|
}
|
||||||
|
return distributorNotifier{
|
||||||
|
client: distributoradapter.New(cfg.Notify.Distributor),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildBatchNotificationRequest(cfg config.Config, batch BatchKind, runID string, startedAt time.Time, reports []BatchReportResult, planned []plannedBatchReport) (batchNotificationRequest, error) {
|
||||||
|
if len(reports) == 0 {
|
||||||
|
return batchNotificationRequest{}, fmt.Errorf("batch notification requires at least one report")
|
||||||
|
}
|
||||||
|
|
||||||
|
identity, err := renderBatchNotificationIdentity(cfg, batch, runID, startedAt)
|
||||||
|
if err != nil {
|
||||||
|
return batchNotificationRequest{}, err
|
||||||
|
}
|
||||||
|
if identity.PipelineID == "" {
|
||||||
|
return batchNotificationRequest{}, fmt.Errorf("batch notification pipeline id is required")
|
||||||
|
}
|
||||||
|
if identity.BundleID == "" {
|
||||||
|
return batchNotificationRequest{}, fmt.Errorf("batch notification bundle id is required")
|
||||||
|
}
|
||||||
|
if identity.IdempotencyKey == "" {
|
||||||
|
return batchNotificationRequest{}, fmt.Errorf("batch notification idempotency key is required for bundle %q", identity.BundleID)
|
||||||
|
}
|
||||||
|
|
||||||
|
plannedByRunID, err := plannedReportsByRunID(planned)
|
||||||
|
if err != nil {
|
||||||
|
return batchNotificationRequest{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req := batchNotificationRequest{
|
||||||
|
Batch: batch,
|
||||||
|
RunID: runID,
|
||||||
|
PipelineID: identity.PipelineID,
|
||||||
|
BundleID: identity.BundleID,
|
||||||
|
IdempotencyKey: identity.IdempotencyKey,
|
||||||
|
CreatedAt: startedAt,
|
||||||
|
}
|
||||||
|
seenBundlePaths := map[string]batchNotificationFile{}
|
||||||
|
for _, item := range reports {
|
||||||
|
plannedReport, ok := plannedByRunID[item.RunID]
|
||||||
|
if !ok {
|
||||||
|
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q has no matching planned report", item.ReportID, item.RunID)
|
||||||
|
}
|
||||||
|
if item.ReportID != plannedReport.Resolved.Definition.ID {
|
||||||
|
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q does not match planned report %q", item.ReportID, item.RunID, plannedReport.Resolved.Definition.ID)
|
||||||
|
}
|
||||||
|
if item.ReportPath == "" {
|
||||||
|
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q is missing managed report path", item.ReportID, item.RunID)
|
||||||
|
}
|
||||||
|
|
||||||
|
values, err := distributorTemplateValuesForReport(cfg, plannedReport.Resolved, item.RunID, plannedReport.OutputCopyName)
|
||||||
|
if err != nil {
|
||||||
|
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q source path %q: %w", item.ReportID, item.RunID, item.ReportPath, err)
|
||||||
|
}
|
||||||
|
bundlePaths, err := renderDistributorReportBundlePaths(cfg, plannedReport.Resolved, item.RunID, item.ReportPath, values)
|
||||||
|
if err != nil {
|
||||||
|
return batchNotificationRequest{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
included := BatchNotificationReport{
|
||||||
|
ReportID: item.ReportID,
|
||||||
|
RunID: item.RunID,
|
||||||
|
SourcePath: item.ReportPath,
|
||||||
|
BundlePaths: append([]string(nil), bundlePaths...),
|
||||||
|
}
|
||||||
|
for _, bundlePath := range bundlePaths {
|
||||||
|
file := batchNotificationFile{
|
||||||
|
ReportID: item.ReportID,
|
||||||
|
RunID: item.RunID,
|
||||||
|
SourcePath: item.ReportPath,
|
||||||
|
BundlePath: bundlePath,
|
||||||
|
}
|
||||||
|
if previous, ok := seenBundlePaths[bundlePath]; ok {
|
||||||
|
return batchNotificationRequest{}, fmt.Errorf("batch notification duplicate bundle path %q for report %q run %q source path %q; already used by report %q run %q source path %q", bundlePath, item.ReportID, item.RunID, item.ReportPath, previous.ReportID, previous.RunID, previous.SourcePath)
|
||||||
|
}
|
||||||
|
seenBundlePaths[bundlePath] = file
|
||||||
|
req.Files = append(req.Files, file)
|
||||||
|
}
|
||||||
|
req.IncludedReports = append(req.IncludedReports, included)
|
||||||
|
}
|
||||||
|
if len(req.Files) == 0 {
|
||||||
|
return batchNotificationRequest{}, fmt.Errorf("batch notification requires at least one file mapping")
|
||||||
|
}
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func plannedReportsByRunID(planned []plannedBatchReport) (map[string]plannedBatchReport, error) {
|
||||||
|
byRunID := make(map[string]plannedBatchReport, len(planned))
|
||||||
|
for _, item := range planned {
|
||||||
|
runID := item.Resolved.Metadata().RunID
|
||||||
|
if runID == "" {
|
||||||
|
return nil, fmt.Errorf("planned report %q has empty run id", item.Resolved.Definition.ID)
|
||||||
|
}
|
||||||
|
if previous, ok := byRunID[runID]; ok {
|
||||||
|
return nil, fmt.Errorf("planned reports %q and %q share run id %q", previous.Resolved.Definition.ID, item.Resolved.Definition.ID, runID)
|
||||||
|
}
|
||||||
|
byRunID[runID] = item
|
||||||
|
}
|
||||||
|
return byRunID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchDistributorUploadRequest(req batchNotificationRequest) distributoradapter.UploadRequest {
|
||||||
|
files := make([]distributoradapter.UploadFile, 0, len(req.Files))
|
||||||
|
for _, file := range req.Files {
|
||||||
|
files = append(files, distributoradapter.UploadFile{
|
||||||
|
SourcePath: file.SourcePath,
|
||||||
|
BundlePath: file.BundlePath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return distributoradapter.UploadRequest{
|
||||||
|
PipelineID: req.PipelineID,
|
||||||
|
BundleID: req.BundleID,
|
||||||
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
|
Files: files,
|
||||||
|
CreatedAt: req.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchNotificationResult(req batchNotificationRequest, result *NotificationResult, path string) *BatchNotificationResult {
|
||||||
|
notification := &BatchNotificationResult{
|
||||||
|
Status: "unknown",
|
||||||
|
PipelineID: req.PipelineID,
|
||||||
|
BundleID: req.BundleID,
|
||||||
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
|
Path: path,
|
||||||
|
IncludedReports: append([]BatchNotificationReport(nil), req.IncludedReports...),
|
||||||
|
}
|
||||||
|
if result != nil {
|
||||||
|
notification.Status = result.Status
|
||||||
|
notification.RunID = result.RunID
|
||||||
|
if result.PipelineID != "" {
|
||||||
|
notification.PipelineID = result.PipelineID
|
||||||
|
}
|
||||||
|
if result.BundleID != "" {
|
||||||
|
notification.BundleID = result.BundleID
|
||||||
|
}
|
||||||
|
if result.IdempotencyKey != "" {
|
||||||
|
notification.IdempotencyKey = result.IdempotencyKey
|
||||||
|
}
|
||||||
|
if result.Error != "" {
|
||||||
|
notification.Error = result.Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if notification.Status == "" {
|
||||||
|
notification.Status = "unknown"
|
||||||
|
}
|
||||||
|
return notification
|
||||||
|
}
|
||||||
|
|
||||||
|
func failedBatchNotificationResult(req batchNotificationRequest, path string, err error) *BatchNotificationResult {
|
||||||
|
notification := batchNotificationResult(req, nil, path)
|
||||||
|
notification.Status = "failed"
|
||||||
|
if err != nil {
|
||||||
|
notification.Error = err.Error()
|
||||||
|
}
|
||||||
|
return notification
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveBatchNotificationArtifact(ctx context.Context, store state.Store, cfg config.Config, batch BatchKind, runID string, startedAt time.Time, req batchNotificationRequest, result *NotificationResult, notifyErr error) (string, error) {
|
||||||
|
if store == nil {
|
||||||
|
return "", fmt.Errorf("state store is required")
|
||||||
|
}
|
||||||
|
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("load batch notification timezone: %w", err)
|
||||||
|
}
|
||||||
|
artifact := state.BatchDistributorNotificationArtifact{
|
||||||
|
SchemaVersion: state.BatchDistributorNotificationSchemaVersion,
|
||||||
|
Batch: string(batch),
|
||||||
|
BatchRunID: runID,
|
||||||
|
AttemptedAt: time.Now(),
|
||||||
|
Endpoint: cfg.Notify.Distributor.Endpoint,
|
||||||
|
PipelineID: req.PipelineID,
|
||||||
|
BundleID: req.BundleID,
|
||||||
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
|
BundleCreated: req.CreatedAt,
|
||||||
|
Reports: batchNotificationReportArtifacts(req.IncludedReports),
|
||||||
|
Status: "attempted",
|
||||||
|
}
|
||||||
|
if result != nil {
|
||||||
|
artifact.Status = result.Status
|
||||||
|
artifact.Upload = &state.DistributorUploadResult{
|
||||||
|
RunID: result.RunID,
|
||||||
|
Status: result.UploadStatus,
|
||||||
|
}
|
||||||
|
if result.PipelineID != "" || !result.AcceptedAt.IsZero() || result.StartedAt != nil || result.FinishedAt != nil || len(result.Report) > 0 || result.Error != "" {
|
||||||
|
artifact.RunStatus = &state.DistributorRunStatus{
|
||||||
|
RunID: result.RunID,
|
||||||
|
PipelineID: result.PipelineID,
|
||||||
|
Status: result.Status,
|
||||||
|
AcceptedAt: result.AcceptedAt,
|
||||||
|
StartedAt: result.StartedAt,
|
||||||
|
FinishedAt: result.FinishedAt,
|
||||||
|
Report: append([]byte(nil), result.Report...),
|
||||||
|
Error: result.Error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
artifact.StatusError = result.StatusError
|
||||||
|
}
|
||||||
|
if notifyErr != nil {
|
||||||
|
artifact.Status = "failed"
|
||||||
|
artifact.Error = notifyErr.Error()
|
||||||
|
}
|
||||||
|
if artifact.Status == "" {
|
||||||
|
artifact.Status = "unknown"
|
||||||
|
}
|
||||||
|
return store.SaveBatchDistributorNotification(ctx, state.BatchDistributorNotificationRef{
|
||||||
|
Batch: string(batch),
|
||||||
|
BatchRunID: runID,
|
||||||
|
StartedAt: startedAt,
|
||||||
|
Location: location,
|
||||||
|
}, artifact)
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchNotificationReportArtifacts(reports []BatchNotificationReport) []state.BatchDistributorNotificationReportArtifact {
|
||||||
|
if len(reports) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
artifacts := make([]state.BatchDistributorNotificationReportArtifact, 0, len(reports))
|
||||||
|
for _, item := range reports {
|
||||||
|
artifacts = append(artifacts, state.BatchDistributorNotificationReportArtifact{
|
||||||
|
ReportID: item.ReportID,
|
||||||
|
RunID: item.RunID,
|
||||||
|
SourcePath: item.SourcePath,
|
||||||
|
BundlePaths: append([]string(nil), item.BundlePaths...),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return artifacts
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderBatchNotificationIdentity(cfg config.Config, batch BatchKind, runID string, startedAt time.Time) (batchNotificationIdentity, error) {
|
||||||
|
values, err := batchNotificationTemplateValues(cfg, batch, runID, startedAt)
|
||||||
|
if err != nil {
|
||||||
|
return batchNotificationIdentity{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
bundleID, err := config.RenderDistributorBatchBundleID(cfg.Notify.Distributor.Batch.BundleIDTemplate, values)
|
||||||
|
if err != nil {
|
||||||
|
return batchNotificationIdentity{}, err
|
||||||
|
}
|
||||||
|
values.BundleID = bundleID
|
||||||
|
|
||||||
|
pipelineID, err := config.RenderDistributorBatchPipelineID(cfg.Notify.Distributor.Batch.PipelineIDTemplate, values)
|
||||||
|
if err != nil {
|
||||||
|
return batchNotificationIdentity{}, err
|
||||||
|
}
|
||||||
|
idempotencyKey, err := config.RenderDistributorBatchIdempotencyKey(cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate, values)
|
||||||
|
if err != nil {
|
||||||
|
return batchNotificationIdentity{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return batchNotificationIdentity{
|
||||||
|
PipelineID: pipelineID,
|
||||||
|
BundleID: bundleID,
|
||||||
|
IdempotencyKey: idempotencyKey,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchNotificationTemplateValues(cfg config.Config, batch BatchKind, runID string, startedAt time.Time) (config.DistributorBatchTemplateValues, error) {
|
||||||
|
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return config.DistributorBatchTemplateValues{}, fmt.Errorf("load batch notification timezone: %w", err)
|
||||||
|
}
|
||||||
|
return config.DistributorBatchTemplateValues{
|
||||||
|
LocationID: cfg.Location.ID,
|
||||||
|
Batch: string(batch),
|
||||||
|
BatchRunID: runID,
|
||||||
|
BatchStartedDate: startedAt.In(location).Format(timeutil.DateLayout),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
139
internal/app/batch_plan.go
Normal file
139
internal/app/batch_plan.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type plannedBatchReport struct {
|
||||||
|
Resolved report.Resolved
|
||||||
|
OutputCopyName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func planBatchRun(req BatchRequest, now time.Time, collection collect.Result) ([]plannedBatchReport, error) {
|
||||||
|
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
batch, err := report.BatchForCommandName(string(req.Batch))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
registry, err := reportRegistry(req.Config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveReq := report.ResolveRequest{
|
||||||
|
Now: now,
|
||||||
|
Location: location,
|
||||||
|
}
|
||||||
|
var planned []plannedBatchReport
|
||||||
|
switch batch {
|
||||||
|
case report.Morning:
|
||||||
|
planned, err = appendPlannedReport(planned, registry, report.Today, resolveReq, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
case report.Evening:
|
||||||
|
planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown batch %q", batch)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hourly *weatherdata.ForecastRun
|
||||||
|
if collection.Bundle != nil {
|
||||||
|
hourly = collection.Bundle.Hourly
|
||||||
|
}
|
||||||
|
for _, date := range eligibleDailyDates(hourly, now, location) {
|
||||||
|
dailyReq := resolveReq
|
||||||
|
dailyReq.Date = date
|
||||||
|
outputCopyName := "daily-" + date.In(location).Format(timeutil.DateLayout) + ".md"
|
||||||
|
planned, err = appendPlannedReport(planned, registry, report.Daily, dailyReq, outputCopyName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return planned, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendPlannedReport(planned []plannedBatchReport, registry report.Registry, id report.ID, req report.ResolveRequest, outputCopyName string) ([]plannedBatchReport, error) {
|
||||||
|
resolved, err := registry.Resolve(id, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return append(planned, plannedBatchReport{
|
||||||
|
Resolved: resolved,
|
||||||
|
OutputCopyName: outputCopyName,
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func eligibleDailyDates(hourly *weatherdata.ForecastRun, now time.Time, location *time.Location) []time.Time {
|
||||||
|
if hourly == nil || location == nil || hourly.Product != "hourly" || len(hourly.Periods) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hourlyStarts := make(map[time.Time]struct{}, len(hourly.Periods))
|
||||||
|
var maxLocalDate time.Time
|
||||||
|
for _, period := range hourly.Periods {
|
||||||
|
if !isHourlyPeriod(period) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
start := period.StartTime
|
||||||
|
hourlyStarts[instantKey(start)] = struct{}{}
|
||||||
|
localDate := localDateStart(start, location)
|
||||||
|
if maxLocalDate.IsZero() || localDate.After(maxLocalDate) {
|
||||||
|
maxLocalDate = localDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(hourlyStarts) == 0 || maxLocalDate.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
startDate := localDateStart(now.In(location).AddDate(0, 0, 2), location)
|
||||||
|
var dates []time.Time
|
||||||
|
for candidate := startDate; !candidate.After(maxLocalDate); candidate = candidate.AddDate(0, 0, 1) {
|
||||||
|
if hasFullHourlyCoverage(candidate, location, hourlyStarts) {
|
||||||
|
dates = append(dates, candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dates
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHourlyPeriod(period weatherdata.ForecastPeriod) bool {
|
||||||
|
if period.StartTime.IsZero() || period.EndTime.IsZero() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return period.EndTime.Equal(period.StartTime.Add(time.Hour))
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasFullHourlyCoverage(date time.Time, location *time.Location, hourlyStarts map[time.Time]struct{}) bool {
|
||||||
|
day := timeutil.CivilDay(date, location)
|
||||||
|
for required := day.Start; required.Before(day.End); required = required.Add(time.Hour) {
|
||||||
|
if _, ok := hourlyStarts[instantKey(required)]; !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func instantKey(value time.Time) time.Time {
|
||||||
|
return value.UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
func localDateStart(value time.Time, location *time.Location) time.Time {
|
||||||
|
local := value.In(location)
|
||||||
|
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location)
|
||||||
|
}
|
||||||
352
internal/app/batch_plan_test.go
Normal file
352
internal/app/batch_plan_test.go
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlanBatchRunMorningOrder(t *testing.T) {
|
||||||
|
location := mustLoadTestLocation(t, "America/Chicago")
|
||||||
|
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
|
||||||
|
|
||||||
|
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchMorning}, mustParse("2026-05-29T08:00:00-05:00"), collectionWithHourly(hourly))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("planBatchRun() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertPlannedReportIDs(t, planned, report.Today, report.Tomorrow, report.Daily)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanBatchRunEveningOrder(t *testing.T) {
|
||||||
|
location := mustLoadTestLocation(t, "America/Chicago")
|
||||||
|
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
|
||||||
|
|
||||||
|
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchEvening}, mustParse("2026-05-29T18:00:00-05:00"), collectionWithHourly(hourly))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("planBatchRun() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertPlannedReportIDs(t, planned, report.Tomorrow, report.Daily)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanBatchRunDynamicDailyDatesStartAfterTomorrow(t *testing.T) {
|
||||||
|
location := mustLoadTestLocation(t, "America/Chicago")
|
||||||
|
periods := fullDayPeriods(t, "2026-05-30", location)
|
||||||
|
periods = append(periods, fullDayPeriods(t, "2026-05-31", location)...)
|
||||||
|
periods = append(periods, fullDayPeriods(t, "2026-06-01", location)...)
|
||||||
|
|
||||||
|
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchMorning}, mustParse("2026-05-29T08:00:00-05:00"), collectionWithHourly(hourlyRun(periods...)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("planBatchRun() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
daily := plannedDailyReports(planned)
|
||||||
|
if len(daily) != 2 {
|
||||||
|
t.Fatalf("daily reports = %#v, want two future Daily reports", daily)
|
||||||
|
}
|
||||||
|
assertPlanningPeriod(t, daily[0].Resolved.ValidPeriod, "2026-05-31T00:00:00-05:00", "2026-06-01T00:00:00-05:00")
|
||||||
|
assertPlanningPeriod(t, daily[1].Resolved.ValidPeriod, "2026-06-01T00:00:00-05:00", "2026-06-02T00:00:00-05:00")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanBatchRunMorningExcludesLegacyStaticReports(t *testing.T) {
|
||||||
|
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchMorning}, mustParse("2026-05-29T08:00:00-05:00"), collect.Result{Bundle: &weatherdata.Bundle{}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("planBatchRun() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range planned {
|
||||||
|
if item.Resolved.Definition.ID == report.ThreeDay || item.Resolved.Definition.ID == report.Weekend {
|
||||||
|
t.Fatalf("morning plan includes %s, want no 3-Day or Weekend", item.Resolved.Definition.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanBatchRunDynamicDailyOutputCopyNames(t *testing.T) {
|
||||||
|
location := mustLoadTestLocation(t, "America/Chicago")
|
||||||
|
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
|
||||||
|
|
||||||
|
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchEvening}, mustParse("2026-05-29T18:00:00-05:00"), collectionWithHourly(hourly))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("planBatchRun() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
daily := plannedDailyReports(planned)
|
||||||
|
if len(daily) != 1 {
|
||||||
|
t.Fatalf("daily reports = %#v, want one Daily report", daily)
|
||||||
|
}
|
||||||
|
if daily[0].OutputCopyName != "daily-2026-05-31.md" {
|
||||||
|
t.Fatalf("OutputCopyName = %q, want date-qualified Daily name", daily[0].OutputCopyName)
|
||||||
|
}
|
||||||
|
if planned[0].OutputCopyName != "" {
|
||||||
|
t.Fatalf("Tomorrow OutputCopyName = %q, want definition batch output name to apply later", planned[0].OutputCopyName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanBatchRunRejectsUnknownBatch(t *testing.T) {
|
||||||
|
_, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchKind("hourly")}, mustParse("2026-05-29T08:00:00-05:00"), collect.Result{Bundle: &weatherdata.Bundle{}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `unknown batch command "hourly"`) {
|
||||||
|
t.Fatalf("planBatchRun() error = %v, want unknown batch command", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEligibleDailyDatesRequiresFullOrdinaryLocalDay(t *testing.T) {
|
||||||
|
location := mustLoadTestLocation(t, "America/Chicago")
|
||||||
|
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
|
||||||
|
|
||||||
|
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||||
|
assertLocalDates(t, got, location, "2026-05-31")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEligibleDailyDatesMatchesFixedOffsetStartInstants(t *testing.T) {
|
||||||
|
location := mustLoadTestLocation(t, "America/Chicago")
|
||||||
|
hourly := hourlyRun(fixedOffsetPeriods(t, fullDayPeriods(t, "2026-05-31", location))...)
|
||||||
|
|
||||||
|
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||||
|
assertLocalDates(t, got, location, "2026-05-31")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEligibleDailyDatesSkipsDayWithMissingRequiredHour(t *testing.T) {
|
||||||
|
location := mustLoadTestLocation(t, "America/Chicago")
|
||||||
|
periods := fullDayPeriods(t, "2026-05-31", location)
|
||||||
|
periods = append(periods[:12], periods[13:]...)
|
||||||
|
hourly := hourlyRun(periods...)
|
||||||
|
|
||||||
|
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||||
|
assertLocalDates(t, got, location)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEligibleDailyDatesSkipsPartialFinalDay(t *testing.T) {
|
||||||
|
location := mustLoadTestLocation(t, "America/Chicago")
|
||||||
|
periods := fullDayPeriods(t, "2026-05-31", location)
|
||||||
|
periods = append(periods, partialDayPeriods(t, "2026-06-01", location, 12)...)
|
||||||
|
hourly := hourlyRun(periods...)
|
||||||
|
|
||||||
|
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||||
|
assertLocalDates(t, got, location, "2026-05-31")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEligibleDailyDatesStartsAfterTomorrow(t *testing.T) {
|
||||||
|
location := mustLoadTestLocation(t, "America/Chicago")
|
||||||
|
periods := fullDayPeriods(t, "2026-05-29", location)
|
||||||
|
periods = append(periods, fullDayPeriods(t, "2026-05-30", location)...)
|
||||||
|
periods = append(periods, fullDayPeriods(t, "2026-05-31", location)...)
|
||||||
|
hourly := hourlyRun(periods...)
|
||||||
|
|
||||||
|
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||||
|
assertLocalDates(t, got, location, "2026-05-31")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEligibleDailyDatesReturnsMultipleFutureDatesInOrder(t *testing.T) {
|
||||||
|
location := mustLoadTestLocation(t, "America/Chicago")
|
||||||
|
periods := fullDayPeriods(t, "2026-05-31", location)
|
||||||
|
periods = append(periods, fullDayPeriods(t, "2026-06-01", location)...)
|
||||||
|
hourly := hourlyRun(periods...)
|
||||||
|
|
||||||
|
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||||
|
assertLocalDates(t, got, location, "2026-05-31", "2026-06-01")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEligibleDailyDatesIgnoresNonHourlyAndInvalidPeriods(t *testing.T) {
|
||||||
|
location := mustLoadTestLocation(t, "America/Chicago")
|
||||||
|
day := timeutil.CivilDay(mustParseLocalDate(t, "2026-05-31", location), location)
|
||||||
|
periods := []weatherdata.ForecastPeriod{
|
||||||
|
{StartTime: day.Start, EndTime: day.Start.Add(2 * time.Hour)},
|
||||||
|
{StartTime: day.Start.Add(time.Hour), EndTime: day.Start.Add(time.Hour)},
|
||||||
|
{StartTime: time.Time{}, EndTime: day.Start.Add(3 * time.Hour)},
|
||||||
|
}
|
||||||
|
periods = append(periods, fullDayPeriods(t, "2026-06-01", location)...)
|
||||||
|
hourly := hourlyRun(periods...)
|
||||||
|
|
||||||
|
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||||
|
assertLocalDates(t, got, location, "2026-06-01")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEligibleDailyDatesUsesDSTCivilDayInstants(t *testing.T) {
|
||||||
|
location := mustLoadTestLocation(t, "America/New_York")
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
now string
|
||||||
|
date string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "spring forward",
|
||||||
|
now: "2026-03-06T08:00:00-05:00",
|
||||||
|
date: "2026-03-08",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fall back",
|
||||||
|
now: "2026-10-30T08:00:00-04:00",
|
||||||
|
date: "2026-11-01",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
hourly := hourlyRun(fullDayPeriods(t, tt.date, location)...)
|
||||||
|
|
||||||
|
got := eligibleDailyDates(hourly, mustParse(tt.now), location)
|
||||||
|
assertLocalDates(t, got, location, tt.date)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEligibleDailyDatesReturnsNoneWithoutHourlyForecast(t *testing.T) {
|
||||||
|
location := mustLoadTestLocation(t, "America/Chicago")
|
||||||
|
fullDay := fullDayPeriods(t, "2026-05-31", location)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
hourly *weatherdata.ForecastRun
|
||||||
|
}{
|
||||||
|
{name: "nil run"},
|
||||||
|
{name: "empty periods", hourly: hourlyRun()},
|
||||||
|
{name: "non-hourly product", hourly: forecastRun("narrative", fullDay...)},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := eligibleDailyDates(tt.hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||||
|
assertLocalDates(t, got, location)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hourlyRun(periods ...weatherdata.ForecastPeriod) *weatherdata.ForecastRun {
|
||||||
|
return forecastRun("hourly", periods...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func forecastRun(product string, periods ...weatherdata.ForecastPeriod) *weatherdata.ForecastRun {
|
||||||
|
return &weatherdata.ForecastRun{
|
||||||
|
Product: product,
|
||||||
|
Periods: periods,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionWithHourly(hourly *weatherdata.ForecastRun) collect.Result {
|
||||||
|
return collect.Result{Bundle: &weatherdata.Bundle{Hourly: hourly}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func planningConfig() config.Config {
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertPlannedReportIDs(t *testing.T, got []plannedBatchReport, want ...report.ID) {
|
||||||
|
t.Helper()
|
||||||
|
gotIDs := make([]string, 0, len(got))
|
||||||
|
for _, item := range got {
|
||||||
|
gotIDs = append(gotIDs, string(item.Resolved.Definition.ID))
|
||||||
|
}
|
||||||
|
wantIDs := make([]string, 0, len(want))
|
||||||
|
for _, id := range want {
|
||||||
|
wantIDs = append(wantIDs, string(id))
|
||||||
|
}
|
||||||
|
if strings.Join(gotIDs, ",") != strings.Join(wantIDs, ",") {
|
||||||
|
t.Fatalf("planned report IDs = [%s], want [%s]", strings.Join(gotIDs, ","), strings.Join(wantIDs, ","))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func plannedDailyReports(planned []plannedBatchReport) []plannedBatchReport {
|
||||||
|
var daily []plannedBatchReport
|
||||||
|
for _, item := range planned {
|
||||||
|
if item.Resolved.Definition.ID == report.Daily {
|
||||||
|
daily = append(daily, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return daily
|
||||||
|
}
|
||||||
|
|
||||||
|
func fullDayPeriods(t *testing.T, date string, location *time.Location) []weatherdata.ForecastPeriod {
|
||||||
|
t.Helper()
|
||||||
|
day := timeutil.CivilDay(mustParseLocalDate(t, date, location), location)
|
||||||
|
var periods []weatherdata.ForecastPeriod
|
||||||
|
for start := day.Start; start.Before(day.End); start = start.Add(time.Hour) {
|
||||||
|
periods = append(periods, weatherdata.ForecastPeriod{
|
||||||
|
StartTime: start,
|
||||||
|
EndTime: start.Add(time.Hour),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return periods
|
||||||
|
}
|
||||||
|
|
||||||
|
func partialDayPeriods(t *testing.T, date string, location *time.Location, count int) []weatherdata.ForecastPeriod {
|
||||||
|
t.Helper()
|
||||||
|
periods := fullDayPeriods(t, date, location)
|
||||||
|
if count > len(periods) {
|
||||||
|
count = len(periods)
|
||||||
|
}
|
||||||
|
return periods[:count]
|
||||||
|
}
|
||||||
|
|
||||||
|
func fixedOffsetPeriods(t *testing.T, periods []weatherdata.ForecastPeriod) []weatherdata.ForecastPeriod {
|
||||||
|
t.Helper()
|
||||||
|
out := make([]weatherdata.ForecastPeriod, 0, len(periods))
|
||||||
|
for _, period := range periods {
|
||||||
|
start, err := time.Parse(time.RFC3339, period.StartTime.Format(time.RFC3339))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse fixed-offset start: %v", err)
|
||||||
|
}
|
||||||
|
end, err := time.Parse(time.RFC3339, period.EndTime.Format(time.RFC3339))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse fixed-offset end: %v", err)
|
||||||
|
}
|
||||||
|
out = append(out, weatherdata.ForecastPeriod{StartTime: start, EndTime: end})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertLocalDates(t *testing.T, got []time.Time, location *time.Location, want ...string) {
|
||||||
|
t.Helper()
|
||||||
|
gotDates := make([]string, 0, len(got))
|
||||||
|
for _, date := range got {
|
||||||
|
gotDates = append(gotDates, date.In(location).Format(timeutil.DateLayout))
|
||||||
|
}
|
||||||
|
if strings.Join(gotDates, ",") != strings.Join(want, ",") {
|
||||||
|
t.Fatalf("eligibleDailyDates() = [%s], want [%s]", strings.Join(gotDates, ","), strings.Join(want, ","))
|
||||||
|
}
|
||||||
|
for _, date := range got {
|
||||||
|
day := timeutil.CivilDay(date, location)
|
||||||
|
if !date.Equal(day.Start) {
|
||||||
|
t.Fatalf("eligible date %s is not local civil day start %s", date, day.Start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertPlanningPeriod(t *testing.T, period timeutil.Period, wantStart string, wantEnd string) {
|
||||||
|
t.Helper()
|
||||||
|
if !period.IsValid() {
|
||||||
|
t.Fatalf("period = %#v, want valid", period)
|
||||||
|
}
|
||||||
|
if got := period.Start.Format(time.RFC3339); got != wantStart {
|
||||||
|
t.Fatalf("Start = %s, want %s", got, wantStart)
|
||||||
|
}
|
||||||
|
if got := period.End.Format(time.RFC3339); got != wantEnd {
|
||||||
|
t.Fatalf("End = %s, want %s", got, wantEnd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustLoadTestLocation(t *testing.T, name string) *time.Location {
|
||||||
|
t.Helper()
|
||||||
|
location, err := time.LoadLocation(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLocation(%q) error = %v", name, err)
|
||||||
|
}
|
||||||
|
return location
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustParseLocalDate(t *testing.T, value string, location *time.Location) time.Time {
|
||||||
|
t.Helper()
|
||||||
|
parsed, err := timeutil.ParseLocalDate(value, location)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseLocalDate(%q) error = %v", value, err)
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
@@ -16,20 +16,24 @@ type AlertDigestModule struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AlertSummary struct {
|
type AlertSummary struct {
|
||||||
Event string `json:"event,omitempty"`
|
Event string `json:"event,omitempty"`
|
||||||
Headline string `json:"headline,omitempty"`
|
Headline string `json:"headline,omitempty"`
|
||||||
Severity string `json:"severity,omitempty"`
|
Severity string `json:"severity,omitempty"`
|
||||||
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
|
Instruction string `json:"instruction,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildAlertDigestModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
func buildAlertDigestModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
value := alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps)
|
value := alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps, ctx.Timezone)
|
||||||
if value == nil {
|
if value == nil {
|
||||||
value = &AlertDigestModule{}
|
value = &AlertDigestModule{}
|
||||||
}
|
}
|
||||||
return &module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: *value}, nil
|
return &module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: *value}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func alertDigest(collected facts.CollectedFacts, overlaps []forecast.AlertOverlap) *AlertDigestModule {
|
func alertDigest(collected facts.CollectedFacts, overlaps []forecast.AlertOverlap, timezone string) *AlertDigestModule {
|
||||||
missing := sourceMissing(collected.SourceProvenance, "alerts")
|
missing := sourceMissing(collected.SourceProvenance, "alerts")
|
||||||
if collected.Alerts == nil && !missing {
|
if collected.Alerts == nil && !missing {
|
||||||
return nil
|
return nil
|
||||||
@@ -42,9 +46,13 @@ func alertDigest(collected facts.CollectedFacts, overlaps []forecast.AlertOverla
|
|||||||
value.RelevantCount = len(overlaps)
|
value.RelevantCount = len(overlaps)
|
||||||
for _, overlap := range overlaps {
|
for _, overlap := range overlaps {
|
||||||
value.Relevant = append(value.Relevant, AlertSummary{
|
value.Relevant = append(value.Relevant, AlertSummary{
|
||||||
Event: overlap.Event,
|
Event: overlap.Event,
|
||||||
Headline: overlap.Headline,
|
Headline: overlap.Headline,
|
||||||
Severity: overlap.Severity,
|
Severity: overlap.Severity,
|
||||||
|
PeriodBegins: friendlyMonthDayTimeLabel(overlap.Period.Start, timezone),
|
||||||
|
PeriodEnds: friendlyMonthDayTimeLabel(overlap.Period.End, timezone),
|
||||||
|
Instruction: overlap.Instruction,
|
||||||
|
Description: overlap.Description,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -59,21 +59,24 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
|
|||||||
t.Fatalf("HourlyForecast = %#v, want hourly metadata and one valid-period period", value)
|
t.Fatalf("HourlyForecast = %#v, want hourly metadata and one valid-period period", value)
|
||||||
}
|
}
|
||||||
period := value.Periods[0]
|
period := value.Periods[0]
|
||||||
if period.TextDescription != "Showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 {
|
if period.HourLabel != "8:00 AM" || period.TextDescription != "Showers likely." || period.TextDescriptionLower != "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)
|
||||||
}
|
}
|
||||||
|
if !period.MentionPrecipitation {
|
||||||
|
t.Fatalf("MentionPrecipitation = false, want true for default threshold")
|
||||||
|
}
|
||||||
data, err := json.Marshal(output.Value)
|
data, err := json.Marshal(output.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
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", "hour_label", "period_begins", "period_ends", "text_description", "text_description_lower", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "mention_precipitation", "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 +84,71 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHourlyForecastPromptExportOmitsTemplateHelpers(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
richText := mustMarshalModuleJSON(t, output.Value)
|
||||||
|
for _, field := range []string{"hour_label", "text_description_lower", "mention_precipitation"} {
|
||||||
|
if !strings.Contains(richText, field) {
|
||||||
|
t.Fatalf("rich hourly json = %s, want helper field %s", richText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt := moduleDataPackageValue[HourlyForecastPromptExport](t, output)
|
||||||
|
if prompt.Product != "hourly" || prompt.SourceLocationID != "test-grid" || len(prompt.Periods) != 1 {
|
||||||
|
t.Fatalf("hourly prompt export = %#v, want hourly metadata and one period", prompt)
|
||||||
|
}
|
||||||
|
period := prompt.Periods[0]
|
||||||
|
if period.PeriodBegins != "2026-05-29 at 8:00 AM" || period.PeriodEnds != "2026-05-29 at 9:00 AM" || period.TextDescription != "Showers likely." {
|
||||||
|
t.Fatalf("hourly prompt period = %#v, want factual period fields", period)
|
||||||
|
}
|
||||||
|
if period.TemperatureF == nil || *period.TemperatureF != 76 || period.WindSpeedMph == nil || *period.WindSpeedMph != 14 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
|
||||||
|
t.Fatalf("hourly prompt period = %#v, want temperature, wind, and precip fields", period)
|
||||||
|
}
|
||||||
|
if period.WindDirection != "S" || period.RelativeHumidityPercent == nil || *period.RelativeHumidityPercent != 66 {
|
||||||
|
t.Fatalf("hourly prompt period = %#v, want wind direction and humidity", period)
|
||||||
|
}
|
||||||
|
promptText := mustMarshalModuleJSON(t, output.DataPackageValue())
|
||||||
|
for _, field := range []string{"period_begins", "period_ends", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} {
|
||||||
|
if !strings.Contains(promptText, field) {
|
||||||
|
t.Fatalf("hourly prompt json = %s, want field %s", promptText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, field := range []string{"hour_label", "text_description_lower", "mention_precipitation"} {
|
||||||
|
if strings.Contains(promptText, field) {
|
||||||
|
t.Fatalf("hourly prompt json = %s, want omitted helper field %s", promptText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHourlyForecastPrecipMentionThreshold(t *testing.T) {
|
||||||
|
periods := []weatherdata.ForecastPeriod{
|
||||||
|
{StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"), ProbabilityOfPrecipitationPercent: floatPtr(19)},
|
||||||
|
{StartTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"), ProbabilityOfPrecipitationPercent: floatPtr(20)},
|
||||||
|
{StartTime: mustParseModuleTime("2026-05-29T10:00:00-05:00")},
|
||||||
|
}
|
||||||
|
value := hourlyForecastPeriodsWithPrecipMentionThreshold(periods, "America/Chicago", DefaultHourlyForecastPrecipMentionProbabilityThreshold)
|
||||||
|
if len(value) != 3 {
|
||||||
|
t.Fatalf("periods length = %d, want 3", len(value))
|
||||||
|
}
|
||||||
|
if value[0].MentionPrecipitation {
|
||||||
|
t.Fatalf("19%% MentionPrecipitation = true, want false")
|
||||||
|
}
|
||||||
|
if !value[1].MentionPrecipitation {
|
||||||
|
t.Fatalf("20%% MentionPrecipitation = false, want true")
|
||||||
|
}
|
||||||
|
if value[2].MentionPrecipitation {
|
||||||
|
t.Fatalf("nil MentionPrecipitation = true, want false")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +163,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 +194,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 +208,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 +216,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)
|
||||||
@@ -163,7 +244,7 @@ func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
|
|||||||
t.Fatalf("BuildModule() error = %v", err)
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
}
|
}
|
||||||
value := moduleValue[MetadataModule](t, output)
|
value := moduleValue[MetadataModule](t, output)
|
||||||
if value.RunID == "" || value.ReportID != report.DailyToday || value.PromptID != "weather.daily_report" {
|
if value.RunID == "" || value.ReportID != report.Daily || value.PromptID != "weather.daily_generated_text" {
|
||||||
t.Fatalf("metadata = %#v, want report identity", value)
|
t.Fatalf("metadata = %#v, want report identity", value)
|
||||||
}
|
}
|
||||||
if value.Location == nil || value.Location.Name != "Brentwood" {
|
if value.Location == nil || value.Location.Name != "Brentwood" {
|
||||||
@@ -194,18 +275,24 @@ func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) {
|
|||||||
t.Fatalf("BuildModule() error = %v", err)
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
}
|
}
|
||||||
value := moduleValue[CurrentConditionsModule](t, output)
|
value := moduleValue[CurrentConditionsModule](t, output)
|
||||||
if value.ConditionText != "Partly cloudy" || value.TemperatureF == nil || *value.TemperatureF != 74 {
|
if value.ConditionText != "Partly cloudy" || value.ConditionTextLower != "partly cloudy" || value.TemperatureF == nil || *value.TemperatureF != 74 {
|
||||||
t.Fatalf("CurrentConditions = %#v, want current condition facts", value)
|
t.Fatalf("CurrentConditions = %#v, want rounded current condition facts", value)
|
||||||
|
}
|
||||||
|
if value.ApparentTemperatureF == nil || *value.ApparentTemperatureF != 76 || value.RelativeHumidityPercent == nil || *value.RelativeHumidityPercent != 71 || value.WindSpeedMph == nil || *value.WindSpeedMph != 8 {
|
||||||
|
t.Fatalf("CurrentConditions rounded values = %#v, want apparent 76, humidity 71, wind 8", value)
|
||||||
}
|
}
|
||||||
if value.WindDirection != "S" {
|
if value.WindDirection != "S" {
|
||||||
t.Fatalf("WindDirection = %q, want S", value.WindDirection)
|
t.Fatalf("WindDirection = %q, want S", value.WindDirection)
|
||||||
}
|
}
|
||||||
|
if value.WindDirectionText != "south" {
|
||||||
|
t.Fatalf("WindDirectionText = %q, want south", value.WindDirectionText)
|
||||||
|
}
|
||||||
data, err := json.Marshal(output.Value)
|
data, err := json.Marshal(output.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Marshal current conditions: %v", err)
|
t.Fatalf("Marshal current conditions: %v", err)
|
||||||
}
|
}
|
||||||
jsonText := string(data)
|
jsonText := string(data)
|
||||||
for _, field := range []string{"condition_text", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction"} {
|
for _, field := range []string{"condition_text", "condition_text_lower", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction", "wind_direction_text"} {
|
||||||
if !strings.Contains(jsonText, field) {
|
if !strings.Contains(jsonText, field) {
|
||||||
t.Fatalf("current json = %s, want field %s", jsonText, field)
|
t.Fatalf("current json = %s, want field %s", jsonText, field)
|
||||||
}
|
}
|
||||||
@@ -215,6 +302,44 @@ func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCurrentConditionsPromptExportOmitsTemplateHelpers(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.CurrentConditions})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
richText := mustMarshalModuleJSON(t, output.Value)
|
||||||
|
for _, field := range []string{"condition_text_lower", "wind_direction_text"} {
|
||||||
|
if !strings.Contains(richText, field) {
|
||||||
|
t.Fatalf("rich current conditions json = %s, want helper field %s", richText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt := moduleDataPackageValue[CurrentConditionsPromptExport](t, output)
|
||||||
|
if prompt.ConditionText != "Partly cloudy" || prompt.TemperatureF == nil || *prompt.TemperatureF != 74 {
|
||||||
|
t.Fatalf("current prompt export = %#v, want condition text and temperature", prompt)
|
||||||
|
}
|
||||||
|
if prompt.ApparentTemperatureF == nil || *prompt.ApparentTemperatureF != 76 || prompt.RelativeHumidityPercent == nil || *prompt.RelativeHumidityPercent != 71 || prompt.WindSpeedMph == nil || *prompt.WindSpeedMph != 8 {
|
||||||
|
t.Fatalf("current prompt export = %#v, want apparent temperature, humidity, and wind speed", prompt)
|
||||||
|
}
|
||||||
|
if prompt.WindDirection != "S" {
|
||||||
|
t.Fatalf("current prompt wind direction = %q, want S", prompt.WindDirection)
|
||||||
|
}
|
||||||
|
promptText := mustMarshalModuleJSON(t, output.DataPackageValue())
|
||||||
|
for _, field := range []string{"condition_text", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction"} {
|
||||||
|
if !strings.Contains(promptText, field) {
|
||||||
|
t.Fatalf("current prompt json = %s, want field %s", promptText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, field := range []string{"condition_text_lower", "wind_direction_text"} {
|
||||||
|
if strings.Contains(promptText, field) {
|
||||||
|
t.Fatalf("current prompt json = %s, want omitted helper field %s", promptText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) {
|
func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := testModuleContext()
|
ctx := testModuleContext()
|
||||||
@@ -242,6 +367,39 @@ func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlertDigestIncludesPeriodAndGuidance(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Collected.Alerts = &weatherdata.AlertRun{Alerts: []json.RawMessage{json.RawMessage(`{"event":"Wind Advisory"}`)}}
|
||||||
|
ctx.Derived.AlertOverlaps = []forecast.AlertOverlap{{
|
||||||
|
Event: "Wind Advisory",
|
||||||
|
Headline: "Wind Advisory until 8 PM",
|
||||||
|
Severity: "Moderate",
|
||||||
|
Period: timeutil.Period{Start: mustParseModuleTime("2026-06-17T18:00:00Z"), End: mustParseModuleTime("2026-06-18T01:00:00Z")},
|
||||||
|
Instruction: "Secure outdoor objects.",
|
||||||
|
Description: "Gusty winds may blow around unsecured objects.",
|
||||||
|
}}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.AlertDigest})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(alert digest) error = %v", err)
|
||||||
|
}
|
||||||
|
value := moduleValue[AlertDigestModule](t, output)
|
||||||
|
if len(value.Relevant) != 1 {
|
||||||
|
t.Fatalf("Relevant length = %d, want 1", len(value.Relevant))
|
||||||
|
}
|
||||||
|
alert := value.Relevant[0]
|
||||||
|
if alert.Event != "Wind Advisory" || alert.Headline != "Wind Advisory until 8 PM" || alert.Severity != "Moderate" {
|
||||||
|
t.Fatalf("alert identity = %#v, want preserved event/headline/severity", alert)
|
||||||
|
}
|
||||||
|
if alert.PeriodBegins != "June 17 at 1:00 PM" || alert.PeriodEnds != "June 17 at 8:00 PM" {
|
||||||
|
t.Fatalf("alert period = %q/%q, want friendly local labels", alert.PeriodBegins, alert.PeriodEnds)
|
||||||
|
}
|
||||||
|
if alert.Instruction != "Secure outdoor objects." || alert.Description != "Gusty winds may blow around unsecured objects." {
|
||||||
|
t.Fatalf("alert guidance = %#v, want instruction and description preserved", alert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBaseModulesOmitMissingOptionalOutputs(t *testing.T) {
|
func TestBaseModulesOmitMissingOptionalOutputs(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := testModuleContext()
|
ctx := testModuleContext()
|
||||||
@@ -285,13 +443,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,9 +478,37 @@ 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.Daily)
|
||||||
resolved := report.Resolved{
|
resolved := report.Resolved{
|
||||||
Definition: definition,
|
Definition: definition,
|
||||||
GeneratedAt: generatedAt,
|
GeneratedAt: generatedAt,
|
||||||
@@ -327,10 +519,10 @@ func testModuleContext() ModuleContext {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
isDay := true
|
isDay := true
|
||||||
tempF := 74.0
|
tempF := 74.4
|
||||||
apparentF := 76.0
|
apparentF := 75.6
|
||||||
humidity := 71.0
|
humidity := 70.6
|
||||||
windMph := 8.0
|
windMph := 8.4
|
||||||
windDirection := 190.0
|
windDirection := 190.0
|
||||||
narrativeTempF := 81.0
|
narrativeTempF := 81.0
|
||||||
narrativePop := 60.0
|
narrativePop := 60.0
|
||||||
@@ -484,6 +676,28 @@ func moduleValue[T any](t *testing.T, output *module.Output) T {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func moduleDataPackageValue[T any](t *testing.T, output *module.Output) T {
|
||||||
|
t.Helper()
|
||||||
|
var value T
|
||||||
|
data, err := json.Marshal(output.DataPackageValue())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal module data package value: %v", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &value); err != nil {
|
||||||
|
t.Fatalf("decode module data package value: %v", err)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustMarshalModuleJSON(t *testing.T, value any) string {
|
||||||
|
t.Helper()
|
||||||
|
data, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal module value: %v", err)
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
func mustParseModuleTime(value string) time.Time {
|
func mustParseModuleTime(value string) time.Time {
|
||||||
parsed, err := time.Parse(time.RFC3339, value)
|
parsed, err := time.Parse(time.RFC3339, value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,20 +1,41 @@
|
|||||||
package briefing
|
package briefing
|
||||||
|
|
||||||
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
)
|
||||||
|
|
||||||
type CurrentConditionsModule struct {
|
type CurrentConditionsModule struct {
|
||||||
ConditionText string `json:"condition_text,omitempty"`
|
ConditionText string `json:"condition_text,omitempty"`
|
||||||
IsDay *bool `json:"is_day,omitempty"`
|
ConditionTextLower string `json:"condition_text_lower,omitempty"`
|
||||||
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
IsDay *bool `json:"is_day,omitempty"`
|
||||||
TemperatureF *float64 `json:"temperature_f,omitempty"`
|
TemperatureC *int `json:"temperature_c,omitempty"`
|
||||||
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"`
|
TemperatureF *int `json:"temperature_f,omitempty"`
|
||||||
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"`
|
ApparentTemperatureC *int `json:"apparent_temperature_c,omitempty"`
|
||||||
DewpointC *float64 `json:"dewpoint_c,omitempty"`
|
ApparentTemperatureF *int `json:"apparent_temperature_f,omitempty"`
|
||||||
DewpointF *float64 `json:"dewpoint_f,omitempty"`
|
DewpointC *int `json:"dewpoint_c,omitempty"`
|
||||||
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
|
DewpointF *int `json:"dewpoint_f,omitempty"`
|
||||||
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
|
RelativeHumidityPercent *int `json:"relative_humidity_percent,omitempty"`
|
||||||
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
|
WindSpeedKmh *int `json:"wind_speed_kmh,omitempty"`
|
||||||
WindDirection string `json:"wind_direction,omitempty"`
|
WindSpeedMph *int `json:"wind_speed_mph,omitempty"`
|
||||||
|
WindDirection string `json:"wind_direction,omitempty"`
|
||||||
|
WindDirectionText string `json:"wind_direction_text,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CurrentConditionsPromptExport struct {
|
||||||
|
ConditionText string `json:"condition_text,omitempty"`
|
||||||
|
IsDay *bool `json:"is_day,omitempty"`
|
||||||
|
TemperatureC *int `json:"temperature_c,omitempty"`
|
||||||
|
TemperatureF *int `json:"temperature_f,omitempty"`
|
||||||
|
ApparentTemperatureC *int `json:"apparent_temperature_c,omitempty"`
|
||||||
|
ApparentTemperatureF *int `json:"apparent_temperature_f,omitempty"`
|
||||||
|
DewpointC *int `json:"dewpoint_c,omitempty"`
|
||||||
|
DewpointF *int `json:"dewpoint_f,omitempty"`
|
||||||
|
RelativeHumidityPercent *int `json:"relative_humidity_percent,omitempty"`
|
||||||
|
WindSpeedKmh *int `json:"wind_speed_kmh,omitempty"`
|
||||||
|
WindSpeedMph *int `json:"wind_speed_mph,omitempty"`
|
||||||
|
WindDirection string `json:"wind_direction,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
@@ -24,17 +45,19 @@ func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, err
|
|||||||
}
|
}
|
||||||
value := CurrentConditionsModule{
|
value := CurrentConditionsModule{
|
||||||
ConditionText: current.ConditionText,
|
ConditionText: current.ConditionText,
|
||||||
|
ConditionTextLower: strings.ToLower(current.ConditionText),
|
||||||
IsDay: copyBool(current.IsDay),
|
IsDay: copyBool(current.IsDay),
|
||||||
TemperatureC: copyFloat(current.TemperatureC),
|
TemperatureC: roundedInt(current.TemperatureC),
|
||||||
TemperatureF: copyFloat(current.TemperatureF),
|
TemperatureF: roundedInt(current.TemperatureF),
|
||||||
ApparentTemperatureC: copyFloat(current.ApparentTemperatureC),
|
ApparentTemperatureC: roundedInt(current.ApparentTemperatureC),
|
||||||
ApparentTemperatureF: copyFloat(current.ApparentTemperatureF),
|
ApparentTemperatureF: roundedInt(current.ApparentTemperatureF),
|
||||||
DewpointC: copyFloat(current.DewpointC),
|
DewpointC: roundedInt(current.DewpointC),
|
||||||
DewpointF: copyFloat(current.DewpointF),
|
DewpointF: roundedInt(current.DewpointF),
|
||||||
RelativeHumidityPercent: copyFloat(current.RelativeHumidityPercent),
|
RelativeHumidityPercent: roundedInt(current.RelativeHumidityPercent),
|
||||||
WindSpeedKmh: copyFloat(current.WindSpeedKmh),
|
WindSpeedKmh: roundedInt(current.WindSpeedKmh),
|
||||||
WindSpeedMph: copyFloat(current.WindSpeedMph),
|
WindSpeedMph: roundedInt(current.WindSpeedMph),
|
||||||
WindDirection: windDirectionLabel(current.WindDirectionDegrees),
|
WindDirection: windDirectionLabel(current.WindDirectionDegrees),
|
||||||
|
WindDirectionText: windDirectionTextLabel(current.WindDirectionDegrees),
|
||||||
}
|
}
|
||||||
if value.isEmpty() {
|
if value.isEmpty() {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -42,8 +65,30 @@ func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, err
|
|||||||
return &module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: value}, nil
|
return &module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: value}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func exportCurrentConditionsPromptValue(value any) (any, error) {
|
||||||
|
rich, ok := value.(CurrentConditionsModule)
|
||||||
|
if !ok {
|
||||||
|
return nil, unexpectedPromptExportValue(value, CurrentConditionsModule{})
|
||||||
|
}
|
||||||
|
return CurrentConditionsPromptExport{
|
||||||
|
ConditionText: rich.ConditionText,
|
||||||
|
IsDay: copyBool(rich.IsDay),
|
||||||
|
TemperatureC: copyInt(rich.TemperatureC),
|
||||||
|
TemperatureF: copyInt(rich.TemperatureF),
|
||||||
|
ApparentTemperatureC: copyInt(rich.ApparentTemperatureC),
|
||||||
|
ApparentTemperatureF: copyInt(rich.ApparentTemperatureF),
|
||||||
|
DewpointC: copyInt(rich.DewpointC),
|
||||||
|
DewpointF: copyInt(rich.DewpointF),
|
||||||
|
RelativeHumidityPercent: copyInt(rich.RelativeHumidityPercent),
|
||||||
|
WindSpeedKmh: copyInt(rich.WindSpeedKmh),
|
||||||
|
WindSpeedMph: copyInt(rich.WindSpeedMph),
|
||||||
|
WindDirection: rich.WindDirection,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (v CurrentConditionsModule) isEmpty() bool {
|
func (v CurrentConditionsModule) isEmpty() bool {
|
||||||
return v.ConditionText == "" &&
|
return v.ConditionText == "" &&
|
||||||
|
v.ConditionTextLower == "" &&
|
||||||
v.IsDay == nil &&
|
v.IsDay == nil &&
|
||||||
v.TemperatureC == nil &&
|
v.TemperatureC == nil &&
|
||||||
v.TemperatureF == nil &&
|
v.TemperatureF == nil &&
|
||||||
@@ -54,5 +99,6 @@ func (v CurrentConditionsModule) isEmpty() bool {
|
|||||||
v.RelativeHumidityPercent == nil &&
|
v.RelativeHumidityPercent == nil &&
|
||||||
v.WindSpeedKmh == nil &&
|
v.WindSpeedKmh == nil &&
|
||||||
v.WindSpeedMph == nil &&
|
v.WindSpeedMph == nil &&
|
||||||
v.WindDirection == ""
|
v.WindDirection == "" &&
|
||||||
|
v.WindDirectionText == ""
|
||||||
}
|
}
|
||||||
|
|||||||
24
internal/briefing/daily_planning_module.go
Normal file
24
internal/briefing/daily_planning_module.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
|
||||||
|
type DailyPlanningModule struct {
|
||||||
|
MorningReadiness []string `json:"morning_readiness,omitempty"`
|
||||||
|
CommuteSchoolWorkdayConcerns []string `json:"commute_school_workday_concerns,omitempty"`
|
||||||
|
OvernightChangeWatch []string `json:"overnight_change_watch,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDailyPlanningModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
summary := ctx.Derived.FirstDailySummary()
|
||||||
|
if summary == nil {
|
||||||
|
return &module.Output{ID: module.DailyPlanning, StanzaName: "daily_planning", Value: DailyPlanningModule{}}, nil
|
||||||
|
}
|
||||||
|
planning := buildMorningCommuteOvernightPlanning(summary)
|
||||||
|
value := DailyPlanningModule{}
|
||||||
|
if planning != nil {
|
||||||
|
value.MorningReadiness = append([]string(nil), planning.MorningReadiness...)
|
||||||
|
value.CommuteSchoolWorkdayConcerns = append([]string(nil), planning.CommuteSchoolWorkdayConcerns...)
|
||||||
|
value.OvernightChangeWatch = append([]string(nil), planning.OvernightChangeWatch...)
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.DailyPlanning, StanzaName: "daily_planning", Value: value}, nil
|
||||||
|
}
|
||||||
@@ -2,32 +2,74 @@ package briefing
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
"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/timeutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DerivedDaypartSummaryModule struct {
|
type DerivedDaypartSummaryModule struct {
|
||||||
Date string `json:"date,omitempty"`
|
Date string `json:"date,omitempty"`
|
||||||
Period string `json:"period,omitempty"`
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
TempRangeF string `json:"temp_range_f,omitempty"`
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
TemperaturePhraseF string `json:"temperature_phrase_f,omitempty"`
|
||||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
||||||
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||||
DominantCondition string `json:"dominant_condition,omitempty"`
|
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||||
NotableConditions []string `json:"notable_conditions,omitempty"`
|
MaxPopTimeLabel string `json:"max_pop_time_label,omitempty"`
|
||||||
Snow bool `json:"snow,omitempty"`
|
MentionPrecipitation bool `json:"mention_precipitation,omitempty"`
|
||||||
Ice bool `json:"ice,omitempty"`
|
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||||
Fog bool `json:"fog,omitempty"`
|
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
|
||||||
Heat bool `json:"heat,omitempty"`
|
DominantCondition string `json:"dominant_condition,omitempty"`
|
||||||
Cold bool `json:"cold,omitempty"`
|
DominantConditionLower string `json:"dominant_condition_lower,omitempty"`
|
||||||
Wind bool `json:"wind,omitempty"`
|
DominantConditionDisplay string `json:"dominant_condition_display,omitempty"`
|
||||||
RelevantAlertCount int `json:"relevant_alert_count,omitempty"`
|
TemperatureTrend string `json:"temperature_trend,omitempty"`
|
||||||
|
TemperatureStartPhraseF string `json:"temperature_start_phrase_f,omitempty"`
|
||||||
|
TemperatureEndPhraseF string `json:"temperature_end_phrase_f,omitempty"`
|
||||||
|
TemperaturePeakPhraseF string `json:"temperature_peak_phrase_f,omitempty"`
|
||||||
|
TemperatureSteadyPhraseF string `json:"temperature_steady_phrase_f,omitempty"`
|
||||||
|
NotableConditions []string `json:"notable_conditions,omitempty"`
|
||||||
|
Snow bool `json:"snow,omitempty"`
|
||||||
|
Ice bool `json:"ice,omitempty"`
|
||||||
|
Fog bool `json:"fog,omitempty"`
|
||||||
|
Heat bool `json:"heat,omitempty"`
|
||||||
|
Cold bool `json:"cold,omitempty"`
|
||||||
|
Wind bool `json:"wind,omitempty"`
|
||||||
|
RelevantAlertCount int `json:"relevant_alert_count,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DerivedDaypartSummaryPromptExport struct {
|
||||||
|
Date string `json:"date,omitempty"`
|
||||||
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
|
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||||
|
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
||||||
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||||
|
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||||
|
MentionPrecipitation bool `json:"mention_precipitation,omitempty"`
|
||||||
|
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||||
|
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
|
||||||
|
DominantCondition string `json:"dominant_condition,omitempty"`
|
||||||
|
TemperatureTrend string `json:"temperature_trend,omitempty"`
|
||||||
|
TemperatureStartPhraseF string `json:"temperature_start_phrase_f,omitempty"`
|
||||||
|
TemperatureEndPhraseF string `json:"temperature_end_phrase_f,omitempty"`
|
||||||
|
TemperaturePeakPhraseF string `json:"temperature_peak_phrase_f,omitempty"`
|
||||||
|
TemperatureSteadyPhraseF string `json:"temperature_steady_phrase_f,omitempty"`
|
||||||
|
NotableConditions []string `json:"notable_conditions,omitempty"`
|
||||||
|
Snow bool `json:"snow,omitempty"`
|
||||||
|
Ice bool `json:"ice,omitempty"`
|
||||||
|
Fog bool `json:"fog,omitempty"`
|
||||||
|
Heat bool `json:"heat,omitempty"`
|
||||||
|
Cold bool `json:"cold,omitempty"`
|
||||||
|
Wind bool `json:"wind,omitempty"`
|
||||||
|
RelevantAlertCount int `json:"relevant_alert_count,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
@@ -43,25 +85,84 @@ func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Outpu
|
|||||||
return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil
|
return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func exportDerivedDaypartSummariesPromptValue(value any) (any, error) {
|
||||||
|
rich, ok := value.(map[string]DerivedDaypartSummaryModule)
|
||||||
|
if !ok {
|
||||||
|
return nil, unexpectedPromptExportValue(value, map[string]DerivedDaypartSummaryModule{})
|
||||||
|
}
|
||||||
|
out := make(map[string]DerivedDaypartSummaryPromptExport, len(rich))
|
||||||
|
for key, daypart := range rich {
|
||||||
|
out[key] = derivedDaypartSummaryPromptValue(daypart)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func derivedDaypartSummaryPromptValue(rich DerivedDaypartSummaryModule) DerivedDaypartSummaryPromptExport {
|
||||||
|
maxPopTime := rich.MaxPopTime
|
||||||
|
if rich.MaxPopTimeLabel != "" {
|
||||||
|
maxPopTime = rich.MaxPopTimeLabel
|
||||||
|
}
|
||||||
|
return DerivedDaypartSummaryPromptExport{
|
||||||
|
Date: rich.Date,
|
||||||
|
DisplayName: rich.DisplayName,
|
||||||
|
PeriodBegins: rich.PeriodBegins,
|
||||||
|
PeriodEnds: rich.PeriodEnds,
|
||||||
|
TempRangeF: rich.TempRangeF,
|
||||||
|
ApparentTempRangeF: rich.ApparentTempRangeF,
|
||||||
|
MaxPopPercent: copyInt(rich.MaxPopPercent),
|
||||||
|
MaxPopTime: maxPopTime,
|
||||||
|
MentionPrecipitation: rich.MentionPrecipitation,
|
||||||
|
MaxWindGustMph: copyInt(rich.MaxWindGustMph),
|
||||||
|
MaxWindGustTime: rich.MaxWindGustTime,
|
||||||
|
DominantCondition: rich.DominantCondition,
|
||||||
|
TemperatureTrend: rich.TemperatureTrend,
|
||||||
|
TemperatureStartPhraseF: rich.TemperatureStartPhraseF,
|
||||||
|
TemperatureEndPhraseF: rich.TemperatureEndPhraseF,
|
||||||
|
TemperaturePeakPhraseF: rich.TemperaturePeakPhraseF,
|
||||||
|
TemperatureSteadyPhraseF: rich.TemperatureSteadyPhraseF,
|
||||||
|
NotableConditions: append([]string(nil), rich.NotableConditions...),
|
||||||
|
Snow: rich.Snow,
|
||||||
|
Ice: rich.Ice,
|
||||||
|
Fog: rich.Fog,
|
||||||
|
Heat: rich.Heat,
|
||||||
|
Cold: rich.Cold,
|
||||||
|
Wind: rich.Wind,
|
||||||
|
RelevantAlertCount: rich.RelevantAlertCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
|
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
|
||||||
|
temperature := daypartTemperatureDisplay(daypart)
|
||||||
value := DerivedDaypartSummaryModule{
|
value := DerivedDaypartSummaryModule{
|
||||||
Date: localDateLabel(daypart.Period.Start, timezone),
|
Date: localDateLabel(daypart.Period.Start, timezone),
|
||||||
Period: friendlyPeriodLabel(daypart.Period, timezone),
|
DisplayName: titleWord(strings.TrimSpace(daypart.Name)),
|
||||||
TempRangeF: rangeLabel(daypart.Temperature),
|
PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone),
|
||||||
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
|
PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone),
|
||||||
DominantCondition: daypart.DominantCondition,
|
TempRangeF: rangeLabel(daypart.Temperature),
|
||||||
NotableConditions: append([]string(nil), daypart.NotableConditions...),
|
TemperaturePhraseF: temperaturePhraseF(daypart.Temperature),
|
||||||
Snow: daypart.Indicators.Snow,
|
TemperatureTrend: temperature.Trend,
|
||||||
Ice: daypart.Indicators.Ice,
|
TemperatureStartPhraseF: temperature.StartPhrase,
|
||||||
Fog: daypart.Indicators.Fog,
|
TemperatureEndPhraseF: temperature.EndPhrase,
|
||||||
Heat: daypart.Indicators.Heat,
|
TemperaturePeakPhraseF: temperature.PeakPhrase,
|
||||||
Cold: daypart.Indicators.Cold,
|
TemperatureSteadyPhraseF: temperature.SteadyPhrase,
|
||||||
Wind: daypart.Indicators.Wind,
|
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
|
||||||
RelevantAlertCount: len(daypart.AlertOverlaps),
|
DominantCondition: daypart.DominantCondition,
|
||||||
|
DominantConditionLower: strings.ToLower(daypart.DominantCondition),
|
||||||
|
DominantConditionDisplay: sentenceCase(daypart.DominantCondition),
|
||||||
|
NotableConditions: append([]string(nil), daypart.NotableConditions...),
|
||||||
|
Snow: daypart.Indicators.Snow,
|
||||||
|
Ice: daypart.Indicators.Ice,
|
||||||
|
Fog: daypart.Indicators.Fog,
|
||||||
|
Heat: daypart.Indicators.Heat,
|
||||||
|
Cold: daypart.Indicators.Cold,
|
||||||
|
Wind: daypart.Indicators.Wind,
|
||||||
|
RelevantAlertCount: len(daypart.AlertOverlaps),
|
||||||
}
|
}
|
||||||
if daypart.MaxPrecipitationProbability != nil {
|
if daypart.MaxPrecipitationProbability != nil {
|
||||||
value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value)
|
value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value)
|
||||||
value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone)
|
value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone)
|
||||||
|
value.MaxPopTimeLabel = hourMinuteLabel(daypart.MaxPrecipitationProbability.Time, timezone)
|
||||||
|
value.MentionPrecipitation = mentionHourlyForecastPrecipitation(&daypart.MaxPrecipitationProbability.Value, DefaultHourlyForecastPrecipMentionProbabilityThreshold)
|
||||||
}
|
}
|
||||||
if daypart.PeakWindGust != nil {
|
if daypart.PeakWindGust != nil {
|
||||||
value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value)
|
value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value)
|
||||||
@@ -70,6 +171,208 @@ func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type daypartTemperaturePresentation struct {
|
||||||
|
Trend string
|
||||||
|
StartPhrase string
|
||||||
|
EndPhrase string
|
||||||
|
PeakPhrase string
|
||||||
|
SteadyPhrase string
|
||||||
|
}
|
||||||
|
|
||||||
|
type temperaturePoint struct {
|
||||||
|
value int
|
||||||
|
bandIndex int
|
||||||
|
phrase string
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
temperatureTrendRising = "rising"
|
||||||
|
temperatureTrendFalling = "falling"
|
||||||
|
temperatureTrendPeaking = "peaking"
|
||||||
|
temperatureTrendSteady = "steady"
|
||||||
|
)
|
||||||
|
|
||||||
|
func daypartTemperatureDisplay(daypart forecast.DaypartSummary) daypartTemperaturePresentation {
|
||||||
|
points := daypartTemperaturePoints(daypart.HourlyPeriods)
|
||||||
|
if len(points) == 0 {
|
||||||
|
return daypartSteadyTemperatureDisplay(temperaturePhraseF(daypart.Temperature))
|
||||||
|
}
|
||||||
|
if len(points) == 1 {
|
||||||
|
return daypartSteadyTemperatureDisplay(points[0].phrase)
|
||||||
|
}
|
||||||
|
|
||||||
|
first := points[0]
|
||||||
|
last := points[len(points)-1]
|
||||||
|
peak, peakIndex := peakTemperaturePoint(points)
|
||||||
|
if peakIndex > 0 && peakIndex < len(points)-1 && peak.bandIndex > first.bandIndex && peak.bandIndex > last.bandIndex {
|
||||||
|
return daypartTemperaturePresentation{
|
||||||
|
Trend: temperatureTrendPeaking,
|
||||||
|
PeakPhrase: peak.phrase,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case first.bandIndex < last.bandIndex:
|
||||||
|
return daypartTemperaturePresentation{
|
||||||
|
Trend: temperatureTrendRising,
|
||||||
|
StartPhrase: first.phrase,
|
||||||
|
EndPhrase: last.phrase,
|
||||||
|
}
|
||||||
|
case first.bandIndex > last.bandIndex:
|
||||||
|
return daypartTemperaturePresentation{
|
||||||
|
Trend: temperatureTrendFalling,
|
||||||
|
StartPhrase: first.phrase,
|
||||||
|
EndPhrase: last.phrase,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return daypartSteadyTemperatureDisplay(temperaturePhraseF(daypart.Temperature))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func daypartSteadyTemperatureDisplay(phrase string) daypartTemperaturePresentation {
|
||||||
|
if phrase == "" {
|
||||||
|
return daypartTemperaturePresentation{}
|
||||||
|
}
|
||||||
|
return daypartTemperaturePresentation{
|
||||||
|
Trend: temperatureTrendSteady,
|
||||||
|
SteadyPhrase: phrase,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func daypartTemperaturePoints(periods []weatherdata.ForecastPeriod) []temperaturePoint {
|
||||||
|
sorted := append([]weatherdata.ForecastPeriod(nil), periods...)
|
||||||
|
sort.SliceStable(sorted, func(i int, j int) bool {
|
||||||
|
return sorted[i].StartTime.Before(sorted[j].StartTime)
|
||||||
|
})
|
||||||
|
points := make([]temperaturePoint, 0, len(sorted))
|
||||||
|
for _, period := range sorted {
|
||||||
|
temperature := forecastPeriodTemperatureF(period)
|
||||||
|
if temperature == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rounded := roundedInt(temperature)
|
||||||
|
if rounded == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
points = append(points, temperaturePoint{
|
||||||
|
value: *rounded,
|
||||||
|
bandIndex: temperatureBandIndex(*rounded),
|
||||||
|
phrase: temperatureBandPhrase(*rounded),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return points
|
||||||
|
}
|
||||||
|
|
||||||
|
func peakTemperaturePoint(points []temperaturePoint) (temperaturePoint, int) {
|
||||||
|
peak := points[0]
|
||||||
|
peakIndex := 0
|
||||||
|
for index, point := range points[1:] {
|
||||||
|
if point.value > peak.value {
|
||||||
|
peak = point
|
||||||
|
peakIndex = index + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return peak, peakIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
func forecastPeriodTemperatureF(period weatherdata.ForecastPeriod) *float64 {
|
||||||
|
switch {
|
||||||
|
case period.TemperatureF != nil:
|
||||||
|
return period.TemperatureF
|
||||||
|
case period.TemperatureFMax != nil:
|
||||||
|
return period.TemperatureFMax
|
||||||
|
case period.TemperatureFMin != nil:
|
||||||
|
return period.TemperatureFMin
|
||||||
|
case period.TemperatureC != nil:
|
||||||
|
value := celsiusToFahrenheit(*period.TemperatureC)
|
||||||
|
return &value
|
||||||
|
case period.TemperatureCMax != nil:
|
||||||
|
value := celsiusToFahrenheit(*period.TemperatureCMax)
|
||||||
|
return &value
|
||||||
|
case period.TemperatureCMin != nil:
|
||||||
|
value := celsiusToFahrenheit(*period.TemperatureCMin)
|
||||||
|
return &value
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func celsiusToFahrenheit(value float64) float64 {
|
||||||
|
return value*9/5 + 32
|
||||||
|
}
|
||||||
|
|
||||||
|
func temperatureBandIndex(value int) int {
|
||||||
|
decade := (value / 10) * 10
|
||||||
|
remainder := value - decade
|
||||||
|
if remainder < 0 {
|
||||||
|
remainder = -remainder
|
||||||
|
}
|
||||||
|
band := 1
|
||||||
|
switch {
|
||||||
|
case remainder <= 3:
|
||||||
|
band = 0
|
||||||
|
case remainder >= 7:
|
||||||
|
band = 2
|
||||||
|
}
|
||||||
|
return decade*3 + band
|
||||||
|
}
|
||||||
|
|
||||||
|
func temperaturePhraseF(value forecast.Range) string {
|
||||||
|
if value.Min == nil && value.Max == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if value.Min != nil && value.Max != nil {
|
||||||
|
low := roundedInt(value.Min)
|
||||||
|
high := roundedInt(value.Max)
|
||||||
|
if low == nil || high == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
lowPhrase := temperatureBandPhrase(*low)
|
||||||
|
highPhrase := temperatureBandPhrase(*high)
|
||||||
|
if lowPhrase == highPhrase {
|
||||||
|
return lowPhrase
|
||||||
|
}
|
||||||
|
return lowPhrase + " to " + highPhrase
|
||||||
|
}
|
||||||
|
if value.Min != nil {
|
||||||
|
low := roundedInt(value.Min)
|
||||||
|
if low == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return temperatureBandPhrase(*low)
|
||||||
|
}
|
||||||
|
high := roundedInt(value.Max)
|
||||||
|
if high == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return temperatureBandPhrase(*high)
|
||||||
|
}
|
||||||
|
|
||||||
|
func temperatureBandPhrase(value int) string {
|
||||||
|
decade := (value / 10) * 10
|
||||||
|
remainder := value - decade
|
||||||
|
if remainder < 0 {
|
||||||
|
remainder = -remainder
|
||||||
|
}
|
||||||
|
qualifier := "mid"
|
||||||
|
switch {
|
||||||
|
case remainder <= 3:
|
||||||
|
qualifier = "low"
|
||||||
|
case remainder >= 7:
|
||||||
|
qualifier = "upper"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s %ds", qualifier, decade)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sentenceCase(value string) string {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
runes := []rune(trimmed)
|
||||||
|
runes[0] = unicode.ToUpper(runes[0])
|
||||||
|
return string(runes)
|
||||||
|
}
|
||||||
|
|
||||||
func multipleSummaryDates(summaries []forecast.DailySummary) bool {
|
func multipleSummaryDates(summaries []forecast.DailySummary) bool {
|
||||||
seen := map[string]struct{}{}
|
seen := map[string]struct{}{}
|
||||||
for _, summary := range summaries {
|
for _, summary := range summaries {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
|
|
||||||
func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.DailyToday)
|
ctx := derivedModuleContext(report.Daily)
|
||||||
|
|
||||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -67,7 +67,7 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
|||||||
|
|
||||||
func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
|
func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.DailyToday)
|
ctx := derivedModuleContext(report.Daily)
|
||||||
ctx.Derived.DailySummaries[0].NarrativePeriods = nil
|
ctx.Derived.DailySummaries[0].NarrativePeriods = nil
|
||||||
|
|
||||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
|
||||||
@@ -89,7 +89,7 @@ func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
|
|||||||
|
|
||||||
func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.DailyToday)
|
ctx := derivedModuleContext(report.Daily)
|
||||||
|
|
||||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -102,19 +102,31 @@ 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].PeriodBeginsHourLabel != "8:00 AM" || rainy.PrecipitationWindows[0].PeriodEnds != "2026-05-29 at 9:00 AM" || rainy.PrecipitationWindows[0].PeriodEndsHourLabel != "9:00 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 || rainy.PrecipitationWindows[0].MaxPopHourLabel != "8:00 AM" {
|
||||||
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[0].PrecipitationType != "showers" || rainy.PrecipitationWindows[0].ExpectationPhrase != "Showers likely." {
|
||||||
|
t.Fatalf("first precipitation window phrase = %#v, want showers likely", rainy.PrecipitationWindows[0])
|
||||||
|
}
|
||||||
|
if rainy.PrecipitationWindows[1].PeriodBegins != "2026-05-29 at 12:00 PM" || rainy.PrecipitationWindows[1].PeriodBeginsHourLabel != "12:00 PM" || rainy.PrecipitationWindows[1].PeriodEnds != "2026-05-29 at 2:00 PM" || rainy.PrecipitationWindows[1].PeriodEndsHourLabel != "2:00 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 || rainy.PrecipitationWindows[1].MaxPopHourLabel != "12:00 PM" {
|
||||||
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])
|
||||||
}
|
}
|
||||||
|
if rainy.PrecipitationWindows[1].PrecipitationType != "showers and thunderstorms" || rainy.PrecipitationWindows[1].ExpectationPhrase != "Expect showers and thunderstorms." {
|
||||||
|
t.Fatalf("second precipitation window phrase = %#v, want expect showers and thunderstorms", rainy.PrecipitationWindows[1])
|
||||||
|
}
|
||||||
data, err := json.Marshal(output.Value)
|
data, err := json.Marshal(output.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("marshal precip timing: %v", err)
|
t.Fatalf("marshal precip timing: %v", err)
|
||||||
}
|
}
|
||||||
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") || !strings.Contains(string(data), "period_begins_hour_label") || !strings.Contains(string(data), "max_pop_hour_label") {
|
||||||
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), "precipitation_type") || !strings.Contains(string(data), "expectation_phrase") {
|
||||||
|
t.Fatalf("precip timing json = %s, want precipitation type and expectation phrase", 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,9 +145,119 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPrecipTimingModuleBuildsExpectationPhrases(t *testing.T) {
|
||||||
|
now := mustParseModuleTime("2026-05-29T08:00:00-05:00")
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
maxPop float64
|
||||||
|
descriptions []string
|
||||||
|
wantType string
|
||||||
|
wantPhrase string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "chance lower bound",
|
||||||
|
maxPop: 40,
|
||||||
|
descriptions: []string{"Scattered showers"},
|
||||||
|
wantType: "showers",
|
||||||
|
wantPhrase: "Chance of showers.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "chance upper bound",
|
||||||
|
maxPop: 49,
|
||||||
|
descriptions: []string{"Rain possible"},
|
||||||
|
wantType: "rain",
|
||||||
|
wantPhrase: "Chance of rain.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "likely lower bound",
|
||||||
|
maxPop: 50,
|
||||||
|
descriptions: []string{"Drizzle"},
|
||||||
|
wantType: "drizzle",
|
||||||
|
wantPhrase: "Drizzle likely.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "likely upper bound",
|
||||||
|
maxPop: 69,
|
||||||
|
descriptions: []string{"Freezing rain"},
|
||||||
|
wantType: "freezing rain",
|
||||||
|
wantPhrase: "Freezing rain likely.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "expect lower bound",
|
||||||
|
maxPop: 70,
|
||||||
|
descriptions: []string{"Snow"},
|
||||||
|
wantType: "snow",
|
||||||
|
wantPhrase: "Expect snow.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "showers and thunderstorms preferred",
|
||||||
|
maxPop: 100,
|
||||||
|
descriptions: []string{"Showers likely", "Thunderstorms possible"},
|
||||||
|
wantType: "showers and thunderstorms",
|
||||||
|
wantPhrase: "Expect showers and thunderstorms.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "thunderstorms only",
|
||||||
|
maxPop: 80,
|
||||||
|
descriptions: []string{"Thunderstorms"},
|
||||||
|
wantType: "thunderstorms",
|
||||||
|
wantPhrase: "Expect thunderstorms.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown fallback",
|
||||||
|
maxPop: 95,
|
||||||
|
descriptions: []string{"Unsettled conditions"},
|
||||||
|
wantType: "precipitation",
|
||||||
|
wantPhrase: "Expect precipitation.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
value := precipTimingValue(forecast.PrecipTiming{
|
||||||
|
ProbabilityThreshold: forecast.DefaultPrecipWindowProbabilityThreshold,
|
||||||
|
PrecipitationWindows: []forecast.PrecipitationWindow{
|
||||||
|
{
|
||||||
|
Start: now,
|
||||||
|
MaxPrecipitationProbability: forecast.TimedValue{
|
||||||
|
Value: tt.maxPop,
|
||||||
|
Time: now,
|
||||||
|
},
|
||||||
|
ProbabilityThreshold: forecast.DefaultPrecipWindowProbabilityThreshold,
|
||||||
|
TextDescriptions: tt.descriptions,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, "America/Chicago")
|
||||||
|
if len(value.PrecipitationWindows) != 1 {
|
||||||
|
t.Fatalf("PrecipitationWindows = %#v, want one window", value.PrecipitationWindows)
|
||||||
|
}
|
||||||
|
window := value.PrecipitationWindows[0]
|
||||||
|
if window.PrecipitationType != tt.wantType || window.ExpectationPhrase != tt.wantPhrase {
|
||||||
|
t.Fatalf("window = %#v, want type %q and phrase %q", window, tt.wantType, tt.wantPhrase)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.Daily)
|
||||||
|
|
||||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -150,14 +272,20 @@ 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.DisplayName != "Morning" || morning.DominantConditionLower != "showers" || morning.DominantConditionDisplay != "Showers" || morning.TemperaturePhraseF != "upper 50s" || morning.TemperatureTrend != "steady" || morning.TemperatureSteadyPhraseF != "upper 50s" || !morning.MentionPrecipitation || morning.MaxPopTimeLabel != "6:00 AM" {
|
||||||
t.Fatalf("morning period = %q/%q, want friendly local date and period labels", morning.Date, morning.Period)
|
t.Fatalf("morning presentation fields = %#v, want display facts for template composition", morning)
|
||||||
|
}
|
||||||
|
if morning.Date != "2026-05-29" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" {
|
||||||
|
t.Fatalf("morning period = %q/%q/%q, want friendly local date and period labels", morning.Date, morning.PeriodBegins, morning.PeriodEnds)
|
||||||
|
}
|
||||||
|
overnight := value["overnight"]
|
||||||
|
if overnight.MentionPrecipitation {
|
||||||
|
t.Fatalf("overnight MentionPrecipitation = true, want false below threshold")
|
||||||
}
|
}
|
||||||
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 {
|
||||||
t.Fatalf("afternoon = %#v, want heat and wind hazard values", afternoon)
|
t.Fatalf("afternoon = %#v, want heat and wind hazard values", afternoon)
|
||||||
}
|
}
|
||||||
overnight := value["overnight"]
|
|
||||||
if !overnight.Cold {
|
if !overnight.Cold {
|
||||||
t.Fatalf("overnight = %#v, want cold hazard", overnight)
|
t.Fatalf("overnight = %#v, want cold hazard", overnight)
|
||||||
}
|
}
|
||||||
@@ -166,19 +294,236 @@ 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", "display_name", "period_begins", "period_ends", "temp_range_f", "temperature_phrase_f", "temperature_trend", "temperature_steady_phrase_f", "max_pop_percent", "max_pop_time_label", "mention_precipitation", "max_wind_gust_mph", "dominant_condition", "dominant_condition_lower", "dominant_condition_display"} {
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDerivedDaypartSummariesPromptExportOmitsTemplateHelpers(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := derivedModuleContext(report.Daily)
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
richText := mustMarshalModuleJSON(t, output.Value)
|
||||||
|
for _, field := range []string{"temperature_phrase_f", "dominant_condition_lower", "dominant_condition_display", "max_pop_time_label"} {
|
||||||
|
if !strings.Contains(richText, field) {
|
||||||
|
t.Fatalf("rich daypart json = %s, want helper field %s", richText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt := moduleDataPackageValue[map[string]DerivedDaypartSummaryPromptExport](t, output)
|
||||||
|
morning, ok := prompt["morning"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("daypart prompt keys = %#v, want morning", prompt)
|
||||||
|
}
|
||||||
|
if morning.Date != "2026-05-29" || morning.DisplayName != "Morning" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" {
|
||||||
|
t.Fatalf("morning prompt period = %#v, want date/display/period labels", morning)
|
||||||
|
}
|
||||||
|
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 || morning.MaxPopTime != "6:00 AM" || !morning.MentionPrecipitation {
|
||||||
|
t.Fatalf("morning prompt precip/temp = %#v, want factual prompt fields with friendly max pop time", morning)
|
||||||
|
}
|
||||||
|
if morning.DominantCondition != "Showers" || morning.TemperatureTrend != "steady" || morning.TemperatureSteadyPhraseF != "upper 50s" {
|
||||||
|
t.Fatalf("morning prompt condition/trend = %#v, want condition and trend fields", morning)
|
||||||
|
}
|
||||||
|
if len(morning.NotableConditions) == 0 || morning.NotableConditions[0] != "Showers" {
|
||||||
|
t.Fatalf("morning prompt notable conditions = %#v, want copied conditions", morning.NotableConditions)
|
||||||
|
}
|
||||||
|
afternoon := prompt["afternoon"]
|
||||||
|
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 || afternoon.RelevantAlertCount != 1 {
|
||||||
|
t.Fatalf("afternoon prompt = %#v, want hazard, wind, and alert fields", afternoon)
|
||||||
|
}
|
||||||
|
|
||||||
|
promptText := mustMarshalModuleJSON(t, output.DataPackageValue())
|
||||||
|
for _, field := range []string{"date", "display_name", "period_begins", "period_ends", "temp_range_f", "max_pop_percent", "max_pop_time", "mention_precipitation", "max_wind_gust_mph", "dominant_condition", "temperature_trend", "temperature_steady_phrase_f", "notable_conditions", "relevant_alert_count"} {
|
||||||
|
if !strings.Contains(promptText, field) {
|
||||||
|
t.Fatalf("daypart prompt json = %s, want field %s", promptText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, field := range []string{"temperature_phrase_f", "dominant_condition_lower", "dominant_condition_display", "max_pop_time_label"} {
|
||||||
|
if strings.Contains(promptText, field) {
|
||||||
|
t.Fatalf("daypart prompt json = %s, want omitted helper field %s", promptText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDerivedDaypartPromptExportTemperatureTrends(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
temps []float64
|
||||||
|
wantTrend string
|
||||||
|
wantStart string
|
||||||
|
wantEnd string
|
||||||
|
wantPeak string
|
||||||
|
wantSteady string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "rising",
|
||||||
|
temps: []float64{58, 68},
|
||||||
|
wantTrend: "rising",
|
||||||
|
wantStart: "upper 50s",
|
||||||
|
wantEnd: "upper 60s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "falling",
|
||||||
|
temps: []float64{65, 58},
|
||||||
|
wantTrend: "falling",
|
||||||
|
wantStart: "mid 60s",
|
||||||
|
wantEnd: "upper 50s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "peaking",
|
||||||
|
temps: []float64{62, 78, 65},
|
||||||
|
wantTrend: "peaking",
|
||||||
|
wantPeak: "upper 70s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "steady",
|
||||||
|
temps: []float64{77, 78},
|
||||||
|
wantTrend: "steady",
|
||||||
|
wantSteady: "upper 70s",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
rich := derivedDaypartSummaryValue(derivedDaypartWithTemperatures(test.name, "2026-05-29T12:00:00-05:00", "sunny", test.temps...), "America/Chicago")
|
||||||
|
prompt := derivedDaypartSummaryPromptValue(rich)
|
||||||
|
if prompt.TemperatureTrend != test.wantTrend ||
|
||||||
|
prompt.TemperatureStartPhraseF != test.wantStart ||
|
||||||
|
prompt.TemperatureEndPhraseF != test.wantEnd ||
|
||||||
|
prompt.TemperaturePeakPhraseF != test.wantPeak ||
|
||||||
|
prompt.TemperatureSteadyPhraseF != test.wantSteady {
|
||||||
|
t.Fatalf("prompt temperature presentation = %#v", prompt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDerivedDaypartPromptExportMaxPopTimeFallback(t *testing.T) {
|
||||||
|
withLabel := derivedDaypartSummaryPromptValue(DerivedDaypartSummaryModule{
|
||||||
|
MaxPopTime: "6 AM",
|
||||||
|
MaxPopTimeLabel: "6:00 AM",
|
||||||
|
})
|
||||||
|
if withLabel.MaxPopTime != "6:00 AM" {
|
||||||
|
t.Fatalf("MaxPopTime with label = %q, want friendly label", withLabel.MaxPopTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
withoutLabel := derivedDaypartSummaryPromptValue(DerivedDaypartSummaryModule{
|
||||||
|
MaxPopTime: "6 AM",
|
||||||
|
})
|
||||||
|
if withoutLabel.MaxPopTime != "6 AM" {
|
||||||
|
t.Fatalf("MaxPopTime without label = %q, want fallback time", withoutLabel.MaxPopTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDerivedDaypartTemperaturePresentationFields(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
temps []float64
|
||||||
|
wantTrend string
|
||||||
|
wantStart string
|
||||||
|
wantEnd string
|
||||||
|
wantPeak string
|
||||||
|
wantSteady string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "rising",
|
||||||
|
temps: []float64{58, 68},
|
||||||
|
wantTrend: "rising",
|
||||||
|
wantStart: "upper 50s",
|
||||||
|
wantEnd: "upper 60s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "falling",
|
||||||
|
temps: []float64{65, 58},
|
||||||
|
wantTrend: "falling",
|
||||||
|
wantStart: "mid 60s",
|
||||||
|
wantEnd: "upper 50s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "peaking",
|
||||||
|
temps: []float64{62, 78, 65},
|
||||||
|
wantTrend: "peaking",
|
||||||
|
wantPeak: "upper 70s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "steady same band",
|
||||||
|
temps: []float64{77, 78},
|
||||||
|
wantTrend: "steady",
|
||||||
|
wantSteady: "upper 70s",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
summary := derivedDaypartWithTemperatures("afternoon", "2026-05-29T12:00:00-05:00", "sunny", test.temps...)
|
||||||
|
value := derivedDaypartSummaryValue(summary, "America/Chicago")
|
||||||
|
if value.DominantConditionDisplay != "Sunny" {
|
||||||
|
t.Fatalf("DominantConditionDisplay = %q, want Sunny", value.DominantConditionDisplay)
|
||||||
|
}
|
||||||
|
if value.TemperatureTrend != test.wantTrend ||
|
||||||
|
value.TemperatureStartPhraseF != test.wantStart ||
|
||||||
|
value.TemperatureEndPhraseF != test.wantEnd ||
|
||||||
|
value.TemperaturePeakPhraseF != test.wantPeak ||
|
||||||
|
value.TemperatureSteadyPhraseF != test.wantSteady {
|
||||||
|
t.Fatalf("temperature presentation = %#v", value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemperaturePhraseF(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value forecast.Range
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "single low band",
|
||||||
|
value: forecast.Range{Min: floatPtr(71), Max: floatPtr(73)},
|
||||||
|
want: "low 70s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single upper value",
|
||||||
|
value: forecast.Range{Min: floatPtr(68), Max: floatPtr(68)},
|
||||||
|
want: "upper 60s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "range across bands",
|
||||||
|
value: forecast.Range{Min: floatPtr(68), Max: floatPtr(75)},
|
||||||
|
want: "upper 60s to mid 70s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "max only",
|
||||||
|
value: forecast.Range{Max: floatPtr(84)},
|
||||||
|
want: "mid 80s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
value: forecast.Range{},
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if got := temperaturePhraseF(test.value); got != test.want {
|
||||||
|
t.Fatalf("temperaturePhraseF() = %q, want %q", got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {
|
func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.DailyTomorrow)
|
ctx := derivedModuleContext(report.Tomorrow)
|
||||||
|
|
||||||
outdoorOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows})
|
outdoorOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -191,6 +536,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 {
|
||||||
@@ -209,9 +560,154 @@ func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDailyPlanningModulePackagesPlanningFields(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := dailyModuleContext()
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(daily planning) error = %v", err)
|
||||||
|
}
|
||||||
|
if output.ID != module.DailyPlanning || output.StanzaName != "daily_planning" {
|
||||||
|
t.Fatalf("output = %#v, want daily planning stanza", output)
|
||||||
|
}
|
||||||
|
planning := moduleValue[DailyPlanningModule](t, output)
|
||||||
|
if len(planning.MorningReadiness) == 0 ||
|
||||||
|
len(planning.CommuteSchoolWorkdayConcerns) == 0 ||
|
||||||
|
len(planning.OvernightChangeWatch) == 0 {
|
||||||
|
t.Fatalf("daily planning = %#v, want populated planning fields", planning)
|
||||||
|
}
|
||||||
|
if !containsString(planning.MorningReadiness, "Morning precipitation chance peaks near 60%.") {
|
||||||
|
t.Fatalf("MorningReadiness = %#v, want precipitation readiness note", planning.MorningReadiness)
|
||||||
|
}
|
||||||
|
if !containsString(planning.CommuteSchoolWorkdayConcerns, "Afternoon alert overlap needs attention.") {
|
||||||
|
t.Fatalf("CommuteSchoolWorkdayConcerns = %#v, want alert-overlap concern", planning.CommuteSchoolWorkdayConcerns)
|
||||||
|
}
|
||||||
|
if !containsString(planning.OvernightChangeWatch, "Watch for forecast timing or intensity adjustments overnight.") {
|
||||||
|
t.Fatalf("OvernightChangeWatch = %#v, want overnight fallback note", planning.OvernightChangeWatch)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(output.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal daily planning: %v", err)
|
||||||
|
}
|
||||||
|
jsonText := string(data)
|
||||||
|
for _, field := range []string{"morning_readiness", "commute_school_workday_concerns", "overnight_change_watch"} {
|
||||||
|
if !strings.Contains(jsonText, field) {
|
||||||
|
t.Fatalf("daily planning json = %s, want field %s", jsonText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(jsonText, "tomorrow_planning") || strings.Contains(jsonText, "morningReadiness") {
|
||||||
|
t.Fatalf("daily planning json = %s, want daily snake_case fields only", jsonText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDailyPlanningModuleRejectsUnsupportedReports(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
for _, id := range []report.ID{report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm} {
|
||||||
|
t.Run(string(id), func(t *testing.T) {
|
||||||
|
ctx := derivedModuleContext(id)
|
||||||
|
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `module "daily_planning" is not compatible with report`) {
|
||||||
|
t.Fatalf("BuildModule(%s) error = %v, want incompatible report", id, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDailyPlanningModuleHandlesMissingDailySummary(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := dailyModuleContext()
|
||||||
|
ctx.Derived.DailySummaries = nil
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(daily planning) error = %v", err)
|
||||||
|
}
|
||||||
|
planning := moduleValue[DailyPlanningModule](t, output)
|
||||||
|
if len(planning.MorningReadiness) != 0 ||
|
||||||
|
len(planning.CommuteSchoolWorkdayConcerns) != 0 ||
|
||||||
|
len(planning.OvernightChangeWatch) != 0 {
|
||||||
|
t.Fatalf("daily planning = %#v, want empty output without daily summary", planning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTodayPlanningModulePackagesPlanningFields(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := todayModuleContext()
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(today planning) error = %v", err)
|
||||||
|
}
|
||||||
|
if output.ID != module.TodayPlanning || output.StanzaName != "today_planning" {
|
||||||
|
t.Fatalf("output = %#v, want today planning stanza", output)
|
||||||
|
}
|
||||||
|
planning := moduleValue[TodayPlanningModule](t, output)
|
||||||
|
if len(planning.MorningReadiness) == 0 ||
|
||||||
|
len(planning.CommuteSchoolWorkdayConcerns) == 0 ||
|
||||||
|
len(planning.OutdoorPlanning) == 0 ||
|
||||||
|
len(planning.LateDayChangeWatch) == 0 {
|
||||||
|
t.Fatalf("today planning = %#v, want populated planning fields", planning)
|
||||||
|
}
|
||||||
|
if !containsString(planning.MorningReadiness, "Morning precipitation chance peaks near 60%.") {
|
||||||
|
t.Fatalf("MorningReadiness = %#v, want precipitation readiness note", planning.MorningReadiness)
|
||||||
|
}
|
||||||
|
if !containsString(planning.OutdoorPlanning, "Best outdoor window: Overnight (cold risk).") ||
|
||||||
|
!containsString(planning.OutdoorPlanning, "Toughest outdoor window: Afternoon (high precipitation chance, gusty wind, alert overlap, heat risk).") {
|
||||||
|
t.Fatalf("OutdoorPlanning = %#v, want deterministic best and toughest windows", planning.OutdoorPlanning)
|
||||||
|
}
|
||||||
|
if !containsString(planning.LateDayChangeWatch, "Afternoon precipitation timing may shift; current peak is near 80%.") {
|
||||||
|
t.Fatalf("LateDayChangeWatch = %#v, want late-day change note", planning.LateDayChangeWatch)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(output.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal today planning: %v", err)
|
||||||
|
}
|
||||||
|
jsonText := string(data)
|
||||||
|
for _, field := range []string{"morning_readiness", "commute_school_workday_concerns", "outdoor_planning", "late_day_change_watch"} {
|
||||||
|
if !strings.Contains(jsonText, field) {
|
||||||
|
t.Fatalf("today planning json = %s, want field %s", jsonText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(jsonText, "morningReadiness") || strings.Contains(jsonText, "lateDayChangeWatch") {
|
||||||
|
t.Fatalf("today planning json = %s, want snake_case fields", jsonText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTodayPlanningModuleRejectsUnsupportedReports(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
for _, id := range []report.ID{report.Tomorrow, report.Daily} {
|
||||||
|
t.Run(string(id), func(t *testing.T) {
|
||||||
|
ctx := derivedModuleContext(id)
|
||||||
|
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `module "today_planning" is not compatible with report`) {
|
||||||
|
t.Fatalf("BuildModule(%s) error = %v, want incompatible report", id, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTodayPlanningModuleHandlesMissingDailySummary(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := todayModuleContext()
|
||||||
|
ctx.Derived.DailySummaries = nil
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(today planning) error = %v", err)
|
||||||
|
}
|
||||||
|
planning := moduleValue[TodayPlanningModule](t, output)
|
||||||
|
if len(planning.MorningReadiness) != 0 ||
|
||||||
|
len(planning.CommuteSchoolWorkdayConcerns) != 0 ||
|
||||||
|
len(planning.OutdoorPlanning) != 0 ||
|
||||||
|
len(planning.LateDayChangeWatch) != 0 {
|
||||||
|
t.Fatalf("today planning = %#v, want empty output without daily summary", planning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDerivedModulesHandleMissingData(t *testing.T) {
|
func TestDerivedModulesHandleMissingData(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.DailyToday)
|
ctx := derivedModuleContext(report.Daily)
|
||||||
ctx.Derived.DailySummaries = nil
|
ctx.Derived.DailySummaries = nil
|
||||||
ctx.Derived.DaypartSummaries = nil
|
ctx.Derived.DaypartSummaries = nil
|
||||||
|
|
||||||
@@ -302,6 +798,26 @@ func derivedModuleContext(id report.ID) ModuleContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func todayModuleContext() ModuleContext {
|
||||||
|
ctx := derivedModuleContext(report.Daily)
|
||||||
|
ctx.Resolved.Definition = report.Definition{
|
||||||
|
ID: report.Today,
|
||||||
|
Name: "Today Report",
|
||||||
|
PromptID: "weather.today_generated_text",
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func dailyModuleContext() ModuleContext {
|
||||||
|
ctx := derivedModuleContext(report.Tomorrow)
|
||||||
|
ctx.Resolved.Definition = report.Definition{
|
||||||
|
ID: report.Daily,
|
||||||
|
Name: "Daily Report",
|
||||||
|
PromptID: "weather.daily_generated_text",
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
func derivedDaypart(name string, start string, end string, text string, temperature float64, apparent *float64, precip float64, gust float64) forecast.DaypartSummary {
|
func derivedDaypart(name string, start string, end string, text string, temperature float64, apparent *float64, precip float64, gust float64) forecast.DaypartSummary {
|
||||||
hour := derivedHour(start, text, precip, temperature, apparent, gust)
|
hour := derivedHour(start, text, precip, temperature, apparent, gust)
|
||||||
return forecast.SummarizeDaypart(name, timeutil.Period{
|
return forecast.SummarizeDaypart(name, timeutil.Period{
|
||||||
@@ -310,6 +826,24 @@ func derivedDaypart(name string, start string, end string, text string, temperat
|
|||||||
}, []weatherdata.ForecastPeriod{hour})
|
}, []weatherdata.ForecastPeriod{hour})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func derivedDaypartWithTemperatures(name string, start string, text string, temperatures ...float64) forecast.DaypartSummary {
|
||||||
|
startTime := mustParseModuleTime(start)
|
||||||
|
periods := make([]weatherdata.ForecastPeriod, 0, len(temperatures))
|
||||||
|
for index, temperature := range temperatures {
|
||||||
|
periodStart := startTime.Add(time.Duration(index) * time.Hour)
|
||||||
|
periods = append(periods, weatherdata.ForecastPeriod{
|
||||||
|
StartTime: periodStart,
|
||||||
|
EndTime: periodStart.Add(time.Hour),
|
||||||
|
TextDescription: text,
|
||||||
|
TemperatureF: floatPtr(temperature),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return forecast.SummarizeDaypart(name, timeutil.Period{
|
||||||
|
Start: startTime,
|
||||||
|
End: startTime.Add(time.Duration(len(temperatures)) * time.Hour),
|
||||||
|
}, periods)
|
||||||
|
}
|
||||||
|
|
||||||
func derivedHour(start string, text string, precip float64, temperature float64, apparent *float64, gust float64) weatherdata.ForecastPeriod {
|
func derivedHour(start string, text string, precip float64, temperature float64, apparent *float64, gust float64) weatherdata.ForecastPeriod {
|
||||||
startTime := mustParseModuleTime(start)
|
startTime := mustParseModuleTime(start)
|
||||||
endTime := startTime.Add(time.Hour)
|
endTime := startTime.Add(time.Hour)
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
package briefing
|
package briefing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const DefaultHourlyForecastPrecipMentionProbabilityThreshold = 20
|
||||||
|
|
||||||
type HourlyForecastModule struct {
|
type HourlyForecastModule struct {
|
||||||
Product string `json:"product,omitempty"`
|
Product string `json:"product,omitempty"`
|
||||||
IssuedAt time.Time `json:"issued_at,omitempty"`
|
IssuedAt time.Time `json:"issued_at,omitempty"`
|
||||||
@@ -16,9 +20,57 @@ type HourlyForecastModule struct {
|
|||||||
Periods []HourlyForecastPeriod `json:"periods,omitempty"`
|
Periods []HourlyForecastPeriod `json:"periods,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HourlyForecastPromptExport struct {
|
||||||
|
Product string `json:"product,omitempty"`
|
||||||
|
IssuedAt time.Time `json:"issued_at,omitempty"`
|
||||||
|
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||||
|
SourceLocation string `json:"source_location,omitempty"`
|
||||||
|
SourceLocationID string `json:"source_location_id,omitempty"`
|
||||||
|
Periods []HourlyForecastPromptPeriod `json:"periods,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type HourlyForecastPeriod struct {
|
type HourlyForecastPeriod struct {
|
||||||
StartTime string `json:"start_time,omitempty"`
|
HourLabel string `json:"hour_label,omitempty"`
|
||||||
EndTime string `json:"end_time,omitempty"`
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
IsDay *bool `json:"is_day,omitempty"`
|
||||||
|
ConditionCode *int `json:"condition_code,omitempty"`
|
||||||
|
TextDescription string `json:"text_description,omitempty"`
|
||||||
|
TextDescriptionLower string `json:"text_description_lower,omitempty"`
|
||||||
|
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||||
|
TemperatureF *float64 `json:"temperature_f,omitempty"`
|
||||||
|
TemperatureCMin *float64 `json:"temperature_c_min,omitempty"`
|
||||||
|
TemperatureFMin *float64 `json:"temperature_f_min,omitempty"`
|
||||||
|
TemperatureCMax *float64 `json:"temperature_c_max,omitempty"`
|
||||||
|
TemperatureFMax *float64 `json:"temperature_f_max,omitempty"`
|
||||||
|
DewpointC *float64 `json:"dewpoint_c,omitempty"`
|
||||||
|
DewpointF *float64 `json:"dewpoint_f,omitempty"`
|
||||||
|
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
|
||||||
|
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
|
||||||
|
WindGustKmh *float64 `json:"wind_gust_kmh,omitempty"`
|
||||||
|
WindGustMph *float64 `json:"wind_gust_mph,omitempty"`
|
||||||
|
WindDirection string `json:"wind_direction,omitempty"`
|
||||||
|
BarometricPressurePa *float64 `json:"barometric_pressure_pa,omitempty"`
|
||||||
|
BarometricPressureInHg *float64 `json:"barometric_pressure_in_hg,omitempty"`
|
||||||
|
VisibilityMeters *float64 `json:"visibility_meters,omitempty"`
|
||||||
|
VisibilityMiles *float64 `json:"visibility_miles,omitempty"`
|
||||||
|
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"`
|
||||||
|
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"`
|
||||||
|
CloudCoverPercent *float64 `json:"cloud_cover_percent,omitempty"`
|
||||||
|
ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"`
|
||||||
|
MentionPrecipitation bool `json:"mention_precipitation,omitempty"`
|
||||||
|
PrecipitationAmountMm *float64 `json:"precipitation_amount_mm,omitempty"`
|
||||||
|
PrecipitationAmountIn *float64 `json:"precipitation_amount_in,omitempty"`
|
||||||
|
SnowfallDepthMM *float64 `json:"snowfall_depth_mm,omitempty"`
|
||||||
|
SnowfallDepthIn *float64 `json:"snowfall_depth_in,omitempty"`
|
||||||
|
UVIndex *float64 `json:"uv_index,omitempty"`
|
||||||
|
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HourlyForecastPromptPeriod struct {
|
||||||
|
PeriodBegins string `json:"period_begins,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"`
|
||||||
@@ -71,12 +123,30 @@ func buildHourlyForecastModule(ctx ModuleContext, _ any) (*module.Output, error)
|
|||||||
return &module.Output{ID: module.HourlyForecast, StanzaName: "hourly_forecast", Value: value}, nil
|
return &module.Output{ID: module.HourlyForecast, StanzaName: "hourly_forecast", Value: value}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod {
|
func exportHourlyForecastPromptValue(value any) (any, error) {
|
||||||
out := make([]HourlyForecastPeriod, 0, len(periods))
|
rich, ok := value.(HourlyForecastModule)
|
||||||
|
if !ok {
|
||||||
|
return nil, unexpectedPromptExportValue(value, HourlyForecastModule{})
|
||||||
|
}
|
||||||
|
return HourlyForecastPromptExport{
|
||||||
|
Product: rich.Product,
|
||||||
|
IssuedAt: rich.IssuedAt,
|
||||||
|
UpdatedAt: copyTime(rich.UpdatedAt),
|
||||||
|
SourceLocation: rich.SourceLocation,
|
||||||
|
SourceLocationID: rich.SourceLocationID,
|
||||||
|
Periods: hourlyForecastPromptPeriods(rich.Periods),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hourlyForecastPromptPeriods(periods []HourlyForecastPeriod) []HourlyForecastPromptPeriod {
|
||||||
|
if len(periods) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]HourlyForecastPromptPeriod, 0, len(periods))
|
||||||
for _, period := range periods {
|
for _, period := range periods {
|
||||||
out = append(out, HourlyForecastPeriod{
|
out = append(out, HourlyForecastPromptPeriod{
|
||||||
StartTime: friendlyDateTimeLabel(period.StartTime, timezone),
|
PeriodBegins: period.PeriodBegins,
|
||||||
EndTime: friendlyDateTimeLabel(period.EndTime, timezone),
|
PeriodEnds: period.PeriodEnds,
|
||||||
Name: period.Name,
|
Name: period.Name,
|
||||||
IsDay: copyBool(period.IsDay),
|
IsDay: copyBool(period.IsDay),
|
||||||
ConditionCode: copyInt(period.ConditionCode),
|
ConditionCode: copyInt(period.ConditionCode),
|
||||||
@@ -93,6 +163,55 @@ func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string
|
|||||||
WindSpeedMph: copyFloat(period.WindSpeedMph),
|
WindSpeedMph: copyFloat(period.WindSpeedMph),
|
||||||
WindGustKmh: copyFloat(period.WindGustKmh),
|
WindGustKmh: copyFloat(period.WindGustKmh),
|
||||||
WindGustMph: copyFloat(period.WindGustMph),
|
WindGustMph: copyFloat(period.WindGustMph),
|
||||||
|
WindDirection: period.WindDirection,
|
||||||
|
BarometricPressurePa: copyFloat(period.BarometricPressurePa),
|
||||||
|
BarometricPressureInHg: copyFloat(period.BarometricPressureInHg),
|
||||||
|
VisibilityMeters: copyFloat(period.VisibilityMeters),
|
||||||
|
VisibilityMiles: copyFloat(period.VisibilityMiles),
|
||||||
|
ApparentTemperatureC: copyFloat(period.ApparentTemperatureC),
|
||||||
|
ApparentTemperatureF: copyFloat(period.ApparentTemperatureF),
|
||||||
|
CloudCoverPercent: copyFloat(period.CloudCoverPercent),
|
||||||
|
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
|
||||||
|
PrecipitationAmountMm: copyFloat(period.PrecipitationAmountMm),
|
||||||
|
PrecipitationAmountIn: copyFloat(period.PrecipitationAmountIn),
|
||||||
|
SnowfallDepthMM: copyFloat(period.SnowfallDepthMM),
|
||||||
|
SnowfallDepthIn: copyFloat(period.SnowfallDepthIn),
|
||||||
|
UVIndex: copyFloat(period.UVIndex),
|
||||||
|
RelativeHumidityPercent: copyFloat(period.RelativeHumidityPercent),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod {
|
||||||
|
return hourlyForecastPeriodsWithPrecipMentionThreshold(periods, timezone, DefaultHourlyForecastPrecipMentionProbabilityThreshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hourlyForecastPeriodsWithPrecipMentionThreshold(periods []weatherdata.ForecastPeriod, timezone string, threshold float64) []HourlyForecastPeriod {
|
||||||
|
out := make([]HourlyForecastPeriod, 0, len(periods))
|
||||||
|
for _, period := range periods {
|
||||||
|
validPeriod := timeutil.Period{Start: period.StartTime, End: period.EndTime}
|
||||||
|
out = append(out, HourlyForecastPeriod{
|
||||||
|
HourLabel: hourMinuteLabel(period.StartTime, timezone),
|
||||||
|
PeriodBegins: friendlyPeriodBeginsLabel(validPeriod, timezone),
|
||||||
|
PeriodEnds: friendlyPeriodEndsLabel(validPeriod, timezone),
|
||||||
|
Name: period.Name,
|
||||||
|
IsDay: copyBool(period.IsDay),
|
||||||
|
ConditionCode: copyInt(period.ConditionCode),
|
||||||
|
TextDescription: period.TextDescription,
|
||||||
|
TextDescriptionLower: strings.ToLower(period.TextDescription),
|
||||||
|
TemperatureC: copyFloat(period.TemperatureC),
|
||||||
|
TemperatureF: copyFloat(period.TemperatureF),
|
||||||
|
TemperatureCMin: copyFloat(period.TemperatureCMin),
|
||||||
|
TemperatureFMin: copyFloat(period.TemperatureFMin),
|
||||||
|
TemperatureCMax: copyFloat(period.TemperatureCMax),
|
||||||
|
TemperatureFMax: copyFloat(period.TemperatureFMax),
|
||||||
|
DewpointC: copyFloat(period.DewpointC),
|
||||||
|
DewpointF: copyFloat(period.DewpointF),
|
||||||
|
WindSpeedKmh: copyFloat(period.WindSpeedKmh),
|
||||||
|
WindSpeedMph: copyFloat(period.WindSpeedMph),
|
||||||
|
WindGustKmh: copyFloat(period.WindGustKmh),
|
||||||
|
WindGustMph: copyFloat(period.WindGustMph),
|
||||||
WindDirection: windDirectionLabel(period.WindDirectionDegrees),
|
WindDirection: windDirectionLabel(period.WindDirectionDegrees),
|
||||||
BarometricPressurePa: copyFloat(period.BarometricPressurePa),
|
BarometricPressurePa: copyFloat(period.BarometricPressurePa),
|
||||||
BarometricPressureInHg: copyFloat(period.BarometricPressureInHg),
|
BarometricPressureInHg: copyFloat(period.BarometricPressureInHg),
|
||||||
@@ -102,6 +221,7 @@ func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string
|
|||||||
ApparentTemperatureF: copyFloat(period.ApparentTemperatureF),
|
ApparentTemperatureF: copyFloat(period.ApparentTemperatureF),
|
||||||
CloudCoverPercent: copyFloat(period.CloudCoverPercent),
|
CloudCoverPercent: copyFloat(period.CloudCoverPercent),
|
||||||
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
|
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
|
||||||
|
MentionPrecipitation: mentionHourlyForecastPrecipitation(period.ProbabilityOfPrecipitationPercent, threshold),
|
||||||
PrecipitationAmountMm: copyFloat(period.PrecipitationAmountMm),
|
PrecipitationAmountMm: copyFloat(period.PrecipitationAmountMm),
|
||||||
PrecipitationAmountIn: copyFloat(period.PrecipitationAmountIn),
|
PrecipitationAmountIn: copyFloat(period.PrecipitationAmountIn),
|
||||||
SnowfallDepthMM: copyFloat(period.SnowfallDepthMM),
|
SnowfallDepthMM: copyFloat(period.SnowfallDepthMM),
|
||||||
@@ -113,6 +233,10 @@ func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mentionHourlyForecastPrecipitation(probability *float64, threshold float64) bool {
|
||||||
|
return probability != nil && *probability >= threshold
|
||||||
|
}
|
||||||
|
|
||||||
func (v HourlyForecastModule) isEmpty() bool {
|
func (v HourlyForecastModule) isEmpty() bool {
|
||||||
return v.Product == "" &&
|
return v.Product == "" &&
|
||||||
v.IssuedAt.IsZero() &&
|
v.IssuedAt.IsZero() &&
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func buildMetadataModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
|||||||
ValidPeriod: metadata.ValidPeriod,
|
ValidPeriod: metadata.ValidPeriod,
|
||||||
Location: copyLocation(ctx.Location),
|
Location: copyLocation(ctx.Location),
|
||||||
SourceWarnings: sourceWarningSummaries(ctx.Collected.SourceWarnings),
|
SourceWarnings: sourceWarningSummaries(ctx.Collected.SourceWarnings),
|
||||||
Alerts: alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps),
|
Alerts: alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps, ctx.Timezone),
|
||||||
}
|
}
|
||||||
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil
|
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,36 @@ func windDirectionLabel(degrees *float64) string {
|
|||||||
return labels[sector]
|
return labels[sector]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func windDirectionTextLabel(degrees *float64) string {
|
||||||
|
if degrees == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
labels := []string{
|
||||||
|
"north",
|
||||||
|
"north-northeast",
|
||||||
|
"northeast",
|
||||||
|
"east-northeast",
|
||||||
|
"east",
|
||||||
|
"east-southeast",
|
||||||
|
"southeast",
|
||||||
|
"south-southeast",
|
||||||
|
"south",
|
||||||
|
"south-southwest",
|
||||||
|
"southwest",
|
||||||
|
"west-southwest",
|
||||||
|
"west",
|
||||||
|
"west-northwest",
|
||||||
|
"northwest",
|
||||||
|
"north-northwest",
|
||||||
|
}
|
||||||
|
normalized := math.Mod(*degrees, 360)
|
||||||
|
if normalized < 0 {
|
||||||
|
normalized += 360
|
||||||
|
}
|
||||||
|
sector := int(math.Floor((normalized+11.25)/22.5)) % len(labels)
|
||||||
|
return labels[sector]
|
||||||
|
}
|
||||||
|
|
||||||
func timedClockLabel(value *forecast.TimedValue, timezone string) string {
|
func timedClockLabel(value *forecast.TimedValue, timezone string) string {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -67,18 +97,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 {
|
||||||
@@ -92,6 +122,17 @@ func friendlyDateTimeLabel(value time.Time, timezone string) string {
|
|||||||
return value.In(location).Format("2006-01-02 at 3:04 PM")
|
return value.In(location).Format("2006-01-02 at 3:04 PM")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func friendlyMonthDayTimeLabel(value time.Time, timezone string) string {
|
||||||
|
if value.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
location, err := timeutil.LoadLocation(timezone)
|
||||||
|
if err != nil {
|
||||||
|
location = time.UTC
|
||||||
|
}
|
||||||
|
return value.In(location).Format("January 2 at 3:04 PM")
|
||||||
|
}
|
||||||
|
|
||||||
func friendlyDateLabel(date string, timezone string) string {
|
func friendlyDateLabel(date string, timezone string) string {
|
||||||
location, err := timeutil.LoadLocation(timezone)
|
location, err := timeutil.LoadLocation(timezone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -126,3 +167,11 @@ func clockLabel(value time.Time, timezone string) string {
|
|||||||
}
|
}
|
||||||
return label
|
return label
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hourMinuteLabel(value time.Time, timezone string) string {
|
||||||
|
location, err := timeutil.LoadLocation(timezone)
|
||||||
|
if err != nil {
|
||||||
|
location = time.UTC
|
||||||
|
}
|
||||||
|
return value.In(location).Format("3:04 PM")
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,3 +27,25 @@ func TestWindDirectionLabelUsesSixteenPointCompass(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWindDirectionTextLabelUsesLowercaseCompassText(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
degrees *float64
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "nil", degrees: nil, want: ""},
|
||||||
|
{name: "north", degrees: floatPtr(0), want: "north"},
|
||||||
|
{name: "north northeast", degrees: floatPtr(11.25), want: "north-northeast"},
|
||||||
|
{name: "northwest", degrees: floatPtr(315), want: "northwest"},
|
||||||
|
{name: "negative normalizes", degrees: floatPtr(-45), want: "northwest"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := windDirectionTextLabel(tt.degrees); got != tt.want {
|
||||||
|
t.Fatalf("windDirectionTextLabel(%v) = %q, want %q", tt.degrees, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ type ModuleContext struct {
|
|||||||
|
|
||||||
type ModuleBuilder func(ModuleContext, any) (*module.Output, error)
|
type ModuleBuilder func(ModuleContext, any) (*module.Output, error)
|
||||||
|
|
||||||
|
type ModulePromptExporter func(value any) (any, error)
|
||||||
|
|
||||||
type ModuleDefinition struct {
|
type ModuleDefinition struct {
|
||||||
ID module.ID
|
ID module.ID
|
||||||
StanzaName string
|
StanzaName string
|
||||||
@@ -31,12 +33,17 @@ type ModuleDefinition struct {
|
|||||||
MissingData module.MissingDataBehavior
|
MissingData module.MissingDataBehavior
|
||||||
AllowDuplicate bool
|
AllowDuplicate bool
|
||||||
Builder ModuleBuilder
|
Builder ModuleBuilder
|
||||||
|
PromptExporter ModulePromptExporter
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModuleRegistry struct {
|
type ModuleRegistry struct {
|
||||||
definitions map[module.ID]ModuleDefinition
|
definitions map[module.ID]ModuleDefinition
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func unexpectedPromptExportValue(got any, want any) error {
|
||||||
|
return fmt.Errorf("value has type %T, want %T", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
func DefaultModuleRegistry() (ModuleRegistry, error) {
|
func DefaultModuleRegistry() (ModuleRegistry, error) {
|
||||||
return NewModuleRegistry(defaultModuleDefinitions())
|
return NewModuleRegistry(defaultModuleDefinitions())
|
||||||
}
|
}
|
||||||
@@ -130,6 +137,15 @@ func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (
|
|||||||
if output.StanzaName != definition.StanzaName {
|
if output.StanzaName != definition.StanzaName {
|
||||||
return nil, fmt.Errorf("module %q produced stanza %q, want %q", definition.ID, output.StanzaName, definition.StanzaName)
|
return nil, fmt.Errorf("module %q produced stanza %q, want %q", definition.ID, output.StanzaName, definition.StanzaName)
|
||||||
}
|
}
|
||||||
|
if definition.PromptExporter == nil {
|
||||||
|
output.PromptValue = output.Value
|
||||||
|
return output, nil
|
||||||
|
}
|
||||||
|
promptValue, err := definition.PromptExporter(output.Value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("module %q stanza %q prompt export: %w", definition.ID, definition.StanzaName, err)
|
||||||
|
}
|
||||||
|
output.PromptValue = promptValue
|
||||||
return output, nil
|
return output, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +178,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 +201,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,8 +265,8 @@ 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.Daily, report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm}
|
||||||
daypartReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend}
|
daypartReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.ThreeDay, report.Weekend}
|
||||||
return []ModuleDefinition{
|
return []ModuleDefinition{
|
||||||
{
|
{
|
||||||
ID: module.Metadata,
|
ID: module.Metadata,
|
||||||
@@ -265,6 +285,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
SupportedReports: allReports,
|
SupportedReports: allReports,
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildCurrentConditionsModule,
|
Builder: buildCurrentConditionsModule,
|
||||||
|
PromptExporter: exportCurrentConditionsPromptValue,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: module.NarrativeForecast,
|
ID: module.NarrativeForecast,
|
||||||
@@ -272,7 +293,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
DefaultOptions: module.NarrativeForecastOptions{},
|
DefaultOptions: module.NarrativeForecastOptions{},
|
||||||
RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast},
|
RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods},
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods},
|
||||||
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow},
|
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildNarrativeForecastModule,
|
Builder: buildNarrativeForecastModule,
|
||||||
},
|
},
|
||||||
@@ -282,16 +303,17 @@ 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.Daily, report.Today, report.Tomorrow, report.Hourly},
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildHourlyForecastModule,
|
Builder: buildHourlyForecastModule,
|
||||||
|
PromptExporter: exportHourlyForecastPromptValue,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: module.DerivedDailySummary,
|
ID: module.DerivedDailySummary,
|
||||||
StanzaName: "derived_daily_summary",
|
StanzaName: "derived_daily_summary",
|
||||||
DefaultOptions: module.DerivedDailySummaryOptions{},
|
DefaultOptions: module.DerivedDailySummaryOptions{},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming},
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming},
|
||||||
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow},
|
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
|
||||||
MissingData: module.MissingDataError,
|
MissingData: module.MissingDataError,
|
||||||
Builder: buildDerivedDailySummaryModule,
|
Builder: buildDerivedDailySummaryModule,
|
||||||
},
|
},
|
||||||
@@ -303,12 +325,13 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
SupportedReports: daypartReports,
|
SupportedReports: daypartReports,
|
||||||
MissingData: module.MissingDataError,
|
MissingData: module.MissingDataError,
|
||||||
Builder: buildDerivedDaypartSummariesModule,
|
Builder: buildDerivedDaypartSummariesModule,
|
||||||
|
PromptExporter: exportDerivedDaypartSummariesPromptValue,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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 +346,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 +365,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",
|
||||||
@@ -350,14 +393,32 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildOutdoorWindowsModule,
|
Builder: buildOutdoorWindowsModule,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: module.TodayPlanning,
|
||||||
|
StanzaName: "today_planning",
|
||||||
|
DefaultOptions: module.TodayPlanningOptions{},
|
||||||
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
||||||
|
SupportedReports: []report.ID{report.Today},
|
||||||
|
MissingData: module.MissingDataEmpty,
|
||||||
|
Builder: buildTodayPlanningModule,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ID: module.TomorrowPlanning,
|
ID: module.TomorrowPlanning,
|
||||||
StanzaName: "tomorrow_planning",
|
StanzaName: "tomorrow_planning",
|
||||||
DefaultOptions: module.TomorrowPlanningOptions{},
|
DefaultOptions: module.TomorrowPlanningOptions{},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
||||||
SupportedReports: []report.ID{report.DailyTomorrow},
|
SupportedReports: []report.ID{report.Tomorrow},
|
||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildTomorrowPlanningModule,
|
Builder: buildTomorrowPlanningModule,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: module.DailyPlanning,
|
||||||
|
StanzaName: "daily_planning",
|
||||||
|
DefaultOptions: module.DailyPlanningOptions{},
|
||||||
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
||||||
|
SupportedReports: []report.ID{report.Daily},
|
||||||
|
MissingData: module.MissingDataEmpty,
|
||||||
|
Builder: buildDailyPlanningModule,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
package briefing
|
package briefing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"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) {
|
||||||
@@ -38,6 +42,9 @@ func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
|
|||||||
t.Fatalf("BuildModule(%s) error = %v", item.ID, err)
|
t.Fatalf("BuildModule(%s) error = %v", item.ID, err)
|
||||||
}
|
}
|
||||||
if output != nil {
|
if output != nil {
|
||||||
|
if output.DataPackageValue() == nil {
|
||||||
|
t.Fatalf("BuildModule(%s) data package value = nil", item.ID)
|
||||||
|
}
|
||||||
outputs = append(outputs, *output)
|
outputs = append(outputs, *output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,9 +59,234 @@ func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDefaultModuleDefinitionsDeclarePromptExportPolicy(t *testing.T) {
|
||||||
|
customExporters := map[module.ID]struct{}{
|
||||||
|
module.CurrentConditions: {},
|
||||||
|
module.HourlyForecast: {},
|
||||||
|
module.DerivedDaypartSummaries: {},
|
||||||
|
}
|
||||||
|
passThroughExporters := map[module.ID]struct{}{
|
||||||
|
module.Metadata: {},
|
||||||
|
module.NarrativeForecast: {},
|
||||||
|
module.DerivedDailySummary: {},
|
||||||
|
module.PrecipTiming: {},
|
||||||
|
module.AlertDigest: {},
|
||||||
|
module.SPCConvectiveOutlooks: {},
|
||||||
|
module.AreaForecastDiscussion: {},
|
||||||
|
module.SPCConvectiveDiscussion: {},
|
||||||
|
module.WeatherStory: {},
|
||||||
|
module.OutdoorWindows: {},
|
||||||
|
module.TodayPlanning: {},
|
||||||
|
module.TomorrowPlanning: {},
|
||||||
|
module.DailyPlanning: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, definition := range defaultModuleDefinitions() {
|
||||||
|
_, custom := customExporters[definition.ID]
|
||||||
|
_, passThrough := passThroughExporters[definition.ID]
|
||||||
|
if custom == passThrough {
|
||||||
|
t.Fatalf("module %q exporter policy custom=%v passThrough=%v, want exactly one policy", definition.ID, custom, passThrough)
|
||||||
|
}
|
||||||
|
if custom && definition.PromptExporter == nil {
|
||||||
|
t.Fatalf("module %q PromptExporter = nil, want custom prompt exporter", definition.ID)
|
||||||
|
}
|
||||||
|
if passThrough && definition.PromptExporter != nil {
|
||||||
|
t.Fatalf("module %q PromptExporter is set, want default pass-through", definition.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryAddsPassThroughPromptValue(t *testing.T) {
|
||||||
|
registry, err := NewModuleRegistry([]ModuleDefinition{
|
||||||
|
{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Builder: func(ModuleContext, any) (*module.Output, error) {
|
||||||
|
return &module.Output{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Value: testRegistryValue{Message: "rich"},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewModuleRegistry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
if output == nil {
|
||||||
|
t.Fatal("BuildModule() output = nil, want output")
|
||||||
|
}
|
||||||
|
if output.PromptValue != output.Value {
|
||||||
|
t.Fatalf("PromptValue = %#v, want pass-through rich value %#v", output.PromptValue, output.Value)
|
||||||
|
}
|
||||||
|
if output.DataPackageValue() != output.Value {
|
||||||
|
t.Fatalf("DataPackageValue() = %#v, want rich value", output.DataPackageValue())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryAddsCustomPromptValue(t *testing.T) {
|
||||||
|
registry, err := NewModuleRegistry([]ModuleDefinition{
|
||||||
|
{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Builder: func(ModuleContext, any) (*module.Output, error) {
|
||||||
|
return &module.Output{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Value: testRegistryValue{Message: "rich"},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
PromptExporter: func(value any) (any, error) {
|
||||||
|
rich, ok := value.(testRegistryValue)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("unexpected rich value type")
|
||||||
|
}
|
||||||
|
return testRegistryValue{Message: rich.Message + " prompt"}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewModuleRegistry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
got, ok := output.PromptValue.(testRegistryValue)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("PromptValue type = %T, want testRegistryValue", output.PromptValue)
|
||||||
|
}
|
||||||
|
if got.Message != "rich prompt" {
|
||||||
|
t.Fatalf("PromptValue = %#v, want custom prompt value", got)
|
||||||
|
}
|
||||||
|
if output.DataPackageValue() != output.PromptValue {
|
||||||
|
t.Fatalf("DataPackageValue() = %#v, want custom prompt value", output.DataPackageValue())
|
||||||
|
}
|
||||||
|
if output.Value.(testRegistryValue).Message != "rich" {
|
||||||
|
t.Fatalf("Value = %#v, want rich value unchanged", output.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryWrapsPromptExporterErrors(t *testing.T) {
|
||||||
|
registry, err := NewModuleRegistry([]ModuleDefinition{
|
||||||
|
{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Builder: func(ModuleContext, any) (*module.Output, error) {
|
||||||
|
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: testRegistryValue{Message: "rich"}}, nil
|
||||||
|
},
|
||||||
|
PromptExporter: func(any) (any, error) {
|
||||||
|
return nil, errors.New("unsupported value")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewModuleRegistry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
||||||
|
if err == nil ||
|
||||||
|
!strings.Contains(err.Error(), `module "metadata" stanza "metadata" prompt export`) ||
|
||||||
|
!strings.Contains(err.Error(), "unsupported value") {
|
||||||
|
t.Fatalf("BuildModule() error = %v, want wrapped exporter error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryValidatesOutputBeforePromptExport(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
registry, err := NewModuleRegistry([]ModuleDefinition{
|
||||||
|
{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Builder: func(ModuleContext, any) (*module.Output, error) {
|
||||||
|
return &module.Output{ID: module.CurrentConditions, StanzaName: "metadata", Value: testRegistryValue{Message: "rich"}}, nil
|
||||||
|
},
|
||||||
|
PromptExporter: func(any) (any, error) {
|
||||||
|
called = true
|
||||||
|
return testRegistryValue{Message: "prompt"}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewModuleRegistry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `module "metadata" produced output id "current_conditions"`) {
|
||||||
|
t.Fatalf("BuildModule() error = %v, want output id validation error", err)
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Fatal("PromptExporter called before output validation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryPromptValueIsNotPersistedInSnapshotJSON(t *testing.T) {
|
||||||
|
registry, err := NewModuleRegistry([]ModuleDefinition{
|
||||||
|
{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Builder: func(ModuleContext, any) (*module.Output, error) {
|
||||||
|
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: testRegistryValue{Message: "rich"}}, nil
|
||||||
|
},
|
||||||
|
PromptExporter: func(any) (any, error) {
|
||||||
|
return testRegistryValue{Message: "prompt-only"}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewModuleRegistry() error = %v", err)
|
||||||
|
}
|
||||||
|
output, err := registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
snapshot, err := module.NewSnapshot([]module.Output{*output})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSnapshot() error = %v", err)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(snapshot)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
if !strings.Contains(text, `"message":"rich"`) {
|
||||||
|
t.Fatalf("snapshot JSON missing rich value: %s", text)
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "prompt-only") || strings.Contains(text, "promptValue") || strings.Contains(text, "PromptValue") {
|
||||||
|
t.Fatalf("snapshot JSON includes runtime-only prompt value: %s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.Daily, []module.ConfigItem{{ID: module.ID("unknown")}})
|
||||||
if err == nil || !strings.Contains(err.Error(), `unknown module "unknown"`) {
|
if err == nil || !strings.Contains(err.Error(), `unknown module "unknown"`) {
|
||||||
t.Fatalf("error = %v, want unknown module", err)
|
t.Fatalf("error = %v, want unknown module", err)
|
||||||
}
|
}
|
||||||
@@ -62,7 +294,7 @@ func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
|
|||||||
|
|
||||||
func TestModuleRegistryRejectsDuplicateModuleIDs(t *testing.T) {
|
func TestModuleRegistryRejectsDuplicateModuleIDs(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{
|
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
|
||||||
{ID: module.Metadata},
|
{ID: module.Metadata},
|
||||||
{ID: module.Metadata},
|
{ID: module.Metadata},
|
||||||
})
|
})
|
||||||
@@ -83,12 +315,84 @@ func TestModuleRegistryRejectsDuplicateStanzaNames(t *testing.T) {
|
|||||||
|
|
||||||
func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
|
func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.TomorrowPlanning}})
|
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.TomorrowPlanning}})
|
||||||
if err == nil || !strings.Contains(err.Error(), `module "tomorrow_planning" is not compatible with report "daily_today"`) {
|
if err == nil || !strings.Contains(err.Error(), `module "tomorrow_planning" is not compatible with report "daily"`) {
|
||||||
t.Fatalf("error = %v, want incompatible report", err)
|
t.Fatalf("error = %v, want incompatible report", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryValidatesTodayPlanningSupport(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
if err := registry.ValidateComposition(report.Today, []module.ConfigItem{{ID: module.TodayPlanning}}); err != nil {
|
||||||
|
t.Fatalf("ValidateComposition(today) error = %v", err)
|
||||||
|
}
|
||||||
|
for _, id := range []report.ID{report.Tomorrow, report.Daily} {
|
||||||
|
t.Run(string(id), func(t *testing.T) {
|
||||||
|
err := registry.ValidateComposition(id, []module.ConfigItem{{ID: module.TodayPlanning}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `module "today_planning" is not compatible with report`) {
|
||||||
|
t.Fatalf("ValidateComposition(%s) error = %v, want incompatible report", id, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryValidatesDailyPlanningSupport(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
if err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.DailyPlanning}}); err != nil {
|
||||||
|
t.Fatalf("ValidateComposition(daily) error = %v", err)
|
||||||
|
}
|
||||||
|
for _, id := range []report.ID{report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm} {
|
||||||
|
t.Run(string(id), func(t *testing.T) {
|
||||||
|
err := registry.ValidateComposition(id, []module.ConfigItem{{ID: module.DailyPlanning}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `module "daily_planning" is not compatible with report`) {
|
||||||
|
t.Fatalf("ValidateComposition(%s) error = %v, want incompatible report", id, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistrySupportsTodayEligibleModules(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
err := registry.ValidateComposition(report.Today, []module.ConfigItem{
|
||||||
|
{ID: module.Metadata},
|
||||||
|
{ID: module.CurrentConditions},
|
||||||
|
{ID: module.NarrativeForecast},
|
||||||
|
{ID: module.HourlyForecast},
|
||||||
|
{ID: module.DerivedDailySummary},
|
||||||
|
{ID: module.DerivedDaypartSummaries},
|
||||||
|
{ID: module.PrecipTiming},
|
||||||
|
{ID: module.AlertDigest},
|
||||||
|
{ID: module.SPCConvectiveOutlooks},
|
||||||
|
{ID: module.AreaForecastDiscussion},
|
||||||
|
{ID: module.SPCConvectiveDiscussion},
|
||||||
|
{ID: module.WeatherStory},
|
||||||
|
{ID: module.OutdoorWindows},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateComposition(today eligible modules) error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryRejectsHourlyIncompatibleModules(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
for _, id := range []module.ID{
|
||||||
|
module.NarrativeForecast,
|
||||||
|
module.DerivedDailySummary,
|
||||||
|
module.DerivedDaypartSummaries,
|
||||||
|
module.OutdoorWindows,
|
||||||
|
module.TodayPlanning,
|
||||||
|
module.TomorrowPlanning,
|
||||||
|
module.DailyPlanning,
|
||||||
|
} {
|
||||||
|
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{}},
|
||||||
@@ -109,7 +413,7 @@ func TestModuleRegistryRejectsUnsupportedMissingDataWarn(t *testing.T) {
|
|||||||
|
|
||||||
func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) {
|
func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{
|
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
|
||||||
{ID: module.Metadata, Options: module.CurrentConditionsOptions{}},
|
{ID: module.Metadata, Options: module.CurrentConditionsOptions{}},
|
||||||
})
|
})
|
||||||
if err == nil || !strings.Contains(err.Error(), `module "metadata" options have type module.CurrentConditionsOptions, want module.MetadataOptions`) {
|
if err == nil || !strings.Contains(err.Error(), `module "metadata" options have type module.CurrentConditionsOptions, want module.MetadataOptions`) {
|
||||||
@@ -119,7 +423,7 @@ func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) {
|
|||||||
|
|
||||||
func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
|
func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{
|
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
|
||||||
{ID: module.Metadata, Options: module.MetadataOptions{}},
|
{ID: module.Metadata, Options: module.MetadataOptions{}},
|
||||||
{ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}},
|
{ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}},
|
||||||
})
|
})
|
||||||
@@ -128,6 +432,42 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type testRegistryValue struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRegistryModuleContext() ModuleContext {
|
||||||
|
return ModuleContext{
|
||||||
|
Resolved: report.Resolved{
|
||||||
|
Definition: report.DefaultRegistry().MustLookup(report.Daily),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -8,31 +8,31 @@ type OutdoorWindowsModule struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OutdoorWindowModule struct {
|
type OutdoorWindowModule struct {
|
||||||
Daypart string `json:"daypart"`
|
Daypart string `json:"daypart"`
|
||||||
Start string `json:"start"`
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
End string `json:"end"`
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
Reasons []string `json:"reasons,omitempty"`
|
Reasons []string `json:"reasons,omitempty"`
|
||||||
Score float64 `json:"score"`
|
Score float64 `json:"score"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries)
|
windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries)
|
||||||
value := OutdoorWindowsModule{
|
value := OutdoorWindowsModule{
|
||||||
Best: outdoorWindowValue(windows.Best),
|
Best: outdoorWindowValue(windows.Best, ctx.Timezone),
|
||||||
Worst: outdoorWindowValue(windows.Worst),
|
Worst: outdoorWindowValue(windows.Worst, ctx.Timezone),
|
||||||
}
|
}
|
||||||
return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil
|
return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func outdoorWindowValue(window *OutdoorWindow) *OutdoorWindowModule {
|
func outdoorWindowValue(window *OutdoorWindow, timezone string) *OutdoorWindowModule {
|
||||||
if window == nil {
|
if window == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &OutdoorWindowModule{
|
return &OutdoorWindowModule{
|
||||||
Daypart: window.Daypart,
|
Daypart: window.Daypart,
|
||||||
Start: window.Start,
|
PeriodBegins: friendlyPeriodBeginsLabel(window.Period, timezone),
|
||||||
End: window.End,
|
PeriodEnds: friendlyPeriodEndsLabel(window.Period, timezone),
|
||||||
Reasons: append([]string(nil), window.Reasons...),
|
Reasons: append([]string(nil), window.Reasons...),
|
||||||
Score: window.Score,
|
Score: window.Score,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,9 +185,9 @@ func alertStatus(bundle *weatherdata.Bundle) *AlertStatus {
|
|||||||
|
|
||||||
func variantForReport(id report.ID) string {
|
func variantForReport(id report.ID) string {
|
||||||
switch id {
|
switch id {
|
||||||
case report.DailyToday:
|
case report.Daily, report.Today:
|
||||||
return "today"
|
return "today"
|
||||||
case report.DailyTomorrow:
|
case report.Tomorrow:
|
||||||
return "tomorrow"
|
return "tomorrow"
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
package briefing
|
package briefing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
precipTimingChanceLowerBound = 40
|
||||||
|
precipTimingLikelyLowerBound = 50
|
||||||
|
precipTimingExpectLowerBound = 70
|
||||||
|
)
|
||||||
|
|
||||||
type PrecipTimingModule struct {
|
type PrecipTimingModule struct {
|
||||||
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"`
|
||||||
@@ -14,10 +23,15 @@ type PrecipTimingModule struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PrecipitationWindowModule struct {
|
type PrecipitationWindowModule struct {
|
||||||
Start string `json:"start"`
|
PeriodBegins string `json:"period_begins"`
|
||||||
End string `json:"end,omitempty"`
|
PeriodBeginsHourLabel string `json:"period_begins_hour_label,omitempty"`
|
||||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
PeriodEndsHourLabel string `json:"period_ends_hour_label,omitempty"`
|
||||||
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||||
|
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||||
|
MaxPopHourLabel string `json:"max_pop_hour_label,omitempty"`
|
||||||
|
PrecipitationType string `json:"precipitation_type,omitempty"`
|
||||||
|
ExpectationPhrase string `json:"expectation_phrase,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildPrecipTimingModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
func buildPrecipTimingModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
@@ -36,14 +50,60 @@ 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),
|
||||||
|
PeriodBeginsHourLabel: hourMinuteLabel(window.Start, timezone),
|
||||||
}
|
}
|
||||||
if window.End != nil {
|
if window.End != nil {
|
||||||
item.End = clockLabel(*window.End, timezone)
|
item.PeriodEnds = friendlyDateTimeLabel(*window.End, timezone)
|
||||||
|
item.PeriodEndsHourLabel = hourMinuteLabel(*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)
|
||||||
|
item.MaxPopHourLabel = hourMinuteLabel(window.MaxPrecipitationProbability.Time, timezone)
|
||||||
|
item.PrecipitationType = precipitationWindowType(window.TextDescriptions)
|
||||||
|
if item.MaxPopPercent != nil {
|
||||||
|
item.ExpectationPhrase = precipitationWindowExpectationPhrase(*item.MaxPopPercent, item.PrecipitationType)
|
||||||
|
}
|
||||||
value.PrecipitationWindows = append(value.PrecipitationWindows, item)
|
value.PrecipitationWindows = append(value.PrecipitationWindows, item)
|
||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func precipitationWindowType(descriptions []string) string {
|
||||||
|
combined := strings.ToLower(strings.Join(descriptions, " "))
|
||||||
|
switch {
|
||||||
|
case (strings.Contains(combined, "thunderstorm") || strings.Contains(combined, "t-storm")) &&
|
||||||
|
(strings.Contains(combined, "shower") || strings.Contains(combined, "rain")):
|
||||||
|
return "showers and thunderstorms"
|
||||||
|
case strings.Contains(combined, "freezing rain"):
|
||||||
|
return "freezing rain"
|
||||||
|
case strings.Contains(combined, "thunderstorm") || strings.Contains(combined, "t-storm"):
|
||||||
|
return "thunderstorms"
|
||||||
|
case strings.Contains(combined, "shower"):
|
||||||
|
return "showers"
|
||||||
|
case strings.Contains(combined, "snow"):
|
||||||
|
return "snow"
|
||||||
|
case strings.Contains(combined, "drizzle"):
|
||||||
|
return "drizzle"
|
||||||
|
case strings.Contains(combined, "rain"):
|
||||||
|
return "rain"
|
||||||
|
default:
|
||||||
|
return "precipitation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func precipitationWindowExpectationPhrase(maxPopPercent int, precipitationType string) string {
|
||||||
|
if precipitationType == "" {
|
||||||
|
precipitationType = "precipitation"
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case maxPopPercent >= precipTimingExpectLowerBound:
|
||||||
|
return fmt.Sprintf("Expect %s.", precipitationType)
|
||||||
|
case maxPopPercent >= precipTimingLikelyLowerBound:
|
||||||
|
return fmt.Sprintf("%s likely.", sentenceCase(precipitationType))
|
||||||
|
case maxPopPercent >= precipTimingChanceLowerBound:
|
||||||
|
return fmt.Sprintf("Chance of %s.", precipitationType)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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 = defaultSPCRiskDigestMinimumSeverityRank
|
||||||
|
const spcCategoricalOutlookType = defaultSPCRiskDigestOutlookType
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
154
internal/briefing/spc_convective_outlooks_module.go
Normal file
154
internal/briefing/spc_convective_outlooks_module.go
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultSPCRiskDigestOutlookType = "categorical"
|
||||||
|
const defaultSPCRiskDigestMinimumSeverityRank = 3
|
||||||
|
|
||||||
|
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"`
|
||||||
|
RiskDigest []SPCConvectiveOutlookDigest `json:"risk_digest,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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SPCConvectiveOutlookDigest struct {
|
||||||
|
LabelText string `json:"label_text,omitempty"`
|
||||||
|
RiskLabel string `json:"risk_label,omitempty"`
|
||||||
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
|
PeriodEnds string `json:"period_ends,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.RiskDigest = spcConvectiveOutlookRiskDigest(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod, ctx.Timezone, defaultSPCRiskDigestPolicy())
|
||||||
|
value.OutlookCount = len(value.Outlooks)
|
||||||
|
return &module.Output{ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type spcRiskDigestPolicy struct {
|
||||||
|
OutlookType string
|
||||||
|
MinimumSeverityRank int
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultSPCRiskDigestPolicy() spcRiskDigestPolicy {
|
||||||
|
return spcRiskDigestPolicy{
|
||||||
|
OutlookType: defaultSPCRiskDigestOutlookType,
|
||||||
|
MinimumSeverityRank: defaultSPCRiskDigestMinimumSeverityRank,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 spcConvectiveOutlookRiskDigest(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period, timezone string, policy spcRiskDigestPolicy) []SPCConvectiveOutlookDigest {
|
||||||
|
records := make([]SPCConvectiveOutlookDigest, 0, len(outlooks))
|
||||||
|
for _, outlook := range outlooks {
|
||||||
|
if outlook.OutlookType != policy.OutlookType {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if outlook.SeverityRank == nil || *outlook.SeverityRank < policy.MinimumSeverityRank {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !outlook.ContainsLocation {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
|
||||||
|
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(reportPeriod) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
records = append(records, SPCConvectiveOutlookDigest{
|
||||||
|
LabelText: outlook.LabelText,
|
||||||
|
RiskLabel: spcRiskDigestLabel(outlook.LabelText),
|
||||||
|
PeriodBegins: friendlyMonthDayTimeLabel(outlookPeriod.Start, timezone),
|
||||||
|
PeriodEnds: friendlyMonthDayTimeLabel(outlookPeriod.End, timezone),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
func spcRiskDigestLabel(labelText string) string {
|
||||||
|
label := strings.TrimSpace(labelText)
|
||||||
|
if label == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
runes := []rune(strings.ToLower(label))
|
||||||
|
runes[0] = unicode.ToUpper(runes[0])
|
||||||
|
return string(runes)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
257
internal/briefing/spc_convective_outlooks_module_test.go
Normal file
257
internal/briefing/spc_convective_outlooks_module_test.go
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
if len(value.RiskDigest) != 1 {
|
||||||
|
t.Fatalf("RiskDigest length = %d, want 1", len(value.RiskDigest))
|
||||||
|
}
|
||||||
|
digest := value.RiskDigest[0]
|
||||||
|
if digest.LabelText != "Slight Risk" || digest.RiskLabel != "Slight risk" || digest.PeriodBegins != "May 29 at 11:00 AM" || digest.PeriodEnds != "May 30 at 7:00 AM" {
|
||||||
|
t.Fatalf("risk digest = %#v, want prompt-facing slight risk record", digest)
|
||||||
|
}
|
||||||
|
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", "risk_digest", "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 TestSPCRiskDigestDefaultPolicyConstants(t *testing.T) {
|
||||||
|
if defaultSPCRiskDigestOutlookType != "categorical" {
|
||||||
|
t.Fatalf("defaultSPCRiskDigestOutlookType = %q, want categorical", defaultSPCRiskDigestOutlookType)
|
||||||
|
}
|
||||||
|
if defaultSPCRiskDigestMinimumSeverityRank != 3 {
|
||||||
|
t.Fatalf("defaultSPCRiskDigestMinimumSeverityRank = %d, want 3", defaultSPCRiskDigestMinimumSeverityRank)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveOutlooksRiskDigestFilters(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
outlook weatherdata.ConvectiveOutlook
|
||||||
|
wantRisk bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "categorical slight risk included",
|
||||||
|
outlook: spcRiskDigestTestOutlook("categorical", "Slight Risk", 3, true,
|
||||||
|
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"),
|
||||||
|
wantRisk: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "marginal risk excluded",
|
||||||
|
outlook: spcRiskDigestTestOutlook("categorical", "Marginal Risk", 2, true,
|
||||||
|
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non categorical high rank excluded",
|
||||||
|
outlook: spcRiskDigestTestOutlook("wind", "30% Wind Risk", 30, true,
|
||||||
|
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non overlapping excluded",
|
||||||
|
outlook: spcRiskDigestTestOutlook("categorical", "Enhanced Risk", 4, true,
|
||||||
|
"2026-05-30T07:00:00-05:00", "2026-05-31T07:00:00-05:00"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "location miss excluded",
|
||||||
|
outlook: spcRiskDigestTestOutlook("categorical", "Moderate Risk", 5, false,
|
||||||
|
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{tt.outlook},
|
||||||
|
}
|
||||||
|
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{tt.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)
|
||||||
|
gotRisk := len(value.RiskDigest) > 0
|
||||||
|
if gotRisk != tt.wantRisk {
|
||||||
|
t.Fatalf("RiskDigest = %#v, want included=%v", value.RiskDigest, tt.wantRisk)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if len(value.RiskDigest) != 0 {
|
||||||
|
t.Fatalf("RiskDigest = %#v, want non-overlapping outlook omitted", value.RiskDigest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func spcRiskDigestTestOutlook(outlookType string, labelText string, rank int, containsLocation bool, validFrom string, validTo string) weatherdata.ConvectiveOutlook {
|
||||||
|
return weatherdata.ConvectiveOutlook{
|
||||||
|
ID: labelText,
|
||||||
|
Day: 1,
|
||||||
|
OutlookType: outlookType,
|
||||||
|
LabelText: labelText,
|
||||||
|
SeverityRank: &rank,
|
||||||
|
ValidFrom: mustParseModuleTime(validFrom),
|
||||||
|
ValidTo: mustParseModuleTime(validTo),
|
||||||
|
ContainsLocation: containsLocation,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,19 @@ type TomorrowPlanning struct {
|
|||||||
OvernightChangeWatch []string
|
OvernightChangeWatch []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type morningCommuteOvernightPlanning struct {
|
||||||
|
MorningReadiness []string
|
||||||
|
CommuteSchoolWorkdayConcerns []string
|
||||||
|
OvernightChangeWatch []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TodayPlanning struct {
|
||||||
|
MorningReadiness []string
|
||||||
|
CommuteSchoolWorkdayConcerns []string
|
||||||
|
OutdoorPlanning []string
|
||||||
|
LateDayChangeWatch []string
|
||||||
|
}
|
||||||
|
|
||||||
func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
|
func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
|
||||||
var best *OutdoorWindow
|
var best *OutdoorWindow
|
||||||
var worst *OutdoorWindow
|
var worst *OutdoorWindow
|
||||||
@@ -48,8 +61,60 @@ func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
|
|||||||
return OutdoorWindows{Best: best, Worst: worst}
|
return OutdoorWindows{Best: best, Worst: worst}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildTodayPlanning(summary *forecast.DailySummary) *TodayPlanning {
|
||||||
|
planning := &TodayPlanning{}
|
||||||
|
morning := daypartNamed(summary.Dayparts, "morning")
|
||||||
|
if morning != nil {
|
||||||
|
planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...)
|
||||||
|
}
|
||||||
|
if len(planning.MorningReadiness) == 0 {
|
||||||
|
planning.MorningReadiness = append(planning.MorningReadiness, "Morning weather looks routine based on the available hourly forecast.")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, daypart := range summary.Dayparts {
|
||||||
|
if daypart.Name == "overnight" || daypart.Name == "evening" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, concernNotes(daypart)...)
|
||||||
|
}
|
||||||
|
for _, alert := range summary.AlertOverlaps {
|
||||||
|
if alert.Event != "" {
|
||||||
|
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, "Active alert to plan around: "+alert.Event+".")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(planning.CommuteSchoolWorkdayConcerns) == 0 {
|
||||||
|
planning.CommuteSchoolWorkdayConcerns = append(planning.CommuteSchoolWorkdayConcerns, "No major commute, school, or workday weather concerns stand out in the available forecast.")
|
||||||
|
}
|
||||||
|
|
||||||
|
planning.OutdoorPlanning = append(planning.OutdoorPlanning, outdoorPlanningNotes(summary.Dayparts)...)
|
||||||
|
if len(planning.OutdoorPlanning) == 0 {
|
||||||
|
planning.OutdoorPlanning = append(planning.OutdoorPlanning, "No standout outdoor weather constraints are evident in the available forecast.")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range []string{"afternoon", "evening"} {
|
||||||
|
daypart := daypartNamed(summary.Dayparts, name)
|
||||||
|
if daypart != nil {
|
||||||
|
planning.LateDayChangeWatch = appendUnique(planning.LateDayChangeWatch, lateDayWatchNotes(*daypart)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(planning.LateDayChangeWatch) == 0 {
|
||||||
|
planning.LateDayChangeWatch = append(planning.LateDayChangeWatch, "Watch for forecast timing or intensity adjustments later today.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return planning
|
||||||
|
}
|
||||||
|
|
||||||
func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning {
|
func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning {
|
||||||
planning := &TomorrowPlanning{}
|
base := buildMorningCommuteOvernightPlanning(summary)
|
||||||
|
return &TomorrowPlanning{
|
||||||
|
MorningReadiness: append([]string(nil), base.MorningReadiness...),
|
||||||
|
CommuteSchoolWorkdayConcerns: append([]string(nil), base.CommuteSchoolWorkdayConcerns...),
|
||||||
|
OvernightChangeWatch: append([]string(nil), base.OvernightChangeWatch...),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMorningCommuteOvernightPlanning(summary *forecast.DailySummary) *morningCommuteOvernightPlanning {
|
||||||
|
planning := &morningCommuteOvernightPlanning{}
|
||||||
morning := daypartNamed(summary.Dayparts, "morning")
|
morning := daypartNamed(summary.Dayparts, "morning")
|
||||||
if morning != nil {
|
if morning != nil {
|
||||||
planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...)
|
planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...)
|
||||||
@@ -84,6 +149,18 @@ func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning {
|
|||||||
return planning
|
return planning
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func outdoorPlanningNotes(dayparts []forecast.DaypartSummary) []string {
|
||||||
|
windows := buildOutdoorWindows(dayparts)
|
||||||
|
var notes []string
|
||||||
|
if windows.Best != nil {
|
||||||
|
notes = append(notes, fmt.Sprintf("Best outdoor window: %s (%s).", titleWord(windows.Best.Daypart), strings.Join(windows.Best.Reasons, ", ")))
|
||||||
|
}
|
||||||
|
if windows.Worst != nil && (windows.Best == nil || windows.Worst.Daypart != windows.Best.Daypart) {
|
||||||
|
notes = append(notes, fmt.Sprintf("Toughest outdoor window: %s (%s).", titleWord(windows.Worst.Daypart), strings.Join(windows.Worst.Reasons, ", ")))
|
||||||
|
}
|
||||||
|
return appendUnique(nil, notes...)
|
||||||
|
}
|
||||||
|
|
||||||
func readinessNotes(daypart forecast.DaypartSummary) []string {
|
func readinessNotes(daypart forecast.DaypartSummary) []string {
|
||||||
notes := []string{}
|
notes := []string{}
|
||||||
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 50 {
|
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 50 {
|
||||||
@@ -104,6 +181,27 @@ func readinessNotes(daypart forecast.DaypartSummary) []string {
|
|||||||
return appendUnique(nil, notes...)
|
return appendUnique(nil, notes...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func lateDayWatchNotes(daypart forecast.DaypartSummary) []string {
|
||||||
|
notes := []string{}
|
||||||
|
prefix := titleWord(daypart.Name)
|
||||||
|
if prefix == "" {
|
||||||
|
prefix = "Late-day"
|
||||||
|
}
|
||||||
|
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 {
|
||||||
|
notes = append(notes, fmt.Sprintf("%s precipitation timing may shift; current peak is near %.0f%%.", prefix, daypart.MaxPrecipitationProbability.Value))
|
||||||
|
}
|
||||||
|
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
||||||
|
notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", prefix, daypart.PeakWindGust.Value))
|
||||||
|
}
|
||||||
|
if daypart.Indicators.Snow || daypart.Indicators.Ice {
|
||||||
|
notes = append(notes, prefix+" wintry weather could affect late-day travel.")
|
||||||
|
}
|
||||||
|
if len(daypart.AlertOverlaps) > 0 {
|
||||||
|
notes = append(notes, prefix+" alert timing could affect late-day plans.")
|
||||||
|
}
|
||||||
|
return appendUnique(nil, notes...)
|
||||||
|
}
|
||||||
|
|
||||||
func concernNotes(daypart forecast.DaypartSummary) []string {
|
func concernNotes(daypart forecast.DaypartSummary) []string {
|
||||||
notes := []string{}
|
notes := []string{}
|
||||||
prefix := titleWord(daypart.Name)
|
prefix := titleWord(daypart.Name)
|
||||||
@@ -190,8 +288,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,
|
||||||
}
|
}
|
||||||
|
|||||||
26
internal/briefing/today_planning_module.go
Normal file
26
internal/briefing/today_planning_module.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
|
||||||
|
type TodayPlanningModule struct {
|
||||||
|
MorningReadiness []string `json:"morning_readiness,omitempty"`
|
||||||
|
CommuteSchoolWorkdayConcerns []string `json:"commute_school_workday_concerns,omitempty"`
|
||||||
|
OutdoorPlanning []string `json:"outdoor_planning,omitempty"`
|
||||||
|
LateDayChangeWatch []string `json:"late_day_change_watch,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTodayPlanningModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
summary := ctx.Derived.FirstDailySummary()
|
||||||
|
if summary == nil {
|
||||||
|
return &module.Output{ID: module.TodayPlanning, StanzaName: "today_planning", Value: TodayPlanningModule{}}, nil
|
||||||
|
}
|
||||||
|
planning := buildTodayPlanning(summary)
|
||||||
|
value := TodayPlanningModule{}
|
||||||
|
if planning != nil {
|
||||||
|
value.MorningReadiness = append([]string(nil), planning.MorningReadiness...)
|
||||||
|
value.CommuteSchoolWorkdayConcerns = append([]string(nil), planning.CommuteSchoolWorkdayConcerns...)
|
||||||
|
value.OutdoorPlanning = append([]string(nil), planning.OutdoorPlanning...)
|
||||||
|
value.LateDayChangeWatch = append([]string(nil), planning.LateDayChangeWatch...)
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.TodayPlanning, StanzaName: "today_planning", Value: value}, nil
|
||||||
|
}
|
||||||
@@ -4,20 +4,21 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WeatherStoryModule struct {
|
type WeatherStoryModule struct {
|
||||||
Available bool `json:"available"`
|
Available bool `json:"available"`
|
||||||
OfficeID string `json:"office_id,omitempty"`
|
OfficeID string `json:"office_id,omitempty"`
|
||||||
StartTime time.Time `json:"start_time"`
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
EndTime time.Time `json:"end_time"`
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
AltText string `json:"alt_text,omitempty"`
|
AltText string `json:"alt_text,omitempty"`
|
||||||
Priority bool `json:"priority"`
|
Priority bool `json:"priority"`
|
||||||
Order int `json:"order"`
|
Order int `json:"order"`
|
||||||
DownloadURL string `json:"download_url,omitempty"`
|
DownloadURL string `json:"download_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
@@ -25,18 +26,19 @@ func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
|||||||
if story == nil {
|
if story == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
period := timeutil.Period{Start: story.StartTime, End: story.EndTime}
|
||||||
value := WeatherStoryModule{
|
value := WeatherStoryModule{
|
||||||
Available: true,
|
Available: true,
|
||||||
OfficeID: story.OfficeID,
|
OfficeID: story.OfficeID,
|
||||||
StartTime: story.StartTime,
|
PeriodBegins: friendlyPeriodBeginsLabel(period, ctx.Timezone),
|
||||||
EndTime: story.EndTime,
|
PeriodEnds: friendlyPeriodEndsLabel(period, ctx.Timezone),
|
||||||
UpdatedAt: copyTime(story.UpdatedAt),
|
UpdatedAt: copyTime(story.UpdatedAt),
|
||||||
Title: story.Title,
|
Title: story.Title,
|
||||||
Description: story.Description,
|
Description: story.Description,
|
||||||
AltText: story.AltText,
|
AltText: story.AltText,
|
||||||
Priority: story.Priority,
|
Priority: story.Priority,
|
||||||
Order: story.Order,
|
Order: story.Order,
|
||||||
DownloadURL: story.DownloadURL,
|
DownloadURL: story.DownloadURL,
|
||||||
}
|
}
|
||||||
return &module.Output{ID: module.WeatherStory, StanzaName: "weather_story", Value: value}, nil
|
return &module.Output{ID: module.WeatherStory, StanzaName: "weather_story", Value: value}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ const helpText = `weatherreporter prepares weather reports from normalized forec
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
weatherreporter --help
|
weatherreporter --help
|
||||||
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
|
weatherreporter generate today [--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 +38,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.
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -185,10 +187,10 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
|||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return app.GenerateRequest{}, fmt.Errorf("generate requires a report name")
|
return app.GenerateRequest{}, fmt.Errorf("generate requires a report name")
|
||||||
}
|
}
|
||||||
reportKind, ok := reportKind(args[0])
|
if _, err := report.IDForCommandName(args[0]); err != nil {
|
||||||
if !ok {
|
|
||||||
return app.GenerateRequest{}, fmt.Errorf("unknown generate report %q", args[0])
|
return app.GenerateRequest{}, fmt.Errorf("unknown generate report %q", args[0])
|
||||||
}
|
}
|
||||||
|
reportKind := app.ReportKind(args[0])
|
||||||
|
|
||||||
opts, err := parseGenerateFlags(reportKind, args[1:])
|
opts, err := parseGenerateFlags(reportKind, args[1:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -216,6 +218,14 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
|||||||
|
|
||||||
switch reportKind {
|
switch reportKind {
|
||||||
case app.ReportDaily:
|
case app.ReportDaily:
|
||||||
|
if opts.Date == "" {
|
||||||
|
return app.GenerateRequest{}, fmt.Errorf("generate daily requires --date YYYY-MM-DD")
|
||||||
|
}
|
||||||
|
req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
|
||||||
|
if err != nil {
|
||||||
|
return app.GenerateRequest{}, err
|
||||||
|
}
|
||||||
|
case app.ReportToday:
|
||||||
if opts.Date == "" {
|
if opts.Date == "" {
|
||||||
req.Date = timeutil.LocalDate(r.Clock.Now(), location)
|
req.Date = timeutil.LocalDate(r.Clock.Now(), location)
|
||||||
} else {
|
} else {
|
||||||
@@ -249,10 +259,10 @@ func (r Runner) resolveRun(args []string) (app.BatchRequest, error) {
|
|||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return app.BatchRequest{}, fmt.Errorf("run requires a batch name")
|
return app.BatchRequest{}, fmt.Errorf("run requires a batch name")
|
||||||
}
|
}
|
||||||
batch, ok := batchKind(args[0])
|
if _, err := report.BatchForCommandName(args[0]); err != nil {
|
||||||
if !ok {
|
|
||||||
return app.BatchRequest{}, fmt.Errorf("unknown run batch %q", args[0])
|
return app.BatchRequest{}, fmt.Errorf("unknown run batch %q", args[0])
|
||||||
}
|
}
|
||||||
|
batch := app.BatchKind(args[0])
|
||||||
opts, err := parseRunFlags(args[1:])
|
opts, err := parseRunFlags(args[1:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return app.BatchRequest{}, err
|
return app.BatchRequest{}, err
|
||||||
@@ -277,7 +287,7 @@ func parseGenerateFlags(report app.ReportKind, args []string) (generateOptions,
|
|||||||
fs.SetOutput(io.Discard)
|
fs.SetOutput(io.Discard)
|
||||||
opts := generateOptions{}
|
opts := generateOptions{}
|
||||||
addCommonFlags(fs, &opts.commonOptions, true)
|
addCommonFlags(fs, &opts.commonOptions, true)
|
||||||
if report == app.ReportDaily {
|
if report == app.ReportDaily || report == app.ReportToday {
|
||||||
fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD")
|
fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD")
|
||||||
}
|
}
|
||||||
if report == app.ReportStorm {
|
if report == app.ReportStorm {
|
||||||
@@ -368,6 +378,28 @@ func writeRunLogs(stderr io.Writer, result *app.BatchResult) {
|
|||||||
}
|
}
|
||||||
_, _ = fmt.Fprintf(stderr, "report=%s status=succeeded output=%q%s\n", item.ReportID, item.OutputPath, notificationFields)
|
_, _ = fmt.Fprintf(stderr, "report=%s status=succeeded output=%q%s\n", item.ReportID, item.OutputPath, notificationFields)
|
||||||
}
|
}
|
||||||
|
if result.Notification != nil {
|
||||||
|
_, _ = fmt.Fprintf(stderr, "batchNotification status=%q", result.Notification.Status)
|
||||||
|
if result.Notification.Reason != "" {
|
||||||
|
_, _ = fmt.Fprintf(stderr, " reason=%q", result.Notification.Reason)
|
||||||
|
}
|
||||||
|
if result.Notification.RunID != "" {
|
||||||
|
_, _ = fmt.Fprintf(stderr, " runId=%q", result.Notification.RunID)
|
||||||
|
}
|
||||||
|
if result.Notification.PipelineID != "" {
|
||||||
|
_, _ = fmt.Fprintf(stderr, " pipelineId=%q", result.Notification.PipelineID)
|
||||||
|
}
|
||||||
|
if result.Notification.BundleID != "" {
|
||||||
|
_, _ = fmt.Fprintf(stderr, " bundleId=%q", result.Notification.BundleID)
|
||||||
|
}
|
||||||
|
if result.Notification.Path != "" {
|
||||||
|
_, _ = fmt.Fprintf(stderr, " path=%q", result.Notification.Path)
|
||||||
|
}
|
||||||
|
if result.Notification.Error != "" {
|
||||||
|
_, _ = fmt.Fprintf(stderr, " error=%q", result.Notification.Error)
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintln(stderr)
|
||||||
|
}
|
||||||
_, _ = fmt.Fprintf(stderr, "batch=%s total=%d succeeded=%d failed=%d\n", result.Batch, result.Total, result.Succeeded, result.Failed)
|
_, _ = fmt.Fprintf(stderr, "batch=%s total=%d succeeded=%d failed=%d\n", result.Batch, result.Total, result.Succeeded, result.Failed)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,31 +411,3 @@ func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) {
|
|||||||
fs.StringVar(&opts.Output, "out", "", "extra Markdown report copy path")
|
fs.StringVar(&opts.Output, "out", "", "extra Markdown report copy path")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func reportKind(value string) (app.ReportKind, bool) {
|
|
||||||
switch value {
|
|
||||||
case string(app.ReportDaily):
|
|
||||||
return app.ReportDaily, true
|
|
||||||
case string(app.ReportTomorrow):
|
|
||||||
return app.ReportTomorrow, true
|
|
||||||
case string(app.ReportThreeDay):
|
|
||||||
return app.ReportThreeDay, true
|
|
||||||
case string(app.ReportWeekend):
|
|
||||||
return app.ReportWeekend, true
|
|
||||||
case string(app.ReportStorm):
|
|
||||||
return app.ReportStorm, true
|
|
||||||
default:
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func batchKind(value string) (app.BatchKind, bool) {
|
|
||||||
switch value {
|
|
||||||
case string(app.BatchMorning):
|
|
||||||
return app.BatchMorning, true
|
|
||||||
case string(app.BatchEvening):
|
|
||||||
return app.BatchEvening, true
|
|
||||||
default:
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
31
internal/collect/collect.go
Normal file
31
internal/collect/collect.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Package collect owns upstream weather source collection for application use.
|
||||||
|
package collect
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/weatherapi"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Request struct {
|
||||||
|
Config config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
Bundle *weatherdata.Bundle
|
||||||
|
}
|
||||||
|
|
||||||
|
func Run(ctx context.Context, req Request) (*Result, error) {
|
||||||
|
client, err := weatherapi.New(req.Config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("prepare weather collection: %w", err)
|
||||||
|
}
|
||||||
|
bundle, err := client.FetchBundle(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("collect weather bundle: %w", err)
|
||||||
|
}
|
||||||
|
return &Result{Bundle: bundle}, nil
|
||||||
|
}
|
||||||
99
internal/collect/collect_test.go
Normal file
99
internal/collect/collect_test.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package collect
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunFetchesBundle(t *testing.T) {
|
||||||
|
server := collectionTestServer(t, nil)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||||
|
|
||||||
|
result, err := Run(context.Background(), Request{Config: cfg})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if result == nil || result.Bundle == nil {
|
||||||
|
t.Fatal("Run() result bundle = nil, want fetched bundle")
|
||||||
|
}
|
||||||
|
if result.Bundle.Hourly == nil {
|
||||||
|
t.Fatal("Hourly = nil, want fetched hourly forecast")
|
||||||
|
}
|
||||||
|
if result.Bundle.WeatherStory == nil || result.Bundle.WeatherStory.Title != "Several Chances for Rain Through Monday" {
|
||||||
|
t.Fatalf("WeatherStory = %#v, want fetched weather story", result.Bundle.WeatherStory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunWrapsAdapterConstructionError(t *testing.T) {
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.WeatherAPI.BaseURL = ""
|
||||||
|
cfg.Secrets.Directory = "super-secret-directory"
|
||||||
|
|
||||||
|
_, err := Run(context.Background(), Request{Config: cfg})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Run() error = nil, want adapter construction error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "prepare weather collection") {
|
||||||
|
t.Fatalf("error = %q, want collection setup context", err.Error())
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), cfg.Secrets.Directory) {
|
||||||
|
t.Fatalf("error = %q, want no secret path leakage", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunWrapsFetchError(t *testing.T) {
|
||||||
|
server := collectionTestServer(t, map[string]int{"/observations": http.StatusBadGateway})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||||
|
|
||||||
|
_, err := Run(context.Background(), Request{Config: cfg})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Run() error = nil, want fetch error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "collect weather bundle") {
|
||||||
|
t.Fatalf("error = %q, want collection fetch context", err.Error())
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "/observations") {
|
||||||
|
t.Fatalf("error = %q, want source endpoint context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionTestServer(t *testing.T, statusByPath map[string]int) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if status := statusByPath[r.URL.Path]; status != 0 {
|
||||||
|
http.Error(w, "upstream failure", status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/observations":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`))
|
||||||
|
case "/conditions/current":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`))
|
||||||
|
case "/forecast/hourly":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T13:00:00-05:00","endTime":"2026-05-29T14:00:00-05:00"}]}}`))
|
||||||
|
case "/forecast/narrative":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[]}}`))
|
||||||
|
case "/alerts/active":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"alerts":[]}}`))
|
||||||
|
case "/discussion":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":[],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for saved bundle."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for saved bundle."}}}`))
|
||||||
|
case "/weatherstories/latest":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
|
||||||
|
case "/outlooks/convective":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -58,15 +58,22 @@ type NotifyConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DistributorNotifyConfig struct {
|
type DistributorNotifyConfig struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
Endpoint string `yaml:"endpoint"`
|
Endpoint string `yaml:"endpoint"`
|
||||||
TokenEnv string `yaml:"token_env"`
|
TokenEnv string `yaml:"token_env"`
|
||||||
Timeout time.Duration `yaml:"timeout"`
|
Timeout time.Duration `yaml:"timeout"`
|
||||||
FailurePolicy NotifyFailurePolicy `yaml:"failure_policy"`
|
FailurePolicy NotifyFailurePolicy `yaml:"failure_policy"`
|
||||||
PipelineIDTemplate string `yaml:"pipeline_id_template"`
|
PipelineIDTemplate string `yaml:"pipeline_id_template"`
|
||||||
BundleIDTemplate string `yaml:"bundle_id_template"`
|
BundleIDTemplate string `yaml:"bundle_id_template"`
|
||||||
IdempotencyKeyTemplate string `yaml:"idempotency_key_template"`
|
IdempotencyKeyTemplate string `yaml:"idempotency_key_template"`
|
||||||
ReportPathTemplates []string `yaml:"report_path_templates"`
|
Batch DistributorBatchNotifyConfig `yaml:"batch"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DistributorBatchNotifyConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
PipelineIDTemplate string `yaml:"pipeline_id_template"`
|
||||||
|
BundleIDTemplate string `yaml:"bundle_id_template"`
|
||||||
|
IdempotencyKeyTemplate string `yaml:"idempotency_key_template"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MissingSourceConfig struct {
|
type MissingSourceConfig struct {
|
||||||
@@ -105,10 +112,16 @@ type RecentChangeConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ReportConfig struct {
|
type ReportConfig struct {
|
||||||
DeterministicModules []ModuleConfigItem `yaml:"deterministic_modules"`
|
DeterministicModules []ModuleConfigItem `yaml:"deterministic_modules"`
|
||||||
|
Distributor ReportDistributorConfig `yaml:"distributor"`
|
||||||
deterministicModulesSet bool
|
deterministicModulesSet bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ReportDistributorConfig struct {
|
||||||
|
PathTemplates []string `yaml:"path_templates"`
|
||||||
|
pathTemplatesSet bool
|
||||||
|
}
|
||||||
|
|
||||||
type ModuleConfigItem struct {
|
type ModuleConfigItem struct {
|
||||||
ID module.ID `yaml:"id"`
|
ID module.ID `yaml:"id"`
|
||||||
Options any `yaml:"options,omitempty"`
|
Options any `yaml:"options,omitempty"`
|
||||||
@@ -127,6 +140,10 @@ func (c *ReportConfig) UnmarshalYAML(value *yaml.Node) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c.deterministicModulesSet = true
|
c.deterministicModulesSet = true
|
||||||
|
case "distributor":
|
||||||
|
if err := node.Decode(&c.Distributor); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unknown report entry field %q", key)
|
return fmt.Errorf("unknown report entry field %q", key)
|
||||||
}
|
}
|
||||||
@@ -134,6 +151,112 @@ func (c *ReportConfig) UnmarshalYAML(value *yaml.Node) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *DistributorNotifyConfig) UnmarshalYAML(value *yaml.Node) error {
|
||||||
|
if value.Kind != yaml.MappingNode {
|
||||||
|
return fmt.Errorf("notify distributor entry must be a mapping")
|
||||||
|
}
|
||||||
|
for i := 0; i < len(value.Content); i += 2 {
|
||||||
|
key := value.Content[i].Value
|
||||||
|
node := value.Content[i+1]
|
||||||
|
switch key {
|
||||||
|
case "enabled":
|
||||||
|
if err := node.Decode(&c.Enabled); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case "endpoint":
|
||||||
|
if err := node.Decode(&c.Endpoint); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case "token_env":
|
||||||
|
if err := node.Decode(&c.TokenEnv); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case "timeout":
|
||||||
|
if err := node.Decode(&c.Timeout); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case "failure_policy":
|
||||||
|
if err := node.Decode(&c.FailurePolicy); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case "pipeline_id_template":
|
||||||
|
if err := node.Decode(&c.PipelineIDTemplate); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case "bundle_id_template":
|
||||||
|
if err := node.Decode(&c.BundleIDTemplate); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case "idempotency_key_template":
|
||||||
|
if err := node.Decode(&c.IdempotencyKeyTemplate); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case "batch":
|
||||||
|
if err := node.Decode(&c.Batch); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown notify distributor field %q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DistributorBatchNotifyConfig) UnmarshalYAML(value *yaml.Node) error {
|
||||||
|
if value.Kind != yaml.MappingNode {
|
||||||
|
return fmt.Errorf("notify distributor batch entry must be a mapping")
|
||||||
|
}
|
||||||
|
for i := 0; i < len(value.Content); i += 2 {
|
||||||
|
key := value.Content[i].Value
|
||||||
|
node := value.Content[i+1]
|
||||||
|
switch key {
|
||||||
|
case "enabled":
|
||||||
|
if err := node.Decode(&c.Enabled); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case "pipeline_id_template":
|
||||||
|
if err := node.Decode(&c.PipelineIDTemplate); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case "bundle_id_template":
|
||||||
|
if err := node.Decode(&c.BundleIDTemplate); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case "idempotency_key_template":
|
||||||
|
if err := node.Decode(&c.IdempotencyKeyTemplate); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown notify distributor batch field %q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ReportDistributorConfig) UnmarshalYAML(value *yaml.Node) error {
|
||||||
|
if value.Kind != yaml.MappingNode {
|
||||||
|
return fmt.Errorf("report distributor entry must be a mapping")
|
||||||
|
}
|
||||||
|
for i := 0; i < len(value.Content); i += 2 {
|
||||||
|
key := value.Content[i].Value
|
||||||
|
node := value.Content[i+1]
|
||||||
|
switch key {
|
||||||
|
case "path_templates":
|
||||||
|
if err := node.Decode(&c.PathTemplates); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.pathTemplatesSet = true
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown report distributor field %q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c ReportDistributorConfig) PathTemplatesSet() bool {
|
||||||
|
return c.pathTemplatesSet
|
||||||
|
}
|
||||||
|
|
||||||
func (m *ModuleConfigItem) UnmarshalYAML(value *yaml.Node) error {
|
func (m *ModuleConfigItem) UnmarshalYAML(value *yaml.Node) error {
|
||||||
switch value.Kind {
|
switch value.Kind {
|
||||||
case yaml.ScalarNode:
|
case yaml.ScalarNode:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -31,8 +31,11 @@ func Defaults() Config {
|
|||||||
PipelineIDTemplate: "",
|
PipelineIDTemplate: "",
|
||||||
BundleIDTemplate: "weatherreporter.{location_id}.{report_id}",
|
BundleIDTemplate: "weatherreporter.{location_id}.{report_id}",
|
||||||
IdempotencyKeyTemplate: "{bundle_id}.{run_id}",
|
IdempotencyKeyTemplate: "{bundle_id}.{run_id}",
|
||||||
ReportPathTemplates: []string{
|
Batch: DistributorBatchNotifyConfig{
|
||||||
"{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md",
|
Enabled: true,
|
||||||
|
PipelineIDTemplate: "weatherreporter",
|
||||||
|
BundleIDTemplate: "weatherreporter.{location_id}.{batch}",
|
||||||
|
IdempotencyKeyTemplate: "{bundle_id}.{batch_run_id}",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,9 +18,18 @@ type DistributorTemplateValues struct {
|
|||||||
ValidEndTime string
|
ValidEndTime string
|
||||||
ValidStartStamp string
|
ValidStartStamp string
|
||||||
ValidEndStamp string
|
ValidEndStamp string
|
||||||
|
StormID string
|
||||||
BundleID string
|
BundleID string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DistributorBatchTemplateValues struct {
|
||||||
|
LocationID string
|
||||||
|
Batch string
|
||||||
|
BatchRunID string
|
||||||
|
BatchStartedDate string
|
||||||
|
BundleID string
|
||||||
|
}
|
||||||
|
|
||||||
var distributorTemplateVariables = map[string]struct{}{
|
var distributorTemplateVariables = map[string]struct{}{
|
||||||
"location_id": {},
|
"location_id": {},
|
||||||
"report_id": {},
|
"report_id": {},
|
||||||
@@ -33,6 +42,7 @@ var distributorTemplateVariables = map[string]struct{}{
|
|||||||
"valid_end_time": {},
|
"valid_end_time": {},
|
||||||
"valid_start_stamp": {},
|
"valid_start_stamp": {},
|
||||||
"valid_end_stamp": {},
|
"valid_end_stamp": {},
|
||||||
|
"storm_id": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
var distributorIdempotencyTemplateVariables = map[string]struct{}{
|
var distributorIdempotencyTemplateVariables = map[string]struct{}{
|
||||||
@@ -47,11 +57,29 @@ var distributorIdempotencyTemplateVariables = map[string]struct{}{
|
|||||||
"valid_end_time": {},
|
"valid_end_time": {},
|
||||||
"valid_start_stamp": {},
|
"valid_start_stamp": {},
|
||||||
"valid_end_stamp": {},
|
"valid_end_stamp": {},
|
||||||
|
"storm_id": {},
|
||||||
"bundle_id": {},
|
"bundle_id": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
var distributorPipelineTemplateVariables = distributorIdempotencyTemplateVariables
|
var distributorPipelineTemplateVariables = distributorIdempotencyTemplateVariables
|
||||||
|
|
||||||
|
var distributorBatchTemplateVariables = map[string]struct{}{
|
||||||
|
"location_id": {},
|
||||||
|
"batch": {},
|
||||||
|
"batch_run_id": {},
|
||||||
|
"batch_started_date": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
var distributorBatchIdempotencyTemplateVariables = map[string]struct{}{
|
||||||
|
"location_id": {},
|
||||||
|
"batch": {},
|
||||||
|
"batch_run_id": {},
|
||||||
|
"batch_started_date": {},
|
||||||
|
"bundle_id": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
var distributorBatchPipelineTemplateVariables = distributorBatchTemplateVariables
|
||||||
|
|
||||||
func RenderDistributorBundleID(template string, values DistributorTemplateValues) (string, error) {
|
func RenderDistributorBundleID(template string, values DistributorTemplateValues) (string, error) {
|
||||||
return renderDistributorTemplate("notify.distributor.bundle_id_template", template, values, distributorTemplateVariables)
|
return renderDistributorTemplate("notify.distributor.bundle_id_template", template, values, distributorTemplateVariables)
|
||||||
}
|
}
|
||||||
@@ -71,23 +99,56 @@ func RenderDistributorIdempotencyKey(template string, values DistributorTemplate
|
|||||||
return renderDistributorTemplate("notify.distributor.idempotency_key_template", template, values, distributorIdempotencyTemplateVariables)
|
return renderDistributorTemplate("notify.distributor.idempotency_key_template", template, values, distributorIdempotencyTemplateVariables)
|
||||||
}
|
}
|
||||||
|
|
||||||
func RenderDistributorReportPaths(templates []string, values DistributorTemplateValues) ([]string, error) {
|
func RenderDistributorBatchBundleID(template string, values DistributorBatchTemplateValues) (string, error) {
|
||||||
|
rendered, err := renderDistributorBatchTemplate("notify.distributor.batch.bundle_id_template", template, values, distributorBatchTemplateVariables)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(rendered) == "" {
|
||||||
|
return "", fmt.Errorf("notify.distributor.batch.bundle_id_template renders an empty bundle id")
|
||||||
|
}
|
||||||
|
return rendered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RenderDistributorBatchPipelineID(template string, values DistributorBatchTemplateValues) (string, error) {
|
||||||
|
rendered, err := renderDistributorBatchTemplate("notify.distributor.batch.pipeline_id_template", template, values, distributorBatchPipelineTemplateVariables)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(rendered) == "" {
|
||||||
|
return "", fmt.Errorf("notify.distributor.batch.pipeline_id_template renders an empty pipeline id")
|
||||||
|
}
|
||||||
|
return rendered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RenderDistributorBatchIdempotencyKey(template string, values DistributorBatchTemplateValues) (string, error) {
|
||||||
|
rendered, err := renderDistributorBatchTemplate("notify.distributor.batch.idempotency_key_template", template, values, distributorBatchIdempotencyTemplateVariables)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(rendered) == "" {
|
||||||
|
return "", fmt.Errorf("notify.distributor.batch.idempotency_key_template renders an empty idempotency key")
|
||||||
|
}
|
||||||
|
return rendered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RenderDistributorReportPaths(name string, templates []string, values DistributorTemplateValues) ([]string, error) {
|
||||||
if len(templates) == 0 {
|
if len(templates) == 0 {
|
||||||
return nil, fmt.Errorf("notify.distributor.report_path_templates must contain at least one entry")
|
return nil, fmt.Errorf("%s must contain at least one entry", name)
|
||||||
}
|
}
|
||||||
paths := make([]string, 0, len(templates))
|
paths := make([]string, 0, len(templates))
|
||||||
seen := make(map[string]struct{}, len(templates))
|
seen := make(map[string]struct{}, len(templates))
|
||||||
for i, template := range templates {
|
for i, template := range templates {
|
||||||
name := fmt.Sprintf("notify.distributor.report_path_templates[%d]", i)
|
itemName := fmt.Sprintf("%s[%d]", name, i)
|
||||||
rendered, err := renderDistributorTemplate(name, template, values, distributorTemplateVariables)
|
rendered, err := renderDistributorTemplate(itemName, template, values, distributorTemplateVariables)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := ValidateDistributorReportPath(name, rendered); err != nil {
|
if err := ValidateDistributorReportPath(itemName, rendered); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if _, ok := seen[rendered]; ok {
|
if _, ok := seen[rendered]; ok {
|
||||||
return nil, fmt.Errorf("notify.distributor.report_path_templates renders duplicate path %q", rendered)
|
return nil, fmt.Errorf("%s renders duplicate path %q", name, rendered)
|
||||||
}
|
}
|
||||||
seen[rendered] = struct{}{}
|
seen[rendered] = struct{}{}
|
||||||
paths = append(paths, rendered)
|
paths = append(paths, rendered)
|
||||||
@@ -100,6 +161,11 @@ func validateDistributorTemplate(name, template string, allowed map[string]struc
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateDistributorBatchTemplate(name, template string, allowed map[string]struct{}) error {
|
||||||
|
_, err := renderDistributorBatchTemplate(name, template, DistributorBatchTemplateValues{}, allowed)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func renderDistributorTemplate(name, template string, values DistributorTemplateValues, allowed map[string]struct{}) (string, error) {
|
func renderDistributorTemplate(name, template string, values DistributorTemplateValues, allowed map[string]struct{}) (string, error) {
|
||||||
var rendered strings.Builder
|
var rendered strings.Builder
|
||||||
for i := 0; i < len(template); {
|
for i := 0; i < len(template); {
|
||||||
@@ -128,6 +194,34 @@ func renderDistributorTemplate(name, template string, values DistributorTemplate
|
|||||||
return rendered.String(), nil
|
return rendered.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func renderDistributorBatchTemplate(name, template string, values DistributorBatchTemplateValues, allowed map[string]struct{}) (string, error) {
|
||||||
|
var rendered strings.Builder
|
||||||
|
for i := 0; i < len(template); {
|
||||||
|
switch template[i] {
|
||||||
|
case '{':
|
||||||
|
end := strings.IndexByte(template[i+1:], '}')
|
||||||
|
if end < 0 {
|
||||||
|
return "", fmt.Errorf("%s contains an unclosed template variable", name)
|
||||||
|
}
|
||||||
|
variable := template[i+1 : i+1+end]
|
||||||
|
if variable == "" {
|
||||||
|
return "", fmt.Errorf("%s contains an empty template variable", name)
|
||||||
|
}
|
||||||
|
if _, ok := allowed[variable]; !ok {
|
||||||
|
return "", fmt.Errorf("%s contains unknown template variable %q", name, variable)
|
||||||
|
}
|
||||||
|
rendered.WriteString(distributorBatchTemplateValue(variable, values))
|
||||||
|
i += end + 2
|
||||||
|
case '}':
|
||||||
|
return "", fmt.Errorf("%s contains an unopened template variable", name)
|
||||||
|
default:
|
||||||
|
rendered.WriteByte(template[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rendered.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
func distributorTemplateValue(variable string, values DistributorTemplateValues) string {
|
func distributorTemplateValue(variable string, values DistributorTemplateValues) string {
|
||||||
switch variable {
|
switch variable {
|
||||||
case "location_id":
|
case "location_id":
|
||||||
@@ -152,6 +246,25 @@ func distributorTemplateValue(variable string, values DistributorTemplateValues)
|
|||||||
return values.ValidStartStamp
|
return values.ValidStartStamp
|
||||||
case "valid_end_stamp":
|
case "valid_end_stamp":
|
||||||
return values.ValidEndStamp
|
return values.ValidEndStamp
|
||||||
|
case "storm_id":
|
||||||
|
return values.StormID
|
||||||
|
case "bundle_id":
|
||||||
|
return values.BundleID
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func distributorBatchTemplateValue(variable string, values DistributorBatchTemplateValues) string {
|
||||||
|
switch variable {
|
||||||
|
case "location_id":
|
||||||
|
return values.LocationID
|
||||||
|
case "batch":
|
||||||
|
return values.Batch
|
||||||
|
case "batch_run_id":
|
||||||
|
return values.BatchRunID
|
||||||
|
case "batch_started_date":
|
||||||
|
return values.BatchStartedDate
|
||||||
case "bundle_id":
|
case "bundle_id":
|
||||||
return values.BundleID
|
return values.BundleID
|
||||||
default:
|
default:
|
||||||
@@ -178,7 +291,7 @@ func ValidateDistributorReportPath(name, path string) error {
|
|||||||
if segment == "." || segment == ".." {
|
if segment == "." || segment == ".." {
|
||||||
return fmt.Errorf("%s must not render . or .. path segments", name)
|
return fmt.Errorf("%s must not render . or .. path segments", name)
|
||||||
}
|
}
|
||||||
if segment == "manifest.json" || segment == ".distributor.json" {
|
if segment == "manifest.json" || segment == distributorSidecarBasename() {
|
||||||
return fmt.Errorf("%s must not render reserved path segment %q", name, segment)
|
return fmt.Errorf("%s must not render reserved path segment %q", name, segment)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,6 +299,10 @@ func ValidateDistributorReportPath(name, path string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func distributorSidecarBasename() string {
|
||||||
|
return "." + "distributor.json"
|
||||||
|
}
|
||||||
|
|
||||||
func isDistributorAbsolutePath(path string) bool {
|
func isDistributorAbsolutePath(path string) bool {
|
||||||
if filepath.IsAbs(path) || strings.HasPrefix(path, "/") {
|
if filepath.IsAbs(path) || strings.HasPrefix(path, "/") {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
@@ -12,65 +11,163 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (cfg Config) ReportModuleOverrides() map[report.ID][]module.ConfigItem {
|
func (cfg Config) ReportModuleOverrides() (map[report.ID][]module.ConfigItem, error) {
|
||||||
overrides := map[report.ID][]module.ConfigItem{}
|
return traverseReportModules(&cfg, reportModuleTraversalOptions{
|
||||||
for key, reportCfg := range cfg.Reports {
|
normalizeOptions: true,
|
||||||
if !reportCfg.deterministicModulesSet {
|
})
|
||||||
continue
|
}
|
||||||
}
|
|
||||||
id, err := reportIDForConfigKey(key)
|
func (cfg Config) ReportDistributorPathOverrides() (map[report.ID][]string, error) {
|
||||||
if err != nil {
|
return traverseReportDistributorPathOverrides(cfg)
|
||||||
continue
|
|
||||||
}
|
|
||||||
items := make([]module.ConfigItem, 0, len(reportCfg.DeterministicModules))
|
|
||||||
for _, item := range reportCfg.DeterministicModules {
|
|
||||||
items = append(items, module.ConfigItem{ID: item.ID, Options: item.Options})
|
|
||||||
}
|
|
||||||
overrides[id] = items
|
|
||||||
}
|
|
||||||
return overrides
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeReportModules(cfg *Config) error {
|
func normalizeReportModules(cfg *Config) error {
|
||||||
if cfg.Reports == nil {
|
if cfg.Reports == nil {
|
||||||
cfg.Reports = map[string]ReportConfig{}
|
cfg.Reports = map[string]ReportConfig{}
|
||||||
}
|
}
|
||||||
|
_, err := traverseReportModules(cfg, reportModuleTraversalOptions{
|
||||||
|
normalizeOptions: true,
|
||||||
|
updateConfig: true,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateReportModules(cfg Config) error {
|
||||||
|
_, err := traverseReportModules(&cfg, reportModuleTraversalOptions{
|
||||||
|
normalizeOptions: true,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateReportDistributorPathOverrides(cfg Config) error {
|
||||||
|
_, err := traverseReportDistributorPathOverrides(cfg)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type reportModuleTraversalOptions struct {
|
||||||
|
normalizeOptions bool
|
||||||
|
updateConfig bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func traverseReportModules(cfg *Config, opts reportModuleTraversalOptions) (map[report.ID][]module.ConfigItem, error) {
|
||||||
|
overrides := map[report.ID][]module.ConfigItem{}
|
||||||
|
if cfg.Reports == nil {
|
||||||
|
return overrides, nil
|
||||||
|
}
|
||||||
moduleRegistry, err := briefing.DefaultModuleRegistry()
|
moduleRegistry, err := briefing.DefaultModuleRegistry()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("initialize module registry: %w", err)
|
return nil, fmt.Errorf("initialize module registry: %w", err)
|
||||||
}
|
}
|
||||||
reportRegistry := report.DefaultRegistry()
|
reportRegistry := report.DefaultRegistry()
|
||||||
seenReports := map[report.ID]string{}
|
seenReports := map[report.ID]string{}
|
||||||
for key, reportCfg := range cfg.Reports {
|
for key, reportCfg := range cfg.Reports {
|
||||||
reportID, err := reportIDForConfigKey(key)
|
reportID, err := report.IDForConfigKey(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, fmt.Errorf("reports.%s: %w", key, err)
|
||||||
}
|
}
|
||||||
if previous, ok := seenReports[reportID]; ok {
|
if previous, ok := seenReports[reportID]; ok {
|
||||||
return fmt.Errorf("reports.%s duplicates report override %q", key, previous)
|
return nil, fmt.Errorf("reports.%s duplicates report override %q", key, previous)
|
||||||
}
|
}
|
||||||
seenReports[reportID] = key
|
seenReports[reportID] = key
|
||||||
if _, err := reportRegistry.Lookup(reportID); err != nil {
|
if _, err := reportRegistry.Lookup(reportID); err != nil {
|
||||||
return fmt.Errorf("reports.%s: %w", key, err)
|
return nil, fmt.Errorf("reports.%s: %w", key, err)
|
||||||
}
|
}
|
||||||
if !reportCfg.deterministicModulesSet {
|
if !reportCfg.deterministicModulesSet {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
items := make([]module.ConfigItem, 0, len(reportCfg.DeterministicModules))
|
items, normalized, err := moduleItemsFromConfig(moduleRegistry, key, reportCfg.DeterministicModules, opts.normalizeOptions)
|
||||||
for i, rawItem := range reportCfg.DeterministicModules {
|
if err != nil {
|
||||||
options, err := normalizeModuleOptions(moduleRegistry, rawItem.ID, rawItem.Options)
|
return nil, err
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reports.%s.deterministic_modules[%d]: %w", key, i, err)
|
|
||||||
}
|
|
||||||
reportCfg.DeterministicModules[i].Options = options
|
|
||||||
items = append(items, module.ConfigItem{ID: rawItem.ID, Options: options})
|
|
||||||
}
|
}
|
||||||
if err := moduleRegistry.ValidateComposition(reportID, items); err != nil {
|
if err := moduleRegistry.ValidateComposition(reportID, items); err != nil {
|
||||||
return fmt.Errorf("reports.%s.deterministic_modules: %w", key, err)
|
return nil, fmt.Errorf("reports.%s.deterministic_modules: %w", key, err)
|
||||||
|
}
|
||||||
|
overrides[reportID] = items
|
||||||
|
if opts.updateConfig {
|
||||||
|
reportCfg.DeterministicModules = normalized
|
||||||
|
cfg.Reports[key] = reportCfg
|
||||||
}
|
}
|
||||||
cfg.Reports[key] = reportCfg
|
|
||||||
}
|
}
|
||||||
return nil
|
return overrides, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func traverseReportDistributorPathOverrides(cfg Config) (map[report.ID][]string, error) {
|
||||||
|
overrides := map[report.ID][]string{}
|
||||||
|
if cfg.Reports == nil {
|
||||||
|
return overrides, nil
|
||||||
|
}
|
||||||
|
reportRegistry := report.DefaultRegistry()
|
||||||
|
seenReports := map[report.ID]string{}
|
||||||
|
for key, reportCfg := range cfg.Reports {
|
||||||
|
reportID, err := report.IDForConfigKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reports.%s: %w", key, err)
|
||||||
|
}
|
||||||
|
if previous, ok := seenReports[reportID]; ok {
|
||||||
|
return nil, fmt.Errorf("reports.%s duplicates report override %q", key, previous)
|
||||||
|
}
|
||||||
|
seenReports[reportID] = key
|
||||||
|
if _, err := reportRegistry.Lookup(reportID); err != nil {
|
||||||
|
return nil, fmt.Errorf("reports.%s: %w", key, err)
|
||||||
|
}
|
||||||
|
if !reportCfg.Distributor.pathTemplatesSet {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := validateReportDistributorPathTemplates(key, reportID, reportCfg.Distributor.PathTemplates); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
overrides[reportID] = append([]string(nil), reportCfg.Distributor.PathTemplates...)
|
||||||
|
}
|
||||||
|
return overrides, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateReportDistributorPathTemplates(reportKey string, reportID report.ID, templates []string) error {
|
||||||
|
name := fmt.Sprintf("reports.%s.distributor.path_templates", reportKey)
|
||||||
|
_, err := RenderDistributorReportPaths(name, templates, sampleDistributorTemplateValuesForReport(reportID))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func sampleDistributorTemplateValues() DistributorTemplateValues {
|
||||||
|
return sampleDistributorTemplateValuesForReport(report.Storm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sampleDistributorTemplateValuesForReport(reportID report.ID) DistributorTemplateValues {
|
||||||
|
stormID := ""
|
||||||
|
if reportID == report.Storm {
|
||||||
|
stormID = "2026-05-29T0000-2026-05-30T0000"
|
||||||
|
}
|
||||||
|
return DistributorTemplateValues{
|
||||||
|
LocationID: "location",
|
||||||
|
ReportID: "report",
|
||||||
|
RunID: "run",
|
||||||
|
ArtifactGroup: "artifact",
|
||||||
|
BatchOutputName: "report.md",
|
||||||
|
ValidStartDate: "2026-05-29",
|
||||||
|
ValidEndDate: "2026-05-30",
|
||||||
|
ValidStartTime: "0000",
|
||||||
|
ValidEndTime: "0000",
|
||||||
|
ValidStartStamp: "2026-05-29T0000",
|
||||||
|
ValidEndStamp: "2026-05-30T0000",
|
||||||
|
StormID: stormID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func moduleItemsFromConfig(registry briefing.ModuleRegistry, reportKey string, items []ModuleConfigItem, normalizeOptions bool) ([]module.ConfigItem, []ModuleConfigItem, error) {
|
||||||
|
out := make([]module.ConfigItem, 0, len(items))
|
||||||
|
normalizedItems := append([]ModuleConfigItem(nil), items...)
|
||||||
|
for i, item := range items {
|
||||||
|
options := item.Options
|
||||||
|
if normalizeOptions {
|
||||||
|
var err error
|
||||||
|
options, err = normalizeModuleOptions(registry, item.ID, item.Options)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("reports.%s.deterministic_modules[%d]: %w", reportKey, i, err)
|
||||||
|
}
|
||||||
|
normalizedItems[i].Options = options
|
||||||
|
}
|
||||||
|
out = append(out, module.ConfigItem{ID: item.ID, Options: options})
|
||||||
|
}
|
||||||
|
return out, normalizedItems, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeModuleOptions(registry briefing.ModuleRegistry, id module.ID, raw any) (any, error) {
|
func normalizeModuleOptions(registry briefing.ModuleRegistry, id module.ID, raw any) (any, error) {
|
||||||
@@ -84,6 +181,9 @@ func normalizeModuleOptions(registry briefing.ModuleRegistry, id module.ID, raw
|
|||||||
if definition.DefaultOptions == nil {
|
if definition.DefaultOptions == nil {
|
||||||
return nil, fmt.Errorf("module %q does not accept options", id)
|
return nil, fmt.Errorf("module %q does not accept options", id)
|
||||||
}
|
}
|
||||||
|
if err := definition.ValidateOptions(raw); err == nil {
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
optionType := reflect.TypeOf(definition.DefaultOptions)
|
optionType := reflect.TypeOf(definition.DefaultOptions)
|
||||||
normalized, err := decodeKnownOptions(raw, optionType)
|
normalized, err := decodeKnownOptions(raw, optionType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -105,21 +205,3 @@ func decodeKnownOptions(raw any, optionType reflect.Type) (any, error) {
|
|||||||
}
|
}
|
||||||
return target.Elem().Interface(), nil
|
return target.Elem().Interface(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func reportIDForConfigKey(key string) (report.ID, error) {
|
|
||||||
normalized := strings.ReplaceAll(strings.TrimSpace(strings.ToLower(key)), "-", "_")
|
|
||||||
switch normalized {
|
|
||||||
case "daily", "daily_today":
|
|
||||||
return report.DailyToday, nil
|
|
||||||
case "tomorrow", "daily_tomorrow":
|
|
||||||
return report.DailyTomorrow, nil
|
|
||||||
case "three_day", "three_day_outlook":
|
|
||||||
return report.ThreeDay, nil
|
|
||||||
case "weekend", "weekend_outlook":
|
|
||||||
return report.Weekend, nil
|
|
||||||
case "storm", "storm_report":
|
|
||||||
return report.Storm, nil
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("reports.%s is not a known report", key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func Validate(cfg Config) error {
|
func Validate(cfg Config) error {
|
||||||
if err := normalizeReportModules(&cfg); err != nil {
|
if err := validateReportModules(cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateReportDistributorPathOverrides(cfg); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if cfg.WeatherAPI.BaseURL != "" {
|
if cfg.WeatherAPI.BaseURL != "" {
|
||||||
@@ -121,22 +124,7 @@ func validateDistributorNotify(cfg DistributorNotifyConfig) error {
|
|||||||
if err := validateDistributorTemplate("notify.distributor.idempotency_key_template", cfg.IdempotencyKeyTemplate, distributorIdempotencyTemplateVariables); err != nil {
|
if err := validateDistributorTemplate("notify.distributor.idempotency_key_template", cfg.IdempotencyKeyTemplate, distributorIdempotencyTemplateVariables); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(cfg.ReportPathTemplates) == 0 {
|
values := sampleDistributorTemplateValues()
|
||||||
return fmt.Errorf("notify.distributor.report_path_templates must contain at least one entry when enabled")
|
|
||||||
}
|
|
||||||
values := DistributorTemplateValues{
|
|
||||||
LocationID: "location",
|
|
||||||
ReportID: "report",
|
|
||||||
RunID: "run",
|
|
||||||
ArtifactGroup: "artifact",
|
|
||||||
BatchOutputName: "report.md",
|
|
||||||
ValidStartDate: "2026-05-29",
|
|
||||||
ValidEndDate: "2026-05-30",
|
|
||||||
ValidStartTime: "0000",
|
|
||||||
ValidEndTime: "0000",
|
|
||||||
ValidStartStamp: "2026-05-29T0000",
|
|
||||||
ValidEndStamp: "2026-05-30T0000",
|
|
||||||
}
|
|
||||||
bundleID, err := RenderDistributorBundleID(cfg.BundleIDTemplate, values)
|
bundleID, err := RenderDistributorBundleID(cfg.BundleIDTemplate, values)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -145,13 +133,56 @@ func validateDistributorNotify(cfg DistributorNotifyConfig) error {
|
|||||||
if _, err := RenderDistributorPipelineID(cfg.PipelineIDTemplate, values); err != nil {
|
if _, err := RenderDistributorPipelineID(cfg.PipelineIDTemplate, values); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := RenderDistributorReportPaths(cfg.ReportPathTemplates, values); err != nil {
|
if err := validateDistributorBatchNotify(cfg.Batch); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateDistributorBatchNotify(cfg DistributorBatchNotifyConfig) error {
|
||||||
|
if !cfg.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if cfg.PipelineIDTemplate == "" {
|
||||||
|
return fmt.Errorf("notify.distributor.batch.pipeline_id_template is required when enabled")
|
||||||
|
}
|
||||||
|
if err := validateDistributorBatchTemplate("notify.distributor.batch.pipeline_id_template", cfg.PipelineIDTemplate, distributorBatchPipelineTemplateVariables); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cfg.BundleIDTemplate == "" {
|
||||||
|
return fmt.Errorf("notify.distributor.batch.bundle_id_template is required when enabled")
|
||||||
|
}
|
||||||
|
if err := validateDistributorBatchTemplate("notify.distributor.batch.bundle_id_template", cfg.BundleIDTemplate, distributorBatchTemplateVariables); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cfg.IdempotencyKeyTemplate == "" {
|
||||||
|
return fmt.Errorf("notify.distributor.batch.idempotency_key_template is required when enabled")
|
||||||
|
}
|
||||||
|
if err := validateDistributorBatchTemplate("notify.distributor.batch.idempotency_key_template", cfg.IdempotencyKeyTemplate, distributorBatchIdempotencyTemplateVariables); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
values := DistributorBatchTemplateValues{
|
||||||
|
LocationID: "location",
|
||||||
|
Batch: "morning",
|
||||||
|
BatchRunID: "20260529T100000.000000000Z_morning",
|
||||||
|
BatchStartedDate: "2026-05-29",
|
||||||
|
}
|
||||||
|
bundleID, err := RenderDistributorBatchBundleID(cfg.BundleIDTemplate, values)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
values.BundleID = bundleID
|
||||||
|
if _, err := RenderDistributorBatchPipelineID(cfg.PipelineIDTemplate, values); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := RenderDistributorBatchIdempotencyKey(cfg.IdempotencyKeyTemplate, values); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func validatePolicy(name string, policy MissingSourcePolicy) error {
|
func validatePolicy(name string, policy MissingSourcePolicy) error {
|
||||||
switch policy {
|
switch policy {
|
||||||
case MissingSourceError, MissingSourceWarn, MissingSourceNone:
|
case MissingSourceError, MissingSourceWarn, MissingSourceNone:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package facts
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
@@ -12,15 +13,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type CollectedFacts struct {
|
type CollectedFacts struct {
|
||||||
FetchedAt time.Time
|
FetchedAt time.Time
|
||||||
Observation *weatherdata.Observation
|
Observation *weatherdata.Observation
|
||||||
Current *weatherdata.Current
|
Current *weatherdata.Current
|
||||||
Hourly *weatherdata.ForecastRun
|
Hourly *weatherdata.ForecastRun
|
||||||
Narrative *weatherdata.ForecastRun
|
Narrative *weatherdata.ForecastRun
|
||||||
Alerts *weatherdata.AlertRun
|
Alerts *weatherdata.AlertRun
|
||||||
Discussion *weatherdata.Discussion
|
Discussion *weatherdata.Discussion
|
||||||
Daily *weatherdata.ForecastRun
|
Daily *weatherdata.ForecastRun
|
||||||
WeatherStory *weatherdata.WeatherStory
|
WeatherStory *weatherdata.WeatherStory
|
||||||
|
SPCConvectiveOutlooks *weatherdata.ConvectiveOutlookRun
|
||||||
|
|
||||||
SourceProvenance []weatherdata.Source
|
SourceProvenance []weatherdata.Source
|
||||||
SourceWarnings []weatherdata.SourceWarning
|
SourceWarnings []weatherdata.SourceWarning
|
||||||
@@ -31,33 +33,35 @@ func BuildCollected(bundle *weatherdata.Bundle) CollectedFacts {
|
|||||||
return CollectedFacts{}
|
return CollectedFacts{}
|
||||||
}
|
}
|
||||||
return CollectedFacts{
|
return CollectedFacts{
|
||||||
FetchedAt: bundle.FetchedAt,
|
FetchedAt: bundle.FetchedAt,
|
||||||
Observation: bundle.Observation,
|
Observation: bundle.Observation,
|
||||||
Current: bundle.Current,
|
Current: bundle.Current,
|
||||||
Hourly: bundle.Hourly,
|
Hourly: bundle.Hourly,
|
||||||
Narrative: bundle.Narrative,
|
Narrative: bundle.Narrative,
|
||||||
Alerts: bundle.Alerts,
|
Alerts: bundle.Alerts,
|
||||||
Discussion: bundle.Discussion,
|
Discussion: bundle.Discussion,
|
||||||
Daily: bundle.Daily,
|
Daily: bundle.Daily,
|
||||||
WeatherStory: bundle.WeatherStory,
|
WeatherStory: bundle.WeatherStory,
|
||||||
SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...),
|
SPCConvectiveOutlooks: bundle.SPCConvectiveOutlooks,
|
||||||
SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...),
|
SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...),
|
||||||
|
SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f CollectedFacts) Bundle() *weatherdata.Bundle {
|
func (f CollectedFacts) Bundle() *weatherdata.Bundle {
|
||||||
return &weatherdata.Bundle{
|
return &weatherdata.Bundle{
|
||||||
FetchedAt: f.FetchedAt,
|
FetchedAt: f.FetchedAt,
|
||||||
Observation: f.Observation,
|
Observation: f.Observation,
|
||||||
Current: f.Current,
|
Current: f.Current,
|
||||||
Hourly: f.Hourly,
|
Hourly: f.Hourly,
|
||||||
Narrative: f.Narrative,
|
Narrative: f.Narrative,
|
||||||
Alerts: f.Alerts,
|
Alerts: f.Alerts,
|
||||||
Discussion: f.Discussion,
|
Discussion: f.Discussion,
|
||||||
Daily: f.Daily,
|
Daily: f.Daily,
|
||||||
WeatherStory: f.WeatherStory,
|
WeatherStory: f.WeatherStory,
|
||||||
Sources: append([]weatherdata.Source(nil), f.SourceProvenance...),
|
SPCConvectiveOutlooks: f.SPCConvectiveOutlooks,
|
||||||
Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...),
|
Sources: append([]weatherdata.Source(nil), f.SourceProvenance...),
|
||||||
|
Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +77,8 @@ type DerivedFacts struct {
|
|||||||
ValidPeriodNarrativePeriods []weatherdata.ForecastPeriod
|
ValidPeriodNarrativePeriods []weatherdata.ForecastPeriod
|
||||||
ValidPeriodDailyPeriods []weatherdata.ForecastPeriod
|
ValidPeriodDailyPeriods []weatherdata.ForecastPeriod
|
||||||
AlertOverlaps []forecast.AlertOverlap
|
AlertOverlaps []forecast.AlertOverlap
|
||||||
|
SPCConvectiveOutlooks []weatherdata.ConvectiveOutlook
|
||||||
|
SPCConvectiveDiscussions []weatherdata.ConvectiveOutlookDiscussion
|
||||||
DailySummaries []forecast.DailySummary
|
DailySummaries []forecast.DailySummary
|
||||||
DaypartSummaries []forecast.DaypartSummary
|
DaypartSummaries []forecast.DaypartSummary
|
||||||
PrecipTiming forecast.PrecipTiming
|
PrecipTiming forecast.PrecipTiming
|
||||||
@@ -96,16 +102,20 @@ 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.DailyToday, report.DailyTomorrow:
|
case report.Hourly:
|
||||||
|
case report.Daily, report.Today, report.Tomorrow:
|
||||||
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 {
|
||||||
return DerivedFacts{}, err
|
return DerivedFacts{}, err
|
||||||
@@ -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,14 +17,24 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
|
|||||||
FetchedAt: fetchedAt,
|
FetchedAt: fetchedAt,
|
||||||
Current: &weatherdata.Current{ConditionText: "Clear"},
|
Current: &weatherdata.Current{ConditionText: "Clear"},
|
||||||
Hourly: &weatherdata.ForecastRun{Product: "hourly"},
|
Hourly: &weatherdata.ForecastRun{Product: "hourly"},
|
||||||
Sources: []weatherdata.Source{{Name: "hourly"}},
|
SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{
|
||||||
Warnings: []weatherdata.SourceWarning{{Source: "discussion", Code: "missing_source"}},
|
Product: "convective_outlook",
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{{
|
||||||
|
ID: "day1-categorical-slight",
|
||||||
|
Label: "SLGT",
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
Sources: []weatherdata.Source{{Name: "hourly"}},
|
||||||
|
Warnings: []weatherdata.SourceWarning{{Source: "discussion", Code: "missing_source"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
collected := BuildCollected(bundle)
|
collected := BuildCollected(bundle)
|
||||||
if collected.FetchedAt != fetchedAt || collected.Current.ConditionText != "Clear" || collected.Hourly.Product != "hourly" {
|
if collected.FetchedAt != fetchedAt || collected.Current.ConditionText != "Clear" || collected.Hourly.Product != "hourly" {
|
||||||
t.Fatalf("CollectedFacts = %#v, want source facts copied from bundle", collected)
|
t.Fatalf("CollectedFacts = %#v, want source facts copied from bundle", collected)
|
||||||
}
|
}
|
||||||
|
if collected.SPCConvectiveOutlooks == nil || collected.SPCConvectiveOutlooks.Outlooks[0].Label != "SLGT" {
|
||||||
|
t.Fatalf("SPCConvectiveOutlooks = %#v, want source copied from bundle", collected.SPCConvectiveOutlooks)
|
||||||
|
}
|
||||||
if len(collected.SourceProvenance) != 1 || collected.SourceProvenance[0].Name != "hourly" {
|
if len(collected.SourceProvenance) != 1 || collected.SourceProvenance[0].Name != "hourly" {
|
||||||
t.Fatalf("SourceProvenance = %#v, want hourly source", collected.SourceProvenance)
|
t.Fatalf("SourceProvenance = %#v, want hourly source", collected.SourceProvenance)
|
||||||
}
|
}
|
||||||
@@ -36,11 +47,16 @@ 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) {
|
||||||
location := testLocation()
|
location := testLocation()
|
||||||
resolved := resolveForTest(t, report.DailyToday, mustParse("2026-05-29T08:00:00-05:00"), location)
|
resolved := resolveForTest(t, report.Daily, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||||
derived, err := BuildDerived(BuildDerivedRequest{
|
derived, err := BuildDerived(BuildDerivedRequest{
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
Timezone: location.String(),
|
Timezone: location.String(),
|
||||||
@@ -119,7 +135,7 @@ func TestBuildDerivedOutlookBuildsPartialDaySummariesWithMissingOptionalSources(
|
|||||||
|
|
||||||
func TestBuildDerivedWeekendAndTomorrow(t *testing.T) {
|
func TestBuildDerivedWeekendAndTomorrow(t *testing.T) {
|
||||||
location := testLocation()
|
location := testLocation()
|
||||||
for _, id := range []report.ID{report.DailyTomorrow, report.Weekend} {
|
for _, id := range []report.ID{report.Tomorrow, report.Weekend} {
|
||||||
resolved := resolveForTest(t, id, mustParse("2026-05-29T08:00:00-05:00"), location)
|
resolved := resolveForTest(t, id, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||||
derived, err := BuildDerived(BuildDerivedRequest{
|
derived, err := BuildDerived(BuildDerivedRequest{
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
@@ -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",
|
||||||
|
resolved: resolveForTest(t, report.Daily, 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.Tomorrow, 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.Daily, 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{
|
||||||
@@ -215,7 +465,11 @@ func testDayparts() []forecast.DaypartDefinition {
|
|||||||
|
|
||||||
func resolveForTest(t *testing.T, id report.ID, now time.Time, location *time.Location) report.Resolved {
|
func resolveForTest(t *testing.T, id report.ID, now time.Time, location *time.Location) report.Resolved {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
resolved, err := report.Resolve(id, report.ResolveRequest{Now: now, Location: location})
|
req := report.ResolveRequest{Now: now, Location: location}
|
||||||
|
if id == report.Daily {
|
||||||
|
req.Date = now
|
||||||
|
}
|
||||||
|
resolved, err := report.Resolve(id, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("resolve %s: %v", id, err)
|
t.Fatalf("resolve %s: %v", id, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ type AlertOverlap struct {
|
|||||||
Period timeutil.Period `json:"period"`
|
Period timeutil.Period `json:"period"`
|
||||||
Overlap timeutil.Period `json:"overlap"`
|
Overlap timeutil.Period `json:"overlap"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
|
Instruction string `json:"instruction,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PrecipTiming struct {
|
type PrecipTiming struct {
|
||||||
@@ -81,6 +82,7 @@ type PrecipitationWindow struct {
|
|||||||
End *time.Time `json:"end,omitempty"`
|
End *time.Time `json:"end,omitempty"`
|
||||||
MaxPrecipitationProbability TimedValue `json:"maxPrecipitationProbability"`
|
MaxPrecipitationProbability TimedValue `json:"maxPrecipitationProbability"`
|
||||||
ProbabilityThreshold float64 `json:"probabilityThreshold"`
|
ProbabilityThreshold float64 `json:"probabilityThreshold"`
|
||||||
|
TextDescriptions []string `json:"textDescriptions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildPrecipTiming(periods []weatherdata.ForecastPeriod) PrecipTiming {
|
func BuildPrecipTiming(periods []weatherdata.ForecastPeriod) PrecipTiming {
|
||||||
@@ -114,6 +116,7 @@ func buildPrecipTimingWithThreshold(periods []weatherdata.ForecastPeriod, thresh
|
|||||||
},
|
},
|
||||||
ProbabilityThreshold: threshold,
|
ProbabilityThreshold: threshold,
|
||||||
}
|
}
|
||||||
|
appendActiveTextDescription(active, forecastPeriod.TextDescription)
|
||||||
activeLastEnd = forecastPeriod.EndTime
|
activeLastEnd = forecastPeriod.EndTime
|
||||||
if timing.FirstPrecipitation == nil {
|
if timing.FirstPrecipitation == nil {
|
||||||
timing.FirstPrecipitation = &TimedValue{
|
timing.FirstPrecipitation = &TimedValue{
|
||||||
@@ -135,6 +138,8 @@ func buildPrecipTimingWithThreshold(periods []weatherdata.ForecastPeriod, thresh
|
|||||||
if forecastPeriod.StartTime.After(activeLastEnd) {
|
if forecastPeriod.StartTime.After(activeLastEnd) {
|
||||||
closeActive()
|
closeActive()
|
||||||
startActive(forecastPeriod, probability)
|
startActive(forecastPeriod, probability)
|
||||||
|
} else {
|
||||||
|
appendActiveTextDescription(active, forecastPeriod.TextDescription)
|
||||||
}
|
}
|
||||||
if probability > active.MaxPrecipitationProbability.Value {
|
if probability > active.MaxPrecipitationProbability.Value {
|
||||||
active.MaxPrecipitationProbability = TimedValue{
|
active.MaxPrecipitationProbability = TimedValue{
|
||||||
@@ -166,6 +171,17 @@ func buildPrecipTimingWithThreshold(periods []weatherdata.ForecastPeriod, thresh
|
|||||||
return timing
|
return timing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appendActiveTextDescription(window *PrecipitationWindow, text string) {
|
||||||
|
if window == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.TextDescriptions = append(window.TextDescriptions, text)
|
||||||
|
}
|
||||||
|
|
||||||
func BuildDailySummary(bundle *weatherdata.Bundle, date time.Time, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) {
|
func BuildDailySummary(bundle *weatherdata.Bundle, date time.Time, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) {
|
||||||
if bundle == nil {
|
if bundle == nil {
|
||||||
return nil, fmt.Errorf("forecast bundle is required")
|
return nil, fmt.Errorf("forecast bundle is required")
|
||||||
@@ -448,6 +464,7 @@ func AlertOverlaps(alertRun *weatherdata.AlertRun, period timeutil.Period) []Ale
|
|||||||
Period: alert.Period,
|
Period: alert.Period,
|
||||||
Overlap: intersect(alert.Period, period),
|
Overlap: intersect(alert.Period, period),
|
||||||
Description: alert.Description,
|
Description: alert.Description,
|
||||||
|
Instruction: alert.Instruction,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
sort.SliceStable(overlaps, func(i int, j int) bool {
|
sort.SliceStable(overlaps, func(i int, j int) bool {
|
||||||
@@ -461,6 +478,7 @@ type parsedAlert struct {
|
|||||||
Headline string
|
Headline string
|
||||||
Severity string
|
Severity string
|
||||||
Description string
|
Description string
|
||||||
|
Instruction string
|
||||||
Period timeutil.Period
|
Period timeutil.Period
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,14 +491,19 @@ func parseAlert(raw json.RawMessage) (parsedAlert, bool) {
|
|||||||
Event: stringField(fields, "event"),
|
Event: stringField(fields, "event"),
|
||||||
Headline: firstStringField(fields, "headline", "title"),
|
Headline: firstStringField(fields, "headline", "title"),
|
||||||
Severity: stringField(fields, "severity"),
|
Severity: stringField(fields, "severity"),
|
||||||
Description: firstStringField(fields, "description", "instruction"),
|
Description: stringField(fields, "description"),
|
||||||
|
Instruction: collapseWhitespace(stringField(fields, "instruction")),
|
||||||
}
|
}
|
||||||
start, startOK := firstTimeField(fields, "effective", "onset", "startsAt", "startTime", "sent")
|
start, startOK := firstTimeField(fields, "onset", "startsAt", "startTime", "effective", "sent")
|
||||||
end, endOK := firstTimeField(fields, "expires", "ends", "endsAt", "endTime")
|
end, endOK := firstTimeField(fields, "ends", "endsAt", "endTime", "expires")
|
||||||
if !startOK || !endOK {
|
if !startOK || !endOK {
|
||||||
return parsedAlert{}, false
|
return parsedAlert{}, false
|
||||||
}
|
}
|
||||||
alert.Period = timeutil.Period{Start: start, End: end}
|
period := timeutil.Period{Start: start, End: end}
|
||||||
|
if !period.IsValid() {
|
||||||
|
return parsedAlert{}, false
|
||||||
|
}
|
||||||
|
alert.Period = period
|
||||||
return alert, true
|
return alert, true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,6 +542,10 @@ func firstTimeField(fields map[string]json.RawMessage, names ...string) (time.Ti
|
|||||||
return time.Time{}, false
|
return time.Time{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func collapseWhitespace(value string) string {
|
||||||
|
return strings.Join(strings.Fields(value), " ")
|
||||||
|
}
|
||||||
|
|
||||||
func intersect(left timeutil.Period, right timeutil.Period) timeutil.Period {
|
func intersect(left timeutil.Period, right timeutil.Period) timeutil.Period {
|
||||||
start := left.Start
|
start := left.Start
|
||||||
if right.Start.After(start) {
|
if right.Start.After(start) {
|
||||||
|
|||||||
@@ -210,6 +210,115 @@ func TestAlertOverlap(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlertOverlapUsesOnsetAndEnds(t *testing.T) {
|
||||||
|
raw := json.RawMessage(`{"event":"Wind Advisory","headline":"Wind Advisory issued June 16 at 12:39PM CDT until June 17 at 8:00PM CDT by NWS St Louis MO","severity":"Moderate","description":"Southwest winds 20 to 30 mph with gusts up to 45 mph expected.","instruction":"Secure outdoor objects.\n\nUse extra\tcaution.","effective":"2026-06-16T17:39:00Z","onset":"2026-06-17T18:00:00Z","ends":"2026-06-18T01:00:00Z","expires":"2026-06-17T08:45:00Z"}`)
|
||||||
|
alertRun := &weatherdata.AlertRun{Alerts: []json.RawMessage{raw}}
|
||||||
|
period := timeutil.Period{
|
||||||
|
Start: mustParse("2026-06-17T12:00:00-05:00"),
|
||||||
|
End: mustParse("2026-06-17T21:00:00-05:00"),
|
||||||
|
}
|
||||||
|
|
||||||
|
overlaps := AlertOverlaps(alertRun, period)
|
||||||
|
if len(overlaps) != 1 {
|
||||||
|
t.Fatalf("overlaps length = %d, want 1", len(overlaps))
|
||||||
|
}
|
||||||
|
if overlaps[0].Period.Start.Format(time.RFC3339) != "2026-06-17T18:00:00Z" {
|
||||||
|
t.Fatalf("alert period start = %s, want onset", overlaps[0].Period.Start.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
if overlaps[0].Period.End.Format(time.RFC3339) != "2026-06-18T01:00:00Z" {
|
||||||
|
t.Fatalf("alert period end = %s, want ends", overlaps[0].Period.End.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
if overlaps[0].Overlap.Start.Format(time.RFC3339) != "2026-06-17T18:00:00Z" {
|
||||||
|
t.Fatalf("overlap start = %s, want onset", overlaps[0].Overlap.Start.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
if overlaps[0].Overlap.End.Format(time.RFC3339) != "2026-06-18T01:00:00Z" {
|
||||||
|
t.Fatalf("overlap end = %s, want alert ends", overlaps[0].Overlap.End.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
if overlaps[0].Description != "Southwest winds 20 to 30 mph with gusts up to 45 mph expected." {
|
||||||
|
t.Fatalf("Description = %q, want preserved description", overlaps[0].Description)
|
||||||
|
}
|
||||||
|
if overlaps[0].Instruction != "Secure outdoor objects. Use extra caution." {
|
||||||
|
t.Fatalf("Instruction = %q, want preserved instruction", overlaps[0].Instruction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollapseWhitespace(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "newlines", value: "Secure outdoor objects.\nUse extra caution.", want: "Secure outdoor objects. Use extra caution."},
|
||||||
|
{name: "crlf", value: "Secure outdoor objects.\r\nUse extra caution.", want: "Secure outdoor objects. Use extra caution."},
|
||||||
|
{name: "tabs and repeated spaces", value: "Secure\toutdoor objects.", want: "Secure outdoor objects."},
|
||||||
|
{name: "leading trailing", value: " Secure outdoor objects. ", want: "Secure outdoor objects."},
|
||||||
|
{name: "empty", value: "", want: ""},
|
||||||
|
{name: "all whitespace", value: " \n\t\r\n ", want: ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := collapseWhitespace(tt.value); got != tt.want {
|
||||||
|
t.Fatalf("collapseWhitespace(%q) = %q, want %q", tt.value, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertOverlapUsesOnsetAndEndsForLocalDateRelevance(t *testing.T) {
|
||||||
|
location, err := time.LoadLocation("America/Chicago")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load location: %v", err)
|
||||||
|
}
|
||||||
|
raw := json.RawMessage(`{"event":"Wind Advisory","headline":"Wind Advisory issued June 16 at 12:39PM CDT until June 17 at 8:00PM CDT by NWS St Louis MO","severity":"Moderate","effective":"2026-06-16T17:39:00Z","onset":"2026-06-17T18:00:00Z","ends":"2026-06-18T01:00:00Z","expires":"2026-06-17T08:45:00Z"}`)
|
||||||
|
alertRun := &weatherdata.AlertRun{Alerts: []json.RawMessage{raw}}
|
||||||
|
june16 := timeutil.CivilDay(time.Date(2026, 6, 16, 12, 0, 0, 0, location), location)
|
||||||
|
june17Afternoon := timeutil.Period{
|
||||||
|
Start: time.Date(2026, 6, 17, 12, 0, 0, 0, location),
|
||||||
|
End: time.Date(2026, 6, 17, 21, 0, 0, 0, location),
|
||||||
|
}
|
||||||
|
|
||||||
|
if overlaps := AlertOverlaps(alertRun, june16); len(overlaps) != 0 {
|
||||||
|
t.Fatalf("June 16 overlaps length = %d, want none: %#v", len(overlaps), overlaps)
|
||||||
|
}
|
||||||
|
if overlaps := AlertOverlaps(alertRun, june17Afternoon); len(overlaps) != 1 {
|
||||||
|
t.Fatalf("June 17 afternoon overlaps length = %d, want 1: %#v", len(overlaps), overlaps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertOverlapSupportsOlderEffectiveExpiresPayload(t *testing.T) {
|
||||||
|
raw := json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T07:00:00-05:00","expires":"2026-05-29T10:00:00-05:00"}`)
|
||||||
|
alertRun := &weatherdata.AlertRun{Alerts: []json.RawMessage{raw}}
|
||||||
|
period := timeutil.Period{
|
||||||
|
Start: mustParse("2026-05-29T06:00:00-05:00"),
|
||||||
|
End: mustParse("2026-05-29T11:00:00-05:00"),
|
||||||
|
}
|
||||||
|
|
||||||
|
overlaps := AlertOverlaps(alertRun, period)
|
||||||
|
if len(overlaps) != 1 {
|
||||||
|
t.Fatalf("overlaps length = %d, want 1", len(overlaps))
|
||||||
|
}
|
||||||
|
if overlaps[0].Period.Start.Format(time.RFC3339) != "2026-05-29T07:00:00-05:00" {
|
||||||
|
t.Fatalf("alert period start = %s, want effective fallback", overlaps[0].Period.Start.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
if overlaps[0].Period.End.Format(time.RFC3339) != "2026-05-29T10:00:00-05:00" {
|
||||||
|
t.Fatalf("alert period end = %s, want expires fallback", overlaps[0].Period.End.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertOverlapSkipsInvalidSelectedPeriod(t *testing.T) {
|
||||||
|
raw := json.RawMessage(`{"event":"Wind Advisory","headline":"Invalid event period","severity":"Moderate","onset":"2026-06-17T18:00:00Z","ends":"2026-06-17T08:45:00Z"}`)
|
||||||
|
alertRun := &weatherdata.AlertRun{Alerts: []json.RawMessage{raw}}
|
||||||
|
period := timeutil.Period{
|
||||||
|
Start: mustParse("2026-06-17T00:00:00Z"),
|
||||||
|
End: mustParse("2026-06-18T00:00:00Z"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if overlaps := AlertOverlaps(alertRun, period); len(overlaps) != 0 {
|
||||||
|
t.Fatalf("overlaps length = %d, want invalid alert skipped: %#v", len(overlaps), overlaps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildPrecipTimingBuildsThresholdWindows(t *testing.T) {
|
func TestBuildPrecipTimingBuildsThresholdWindows(t *testing.T) {
|
||||||
location := time.FixedZone("Test", -5*60*60)
|
location := time.FixedZone("Test", -5*60*60)
|
||||||
periods := []weatherdata.ForecastPeriod{
|
periods := []weatherdata.ForecastPeriod{
|
||||||
@@ -245,10 +354,16 @@ func TestBuildPrecipTimingBuildsThresholdWindows(t *testing.T) {
|
|||||||
if first.MaxPrecipitationProbability.Value != 60 || first.MaxPrecipitationProbability.Time.Format(time.RFC3339) != "2026-05-29T10:00:00-05:00" {
|
if first.MaxPrecipitationProbability.Value != 60 || first.MaxPrecipitationProbability.Time.Format(time.RFC3339) != "2026-05-29T10:00:00-05:00" {
|
||||||
t.Fatalf("first window max = %#v, want 60 at 10 AM", first.MaxPrecipitationProbability)
|
t.Fatalf("first window max = %#v, want 60 at 10 AM", first.MaxPrecipitationProbability)
|
||||||
}
|
}
|
||||||
|
if len(first.TextDescriptions) != 2 || first.TextDescriptions[0] != "Showers" || first.TextDescriptions[1] != "Rain likely" {
|
||||||
|
t.Fatalf("first window text descriptions = %#v, want contributing hourly descriptions", first.TextDescriptions)
|
||||||
|
}
|
||||||
second := timing.PrecipitationWindows[1]
|
second := timing.PrecipitationWindows[1]
|
||||||
if second.Start.Format(time.RFC3339) != "2026-05-29T12:00:00-05:00" || second.End == nil || second.End.Format(time.RFC3339) != "2026-05-29T13:00:00-05:00" {
|
if second.Start.Format(time.RFC3339) != "2026-05-29T12:00:00-05:00" || second.End == nil || second.End.Format(time.RFC3339) != "2026-05-29T13:00:00-05:00" {
|
||||||
t.Fatalf("second window = %#v, want noon-1 PM", second)
|
t.Fatalf("second window = %#v, want noon-1 PM", second)
|
||||||
}
|
}
|
||||||
|
if len(second.TextDescriptions) != 1 || second.TextDescriptions[0] != "Thunderstorms" {
|
||||||
|
t.Fatalf("second window text descriptions = %#v, want thunderstorm description", second.TextDescriptions)
|
||||||
|
}
|
||||||
if !timing.ThunderMentioned {
|
if !timing.ThunderMentioned {
|
||||||
t.Fatal("ThunderMentioned = false, want true")
|
t.Fatal("ThunderMentioned = false, want true")
|
||||||
}
|
}
|
||||||
|
|||||||
195
internal/generatedtext/catalog.go
Normal file
195
internal/generatedtext/catalog.go
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
package generatedtext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/reporttemplate"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
schemaIDHourly = "hourly"
|
||||||
|
schemaIDDaily = "daily"
|
||||||
|
schemaIDToday = "today"
|
||||||
|
schemaIDTomorrow = "tomorrow"
|
||||||
|
|
||||||
|
templateIDHourly = "hourly"
|
||||||
|
templateIDDaily = "daily"
|
||||||
|
templateIDToday = "today"
|
||||||
|
templateIDTomorrow = "tomorrow"
|
||||||
|
)
|
||||||
|
|
||||||
|
type validator func([]byte) (any, []byte, error)
|
||||||
|
|
||||||
|
type renderContextBuilder func(report.ID, string, briefing.Metadata, module.Snapshot, facts.CollectedFacts, facts.DerivedFacts, any) (any, error)
|
||||||
|
|
||||||
|
type catalogEntry struct {
|
||||||
|
schemaID string
|
||||||
|
templateID string
|
||||||
|
validate validator
|
||||||
|
renderContextBuilder renderContextBuilder
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
reportID report.ID
|
||||||
|
schemaID string
|
||||||
|
templateID string
|
||||||
|
validate validator
|
||||||
|
renderContextBuilder renderContextBuilder
|
||||||
|
}
|
||||||
|
|
||||||
|
var catalog = []catalogEntry{
|
||||||
|
{
|
||||||
|
schemaID: schemaIDHourly,
|
||||||
|
templateID: templateIDHourly,
|
||||||
|
validate: validateHourly,
|
||||||
|
renderContextBuilder: buildHourlyContext,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schemaID: schemaIDDaily,
|
||||||
|
templateID: templateIDDaily,
|
||||||
|
validate: validateDaily,
|
||||||
|
renderContextBuilder: buildDailyContext,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schemaID: schemaIDToday,
|
||||||
|
templateID: templateIDToday,
|
||||||
|
validate: validateToday,
|
||||||
|
renderContextBuilder: buildTodayContext,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schemaID: schemaIDTomorrow,
|
||||||
|
templateID: templateIDTomorrow,
|
||||||
|
validate: validateTomorrow,
|
||||||
|
renderContextBuilder: buildTomorrowContext,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func LookupDefinition(definition report.Definition) (Handler, error) {
|
||||||
|
if definition.GenerationMode != report.GenerationModeGeneratedTextTemplate {
|
||||||
|
return Handler{}, fmt.Errorf("report %q uses generation mode %q, not %q", definition.ID, definition.GenerationMode, report.GenerationModeGeneratedTextTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
var schemaKnown, templateKnown bool
|
||||||
|
for _, entry := range catalog {
|
||||||
|
if entry.schemaID == definition.GeneratedTextSchemaID {
|
||||||
|
schemaKnown = true
|
||||||
|
}
|
||||||
|
if entry.templateID == definition.TemplateID {
|
||||||
|
templateKnown = true
|
||||||
|
}
|
||||||
|
if entry.schemaID == definition.GeneratedTextSchemaID && entry.templateID == definition.TemplateID {
|
||||||
|
return Handler{
|
||||||
|
reportID: definition.ID,
|
||||||
|
schemaID: entry.schemaID,
|
||||||
|
templateID: entry.templateID,
|
||||||
|
validate: entry.validate,
|
||||||
|
renderContextBuilder: entry.renderContextBuilder,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !schemaKnown {
|
||||||
|
return Handler{}, fmt.Errorf("generated text schema %q is not supported for report %q", definition.GeneratedTextSchemaID, definition.ID)
|
||||||
|
}
|
||||||
|
if !templateKnown {
|
||||||
|
return Handler{}, fmt.Errorf("report template %q is not supported for report %q", definition.TemplateID, definition.ID)
|
||||||
|
}
|
||||||
|
return Handler{}, fmt.Errorf("generated text schema %q and report template %q are not supported together for report %q", definition.GeneratedTextSchemaID, definition.TemplateID, definition.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) SchemaID() string {
|
||||||
|
return h.schemaID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) TemplateID() string {
|
||||||
|
return h.templateID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) Schema() ([]byte, error) {
|
||||||
|
data, err := reporttemplate.Schema(h.schemaID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load generated text schema %q for report %q: %w", h.schemaID, h.reportID, err)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) Template() (string, error) {
|
||||||
|
source, err := reporttemplate.Template(h.templateID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("load report template %q for report %q: %w", h.templateID, h.reportID, err)
|
||||||
|
}
|
||||||
|
return source, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) Validate(data []byte) (any, []byte, error) {
|
||||||
|
if h.validate == nil {
|
||||||
|
return nil, nil, fmt.Errorf("generated text validator is not registered for schema %q on report %q", h.schemaID, h.reportID)
|
||||||
|
}
|
||||||
|
return h.validate(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) BuildRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
|
||||||
|
if h.renderContextBuilder == nil {
|
||||||
|
return nil, fmt.Errorf("render-context builder is not registered for template %q on report %q", h.templateID, h.reportID)
|
||||||
|
}
|
||||||
|
return h.renderContextBuilder(h.reportID, h.templateID, metadata, snapshot, collected, derived, generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) Render(data any) ([]byte, error) {
|
||||||
|
rendered, err := reporttemplate.Render(h.templateID, data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("render report template %q for report %q: %w", h.templateID, h.reportID, err)
|
||||||
|
}
|
||||||
|
return rendered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateHourly(data []byte) (any, []byte, error) {
|
||||||
|
return ValidateHourly(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDaily(data []byte) (any, []byte, error) {
|
||||||
|
return ValidateDaily(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateToday(data []byte) (any, []byte, error) {
|
||||||
|
return ValidateToday(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateTomorrow(data []byte) (any, []byte, error) {
|
||||||
|
return ValidateTomorrow(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildHourlyContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
|
||||||
|
hourly, ok := generated.(Hourly)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("report template %q requires hourly generated text for report %q", templateID, reportID)
|
||||||
|
}
|
||||||
|
return BuildHourlyRenderContext(metadata, snapshot, hourly, collected, derived)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDailyContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
|
||||||
|
daily, ok := generated.(Daily)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("report template %q requires daily generated text for report %q", templateID, reportID)
|
||||||
|
}
|
||||||
|
return BuildDailyRenderContext(metadata, snapshot, daily, collected, derived)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTodayContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
|
||||||
|
today, ok := generated.(Today)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("report template %q requires today generated text for report %q", templateID, reportID)
|
||||||
|
}
|
||||||
|
return BuildTodayRenderContext(metadata, snapshot, today, collected, derived)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTomorrowContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
|
||||||
|
tomorrow, ok := generated.(Tomorrow)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("report template %q requires tomorrow generated text for report %q", templateID, reportID)
|
||||||
|
}
|
||||||
|
return BuildTomorrowRenderContext(metadata, snapshot, tomorrow, collected, derived)
|
||||||
|
}
|
||||||
324
internal/generatedtext/catalog_test.go
Normal file
324
internal/generatedtext/catalog_test.go
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
package generatedtext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCatalogCompleteForGeneratedTextTemplateReports(t *testing.T) {
|
||||||
|
for _, definition := range report.DefaultRegistry().All() {
|
||||||
|
if definition.GenerationMode != report.GenerationModeGeneratedTextTemplate {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.Run(string(definition.ID), func(t *testing.T) {
|
||||||
|
handler, err := LookupDefinition(definition)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupDefinition() error = %v", err)
|
||||||
|
}
|
||||||
|
if handler.SchemaID() != definition.GeneratedTextSchemaID {
|
||||||
|
t.Fatalf("SchemaID() = %q, want %q", handler.SchemaID(), definition.GeneratedTextSchemaID)
|
||||||
|
}
|
||||||
|
if handler.TemplateID() != definition.TemplateID {
|
||||||
|
t.Fatalf("TemplateID() = %q, want %q", handler.TemplateID(), definition.TemplateID)
|
||||||
|
}
|
||||||
|
if handler.validate == nil {
|
||||||
|
t.Fatal("validator is nil")
|
||||||
|
}
|
||||||
|
if handler.renderContextBuilder == nil {
|
||||||
|
t.Fatal("render-context builder is nil")
|
||||||
|
}
|
||||||
|
if schema, err := handler.Schema(); err != nil {
|
||||||
|
t.Fatalf("Schema() error = %v", err)
|
||||||
|
} else if len(schema) == 0 {
|
||||||
|
t.Fatal("Schema() returned empty asset")
|
||||||
|
}
|
||||||
|
if template, err := handler.Template(); err != nil {
|
||||||
|
t.Fatalf("Template() error = %v", err)
|
||||||
|
} else if template == "" {
|
||||||
|
t.Fatal("Template() returned empty asset")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogLookupRejectsUnsupportedSchemaAndTemplate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
definition report.Definition
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "schema",
|
||||||
|
definition: func() report.Definition {
|
||||||
|
definition := report.DefaultRegistry().MustLookup(report.Hourly)
|
||||||
|
definition.GeneratedTextSchemaID = "missing-schema"
|
||||||
|
return definition
|
||||||
|
}(),
|
||||||
|
want: []string{`generated text schema "missing-schema"`, `report "hourly"`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "template",
|
||||||
|
definition: func() report.Definition {
|
||||||
|
definition := report.DefaultRegistry().MustLookup(report.Hourly)
|
||||||
|
definition.TemplateID = "missing-template"
|
||||||
|
return definition
|
||||||
|
}(),
|
||||||
|
want: []string{`report template "missing-template"`, `report "hourly"`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "combination",
|
||||||
|
definition: func() report.Definition {
|
||||||
|
definition := report.DefaultRegistry().MustLookup(report.Hourly)
|
||||||
|
definition.TemplateID = "tomorrow"
|
||||||
|
return definition
|
||||||
|
}(),
|
||||||
|
want: []string{`generated text schema "hourly"`, `report template "tomorrow"`, `report "hourly"`},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, err := LookupDefinition(test.definition)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("LookupDefinition() error = nil, want error")
|
||||||
|
}
|
||||||
|
for _, want := range test.want {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Fatalf("LookupDefinition() error = %v, want %q", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogLookupSupportsTodayDefinition(t *testing.T) {
|
||||||
|
definition := report.Definition{
|
||||||
|
ID: report.Today,
|
||||||
|
GenerationMode: report.GenerationModeGeneratedTextTemplate,
|
||||||
|
GeneratedTextSchemaID: "today",
|
||||||
|
TemplateID: "today",
|
||||||
|
}
|
||||||
|
handler, err := LookupDefinition(definition)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupDefinition(today) error = %v", err)
|
||||||
|
}
|
||||||
|
if handler.SchemaID() != "today" || handler.TemplateID() != "today" {
|
||||||
|
t.Fatalf("handler IDs = %q/%q, want today/today", handler.SchemaID(), handler.TemplateID())
|
||||||
|
}
|
||||||
|
if schema, err := handler.Schema(); err != nil {
|
||||||
|
t.Fatalf("Schema() error = %v", err)
|
||||||
|
} else if len(schema) == 0 {
|
||||||
|
t.Fatal("Schema() returned empty asset")
|
||||||
|
}
|
||||||
|
if template, err := handler.Template(); err != nil {
|
||||||
|
t.Fatalf("Template() error = %v", err)
|
||||||
|
} else if !strings.Contains(template, "# {{ .Report.Title }}") {
|
||||||
|
t.Fatalf("Template() = %q, want Today template source", template)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogLookupSupportsDailyDefinitionAssets(t *testing.T) {
|
||||||
|
definition := report.Definition{
|
||||||
|
ID: report.Daily,
|
||||||
|
GenerationMode: report.GenerationModeGeneratedTextTemplate,
|
||||||
|
GeneratedTextSchemaID: "daily",
|
||||||
|
TemplateID: "daily",
|
||||||
|
}
|
||||||
|
handler, err := LookupDefinition(definition)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupDefinition(daily) error = %v", err)
|
||||||
|
}
|
||||||
|
if handler.SchemaID() != "daily" || handler.TemplateID() != "daily" {
|
||||||
|
t.Fatalf("handler IDs = %q/%q, want daily/daily", handler.SchemaID(), handler.TemplateID())
|
||||||
|
}
|
||||||
|
if schema, err := handler.Schema(); err != nil {
|
||||||
|
t.Fatalf("Schema() error = %v", err)
|
||||||
|
} else if len(schema) == 0 {
|
||||||
|
t.Fatal("Schema() returned empty asset")
|
||||||
|
}
|
||||||
|
if template, err := handler.Template(); err != nil {
|
||||||
|
t.Fatalf("Template() error = %v", err)
|
||||||
|
} else if !strings.Contains(template, "# {{ .Report.Title }}") {
|
||||||
|
t.Fatalf("Template() = %q, want Daily template source", template)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) {
|
||||||
|
hourlyHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Hourly))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupDefinition(hourly) error = %v", err)
|
||||||
|
}
|
||||||
|
hourly, normalized, err := hourlyHandler.Validate([]byte(`{
|
||||||
|
"summary": " Storm chances increase. ",
|
||||||
|
"forecast_discussion": " A front will keep the region unsettled. "
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Validate(hourly) error = %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := hourly.(Hourly); !ok {
|
||||||
|
t.Fatalf("hourly generated text type = %T, want generatedtext.Hourly", hourly)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(normalized), `"summary":"Storm chances increase."`) {
|
||||||
|
t.Fatalf("hourly normalized text = %s, want trimmed summary", normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
tomorrowHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Tomorrow))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupDefinition(tomorrow) error = %v", err)
|
||||||
|
}
|
||||||
|
tomorrow, normalized, err := tomorrowHandler.Validate([]byte(`{
|
||||||
|
"summary": " Storms become more likely tomorrow. ",
|
||||||
|
"forecast_discussion": [" A front will keep showers in the forecast. ", ""]
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Validate(tomorrow) error = %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := tomorrow.(Tomorrow); !ok {
|
||||||
|
t.Fatalf("tomorrow generated text type = %T, want generatedtext.Tomorrow", tomorrow)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(normalized), `"forecast_discussion":["A front will keep showers in the forecast."]`) {
|
||||||
|
t.Fatalf("tomorrow normalized text = %s, want trimmed discussion paragraph", normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
todayHandler, err := LookupDefinition(report.Definition{
|
||||||
|
ID: report.Today,
|
||||||
|
GenerationMode: report.GenerationModeGeneratedTextTemplate,
|
||||||
|
GeneratedTextSchemaID: "today",
|
||||||
|
TemplateID: "today",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupDefinition(today) error = %v", err)
|
||||||
|
}
|
||||||
|
today, normalized, err := todayHandler.Validate([]byte(`{
|
||||||
|
"summary": " Showers are likely today. ",
|
||||||
|
"forecast_discussion": [" A front will keep rain chances elevated. ", ""]
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Validate(today) error = %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := today.(Today); !ok {
|
||||||
|
t.Fatalf("today generated text type = %T, want generatedtext.Today", today)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(normalized), `"forecast_discussion":["A front will keep rain chances elevated."]`) {
|
||||||
|
t.Fatalf("today normalized text = %s, want trimmed discussion paragraph", normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
dailyHandler, err := LookupDefinition(report.Definition{
|
||||||
|
ID: report.Daily,
|
||||||
|
GenerationMode: report.GenerationModeGeneratedTextTemplate,
|
||||||
|
GeneratedTextSchemaID: "daily",
|
||||||
|
TemplateID: "daily",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupDefinition(daily) error = %v", err)
|
||||||
|
}
|
||||||
|
daily, normalized, err := dailyHandler.Validate([]byte(`{
|
||||||
|
"summary": " Showers are possible during the selected day. ",
|
||||||
|
"forecast_discussion": [" A front will keep rain chances in the forecast. ", ""]
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Validate(daily) error = %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := daily.(Daily); !ok {
|
||||||
|
t.Fatalf("daily generated text type = %T, want generatedtext.Daily", daily)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(normalized), `"forecast_discussion":["A front will keep rain chances in the forecast."]`) {
|
||||||
|
t.Fatalf("daily normalized text = %s, want trimmed discussion paragraph", normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
|
||||||
|
hourlyHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Hourly))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupDefinition(hourly) error = %v", err)
|
||||||
|
}
|
||||||
|
_, err = hourlyHandler.BuildRenderContext(testMetadata(), testSnapshot(t), testCollected(), testDerived(), Tomorrow{
|
||||||
|
Summary: "Storms become more likely tomorrow.",
|
||||||
|
ForecastDiscussion: []string{"A front will keep showers in the forecast."},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("BuildRenderContext(hourly) error = nil, want type mismatch")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), `requires hourly generated text`) {
|
||||||
|
t.Fatalf("BuildRenderContext(hourly) error = %v, want hourly generated text requirement", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tomorrowHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Tomorrow))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupDefinition(tomorrow) error = %v", err)
|
||||||
|
}
|
||||||
|
_, err = tomorrowHandler.BuildRenderContext(testTomorrowMetadata(), testTomorrowSnapshot(t), testCollected(), testTomorrowDerived(), Hourly{
|
||||||
|
Summary: "Storm chances increase.",
|
||||||
|
ForecastDiscussion: "A front will keep showers in the forecast.",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("BuildRenderContext(tomorrow) error = nil, want type mismatch")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), `requires tomorrow generated text`) {
|
||||||
|
t.Fatalf("BuildRenderContext(tomorrow) error = %v, want tomorrow generated text requirement", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
todayHandler, err := LookupDefinition(report.Definition{
|
||||||
|
ID: report.Today,
|
||||||
|
GenerationMode: report.GenerationModeGeneratedTextTemplate,
|
||||||
|
GeneratedTextSchemaID: "today",
|
||||||
|
TemplateID: "today",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupDefinition(today) error = %v", err)
|
||||||
|
}
|
||||||
|
_, err = todayHandler.BuildRenderContext(testTodayMetadata(), testTodaySnapshot(t), testCollected(), testTodayDerived(), Tomorrow{
|
||||||
|
Summary: "Storms become more likely tomorrow.",
|
||||||
|
ForecastDiscussion: []string{"A front will keep showers in the forecast."},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("BuildRenderContext(today) error = nil, want type mismatch")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), `requires today generated text`) {
|
||||||
|
t.Fatalf("BuildRenderContext(today) error = %v, want today generated text requirement", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dailyHandler, err := LookupDefinition(report.Definition{
|
||||||
|
ID: report.Daily,
|
||||||
|
GenerationMode: report.GenerationModeGeneratedTextTemplate,
|
||||||
|
GeneratedTextSchemaID: "daily",
|
||||||
|
TemplateID: "daily",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupDefinition(daily) error = %v", err)
|
||||||
|
}
|
||||||
|
_, err = dailyHandler.BuildRenderContext(testDailyMetadata(), testDailySnapshot(t), testCollected(), testDailyDerived(), Tomorrow{
|
||||||
|
Summary: "Storms become more likely tomorrow.",
|
||||||
|
ForecastDiscussion: []string{"A front will keep showers in the forecast."},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("BuildRenderContext(daily) error = nil, want type mismatch")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), `requires daily generated text`) {
|
||||||
|
t.Fatalf("BuildRenderContext(daily) error = %v, want daily generated text requirement", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogBuildRenderContextSupportsDaily(t *testing.T) {
|
||||||
|
handler, err := LookupDefinition(report.Definition{
|
||||||
|
ID: report.Daily,
|
||||||
|
GenerationMode: report.GenerationModeGeneratedTextTemplate,
|
||||||
|
GeneratedTextSchemaID: "daily",
|
||||||
|
TemplateID: "daily",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupDefinition(daily) error = %v", err)
|
||||||
|
}
|
||||||
|
ctx, err := handler.BuildRenderContext(testDailyMetadata(), testDailySnapshot(t), testCollected(), testDailyDerived(), Daily{
|
||||||
|
Summary: "Showers are possible during the selected day.",
|
||||||
|
ForecastDiscussion: []string{"A front will keep rain chances in the forecast."},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildRenderContext(daily) error = %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := ctx.(DailyRenderContext); !ok {
|
||||||
|
t.Fatalf("BuildRenderContext(daily) = %T, want DailyRenderContext", ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
28
internal/generatedtext/daily.go
Normal file
28
internal/generatedtext/daily.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package generatedtext
|
||||||
|
|
||||||
|
type Daily struct {
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
ForecastDiscussion []string `json:"forecast_discussion"`
|
||||||
|
PrecipitationTiming string `json:"precipitation_timing,omitempty"`
|
||||||
|
Confidence string `json:"confidence,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateDaily(data []byte) (Daily, []byte, error) {
|
||||||
|
return validateDayStyleGeneratedText[Daily, *Daily](data, "daily")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Daily) dayStyleFields() dayStyleFields {
|
||||||
|
return dayStyleFields{
|
||||||
|
Summary: d.Summary,
|
||||||
|
ForecastDiscussion: d.ForecastDiscussion,
|
||||||
|
PrecipitationTiming: d.PrecipitationTiming,
|
||||||
|
Confidence: d.Confidence,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Daily) setDayStyleFields(fields dayStyleFields) {
|
||||||
|
d.Summary = fields.Summary
|
||||||
|
d.ForecastDiscussion = fields.ForecastDiscussion
|
||||||
|
d.PrecipitationTiming = fields.PrecipitationTiming
|
||||||
|
d.Confidence = fields.Confidence
|
||||||
|
}
|
||||||
111
internal/generatedtext/daily_test.go
Normal file
111
internal/generatedtext/daily_test.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package generatedtext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateDailyNormalizesJSON(t *testing.T) {
|
||||||
|
value, normalized, err := ValidateDaily([]byte(`{
|
||||||
|
"summary": " Showers are possible during the selected day. ",
|
||||||
|
"forecast_discussion": [
|
||||||
|
" A front will keep rain chances in the forecast. ",
|
||||||
|
"",
|
||||||
|
" Temperatures stay seasonable by afternoon. "
|
||||||
|
],
|
||||||
|
"precipitation_timing": " Rain is most likely during the afternoon. ",
|
||||||
|
"confidence": " Medium "
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateDaily() error = %v", err)
|
||||||
|
}
|
||||||
|
if value.Summary != "Showers are possible during the selected day." {
|
||||||
|
t.Fatalf("Summary = %q, want trimmed summary", value.Summary)
|
||||||
|
}
|
||||||
|
if strings.Join(value.ForecastDiscussion, "|") != "A front will keep rain chances in the forecast.|Temperatures stay seasonable by afternoon." {
|
||||||
|
t.Fatalf("ForecastDiscussion = %#v, want trimmed non-empty paragraphs", value.ForecastDiscussion)
|
||||||
|
}
|
||||||
|
if value.PrecipitationTiming != "Rain is most likely during the afternoon." {
|
||||||
|
t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming)
|
||||||
|
}
|
||||||
|
want := `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast.","Temperatures stay seasonable by afternoon."],"precipitation_timing":"Rain is most likely during the afternoon.","confidence":"Medium"}`
|
||||||
|
if string(normalized) != want {
|
||||||
|
t.Fatalf("normalized = %s, want %s", normalized, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDailyOmitsEmptyOptionalFields(t *testing.T) {
|
||||||
|
_, normalized, err := ValidateDaily([]byte(`{
|
||||||
|
"summary": "Showers are possible during the selected day.",
|
||||||
|
"forecast_discussion": ["A front will keep rain chances in the forecast."],
|
||||||
|
"precipitation_timing": " ",
|
||||||
|
"confidence": " "
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateDaily() error = %v", err)
|
||||||
|
}
|
||||||
|
want := `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."]}`
|
||||||
|
if string(normalized) != want {
|
||||||
|
t.Fatalf("normalized = %s, want %s", normalized, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDailyRejectsInvalidInput(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "malformed",
|
||||||
|
in: `{`,
|
||||||
|
want: "decode daily generated text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown field",
|
||||||
|
in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."],"extra":"value"}`,
|
||||||
|
want: `unknown field "extra"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing summary",
|
||||||
|
in: `{"forecast_discussion":["A front will keep rain chances in the forecast."]}`,
|
||||||
|
want: "summary is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blank summary",
|
||||||
|
in: `{"summary":" ","forecast_discussion":["A front will keep rain chances in the forecast."]}`,
|
||||||
|
want: "summary is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing forecast discussion",
|
||||||
|
in: `{"summary":"Showers are possible during the selected day."}`,
|
||||||
|
want: "forecast discussion is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blank forecast discussion",
|
||||||
|
in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":[" ",""]}`,
|
||||||
|
want: "forecast discussion is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "forecast discussion wrong type",
|
||||||
|
in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":"A front will keep rain chances in the forecast."}`,
|
||||||
|
want: "cannot unmarshal string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple values",
|
||||||
|
in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."]} {}`,
|
||||||
|
want: "multiple JSON values",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, _, err := ValidateDaily([]byte(test.in))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ValidateDaily() error = nil, want error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("ValidateDaily() error = %v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
63
internal/generatedtext/day_style.go
Normal file
63
internal/generatedtext/day_style.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package generatedtext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type dayStyleFields struct {
|
||||||
|
Summary string
|
||||||
|
ForecastDiscussion []string
|
||||||
|
PrecipitationTiming string
|
||||||
|
Confidence string
|
||||||
|
}
|
||||||
|
|
||||||
|
type dayStyleGeneratedText interface {
|
||||||
|
dayStyleFields() dayStyleFields
|
||||||
|
setDayStyleFields(dayStyleFields)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDayStyleGeneratedText[T any, PT interface {
|
||||||
|
*T
|
||||||
|
dayStyleGeneratedText
|
||||||
|
}](data []byte, name string) (T, []byte, error) {
|
||||||
|
value, err := decodeGeneratedText[T](data, name)
|
||||||
|
if err != nil {
|
||||||
|
var zero T
|
||||||
|
return zero, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pointer := PT(&value)
|
||||||
|
fields := pointer.dayStyleFields()
|
||||||
|
fields.Summary = strings.TrimSpace(fields.Summary)
|
||||||
|
fields.PrecipitationTiming = strings.TrimSpace(fields.PrecipitationTiming)
|
||||||
|
fields.Confidence = strings.TrimSpace(fields.Confidence)
|
||||||
|
fields.ForecastDiscussion = trimNonEmpty(fields.ForecastDiscussion)
|
||||||
|
if fields.Summary == "" {
|
||||||
|
var zero T
|
||||||
|
return zero, nil, fmt.Errorf("%s generated text summary is required", name)
|
||||||
|
}
|
||||||
|
if len(fields.ForecastDiscussion) == 0 {
|
||||||
|
var zero T
|
||||||
|
return zero, nil, fmt.Errorf("%s generated text forecast discussion is required", name)
|
||||||
|
}
|
||||||
|
pointer.setDayStyleFields(fields)
|
||||||
|
|
||||||
|
normalized, err := normalizeGeneratedText(value, name)
|
||||||
|
if err != nil {
|
||||||
|
var zero T
|
||||||
|
return zero, nil, err
|
||||||
|
}
|
||||||
|
return value, normalized, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimNonEmpty(values []string) []string {
|
||||||
|
out := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed != "" {
|
||||||
|
out = append(out, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
131
internal/generatedtext/day_style_test.go
Normal file
131
internal/generatedtext/day_style_test.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
package generatedtext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateDayStyleGeneratedTextSharedBehavior(t *testing.T) {
|
||||||
|
reports := []struct {
|
||||||
|
name string
|
||||||
|
validate func([]byte) (any, []byte, error)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "daily",
|
||||||
|
validate: func(data []byte) (any, []byte, error) {
|
||||||
|
value, normalized, err := ValidateDaily(data)
|
||||||
|
return value, normalized, err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "today",
|
||||||
|
validate: func(data []byte) (any, []byte, error) {
|
||||||
|
value, normalized, err := ValidateToday(data)
|
||||||
|
return value, normalized, err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tomorrow",
|
||||||
|
validate: func(data []byte) (any, []byte, error) {
|
||||||
|
value, normalized, err := ValidateTomorrow(data)
|
||||||
|
return value, normalized, err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, report := range reports {
|
||||||
|
t.Run(report.name, func(t *testing.T) {
|
||||||
|
t.Run("requires summary", func(t *testing.T) {
|
||||||
|
_, _, err := report.validate([]byte(`{"forecast_discussion":["First paragraph."]}`))
|
||||||
|
want := fmt.Sprintf("%s generated text summary is required", report.name)
|
||||||
|
if err == nil || err.Error() != want {
|
||||||
|
t.Fatalf("validate() error = %v, want %q", err, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("requires non-empty forecast discussion", func(t *testing.T) {
|
||||||
|
_, _, err := report.validate([]byte(`{"summary":"Shared summary.","forecast_discussion":[" ",""]}`))
|
||||||
|
want := fmt.Sprintf("%s generated text forecast discussion is required", report.name)
|
||||||
|
if err == nil || err.Error() != want {
|
||||||
|
t.Fatalf("validate() error = %v, want %q", err, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("trims and normalizes", func(t *testing.T) {
|
||||||
|
value, normalized, err := report.validate([]byte(`{
|
||||||
|
"summary": " Shared summary. ",
|
||||||
|
"forecast_discussion": [
|
||||||
|
" First paragraph. ",
|
||||||
|
"",
|
||||||
|
" Second paragraph. "
|
||||||
|
],
|
||||||
|
"precipitation_timing": " Afternoon. ",
|
||||||
|
"confidence": " Medium "
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validate() error = %v", err)
|
||||||
|
}
|
||||||
|
fields := dayStyleFieldsForTest(t, value)
|
||||||
|
if fields.Summary != "Shared summary." {
|
||||||
|
t.Fatalf("Summary = %q, want trimmed summary", fields.Summary)
|
||||||
|
}
|
||||||
|
if strings.Join(fields.ForecastDiscussion, "|") != "First paragraph.|Second paragraph." {
|
||||||
|
t.Fatalf("ForecastDiscussion = %#v, want trimmed non-empty paragraphs", fields.ForecastDiscussion)
|
||||||
|
}
|
||||||
|
if fields.PrecipitationTiming != "Afternoon." {
|
||||||
|
t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", fields.PrecipitationTiming)
|
||||||
|
}
|
||||||
|
if fields.Confidence != "Medium" {
|
||||||
|
t.Fatalf("Confidence = %q, want trimmed confidence", fields.Confidence)
|
||||||
|
}
|
||||||
|
want := `{"summary":"Shared summary.","forecast_discussion":["First paragraph.","Second paragraph."],"precipitation_timing":"Afternoon.","confidence":"Medium"}`
|
||||||
|
if string(normalized) != want {
|
||||||
|
t.Fatalf("normalized = %s, want %s", normalized, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("omits empty optional fields", func(t *testing.T) {
|
||||||
|
_, normalized, err := report.validate([]byte(`{
|
||||||
|
"summary": "Shared summary.",
|
||||||
|
"forecast_discussion": ["First paragraph."],
|
||||||
|
"precipitation_timing": " ",
|
||||||
|
"confidence": " "
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validate() error = %v", err)
|
||||||
|
}
|
||||||
|
want := `{"summary":"Shared summary.","forecast_discussion":["First paragraph."]}`
|
||||||
|
if string(normalized) != want {
|
||||||
|
t.Fatalf("normalized = %s, want %s", normalized, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rejects unknown fields", func(t *testing.T) {
|
||||||
|
_, _, err := report.validate([]byte(`{"summary":"Shared summary.","forecast_discussion":["First paragraph."],"extra":"value"}`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("validate() error = nil, want error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), `unknown field "extra"`) {
|
||||||
|
t.Fatalf("validate() error = %v, want unknown field error", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dayStyleFieldsForTest(t *testing.T, value any) dayStyleFields {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
switch value := value.(type) {
|
||||||
|
case Daily:
|
||||||
|
return (&value).dayStyleFields()
|
||||||
|
case Today:
|
||||||
|
return (&value).dayStyleFields()
|
||||||
|
case Tomorrow:
|
||||||
|
return (&value).dayStyleFields()
|
||||||
|
default:
|
||||||
|
t.Fatalf("value type = %T, want day-style generated text", value)
|
||||||
|
return dayStyleFields{}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
internal/generatedtext/hourly.go
Normal file
38
internal/generatedtext/hourly.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// Package generatedtext validates structured LLM text and render contexts.
|
||||||
|
package generatedtext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Hourly struct {
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
ForecastDiscussion string `json:"forecast_discussion"`
|
||||||
|
PrecipitationTiming string `json:"precipitation_timing,omitempty"`
|
||||||
|
Confidence string `json:"confidence,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateHourly(data []byte) (Hourly, []byte, error) {
|
||||||
|
value, err := decodeGeneratedText[Hourly](data, "hourly")
|
||||||
|
if err != nil {
|
||||||
|
return Hourly{}, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
value.Summary = strings.TrimSpace(value.Summary)
|
||||||
|
value.ForecastDiscussion = strings.TrimSpace(value.ForecastDiscussion)
|
||||||
|
value.PrecipitationTiming = strings.TrimSpace(value.PrecipitationTiming)
|
||||||
|
value.Confidence = strings.TrimSpace(value.Confidence)
|
||||||
|
if value.Summary == "" {
|
||||||
|
return Hourly{}, nil, fmt.Errorf("hourly generated text summary is required")
|
||||||
|
}
|
||||||
|
if value.ForecastDiscussion == "" {
|
||||||
|
return Hourly{}, nil, fmt.Errorf("hourly generated text forecast discussion is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized, err := normalizeGeneratedText(value, "hourly")
|
||||||
|
if err != nil {
|
||||||
|
return Hourly{}, nil, err
|
||||||
|
}
|
||||||
|
return value, normalized, nil
|
||||||
|
}
|
||||||
102
internal/generatedtext/hourly_test.go
Normal file
102
internal/generatedtext/hourly_test.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package generatedtext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateHourlyNormalizesJSON(t *testing.T) {
|
||||||
|
value, normalized, err := ValidateHourly([]byte(`{
|
||||||
|
"summary": " Storm chances increase. ",
|
||||||
|
"forecast_discussion": " A front will keep the region unsettled. ",
|
||||||
|
"precipitation_timing": " Showers are most likely early this afternoon. ",
|
||||||
|
"confidence": " Medium "
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateHourly() error = %v", err)
|
||||||
|
}
|
||||||
|
if value.Summary != "Storm chances increase." {
|
||||||
|
t.Fatalf("Summary = %q, want trimmed summary", value.Summary)
|
||||||
|
}
|
||||||
|
if value.ForecastDiscussion != "A front will keep the region unsettled." {
|
||||||
|
t.Fatalf("ForecastDiscussion = %q, want trimmed discussion", value.ForecastDiscussion)
|
||||||
|
}
|
||||||
|
if value.PrecipitationTiming != "Showers are most likely early this afternoon." {
|
||||||
|
t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming)
|
||||||
|
}
|
||||||
|
want := `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"Showers are most likely early this afternoon.","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.",
|
||||||
|
"forecast_discussion": "A front will keep the region unsettled.",
|
||||||
|
"precipitation_timing": " ",
|
||||||
|
"confidence": " "
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateHourly() error = %v", err)
|
||||||
|
}
|
||||||
|
want := `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled."}`
|
||||||
|
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.","forecast_discussion":"A front will keep the region unsettled.","extra":"value"}`,
|
||||||
|
want: `unknown field "extra"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing summary",
|
||||||
|
in: `{"forecast_discussion":"A front will keep the region unsettled."}`,
|
||||||
|
want: "summary is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blank forecast discussion",
|
||||||
|
in: `{"summary":"Storm chances increase.","forecast_discussion":" "}`,
|
||||||
|
want: "forecast discussion is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "old timing field rejected",
|
||||||
|
in: `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled.","timing":"Late morning."}`,
|
||||||
|
want: `unknown field "timing"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "old impacts field rejected",
|
||||||
|
in: `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled.","impacts":"Brief downpours."}`,
|
||||||
|
want: `unknown field "impacts"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple values",
|
||||||
|
in: `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled."} {}`,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
33
internal/generatedtext/json.go
Normal file
33
internal/generatedtext/json.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package generatedtext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
func decodeGeneratedText[T any](data []byte, name string) (T, error) {
|
||||||
|
var value T
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&value); err != nil {
|
||||||
|
return value, fmt.Errorf("decode %s generated text: %w", name, err)
|
||||||
|
}
|
||||||
|
var extra any
|
||||||
|
if err := decoder.Decode(&extra); err != nil {
|
||||||
|
if err != io.EOF {
|
||||||
|
return value, fmt.Errorf("decode %s generated text: %w", name, err)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
return value, fmt.Errorf("decode %s generated text: multiple JSON values", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeGeneratedText[T any](value T, name string) ([]byte, error) {
|
||||||
|
normalized, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("normalize %s generated text: %w", name, err)
|
||||||
|
}
|
||||||
|
return normalized, nil
|
||||||
|
}
|
||||||
757
internal/generatedtext/render_context.go
Normal file
757
internal/generatedtext/render_context.go
Normal file
@@ -0,0 +1,757 @@
|
|||||||
|
package generatedtext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HourlyRenderContext struct {
|
||||||
|
Report HourlyReportContext
|
||||||
|
GeneratedText Hourly
|
||||||
|
Modules HourlyTemplateModules
|
||||||
|
Collected facts.CollectedFacts
|
||||||
|
Derived facts.DerivedFacts
|
||||||
|
}
|
||||||
|
|
||||||
|
type HourlyReportContext struct {
|
||||||
|
Title string
|
||||||
|
LocationName string
|
||||||
|
GeneratedAt time.Time
|
||||||
|
GeneratedAtLabel string
|
||||||
|
ValidPeriod timeutil.Period
|
||||||
|
ValidPeriodLabel string
|
||||||
|
Timezone string
|
||||||
|
}
|
||||||
|
|
||||||
|
type HourlyTemplateModules struct {
|
||||||
|
Metadata *briefing.MetadataModule
|
||||||
|
CurrentConditions *briefing.CurrentConditionsModule
|
||||||
|
HourlyForecast *briefing.HourlyForecastModule
|
||||||
|
PrecipTiming *briefing.PrecipTimingModule
|
||||||
|
AlertDigest *briefing.AlertDigestModule
|
||||||
|
SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule
|
||||||
|
AreaForecastDiscussion *briefing.AreaForecastDiscussionModule
|
||||||
|
SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule
|
||||||
|
WeatherStory *briefing.WeatherStoryModule
|
||||||
|
}
|
||||||
|
|
||||||
|
type TomorrowRenderContext struct {
|
||||||
|
Report TomorrowReportContext
|
||||||
|
GeneratedText Tomorrow
|
||||||
|
Modules TomorrowTemplateModules
|
||||||
|
Collected facts.CollectedFacts
|
||||||
|
Derived facts.DerivedFacts
|
||||||
|
}
|
||||||
|
|
||||||
|
type DailyRenderContext struct {
|
||||||
|
Report DailyReportContext
|
||||||
|
GeneratedText Daily
|
||||||
|
Modules DailyTemplateModules
|
||||||
|
Collected facts.CollectedFacts
|
||||||
|
Derived facts.DerivedFacts
|
||||||
|
}
|
||||||
|
|
||||||
|
type TodayRenderContext struct {
|
||||||
|
Report TodayReportContext
|
||||||
|
GeneratedText Today
|
||||||
|
Modules TodayTemplateModules
|
||||||
|
Collected facts.CollectedFacts
|
||||||
|
Derived facts.DerivedFacts
|
||||||
|
}
|
||||||
|
|
||||||
|
type TodayReportContext struct {
|
||||||
|
Title string
|
||||||
|
ForecastDate time.Time
|
||||||
|
ForecastDateLabel string
|
||||||
|
ForecastDayName string
|
||||||
|
GeneratedAt time.Time
|
||||||
|
GeneratedAtLabel string
|
||||||
|
ValidPeriod timeutil.Period
|
||||||
|
Timezone string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TodayTemplateModules struct {
|
||||||
|
Metadata *briefing.MetadataModule
|
||||||
|
CurrentConditions *briefing.CurrentConditionsModule
|
||||||
|
HourlyForecast *briefing.HourlyForecastModule
|
||||||
|
DerivedDailySummary *briefing.DerivedDailySummaryModule
|
||||||
|
DerivedDaypartSummaries *map[string]briefing.DerivedDaypartSummaryModule
|
||||||
|
Dayparts []TodayDaypartContext
|
||||||
|
PrecipTiming *briefing.PrecipTimingModule
|
||||||
|
AlertDigest *briefing.AlertDigestModule
|
||||||
|
SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule
|
||||||
|
AreaForecastDiscussion *briefing.AreaForecastDiscussionModule
|
||||||
|
SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule
|
||||||
|
WeatherStory *briefing.WeatherStoryModule
|
||||||
|
TodayPlanning *briefing.TodayPlanningModule
|
||||||
|
}
|
||||||
|
|
||||||
|
type TodayDaypartContext struct {
|
||||||
|
Key string
|
||||||
|
Summary briefing.DerivedDaypartSummaryModule
|
||||||
|
}
|
||||||
|
|
||||||
|
type TomorrowReportContext struct {
|
||||||
|
Title string
|
||||||
|
ForecastDate time.Time
|
||||||
|
ForecastDateLabel string
|
||||||
|
ForecastDayName string
|
||||||
|
GeneratedAt time.Time
|
||||||
|
GeneratedAtLabel string
|
||||||
|
ValidPeriod timeutil.Period
|
||||||
|
Timezone string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TomorrowTemplateModules struct {
|
||||||
|
Metadata *briefing.MetadataModule
|
||||||
|
CurrentConditions *briefing.CurrentConditionsModule
|
||||||
|
HourlyForecast *briefing.HourlyForecastModule
|
||||||
|
DerivedDailySummary *briefing.DerivedDailySummaryModule
|
||||||
|
DerivedDaypartSummaries *map[string]briefing.DerivedDaypartSummaryModule
|
||||||
|
Dayparts []TomorrowDaypartContext
|
||||||
|
PrecipTiming *briefing.PrecipTimingModule
|
||||||
|
AlertDigest *briefing.AlertDigestModule
|
||||||
|
SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule
|
||||||
|
AreaForecastDiscussion *briefing.AreaForecastDiscussionModule
|
||||||
|
SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule
|
||||||
|
WeatherStory *briefing.WeatherStoryModule
|
||||||
|
TomorrowPlanning *briefing.TomorrowPlanningModule
|
||||||
|
}
|
||||||
|
|
||||||
|
type TomorrowDaypartContext struct {
|
||||||
|
Key string
|
||||||
|
Summary briefing.DerivedDaypartSummaryModule
|
||||||
|
}
|
||||||
|
|
||||||
|
type DailyReportContext struct {
|
||||||
|
Title string
|
||||||
|
ForecastDate time.Time
|
||||||
|
ForecastDateLabel string
|
||||||
|
ForecastDayName string
|
||||||
|
GeneratedAt time.Time
|
||||||
|
GeneratedAtLabel string
|
||||||
|
ValidPeriod timeutil.Period
|
||||||
|
Timezone string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DailyTemplateModules struct {
|
||||||
|
Metadata *briefing.MetadataModule
|
||||||
|
CurrentConditions *briefing.CurrentConditionsModule
|
||||||
|
HourlyForecast *briefing.HourlyForecastModule
|
||||||
|
DerivedDailySummary *briefing.DerivedDailySummaryModule
|
||||||
|
DerivedDaypartSummaries *map[string]briefing.DerivedDaypartSummaryModule
|
||||||
|
Dayparts []DailyDaypartContext
|
||||||
|
PrecipTiming *briefing.PrecipTimingModule
|
||||||
|
AlertDigest *briefing.AlertDigestModule
|
||||||
|
SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule
|
||||||
|
AreaForecastDiscussion *briefing.AreaForecastDiscussionModule
|
||||||
|
SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule
|
||||||
|
WeatherStory *briefing.WeatherStoryModule
|
||||||
|
OutdoorWindows *briefing.OutdoorWindowsModule
|
||||||
|
DailyPlanning *briefing.DailyPlanningModule
|
||||||
|
}
|
||||||
|
|
||||||
|
type DailyDaypartContext struct {
|
||||||
|
Key string
|
||||||
|
Summary briefing.DerivedDaypartSummaryModule
|
||||||
|
}
|
||||||
|
|
||||||
|
type dayStyleReportContext struct {
|
||||||
|
Title string
|
||||||
|
ForecastDate time.Time
|
||||||
|
ForecastDateLabel string
|
||||||
|
ForecastDayName string
|
||||||
|
GeneratedAt time.Time
|
||||||
|
GeneratedAtLabel string
|
||||||
|
ValidPeriod timeutil.Period
|
||||||
|
Timezone string
|
||||||
|
}
|
||||||
|
|
||||||
|
type dayStyleTemplateModules struct {
|
||||||
|
Metadata *briefing.MetadataModule
|
||||||
|
CurrentConditions *briefing.CurrentConditionsModule
|
||||||
|
HourlyForecast *briefing.HourlyForecastModule
|
||||||
|
DerivedDailySummary *briefing.DerivedDailySummaryModule
|
||||||
|
DerivedDaypartSummaries *map[string]briefing.DerivedDaypartSummaryModule
|
||||||
|
PrecipTiming *briefing.PrecipTimingModule
|
||||||
|
AlertDigest *briefing.AlertDigestModule
|
||||||
|
SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule
|
||||||
|
AreaForecastDiscussion *briefing.AreaForecastDiscussionModule
|
||||||
|
SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule
|
||||||
|
WeatherStory *briefing.WeatherStoryModule
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Hourly, collected facts.CollectedFacts, derived facts.DerivedFacts) (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")
|
||||||
|
}
|
||||||
|
modules, err := hourlyTemplateModules(snapshot)
|
||||||
|
if err != nil {
|
||||||
|
return HourlyRenderContext{}, err
|
||||||
|
}
|
||||||
|
return HourlyRenderContext{
|
||||||
|
Report: HourlyReportContext{
|
||||||
|
Title: "Hourly Report",
|
||||||
|
LocationName: locationName(metadata),
|
||||||
|
GeneratedAt: metadata.GeneratedAt,
|
||||||
|
GeneratedAtLabel: generatedAtLabel(metadata.GeneratedAt, location),
|
||||||
|
ValidPeriod: metadata.ValidPeriod,
|
||||||
|
ValidPeriodLabel: periodLabel(metadata.ValidPeriod, location),
|
||||||
|
Timezone: metadata.Timezone,
|
||||||
|
},
|
||||||
|
GeneratedText: generated,
|
||||||
|
Modules: modules,
|
||||||
|
Collected: collected,
|
||||||
|
Derived: derived,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildDailyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Daily, collected facts.CollectedFacts, derived facts.DerivedFacts) (DailyRenderContext, error) {
|
||||||
|
reportContext, err := buildDayStyleReportContext(metadata, "daily", func(dayName string) string {
|
||||||
|
return dayName + "'s Weather"
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return DailyRenderContext{}, err
|
||||||
|
}
|
||||||
|
modules, err := dailyTemplateModules(snapshot, derived)
|
||||||
|
if err != nil {
|
||||||
|
return DailyRenderContext{}, err
|
||||||
|
}
|
||||||
|
return DailyRenderContext{
|
||||||
|
Report: reportContext.dailyReportContext(),
|
||||||
|
GeneratedText: generated,
|
||||||
|
Modules: modules,
|
||||||
|
Collected: collected,
|
||||||
|
Derived: derived,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildTodayRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Today, collected facts.CollectedFacts, derived facts.DerivedFacts) (TodayRenderContext, error) {
|
||||||
|
reportContext, err := buildDayStyleReportContext(metadata, "today", func(string) string {
|
||||||
|
return "Today's Weather"
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return TodayRenderContext{}, err
|
||||||
|
}
|
||||||
|
modules, err := todayTemplateModules(snapshot, derived)
|
||||||
|
if err != nil {
|
||||||
|
return TodayRenderContext{}, err
|
||||||
|
}
|
||||||
|
return TodayRenderContext{
|
||||||
|
Report: reportContext.todayReportContext(),
|
||||||
|
GeneratedText: generated,
|
||||||
|
Modules: modules,
|
||||||
|
Collected: collected,
|
||||||
|
Derived: derived,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Tomorrow, collected facts.CollectedFacts, derived facts.DerivedFacts) (TomorrowRenderContext, error) {
|
||||||
|
reportContext, err := buildDayStyleReportContext(metadata, "tomorrow", func(dayName string) string {
|
||||||
|
return dayName + "'s Weather"
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowRenderContext{}, err
|
||||||
|
}
|
||||||
|
modules, err := tomorrowTemplateModules(snapshot, derived)
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowRenderContext{}, err
|
||||||
|
}
|
||||||
|
return TomorrowRenderContext{
|
||||||
|
Report: reportContext.tomorrowReportContext(),
|
||||||
|
GeneratedText: generated,
|
||||||
|
Modules: modules,
|
||||||
|
Collected: collected,
|
||||||
|
Derived: derived,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDayStyleReportContext(metadata briefing.Metadata, name string, title func(string) string) (dayStyleReportContext, error) {
|
||||||
|
location, err := timeutil.LoadLocation(metadata.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return dayStyleReportContext{}, fmt.Errorf("build %s render context: %w", name, err)
|
||||||
|
}
|
||||||
|
if metadata.GeneratedAt.IsZero() {
|
||||||
|
return dayStyleReportContext{}, fmt.Errorf("build %s render context: generatedAt is required", name)
|
||||||
|
}
|
||||||
|
if !metadata.ValidPeriod.IsValid() {
|
||||||
|
return dayStyleReportContext{}, fmt.Errorf("build %s render context: valid period is required", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
forecastDate := metadata.ValidPeriod.Start.In(location)
|
||||||
|
forecastDayName := forecastDate.Format("Monday")
|
||||||
|
return dayStyleReportContext{
|
||||||
|
Title: title(forecastDayName),
|
||||||
|
ForecastDate: forecastDate,
|
||||||
|
ForecastDateLabel: forecastDate.Format("Monday, January 2, 2006"),
|
||||||
|
ForecastDayName: forecastDayName,
|
||||||
|
GeneratedAt: metadata.GeneratedAt,
|
||||||
|
GeneratedAtLabel: generatedAtLabel(metadata.GeneratedAt, location),
|
||||||
|
ValidPeriod: metadata.ValidPeriod,
|
||||||
|
Timezone: metadata.Timezone,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx dayStyleReportContext) dailyReportContext() DailyReportContext {
|
||||||
|
return DailyReportContext{
|
||||||
|
Title: ctx.Title,
|
||||||
|
ForecastDate: ctx.ForecastDate,
|
||||||
|
ForecastDateLabel: ctx.ForecastDateLabel,
|
||||||
|
ForecastDayName: ctx.ForecastDayName,
|
||||||
|
GeneratedAt: ctx.GeneratedAt,
|
||||||
|
GeneratedAtLabel: ctx.GeneratedAtLabel,
|
||||||
|
ValidPeriod: ctx.ValidPeriod,
|
||||||
|
Timezone: ctx.Timezone,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx dayStyleReportContext) todayReportContext() TodayReportContext {
|
||||||
|
return TodayReportContext{
|
||||||
|
Title: ctx.Title,
|
||||||
|
ForecastDate: ctx.ForecastDate,
|
||||||
|
ForecastDateLabel: ctx.ForecastDateLabel,
|
||||||
|
ForecastDayName: ctx.ForecastDayName,
|
||||||
|
GeneratedAt: ctx.GeneratedAt,
|
||||||
|
GeneratedAtLabel: ctx.GeneratedAtLabel,
|
||||||
|
ValidPeriod: ctx.ValidPeriod,
|
||||||
|
Timezone: ctx.Timezone,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx dayStyleReportContext) tomorrowReportContext() TomorrowReportContext {
|
||||||
|
return TomorrowReportContext{
|
||||||
|
Title: ctx.Title,
|
||||||
|
ForecastDate: ctx.ForecastDate,
|
||||||
|
ForecastDateLabel: ctx.ForecastDateLabel,
|
||||||
|
ForecastDayName: ctx.ForecastDayName,
|
||||||
|
GeneratedAt: ctx.GeneratedAt,
|
||||||
|
GeneratedAtLabel: ctx.GeneratedAtLabel,
|
||||||
|
ValidPeriod: ctx.ValidPeriod,
|
||||||
|
Timezone: ctx.Timezone,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dailyTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (DailyTemplateModules, error) {
|
||||||
|
common, lookup, err := dayStyleTemplateModuleSnapshot(snapshot)
|
||||||
|
if err != nil {
|
||||||
|
return DailyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
outdoor, err := lookup.outdoorWindows()
|
||||||
|
if err != nil {
|
||||||
|
return DailyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
planning, err := lookup.dailyPlanning()
|
||||||
|
if err != nil {
|
||||||
|
return DailyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
return DailyTemplateModules{
|
||||||
|
Metadata: common.Metadata,
|
||||||
|
CurrentConditions: common.CurrentConditions,
|
||||||
|
HourlyForecast: common.HourlyForecast,
|
||||||
|
DerivedDailySummary: common.DerivedDailySummary,
|
||||||
|
DerivedDaypartSummaries: common.DerivedDaypartSummaries,
|
||||||
|
Dayparts: orderedDailyDayparts(common.DerivedDaypartSummaries, derived.DaypartSummaries),
|
||||||
|
PrecipTiming: common.PrecipTiming,
|
||||||
|
AlertDigest: common.AlertDigest,
|
||||||
|
SPCConvectiveOutlooks: common.SPCConvectiveOutlooks,
|
||||||
|
AreaForecastDiscussion: common.AreaForecastDiscussion,
|
||||||
|
SPCConvectiveDiscussion: common.SPCConvectiveDiscussion,
|
||||||
|
WeatherStory: common.WeatherStory,
|
||||||
|
OutdoorWindows: outdoor,
|
||||||
|
DailyPlanning: planning,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func todayTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TodayTemplateModules, error) {
|
||||||
|
common, lookup, err := dayStyleTemplateModuleSnapshot(snapshot)
|
||||||
|
if err != nil {
|
||||||
|
return TodayTemplateModules{}, err
|
||||||
|
}
|
||||||
|
planning, err := lookup.todayPlanning()
|
||||||
|
if err != nil {
|
||||||
|
return TodayTemplateModules{}, err
|
||||||
|
}
|
||||||
|
return TodayTemplateModules{
|
||||||
|
Metadata: common.Metadata,
|
||||||
|
CurrentConditions: common.CurrentConditions,
|
||||||
|
HourlyForecast: common.HourlyForecast,
|
||||||
|
DerivedDailySummary: common.DerivedDailySummary,
|
||||||
|
DerivedDaypartSummaries: common.DerivedDaypartSummaries,
|
||||||
|
Dayparts: orderedTodayDayparts(common.DerivedDaypartSummaries, derived.DaypartSummaries),
|
||||||
|
PrecipTiming: common.PrecipTiming,
|
||||||
|
AlertDigest: common.AlertDigest,
|
||||||
|
SPCConvectiveOutlooks: common.SPCConvectiveOutlooks,
|
||||||
|
AreaForecastDiscussion: common.AreaForecastDiscussion,
|
||||||
|
SPCConvectiveDiscussion: common.SPCConvectiveDiscussion,
|
||||||
|
WeatherStory: common.WeatherStory,
|
||||||
|
TodayPlanning: planning,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, error) {
|
||||||
|
lookup := newModuleSnapshotLookup(snapshot)
|
||||||
|
metadata, err := lookup.metadata()
|
||||||
|
if err != nil {
|
||||||
|
return HourlyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
current, err := lookup.currentConditions()
|
||||||
|
if err != nil {
|
||||||
|
return HourlyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
hourly, err := lookup.hourlyForecast()
|
||||||
|
if err != nil {
|
||||||
|
return HourlyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
precip, err := lookup.precipTiming()
|
||||||
|
if err != nil {
|
||||||
|
return HourlyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
alerts, err := lookup.alertDigest()
|
||||||
|
if err != nil {
|
||||||
|
return HourlyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
outlooks, err := lookup.spcConvectiveOutlooks()
|
||||||
|
if err != nil {
|
||||||
|
return HourlyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
discussion, err := lookup.areaForecastDiscussion()
|
||||||
|
if err != nil {
|
||||||
|
return HourlyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
spcDiscussion, err := lookup.spcConvectiveDiscussion()
|
||||||
|
if err != nil {
|
||||||
|
return HourlyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
story, err := lookup.weatherStory()
|
||||||
|
if err != nil {
|
||||||
|
return HourlyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
return HourlyTemplateModules{
|
||||||
|
Metadata: metadata,
|
||||||
|
CurrentConditions: current,
|
||||||
|
HourlyForecast: hourly,
|
||||||
|
PrecipTiming: precip,
|
||||||
|
AlertDigest: alerts,
|
||||||
|
SPCConvectiveOutlooks: outlooks,
|
||||||
|
AreaForecastDiscussion: discussion,
|
||||||
|
SPCConvectiveDiscussion: spcDiscussion,
|
||||||
|
WeatherStory: story,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tomorrowTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TomorrowTemplateModules, error) {
|
||||||
|
common, lookup, err := dayStyleTemplateModuleSnapshot(snapshot)
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowTemplateModules{}, err
|
||||||
|
}
|
||||||
|
planning, err := lookup.tomorrowPlanning()
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowTemplateModules{}, err
|
||||||
|
}
|
||||||
|
return TomorrowTemplateModules{
|
||||||
|
Metadata: common.Metadata,
|
||||||
|
CurrentConditions: common.CurrentConditions,
|
||||||
|
HourlyForecast: common.HourlyForecast,
|
||||||
|
DerivedDailySummary: common.DerivedDailySummary,
|
||||||
|
DerivedDaypartSummaries: common.DerivedDaypartSummaries,
|
||||||
|
Dayparts: orderedTomorrowDayparts(common.DerivedDaypartSummaries, derived.DaypartSummaries),
|
||||||
|
PrecipTiming: common.PrecipTiming,
|
||||||
|
AlertDigest: common.AlertDigest,
|
||||||
|
SPCConvectiveOutlooks: common.SPCConvectiveOutlooks,
|
||||||
|
AreaForecastDiscussion: common.AreaForecastDiscussion,
|
||||||
|
SPCConvectiveDiscussion: common.SPCConvectiveDiscussion,
|
||||||
|
WeatherStory: common.WeatherStory,
|
||||||
|
TomorrowPlanning: planning,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dayStyleTemplateModuleSnapshot(snapshot module.Snapshot) (dayStyleTemplateModules, moduleSnapshotLookup, error) {
|
||||||
|
lookup := newModuleSnapshotLookup(snapshot)
|
||||||
|
metadata, err := lookup.metadata()
|
||||||
|
if err != nil {
|
||||||
|
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
|
||||||
|
}
|
||||||
|
current, err := lookup.currentConditions()
|
||||||
|
if err != nil {
|
||||||
|
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
|
||||||
|
}
|
||||||
|
hourly, err := lookup.hourlyForecast()
|
||||||
|
if err != nil {
|
||||||
|
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
|
||||||
|
}
|
||||||
|
daily, err := lookup.derivedDailySummary()
|
||||||
|
if err != nil {
|
||||||
|
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
|
||||||
|
}
|
||||||
|
dayparts, err := lookup.derivedDaypartSummaries()
|
||||||
|
if err != nil {
|
||||||
|
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
|
||||||
|
}
|
||||||
|
precip, err := lookup.precipTiming()
|
||||||
|
if err != nil {
|
||||||
|
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
|
||||||
|
}
|
||||||
|
alerts, err := lookup.alertDigest()
|
||||||
|
if err != nil {
|
||||||
|
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
|
||||||
|
}
|
||||||
|
outlooks, err := lookup.spcConvectiveOutlooks()
|
||||||
|
if err != nil {
|
||||||
|
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
|
||||||
|
}
|
||||||
|
discussion, err := lookup.areaForecastDiscussion()
|
||||||
|
if err != nil {
|
||||||
|
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
|
||||||
|
}
|
||||||
|
spcDiscussion, err := lookup.spcConvectiveDiscussion()
|
||||||
|
if err != nil {
|
||||||
|
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
|
||||||
|
}
|
||||||
|
story, err := lookup.weatherStory()
|
||||||
|
if err != nil {
|
||||||
|
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
|
||||||
|
}
|
||||||
|
return dayStyleTemplateModules{
|
||||||
|
Metadata: metadata,
|
||||||
|
CurrentConditions: current,
|
||||||
|
HourlyForecast: hourly,
|
||||||
|
DerivedDailySummary: daily,
|
||||||
|
DerivedDaypartSummaries: dayparts,
|
||||||
|
PrecipTiming: precip,
|
||||||
|
AlertDigest: alerts,
|
||||||
|
SPCConvectiveOutlooks: outlooks,
|
||||||
|
AreaForecastDiscussion: discussion,
|
||||||
|
SPCConvectiveDiscussion: spcDiscussion,
|
||||||
|
WeatherStory: story,
|
||||||
|
}, lookup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type moduleSnapshotLookup struct {
|
||||||
|
snapshot module.Snapshot
|
||||||
|
stanzas map[string]module.Output
|
||||||
|
}
|
||||||
|
|
||||||
|
func newModuleSnapshotLookup(snapshot module.Snapshot) moduleSnapshotLookup {
|
||||||
|
stanzas := make(map[string]module.Output, len(snapshot.Outputs))
|
||||||
|
for _, output := range snapshot.Outputs {
|
||||||
|
stanzas[output.StanzaName] = output
|
||||||
|
}
|
||||||
|
return moduleSnapshotLookup{
|
||||||
|
snapshot: snapshot,
|
||||||
|
stanzas: stanzas,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) metadata() (*briefing.MetadataModule, error) {
|
||||||
|
return optionalStanza[briefing.MetadataModule](lookup, module.Metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) currentConditions() (*briefing.CurrentConditionsModule, error) {
|
||||||
|
return optionalStanza[briefing.CurrentConditionsModule](lookup, module.CurrentConditions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) hourlyForecast() (*briefing.HourlyForecastModule, error) {
|
||||||
|
return optionalStanza[briefing.HourlyForecastModule](lookup, module.HourlyForecast)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) derivedDailySummary() (*briefing.DerivedDailySummaryModule, error) {
|
||||||
|
return optionalStanza[briefing.DerivedDailySummaryModule](lookup, module.DerivedDailySummary)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) derivedDaypartSummaries() (*map[string]briefing.DerivedDaypartSummaryModule, error) {
|
||||||
|
return optionalStanza[map[string]briefing.DerivedDaypartSummaryModule](lookup, module.DerivedDaypartSummaries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) precipTiming() (*briefing.PrecipTimingModule, error) {
|
||||||
|
return optionalStanza[briefing.PrecipTimingModule](lookup, module.PrecipTiming)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) alertDigest() (*briefing.AlertDigestModule, error) {
|
||||||
|
return optionalStanza[briefing.AlertDigestModule](lookup, module.AlertDigest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) spcConvectiveOutlooks() (*briefing.SPCConvectiveOutlooksModule, error) {
|
||||||
|
return optionalStanza[briefing.SPCConvectiveOutlooksModule](lookup, module.SPCConvectiveOutlooks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) areaForecastDiscussion() (*briefing.AreaForecastDiscussionModule, error) {
|
||||||
|
return optionalStanza[briefing.AreaForecastDiscussionModule](lookup, module.AreaForecastDiscussion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) spcConvectiveDiscussion() (*briefing.SPCConvectiveDiscussionModule, error) {
|
||||||
|
return optionalStanza[briefing.SPCConvectiveDiscussionModule](lookup, module.SPCConvectiveDiscussion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) weatherStory() (*briefing.WeatherStoryModule, error) {
|
||||||
|
return optionalStanza[briefing.WeatherStoryModule](lookup, module.WeatherStory)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) outdoorWindows() (*briefing.OutdoorWindowsModule, error) {
|
||||||
|
return optionalStanza[briefing.OutdoorWindowsModule](lookup, module.OutdoorWindows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) dailyPlanning() (*briefing.DailyPlanningModule, error) {
|
||||||
|
return optionalStanza[briefing.DailyPlanningModule](lookup, module.DailyPlanning)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) todayPlanning() (*briefing.TodayPlanningModule, error) {
|
||||||
|
return optionalStanza[briefing.TodayPlanningModule](lookup, module.TodayPlanning)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) tomorrowPlanning() (*briefing.TomorrowPlanningModule, error) {
|
||||||
|
return optionalStanza[briefing.TomorrowPlanningModule](lookup, module.TomorrowPlanning)
|
||||||
|
}
|
||||||
|
|
||||||
|
func optionalStanza[T any](lookup moduleSnapshotLookup, id module.ID) (*T, error) {
|
||||||
|
name := string(id)
|
||||||
|
output, ok := lookup.stanzas[name]
|
||||||
|
if !ok || output.Value == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
value, _, err := module.StanzaValue[T](lookup.snapshot, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build render context module %q: %w", id, err)
|
||||||
|
}
|
||||||
|
return &value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderedTodayDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []TodayDaypartContext {
|
||||||
|
orderedRows := orderedDaypartRows(dayparts, ordered)
|
||||||
|
out := make([]TodayDaypartContext, 0, len(orderedRows))
|
||||||
|
for _, row := range orderedRows {
|
||||||
|
out = append(out, TodayDaypartContext{Key: row.Key, Summary: row.Summary})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderedDailyDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []DailyDaypartContext {
|
||||||
|
orderedRows := orderedDaypartRows(dayparts, ordered)
|
||||||
|
out := make([]DailyDaypartContext, 0, len(orderedRows))
|
||||||
|
for _, row := range orderedRows {
|
||||||
|
out = append(out, DailyDaypartContext{Key: row.Key, Summary: row.Summary})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderedTomorrowDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []TomorrowDaypartContext {
|
||||||
|
orderedRows := orderedDaypartRows(dayparts, ordered)
|
||||||
|
out := make([]TomorrowDaypartContext, 0, len(orderedRows))
|
||||||
|
for _, row := range orderedRows {
|
||||||
|
out = append(out, TomorrowDaypartContext{Key: row.Key, Summary: row.Summary})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type orderedDaypartRow struct {
|
||||||
|
Key string
|
||||||
|
Summary briefing.DerivedDaypartSummaryModule
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderedDaypartRows(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []orderedDaypartRow {
|
||||||
|
if dayparts == nil || len(*dayparts) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]orderedDaypartRow, 0, len(*dayparts))
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, daypart := range ordered {
|
||||||
|
for _, key := range daypartModuleKeyCandidates(daypart) {
|
||||||
|
value, ok := (*dayparts)[key]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
out = append(out, orderedDaypartRow{Key: key, Summary: value})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var remaining []string
|
||||||
|
for key := range *dayparts {
|
||||||
|
if _, ok := seen[key]; !ok {
|
||||||
|
remaining = append(remaining, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(remaining)
|
||||||
|
for _, key := range remaining {
|
||||||
|
out = append(out, orderedDaypartRow{Key: key, Summary: (*dayparts)[key]})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func daypartModuleKeyCandidates(daypart forecast.DaypartSummary) []string {
|
||||||
|
key := normalizedDaypartKey(daypart.Name)
|
||||||
|
if key == "" {
|
||||||
|
key = "unnamed"
|
||||||
|
}
|
||||||
|
if daypart.Period.Start.IsZero() {
|
||||||
|
return []string{key}
|
||||||
|
}
|
||||||
|
return []string{key, daypart.Period.Start.Format(timeutil.DateLayout) + "_" + key}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedDaypartKey(value string) string {
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(value))
|
||||||
|
var out strings.Builder
|
||||||
|
lastUnderscore := false
|
||||||
|
for _, r := range lower {
|
||||||
|
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||||
|
out.WriteRune(r)
|
||||||
|
lastUnderscore = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !lastUnderscore {
|
||||||
|
out.WriteByte('_')
|
||||||
|
lastUnderscore = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Trim(out.String(), "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 generatedAtLabel(value time.Time, location *time.Location) string {
|
||||||
|
return value.In(location).Format("Monday, January 2, 2006 at 3:04 PM")
|
||||||
|
}
|
||||||
1395
internal/generatedtext/render_context_test.go
Normal file
1395
internal/generatedtext/render_context_test.go
Normal file
File diff suppressed because it is too large
Load Diff
28
internal/generatedtext/today.go
Normal file
28
internal/generatedtext/today.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package generatedtext
|
||||||
|
|
||||||
|
type Today struct {
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
ForecastDiscussion []string `json:"forecast_discussion"`
|
||||||
|
PrecipitationTiming string `json:"precipitation_timing,omitempty"`
|
||||||
|
Confidence string `json:"confidence,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateToday(data []byte) (Today, []byte, error) {
|
||||||
|
return validateDayStyleGeneratedText[Today, *Today](data, "today")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Today) dayStyleFields() dayStyleFields {
|
||||||
|
return dayStyleFields{
|
||||||
|
Summary: t.Summary,
|
||||||
|
ForecastDiscussion: t.ForecastDiscussion,
|
||||||
|
PrecipitationTiming: t.PrecipitationTiming,
|
||||||
|
Confidence: t.Confidence,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Today) setDayStyleFields(fields dayStyleFields) {
|
||||||
|
t.Summary = fields.Summary
|
||||||
|
t.ForecastDiscussion = fields.ForecastDiscussion
|
||||||
|
t.PrecipitationTiming = fields.PrecipitationTiming
|
||||||
|
t.Confidence = fields.Confidence
|
||||||
|
}
|
||||||
111
internal/generatedtext/today_test.go
Normal file
111
internal/generatedtext/today_test.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package generatedtext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateTodayNormalizesJSON(t *testing.T) {
|
||||||
|
value, normalized, err := ValidateToday([]byte(`{
|
||||||
|
"summary": " Showers are likely today. ",
|
||||||
|
"forecast_discussion": [
|
||||||
|
" A front will keep rain chances elevated. ",
|
||||||
|
"",
|
||||||
|
" Temperatures stay mild through the afternoon. "
|
||||||
|
],
|
||||||
|
"precipitation_timing": " Rain is most likely during the afternoon. ",
|
||||||
|
"confidence": " Medium "
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateToday() error = %v", err)
|
||||||
|
}
|
||||||
|
if value.Summary != "Showers are likely today." {
|
||||||
|
t.Fatalf("Summary = %q, want trimmed summary", value.Summary)
|
||||||
|
}
|
||||||
|
if strings.Join(value.ForecastDiscussion, "|") != "A front will keep rain chances elevated.|Temperatures stay mild through the afternoon." {
|
||||||
|
t.Fatalf("ForecastDiscussion = %#v, want trimmed non-empty paragraphs", value.ForecastDiscussion)
|
||||||
|
}
|
||||||
|
if value.PrecipitationTiming != "Rain is most likely during the afternoon." {
|
||||||
|
t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming)
|
||||||
|
}
|
||||||
|
want := `{"summary":"Showers are likely today.","forecast_discussion":["A front will keep rain chances elevated.","Temperatures stay mild through the afternoon."],"precipitation_timing":"Rain is most likely during the afternoon.","confidence":"Medium"}`
|
||||||
|
if string(normalized) != want {
|
||||||
|
t.Fatalf("normalized = %s, want %s", normalized, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateTodayOmitsEmptyOptionalFields(t *testing.T) {
|
||||||
|
_, normalized, err := ValidateToday([]byte(`{
|
||||||
|
"summary": "Showers are likely today.",
|
||||||
|
"forecast_discussion": ["A front will keep rain chances elevated."],
|
||||||
|
"precipitation_timing": " ",
|
||||||
|
"confidence": " "
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateToday() error = %v", err)
|
||||||
|
}
|
||||||
|
want := `{"summary":"Showers are likely today.","forecast_discussion":["A front will keep rain chances elevated."]}`
|
||||||
|
if string(normalized) != want {
|
||||||
|
t.Fatalf("normalized = %s, want %s", normalized, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateTodayRejectsInvalidInput(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "malformed",
|
||||||
|
in: `{`,
|
||||||
|
want: "decode today generated text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown field",
|
||||||
|
in: `{"summary":"Showers are likely today.","forecast_discussion":["A front will keep rain chances elevated."],"extra":"value"}`,
|
||||||
|
want: `unknown field "extra"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing summary",
|
||||||
|
in: `{"forecast_discussion":["A front will keep rain chances elevated."]}`,
|
||||||
|
want: "summary is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blank summary",
|
||||||
|
in: `{"summary":" ","forecast_discussion":["A front will keep rain chances elevated."]}`,
|
||||||
|
want: "summary is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing forecast discussion",
|
||||||
|
in: `{"summary":"Showers are likely today."}`,
|
||||||
|
want: "forecast discussion is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blank forecast discussion",
|
||||||
|
in: `{"summary":"Showers are likely today.","forecast_discussion":[" ",""]}`,
|
||||||
|
want: "forecast discussion is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "forecast discussion wrong type",
|
||||||
|
in: `{"summary":"Showers are likely today.","forecast_discussion":"A front will keep rain chances elevated."}`,
|
||||||
|
want: "cannot unmarshal string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple values",
|
||||||
|
in: `{"summary":"Showers are likely today.","forecast_discussion":["A front will keep rain chances elevated."]} {}`,
|
||||||
|
want: "multiple JSON values",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, _, err := ValidateToday([]byte(test.in))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ValidateToday() error = nil, want error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("ValidateToday() error = %v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
28
internal/generatedtext/tomorrow.go
Normal file
28
internal/generatedtext/tomorrow.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package generatedtext
|
||||||
|
|
||||||
|
type Tomorrow struct {
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
ForecastDiscussion []string `json:"forecast_discussion"`
|
||||||
|
PrecipitationTiming string `json:"precipitation_timing,omitempty"`
|
||||||
|
Confidence string `json:"confidence,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateTomorrow(data []byte) (Tomorrow, []byte, error) {
|
||||||
|
return validateDayStyleGeneratedText[Tomorrow, *Tomorrow](data, "tomorrow")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tomorrow) dayStyleFields() dayStyleFields {
|
||||||
|
return dayStyleFields{
|
||||||
|
Summary: t.Summary,
|
||||||
|
ForecastDiscussion: t.ForecastDiscussion,
|
||||||
|
PrecipitationTiming: t.PrecipitationTiming,
|
||||||
|
Confidence: t.Confidence,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tomorrow) setDayStyleFields(fields dayStyleFields) {
|
||||||
|
t.Summary = fields.Summary
|
||||||
|
t.ForecastDiscussion = fields.ForecastDiscussion
|
||||||
|
t.PrecipitationTiming = fields.PrecipitationTiming
|
||||||
|
t.Confidence = fields.Confidence
|
||||||
|
}
|
||||||
111
internal/generatedtext/tomorrow_test.go
Normal file
111
internal/generatedtext/tomorrow_test.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package generatedtext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateTomorrowNormalizesJSON(t *testing.T) {
|
||||||
|
value, normalized, err := ValidateTomorrow([]byte(`{
|
||||||
|
"summary": " Storms become more likely tomorrow. ",
|
||||||
|
"forecast_discussion": [
|
||||||
|
" A front will keep showers in the forecast. ",
|
||||||
|
"",
|
||||||
|
" Temperatures stay seasonable by afternoon. "
|
||||||
|
],
|
||||||
|
"precipitation_timing": " Rain is most likely before sunrise. ",
|
||||||
|
"confidence": " Medium "
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateTomorrow() error = %v", err)
|
||||||
|
}
|
||||||
|
if value.Summary != "Storms become more likely tomorrow." {
|
||||||
|
t.Fatalf("Summary = %q, want trimmed summary", value.Summary)
|
||||||
|
}
|
||||||
|
if strings.Join(value.ForecastDiscussion, "|") != "A front will keep showers in the forecast.|Temperatures stay seasonable by afternoon." {
|
||||||
|
t.Fatalf("ForecastDiscussion = %#v, want trimmed non-empty paragraphs", value.ForecastDiscussion)
|
||||||
|
}
|
||||||
|
if value.PrecipitationTiming != "Rain is most likely before sunrise." {
|
||||||
|
t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming)
|
||||||
|
}
|
||||||
|
want := `{"summary":"Storms become more likely tomorrow.","forecast_discussion":["A front will keep showers in the forecast.","Temperatures stay seasonable by afternoon."],"precipitation_timing":"Rain is most likely before sunrise.","confidence":"Medium"}`
|
||||||
|
if string(normalized) != want {
|
||||||
|
t.Fatalf("normalized = %s, want %s", normalized, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateTomorrowOmitsEmptyOptionalFields(t *testing.T) {
|
||||||
|
_, normalized, err := ValidateTomorrow([]byte(`{
|
||||||
|
"summary": "Storms become more likely tomorrow.",
|
||||||
|
"forecast_discussion": ["A front will keep showers in the forecast."],
|
||||||
|
"precipitation_timing": " ",
|
||||||
|
"confidence": " "
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateTomorrow() error = %v", err)
|
||||||
|
}
|
||||||
|
want := `{"summary":"Storms become more likely tomorrow.","forecast_discussion":["A front will keep showers in the forecast."]}`
|
||||||
|
if string(normalized) != want {
|
||||||
|
t.Fatalf("normalized = %s, want %s", normalized, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateTomorrowRejectsInvalidInput(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "malformed",
|
||||||
|
in: `{`,
|
||||||
|
want: "decode tomorrow generated text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown field",
|
||||||
|
in: `{"summary":"Storms become more likely tomorrow.","forecast_discussion":["A front will keep showers in the forecast."],"extra":"value"}`,
|
||||||
|
want: `unknown field "extra"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing summary",
|
||||||
|
in: `{"forecast_discussion":["A front will keep showers in the forecast."]}`,
|
||||||
|
want: "summary is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blank summary",
|
||||||
|
in: `{"summary":" ","forecast_discussion":["A front will keep showers in the forecast."]}`,
|
||||||
|
want: "summary is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing forecast discussion",
|
||||||
|
in: `{"summary":"Storms become more likely tomorrow."}`,
|
||||||
|
want: "forecast discussion is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blank forecast discussion",
|
||||||
|
in: `{"summary":"Storms become more likely tomorrow.","forecast_discussion":[" ",""]}`,
|
||||||
|
want: "forecast discussion is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "forecast discussion wrong type",
|
||||||
|
in: `{"summary":"Storms become more likely tomorrow.","forecast_discussion":"A front will keep showers in the forecast."}`,
|
||||||
|
want: "cannot unmarshal string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple values",
|
||||||
|
in: `{"summary":"Storms become more likely tomorrow.","forecast_discussion":["A front will keep showers in the forecast."]} {}`,
|
||||||
|
want: "multiple JSON values",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, _, err := ValidateTomorrow([]byte(test.in))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ValidateTomorrow() error = nil, want error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("ValidateTomorrow() error = %v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,8 +21,12 @@ 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"
|
||||||
|
TodayPlanning ID = "today_planning"
|
||||||
TomorrowPlanning ID = "tomorrow_planning"
|
TomorrowPlanning ID = "tomorrow_planning"
|
||||||
|
DailyPlanning ID = "daily_planning"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ConfigItem struct {
|
type ConfigItem struct {
|
||||||
@@ -31,9 +35,10 @@ type ConfigItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Output struct {
|
type Output struct {
|
||||||
ID ID `json:"id"`
|
ID ID `json:"id"`
|
||||||
StanzaName string `json:"stanzaName"`
|
StanzaName string `json:"stanzaName"`
|
||||||
Value any `json:"value"`
|
Value any `json:"value"`
|
||||||
|
PromptValue any `json:"-" yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Snapshot struct {
|
type Snapshot struct {
|
||||||
@@ -41,6 +46,13 @@ type Snapshot struct {
|
|||||||
Outputs []Output `json:"outputs"`
|
Outputs []Output `json:"outputs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (o Output) DataPackageValue() any {
|
||||||
|
if o.PromptValue != nil {
|
||||||
|
return o.PromptValue
|
||||||
|
}
|
||||||
|
return o.Value
|
||||||
|
}
|
||||||
|
|
||||||
func NewSnapshot(outputs []Output) (Snapshot, error) {
|
func NewSnapshot(outputs []Output) (Snapshot, error) {
|
||||||
snapshot := Snapshot{
|
snapshot := Snapshot{
|
||||||
SchemaVersion: SnapshotSchemaVersion,
|
SchemaVersion: SnapshotSchemaVersion,
|
||||||
@@ -105,19 +117,21 @@ func StanzaValue[T any](s Snapshot, name string) (T, bool, error) {
|
|||||||
type FactRequirement string
|
type FactRequirement string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
CollectedCurrentConditions FactRequirement = "collected.current_conditions"
|
CollectedCurrentConditions FactRequirement = "collected.current_conditions"
|
||||||
CollectedNarrativeForecast FactRequirement = "collected.narrative_forecast"
|
CollectedNarrativeForecast FactRequirement = "collected.narrative_forecast"
|
||||||
CollectedHourlyForecast FactRequirement = "collected.hourly_forecast"
|
CollectedHourlyForecast FactRequirement = "collected.hourly_forecast"
|
||||||
CollectedAlerts FactRequirement = "collected.alerts"
|
CollectedAlerts FactRequirement = "collected.alerts"
|
||||||
CollectedDiscussion FactRequirement = "collected.discussion"
|
CollectedDiscussion FactRequirement = "collected.discussion"
|
||||||
CollectedWeatherStory FactRequirement = "collected.weather_story"
|
CollectedWeatherStory FactRequirement = "collected.weather_story"
|
||||||
CollectedSourceMetadata FactRequirement = "collected.source_metadata"
|
CollectedSPCConvectiveOutlooks FactRequirement = "collected.spc_convective_outlooks"
|
||||||
RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods"
|
CollectedSourceMetadata FactRequirement = "collected.source_metadata"
|
||||||
RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods"
|
RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods"
|
||||||
RequiresDerivedAlertOverlaps FactRequirement = "derived.alert_overlaps"
|
RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods"
|
||||||
RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries"
|
RequiresDerivedAlertOverlaps FactRequirement = "derived.alert_overlaps"
|
||||||
RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries"
|
RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries"
|
||||||
RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing"
|
RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries"
|
||||||
|
RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing"
|
||||||
|
RequiresDerivedSPCConvectiveOutlooks FactRequirement = "derived.spc_convective_outlooks"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MissingDataBehavior string
|
type MissingDataBehavior string
|
||||||
@@ -141,5 +155,9 @@ 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 TodayPlanningOptions struct{}
|
||||||
type TomorrowPlanningOptions struct{}
|
type TomorrowPlanningOptions struct{}
|
||||||
|
type DailyPlanningOptions struct{}
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ type testStanza struct {
|
|||||||
|
|
||||||
func TestSnapshotPreservesOutputOrderAndJSON(t *testing.T) {
|
func TestSnapshotPreservesOutputOrderAndJSON(t *testing.T) {
|
||||||
snapshot, err := NewSnapshot([]Output{
|
snapshot, err := NewSnapshot([]Output{
|
||||||
{ID: Metadata, StanzaName: "metadata", Value: testStanza{Message: "first", Count: 1}},
|
{
|
||||||
|
ID: Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Value: testStanza{Message: "first", Count: 1},
|
||||||
|
PromptValue: testStanza{Message: "prompt-only", Count: 10},
|
||||||
|
},
|
||||||
{ID: AlertDigest, StanzaName: "alert_digest", Value: testStanza{Message: "second", Count: 2}},
|
{ID: AlertDigest, StanzaName: "alert_digest", Value: testStanza{Message: "second", Count: 2}},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -35,6 +40,26 @@ func TestSnapshotPreservesOutputOrderAndJSON(t *testing.T) {
|
|||||||
if got != want {
|
if got != want {
|
||||||
t.Fatalf("json = %s, want %s", got, want)
|
t.Fatalf("json = %s, want %s", got, want)
|
||||||
}
|
}
|
||||||
|
if strings.Contains(got, "PromptValue") || strings.Contains(got, "promptValue") || strings.Contains(got, "prompt-only") {
|
||||||
|
t.Fatalf("json includes runtime-only prompt value: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutputDataPackageValue(t *testing.T) {
|
||||||
|
output := Output{
|
||||||
|
ID: Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Value: testStanza{Message: "rich", Count: 1},
|
||||||
|
PromptValue: testStanza{Message: "prompt", Count: 2},
|
||||||
|
}
|
||||||
|
if got := output.DataPackageValue(); got != output.PromptValue {
|
||||||
|
t.Fatalf("DataPackageValue() = %#v, want prompt value", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
output.PromptValue = nil
|
||||||
|
if got := output.DataPackageValue(); got != output.Value {
|
||||||
|
t.Fatalf("DataPackageValue() = %#v, want rich value fallback", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSnapshotRejectsDuplicateOutputs(t *testing.T) {
|
func TestSnapshotRejectsDuplicateOutputs(t *testing.T) {
|
||||||
@@ -57,7 +82,12 @@ func TestSnapshotRejectsDuplicateOutputs(t *testing.T) {
|
|||||||
|
|
||||||
func TestStanzaValueDecodesTypedOutput(t *testing.T) {
|
func TestStanzaValueDecodesTypedOutput(t *testing.T) {
|
||||||
snapshot, err := NewSnapshot([]Output{
|
snapshot, err := NewSnapshot([]Output{
|
||||||
{ID: Metadata, StanzaName: "metadata", Value: testStanza{Message: "available", Count: 3}},
|
{
|
||||||
|
ID: Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Value: testStanza{Message: "available", Count: 3},
|
||||||
|
PromptValue: testStanza{Message: "prompt-only", Count: 99},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewSnapshot() error = %v", err)
|
t.Fatalf("NewSnapshot() error = %v", err)
|
||||||
@@ -82,3 +112,29 @@ 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{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDailyPlanningModuleContractIsStable(t *testing.T) {
|
||||||
|
if DailyPlanning != ID("daily_planning") {
|
||||||
|
t.Fatalf("DailyPlanning = %q, want stable module ID", DailyPlanning)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = DailyPlanningOptions{}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const SchemaVersion = "weatherreporter.data_package.v2"
|
const SchemaVersion = "weatherreporter.data_package.v3"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
metadataStanza = "metadata"
|
metadataStanza = "metadata"
|
||||||
@@ -35,13 +35,17 @@ 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,
|
||||||
string(module.OutdoorWindows): categoryDerivedSummaries,
|
string(module.OutdoorWindows): categoryDerivedSummaries,
|
||||||
|
string(module.DailyPlanning): categoryDerivedSummaries,
|
||||||
|
string(module.TodayPlanning): categoryDerivedSummaries,
|
||||||
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,
|
||||||
@@ -129,7 +133,7 @@ func stanzasFromSnapshot(snapshot module.Snapshot) BriefingStanzas {
|
|||||||
order := make([]string, 0, len(snapshot.Outputs))
|
order := make([]string, 0, len(snapshot.Outputs))
|
||||||
for _, output := range snapshot.Outputs {
|
for _, output := range snapshot.Outputs {
|
||||||
order = append(order, output.StanzaName)
|
order = append(order, output.StanzaName)
|
||||||
values[output.StanzaName] = output.Value
|
values[output.StanzaName] = output.DataPackageValue()
|
||||||
}
|
}
|
||||||
return BriefingStanzas{Order: order, Values: values}
|
return BriefingStanzas{Order: order, Values: values}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ func TestBuildDailyDataPackage(t *testing.T) {
|
|||||||
if pkg.SchemaVersion != SchemaVersion {
|
if pkg.SchemaVersion != SchemaVersion {
|
||||||
t.Fatalf("SchemaVersion = %q, want %q", pkg.SchemaVersion, SchemaVersion)
|
t.Fatalf("SchemaVersion = %q, want %q", pkg.SchemaVersion, SchemaVersion)
|
||||||
}
|
}
|
||||||
if pkg.RunID != "20260529T100000Z_daily_today" {
|
if pkg.RunID != "20260529T100000Z_daily" {
|
||||||
t.Fatalf("RunID = %q, want metadata run id", pkg.RunID)
|
t.Fatalf("RunID = %q, want metadata run id", pkg.RunID)
|
||||||
}
|
}
|
||||||
if pkg.Report.PromptID != "weather.daily_report" {
|
if pkg.Report.PromptID != "weather.daily_generated_text" {
|
||||||
t.Fatalf("PromptID = %q, want weather.daily_report", pkg.Report.PromptID)
|
t.Fatalf("PromptID = %q, want weather.daily_generated_text", pkg.Report.PromptID)
|
||||||
}
|
}
|
||||||
if pkg.Report.CurrentLocalDate != "2026-05-29" {
|
if pkg.Report.CurrentLocalDate != "2026-05-29" {
|
||||||
t.Fatalf("CurrentLocalDate = %q, want 2026-05-29", pkg.Report.CurrentLocalDate)
|
t.Fatalf("CurrentLocalDate = %q, want 2026-05-29", pkg.Report.CurrentLocalDate)
|
||||||
@@ -107,6 +107,7 @@ func TestBuildUsesNamedSnapshotStanzas(t *testing.T) {
|
|||||||
req.Modules = snapshotWithOutputs(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.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": req.Metadata.RunID}},
|
||||||
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{"days": []string{"2026-05-29"}}},
|
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{"days": []string{"2026-05-29"}}},
|
||||||
|
module.Output{ID: module.TodayPlanning, StanzaName: "today_planning", Value: map[string]any{"morning_readiness": []string{"routine"}}},
|
||||||
)
|
)
|
||||||
|
|
||||||
pkg, err := Build(req)
|
pkg, err := Build(req)
|
||||||
@@ -120,6 +121,47 @@ func TestBuildUsesNamedSnapshotStanzas(t *testing.T) {
|
|||||||
if _, ok := pkg.Briefing.Values["derived_daypart_summaries"]; !ok {
|
if _, ok := pkg.Briefing.Values["derived_daypart_summaries"]; !ok {
|
||||||
t.Fatal("Briefing.Values[derived_daypart_summaries] missing")
|
t.Fatal("Briefing.Values[derived_daypart_summaries] missing")
|
||||||
}
|
}
|
||||||
|
if _, ok := pkg.Briefing.Values["today_planning"]; !ok {
|
||||||
|
t.Fatal("Briefing.Values[today_planning] missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildUsesPromptValuesFromSnapshot(t *testing.T) {
|
||||||
|
req := validBuildRequest(t)
|
||||||
|
req.Modules = snapshotWithOutputs(t,
|
||||||
|
module.Output{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Value: map[string]string{"run_id": "rich"},
|
||||||
|
PromptValue: map[string]string{"run_id": "prompt"},
|
||||||
|
},
|
||||||
|
module.Output{
|
||||||
|
ID: module.CurrentConditions,
|
||||||
|
StanzaName: "current_conditions",
|
||||||
|
Value: map[string]string{"condition_text": "Rich conditions"},
|
||||||
|
PromptValue: map[string]string{"condition_text": "Prompt conditions"},
|
||||||
|
},
|
||||||
|
module.Output{
|
||||||
|
ID: module.AlertDigest,
|
||||||
|
StanzaName: "alert_digest",
|
||||||
|
Value: map[string]bool{"checked": true},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
pkg, err := Build(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Build() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := pkg.Briefing.Values["metadata"].(map[string]string)["run_id"]; got != "prompt" {
|
||||||
|
t.Fatalf("metadata.run_id = %q, want prompt value", got)
|
||||||
|
}
|
||||||
|
if got := pkg.Briefing.Values["current_conditions"].(map[string]string)["condition_text"]; got != "Prompt conditions" {
|
||||||
|
t.Fatalf("current_conditions.condition_text = %q, want prompt value", got)
|
||||||
|
}
|
||||||
|
if got := pkg.Briefing.Values["alert_digest"].(map[string]bool)["checked"]; !got {
|
||||||
|
t.Fatalf("alert_digest.checked = %v, want rich value fallback", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) {
|
func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) {
|
||||||
@@ -139,7 +181,7 @@ func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) {
|
|||||||
if string(first) != string(second) {
|
if string(first) != string(second) {
|
||||||
t.Fatalf("YAML output changed between marshals:\n%s\n---\n%s", string(first), string(second))
|
t.Fatalf("YAML output changed between marshals:\n%s\n---\n%s", string(first), string(second))
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(first), "schema_version: weatherreporter.data_package.v2") ||
|
if !strings.Contains(string(first), "schema_version: weatherreporter.data_package.v3") ||
|
||||||
!strings.Contains(string(first), "briefing:\n") ||
|
!strings.Contains(string(first), "briefing:\n") ||
|
||||||
!strings.Contains(string(first), " applicable_risk_products:\n") ||
|
!strings.Contains(string(first), " applicable_risk_products:\n") ||
|
||||||
!strings.Contains(string(first), " derived_summaries:\n") ||
|
!strings.Contains(string(first), " derived_summaries:\n") ||
|
||||||
@@ -164,6 +206,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 {
|
||||||
@@ -203,11 +289,11 @@ func TestMarshalYAMLRejectsUncategorizedStanza(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoadYAMLRejectsMisplacedStanza(t *testing.T) {
|
func TestLoadYAMLRejectsMisplacedStanza(t *testing.T) {
|
||||||
data := []byte(`
|
data := []byte(`
|
||||||
schema_version: weatherreporter.data_package.v2
|
schema_version: weatherreporter.data_package.v3
|
||||||
run_id: 20260529T100000Z_daily_today
|
run_id: 20260529T100000Z_daily
|
||||||
report:
|
report:
|
||||||
id: daily_today
|
id: daily
|
||||||
prompt_id: weather.daily_report
|
prompt_id: weather.daily_generated_text
|
||||||
generated_at: 2026-05-29T10:00:00Z
|
generated_at: 2026-05-29T10:00:00Z
|
||||||
timezone: America/Chicago
|
timezone: America/Chicago
|
||||||
current_local_date: "2026-05-29"
|
current_local_date: "2026-05-29"
|
||||||
@@ -216,7 +302,7 @@ report:
|
|||||||
end: 2026-05-30T05:00:00Z
|
end: 2026-05-30T05:00:00Z
|
||||||
briefing:
|
briefing:
|
||||||
metadata:
|
metadata:
|
||||||
run_id: 20260529T100000Z_daily_today
|
run_id: 20260529T100000Z_daily
|
||||||
raw_data:
|
raw_data:
|
||||||
alert_digest:
|
alert_digest:
|
||||||
checked: true
|
checked: true
|
||||||
@@ -230,15 +316,32 @@ recent_changes:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadYAMLRejectsOldSchemaVersion(t *testing.T) {
|
||||||
|
pkg, err := Build(validBuildRequest(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Build() error = %v", err)
|
||||||
|
}
|
||||||
|
data, err := MarshalYAML(pkg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarshalYAML() error = %v", err)
|
||||||
|
}
|
||||||
|
data = []byte(strings.Replace(string(data), "weatherreporter.data_package.v3", "weatherreporter.data_package.v2", 1))
|
||||||
|
|
||||||
|
_, err = LoadYAML(data)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "schemaVersion must be weatherreporter.data_package.v3") {
|
||||||
|
t.Fatalf("LoadYAML() error = %v, want current schema version error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func validBuildRequest(t *testing.T) BuildRequest {
|
func validBuildRequest(t *testing.T) BuildRequest {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
generatedAt := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC)
|
generatedAt := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC)
|
||||||
return BuildRequest{
|
return BuildRequest{
|
||||||
Metadata: Metadata{
|
Metadata: Metadata{
|
||||||
RunID: "20260529T100000Z_daily_today",
|
RunID: "20260529T100000Z_daily",
|
||||||
ReportID: report.DailyToday,
|
ReportID: report.Daily,
|
||||||
Variant: "today",
|
Variant: "today",
|
||||||
PromptID: "weather.daily_report",
|
PromptID: "weather.daily_generated_text",
|
||||||
GeneratedAt: generatedAt,
|
GeneratedAt: generatedAt,
|
||||||
Timezone: "America/Chicago",
|
Timezone: "America/Chicago",
|
||||||
ValidPeriod: timeutil.Period{
|
ValidPeriod: timeutil.Period{
|
||||||
@@ -247,7 +350,7 @@ func validBuildRequest(t *testing.T) BuildRequest {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
Modules: snapshotWithOutputs(t,
|
Modules: snapshotWithOutputs(t,
|
||||||
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": "20260529T100000Z_daily_today"}},
|
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": "20260529T100000Z_daily"}},
|
||||||
module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: map[string]string{"condition_text": "Partly cloudy"}},
|
module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: map[string]string{"condition_text": "Partly cloudy"}},
|
||||||
module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]string{"date": "2026-05-29"}},
|
module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]string{"date": "2026-05-29"}},
|
||||||
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: map[string]bool{"checked": true}},
|
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: map[string]bool{"checked": true}},
|
||||||
|
|||||||
@@ -1,43 +1,36 @@
|
|||||||
package report
|
package report
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"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/timeutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func dailyTodayDefinition() Definition {
|
func dailyDefinition() Definition {
|
||||||
return Definition{
|
return Definition{
|
||||||
ID: DailyToday,
|
ID: Daily,
|
||||||
Name: "Daily Report",
|
Name: "Daily Report",
|
||||||
PromptID: "weather.daily_report",
|
PromptID: "weather.daily_generated_text",
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
GenerationMode: GenerationModeGeneratedTextTemplate,
|
||||||
ArtifactGroup: "daily",
|
TemplateID: "daily",
|
||||||
BatchOutputName: "daily.md",
|
GeneratedTextSchemaID: "daily",
|
||||||
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
|
ArtifactGroup: "daily",
|
||||||
|
BatchOutputName: "daily.md",
|
||||||
|
DistributorPathTemplates: []string{
|
||||||
|
"daily/{valid_start_date}/{run_id}.md",
|
||||||
|
"daily/{valid_start_date}/index.md",
|
||||||
|
},
|
||||||
Generated: true,
|
Generated: true,
|
||||||
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
CompatiblePriorIDs: []ID{Daily},
|
||||||
Modules: dailyTodayModules(),
|
Modules: dailyModules(),
|
||||||
Morning: true,
|
resolve: resolveDaily,
|
||||||
resolve: resolveDailyToday,
|
runIDDisambiguator: validStartDateRunIDDisambiguator,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func dailyTomorrowDefinition() Definition {
|
func dailyModules() []module.ConfigItem {
|
||||||
return Definition{
|
|
||||||
ID: DailyTomorrow,
|
|
||||||
Name: "Tomorrow Planning Brief",
|
|
||||||
PromptID: "weather.daily_report",
|
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
|
||||||
ArtifactGroup: "daily",
|
|
||||||
BatchOutputName: "tomorrow.md",
|
|
||||||
Generated: true,
|
|
||||||
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
|
||||||
Modules: dailyTomorrowModules(),
|
|
||||||
Evening: true,
|
|
||||||
resolve: resolveDailyTomorrow,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func dailyTodayModules() []module.ConfigItem {
|
|
||||||
return moduleItems(
|
return moduleItems(
|
||||||
module.Metadata,
|
module.Metadata,
|
||||||
module.CurrentConditions,
|
module.CurrentConditions,
|
||||||
@@ -46,37 +39,30 @@ 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.DailyPlanning,
|
||||||
module.HourlyForecast,
|
module.HourlyForecast,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func dailyTomorrowModules() []module.ConfigItem {
|
func resolveDaily(req ResolveRequest) (timeutil.Period, error) {
|
||||||
return moduleItems(
|
if req.Date.IsZero() {
|
||||||
module.Metadata,
|
return timeutil.Period{}, fmt.Errorf("daily report requires an explicit date")
|
||||||
module.CurrentConditions,
|
|
||||||
module.NarrativeForecast,
|
|
||||||
module.DerivedDailySummary,
|
|
||||||
module.DerivedDaypartSummaries,
|
|
||||||
module.PrecipTiming,
|
|
||||||
module.AlertDigest,
|
|
||||||
module.AreaForecastDiscussion,
|
|
||||||
module.WeatherStory,
|
|
||||||
module.OutdoorWindows,
|
|
||||||
module.TomorrowPlanning,
|
|
||||||
module.HourlyForecast,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveDailyToday(req ResolveRequest) (timeutil.Period, error) {
|
|
||||||
if !req.Date.IsZero() {
|
|
||||||
return timeutil.CivilDay(req.Date, req.Location), nil
|
|
||||||
}
|
}
|
||||||
return timeutil.CivilDay(req.Now, req.Location), nil
|
return timeutil.CivilDay(req.Date, req.Location), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveDailyTomorrow(req ResolveRequest) (timeutil.Period, error) {
|
func validStartDateRunIDDisambiguator(resolved Resolved) string {
|
||||||
return timeutil.CivilDay(req.Now.In(req.Location).AddDate(0, 0, 1), req.Location), nil
|
if resolved.ValidPeriod.Start.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
location, err := timeutil.LoadLocation(resolved.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return resolved.ValidPeriod.Start.Format(timeutil.DateLayout)
|
||||||
|
}
|
||||||
|
return resolved.ValidPeriod.Start.In(location).Format(timeutil.DateLayout)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package report
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
@@ -12,11 +13,13 @@ import (
|
|||||||
type ID string
|
type ID string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DailyToday ID = "daily_today"
|
Daily ID = "daily"
|
||||||
DailyTomorrow ID = "daily_tomorrow"
|
Today ID = "today"
|
||||||
ThreeDay ID = "three_day"
|
Tomorrow ID = "tomorrow"
|
||||||
Weekend ID = "weekend"
|
Hourly ID = "hourly"
|
||||||
Storm ID = "storm"
|
ThreeDay ID = "three_day"
|
||||||
|
Weekend ID = "weekend"
|
||||||
|
Storm ID = "storm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ComparisonStrategy string
|
type ComparisonStrategy string
|
||||||
@@ -25,6 +28,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
|
||||||
@@ -35,18 +46,23 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Definition struct {
|
type Definition struct {
|
||||||
ID ID
|
ID ID
|
||||||
Name string
|
Name string
|
||||||
PromptID string
|
PromptID string
|
||||||
ComparisonStrategy ComparisonStrategy
|
GenerationMode GenerationMode
|
||||||
ArtifactGroup string
|
TemplateID string
|
||||||
BatchOutputName string
|
GeneratedTextSchemaID string
|
||||||
Generated bool
|
ComparisonStrategy ComparisonStrategy
|
||||||
CompatiblePriorIDs []ID
|
ArtifactGroup string
|
||||||
Modules []module.ConfigItem
|
BatchOutputName string
|
||||||
Morning bool
|
DistributorPathTemplates []string
|
||||||
Evening bool
|
Generated bool
|
||||||
resolve func(ResolveRequest) (timeutil.Period, error)
|
CompatiblePriorIDs []ID
|
||||||
|
Modules []module.ConfigItem
|
||||||
|
Morning bool
|
||||||
|
Evening bool
|
||||||
|
resolve func(ResolveRequest) (timeutil.Period, error)
|
||||||
|
runIDDisambiguator func(Resolved) string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d Definition) ResolvePeriod(req ResolveRequest) (timeutil.Period, error) {
|
func (d Definition) ResolvePeriod(req ResolveRequest) (timeutil.Period, error) {
|
||||||
@@ -99,7 +115,7 @@ type Metadata struct {
|
|||||||
|
|
||||||
func (r Resolved) Metadata() Metadata {
|
func (r Resolved) Metadata() Metadata {
|
||||||
return Metadata{
|
return Metadata{
|
||||||
RunID: r.GeneratedAt.UTC().Format("20060102T150405.000000000Z") + "_" + string(r.Definition.ID),
|
RunID: r.runID(),
|
||||||
ReportID: r.Definition.ID,
|
ReportID: r.Definition.ID,
|
||||||
PromptID: r.Definition.PromptID,
|
PromptID: r.Definition.PromptID,
|
||||||
GeneratedAt: r.GeneratedAt,
|
GeneratedAt: r.GeneratedAt,
|
||||||
@@ -107,3 +123,15 @@ func (r Resolved) Metadata() Metadata {
|
|||||||
ValidPeriod: r.ValidPeriod,
|
ValidPeriod: r.ValidPeriod,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r Resolved) runID() string {
|
||||||
|
runID := r.GeneratedAt.UTC().Format("20060102T150405.000000000Z") + "_" + string(r.Definition.ID)
|
||||||
|
if r.Definition.runIDDisambiguator == nil {
|
||||||
|
return runID
|
||||||
|
}
|
||||||
|
disambiguator := strings.TrimSpace(r.Definition.runIDDisambiguator(r))
|
||||||
|
if disambiguator == "" {
|
||||||
|
return runID
|
||||||
|
}
|
||||||
|
return runID + "_" + disambiguator
|
||||||
|
}
|
||||||
|
|||||||
58
internal/report/hourly_report.go
Normal file
58
internal/report/hourly_report.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
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",
|
||||||
|
DistributorPathTemplates: []string{
|
||||||
|
"hourly/index.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
|
||||||
|
}
|
||||||
92
internal/report/names.go
Normal file
92
internal/report/names.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package report
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
CommandNameDaily = "daily"
|
||||||
|
CommandNameToday = "today"
|
||||||
|
CommandNameTomorrow = "tomorrow"
|
||||||
|
CommandNameHourly = "hourly"
|
||||||
|
CommandNameThreeDay = "three-day"
|
||||||
|
CommandNameWeekend = "weekend"
|
||||||
|
CommandNameStorm = "storm"
|
||||||
|
|
||||||
|
BatchNameMorning = "morning"
|
||||||
|
BatchNameEvening = "evening"
|
||||||
|
)
|
||||||
|
|
||||||
|
func IDForCommandName(name string) (ID, error) {
|
||||||
|
switch name {
|
||||||
|
case CommandNameDaily:
|
||||||
|
return Daily, nil
|
||||||
|
case CommandNameToday:
|
||||||
|
return Today, nil
|
||||||
|
case CommandNameTomorrow:
|
||||||
|
return Tomorrow, nil
|
||||||
|
case CommandNameHourly:
|
||||||
|
return Hourly, nil
|
||||||
|
case CommandNameThreeDay:
|
||||||
|
return ThreeDay, nil
|
||||||
|
case CommandNameWeekend:
|
||||||
|
return Weekend, nil
|
||||||
|
case CommandNameStorm:
|
||||||
|
return Storm, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unknown report command %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CommandNames() []string {
|
||||||
|
return []string{
|
||||||
|
CommandNameDaily,
|
||||||
|
CommandNameToday,
|
||||||
|
CommandNameTomorrow,
|
||||||
|
CommandNameHourly,
|
||||||
|
CommandNameThreeDay,
|
||||||
|
CommandNameWeekend,
|
||||||
|
CommandNameStorm,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func IDForConfigKey(key string) (ID, error) {
|
||||||
|
normalized := strings.ReplaceAll(strings.TrimSpace(strings.ToLower(key)), "-", "_")
|
||||||
|
switch normalized {
|
||||||
|
case "daily":
|
||||||
|
return Daily, nil
|
||||||
|
case "today":
|
||||||
|
return Today, nil
|
||||||
|
case "tomorrow":
|
||||||
|
return Tomorrow, nil
|
||||||
|
case "hourly":
|
||||||
|
return Hourly, nil
|
||||||
|
case "three_day", "three_day_outlook":
|
||||||
|
return ThreeDay, nil
|
||||||
|
case "weekend", "weekend_outlook":
|
||||||
|
return Weekend, nil
|
||||||
|
case "storm", "storm_report":
|
||||||
|
return Storm, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("report config key %q is not a known report", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BatchForCommandName(name string) (Batch, error) {
|
||||||
|
switch name {
|
||||||
|
case BatchNameMorning:
|
||||||
|
return Morning, nil
|
||||||
|
case BatchNameEvening:
|
||||||
|
return Evening, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unknown batch command %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BatchCommandNames() []string {
|
||||||
|
return []string{
|
||||||
|
BatchNameMorning,
|
||||||
|
BatchNameEvening,
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user