Compare commits
89 Commits
v0.6.0
...
473f252aee
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| f149563c68 | |||
| 276e4f1189 | |||
| cb42cad6a6 | |||
| ef044327c6 | |||
| d1d0df11a8 | |||
| c3da3af2f4 | |||
| 7b760a0823 | |||
| 1e9c29aa55 | |||
| 1ddd88231a | |||
| d665049f05 | |||
| 1af6169999 | |||
| 468197f7e0 | |||
| 816cfb24aa | |||
| 479d144592 | |||
| 0b516d9762 | |||
| 2483c2362d | |||
| 40639309b1 | |||
| eefb0681dc | |||
| 9501dad1dc | |||
| 24dba3bd60 | |||
| e9508089ab | |||
| 454f47b2b5 | |||
| d8b417458b | |||
| 195a130124 | |||
| 8577fc29e4 | |||
| d71c7e4d28 | |||
| 9bc8156615 | |||
| 183b23cf5a | |||
| 138bc4e7e4 | |||
| e2529cfdf2 | |||
| b982b27f84 | |||
| c573cd5b4d | |||
| c2758d7a91 | |||
| 64cae8c4d9 | |||
| a2ba6f5382 | |||
| 7c8d9191c1 | |||
| 9677835d84 |
@@ -1,9 +1,10 @@
|
|||||||
# weatherreporter
|
# weatherreporter
|
||||||
|
|
||||||
`weatherreporter` is a Go application for preparing human-facing weather
|
`weatherreporter` is a Go application for preparing human-facing weather
|
||||||
reports from normalized forecast data. It builds structured briefing packages,
|
reports from normalized forecast data. It builds JSON module snapshots, passes
|
||||||
runs them through `scriptorium`, and keeps inspectable artifacts under a local
|
YAML prompt data packages to `scriptorium`, and keeps inspectable artifacts
|
||||||
workspace.
|
under a local workspace. It can also upload successfully generated managed
|
||||||
|
Markdown reports to a configured `distributor` HTTP upload endpoint.
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
|
|||||||
44
docs/cli.md
44
docs/cli.md
@@ -1,7 +1,7 @@
|
|||||||
# Weatherreporter CLI
|
# Weatherreporter CLI
|
||||||
|
|
||||||
`weatherreporter` generates Markdown weather reports, runs scheduled report
|
`weatherreporter` generates Markdown weather reports, runs scheduled report
|
||||||
batches, and inspects previously generated artifacts.
|
batches, and inspects stored artifacts.
|
||||||
|
|
||||||
## Shortest Useful Command
|
## Shortest Useful Command
|
||||||
|
|
||||||
@@ -11,7 +11,9 @@ weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
|||||||
|
|
||||||
This loads configuration, fetches weather data, writes managed workspace
|
This loads configuration, fetches weather data, writes managed workspace
|
||||||
artifacts, runs `scriptorium render` as a preflight check, runs
|
artifacts, runs `scriptorium render` as a preflight check, runs
|
||||||
`scriptorium run`, and writes an extra Markdown copy to `./daily.md`.
|
`scriptorium run`, and writes an extra Markdown copy to `./daily.md`. If
|
||||||
|
distributor notification is enabled in configuration, the command also uploads
|
||||||
|
the managed Markdown report after final metadata is saved.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ artifacts, runs `scriptorium render` as a preflight check, runs
|
|||||||
weatherreporter --help
|
weatherreporter --help
|
||||||
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
||||||
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
|
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
|
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
|
||||||
@@ -26,21 +29,35 @@ weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-d
|
|||||||
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
|
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
|
||||||
weatherreporter inspect reports [--config PATH] [--limit N]
|
weatherreporter inspect reports [--config PATH] [--limit N]
|
||||||
weatherreporter inspect metadata [--config PATH] RUN_ID
|
weatherreporter inspect metadata [--config PATH] RUN_ID
|
||||||
weatherreporter inspect briefing [--config PATH] RUN_ID
|
weatherreporter inspect modules [--config PATH] RUN_ID
|
||||||
weatherreporter inspect data-package [--config PATH] RUN_ID
|
weatherreporter inspect data-package [--config PATH] RUN_ID
|
||||||
weatherreporter inspect prior [--config PATH] RUN_ID
|
weatherreporter inspect prior [--config PATH] RUN_ID
|
||||||
weatherreporter inspect sources [--config PATH] RUN_ID
|
weatherreporter inspect sources [--config PATH] RUN_ID
|
||||||
```
|
```
|
||||||
|
|
||||||
`generate` commands write briefing, data package, preflight, report, and
|
Implemented `generate` commands write a JSON module snapshot, YAML data package,
|
||||||
metadata artifacts under the configured workspace. `generate storm` requires
|
preflight artifact, managed Markdown report, and metadata under the configured
|
||||||
explicit event-window bounds with `--start` and `--end`.
|
workspace. `--out` writes an extra Markdown copy for the operator; distributor
|
||||||
|
notification uses the managed report path, not the extra copy. `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 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 Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
||||||
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch
|
except on Sunday. `run evening` generates the Tomorrow Report. Batch
|
||||||
runs continue independent reports after a failure, print a JSON summary to
|
runs continue independent reports after a failure, print a JSON summary to
|
||||||
stdout, write compact status lines to stderr, and return nonzero when any report
|
stdout, write compact status lines to stderr, and return nonzero when any report
|
||||||
failed.
|
failed. `--out-dir` writes extra Markdown copies for the operator; distributor
|
||||||
|
notification uses each managed report path, not the extra copies. When
|
||||||
|
notification is enabled, batch summaries and status lines include notification
|
||||||
|
status, accepted distributor run ID, or notification error fields for each
|
||||||
|
attempted report.
|
||||||
|
|
||||||
|
Hourly Report is explicit only; it is not included in `run morning` or `run
|
||||||
|
evening`.
|
||||||
|
|
||||||
`inspect` commands read existing workspace artifacts and emit JSON to stdout.
|
`inspect` commands read existing workspace artifacts and emit JSON to stdout.
|
||||||
They do not fetch weather data or invoke `scriptorium`.
|
They do not fetch weather data or invoke `scriptorium`.
|
||||||
@@ -51,7 +68,7 @@ They do not fetch weather data or invoke `scriptorium`.
|
|||||||
- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`.
|
- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`.
|
||||||
- `--units VALUE`: override configured Weather API units for `generate` and `run`.
|
- `--units VALUE`: override configured Weather API units for `generate` and `run`.
|
||||||
- `--tz NAME`: override configured Weather API timezone for `generate` and `run`.
|
- `--tz NAME`: override configured Weather API timezone for `generate` and `run`.
|
||||||
- `--out PATH`: write an extra Markdown report copy for `generate` commands.
|
- `--out PATH`: write an extra Markdown report copy where supported by the `generate` command.
|
||||||
- `--out-dir PATH`: write extra Markdown report copies for `run morning` and `run evening`.
|
- `--out-dir PATH`: write extra Markdown report copies for `run morning` and `run evening`.
|
||||||
- `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone.
|
- `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone.
|
||||||
- `--start TIME`: required start time for `generate storm`.
|
- `--start TIME`: required start time for `generate storm`.
|
||||||
@@ -61,10 +78,14 @@ They do not fetch weather data or invoke `scriptorium`.
|
|||||||
Storm times accept `YYYY-MM-DDTHH:MM` in the configured timezone or RFC3339
|
Storm times accept `YYYY-MM-DDTHH:MM` in the configured timezone or RFC3339
|
||||||
timestamps with explicit offsets.
|
timestamps with explicit offsets.
|
||||||
|
|
||||||
|
Distributor notification is configured only through `notify.distributor`; there
|
||||||
|
are no distributor-specific CLI flags.
|
||||||
|
|
||||||
## Common Workflows
|
## Common Workflows
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
weatherreporter generate tomorrow --out ./tomorrow.md
|
weatherreporter generate tomorrow --out ./tomorrow.md
|
||||||
|
weatherreporter generate hourly
|
||||||
weatherreporter generate three-day --out ./three-day.md
|
weatherreporter generate three-day --out ./three-day.md
|
||||||
weatherreporter generate weekend --out ./weekend.md
|
weatherreporter generate weekend --out ./weekend.md
|
||||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
|
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
|
||||||
@@ -77,14 +98,15 @@ 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_daily_today
|
||||||
weatherreporter inspect briefing 20260529T100000.000000000Z_daily_today
|
weatherreporter inspect modules 20260529T100000.000000000Z_daily_today
|
||||||
weatherreporter inspect data-package 20260529T100000.000000000Z_daily_today
|
weatherreporter inspect data-package 20260529T100000.000000000Z_daily_today
|
||||||
weatherreporter inspect prior 20260529T100000.000000000Z_daily_today
|
weatherreporter inspect prior 20260529T100000.000000000Z_daily_today
|
||||||
weatherreporter inspect sources 20260529T100000.000000000Z_daily_today
|
weatherreporter inspect sources 20260529T100000.000000000Z_daily_today
|
||||||
```
|
```
|
||||||
|
|
||||||
`inspect reports` lists recent generated runs with artifact paths and source
|
`inspect reports` lists recent generated runs with artifact paths and source
|
||||||
warning counts. The other inspect commands require a RunID. `inspect prior`
|
warning counts. The other inspect commands require a RunID. `inspect modules`
|
||||||
|
returns the persisted ordered module snapshot for a run. `inspect prior`
|
||||||
returns the prior comparable snapshot metadata selected from stored metadata, or
|
returns the prior comparable snapshot metadata selected from stored metadata, or
|
||||||
`null` when none exists. `inspect sources` shows source provenance and source
|
`null` when none exists. `inspect sources` shows source provenance and source
|
||||||
warnings without dumping full weather payloads.
|
warnings without dumping full weather payloads.
|
||||||
|
|||||||
148
docs/config.md
148
docs/config.md
@@ -16,9 +16,9 @@ Precedence is:
|
|||||||
2. configuration file
|
2. configuration file
|
||||||
3. built-in defaults
|
3. built-in defaults
|
||||||
|
|
||||||
The implemented configuration overrides are `--units` and `--tz`. Output flags
|
The CLI configuration overrides are `--units` and `--tz`. Output flags control
|
||||||
control report copies for the current command but do not change configuration
|
report copies for the current command but do not change configuration files.
|
||||||
files. Environment-variable configuration is not implemented.
|
Environment variables do not override configuration fields.
|
||||||
|
|
||||||
## Minimal Config
|
## Minimal Config
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ Timezone values may be IANA names, configured aliases such as `Chicago` and
|
|||||||
|
|
||||||
### `location`
|
### `location`
|
||||||
|
|
||||||
`location` is descriptive prompt context included in briefing metadata and
|
`location` is descriptive prompt context included in module metadata and
|
||||||
Scriptorium data packages. It does not select a Weather API endpoint or enable
|
Scriptorium data packages. It does not select a Weather API endpoint or enable
|
||||||
multiple configured forecast locations.
|
multiple configured forecast locations.
|
||||||
|
|
||||||
@@ -64,13 +64,75 @@ multiple configured forecast locations.
|
|||||||
The prompt-facing location object also includes `timezone`, derived from the
|
The prompt-facing location object also includes `timezone`, derived from the
|
||||||
effective `weather_api.timezone` after CLI overrides such as `--tz`.
|
effective `weather_api.timezone` after CLI overrides such as `--tz`.
|
||||||
|
|
||||||
|
### `secrets`
|
||||||
|
|
||||||
|
- `directory`: optional directory of file-backed environment secrets. Default:
|
||||||
|
empty, which disables secret loading.
|
||||||
|
|
||||||
|
When configured, each regular file directly under `secrets.directory` is loaded
|
||||||
|
after config file parsing and CLI overrides. The file basename must be a valid
|
||||||
|
environment variable name matching `[A-Za-z_][A-Za-z0-9_]*`; the file contents
|
||||||
|
become the environment variable value and overwrite any existing value. One
|
||||||
|
trailing LF or CRLF is stripped. Subdirectories, symlinks, invalid filenames,
|
||||||
|
missing directories, and unreadable files fail config loading.
|
||||||
|
|
||||||
|
### `notify`
|
||||||
|
|
||||||
|
`notify.distributor` controls distributor notification after successful report
|
||||||
|
generation. It is disabled by default and does not add CLI flags. When enabled,
|
||||||
|
weatherreporter uploads one distributor bundle per generated report after
|
||||||
|
report rendering succeeds and final metadata is saved.
|
||||||
|
|
||||||
|
- `enabled`: whether distributor notification config is active. Default:
|
||||||
|
`false`.
|
||||||
|
- `endpoint`: absolute distributor endpoint URL. Required when enabled.
|
||||||
|
Default: `https://distributor.example.com`.
|
||||||
|
- `token_env`: environment variable name that will contain the distributor
|
||||||
|
upload token. Required when enabled. Default: `DISTRIBUTOR_UPLOAD_TOKEN`.
|
||||||
|
- `timeout`: distributor operation timeout. Must be greater than zero when
|
||||||
|
enabled. Default: `30s`.
|
||||||
|
- `failure_policy`: must be `error` when enabled. Default: `error`.
|
||||||
|
- `pipeline_id_template`: template for the distributor pipeline ID. Required
|
||||||
|
when enabled. Default: empty.
|
||||||
|
- `bundle_id_template`: template for distributor bundle IDs. Default:
|
||||||
|
`weatherreporter.{location_id}.{report_id}`.
|
||||||
|
- `idempotency_key_template`: template for distributor idempotency keys.
|
||||||
|
Default: `{bundle_id}.{run_id}`.
|
||||||
|
- `report_path_templates`: ordered list of templates for Markdown report paths
|
||||||
|
inside the distributor bundle. Each rendered path maps to the same managed
|
||||||
|
Markdown report source. Default:
|
||||||
|
```yaml
|
||||||
|
- "{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md"
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported template variables are `location_id`, `report_id`, `run_id`,
|
||||||
|
`artifact_group`, `batch_output_name`, `valid_start_date`, `valid_end_date`,
|
||||||
|
`valid_start_time`, `valid_end_time`, `valid_start_stamp`, and
|
||||||
|
`valid_end_stamp`. Date values use `YYYY-MM-DD`, time values use `HHMM`, and
|
||||||
|
stamp values use `YYYY-MM-DDTHHMM` in the effective report timezone.
|
||||||
|
`pipeline_id_template` and `idempotency_key_template` may also use `bundle_id`.
|
||||||
|
|
||||||
|
The rendered pipeline ID selects the configured distributor `http_upload`
|
||||||
|
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.
|
||||||
|
|
||||||
|
Rendered report paths must be unique relative paths with `/` separators. They
|
||||||
|
must not contain backslashes, empty path segments, `.`, `..`, `manifest.json`,
|
||||||
|
or `.distributor.json`.
|
||||||
|
|
||||||
|
The upload token is read from the environment variable named by `token_env`
|
||||||
|
after config loading and `secrets.directory` processing. Config files should
|
||||||
|
name the variable only; they should not contain the token value.
|
||||||
|
|
||||||
### `missing_source`
|
### `missing_source`
|
||||||
|
|
||||||
- `default`: missing-source behavior for optional sources. One of `error`, `warn`, or `none`. Default: `warn`.
|
- `default`: missing-source behavior for optional sources. One of `error`, `warn`, or `none`. Default: `warn`.
|
||||||
- `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 and
|
Hourly forecast data is required for generated reports. Optional sources use
|
||||||
stub source slots 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`
|
||||||
|
|
||||||
@@ -83,10 +145,11 @@ stub source slots use the missing-source policy.
|
|||||||
### `workspace`
|
### `workspace`
|
||||||
|
|
||||||
- `root`: workspace root for managed artifacts. Default: `workspace`.
|
- `root`: workspace root for managed artifacts. Default: `workspace`.
|
||||||
- `snapshots_dir`: briefing and metadata directory under `workspace.root`. Default: `snapshots`.
|
- `snapshots_dir`: module snapshot and metadata directory under `workspace.root`. Default: `snapshots`.
|
||||||
- `reports_dir`: managed Markdown report directory under `workspace.root`. Default: `reports`.
|
- `reports_dir`: managed Markdown report directory under `workspace.root`. Default: `reports`.
|
||||||
- `data_packages_dir`: prompt input package directory under `workspace.root`. Default: `data-packages`.
|
- `data_packages_dir`: prompt input package directory under `workspace.root`. Default: `data-packages`.
|
||||||
- `preflight_dir`: Scriptorium render output directory under `workspace.root`. Default: `preflight`.
|
- `preflight_dir`: Scriptorium render output directory under `workspace.root`. Default: `preflight`.
|
||||||
|
- `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`.
|
||||||
@@ -110,19 +173,82 @@ midday, afternoon, and evening.
|
|||||||
- `wind_gust_miles_per_hour`: wind gust change threshold. Default: `10`.
|
- `wind_gust_miles_per_hour`: wind gust change threshold. Default: `10`.
|
||||||
- `precip_timing_shift_minutes`: precipitation timing shift threshold. Default: `120`.
|
- `precip_timing_shift_minutes`: precipitation timing shift threshold. Default: `120`.
|
||||||
|
|
||||||
Recent Changes are added to prompt input when a prior comparable briefing
|
Recent Changes are added to prompt input when a prior comparable module
|
||||||
snapshot exists and a threshold is crossed.
|
snapshot exists and a threshold is crossed.
|
||||||
|
|
||||||
|
### `reports`
|
||||||
|
|
||||||
|
`reports` optionally overrides the ordered deterministic modules declared by
|
||||||
|
report definitions. Omit a report entry to use its default module order.
|
||||||
|
|
||||||
|
Supported report keys are `daily`, `tomorrow`, `hourly`, `three_day`,
|
||||||
|
`weekend`, and `storm`. Canonical report IDs such as `daily_today` are also
|
||||||
|
accepted.
|
||||||
|
|
||||||
|
Each report entry supports:
|
||||||
|
|
||||||
|
- `deterministic_modules`: ordered module list. Entries may be string module
|
||||||
|
IDs or objects with `id` and optional `options`.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
reports:
|
||||||
|
daily:
|
||||||
|
deterministic_modules:
|
||||||
|
- metadata
|
||||||
|
- current_conditions
|
||||||
|
- narrative_forecast
|
||||||
|
- alert_digest
|
||||||
|
- spc_convective_outlooks
|
||||||
|
- id: area_forecast_discussion
|
||||||
|
options:
|
||||||
|
sections:
|
||||||
|
- short_term
|
||||||
|
- spc_convective_discussion
|
||||||
|
- hourly_forecast
|
||||||
|
hourly:
|
||||||
|
deterministic_modules:
|
||||||
|
- metadata
|
||||||
|
- current_conditions
|
||||||
|
- hourly_forecast
|
||||||
|
- precip_timing
|
||||||
|
- alert_digest
|
||||||
|
- spc_convective_outlooks
|
||||||
|
- id: area_forecast_discussion
|
||||||
|
options:
|
||||||
|
sections:
|
||||||
|
- key_messages
|
||||||
|
- short_term
|
||||||
|
- spc_convective_discussion
|
||||||
|
- weather_story
|
||||||
|
```
|
||||||
|
|
||||||
|
Unknown reports, unknown modules, duplicate modules, incompatible report/module
|
||||||
|
combinations, duplicate stanza names, and invalid options fail config loading.
|
||||||
|
`area_forecast_discussion.options.sections` may contain `product`,
|
||||||
|
`key_messages`, `short_term`, and `long_term`. Empty or omitted `sections`
|
||||||
|
includes all available AFD sections.
|
||||||
|
|
||||||
|
The module registry accepts all module IDs documented in
|
||||||
|
[Module Contract Internals](internal/module.md). Unknown or unimplemented
|
||||||
|
module IDs fail validation instead of being skipped.
|
||||||
|
|
||||||
## Secrets
|
## Secrets
|
||||||
|
|
||||||
Configuration files should not contain secrets. The current Weather API and
|
Configuration files should not contain raw secrets. Use `secrets.directory` to
|
||||||
Scriptorium integration settings do not require secret fields.
|
load secret values from files into environment variables for integrations that
|
||||||
|
read credentials from the environment. Secret file names become environment
|
||||||
|
variable names, and secret file contents become values. For distributor
|
||||||
|
notification, this allows a file such as
|
||||||
|
`<secrets.directory>/DISTRIBUTOR_UPLOAD_TOKEN` to supply the token referenced by
|
||||||
|
`notify.distributor.token_env`.
|
||||||
|
|
||||||
## Maintained Examples
|
## Maintained Examples
|
||||||
|
|
||||||
- [examples/minimal-config.yml](../examples/minimal-config.yml): smallest
|
- [examples/minimal-config.yml](../examples/minimal-config.yml): smallest
|
||||||
useful config for generation and fetching.
|
useful config for generation and fetching.
|
||||||
- [examples/config.yml](../examples/config.yml): production-oriented config
|
- [examples/config.yml](../examples/config.yml): production-oriented config
|
||||||
covering implemented fields.
|
covering maintained fields.
|
||||||
|
|
||||||
Both example files are loaded by the config test suite.
|
Both example files are loaded by the config test suite.
|
||||||
|
|||||||
136
docs/integrations/distributor/api.md
Normal file
136
docs/integrations/distributor/api.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# Upstream Producer Integration
|
||||||
|
|
||||||
|
Audience: developers and LLM coding agents adding `distributor` support to an upstream Go producer application.
|
||||||
|
|
||||||
|
This document is the copyable implementation guide for submitting producer outputs to a `distributor` pipeline whose source backend is `http_upload`.
|
||||||
|
|
||||||
|
## Required Inputs
|
||||||
|
|
||||||
|
The upstream application needs these values from deployment or operator configuration:
|
||||||
|
|
||||||
|
- distributor endpoint: the HTTP server base URL, such as `https://distributor.example.com`;
|
||||||
|
- upload token: bearer token that authenticates the producer;
|
||||||
|
- pipeline id: configured `http_upload` pipeline that should process this upload;
|
||||||
|
- generated files: regular local files to include in the source bundle;
|
||||||
|
- bundle id: stable identifier for the logical report stream or artifact;
|
||||||
|
- idempotency key: unique key for one producer run, reused only when retrying that same run.
|
||||||
|
|
||||||
|
Do not put destination routing, public URLs, transform settings, or credentials in the source manifest. Those belong in the `distributor` pipeline configuration.
|
||||||
|
|
||||||
|
The token, pipeline id, bundle id, and idempotency key have different jobs. The token authenticates the producer. The pipeline id selects the configured distributor workflow, including destinations and publishing policy. The bundle id tells `distributor` whether a new upload is a newer version of the same source; keep it stable across runs that should replace the same managed destination artifact. The idempotency key tells `distributor` whether an upload request is a retry; change it for each distinct producer run so new content is enqueued.
|
||||||
|
|
||||||
|
## Recommended Workflow
|
||||||
|
|
||||||
|
Use `gitea.maximumdirect.net/eric/distributor/pkg/upload`.
|
||||||
|
|
||||||
|
For most producers, use `UploadFiles`. It accepts producer-generated files, builds a temporary valid source bundle with `pkg/bundle`, uploads a gzip-compressed tar archive, and removes temporary files when the call returns.
|
||||||
|
|
||||||
|
Use `UploadBundle` only when the producer already assembled a complete bundle directory containing `manifest.json`.
|
||||||
|
|
||||||
|
Add the dependency from the upstream application:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/distributor
|
||||||
|
```
|
||||||
|
|
||||||
|
## Minimal Go Example
|
||||||
|
|
||||||
|
```go
|
||||||
|
package reports
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/distributor/pkg/bundle"
|
||||||
|
"gitea.maximumdirect.net/eric/distributor/pkg/upload"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SubmitReport(reportPath, summaryPath string) error {
|
||||||
|
endpoint := os.Getenv("DISTRIBUTOR_UPLOAD_ENDPOINT")
|
||||||
|
token := os.Getenv("DISTRIBUTOR_UPLOAD_TOKEN")
|
||||||
|
if endpoint == "" || token == "" {
|
||||||
|
return fmt.Errorf("distributor endpoint and token are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
pipelineID := "weather-hourly"
|
||||||
|
reportID := "weather.hourly.brentwood"
|
||||||
|
runID := time.Now().UTC().Format("20060102T150405.000000000Z")
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
client, err := upload.NewClient(upload.ClientOptions{
|
||||||
|
Endpoint: endpoint,
|
||||||
|
Token: token,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.UploadFiles(ctx, upload.UploadFilesOptions{
|
||||||
|
PipelineID: pipelineID,
|
||||||
|
ID: reportID,
|
||||||
|
IdempotencyKey: reportID + "." + runID,
|
||||||
|
Files: []bundle.BundleFile{
|
||||||
|
{SourcePath: reportPath, Path: "report.md"},
|
||||||
|
{SourcePath: summaryPath, Path: "summary.txt"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
var conflict *upload.IdempotencyConflictError
|
||||||
|
if errors.As(err, &conflict) {
|
||||||
|
return fmt.Errorf("idempotency key was reused for different bundle content: %w", err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("distributor accepted run %s\n", result.RunID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Producer Responsibilities
|
||||||
|
|
||||||
|
- Use a stable bundle id for the logical producer output that should replace the same destination artifact, such as `weather.hourly.brentwood`.
|
||||||
|
- Set `PipelineID` to the configured upload pipeline that should process the bundle.
|
||||||
|
- Do not include per-run timestamps, random values, or job ids in the bundle id unless each run should be treated as a different source.
|
||||||
|
- Use an idempotency key that changes for every distinct producer run, such as `<bundle-id>.<run-id>`.
|
||||||
|
- Reuse the same idempotency key only when retrying the exact same producer run with the same source manifest.
|
||||||
|
- Map each generated file to a clean slash-separated bundle path, such as `report.md` or `assets/chart.png`.
|
||||||
|
- Include only regular files. Symlinks, directories as files, devices, FIFOs, and sockets are rejected.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
## Idempotency And Status
|
||||||
|
|
||||||
|
`pkg/upload` sends `Idempotency-Key` on every upload. If the caller omits one, the package generates a random key for that call and reuses it for in-process retries. That is enough for transient network retry within one process, but it does not give cross-process retry identity.
|
||||||
|
|
||||||
|
For producer jobs that may retry after process restart, supply a key derived from the producer run, such as `<bundle-id>.<run-id>`. Reusing the same key with the same token, pipeline id, and normalized source manifest returns the original accepted run. Reusing the same key with different source content in that scope returns a conflict. Reusing one key across multiple distinct report generations prevents those generations from being treated as new uploads.
|
||||||
|
|
||||||
|
`Status` polls `/runs/<run-id>` while the distributor server retains the in-memory status record. Status values are `accepted`, `queued`, `running`, `succeeded`, and `failed`. Completed records expire according to the server's `server.http.retention` setting, and server restart clears status and idempotency records.
|
||||||
|
|
||||||
|
Optional status check:
|
||||||
|
|
||||||
|
```go
|
||||||
|
status, err := client.Status(ctx, result.RunID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status.Status == "failed" {
|
||||||
|
return fmt.Errorf("distributor run failed: %s", status.Error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
In the `distributor` source tree:
|
||||||
|
|
||||||
|
- `docs/consumers/pkg-upload.md`: Go upload package workflow.
|
||||||
|
- `docs/consumers/pkg-bundle.md`: Go bundle package workflow.
|
||||||
|
- `docs/integrations/http-upload.md`: canonical HTTP upload wire contract.
|
||||||
|
- `docs/integrations/source-bundle.md`: canonical source bundle file-format contract.
|
||||||
90
docs/integrations/distributor/pkg-bundle.md
Normal file
90
docs/integrations/distributor/pkg-bundle.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# `pkg/bundle`
|
||||||
|
|
||||||
|
Audience: upstream Go producer developers and LLM coding agents using `distributor` source bundle helpers.
|
||||||
|
|
||||||
|
Import path:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
|
||||||
|
```
|
||||||
|
|
||||||
|
`pkg/bundle` builds, writes, parses, and validates local source bundles. Use it directly when a producer writes bundles for `distributor` to discover, or when a producer wants to assemble and validate a bundle before using another transport.
|
||||||
|
|
||||||
|
The canonical source bundle file-format contract is [Source Bundle Contract](../integrations/source-bundle.md).
|
||||||
|
|
||||||
|
## Preferred Complete-Bundle Workflow
|
||||||
|
|
||||||
|
Use `WriteBundle` when producer-generated files live outside the final bundle root.
|
||||||
|
|
||||||
|
```go
|
||||||
|
manifest, err := bundle.WriteBundle(bundle.WriteBundleOptions{
|
||||||
|
Root: "/var/spool/distributor/weather/hourly-2026-06-07T15",
|
||||||
|
ID: "weather.hourly.brentwood",
|
||||||
|
Files: []bundle.BundleFile{
|
||||||
|
{SourcePath: "/tmp/weather/report.md", Path: "report.md"},
|
||||||
|
{SourcePath: "/tmp/weather/summary.txt", Path: "summary.txt"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = manifest
|
||||||
|
```
|
||||||
|
|
||||||
|
`WriteBundle` copies each source file into a staged bundle root, writes `manifest.json`, validates the staged bundle, and promotes it into place. Set `Overwrite: true` only when the producer intentionally replaces an existing bundle root.
|
||||||
|
|
||||||
|
## Existing Bundle Root Workflow
|
||||||
|
|
||||||
|
Use `BuildManifest` and `WriteManifest` when files are already staged under the final bundle root.
|
||||||
|
|
||||||
|
```go
|
||||||
|
root := "/var/spool/distributor/weather/hourly-2026-06-07T15"
|
||||||
|
manifest, err := bundle.BuildManifest(bundle.BuildOptions{
|
||||||
|
Root: root,
|
||||||
|
ID: "weather.hourly.brentwood",
|
||||||
|
Files: []string{"report.md", "summary.txt"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := bundle.WriteManifest(root, manifest, bundle.WriteManifestOptions{}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := bundle.ValidateBundle(root, manifest); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `Scan: true` instead of `Files` only when every valid regular file under the root should be included. Scan mode includes dotfiles, skips reserved metadata files, rejects symlinks, and sorts paths lexically.
|
||||||
|
|
||||||
|
## Paths And Ordering
|
||||||
|
|
||||||
|
Bundle paths are slash-separated paths relative to the bundle root.
|
||||||
|
|
||||||
|
Invalid paths include:
|
||||||
|
|
||||||
|
- empty paths;
|
||||||
|
- absolute paths;
|
||||||
|
- paths containing backslashes;
|
||||||
|
- `.` or `..` path segments;
|
||||||
|
- empty path segments;
|
||||||
|
- any basename of `manifest.json` or `.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.
|
||||||
|
|
||||||
|
The manifest `ID` is the logical source identity used by `distributor` destination comparison. Keep it stable for runs that should replace the same managed destination artifact. If every run uses a different manifest `ID`, `distributor` treats those runs as different sources and may report a destination conflict instead of replacing older output.
|
||||||
|
|
||||||
|
## Validation And Digest Helpers
|
||||||
|
|
||||||
|
Use `ValidateBundle` before handing an existing local bundle to another process. It verifies manifest semantics, file existence, regular-file type, file size, per-file SHA-256 digests, and bundle digest.
|
||||||
|
|
||||||
|
Useful helpers:
|
||||||
|
|
||||||
|
- `LoadManifest`: read `manifest.json` from a bundle root.
|
||||||
|
- `ParseManifest` and `MarshalManifest`: parse or write manifest bytes.
|
||||||
|
- `ValidateManifest`: validate manifest-only semantics.
|
||||||
|
- `FileDigest`, `BundleDigest`, and `ValidateDigest`: digest helpers for diagnostics and tests.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
`pkg/bundle` does not upload bundles, publish destinations, transform Markdown, select pipelines, configure credentials, or write destination state. Those concerns belong to `pkg/upload` or the `distributor` application.
|
||||||
122
docs/integrations/distributor/pkg-upload.md
Normal file
122
docs/integrations/distributor/pkg-upload.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# `pkg/upload`
|
||||||
|
|
||||||
|
Audience: upstream Go producer developers and LLM coding agents submitting bundles to `distributor serve`.
|
||||||
|
|
||||||
|
Import path:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "gitea.maximumdirect.net/eric/distributor/pkg/upload"
|
||||||
|
```
|
||||||
|
|
||||||
|
`pkg/upload` is the producer-facing HTTP upload client. It builds on `pkg/bundle`, packages valid source bundles as gzip-compressed tar archives, sends bearer authentication, routes uploads to a configured pipeline, includes idempotency keys, and exposes a status polling helper.
|
||||||
|
|
||||||
|
`UploadFiles` examples also use:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
|
||||||
|
```
|
||||||
|
|
||||||
|
The canonical HTTP wire contract is [HTTP Upload API Contract](../integrations/http-upload.md).
|
||||||
|
|
||||||
|
## Client Construction
|
||||||
|
|
||||||
|
```go
|
||||||
|
client, err := upload.NewClient(upload.ClientOptions{
|
||||||
|
Endpoint: "https://distributor.example.com",
|
||||||
|
Token: token,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Endpoint` is the distributor server base URL. The client derives `/v1/pipelines/<pipeline-id>/upload` and `/runs/<run-id>`. `Token` is required and is sent as `Authorization: Bearer <token>`. Token values are redacted from client errors.
|
||||||
|
|
||||||
|
`HTTPClient` and `Retry` are optional. Defaults use a 30 second HTTP timeout and safe retry settings.
|
||||||
|
|
||||||
|
## Upload Producer Files
|
||||||
|
|
||||||
|
Use `UploadFiles` when the producer has generated output files but has not assembled a bundle directory.
|
||||||
|
|
||||||
|
```go
|
||||||
|
result, err := client.UploadFiles(ctx, upload.UploadFilesOptions{
|
||||||
|
PipelineID: "weather-hourly",
|
||||||
|
ID: "weather.hourly.brentwood",
|
||||||
|
IdempotencyKey: "weather.hourly.brentwood.20260607T150000Z",
|
||||||
|
Files: []bundle.BundleFile{
|
||||||
|
{SourcePath: "/tmp/weather/report.md", Path: "report.md"},
|
||||||
|
{SourcePath: "/tmp/weather/summary.txt", Path: "summary.txt"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = result.RunID
|
||||||
|
```
|
||||||
|
|
||||||
|
`PipelineID` is required and selects the configured distributor workflow for this upload. `ID` is the source manifest id and identifies the logical artifact inside that workflow. `UploadFiles` creates a temporary bundle, writes and validates a manifest, uploads the archive, and removes temporary files when the call returns. It does not write into producer source directories.
|
||||||
|
|
||||||
|
## Upload An Existing Bundle
|
||||||
|
|
||||||
|
Use `UploadBundle` when the producer already has a complete local bundle root containing `manifest.json`.
|
||||||
|
|
||||||
|
```go
|
||||||
|
result, err := client.UploadBundle(ctx, upload.UploadBundleOptions{
|
||||||
|
PipelineID: "weather-hourly",
|
||||||
|
Root: "/var/spool/weather/hourly-2026-06-07T15",
|
||||||
|
IdempotencyKey: "weather.hourly.brentwood.20260607T150000Z",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = result.RunID
|
||||||
|
```
|
||||||
|
|
||||||
|
`PipelineID` is required for existing bundles too. `UploadBundle` validates the local bundle by default and uploads only `manifest.json` plus manifest-listed files. Unlisted files are not uploaded.
|
||||||
|
|
||||||
|
## Result And Status
|
||||||
|
|
||||||
|
Upload success means the server returned `202 Accepted` after staging and validating the upload. It does not mean all configured destinations have published.
|
||||||
|
|
||||||
|
Poll status while the server retains the in-memory run record:
|
||||||
|
|
||||||
|
```go
|
||||||
|
status, err := client.Status(ctx, result.RunID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status.Status == "failed" {
|
||||||
|
return fmt.Errorf("distributor run failed: %s", status.Error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Status values are `accepted`, `queued`, `running`, `succeeded`, and `failed`. Completed records expire according to `server.http.retention`; server restart clears run status and idempotency records.
|
||||||
|
|
||||||
|
## Idempotency And Retry
|
||||||
|
|
||||||
|
Every upload request includes `Idempotency-Key`.
|
||||||
|
|
||||||
|
If `IdempotencyKey` is omitted, the client generates a random 128-bit lowercase hexadecimal key for that upload operation and reuses it for retries within the same call. For cross-process retry safety, producers should pass a key derived from the producer run, such as `<bundle-id>.<run-id>`.
|
||||||
|
|
||||||
|
Do not reuse the same idempotency key for multiple distinct report generations. Reuse it only when retrying the exact same run with the same token, pipeline id, and source manifest. A repeated key with the same manifest in that scope returns the original accepted run instead of enqueueing another run; a repeated key with different content returns an idempotency conflict.
|
||||||
|
|
||||||
|
The client retries only safe cases:
|
||||||
|
|
||||||
|
- `503 Service Unavailable`;
|
||||||
|
- temporary network errors;
|
||||||
|
- ambiguous mid-upload failures.
|
||||||
|
|
||||||
|
It does not retry after `202 Accepted` and does not retry `400`, `401`, `403`, `404`, `409`, `413`, or `415`.
|
||||||
|
|
||||||
|
Detect conflicting key reuse with `errors.As`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
var conflict *upload.IdempotencyConflictError
|
||||||
|
if errors.As(err, &conflict) {
|
||||||
|
return fmt.Errorf("idempotency key was reused for different bundle content: %w", err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
`pkg/upload` does not configure server pipelines, choose destinations, wait for publication completion automatically, persist client queues, provide durable idempotency across server restarts, or expose destination state. It submits complete source bundles to the configured HTTP upload API.
|
||||||
@@ -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,9 +29,24 @@ 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 JSON created by
|
`--input data_package=<path>`. The data package is structured YAML created by
|
||||||
`internal/promptinput`.
|
`internal/promptinput`; module snapshots remain separate JSON artifacts for
|
||||||
|
inspection and Recent Changes.
|
||||||
|
|
||||||
|
For generated-text reports, Scriptorium selects the structured output schema
|
||||||
|
from the prompt configuration associated with the prompt ID. `weatherreporter`
|
||||||
|
does not pass `--format`, schema path, or JSON Schema flags for structured
|
||||||
|
generation.
|
||||||
|
|
||||||
## Configured Arguments
|
## Configured Arguments
|
||||||
|
|
||||||
@@ -67,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
|
||||||
|
|
||||||
@@ -79,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
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ This document describes the external Weather API contract used by
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`weatherreporter` uses a configured Weather API base URL to fetch normalized
|
`weatherreporter` uses a configured Weather API base URL to fetch normalized
|
||||||
weather source data and assemble a `forecast.Bundle`. This is an integration
|
weather source data and assemble a `weatherdata.Bundle`. This is an integration
|
||||||
contract for the project adapter, not a complete public API reference for the
|
contract for the project adapter, not a complete public API reference for the
|
||||||
upstream service.
|
upstream service.
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -45,16 +49,70 @@ they fail the fetch; optional malformed sources follow the missing-source policy
|
|||||||
|
|
||||||
The adapter sends these query parameters:
|
The adapter sends these query parameters:
|
||||||
|
|
||||||
- `format`: from `weather_api.format`; the implemented configuration requires
|
- `format`: from `weather_api.format`; configuration validation requires `json`
|
||||||
`json`
|
|
||||||
- `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
|
||||||
|
|
||||||
@@ -67,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
|
||||||
@@ -89,10 +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`
|
||||||
The adapter also creates a missing stub source record for `daily` because that
|
|
||||||
source slot exists in the internal bundle but is not fetched from the Weather
|
|
||||||
API.
|
|
||||||
|
|
||||||
Policy behavior:
|
Policy behavior:
|
||||||
|
|
||||||
@@ -104,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`
|
||||||
@@ -121,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
|
||||||
@@ -131,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
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
# App Orchestration Internals
|
# App Orchestration Internals
|
||||||
|
|
||||||
This document describes the implemented workflow coordinator in `internal/app`.
|
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, fetches weather data,
|
||||||
builds briefing and prompt-input artifacts, invokes Scriptorium through the
|
builds collected and derived facts, builds module snapshots and prompt-input
|
||||||
adapter boundary, persists managed state, runs batches, and reads existing
|
artifacts, invokes Scriptorium through the adapter boundary, optionally
|
||||||
artifacts for inspection.
|
notifies distributor through an app-owned notifier boundary, persists managed
|
||||||
|
state, runs batches, and reads existing artifacts for inspection.
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
@@ -17,76 +18,131 @@ 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 fetch and save workflows
|
||||||
- `BriefingRequest` and `ReportRequest` for package-level orchestration tests
|
- `ReportRequest` for single-report generation
|
||||||
and internal composition
|
|
||||||
- resolved report definitions from `internal/report`
|
- resolved report definitions from `internal/report`
|
||||||
- forecast bundles from `internal/adapters/weatherapi`
|
- weather data bundles from `internal/adapters/weatherapi`
|
||||||
- prior snapshots loaded from `internal/state`
|
- prior snapshots loaded from `internal/state`
|
||||||
- optional renderer and state-store fakes for tests
|
- optional renderer, notifier, and state-store fakes for tests
|
||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
|
|
||||||
- generated report results with briefing, data package, preflight, report,
|
- generated report results with JSON module snapshot, YAML data package,
|
||||||
metadata, prior snapshot, Recent Changes, and Scriptorium result details
|
preflight, report, metadata, prior snapshot, Recent Changes, Scriptorium
|
||||||
- batch summaries with per-report status, artifact paths, and error text
|
result details, generated-text artifact paths when applicable, and
|
||||||
|
notification result when attempted
|
||||||
|
- batch summaries with per-report status, artifact paths, error text, and
|
||||||
|
notification outcome when attempted
|
||||||
- saved Weather API bundle JSON for fetch workflows
|
- saved Weather API bundle JSON for fetch workflows
|
||||||
- inspection JSON values for reports, metadata, briefings, data packages, prior
|
- inspection JSON values for reports, metadata, module snapshots, data
|
||||||
snapshots, and source provenance
|
packages, prior snapshots, and source provenance
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
`internal/app` owns workflow order and request composition. It does not parse
|
`internal/app` owns workflow order and request composition. It does not parse
|
||||||
CLI flags, load YAML files directly, implement HTTP transport, derive forecast
|
CLI flags, load YAML files directly, implement HTTP transport, own fact
|
||||||
facts, define report periods, compare rendered Markdown, or construct
|
derivation algorithms, define report periods, compare rendered Markdown, or
|
||||||
Scriptorium argv.
|
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`.
|
||||||
Weather API transport stays in `internal/adapters/weatherapi`. Scriptorium
|
Weather API transport stays in `internal/adapters/weatherapi`. Scriptorium
|
||||||
subprocess behavior stays in `internal/adapters/scriptorium`. Filesystem layout
|
subprocess behavior stays in `internal/adapters/scriptorium`. Distributor
|
||||||
and persisted metadata stay in `internal/state`.
|
upload behavior stays in `internal/adapters/distributor`. Filesystem layout and
|
||||||
|
persisted metadata stay in `internal/state`.
|
||||||
|
|
||||||
|
## Data Flow Terms
|
||||||
|
|
||||||
|
- `CollectedFacts` are normalized source facts fetched once from Weather API
|
||||||
|
and made available to derivation and module builders.
|
||||||
|
- `DerivedFacts` are deterministic calculations over collected facts, the
|
||||||
|
resolved valid period, daypart configuration, and report-specific windows.
|
||||||
|
- `module.Output` values are ordered deterministic stanzas built from collected
|
||||||
|
and derived facts for prompt input and inspection.
|
||||||
|
- `GeneratedText` is structured prose returned by Scriptorium for
|
||||||
|
generated-text-template reports and validated by `internal/generatedtext`.
|
||||||
|
- `RenderContext` is the typed template input built from report metadata,
|
||||||
|
module outputs, and validated generated text before Markdown rendering.
|
||||||
|
|
||||||
## Config Fields Used
|
## Config Fields Used
|
||||||
|
|
||||||
- `weather_api.*` for Weather API client construction and briefing metadata
|
- `weather_api.*` for Weather API client construction and module metadata
|
||||||
- `scriptorium.*` for renderer construction
|
- `scriptorium.*` for renderer construction
|
||||||
- `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
|
||||||
|
|
||||||
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 generation shares this setup:
|
||||||
|
|
||||||
1. Resolve the command report to a `report.Resolved` value.
|
1. Resolve the command report to a `report.Resolved` value.
|
||||||
2. Create or use a filesystem store.
|
2. Create or use a filesystem store.
|
||||||
3. Locate any prior compatible snapshot through `internal/state`.
|
3. Locate any prior compatible snapshot through `internal/state`.
|
||||||
4. Fetch a Weather API bundle.
|
4. Fetch a Weather API bundle.
|
||||||
5. Build a report-specific briefing package.
|
5. Build collected and derived facts once.
|
||||||
6. Save the briefing snapshot.
|
6. Execute configured modules and save the module snapshot.
|
||||||
7. Compute Recent Changes from structured prior and current briefings.
|
7. Compute Recent Changes from structured prior and current module snapshots.
|
||||||
8. Build and save the Scriptorium `data_package`.
|
8. Build and save the YAML Scriptorium `data_package`.
|
||||||
9. Run Scriptorium render preflight.
|
9. Run Scriptorium render preflight.
|
||||||
10. Save preflight JSON when a render result is available.
|
10. Save preflight JSON when a render result is available.
|
||||||
11. Save metadata for inspection.
|
11. Save metadata for inspection.
|
||||||
12. Run Scriptorium report generation to the managed report path.
|
|
||||||
13. Copy the managed report to the requested `--out` path when provided.
|
For `scriptorium_markdown` reports, generation then:
|
||||||
14. Save metadata with the managed report path.
|
|
||||||
|
12. Runs Scriptorium report generation to the managed report path.
|
||||||
|
13. Copies the managed report to the requested `--out` path when provided.
|
||||||
|
14. Saves metadata with the managed report path.
|
||||||
|
15. If distributor notification is enabled, notifies using the managed report
|
||||||
|
path as the source file.
|
||||||
|
16. Saves a distributor notification debug artifact and updates metadata with
|
||||||
|
its path.
|
||||||
|
|
||||||
|
For `generated_text_template` reports, generation then:
|
||||||
|
|
||||||
|
12. Runs structured Scriptorium generation to the raw generated-text JSON path.
|
||||||
|
13. Saves the structured Scriptorium run result.
|
||||||
|
14. Validates and saves normalized generated text.
|
||||||
|
15. Builds and saves a typed render context.
|
||||||
|
16. Renders Markdown from the embedded template to the managed report path.
|
||||||
|
17. Saves final metadata with generated-text paths, render context path, schema
|
||||||
|
ID, and managed report path.
|
||||||
|
18. Copies the managed report to the requested `--out` path when provided.
|
||||||
|
19. If distributor notification is enabled, notifies using the managed report
|
||||||
|
path as the source file.
|
||||||
|
20. Saves a distributor notification debug artifact and updates metadata with
|
||||||
|
its path.
|
||||||
|
|
||||||
If render preflight returns both a result and an error, preflight JSON and
|
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.
|
metadata remain inspectable. Notification is not attempted after Weather API,
|
||||||
|
module snapshot, prompt input, render, Scriptorium run, or metadata-save
|
||||||
|
failures.
|
||||||
|
Generated-text report failures are returned with report ID, RunID, and the
|
||||||
|
failed operation. When available, the app preserves the latest generated-text
|
||||||
|
artifacts already reached by the workflow: preflight output, structured run
|
||||||
|
result, raw generated text, validated generated text, and render context.
|
||||||
|
When notification is attempted, the debug artifact records request identity,
|
||||||
|
including rendered pipeline ID, bundle paths, accepted upload fields,
|
||||||
|
distributor status fields, raw status report JSON when available, and redacted
|
||||||
|
failure context.
|
||||||
|
`--out` copies are never used as notification source files.
|
||||||
|
|
||||||
## Batch Workflow
|
## Batch Workflow
|
||||||
|
|
||||||
`run morning` resolves Daily Today, 3-Day Outlook, and Weekend Outlook except
|
`run morning` resolves Daily Today, 3-Day Outlook, and Weekend Outlook except
|
||||||
on Sunday. `run evening` resolves Daily Tomorrow. Batch output copy names come
|
on Sunday. `run evening` resolves Tomorrow Report. Batch output copy names come
|
||||||
from report definitions. Batch generation continues independent reports after a
|
from report definitions. 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
|
||||||
|
notification fields in the batch result, and does not stop later reports.
|
||||||
|
`--out-dir` copies are never used as notification source files.
|
||||||
|
|
||||||
## Inspection Workflow
|
## Inspection Workflow
|
||||||
|
|
||||||
@@ -98,9 +154,14 @@ inspection view.
|
|||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
- Resolve errors stop the requested workflow before fetching weather data.
|
- Resolve errors stop the requested workflow before fetching weather data.
|
||||||
- Weather API and briefing errors stop that report before Scriptorium runs.
|
- Weather API and module execution errors stop that report before Scriptorium
|
||||||
|
runs.
|
||||||
- Prompt input validation fails before render preflight.
|
- Prompt input validation fails before render preflight.
|
||||||
- Render and run errors preserve Scriptorium stderr and exit-code context.
|
- Render and run errors preserve Scriptorium stderr and exit-code context.
|
||||||
|
- Generated-text report errors preserve available intermediate artifacts and do
|
||||||
|
not create extra output copies.
|
||||||
|
- Notification errors are wrapped with report ID, RunID, and managed report path
|
||||||
|
context and are recorded separately in batch results.
|
||||||
- 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.
|
||||||
@@ -119,5 +180,9 @@ Inspect:
|
|||||||
- Generated reports use the same app request and result types regardless of
|
- Generated reports use the same app request and result types regardless of
|
||||||
report ID.
|
report ID.
|
||||||
- Render preflight precedes Scriptorium report generation.
|
- Render preflight precedes Scriptorium report generation.
|
||||||
- Recent Changes are computed from structured briefing snapshots.
|
- Generated-text reports render Markdown from a curated render context, not from
|
||||||
|
a raw data package.
|
||||||
|
- Recent Changes are computed from structured module snapshots.
|
||||||
- Metadata links artifacts produced for a run.
|
- Metadata links artifacts produced for a run.
|
||||||
|
- Distributor notification maps the managed Markdown report path to configured
|
||||||
|
bundle paths; extra output copies are not upload sources.
|
||||||
|
|||||||
@@ -1,47 +1,94 @@
|
|||||||
# Briefing Internals
|
# Module Builder Internals
|
||||||
|
|
||||||
This document describes the implemented briefing package boundary.
|
This document describes module builder behavior in `internal/briefing`.
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/briefing` builds structured report-specific briefing packages from
|
`internal/briefing` turns report metadata, collected weather data, and derived
|
||||||
resolved report metadata, forecast bundles, and derived forecast summaries.
|
forecast facts into prompt-facing module outputs. The package also owns the
|
||||||
Briefings are curated inputs for prompt data packages, not rendered report
|
module registry used to validate report composition and config overrides.
|
||||||
prose.
|
|
||||||
|
Module outputs are structured prompt inputs. They are not rendered report prose
|
||||||
|
and they are not persisted by this package.
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
Inputs:
|
Inputs:
|
||||||
|
|
||||||
- resolved report definition, generation time, timezone, and valid period
|
- resolved report definition, generation time, timezone, and valid period
|
||||||
- forecast bundle with source provenance and warnings
|
- collected facts built from `weatherdata.Bundle`
|
||||||
- derived daily or period summaries where required
|
- derived daily, daypart, precipitation, alert, and storm-window facts where
|
||||||
|
required
|
||||||
- configured units, timezone, and descriptive location context
|
- configured units, timezone, and descriptive location context
|
||||||
|
- typed module options from report defaults or config overrides
|
||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
|
|
||||||
- `briefing.Package` with common metadata and one report-specific content
|
- `ModuleDefinition` values with module ID, stanza name, option type,
|
||||||
object for Daily, 3-Day, Weekend, or Storm Report
|
supported reports, fact requirements, missing-data behavior, and builder
|
||||||
- optional `currentConditions` prompt context from normalized
|
- `module.Output` values for source-oriented stanzas:
|
||||||
`/conditions/current` data when available
|
`metadata`, `current_conditions`, `narrative_forecast`, `hourly_forecast`,
|
||||||
- optional structured `weatherStory` context on report-specific briefing
|
`alert_digest`, `spc_convective_outlooks`,
|
||||||
objects when `/weatherstories/latest` is available
|
`area_forecast_discussion`, `spc_convective_discussion`, and
|
||||||
- optional JSON file written by `briefing.Save`
|
`weather_story`
|
||||||
|
- `module.Output` values for derived stanzas:
|
||||||
|
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
|
||||||
|
`outdoor_windows`, and `tomorrow_planning`
|
||||||
|
|
||||||
|
Every registered composition entry has a builder. Unknown or unimplemented
|
||||||
|
module IDs fail validation instead of being skipped.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Hourly Report supports source and valid-period modules that operate over its
|
||||||
|
rolling six-hour period: `metadata`, `current_conditions`, `hourly_forecast`,
|
||||||
|
`precip_timing`, `alert_digest`, `spc_convective_outlooks`,
|
||||||
|
`area_forecast_discussion`, `spc_convective_discussion`, and `weather_story`.
|
||||||
|
It does not support daily/daypart-only modules such as
|
||||||
|
`derived_daily_summary`, `derived_daypart_summaries`, `outdoor_windows`, or
|
||||||
|
`tomorrow_planning`.
|
||||||
|
|
||||||
|
Prompt-facing module values use local, human-readable date and time labels
|
||||||
|
where the LLM is expected to reason about report content. Canonical timestamps
|
||||||
|
remain in report metadata, source provenance, and integration artifacts.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
- This package selects and shapes weather facts for prompts.
|
- This package selects and shapes already-collected weather facts for prompts.
|
||||||
- It does not fetch weather data, compare prior snapshots, build
|
- It validates module composition against report compatibility and option
|
||||||
`data_package` files, invoke Scriptorium, or write workflow metadata.
|
types.
|
||||||
|
- It does not fetch weather data, compare prior snapshots, write module
|
||||||
|
snapshots, build YAML data packages, invoke Scriptorium, or write workflow
|
||||||
|
metadata.
|
||||||
|
|
||||||
## Config Fields Used
|
## Config Fields Used
|
||||||
|
|
||||||
The package receives configured units and timezone from the app layer. Daypart
|
The app layer passes effective units, timezone, and location context into the
|
||||||
configuration is consumed by `internal/forecast` before briefing builders run.
|
module context. `internal/facts` consumes daypart configuration before module
|
||||||
Configured `location` values are prompt context only; Weather API
|
builders run. Configured `location` values are prompt context only; Weather API
|
||||||
`sourceLocationId` and `sourceLocation` remain source provenance.
|
`sourceLocationId` and `sourceLocation` remain source provenance.
|
||||||
Current conditions are copied from the normalized `/conditions/current` bundle
|
|
||||||
source only; observation station and timestamp fields remain provenance.
|
`area_forecast_discussion` uses optional `sections` configuration to include a
|
||||||
|
subset of discussion fields. Hourly Report defaults this module to
|
||||||
|
`key_messages` and `short_term`.
|
||||||
|
|
||||||
|
`spc_convective_outlooks` uses collected SPC run metadata and derived
|
||||||
|
report-period outlooks. It emits `checked: true` for a successfully fetched
|
||||||
|
empty run, reports `outlook_count`, and includes prompt-facing outlook fields
|
||||||
|
such as risk label, `period_begins`, `period_ends`, image URL, and whether the
|
||||||
|
outlook contains the configured location. It does not emit GeoJSON geometry,
|
||||||
|
source URL, expiration time, or severity rank.
|
||||||
|
|
||||||
|
Prompt-facing module intervals use friendly local `period_begins` and
|
||||||
|
`period_ends` labels. Canonical report metadata, source provenance,
|
||||||
|
`issued_at`, `updated_at`, and point-in-time fields remain separate.
|
||||||
|
|
||||||
|
`spc_convective_discussion` uses the same derived report-period outlooks and
|
||||||
|
discussion records. It is omitted unless at least one retained categorical
|
||||||
|
outlook for the same SPC day has severity rank `3` or higher and matching
|
||||||
|
discussion text exists.
|
||||||
|
|
||||||
## External Adapters Used
|
## External Adapters Used
|
||||||
|
|
||||||
@@ -49,39 +96,45 @@ None directly.
|
|||||||
|
|
||||||
## State Or Manifest Behavior
|
## State Or Manifest Behavior
|
||||||
|
|
||||||
`briefing.Save` writes briefing JSON atomically. Managed workspace placement is
|
None. `internal/app` collects module outputs into a `module.Snapshot`, and
|
||||||
owned by `internal/state`.
|
`internal/state` persists that snapshot.
|
||||||
|
|
||||||
## Skip And Resume Behavior
|
## Skip And Resume Behavior
|
||||||
|
|
||||||
None. Builders either return a complete briefing package or an error.
|
None. Builders either emit a module output, omit optional unavailable data, or
|
||||||
|
return an error for invalid required inputs.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
- Daily briefing construction requires a Daily report definition and derived
|
- Required derived modules return errors when their dependent facts are not
|
||||||
daily summary.
|
available.
|
||||||
- 3-Day briefing construction requires a 3-Day report definition and at least
|
- Module registry construction rejects duplicate module IDs and duplicate
|
||||||
one derived summary.
|
stanza names.
|
||||||
- Weekend briefing construction requires a Weekend report definition and at
|
- Composition validation rejects unknown modules, duplicate modules,
|
||||||
least one derived summary.
|
incompatible report/module combinations, duplicate stanza names, and invalid
|
||||||
- Storm briefing construction requires a Storm Report definition and forecast
|
option shapes.
|
||||||
bundle.
|
- Source-oriented module builders omit missing optional current conditions,
|
||||||
- Save failures include path and operation context.
|
forecast discussion, and weather story stanzas.
|
||||||
|
- Alert digest output distinguishes checked empty alert data from missing alert
|
||||||
|
source data.
|
||||||
|
- SPC convective outlook output distinguishes checked empty outlook data from
|
||||||
|
missing outlook source data and omits GeoJSON geometry from prompt-facing
|
||||||
|
fields.
|
||||||
|
- SPC convective discussion output is omitted unless a retained outlook has
|
||||||
|
severity rank `3` or higher and matching discussion text is available.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
Inspect:
|
Inspect:
|
||||||
|
|
||||||
- `internal/briefing/daily_test.go`
|
- `internal/briefing/base_modules_test.go`
|
||||||
- `internal/briefing/three_day_test.go`
|
- `internal/briefing/derived_modules_test.go`
|
||||||
- `internal/briefing/weekend_test.go`
|
- `internal/briefing/modules_test.go`
|
||||||
- `internal/briefing/storm_test.go`
|
|
||||||
- `internal/app/app_test.go`
|
- `internal/app/app_test.go`
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
- Briefings contain structured weather facts and source context.
|
- Module outputs contain structured weather facts and source context.
|
||||||
- Common metadata includes RunID, report ID, prompt ID, valid period, source
|
- Common metadata includes RunID, report ID, prompt ID, valid period, source
|
||||||
provenance, source hashes, source warnings, and configured prompt location.
|
provenance, source hashes, source warnings, and configured prompt location.
|
||||||
- LLM prompt input packaging and Scriptorium execution remain outside this
|
- Prompt input packaging and Scriptorium execution remain outside this package.
|
||||||
boundary.
|
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ This document describes structured Recent Changes comparison.
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/changes` compares current and prior briefing packages and emits
|
`internal/changes` compares current and prior module snapshots and emits
|
||||||
compact change records for prompt input data packages.
|
compact change records for prompt input data packages.
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
Inputs:
|
Inputs:
|
||||||
|
|
||||||
- prior briefing package
|
- prior module snapshot
|
||||||
- current briefing package
|
- current module snapshot
|
||||||
- comparison thresholds from configuration
|
- comparison thresholds from configuration
|
||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
@@ -22,7 +22,7 @@ Outputs:
|
|||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
- This package compares structured briefing data only.
|
- This package compares structured module snapshot data only.
|
||||||
- It does not read filesystem state, find prior snapshots, render Markdown,
|
- It does not read filesystem state, find prior snapshots, render Markdown,
|
||||||
invoke Scriptorium, or compare generated report text.
|
invoke Scriptorium, or compare generated report text.
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ None.
|
|||||||
|
|
||||||
## State Or Manifest Behavior
|
## State Or Manifest Behavior
|
||||||
|
|
||||||
None directly. The app loads prior briefing snapshots through `internal/state`
|
None directly. The app loads prior module snapshots through `internal/state`
|
||||||
before calling comparison functions.
|
before calling comparison functions.
|
||||||
|
|
||||||
## Skip And Resume Behavior
|
## Skip And Resume Behavior
|
||||||
@@ -51,11 +51,12 @@ empty Recent Changes list without calling a comparison function.
|
|||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
- Daily comparison requires both inputs to contain Daily briefing content.
|
- Daily comparison requires `derived_daily_summary` and
|
||||||
- 3-Day comparison requires both inputs to contain 3-Day briefing content.
|
`derived_daypart_summaries` stanzas. It also uses `alert_digest` and
|
||||||
- Weekend comparison requires both inputs to contain Weekend briefing content.
|
`precip_timing` when present.
|
||||||
- Storm Report currently has no comparison implementation, so the app leaves
|
- 3-Day comparison requires `derived_daypart_summaries`.
|
||||||
Recent Changes empty for Storm reports.
|
- Weekend comparison requires `derived_daypart_summaries`.
|
||||||
|
- Storm Report comparison returns no changes.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
123
docs/internal/distributor-adapter.md
Normal file
123
docs/internal/distributor-adapter.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# Distributor Adapter Internals
|
||||||
|
|
||||||
|
This document describes the distributor upload adapter in
|
||||||
|
`internal/adapters/distributor`.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
The adapter submits generated weatherreporter Markdown reports to a configured
|
||||||
|
distributor HTTP upload endpoint. It isolates distributor package types,
|
||||||
|
token-env lookup, upload client construction, source-bundle file mapping,
|
||||||
|
timeout handling, status polling, and upload error wrapping from app
|
||||||
|
orchestration.
|
||||||
|
|
||||||
|
## Inputs And Outputs
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
|
||||||
|
- distributor endpoint URL
|
||||||
|
- token environment variable name
|
||||||
|
- upload timeout
|
||||||
|
- pipeline ID
|
||||||
|
- bundle ID
|
||||||
|
- idempotency key
|
||||||
|
- source Markdown report path and bundle-relative path mappings
|
||||||
|
- bundle created timestamp
|
||||||
|
- context for cancellation
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
|
||||||
|
- accepted distributor run ID
|
||||||
|
- accepted distributor upload status
|
||||||
|
- distributor run status, status polling error, and raw run report JSON when available
|
||||||
|
- weatherreporter-owned idempotency conflict error when applicable
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
`internal/adapters/distributor` is the only weatherreporter package that imports
|
||||||
|
`gitea.maximumdirect.net/eric/distributor/pkg/upload` or
|
||||||
|
`gitea.maximumdirect.net/eric/distributor/pkg/bundle`.
|
||||||
|
|
||||||
|
The app layer passes weatherreporter-owned request values to the adapter. The
|
||||||
|
adapter does not choose report types, render templates, select output copies,
|
||||||
|
configure destinations, wait for downstream publication, transform Markdown, or
|
||||||
|
persist notification state.
|
||||||
|
|
||||||
|
Full upstream distributor package and HTTP contract details stay under
|
||||||
|
`docs/integrations/distributor/`.
|
||||||
|
|
||||||
|
## Config Fields Used
|
||||||
|
|
||||||
|
The adapter is built from `notify.distributor` config:
|
||||||
|
|
||||||
|
- `endpoint`
|
||||||
|
- `token_env`
|
||||||
|
- `timeout`
|
||||||
|
|
||||||
|
The app layer renders pipeline ID, bundle ID, idempotency key, and bundle paths
|
||||||
|
from:
|
||||||
|
|
||||||
|
- `pipeline_id_template`
|
||||||
|
- `bundle_id_template`
|
||||||
|
- `idempotency_key_template`
|
||||||
|
- `report_path_templates`
|
||||||
|
|
||||||
|
The token value is read from the environment variable named by `token_env`
|
||||||
|
after config loading and `secrets.directory` processing.
|
||||||
|
|
||||||
|
## Upload Behavior
|
||||||
|
|
||||||
|
The adapter calls distributor `UploadFiles` with one or more file mappings:
|
||||||
|
|
||||||
|
- pipeline ID: the rendered distributor workflow selector
|
||||||
|
- source path: the managed Markdown report path selected by app orchestration
|
||||||
|
- bundle paths: rendered bundle-relative report paths
|
||||||
|
- created: the report generation timestamp
|
||||||
|
|
||||||
|
The adapter creates a distributor upload client with the configured endpoint,
|
||||||
|
bearer token, and timeout-backed HTTP client. It also wraps the upload context
|
||||||
|
with the configured timeout when the timeout is greater than zero.
|
||||||
|
|
||||||
|
After upload acceptance, the adapter polls distributor `Status` for the accepted
|
||||||
|
run ID until the run reaches `succeeded` or `failed`, or until the configured
|
||||||
|
timeout expires. It returns the latest status, error text, and raw report JSON in
|
||||||
|
weatherreporter-owned types so app orchestration can persist them in the
|
||||||
|
notification debug artifact. Status lookup failures or timeout before a terminal
|
||||||
|
state are kept as debug status errors on an otherwise accepted upload. A
|
||||||
|
terminal distributor run status of `failed` is returned as a notification failure
|
||||||
|
with the status report preserved.
|
||||||
|
|
||||||
|
## Failure Behavior
|
||||||
|
|
||||||
|
The adapter validates required endpoint, token env name, token value, pipeline
|
||||||
|
ID, bundle ID, idempotency key, upload files, source paths, bundle paths, and
|
||||||
|
upload client inputs before uploading.
|
||||||
|
|
||||||
|
Upload failures include endpoint, pipeline ID, bundle ID, idempotency key,
|
||||||
|
source paths, and bundle paths context. Token values are redacted from adapter
|
||||||
|
errors.
|
||||||
|
|
||||||
|
Distributor idempotency conflicts are exposed as a weatherreporter-owned
|
||||||
|
`IdempotencyConflictError`, so callers do not depend on upstream distributor
|
||||||
|
types.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Inspect:
|
||||||
|
|
||||||
|
- `internal/adapters/distributor/client_test.go`
|
||||||
|
- `internal/app/app_test.go`
|
||||||
|
- `internal/cli/root_test.go`
|
||||||
|
|
||||||
|
Adapter tests use a fake upload client factory and do not require a live
|
||||||
|
distributor service.
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
- Distributor package types do not leak outside the adapter.
|
||||||
|
- Only the managed Markdown report is uploaded.
|
||||||
|
- The adapter never scans the workspace.
|
||||||
|
- Token values are not included in errors, CLI output, metadata, docs, or
|
||||||
|
examples.
|
||||||
|
- Destination routing and Markdown-to-HTML transformation belong to
|
||||||
|
distributor, not weatherreporter.
|
||||||
94
docs/internal/facts.md
Normal file
94
docs/internal/facts.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Fact Contracts Internals
|
||||||
|
|
||||||
|
This document describes the fact contract boundary.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
`internal/facts` separates normalized upstream facts collected for a report run
|
||||||
|
from conservative report-scoped facts derived from them. The package gives app
|
||||||
|
orchestration one place to build reusable facts before module execution.
|
||||||
|
|
||||||
|
## Inputs And Outputs
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
|
||||||
|
- `weatherdata.Bundle` from the Weather API adapter
|
||||||
|
- resolved report definition and valid period
|
||||||
|
- report timezone
|
||||||
|
- configured daypart definitions
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
|
||||||
|
- `facts.CollectedFacts` with normalized source facts plus separate source
|
||||||
|
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,
|
||||||
|
report-period SPC convective outlooks and discussions, daily summaries,
|
||||||
|
daypart summaries, and Storm Report window summary
|
||||||
|
|
||||||
|
Hourly Report uses the generic valid-period hourly and narrative selection
|
||||||
|
for its rolling six-hour window. Its derived facts include precipitation timing
|
||||||
|
from the selected hourly periods, alert overlaps for the six-hour period, and
|
||||||
|
SPC outlooks/discussions overlapping that period. It does not build daily
|
||||||
|
summaries, daypart summaries, or a storm-window summary.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- This package owns fact assembly and reusable deterministic derivation for a
|
||||||
|
report run.
|
||||||
|
- SPC convective outlook derivation selects already-collected outlooks whose
|
||||||
|
half-open valid intervals overlap the resolved report period and retains
|
||||||
|
discussions for represented outlook days.
|
||||||
|
- Derived SPC outlook records preserve the collected outlook fields, including
|
||||||
|
geometry, for downstream components that need source-level facts. Prompt
|
||||||
|
modules decide which fields are exposed to Scriptorium.
|
||||||
|
- It does not fetch upstream data, build prompt wording, compare prior
|
||||||
|
snapshots, write workflow state, invoke Scriptorium, or define modules.
|
||||||
|
|
||||||
|
## Config Fields Used
|
||||||
|
|
||||||
|
- `dayparts[].name`
|
||||||
|
- `dayparts[].start`
|
||||||
|
- `dayparts[].end`
|
||||||
|
- `weather_api.timezone`
|
||||||
|
|
||||||
|
## External Adapters Used
|
||||||
|
|
||||||
|
None directly. Collected facts are built from `weatherdata.Bundle`.
|
||||||
|
|
||||||
|
## State Or Manifest Behavior
|
||||||
|
|
||||||
|
None. Source provenance and warnings remain data fields for downstream metadata
|
||||||
|
and inspection.
|
||||||
|
|
||||||
|
## Failure Behavior
|
||||||
|
|
||||||
|
- Invalid or missing report valid periods return an error.
|
||||||
|
- Invalid timezone names return an error.
|
||||||
|
- Missing required hourly forecast data returns the underlying forecast
|
||||||
|
derivation error for reports that require daily summaries.
|
||||||
|
- Hourly Report can derive its default module facts without daily or
|
||||||
|
daypart summaries.
|
||||||
|
- Missing optional narrative, alert, discussion, daily, or weather story data
|
||||||
|
produces empty or nil derived fields.
|
||||||
|
- Missing optional SPC convective outlook data produces a nil collected field.
|
||||||
|
- A present SPC convective outlook source with no report-period matches
|
||||||
|
produces non-nil empty derived outlook and discussion slices.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Inspect:
|
||||||
|
|
||||||
|
- `internal/facts/facts_test.go`
|
||||||
|
- `internal/app/app_test.go`
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
- Collected facts are built once from a fetched bundle.
|
||||||
|
- Derived facts are scoped to one resolved report.
|
||||||
|
- SPC convective outlook selection uses the resolved report period and the
|
||||||
|
already-collected outlook run.
|
||||||
|
- Source provenance and warnings stay separate from ordinary fact fields.
|
||||||
|
- Prompt-specific wording and one-off presentation decisions stay outside this
|
||||||
|
package.
|
||||||
@@ -5,14 +5,14 @@ This document describes deterministic forecast summarization in
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/forecast` converts normalized bundle data into daily and period
|
`internal/forecast` converts normalized weather data into daily and period
|
||||||
summaries used by briefing builders.
|
summaries used by fact builders and module builders.
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
Inputs:
|
Inputs:
|
||||||
|
|
||||||
- `forecast.Bundle`
|
- `weatherdata.Bundle`
|
||||||
- local date or resolved report period
|
- local date or resolved report period
|
||||||
- timezone
|
- timezone
|
||||||
- configured daypart definitions
|
- configured daypart definitions
|
||||||
@@ -43,12 +43,12 @@ configuration.
|
|||||||
|
|
||||||
## External Adapters Used
|
## External Adapters Used
|
||||||
|
|
||||||
None directly. Forecast data arrives through `forecast.Bundle`.
|
None directly. Forecast data arrives through `weatherdata.Bundle`.
|
||||||
|
|
||||||
## State Or Manifest Behavior
|
## State Or Manifest Behavior
|
||||||
|
|
||||||
None. Source warnings and provenance from the bundle are carried into summaries
|
None. Source warnings and provenance from the bundle are carried into summaries
|
||||||
for later metadata and briefing output.
|
for later metadata and module output.
|
||||||
|
|
||||||
## Skip And Resume Behavior
|
## Skip And Resume Behavior
|
||||||
|
|
||||||
|
|||||||
94
docs/internal/generatedtext.md
Normal file
94
docs/internal/generatedtext.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Generated Text Internals
|
||||||
|
|
||||||
|
This document describes structured generated-text handling in
|
||||||
|
`internal/generatedtext`.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
`internal/generatedtext` validates structured text returned for
|
||||||
|
generated-text-template reports and builds curated render contexts for
|
||||||
|
templates. The implemented contracts are Tomorrow Report and Hourly Report.
|
||||||
|
|
||||||
|
## Inputs And Outputs
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
|
||||||
|
- raw GeneratedText JSON for Tomorrow Report or Hourly Report
|
||||||
|
- report metadata from `internal/briefing`
|
||||||
|
- a module snapshot from `internal/module`
|
||||||
|
- validated generated text
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
|
||||||
|
- typed `Tomorrow` generated text
|
||||||
|
- typed `Hourly` generated text
|
||||||
|
- normalized stable JSON for validated generated text
|
||||||
|
- typed `TomorrowRenderContext` values for `internal/reporttemplate`
|
||||||
|
- typed `HourlyRenderContext` values for `internal/reporttemplate`
|
||||||
|
|
||||||
|
The hourly generated text JSON accepts:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"summary": "string",
|
||||||
|
"forecast_discussion": "string",
|
||||||
|
"precipitation_timing": "string",
|
||||||
|
"confidence": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`summary` and `forecast_discussion` are required after trimming whitespace.
|
||||||
|
`precipitation_timing` and `confidence` are optional and omitted from normalized
|
||||||
|
JSON when blank.
|
||||||
|
|
||||||
|
The Tomorrow generated text JSON accepts:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"summary": "string",
|
||||||
|
"forecast_discussion": ["string"],
|
||||||
|
"precipitation_timing": "string",
|
||||||
|
"confidence": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`summary` is required 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.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- This package owns typed generated-text validation and render-context shaping.
|
||||||
|
- It uses typed module snapshot decoding through `module.StanzaValue`.
|
||||||
|
- It does not invoke Scriptorium, write state artifacts, choose report
|
||||||
|
definitions, compare snapshots, or render templates directly in production
|
||||||
|
workflows.
|
||||||
|
- It does not use a Go JSON Schema dependency; schema enforcement in Go is
|
||||||
|
limited to typed JSON decoding, unknown-field rejection, and required-field
|
||||||
|
checks.
|
||||||
|
|
||||||
|
## Failure Behavior
|
||||||
|
|
||||||
|
- Malformed generated-text JSON fails with decode context.
|
||||||
|
- Unknown generated-text JSON fields fail during decoding.
|
||||||
|
- Empty required fields fail after trimming whitespace.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Inspect:
|
||||||
|
|
||||||
|
- `internal/generatedtext/hourly_test.go`
|
||||||
|
- `internal/generatedtext/tomorrow_test.go`
|
||||||
|
- `internal/generatedtext/render_context_test.go`
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
- Render contexts are curated structs, not raw prompt-input packages.
|
||||||
|
- Required generated text is normalized before downstream artifact storage.
|
||||||
|
- Missing optional weather narrative stanzas produce empty or fallback render
|
||||||
|
context fields rather than forcing raw module data into templates.
|
||||||
167
docs/internal/module.md
Normal file
167
docs/internal/module.md
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
# Module Contract Internals
|
||||||
|
|
||||||
|
This document describes the module contract in `internal/module`.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
`internal/module` defines the shared identifiers and data envelopes used for
|
||||||
|
prompt-facing modules. Report definitions use module IDs for composition,
|
||||||
|
module builders produce outputs with stanza names, prompt input packages consume
|
||||||
|
snapshots, and Recent Changes compares snapshot stanzas.
|
||||||
|
|
||||||
|
## Inputs And Outputs
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
|
||||||
|
- ordered `module.ConfigItem` values from report definitions or config
|
||||||
|
overrides
|
||||||
|
- `module.Output` values produced by module builders
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
|
||||||
|
- stable `module.ID` constants
|
||||||
|
- typed option structs for registered modules
|
||||||
|
- `module.Snapshot` with schema version `weatherreporter.modules.v1`
|
||||||
|
- ordered snapshot outputs with module ID, stanza name, and typed value
|
||||||
|
- typed stanza lookup through `module.StanzaValue`
|
||||||
|
|
||||||
|
## Registered Module IDs
|
||||||
|
|
||||||
|
The registry recognizes these IDs:
|
||||||
|
|
||||||
|
- `metadata`
|
||||||
|
- `current_conditions`
|
||||||
|
- `narrative_forecast`
|
||||||
|
- `hourly_forecast`
|
||||||
|
- `derived_daily_summary`
|
||||||
|
- `derived_daypart_summaries`
|
||||||
|
- `precip_timing`
|
||||||
|
- `alert_digest`
|
||||||
|
- `spc_convective_outlooks`
|
||||||
|
- `area_forecast_discussion`
|
||||||
|
- `spc_convective_discussion`
|
||||||
|
- `weather_story`
|
||||||
|
- `outdoor_windows`
|
||||||
|
- `tomorrow_planning`
|
||||||
|
|
||||||
|
Every registered module has a builder. Report composition entries that refer to
|
||||||
|
unknown or unimplemented module IDs fail validation instead of being skipped.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Hourly Composition
|
||||||
|
|
||||||
|
The default Hourly Report module order is:
|
||||||
|
|
||||||
|
1. `metadata`
|
||||||
|
2. `current_conditions`
|
||||||
|
3. `hourly_forecast`
|
||||||
|
4. `precip_timing`
|
||||||
|
5. `alert_digest`
|
||||||
|
6. `spc_convective_outlooks`
|
||||||
|
7. `area_forecast_discussion`
|
||||||
|
8. `spc_convective_discussion`
|
||||||
|
9. `weather_story`
|
||||||
|
|
||||||
|
Hourly Report does not include daily or daypart summary modules by default.
|
||||||
|
Its `area_forecast_discussion` item is configured to include only
|
||||||
|
`key_messages` and `short_term`.
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
Most modules use an empty options struct, including
|
||||||
|
`spc_convective_outlooks` and `spc_convective_discussion`.
|
||||||
|
`area_forecast_discussion` accepts:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
sections:
|
||||||
|
- product
|
||||||
|
- key_messages
|
||||||
|
- short_term
|
||||||
|
- long_term
|
||||||
|
```
|
||||||
|
|
||||||
|
An omitted or empty `sections` list includes all available discussion sections.
|
||||||
|
Invalid option shapes fail during config normalization or composition
|
||||||
|
validation.
|
||||||
|
|
||||||
|
## SPC Convective Module Outputs
|
||||||
|
|
||||||
|
`spc_convective_outlooks` emits a prompt-facing risk-product stanza with:
|
||||||
|
|
||||||
|
- `checked`
|
||||||
|
- `as_of`
|
||||||
|
- `issued_at`
|
||||||
|
- `location_id`
|
||||||
|
- `location_name`
|
||||||
|
- `outlook_count`
|
||||||
|
- `outlooks`
|
||||||
|
|
||||||
|
Each outlook entry may include `day`, `outlook_type`, `label`, `label_text`,
|
||||||
|
`period_begins`, `period_ends`, `issued_at`, `contains_location`, and
|
||||||
|
`image_url`. It omits GeoJSON geometry, source URL, expiration time, and
|
||||||
|
severity rank.
|
||||||
|
|
||||||
|
`spc_convective_discussion` emits a narrative stanza only when a retained
|
||||||
|
report-period categorical outlook has severity rank `3` or higher and matching
|
||||||
|
discussion text is available. Its output includes `included_because` and
|
||||||
|
`discussions`; each discussion may include `day`, `period_begins`,
|
||||||
|
`period_ends`, `headline`, `summary`, `discussion`, and `updated_at`.
|
||||||
|
Discussions are included only for SPC days whose retained categorical outlooks
|
||||||
|
meet the severity threshold.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- This package owns module identifiers, config item envelopes, output
|
||||||
|
envelopes, snapshot validation, and typed stanza lookup.
|
||||||
|
- It does not define report IDs, execute builders, fetch weather data, derive
|
||||||
|
forecast facts, write state, or invoke Scriptorium.
|
||||||
|
|
||||||
|
## State Or Manifest Behavior
|
||||||
|
|
||||||
|
`module.Snapshot` values are persisted by `internal/state` as JSON. Snapshot
|
||||||
|
validation rejects missing schema version, missing module IDs, missing stanza
|
||||||
|
names, duplicate module outputs, and duplicate stanza names while preserving
|
||||||
|
output order.
|
||||||
|
|
||||||
|
## Failure Behavior
|
||||||
|
|
||||||
|
- Snapshot construction fails for duplicate module outputs or duplicate stanza
|
||||||
|
names.
|
||||||
|
- Typed stanza lookup returns `found=false` for missing stanzas.
|
||||||
|
- Typed stanza lookup wraps JSON marshal/decode failures with stanza context.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Inspect:
|
||||||
|
|
||||||
|
- `internal/module/module_test.go`
|
||||||
|
- `internal/briefing/modules_test.go`
|
||||||
|
- `internal/report/period_test.go`
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
- `internal/module` does not import `internal/report`.
|
||||||
|
- Module IDs are stable strings.
|
||||||
|
- Each emitted module output has exactly one stanza name and one typed value.
|
||||||
|
- Snapshot output order is caller-owned and preserved.
|
||||||
@@ -1,40 +1,112 @@
|
|||||||
# Prompt Input Internals
|
# Prompt Input Internals
|
||||||
|
|
||||||
This document describes prompt input data package construction.
|
This document describes YAML prompt data package construction in
|
||||||
|
`internal/promptinput`.
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/promptinput` converts a structured briefing package and optional
|
`internal/promptinput` converts report metadata, an ordered module snapshot,
|
||||||
Recent Changes into the `data_package` JSON passed to Scriptorium prompts.
|
Recent Changes, and source warnings into the `data_package` file passed to
|
||||||
|
Scriptorium.
|
||||||
|
|
||||||
|
The persisted data package is YAML with schema version
|
||||||
|
`weatherreporter.data_package.v2`. It is separate from the JSON module snapshot
|
||||||
|
used for inspection and comparison.
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
Inputs:
|
Inputs:
|
||||||
|
|
||||||
- `briefing.Package`
|
- report metadata from app/state orchestration
|
||||||
|
- `module.Snapshot`
|
||||||
- optional `[]changes.Change`
|
- optional `[]changes.Change`
|
||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
|
|
||||||
- `promptinput.Package` containing schema version, RunID, report metadata,
|
- `promptinput.Package` with schema version, RunID, report metadata, named
|
||||||
briefing content, Recent Changes, and source warnings. Briefing content
|
module stanzas grouped for prompt presentation, Recent Changes, and source
|
||||||
includes configured location context, current conditions when available,
|
warnings
|
||||||
structured weather story context when available, discussion key messages, and
|
- YAML bytes from `promptinput.MarshalYAML`
|
||||||
short/long-term AFD narratives when the Weather API provides them.
|
- YAML file written atomically by `promptinput.Save`
|
||||||
- report metadata includes `currentLocalDate`, the generation date formatted as
|
|
||||||
`YYYY-MM-DD` in the effective report timezone.
|
The YAML shape includes:
|
||||||
- optional JSON file written by `promptinput.Save`
|
|
||||||
|
```yaml
|
||||||
|
schema_version: weatherreporter.data_package.v2
|
||||||
|
run_id: <run_id>
|
||||||
|
report:
|
||||||
|
id: <report_id>
|
||||||
|
prompt_id: <prompt_id>
|
||||||
|
briefing:
|
||||||
|
metadata: {}
|
||||||
|
applicable_risk_products:
|
||||||
|
alert_digest: {}
|
||||||
|
spc_convective_outlooks: {}
|
||||||
|
derived_summaries:
|
||||||
|
derived_daily_summary: {}
|
||||||
|
derived_daypart_summaries: {}
|
||||||
|
precip_timing: {}
|
||||||
|
outdoor_windows: {}
|
||||||
|
narrative_products:
|
||||||
|
narrative_forecast: {}
|
||||||
|
area_forecast_discussion: {}
|
||||||
|
spc_convective_discussion: {}
|
||||||
|
weather_story: {}
|
||||||
|
raw_data:
|
||||||
|
current_conditions: {}
|
||||||
|
hourly_forecast: {}
|
||||||
|
recent_changes:
|
||||||
|
items: []
|
||||||
|
```
|
||||||
|
|
||||||
|
The `briefing` mapping keeps `metadata` directly under `briefing` and groups
|
||||||
|
weather module stanzas under prompt-facing categories. This grouping is a YAML
|
||||||
|
presentation concern only: module snapshots remain flat, and loaded
|
||||||
|
`promptinput.Package` values expose flat stanza names in `Briefing.Values`.
|
||||||
|
Within each category, stanza order follows the module snapshot output order.
|
||||||
|
Prompt-facing module intervals use local `period_begins` and `period_ends`
|
||||||
|
labels; canonical report metadata and source timestamps remain structured
|
||||||
|
timestamps where applicable.
|
||||||
|
|
||||||
|
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 Tomorrow module list includes civil-day summary stanzas,
|
||||||
|
`tomorrow_planning`, and `hourly_forecast` in the data package before
|
||||||
|
structured GeneratedText is requested from Scriptorium.
|
||||||
|
|
||||||
|
Current categories are:
|
||||||
|
|
||||||
|
- `applicable_risk_products`: location-applicable alerts, warnings, outlooks,
|
||||||
|
and similar risk products. Current stanzas include `alert_digest` and
|
||||||
|
`spc_convective_outlooks`.
|
||||||
|
- `derived_summaries`: deterministic summaries and calculated report facts.
|
||||||
|
- `narrative_products`: official narrative text products and forecast stories.
|
||||||
|
Current stanzas include `narrative_forecast`,
|
||||||
|
`area_forecast_discussion`, `spc_convective_discussion`, and
|
||||||
|
`weather_story`.
|
||||||
|
- `raw_data`: minimally transformed underlying weather data.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
- This package owns the prompt input schema and validation.
|
- This package owns prompt package schema, YAML marshaling, YAML loading, and
|
||||||
- It does not fetch weather data, derive forecast summaries, find prior
|
validation.
|
||||||
snapshots, compare changes, or invoke Scriptorium.
|
- It does not fetch weather data, derive forecast summaries, execute modules,
|
||||||
|
find prior snapshots, compare changes, choose artifact paths, or invoke
|
||||||
|
Scriptorium.
|
||||||
|
|
||||||
## Config Fields Used
|
## Config Fields Used
|
||||||
|
|
||||||
None directly. Config-derived values, including timezone and prompt location
|
None directly. Config-derived values such as timezone, units, and prompt
|
||||||
context, are already present in briefing metadata before this package runs.
|
location are already present in report metadata and module stanzas before this
|
||||||
|
package runs.
|
||||||
|
|
||||||
## External Adapters Used
|
## External Adapters Used
|
||||||
|
|
||||||
@@ -42,7 +114,7 @@ None.
|
|||||||
|
|
||||||
## State Or Manifest Behavior
|
## State Or Manifest Behavior
|
||||||
|
|
||||||
`promptinput.Save` writes JSON atomically. Managed workspace paths are owned by
|
`promptinput.Save` writes YAML atomically. Managed workspace paths are owned by
|
||||||
`internal/state`.
|
`internal/state`.
|
||||||
|
|
||||||
## Skip And Resume Behavior
|
## Skip And Resume Behavior
|
||||||
@@ -51,9 +123,10 @@ None. Recent Changes is always present as an `items` list and may be empty.
|
|||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
Validation fails before render preflight when required top-level or briefing
|
Validation fails before render preflight when required top-level fields are
|
||||||
metadata fields are missing or inconsistent, or when no report content is
|
missing or inconsistent, when the valid period is invalid, or when no module
|
||||||
present. Save failures include filesystem operation and path context.
|
stanzas are present. Save failures include filesystem operation and path
|
||||||
|
context.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@@ -64,6 +137,8 @@ Inspect:
|
|||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
- Scriptorium receives structured `data_package` JSON.
|
- Scriptorium receives structured YAML through `--input data_package=<path>`.
|
||||||
- Briefing metadata and top-level report metadata must agree.
|
- Module stanza order is deterministic within each prompt-facing category.
|
||||||
- Recent Changes are not inferred from rendered report text.
|
- Every non-metadata module stanza has exactly one prompt-input category.
|
||||||
|
- Recent Changes are provided by `internal/changes`; this package does not
|
||||||
|
infer changes from rendered report text.
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ membership, output naming, artifact grouping, and comparison declarations in
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/report` is the canonical source for report definitions. App, state,
|
`internal/report` is the canonical source for report definitions. App, state,
|
||||||
briefing, and CLI wiring consume resolved definitions instead of owning report
|
module building, and CLI wiring consume resolved definitions instead of owning
|
||||||
identity policy themselves.
|
report identity policy themselves.
|
||||||
|
|
||||||
## Definition Fields
|
## Definition Fields
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ Each report definition declares:
|
|||||||
|
|
||||||
- report ID and display name
|
- report ID and display name
|
||||||
- Scriptorium prompt ID
|
- Scriptorium prompt ID
|
||||||
|
- generation mode
|
||||||
- valid-period resolver
|
- valid-period resolver
|
||||||
- comparison strategy
|
- comparison strategy
|
||||||
- managed artifact group
|
- managed artifact group
|
||||||
@@ -23,24 +24,35 @@ Each report definition declares:
|
|||||||
- generated-report eligibility
|
- generated-report eligibility
|
||||||
- prior-report compatibility list
|
- prior-report compatibility list
|
||||||
- morning or evening batch membership
|
- morning or evening batch membership
|
||||||
|
- default ordered module composition
|
||||||
|
|
||||||
## Implemented Reports
|
Markdown report definitions use the `scriptorium_markdown` generation mode.
|
||||||
|
Their template and structured-text schema identifiers are empty. 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.
|
||||||
|
|
||||||
| Report | ID | Prompt | Artifact group | Batch copy | Prior compatibility |
|
## Reports
|
||||||
| --- | --- | --- | --- | --- | --- |
|
|
||||||
| Daily Today | `daily_today` | `weather.daily_report` | `daily` | `daily.md` | Daily Today, Daily Tomorrow |
|
|
||||||
| Daily Tomorrow | `daily_tomorrow` | `weather.daily_report` | `daily` | `tomorrow.md` | Daily Today, Daily Tomorrow |
|
|
||||||
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `three-day` | `three-day.md` | 3-Day Outlook |
|
|
||||||
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `weekend` | `weekend.md` | Weekend Outlook |
|
|
||||||
| Storm Report | `storm` | `weather.storm_report` | `storm` | `storm.md` | Storm Report |
|
|
||||||
|
|
||||||
All implemented report definitions are eligible for generation.
|
| Report | ID | Prompt | Generation mode | Artifact group | Batch copy | Prior compatibility |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| Daily Today | `daily_today` | `weather.daily_report` | `scriptorium_markdown` | `daily` | `daily.md` | Daily Today |
|
||||||
|
| Tomorrow Report | `tomorrow` | `weather.tomorrow_generated_text` | `generated_text_template` | `tomorrow` | `tomorrow.md` | Tomorrow Report |
|
||||||
|
| Hourly Report | `hourly` | `weather.hourly_generated_text` | `generated_text_template` | `hourly` | `hourly.md` | Hourly Report |
|
||||||
|
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `scriptorium_markdown` | `three-day` | `three-day.md` | 3-Day Outlook |
|
||||||
|
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `scriptorium_markdown` | `weekend` | `weekend.md` | Weekend Outlook |
|
||||||
|
| Storm Report | `storm` | `weather.storm_report` | `scriptorium_markdown` | `storm` | `storm.md` | Storm Report |
|
||||||
|
|
||||||
|
All report definitions are eligible for generation.
|
||||||
|
|
||||||
## Valid Periods
|
## Valid Periods
|
||||||
|
|
||||||
- Daily Today covers the selected local civil day, or the current local civil
|
- Daily Today covers the selected local civil day, or the current local civil
|
||||||
day when no date override is supplied.
|
day when no date override is supplied.
|
||||||
- Daily Tomorrow covers the next local civil day from generation time.
|
- 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 and is not scheduled for
|
||||||
@@ -54,8 +66,8 @@ must be after start time.
|
|||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
`internal/report` defines report metadata and time coverage. It does not fetch
|
`internal/report` defines report metadata and time coverage. It does not fetch
|
||||||
weather data, build briefings, compare briefing contents, write state, parse CLI
|
weather data, build module values, compare snapshot contents, write state,
|
||||||
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 owns public command names. The app maps those command names to report
|
||||||
IDs, then uses the registry for report policy.
|
IDs, then uses the registry for report policy.
|
||||||
@@ -63,13 +75,22 @@ IDs, then uses the registry for report policy.
|
|||||||
## Config Fields Used
|
## Config Fields Used
|
||||||
|
|
||||||
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
|
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
|
||||||
output path copying uses batch output names from report definitions.
|
output path copying uses batch output names from report definitions. Report
|
||||||
|
module overrides can use short keys such as `tomorrow` and `hourly`, or
|
||||||
|
canonical report IDs such as `daily_today`.
|
||||||
|
|
||||||
|
## Batch Membership
|
||||||
|
|
||||||
|
Morning batches include Daily Today, 3-Day Outlook, and Weekend Outlook except
|
||||||
|
on Sunday. Evening batches include Tomorrow Report. Hourly Report is not part
|
||||||
|
of a scheduled batch.
|
||||||
|
|
||||||
## State And App Usage
|
## State And App Usage
|
||||||
|
|
||||||
- State paths use `ArtifactGroup`.
|
- State paths use `ArtifactGroup`.
|
||||||
- Batch output copies use `BatchOutputName`.
|
- Batch output copies use `BatchOutputName`.
|
||||||
- Generation checks `Generated`.
|
- Generation checks `Generated`.
|
||||||
|
- Module composition defaults use `Modules`.
|
||||||
- Prior lookup checks `CompatiblePriorIDs` and the comparison strategy.
|
- Prior lookup checks `CompatiblePriorIDs` and the comparison strategy.
|
||||||
- RunIDs include the resolved report ID.
|
- RunIDs include the resolved report ID.
|
||||||
|
|
||||||
@@ -90,8 +111,10 @@ 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`.
|
- 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,
|
||||||
comparison compatibility, and comparison strategy are declared by report
|
default module composition, comparison compatibility, and comparison strategy
|
||||||
definition.
|
are declared by report definition.
|
||||||
|
|||||||
100
docs/internal/reporttemplate.md
Normal file
100
docs/internal/reporttemplate.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# 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 contracts are Tomorrow
|
||||||
|
Report and Hourly Report.
|
||||||
|
|
||||||
|
The package embeds assets from:
|
||||||
|
|
||||||
|
- `internal/reporttemplate/templates/*.md.tmpl`
|
||||||
|
- `internal/reporttemplate/schemas/*.schema.json`
|
||||||
|
|
||||||
|
## Inputs And Outputs
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
|
||||||
|
- template ID from a report definition
|
||||||
|
- typed render context built by `internal/generatedtext`
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
|
||||||
|
- template source for inspection and tests
|
||||||
|
- GeneratedText schema bytes for prompt/schema configuration
|
||||||
|
- rendered Markdown bytes for app orchestration to persist
|
||||||
|
|
||||||
|
The implemented template IDs are `tomorrow` and `hourly`. The implemented
|
||||||
|
schema IDs are also `tomorrow` and `hourly`, backed by
|
||||||
|
`tomorrow.generated_text.schema.json` and `hourly.generated_text.schema.json`.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
This package owns embedded asset lookup, Go template parsing, and Markdown
|
||||||
|
template execution. It does not fetch weather data, build module outputs,
|
||||||
|
validate GeneratedText, construct render contexts, choose report definitions,
|
||||||
|
write artifacts, invoke Scriptorium, or notify distributor.
|
||||||
|
|
||||||
|
GeneratedText validation is owned by `internal/generatedtext`. App
|
||||||
|
orchestration decides which template and schema IDs apply to a report through
|
||||||
|
`internal/report` definitions.
|
||||||
|
|
||||||
|
## Template Contracts
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Tomorrow additionally exposes forecast-date labels, ordered daypart forecast
|
||||||
|
rows, daily/daypart summaries, tomorrow 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.
|
||||||
|
|
||||||
|
Templates use `text/template` with `missingkey=error`, so missing context fields
|
||||||
|
fail rendering instead of producing incomplete Markdown.
|
||||||
|
|
||||||
|
## Schema Contract
|
||||||
|
|
||||||
|
The GeneratedText schemas describe the structured prose Scriptorium is expected
|
||||||
|
to write for each generated-text prompt. Hourly requires:
|
||||||
|
|
||||||
|
- `summary`
|
||||||
|
- `forecast_discussion`
|
||||||
|
|
||||||
|
Tomorrow requires `summary` and a nonempty `forecast_discussion` array. Both
|
||||||
|
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.
|
||||||
|
- Template execution errors include the template ID and usually identify the
|
||||||
|
missing context field.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Inspect:
|
||||||
|
|
||||||
|
- `internal/reporttemplate/reporttemplate_test.go`
|
||||||
|
- `internal/generatedtext/render_context_test.go`
|
||||||
|
- `internal/app/app_test.go`
|
||||||
|
- `internal/cli/root_test.go`
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
- Embedded templates and schemas live as separate files, not inline Go strings.
|
||||||
|
- Report definitions select templates by ID.
|
||||||
|
- Templates render from curated render contexts, not raw data packages.
|
||||||
|
- GeneratedText schemas describe LLM prose slots, not deterministic weather
|
||||||
|
facts.
|
||||||
@@ -6,17 +6,18 @@ 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
|
||||||
|
|
||||||
Inputs:
|
Inputs:
|
||||||
|
|
||||||
- prompt ID
|
- prompt ID
|
||||||
- prompt input data package path
|
- YAML prompt input data package path
|
||||||
- report output path for `run`
|
- report output path for `run`
|
||||||
|
- raw generated-text output path for structured `run`
|
||||||
- configured binary, config path, profile, timeout, and extra arguments
|
- configured binary, config path, profile, timeout, and extra arguments
|
||||||
- context for cancellation
|
- context for cancellation
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ Outputs:
|
|||||||
- truncation flags for captured output
|
- truncation flags for captured output
|
||||||
- exit code
|
- exit code
|
||||||
- report output path for `run`
|
- report output path for `run`
|
||||||
|
- raw generated-text output path for structured `run`
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
@@ -34,9 +36,9 @@ Outputs:
|
|||||||
subprocess execution. It does not choose report types, build prompt input,
|
subprocess execution. It does not choose report types, build prompt input,
|
||||||
fetch weather data, decide workflow order, or persist workflow metadata.
|
fetch weather data, decide workflow order, or persist workflow metadata.
|
||||||
|
|
||||||
The adapter exposes request and result structs for render and run operations.
|
The adapter exposes request and result structs for render, Markdown run, and
|
||||||
State persistence uses a state-owned preflight artifact shape; app
|
structured generated-text run operations. State persistence uses state-owned
|
||||||
orchestration converts render results before saving.
|
artifact shapes; app orchestration converts adapter results before saving.
|
||||||
|
|
||||||
## Config Fields Used
|
## Config Fields Used
|
||||||
|
|
||||||
@@ -60,6 +62,12 @@ Report generation argv starts with:
|
|||||||
scriptorium run --prompt <prompt_id> --input data_package=<path> --out <path>
|
scriptorium run --prompt <prompt_id> --input data_package=<path> --out <path>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Structured generated-text argv uses the same `scriptorium run` form, with the
|
||||||
|
`--out` value set to the raw generated-text JSON artifact path. The adapter
|
||||||
|
does not add `--format`, schema path, or JSON Schema flags for structured
|
||||||
|
generation; Scriptorium selects the structured output schema from prompt
|
||||||
|
configuration.
|
||||||
|
|
||||||
Configured `--config` and `--profile` flags are inserted after the subcommand
|
Configured `--config` and `--profile` flags are inserted after the subcommand
|
||||||
and before prompt-specific arguments. Extra arguments are appended after the
|
and before prompt-specific arguments. Extra arguments are appended after the
|
||||||
built-in arguments.
|
built-in arguments.
|
||||||
@@ -67,8 +75,8 @@ built-in arguments.
|
|||||||
## Execution Behavior
|
## Execution Behavior
|
||||||
|
|
||||||
The adapter runs commands without shell interpolation. The same private
|
The adapter runs commands without shell interpolation. The same private
|
||||||
execution path is used by render and run after command-specific request
|
execution path is used by render, Markdown run, and structured run after
|
||||||
validation and argv construction.
|
command-specific request validation and argv construction.
|
||||||
|
|
||||||
When `scriptorium.timeout` is greater than zero, each subprocess call uses a
|
When `scriptorium.timeout` is greater than zero, each subprocess call uses a
|
||||||
context with that timeout. Stdout and stderr are captured separately, capped at
|
context with that timeout. Stdout and stderr are captured separately, capped at
|
||||||
@@ -81,8 +89,8 @@ context with that timeout. Stdout and stderr are captured separately, capped at
|
|||||||
- Missing run output path returns an error before subprocess execution.
|
- Missing run output path returns an error before subprocess execution.
|
||||||
- Subprocess start errors, context cancellation, and timeouts are wrapped with
|
- Subprocess start errors, context cancellation, and timeouts are wrapped with
|
||||||
operation context by the caller-facing method.
|
operation context by the caller-facing method.
|
||||||
- Nonzero render and run exits return the captured result plus an error
|
- Nonzero render, Markdown run, and structured run exits return the captured
|
||||||
containing the exit code and stderr.
|
result plus an error containing the exit code and stderr.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@@ -96,5 +104,7 @@ 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`.
|
||||||
- Render and run preserve command-specific result structs.
|
- The file at the data package path is YAML produced by `internal/promptinput`.
|
||||||
|
- 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.
|
||||||
|
|||||||
@@ -13,28 +13,34 @@ Inputs:
|
|||||||
|
|
||||||
- workspace configuration
|
- workspace configuration
|
||||||
- resolved report definition and valid period
|
- resolved report definition and valid period
|
||||||
- briefing package
|
- 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
|
||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
|
|
||||||
- briefing snapshot JSON path
|
- module snapshot JSON path
|
||||||
- prompt input data package JSON path
|
- prompt input data package YAML path
|
||||||
- render preflight JSON path
|
- render preflight JSON path
|
||||||
|
- generated-text raw JSON path
|
||||||
|
- generated-text run-result JSON path
|
||||||
|
- validated generated-text JSON path
|
||||||
|
- render context JSON path
|
||||||
- managed Markdown report path
|
- managed Markdown report path
|
||||||
- metadata JSON path
|
- metadata JSON path
|
||||||
- prior comparable snapshot metadata
|
- prior comparable snapshot metadata
|
||||||
- loaded briefing 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
|
||||||
|
|
||||||
`internal/state` owns local filesystem layout, path validation, durable writes,
|
`internal/state` owns local filesystem layout, path validation, durable writes,
|
||||||
metadata reads, prior lookup, and report listing. It does not fetch weather
|
metadata reads, prior lookup, and report listing. It does not fetch weather
|
||||||
data, derive forecasts, build prompt input content, compare briefing contents,
|
data, derive forecasts, build prompt input content, compare module contents,
|
||||||
invoke Scriptorium, import adapter result types, or parse CLI flags.
|
invoke Scriptorium, import adapter result types, or parse CLI flags.
|
||||||
|
|
||||||
Preflight persistence uses the state-owned `PreflightArtifact` shape. The app
|
Preflight persistence uses the state-owned `PreflightArtifact` shape. The app
|
||||||
@@ -47,6 +53,7 @@ converts adapter render results into that shape before saving.
|
|||||||
- `workspace.reports_dir`
|
- `workspace.reports_dir`
|
||||||
- `workspace.data_packages_dir`
|
- `workspace.data_packages_dir`
|
||||||
- `workspace.preflight_dir`
|
- `workspace.preflight_dir`
|
||||||
|
- `workspace.notifications_dir`
|
||||||
|
|
||||||
Workspace subdirectories must be relative paths that stay under
|
Workspace subdirectories must be relative paths that stay under
|
||||||
`workspace.root`.
|
`workspace.root`.
|
||||||
@@ -54,20 +61,29 @@ 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 JSON artifacts, and the RunID.
|
valid-period start date for dated artifacts, and the RunID.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
<workspace.root>/
|
<workspace.root>/
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.briefing.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.modules.json
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.metadata.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.metadata.json
|
||||||
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.raw.json
|
||||||
|
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.run.json
|
||||||
|
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.json
|
||||||
|
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.render_context.json
|
||||||
|
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.yaml
|
||||||
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json
|
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json
|
||||||
|
notifications/<artifact_group>/<YYYY-MM-DD>/<run_id>.distributor.json
|
||||||
reports/<artifact_group>/<run_id>.md
|
reports/<artifact_group>/<run_id>.md
|
||||||
```
|
```
|
||||||
|
|
||||||
Metadata is stored beside briefing snapshots and links the briefing, data
|
Metadata is stored beside module snapshots and links the module snapshot, data
|
||||||
package, preflight, report paths, and configured prompt location. Report
|
package, preflight, report paths, notification path when attempted, and
|
||||||
listing walks metadata files under the snapshots directory.
|
configured prompt location. For generated-text-template reports, metadata also
|
||||||
|
records the generated text schema ID and links the raw generated text,
|
||||||
|
Scriptorium run result, validated generated text, and render context artifacts.
|
||||||
|
Markdown-report metadata omits those generated-text fields. Report listing
|
||||||
|
walks metadata files under the snapshots directory.
|
||||||
|
|
||||||
## Prior Lookup
|
## Prior Lookup
|
||||||
|
|
||||||
@@ -75,24 +91,35 @@ 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 Today compares with prior Daily Today 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.
|
||||||
- Storm Report currently has no prior lookup because explicit event-window
|
- Hourly Report uses the rolling-window comparison strategy and currently
|
||||||
comparison is not searched by the filesystem store.
|
returns no prior snapshot from filesystem lookup.
|
||||||
|
- Storm Report has no prior lookup because explicit event-window comparison is
|
||||||
|
not searched by the filesystem store.
|
||||||
|
|
||||||
## Writes And Inspection
|
## 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
|
||||||
|
Markdown reports are prepared by creating their parent directory; Scriptorium
|
||||||
|
writes the report body to the prepared path. Extra Markdown copies are handled
|
||||||
|
by app orchestration. Distributor notification debug artifacts are written
|
||||||
|
atomically when notification is attempted and include rendered distributor
|
||||||
|
pipeline ID, bundle ID, idempotency key, bundle paths, upload status, latest
|
||||||
|
run status, and redacted errors.
|
||||||
|
|
||||||
Inspection helpers read existing metadata, briefing, and data package files.
|
Inspection helpers read existing metadata, module snapshot, data package,
|
||||||
Missing metadata directories return no inspection records or no prior snapshot
|
generated text, generated-text run result, and render context files. Missing
|
||||||
rather than creating state.
|
metadata directories return no inspection records or no prior snapshot rather
|
||||||
|
than creating state.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
@@ -114,4 +141,6 @@ Inspect:
|
|||||||
- Managed paths stay under the configured workspace root.
|
- Managed paths stay under the configured workspace root.
|
||||||
- Artifact grouping comes from report definitions.
|
- Artifact grouping comes from report definitions.
|
||||||
- Metadata links artifacts produced for a run.
|
- Metadata links artifacts produced for a run.
|
||||||
|
- Generated-text artifacts live under the snapshots tree beside module
|
||||||
|
snapshots and metadata.
|
||||||
- Prior lookup is based on structured metadata, not rendered report text.
|
- Prior lookup is based on structured metadata, not rendered report text.
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
# Weather Data Internals
|
# Weather Data Internals
|
||||||
|
|
||||||
This document describes Weather API ingestion into `forecast.Bundle`.
|
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 briefing builders. Briefing 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
|
||||||
|
|
||||||
@@ -19,17 +19,17 @@ Inputs:
|
|||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
|
|
||||||
- `forecast.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
|
||||||
- stub source record for the daily forecast source slot
|
optional source is available
|
||||||
- optional saved bundle JSON through app fetch helpers
|
- optional saved bundle JSON through app fetch helpers
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
- The adapter owns HTTP calls, response-envelope handling, source hashing, and
|
- The adapter owns HTTP calls, response-envelope handling, source hashing, and
|
||||||
decoding into internal bundle types.
|
decoding into internal bundle types.
|
||||||
- It does not derive dayparts, resolve report periods, build briefings, compare
|
- It does not derive dayparts, resolve report periods, build module values, compare
|
||||||
snapshots, write report state, or invoke Scriptorium.
|
snapshots, write report state, or invoke Scriptorium.
|
||||||
|
|
||||||
## Config Fields Used
|
## Config Fields Used
|
||||||
@@ -55,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,
|
||||||
@@ -70,9 +80,11 @@ data is required and cannot be skipped.
|
|||||||
- HTTP errors, response read failures, and envelope decode failures include
|
- HTTP errors, response read failures, and envelope decode failures include
|
||||||
endpoint context.
|
endpoint context.
|
||||||
- Missing hourly data or hourly forecasts with no periods fail bundle fetch.
|
- Missing hourly data or hourly forecasts with no periods fail bundle fetch.
|
||||||
- Optional and stub 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
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,41 @@
|
|||||||
# Weatherreporter Operations
|
# Weatherreporter Operations
|
||||||
|
|
||||||
This guide covers normal operation, generated artifacts, inspection, recovery,
|
This guide covers normal operation, generated artifacts, inspection, recovery,
|
||||||
and current operational caveats. For symptom-specific diagnosis, see
|
and operational caveats. For symptom-specific diagnosis, see
|
||||||
[Troubleshooting](troubleshooting.md).
|
[Troubleshooting](troubleshooting.md).
|
||||||
|
|
||||||
## Normal Workflow
|
## Normal Workflow
|
||||||
|
|
||||||
Implemented generation commands:
|
Generation commands:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
weatherreporter generate daily --date 2026-05-29
|
weatherreporter generate daily --date 2026-05-29
|
||||||
weatherreporter generate tomorrow
|
weatherreporter generate tomorrow
|
||||||
|
weatherreporter generate hourly
|
||||||
weatherreporter generate three-day
|
weatherreporter generate three-day
|
||||||
weatherreporter generate weekend
|
weatherreporter generate weekend
|
||||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
|
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
|
||||||
```
|
```
|
||||||
|
|
||||||
Each command resolves a report period, fetches a Weather API bundle, builds a
|
Generation commands resolve a report period, fetch a Weather API bundle, build
|
||||||
briefing, builds a prompt input data package, runs `scriptorium render`, runs
|
a JSON module snapshot, build a YAML prompt input data package, run
|
||||||
`scriptorium run`, and writes managed artifacts under the configured workspace.
|
`scriptorium render`, and write managed artifacts under the configured
|
||||||
`--out PATH` writes an extra Markdown copy for the current generated report.
|
workspace. Markdown-path reports then run `scriptorium run` directly to the
|
||||||
|
managed Markdown report path.
|
||||||
|
|
||||||
Implemented batch commands:
|
`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 hourly` covers the six-hour
|
||||||
|
rolling period from generation time in the effective report timezone and is not
|
||||||
|
part of scheduled morning or evening batches.
|
||||||
|
|
||||||
|
When distributor notification is enabled, weatherreporter uploads the managed
|
||||||
|
Markdown report after report rendering succeeds and final metadata is saved.
|
||||||
|
`--out PATH` writes an extra Markdown copy for generated reports; it is not used
|
||||||
|
as the distributor upload source.
|
||||||
|
|
||||||
|
Batch commands:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
weatherreporter run morning
|
weatherreporter run morning
|
||||||
@@ -29,12 +43,15 @@ weatherreporter run evening
|
|||||||
```
|
```
|
||||||
|
|
||||||
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
||||||
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch
|
except on Sunday. `run evening` generates the Tomorrow Report. Batch
|
||||||
commands print a JSON summary to stdout, write compact per-report status lines
|
commands print a JSON summary to stdout, write compact per-report status lines
|
||||||
to stderr, continue independent reports after one report fails, and return
|
to stderr, continue independent reports after one report fails, and return
|
||||||
nonzero when any report failed. `--out-dir PATH` writes extra Markdown copies
|
nonzero when any report failed. When notification is configured, the summary and
|
||||||
using report default filenames such as `daily.md`, `three-day.md`,
|
status lines include notification status, accepted distributor run ID, or
|
||||||
`weekend.md`, and `tomorrow.md`.
|
notification error fields for each attempted report. `--out-dir PATH` writes
|
||||||
|
extra Markdown copies using report default filenames such as `daily.md`,
|
||||||
|
`three-day.md`, `weekend.md`, and `tomorrow.md`; these copies are not used as
|
||||||
|
distributor upload sources.
|
||||||
|
|
||||||
## Filesystem Layout
|
## Filesystem Layout
|
||||||
|
|
||||||
@@ -45,33 +62,55 @@ workspace/
|
|||||||
snapshots/
|
snapshots/
|
||||||
daily/
|
daily/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.briefing.json
|
<run_id>.modules.json
|
||||||
<run_id>.metadata.json
|
<run_id>.metadata.json
|
||||||
three-day/
|
three-day/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.briefing.json
|
<run_id>.modules.json
|
||||||
<run_id>.metadata.json
|
<run_id>.metadata.json
|
||||||
weekend/
|
weekend/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.briefing.json
|
<run_id>.modules.json
|
||||||
<run_id>.metadata.json
|
<run_id>.metadata.json
|
||||||
|
hourly/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.modules.json
|
||||||
|
<run_id>.metadata.json
|
||||||
|
<run_id>.generated_text.raw.json
|
||||||
|
<run_id>.generated_text.run.json
|
||||||
|
<run_id>.generated_text.json
|
||||||
|
<run_id>.render_context.json
|
||||||
|
tomorrow/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.modules.json
|
||||||
|
<run_id>.metadata.json
|
||||||
|
<run_id>.generated_text.raw.json
|
||||||
|
<run_id>.generated_text.run.json
|
||||||
|
<run_id>.generated_text.json
|
||||||
|
<run_id>.render_context.json
|
||||||
storm/
|
storm/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.briefing.json
|
<run_id>.modules.json
|
||||||
<run_id>.metadata.json
|
<run_id>.metadata.json
|
||||||
data-packages/
|
data-packages/
|
||||||
daily/
|
daily/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.data_package.json
|
<run_id>.data_package.yaml
|
||||||
three-day/
|
three-day/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.data_package.json
|
<run_id>.data_package.yaml
|
||||||
weekend/
|
weekend/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.data_package.json
|
<run_id>.data_package.yaml
|
||||||
|
hourly/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.data_package.yaml
|
||||||
|
tomorrow/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.data_package.yaml
|
||||||
storm/
|
storm/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.data_package.json
|
<run_id>.data_package.yaml
|
||||||
preflight/
|
preflight/
|
||||||
daily/
|
daily/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
@@ -82,9 +121,34 @@ workspace/
|
|||||||
weekend/
|
weekend/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.render.json
|
<run_id>.render.json
|
||||||
|
hourly/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.render.json
|
||||||
|
tomorrow/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.render.json
|
||||||
storm/
|
storm/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.render.json
|
<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
|
||||||
|
hourly/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.distributor.json
|
||||||
|
tomorrow/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.distributor.json
|
||||||
|
storm/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.distributor.json
|
||||||
reports/
|
reports/
|
||||||
daily/
|
daily/
|
||||||
<run_id>.md
|
<run_id>.md
|
||||||
@@ -92,6 +156,10 @@ workspace/
|
|||||||
<run_id>.md
|
<run_id>.md
|
||||||
weekend/
|
weekend/
|
||||||
<run_id>.md
|
<run_id>.md
|
||||||
|
hourly/
|
||||||
|
<run_id>.md
|
||||||
|
tomorrow/
|
||||||
|
<run_id>.md
|
||||||
storm/
|
storm/
|
||||||
<run_id>.md
|
<run_id>.md
|
||||||
```
|
```
|
||||||
@@ -112,13 +180,64 @@ Each generated report writes metadata that links:
|
|||||||
- RunID, report ID, variant, and prompt ID
|
- RunID, report ID, variant, and prompt ID
|
||||||
- generation time, timezone, and valid period
|
- generation time, timezone, and valid period
|
||||||
- source location, source hashes, and source warnings
|
- source location, source hashes, and source warnings
|
||||||
- briefing snapshot path
|
- module snapshot path
|
||||||
- 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
|
||||||
|
|
||||||
Batch summaries include report status, error text when applicable, valid
|
Batch summaries include report status, error text when applicable, notification
|
||||||
period, and known artifact paths for each attempted report.
|
outcome when attempted, valid period, and known artifact paths for each
|
||||||
|
attempted report. Notification fields are `notificationStatus`,
|
||||||
|
`notificationRunId`, and `notificationError`.
|
||||||
|
|
||||||
|
## Distributor Notification
|
||||||
|
|
||||||
|
Distributor notification is configured with `notify.distributor` and is
|
||||||
|
disabled by default. When enabled, weatherreporter uploads the managed Markdown
|
||||||
|
report path recorded in the report result and metadata. That single source file
|
||||||
|
can be mapped to one or more configured bundle paths. By default, it is mapped
|
||||||
|
to one dated report path. Extra copies written by `--out` or `--out-dir` are
|
||||||
|
operator conveniences only.
|
||||||
|
|
||||||
|
The rendered pipeline ID selects the configured distributor `http_upload`
|
||||||
|
workflow. The default bundle ID is a stable logical source identity derived from
|
||||||
|
producer name, location ID, and report ID:
|
||||||
|
|
||||||
|
```text
|
||||||
|
weatherreporter.{location_id}.{report_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
The default idempotency key appends RunID to the rendered bundle ID so each
|
||||||
|
report generation has a distinct retry identity. The default bundle path uses
|
||||||
|
the valid-period start date, artifact group, and RunID. Distributor owns
|
||||||
|
destination merge, retention, and derived snapshot behavior such as `latest`.
|
||||||
|
|
||||||
|
Notification happens after final metadata save for generated reports. Weather
|
||||||
|
API, module snapshot, data-package, render preflight, Scriptorium run,
|
||||||
|
generated-text validation, template rendering, and metadata-save failures do
|
||||||
|
not trigger notification. A notification failure fails that report.
|
||||||
|
In a batch, other reports continue, the failed report includes notification
|
||||||
|
fields in the JSON summary, and the batch returns nonzero.
|
||||||
|
|
||||||
|
Each notification attempt writes a debug artifact under `notifications/`. The
|
||||||
|
artifact records the rendered pipeline ID, bundle ID, idempotency key, managed
|
||||||
|
source path, bundle-relative paths, bundle created 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,
|
||||||
|
constructing a source bundle, and submitting it to the configured distributor
|
||||||
|
HTTP endpoint. Distributor remains responsible for destination routing,
|
||||||
|
publication, and any downstream Markdown-to-HTML transformation. Distributor
|
||||||
|
leaves destination files alone when they are not tracked by a newly uploaded
|
||||||
|
bundle, so existing uploaded dated report paths can remain available.
|
||||||
|
|
||||||
## Inspection
|
## Inspection
|
||||||
|
|
||||||
@@ -128,30 +247,31 @@ They do not fetch weather data or run `scriptorium`.
|
|||||||
```text
|
```text
|
||||||
weatherreporter inspect reports --limit 10
|
weatherreporter inspect reports --limit 10
|
||||||
weatherreporter inspect metadata RUN_ID
|
weatherreporter inspect metadata RUN_ID
|
||||||
weatherreporter inspect briefing RUN_ID
|
weatherreporter inspect modules RUN_ID
|
||||||
weatherreporter inspect data-package RUN_ID
|
weatherreporter inspect data-package RUN_ID
|
||||||
weatherreporter inspect prior RUN_ID
|
weatherreporter inspect prior RUN_ID
|
||||||
weatherreporter inspect sources RUN_ID
|
weatherreporter inspect sources RUN_ID
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `inspect reports` to find recent 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 briefing` and `inspect data-package` to review the exact structured
|
`inspect modules` to review the persisted ordered module snapshot, and
|
||||||
inputs used for rendering. Use `inspect prior` to see the prior comparable
|
`inspect data-package` to review the structured prompt package used for
|
||||||
snapshot selected for Recent Changes, or `null` when none exists. Use
|
rendering. Use `inspect prior` to see the prior comparable snapshot selected for
|
||||||
`inspect sources` to review source provenance and warnings without dumping full
|
Recent Changes, or `null` when none exists. Use `inspect sources` to review
|
||||||
weather payloads.
|
source provenance and warnings without dumping full weather payloads.
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
|
||||||
Recent Changes are computed from structured briefing snapshots, not rendered
|
Recent Changes are computed from structured module snapshots, not rendered
|
||||||
Markdown text.
|
Markdown or YAML text.
|
||||||
|
|
||||||
Daily Today and Daily Tomorrow can compare with each other when they cover the
|
Daily Today compares with prior Daily Today snapshots for the same valid local
|
||||||
|
date. Tomorrow Report compares with prior Tomorrow Report snapshots for the
|
||||||
same valid local date. 3-Day Outlook compares with prior compatible 3-Day
|
same valid local date. 3-Day Outlook compares with prior compatible 3-Day
|
||||||
snapshots for the same valid local date. Weekend Outlook compares with prior
|
snapshots for the same valid local date. Weekend Outlook compares with prior
|
||||||
compatible Weekend snapshots for the same weekend window. Storm Report currently
|
compatible Weekend snapshots for the same weekend window. Hourly Report and
|
||||||
leaves Recent Changes empty.
|
Storm Report leave Recent Changes empty.
|
||||||
|
|
||||||
When no prior comparable snapshot exists, or no configured threshold is crossed,
|
When no prior comparable snapshot exists, or no configured threshold is crossed,
|
||||||
`recentChanges.items` is empty.
|
`recentChanges.items` is empty.
|
||||||
@@ -164,6 +284,12 @@ A failed generation run may still leave useful artifacts:
|
|||||||
preflight JSON and metadata are written for inspection.
|
preflight JSON and metadata are written for inspection.
|
||||||
- If `scriptorium run` exits nonzero after writing a report, the managed report
|
- If `scriptorium run` exits nonzero after writing a report, the managed report
|
||||||
and metadata remain available.
|
and metadata remain available.
|
||||||
|
- Hourly generated-text failures preserve available intermediate artifacts,
|
||||||
|
such as the structured run result, raw generated-text JSON, validated
|
||||||
|
generated text, and render context. Metadata links those paths when it can be
|
||||||
|
safely written.
|
||||||
|
- If distributor notification fails, report artifacts and final metadata remain
|
||||||
|
available, but the report or batch command returns nonzero.
|
||||||
- For batch commands, inspect the stdout JSON summary first, then inspect the
|
- For batch commands, inspect the stdout JSON summary first, then inspect the
|
||||||
artifact paths for each failed report.
|
artifact paths for each failed report.
|
||||||
|
|
||||||
@@ -172,7 +298,7 @@ For a bad report, start with:
|
|||||||
```text
|
```text
|
||||||
weatherreporter inspect metadata RUN_ID
|
weatherreporter inspect metadata RUN_ID
|
||||||
weatherreporter inspect sources RUN_ID
|
weatherreporter inspect sources RUN_ID
|
||||||
weatherreporter inspect briefing RUN_ID
|
weatherreporter inspect modules RUN_ID
|
||||||
weatherreporter inspect data-package RUN_ID
|
weatherreporter inspect data-package RUN_ID
|
||||||
weatherreporter inspect prior RUN_ID
|
weatherreporter inspect prior RUN_ID
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -3,16 +3,22 @@
|
|||||||
This document defines the development principles for this Go project. It is inward-facing: developers and LLM coding agents should use it to preserve the project’s shape, boundaries, and invariants as the code evolves.
|
This document defines the development principles for this Go project. It is inward-facing: developers and LLM coding agents should use it to preserve the project’s shape, boundaries, and invariants as the code evolves.
|
||||||
|
|
||||||
## weatherreporter
|
## weatherreporter
|
||||||
`weatherreporter` is a deterministic weather briefing and report-preparation application. It consumes normalized weather data from the internal weatherfeeder-backed API, derives report-specific briefing packages, compares those packages against prior snapshots, and invokes an external prompt runner to produce human-facing reports.
|
`weatherreporter` is a deterministic weather briefing and report-preparation application. It consumes normalized weather data from the internal weatherfeeder-backed API, derives report-specific module snapshots and prompt packages, compares module snapshots against prior runs, and invokes an external prompt runner to produce human-facing reports.
|
||||||
|
|
||||||
The application should keep meteorological data selection, daypart grouping, threshold detection, forecast-period resolution, and recent-change comparison inside Go domain packages. LLM prompts should receive curated briefing packages rather than raw unbounded source payloads wherever practical.
|
The application should keep meteorological data selection, daypart grouping, threshold detection, forecast-period resolution, and recent-change comparison inside Go domain packages. LLM prompts should receive curated module-based prompt packages rather than raw unbounded source payloads wherever practical.
|
||||||
|
|
||||||
Report types must be defined through a registry or equivalent mechanism. Each report definition should declare its report ID, prompt ID, valid-period resolver, briefing builder, comparison strategy, and output naming behavior. Avoid scattering report-type conditionals across CLI and orchestration code.
|
Report types must be defined through a registry or equivalent mechanism. Each report definition should declare its report ID, prompt ID, valid-period resolver, module composition, comparison strategy, and output naming behavior. Avoid scattering report-type conditionals across CLI and orchestration code.
|
||||||
|
|
||||||
Generated reports must be associated with explicit metadata, including report type, location, generation time, valid period, source product timestamps or hashes, briefing snapshot path, and output path. Recent Changes must be based on structured snapshot comparison rather than comparison of rendered Markdown report text.
|
Generated reports must be associated with explicit metadata, including report type, location, generation time, valid period, source product timestamps or hashes, module snapshot path, and output path. Recent Changes must be based on structured snapshot comparison rather than comparison of rendered Markdown report text.
|
||||||
|
|
||||||
`scriptorium` is an external adapter, not domain logic. Subprocess execution must be isolated under `internal/adapters/scriptorium`, use context-aware execution, avoid shell interpolation, capture actionable stderr, and keep scriptorium-specific flags from leaking into domain packages.
|
`scriptorium` is an external adapter, not domain logic. Subprocess execution must be isolated under `internal/adapters/scriptorium`, use context-aware execution, avoid shell interpolation, capture actionable stderr, and keep scriptorium-specific flags from leaking into domain packages.
|
||||||
|
|
||||||
|
`distributor` is also an external adapter. Upload behavior must be isolated
|
||||||
|
under `internal/adapters/distributor`, dependency types from the distributor
|
||||||
|
module must not leak outside that adapter, and the selected upload source must
|
||||||
|
be the managed Markdown report rather than optional output copies or broad
|
||||||
|
workspace scans.
|
||||||
|
|
||||||
## Project Shape
|
## Project Shape
|
||||||
|
|
||||||
Default to a small, explicit, dependency-light Go application. Keep the design modular enough to test and change safely, but do not add abstraction unless it protects a real boundary or enables a real extension point.
|
Default to a small, explicit, dependency-light Go application. Keep the design modular enough to test and change safely, but do not add abstraction unless it protects a real boundary or enables a real extension point.
|
||||||
@@ -54,7 +60,7 @@ Configuration precedence is:
|
|||||||
|
|
||||||
Prefer YAML configuration unless the project has a strong reason to use another format. Config files should be discovered at `/usr/local/etc/<app_name>/config.yml`, with a CLI override via `--config`.
|
Prefer YAML configuration unless the project has a strong reason to use another format. Config files should be discovered at `/usr/local/etc/<app_name>/config.yml`, with a CLI override via `--config`.
|
||||||
|
|
||||||
Configuration files should not contain raw secrets unless the application is explicitly designed for that. Prefer environment variables or secret files for secrets.
|
Configuration files should not contain raw secrets unless the application is explicitly designed for that. Prefer environment variables or secret files for secrets. File-backed secrets are loaded through `secrets.directory`; secret values must not be logged, persisted, or included in user-facing output.
|
||||||
|
|
||||||
## Adapters and External Integrations
|
## Adapters and External Integrations
|
||||||
|
|
||||||
|
|||||||
@@ -13,13 +13,17 @@ Developers and LLM coding agents should use it with
|
|||||||
- `internal/config`: configuration structs, defaults, loading, overrides, and
|
- `internal/config`: configuration structs, defaults, loading, overrides, and
|
||||||
validation.
|
validation.
|
||||||
- `internal/fileutil`: shared atomic filesystem write and copy helpers.
|
- `internal/fileutil`: shared atomic filesystem write and copy helpers.
|
||||||
|
- `internal/adapters/distributor`: Distributor upload adapter.
|
||||||
- `internal/adapters/weatherapi`: Weather API HTTP adapter.
|
- `internal/adapters/weatherapi`: Weather API HTTP adapter.
|
||||||
- `internal/adapters/scriptorium`: Scriptorium subprocess adapter.
|
- `internal/adapters/scriptorium`: Scriptorium subprocess adapter.
|
||||||
- `internal/forecast`: normalized bundle types and deterministic forecast
|
- `internal/weatherdata`: normalized weather source facts, source metadata, and
|
||||||
derivation.
|
source warnings.
|
||||||
|
- `internal/forecast`: deterministic forecast derivation.
|
||||||
|
- `internal/facts`: collected and derived report fact contracts.
|
||||||
|
- `internal/module`: module IDs, config items, output envelopes, and snapshots.
|
||||||
- `internal/report`: report definitions, valid periods, batches, output names,
|
- `internal/report`: report definitions, valid periods, batches, output names,
|
||||||
and comparison declarations.
|
and comparison declarations.
|
||||||
- `internal/briefing`: report-specific briefing package builders.
|
- `internal/briefing`: prompt-facing module value builders and module registry.
|
||||||
- `internal/changes`: structured Recent Changes comparison.
|
- `internal/changes`: structured Recent Changes comparison.
|
||||||
- `internal/promptinput`: Scriptorium `data_package` construction and
|
- `internal/promptinput`: Scriptorium `data_package` construction and
|
||||||
validation.
|
validation.
|
||||||
@@ -45,7 +49,7 @@ Useful focused checks:
|
|||||||
```bash
|
```bash
|
||||||
go test ./internal/cli ./internal/config
|
go test ./internal/cli ./internal/config
|
||||||
go test ./internal/app ./internal/state
|
go test ./internal/app ./internal/state
|
||||||
go test ./internal/adapters/weatherapi ./internal/adapters/scriptorium
|
go test ./internal/adapters/distributor ./internal/adapters/weatherapi ./internal/adapters/scriptorium
|
||||||
go test ./internal/forecast ./internal/report ./internal/briefing ./internal/changes ./internal/promptinput
|
go test ./internal/forecast ./internal/report ./internal/briefing ./internal/changes ./internal/promptinput
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -64,6 +68,8 @@ Run `gofmt -w` on changed Go files before committing.
|
|||||||
- Use atomic writes for durable JSON artifacts where practical.
|
- Use atomic writes for durable JSON artifacts where practical.
|
||||||
- Keep report selection and prompt IDs centralized in `internal/report`.
|
- Keep report selection and prompt IDs centralized in `internal/report`.
|
||||||
- Keep Scriptorium argv construction inside `internal/adapters/scriptorium`.
|
- Keep Scriptorium argv construction inside `internal/adapters/scriptorium`.
|
||||||
|
- Keep distributor package types and upload-client construction inside
|
||||||
|
`internal/adapters/distributor`.
|
||||||
- Keep Weather API transport and envelope handling inside
|
- Keep Weather API transport and envelope handling inside
|
||||||
`internal/adapters/weatherapi`.
|
`internal/adapters/weatherapi`.
|
||||||
|
|
||||||
@@ -72,8 +78,10 @@ Run `gofmt -w` on changed Go files before committing.
|
|||||||
Prefer the Go standard library. Add dependencies only when they materially
|
Prefer the Go standard library. Add dependencies only when they materially
|
||||||
improve correctness, interoperability, security, or maintainability.
|
improve correctness, interoperability, security, or maintainability.
|
||||||
|
|
||||||
Current external dependency:
|
Current external dependencies:
|
||||||
|
|
||||||
|
- `gitea.maximumdirect.net/eric/distributor` for distributor source bundle
|
||||||
|
construction and HTTP upload client behavior.
|
||||||
- `gopkg.in/yaml.v3` for YAML configuration parsing.
|
- `gopkg.in/yaml.v3` for YAML configuration parsing.
|
||||||
|
|
||||||
When adding a dependency:
|
When adding a dependency:
|
||||||
@@ -145,12 +153,14 @@ When an external contract changes, update the matching file under
|
|||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
Core tests must not require live Weather API or Scriptorium services.
|
Core tests must not require live Weather API, Scriptorium, or distributor
|
||||||
|
services.
|
||||||
|
|
||||||
Preferred test patterns:
|
Preferred test patterns:
|
||||||
|
|
||||||
- fake command runners for subprocess behavior;
|
- fake command runners for subprocess behavior;
|
||||||
- `httptest.Server` for Weather API behavior;
|
- `httptest.Server` for Weather API behavior;
|
||||||
|
- fake distributor upload clients for notification behavior;
|
||||||
- filesystem temp directories for state behavior;
|
- filesystem temp directories for state behavior;
|
||||||
- deterministic clocks for report periods and RunIDs;
|
- deterministic clocks for report periods and RunIDs;
|
||||||
- table tests for config validation, CLI parsing, period resolution, and
|
- table tests for config validation, CLI parsing, period resolution, and
|
||||||
@@ -187,7 +197,8 @@ Update:
|
|||||||
behavior;
|
behavior;
|
||||||
- `docs/troubleshooting.md` for recurring operator-facing failure modes;
|
- `docs/troubleshooting.md` for recurring operator-facing failure modes;
|
||||||
- `docs/internal/` for component contracts and invariants;
|
- `docs/internal/` for component contracts and invariants;
|
||||||
- `docs/integrations/` for external Weather API or Scriptorium contract changes;
|
- `docs/integrations/` for external Weather API, Scriptorium, or distributor
|
||||||
|
contract changes;
|
||||||
- `docs/roadmap/` only for unimplemented or deferred work.
|
- `docs/roadmap/` only for unimplemented or deferred work.
|
||||||
|
|
||||||
Non-roadmap docs must describe implemented behavior only.
|
Non-roadmap docs must describe implemented behavior only.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
This roadmap contains project work that is not implemented. Current behavior is
|
This roadmap contains project work that is not implemented. Current behavior is
|
||||||
documented outside `docs/roadmap/`.
|
documented outside `docs/roadmap/`.
|
||||||
|
|
||||||
## Deferred: Automatic Storm Monitoring
|
## Automatic Storm Monitoring
|
||||||
|
|
||||||
Manual Storm Report generation is implemented through
|
Manual Storm Report generation is implemented through
|
||||||
`weatherreporter generate storm --start TIME --end TIME`. Automatic storm-event
|
`weatherreporter generate storm --start TIME --end TIME`. Automatic storm-event
|
||||||
@@ -34,7 +34,68 @@ Acceptance criteria before implementation:
|
|||||||
- evaluator failures are inspectable and do not create noisy report output;
|
- evaluator failures are inspectable and do not create noisy report output;
|
||||||
- manual Storm Report generation remains available.
|
- manual Storm Report generation remains available.
|
||||||
|
|
||||||
## Deferred: Alternate Runtime Integrations
|
## 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:
|
||||||
|
|
||||||
|
- another short-fuse planning report distinct from the implemented Hourly
|
||||||
|
Report, if a separate product is needed;
|
||||||
|
- event-specific reports with stable event IDs;
|
||||||
|
- storm review or yesterday-style reports using historical observations;
|
||||||
|
- archive-focused report variants if generated report history becomes a
|
||||||
|
first-class product.
|
||||||
|
|
||||||
|
Possible future modules:
|
||||||
|
|
||||||
|
- `hourly_table` for compact valid-period hourly facts;
|
||||||
|
- `forecast_delta` if a separate stanza is useful beyond current Recent
|
||||||
|
Changes;
|
||||||
|
- `weekend_planning` if weekend-specific planning guidance needs a dedicated
|
||||||
|
deterministic stanza;
|
||||||
|
- `storm_window_summary` if manual or automatic Storm Reports need a dedicated
|
||||||
|
prompt-facing storm-window module;
|
||||||
|
- separate AFD section aliases, such as `afd_key_messages`,
|
||||||
|
`afd_short_term_text`, and `afd_long_term_text`, if separate stanzas prove
|
||||||
|
more useful than `area_forecast_discussion.options.sections`;
|
||||||
|
- SPC, radar, QPF, snow/rain total, or historical-observation modules once
|
||||||
|
upstream sources and report requirements exist.
|
||||||
|
|
||||||
|
QPF fields such as `measurable_qpf_total_in` and `max_hourly_qpf_in` should
|
||||||
|
remain omitted until a real upstream quantitative precipitation source is
|
||||||
|
represented in `CollectedFacts`.
|
||||||
|
|
||||||
|
Future module work should preserve these boundaries:
|
||||||
|
|
||||||
|
- collect upstream facts once per report run;
|
||||||
|
- keep upstream fetching out of modules;
|
||||||
|
- keep broad reusable calculations in `DerivedFacts`;
|
||||||
|
- keep prompt-facing field shape inside module builders;
|
||||||
|
- use typed options for configurable module behavior;
|
||||||
|
- keep module snapshots structured and deterministic for Recent Changes.
|
||||||
|
|
||||||
|
## Distributor Notification Enhancements
|
||||||
|
|
||||||
|
Distributor notification currently uploads one managed Markdown report per
|
||||||
|
successful generated report through the configured HTTP upload pipeline.
|
||||||
|
|
||||||
|
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;
|
||||||
|
- 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,
|
||||||
|
while distributor owns destination routing and publication behavior.
|
||||||
|
|
||||||
|
## Alternate Runtime Integrations
|
||||||
|
|
||||||
These ideas are not current behavior:
|
These ideas are not current behavior:
|
||||||
|
|
||||||
@@ -44,12 +105,16 @@ These ideas are not current behavior:
|
|||||||
- multi-location selection;
|
- multi-location selection;
|
||||||
- daemon mode;
|
- daemon mode;
|
||||||
- multi-user authorization;
|
- multi-user authorization;
|
||||||
- plugin system.
|
- plugin system;
|
||||||
|
- dynamic module loading;
|
||||||
|
- user-defined module code;
|
||||||
|
- YAML-defined module schemas;
|
||||||
|
- 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.
|
||||||
|
|
||||||
## Deferred: Cleanup Refactors
|
## Cleanup Refactors
|
||||||
|
|
||||||
The initial cleanup pass intentionally left these refactors out because the
|
The initial cleanup pass intentionally left these refactors out because the
|
||||||
current implementation does not yet make them worth the added abstraction.
|
current implementation does not yet make them worth the added abstraction.
|
||||||
@@ -61,14 +126,12 @@ more expensive:
|
|||||||
- Weather API optional-source specification/helper refactor: consider when
|
- Weather API optional-source specification/helper refactor: consider when
|
||||||
additional Weather API sources make per-source fan-out, policy handling, and
|
additional Weather API sources make per-source fan-out, policy handling, and
|
||||||
provenance wiring repetitive enough to obscure adapter behavior.
|
provenance wiring repetitive enough to obscure adapter behavior.
|
||||||
- Broad briefing weather-signal consolidation: consider when multiple briefing
|
- Broad briefing weather-signal consolidation: consider when multiple module
|
||||||
builders repeatedly derive the same weather signals and tests begin to need
|
builders repeatedly derive the same weather signals and tests begin to need
|
||||||
coordinated fixture updates.
|
coordinated fixture updates.
|
||||||
- Generic workflow engine: defer unless generation, inspection, recovery, or
|
- Generic workflow engine: defer unless generation, inspection, recovery, or
|
||||||
future background workflows gain enough shared step semantics to justify a
|
future background workflows gain enough shared step semantics to justify a
|
||||||
declared execution model.
|
declared execution model.
|
||||||
- Plugin architecture: defer until there is a concrete external extension
|
|
||||||
contract and at least one implemented extension point.
|
|
||||||
- Cobra migration: defer while the standard-library CLI remains small,
|
- Cobra migration: defer while the standard-library CLI remains small,
|
||||||
explicit, and covered by parser tests.
|
explicit, and covered by parser tests.
|
||||||
- Manifest, resume, or progress system: defer until operators need resumable
|
- Manifest, resume, or progress system: defer until operators need resumable
|
||||||
@@ -81,5 +144,5 @@ more expensive:
|
|||||||
artifact output.
|
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, and adapter boundaries unless a separate roadmap
|
paths, report identities, module boundaries, and adapter boundaries unless a
|
||||||
explicitly changes them.
|
separate roadmap explicitly changes them.
|
||||||
|
|||||||
441
docs/roadmap/implementation.md
Normal file
441
docs/roadmap/implementation.md
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
# Tomorrow Report Implementation Roadmap
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This roadmap defines the staged implementation plan for
|
||||||
|
`docs/roadmap/tomorrow.md`. It is written for an LLM coding agent that will
|
||||||
|
implement each stage in order. The conceptual target state, user intent, and
|
||||||
|
locked product decisions live in `docs/roadmap/tomorrow.md`; this file defines
|
||||||
|
the concrete implementation sequence.
|
||||||
|
|
||||||
|
This is future-work planning. Do not treat the behavior described here as
|
||||||
|
implemented until the corresponding code, tests, examples, and non-roadmap docs
|
||||||
|
are updated.
|
||||||
|
|
||||||
|
## Implementation Guardrails
|
||||||
|
|
||||||
|
- Preserve public CLI syntax: `weatherreporter generate tomorrow`.
|
||||||
|
- Make a clean pre-release break from report ID `daily_tomorrow`; do not add
|
||||||
|
compatibility aliases.
|
||||||
|
- Keep report identity, prompt IDs, template IDs, artifact groups, output names,
|
||||||
|
and comparison policy centralized in `internal/report`.
|
||||||
|
- Reuse the existing `generated_text_template` workflow implemented for Hourly.
|
||||||
|
- Keep Scriptorium details behind the existing adapter boundary.
|
||||||
|
- Keep Go responsible for deterministic facts, valid periods, module snapshots,
|
||||||
|
structured generated-text validation, and final Markdown template rendering.
|
||||||
|
- Keep templates responsible for wording and layout.
|
||||||
|
- Keep generated JSON schemas and Markdown templates as embedded asset files,
|
||||||
|
not inline Go strings.
|
||||||
|
- Preserve current managed artifact behavior except where report identity
|
||||||
|
intentionally changes from `daily_tomorrow` to `tomorrow`.
|
||||||
|
|
||||||
|
## Stage 1: Report Identity Split
|
||||||
|
|
||||||
|
Goal: make Tomorrow an independent report ID and artifact identity while
|
||||||
|
preserving the public `generate tomorrow` command.
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
|
||||||
|
- Replace `report.DailyTomorrow` with `report.Tomorrow` whose value is
|
||||||
|
`"tomorrow"`.
|
||||||
|
- Rename report-definition helpers and resolvers around the new identity:
|
||||||
|
`dailyTomorrowDefinition` to `tomorrowDefinition`,
|
||||||
|
`dailyTomorrowModules` to `tomorrowModules`, and
|
||||||
|
`resolveDailyTomorrow` to `resolveTomorrow`.
|
||||||
|
- Update `report.DefaultRegistry`, `Registry.All`, batch resolution, and tests
|
||||||
|
so the built-in report order contains `tomorrow` instead of
|
||||||
|
`daily_tomorrow`.
|
||||||
|
- Update `internal/app` so `app.ReportTomorrow` resolves to `report.Tomorrow`.
|
||||||
|
- Keep the valid period as the next local civil day.
|
||||||
|
- Keep evening batch behavior: `run evening` should still generate the Tomorrow
|
||||||
|
report.
|
||||||
|
- Change Tomorrow definition identity fields to:
|
||||||
|
- `ID: report.Tomorrow`
|
||||||
|
- `Name: "Tomorrow Report"`
|
||||||
|
- `ArtifactGroup: "tomorrow"`
|
||||||
|
- `BatchOutputName: "tomorrow.md"`
|
||||||
|
- `Generated: true`
|
||||||
|
- `CompatiblePriorIDs: []report.ID{report.Tomorrow}`
|
||||||
|
- `ComparisonStrategy: report.CompareSameValidDate`
|
||||||
|
- Keep the current Tomorrow module composition initially, renamed to
|
||||||
|
`tomorrowModules`, so the prompt data package continues to include the
|
||||||
|
daypart, precipitation, alert, SPC, AFD, weather story, tomorrow planning,
|
||||||
|
and hourly facts already available.
|
||||||
|
- Update all code and tests that assert `daily_tomorrow` paths, metadata,
|
||||||
|
RunIDs, prior compatibility, or registry IDs.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- No production-code references to `report.DailyTomorrow` or report ID
|
||||||
|
`daily_tomorrow` remain.
|
||||||
|
- `weatherreporter generate tomorrow` still parses and resolves successfully.
|
||||||
|
- Evening batch contains `tomorrow`.
|
||||||
|
- Managed workspace artifact paths and distributor identity values now use
|
||||||
|
`tomorrow`.
|
||||||
|
|
||||||
|
Suggested validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/report ./internal/app ./internal/cli ./internal/state
|
||||||
|
```
|
||||||
|
|
||||||
|
This stage is suitable for one implementation prompt.
|
||||||
|
|
||||||
|
## Stage 2: Tomorrow GeneratedText Contract
|
||||||
|
|
||||||
|
Goal: add structured Tomorrow LLM output validation and schema assets.
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
|
||||||
|
- Add `internal/generatedtext/tomorrow.go` with:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Tomorrow struct {
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
ForecastDiscussion []string `json:"forecast_discussion"`
|
||||||
|
PrecipitationTiming string `json:"precipitation_timing,omitempty"`
|
||||||
|
Confidence string `json:"confidence,omitempty"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Add `generatedtext.ValidateTomorrow`.
|
||||||
|
- Use `json.Decoder.DisallowUnknownFields`.
|
||||||
|
- Reject multiple JSON values.
|
||||||
|
- Trim `Summary`, `PrecipitationTiming`, `Confidence`, and each
|
||||||
|
`ForecastDiscussion` paragraph.
|
||||||
|
- Drop blank discussion paragraphs after trimming, then require at least one
|
||||||
|
remaining paragraph.
|
||||||
|
- Reject blank `Summary`.
|
||||||
|
- Return normalized JSON with the same public field names.
|
||||||
|
- Add `internal/reporttemplate/schemas/tomorrow.generated_text.schema.json`.
|
||||||
|
The schema should:
|
||||||
|
- require `summary`;
|
||||||
|
- require `forecast_discussion`;
|
||||||
|
- define `forecast_discussion` as an array of strings with at least one item;
|
||||||
|
- allow optional `precipitation_timing` and `confidence`;
|
||||||
|
- reject additional properties.
|
||||||
|
- Add `internal/reporttemplate/prompts/tomorrow.generated_text.md` as the
|
||||||
|
maintained Scriptorium prompt source asset. This file is a source contract for
|
||||||
|
out-of-band Scriptorium prompt registration; weatherreporter does not need to
|
||||||
|
load prompt Markdown at runtime.
|
||||||
|
- Add Tomorrow to `internal/reporttemplate` schema lookup.
|
||||||
|
- Update app generated-text validation dispatch so it can return either
|
||||||
|
`generatedtext.Hourly` or `generatedtext.Tomorrow`. Prefer a small generic
|
||||||
|
dispatch shape such as:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func validateGeneratedText(def report.Definition, data []byte) (any, []byte, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then type-check the returned value in render-context dispatch.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Tomorrow generated text rejects unknown fields, missing required fields,
|
||||||
|
blank summary, and no usable forecast discussion paragraphs.
|
||||||
|
- Optional `precipitation_timing` and `confidence` are trimmed and omitted from
|
||||||
|
normalized JSON when empty.
|
||||||
|
- Hourly generated text behavior is unchanged.
|
||||||
|
|
||||||
|
Suggested validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/generatedtext ./internal/reporttemplate ./internal/app
|
||||||
|
```
|
||||||
|
|
||||||
|
This stage is suitable for one implementation prompt.
|
||||||
|
|
||||||
|
## Stage 3: Daypart Presentation Fields
|
||||||
|
|
||||||
|
Goal: add only the daypart fields needed to keep the Tomorrow template
|
||||||
|
composable without moving prose construction into Go.
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
|
||||||
|
- Extend the `derived_daypart_summaries` module output with presentation
|
||||||
|
helpers that are facts, not complete sentences:
|
||||||
|
- `display_name`, for example `Morning`;
|
||||||
|
- `dominant_condition_lower`, for inline template text;
|
||||||
|
- `temperature_phrase_f`, such as `low 70s`, `upper 60s`, or
|
||||||
|
`upper 60s to mid-70s`;
|
||||||
|
- `mention_precipitation`, true when max PoP is at or above the existing
|
||||||
|
hourly forecast precipitation mention threshold, currently 20%;
|
||||||
|
- `max_pop_time_label`, using friendly local hour format such as `8:00 AM`
|
||||||
|
when max PoP time exists.
|
||||||
|
- Reuse the existing hourly forecast precipitation mention threshold constant
|
||||||
|
rather than adding a user config field in this change.
|
||||||
|
- Keep existing structured numeric fields in the module output.
|
||||||
|
- Do not add a prewritten `daypart_line` string.
|
||||||
|
- Do not add wind prose in this stage unless tests show the initial template
|
||||||
|
needs a specific structured wind fact. If wind wording is needed, add a
|
||||||
|
small structured wind field, not a full sentence.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Daypart module output has enough structured fields for a readable Tomorrow
|
||||||
|
template.
|
||||||
|
- The output remains useful for YAML prompt packages.
|
||||||
|
- No generated prose sentence is hard-coded into the module.
|
||||||
|
|
||||||
|
Suggested validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/briefing ./internal/promptinput
|
||||||
|
```
|
||||||
|
|
||||||
|
This stage is suitable for one implementation prompt.
|
||||||
|
|
||||||
|
## Stage 4: Tomorrow Render Context And Template
|
||||||
|
|
||||||
|
Goal: render Tomorrow Markdown from structured generated text, module outputs,
|
||||||
|
and report metadata.
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
|
||||||
|
- Add a dedicated Tomorrow render context under `internal/generatedtext`, for
|
||||||
|
example:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type TomorrowRenderContext struct {
|
||||||
|
Report TomorrowReportContext
|
||||||
|
GeneratedText Tomorrow
|
||||||
|
Modules TomorrowTemplateModules
|
||||||
|
Collected facts.CollectedFacts
|
||||||
|
Derived facts.DerivedFacts
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Add `TomorrowReportContext` with:
|
||||||
|
- `Title`, for example `Sunday's Weather`;
|
||||||
|
- `ForecastDate`;
|
||||||
|
- `ForecastDateLabel`, for example `Sunday, June 15, 2026`;
|
||||||
|
- `ForecastDayName`, for example `Sunday`;
|
||||||
|
- `GeneratedAt`;
|
||||||
|
- `GeneratedAtLabel`, for example
|
||||||
|
`Saturday, June 14, 2026 at 9:14 AM`;
|
||||||
|
- `ValidPeriod`;
|
||||||
|
- `Timezone`.
|
||||||
|
- Construct `Title` in Go, not in the template.
|
||||||
|
- Add `TomorrowTemplateModules` with pointer fields for the module outputs used
|
||||||
|
by the template:
|
||||||
|
- `Metadata`;
|
||||||
|
- `DerivedDailySummary`;
|
||||||
|
- `DerivedDaypartSummaries`;
|
||||||
|
- `PrecipTiming`;
|
||||||
|
- `AlertDigest`;
|
||||||
|
- `SPCConvectiveOutlooks`;
|
||||||
|
- `AreaForecastDiscussion`;
|
||||||
|
- `SPCConvectiveDiscussion`;
|
||||||
|
- `WeatherStory`;
|
||||||
|
- `TomorrowPlanning`.
|
||||||
|
- Add an ordered daypart slice for the template, derived from the configured
|
||||||
|
daypart order rather than ranging directly over a map. This can live in
|
||||||
|
`TomorrowTemplateModules`, for example `Dayparts []TomorrowDaypartContext`.
|
||||||
|
- Add `BuildTomorrowRenderContext`.
|
||||||
|
- Add `internal/reporttemplate/templates/tomorrow.md.tmpl`.
|
||||||
|
- Add Tomorrow to `internal/reporttemplate` template lookup.
|
||||||
|
- Template shape:
|
||||||
|
- title;
|
||||||
|
- forecast date;
|
||||||
|
- generated timestamp;
|
||||||
|
- `GeneratedText.Summary`;
|
||||||
|
- deterministic `Daypart Forecast` bullets in configured order;
|
||||||
|
- conditional `Precipitation Timing` only when precipitation windows exist;
|
||||||
|
- deterministic precipitation window bullets before optional
|
||||||
|
`GeneratedText.PrecipitationTiming`;
|
||||||
|
- `Forecast Discussion` with one paragraph per
|
||||||
|
`GeneratedText.ForecastDiscussion` item.
|
||||||
|
- Keep current conditions and hourly forecast available through the data
|
||||||
|
package and render context, but do not render them in the initial Tomorrow
|
||||||
|
template unless the template explicitly uses them.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- The template renders without map-order nondeterminism.
|
||||||
|
- Precipitation Timing is omitted when no precipitation windows exist.
|
||||||
|
- Forecast Discussion supports multiple paragraphs.
|
||||||
|
- Missing optional modules produce clean omission or fallback behavior, not
|
||||||
|
template execution errors.
|
||||||
|
|
||||||
|
Suggested validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/generatedtext ./internal/reporttemplate ./internal/briefing
|
||||||
|
```
|
||||||
|
|
||||||
|
This stage is suitable for one implementation prompt.
|
||||||
|
|
||||||
|
## Stage 5: App Workflow Integration
|
||||||
|
|
||||||
|
Goal: route Tomorrow through the generated-text-template workflow end to end.
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
|
||||||
|
- Change Tomorrow report definition to:
|
||||||
|
- `PromptID: "weather.tomorrow_generated_text"`
|
||||||
|
- `GenerationMode: report.GenerationModeGeneratedTextTemplate`
|
||||||
|
- `TemplateID: "tomorrow"`
|
||||||
|
- `GeneratedTextSchemaID: "tomorrow"`
|
||||||
|
- Update `internal/app.buildRenderContext` dispatch:
|
||||||
|
- hourly template requires `generatedtext.Hourly`;
|
||||||
|
- tomorrow template requires `generatedtext.Tomorrow`;
|
||||||
|
- unsupported type/template combinations return actionable errors.
|
||||||
|
- Ensure Scriptorium `run` writes raw generated text to a `.json` path for
|
||||||
|
Tomorrow, matching the existing generated-text-template workflow.
|
||||||
|
- Ensure normalized generated text, render context JSON, generated Markdown,
|
||||||
|
metadata, and final report artifacts are persisted through existing state
|
||||||
|
helpers.
|
||||||
|
- Ensure app errors include report ID `tomorrow` and RunID context.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- `weatherreporter generate tomorrow` no longer invokes Scriptorium for full
|
||||||
|
Markdown.
|
||||||
|
- The workflow validates Scriptorium JSON output, builds a Tomorrow render
|
||||||
|
context, and renders Markdown locally.
|
||||||
|
- Hourly generated-text-template behavior remains unchanged.
|
||||||
|
|
||||||
|
Suggested validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/app ./internal/state ./internal/adapters/scriptorium
|
||||||
|
```
|
||||||
|
|
||||||
|
This stage is suitable for one implementation prompt.
|
||||||
|
|
||||||
|
## Stage 6: CLI, State, Distributor, And Batch Behavior
|
||||||
|
|
||||||
|
Goal: update cross-package behavior affected by the report ID clean break.
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
|
||||||
|
- Update CLI tests and command-output expectations for `generate tomorrow`.
|
||||||
|
- Update state/path tests so managed artifacts, snapshots, data packages,
|
||||||
|
generated-text artifacts, render contexts, and report files use artifact group
|
||||||
|
`tomorrow`.
|
||||||
|
- Update batch tests:
|
||||||
|
- evening batch emits report ID `tomorrow`;
|
||||||
|
- batch output copy name remains `tomorrow.md`;
|
||||||
|
- partial-failure behavior is unchanged.
|
||||||
|
- Update Recent Changes tests:
|
||||||
|
- Tomorrow compares only against prior `tomorrow` snapshots;
|
||||||
|
- Daily Today no longer treats Tomorrow as a compatible prior unless the
|
||||||
|
implementation explicitly keeps that relationship for Daily Today only.
|
||||||
|
- Update distributor notification tests so rendered template variables use:
|
||||||
|
- `report_id=tomorrow`;
|
||||||
|
- `artifact_group=tomorrow`;
|
||||||
|
- `batch_output_name=tomorrow.md`.
|
||||||
|
- Update config tests so report module overrides use `reports.tomorrow`.
|
||||||
|
Do not accept `reports.daily_tomorrow` unless a future explicit
|
||||||
|
compatibility decision reverses the clean break.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Public CLI syntax is stable.
|
||||||
|
- Persisted artifacts and distributor request context use the new report ID.
|
||||||
|
- Batch behavior is unchanged except for the new ID.
|
||||||
|
- No tests rely on `daily_tomorrow`.
|
||||||
|
|
||||||
|
Suggested validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/cli ./internal/app ./internal/state ./internal/config ./internal/report
|
||||||
|
```
|
||||||
|
|
||||||
|
This stage is suitable for one implementation prompt.
|
||||||
|
|
||||||
|
## Stage 7: Documentation And Examples
|
||||||
|
|
||||||
|
Goal: align implemented docs and maintained examples after the code change.
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
|
||||||
|
- Update non-roadmap docs only after the behavior is implemented.
|
||||||
|
- Inspect and update:
|
||||||
|
- `docs/cli.md`;
|
||||||
|
- `docs/config.md`;
|
||||||
|
- `docs/operations.md`;
|
||||||
|
- `docs/troubleshooting.md`, if new failure modes are introduced;
|
||||||
|
- `docs/internal/report-registry.md`;
|
||||||
|
- `docs/internal/generatedtext.md`;
|
||||||
|
- `docs/internal/reporttemplate.md`;
|
||||||
|
- `docs/internal/state.md`;
|
||||||
|
- `docs/templates.md`;
|
||||||
|
- relevant Scriptorium and distributor integration docs only if their
|
||||||
|
weatherreporter-facing contract changed.
|
||||||
|
- Update `examples/config.yml` if it references Tomorrow modules or
|
||||||
|
`reports.daily_tomorrow`.
|
||||||
|
- Keep future Today/Daily split language only under `docs/roadmap/`.
|
||||||
|
- Do not document unimplemented Today or generic Daily products as current
|
||||||
|
behavior.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Non-roadmap docs describe implemented behavior only.
|
||||||
|
- Docs use report ID `tomorrow`.
|
||||||
|
- Examples load under current config validation.
|
||||||
|
- No stale user-facing references to `daily_tomorrow` remain outside historical
|
||||||
|
roadmap context.
|
||||||
|
|
||||||
|
Suggested validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "daily_tomorrow|Daily Tomorrow|weather.daily_report" README.md docs examples internal
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
This stage is suitable for one implementation prompt.
|
||||||
|
|
||||||
|
## Stage 8: Final Validation
|
||||||
|
|
||||||
|
Goal: verify the completed cutover as a coherent behavior change.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/report ./internal/generatedtext ./internal/reporttemplate ./internal/briefing ./internal/app ./internal/cli ./internal/state
|
||||||
|
go test ./...
|
||||||
|
go run ./cmd/weatherreporter --help
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Also run targeted stale-symbol checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "DailyTomorrow|dailyTomorrow|daily_tomorrow" internal docs examples
|
||||||
|
rg -n "weather.tomorrow_generated_text|TemplateID:.*tomorrow|GeneratedTextSchemaID:.*tomorrow" internal docs
|
||||||
|
```
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Full test suite passes.
|
||||||
|
- Help output still shows `generate tomorrow`.
|
||||||
|
- No production-code stale `daily_tomorrow` symbols remain.
|
||||||
|
- Generated-text-template artifacts for Tomorrow are persisted in the same
|
||||||
|
categories as Hourly.
|
||||||
|
- Existing Hourly behavior still passes tests.
|
||||||
|
|
||||||
|
This stage is suitable for one implementation prompt.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None block implementation. The required decisions are locked by
|
||||||
|
`docs/roadmap/tomorrow.md` and this implementation plan:
|
||||||
|
|
||||||
|
- Tomorrow uses report ID `tomorrow`.
|
||||||
|
- Tomorrow uses prompt ID `weather.tomorrow_generated_text`.
|
||||||
|
- Tomorrow uses template ID and generated-text schema ID `tomorrow`.
|
||||||
|
- Tomorrow forecast discussion is an array of paragraph strings.
|
||||||
|
- `daily_tomorrow` compatibility aliases are intentionally not preserved.
|
||||||
|
|
||||||
|
## Global Validation Checklist
|
||||||
|
|
||||||
|
- `go test ./...`
|
||||||
|
- `go run ./cmd/weatherreporter --help`
|
||||||
|
- `git diff --check`
|
||||||
|
- `rg -n "DailyTomorrow|dailyTomorrow|daily_tomorrow" internal docs examples`
|
||||||
|
- Confirm `weatherreporter generate tomorrow` uses structured JSON from
|
||||||
|
Scriptorium and renders final Markdown locally.
|
||||||
|
- Confirm distributor notification context uses `report_id=tomorrow` and
|
||||||
|
`artifact_group=tomorrow`.
|
||||||
|
- Confirm examples contain no unimplemented fields and no secrets.
|
||||||
276
docs/roadmap/tomorrow.md
Normal file
276
docs/roadmap/tomorrow.md
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
# Tomorrow Report Roadmap
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This roadmap defines the target state for making Tomorrow an independent
|
||||||
|
generated-text-template report. The feature is not implemented yet, so this
|
||||||
|
document lives under `docs/roadmap/`.
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
|
||||||
|
Tomorrow should become its own report product, not a variant of the Daily
|
||||||
|
Report. The current CLI command `weatherreporter generate tomorrow` should
|
||||||
|
remain, but the internal report ID, prompt ID, template, schema, workspace
|
||||||
|
paths, and distributor identity should use `tomorrow`.
|
||||||
|
|
||||||
|
The report should combine deterministic daypart and precipitation facts with
|
||||||
|
LLM prose for the high-level summary, optional precipitation context, and
|
||||||
|
forecast discussion. The resulting Markdown should be predictable and
|
||||||
|
template-driven, similar to the implemented Hourly Report.
|
||||||
|
|
||||||
|
Longer term, Today, Tomorrow, and Daily may all become separate report products
|
||||||
|
with different prompts, templates, and deterministic sections. This roadmap
|
||||||
|
starts that split with Tomorrow.
|
||||||
|
|
||||||
|
## Target Report Shape
|
||||||
|
|
||||||
|
Example structure:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Sunday's Weather
|
||||||
|
|
||||||
|
**Forecast Date:** Sunday, June 15, 2026
|
||||||
|
**Generated:** Saturday, June 14, 2026 at 9:14 AM
|
||||||
|
|
||||||
|
<GeneratedText summary>
|
||||||
|
|
||||||
|
## Daypart Forecast
|
||||||
|
|
||||||
|
- **Overnight:** <deterministic daypart line>
|
||||||
|
- **Morning:** <deterministic daypart line>
|
||||||
|
- **Midday:** <deterministic daypart line>
|
||||||
|
- **Afternoon:** <deterministic daypart line>
|
||||||
|
- **Evening:** <deterministic daypart line>
|
||||||
|
|
||||||
|
## Precipitation Timing
|
||||||
|
|
||||||
|
- **1:00 AM** to **5:00 AM**: Precipitation is expected during this period. The peak precipitation chance is 59% at 2:00 AM.
|
||||||
|
- <optional GeneratedText precipitation_timing>
|
||||||
|
|
||||||
|
## Forecast Discussion
|
||||||
|
|
||||||
|
<GeneratedText forecast_discussion paragraphs>
|
||||||
|
```
|
||||||
|
|
||||||
|
`Precipitation Timing` should render only when at least one precipitation
|
||||||
|
window exists for the valid period. The threshold for precipitation windows
|
||||||
|
remains the existing precipitation-window threshold, currently 40%.
|
||||||
|
|
||||||
|
## Locked Decisions
|
||||||
|
|
||||||
|
- Replace report ID `daily_tomorrow` with `tomorrow`.
|
||||||
|
- Do not preserve compatibility aliases for `daily_tomorrow`; this is a
|
||||||
|
pre-release clean break.
|
||||||
|
- Keep public CLI syntax: `weatherreporter generate tomorrow`.
|
||||||
|
- Use generated-text-template generation for Tomorrow, not full Markdown
|
||||||
|
generation by Scriptorium.
|
||||||
|
- Use a dedicated Scriptorium prompt ID, template ID, and schema ID:
|
||||||
|
- prompt ID: `weather.tomorrow_generated_text`
|
||||||
|
- template ID: `tomorrow`
|
||||||
|
- generated-text schema ID: `tomorrow`
|
||||||
|
- Use `ArtifactGroup: "tomorrow"` and `BatchOutputName: "tomorrow.md"`.
|
||||||
|
- Use `CompatiblePriorIDs: []report.ID{report.Tomorrow}`.
|
||||||
|
- Keep valid-period behavior: Tomorrow covers the next local civil day.
|
||||||
|
- Keep Morning/Evening batch behavior unless explicitly changed later; evening
|
||||||
|
batch should still include Tomorrow.
|
||||||
|
- Future Today/Daily split is out of scope for this roadmap.
|
||||||
|
|
||||||
|
## GeneratedText Contract
|
||||||
|
|
||||||
|
Add a Tomorrow GeneratedText schema:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"summary": "string",
|
||||||
|
"forecast_discussion": ["string"],
|
||||||
|
"precipitation_timing": "string",
|
||||||
|
"confidence": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Required:
|
||||||
|
|
||||||
|
- `summary`
|
||||||
|
- `forecast_discussion`
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
- `precipitation_timing`
|
||||||
|
- `confidence`
|
||||||
|
|
||||||
|
`forecast_discussion` should be an array of paragraph strings so Scriptorium
|
||||||
|
can return multi-paragraph discussion without embedding paragraph delimiters in
|
||||||
|
one string. Empty or whitespace-only discussion paragraphs should be rejected or
|
||||||
|
trimmed out during validation; after trimming, at least one paragraph is
|
||||||
|
required.
|
||||||
|
|
||||||
|
`confidence` may be validated and persisted but does not need to render in the
|
||||||
|
initial template.
|
||||||
|
|
||||||
|
## Template Context
|
||||||
|
|
||||||
|
Add a dedicated Tomorrow render context rather than reusing Hourly context
|
||||||
|
types.
|
||||||
|
|
||||||
|
Recommended top-level shape:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type TomorrowRenderContext struct {
|
||||||
|
Report TomorrowReportContext
|
||||||
|
GeneratedText Tomorrow
|
||||||
|
Modules TomorrowTemplateModules
|
||||||
|
Collected facts.CollectedFacts
|
||||||
|
Derived facts.DerivedFacts
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`TomorrowReportContext` should include:
|
||||||
|
|
||||||
|
- `Title`: for example `Sunday's Weather`
|
||||||
|
- `ForecastDate`: canonical local forecast date if useful
|
||||||
|
- `ForecastDateLabel`: for example `Sunday, June 15, 2026`
|
||||||
|
- `ForecastDayName`: for example `Sunday`
|
||||||
|
- `GeneratedAt`
|
||||||
|
- `GeneratedAtLabel`: for example `Saturday, June 14, 2026 at 9:14 AM`
|
||||||
|
- `ValidPeriod`
|
||||||
|
- `Timezone`
|
||||||
|
|
||||||
|
Do not derive the possessive title in the template. Go should provide `Title`
|
||||||
|
so wording is consistent and easy to test.
|
||||||
|
|
||||||
|
`TomorrowTemplateModules` should expose the module outputs needed by the
|
||||||
|
template:
|
||||||
|
|
||||||
|
- `Metadata`
|
||||||
|
- `DerivedDailySummary`
|
||||||
|
- `DerivedDaypartSummaries`
|
||||||
|
- `PrecipTiming`
|
||||||
|
- `AlertDigest`
|
||||||
|
- `SPCConvectiveOutlooks`
|
||||||
|
- `AreaForecastDiscussion`
|
||||||
|
- `SPCConvectiveDiscussion`
|
||||||
|
- `WeatherStory`
|
||||||
|
- `TomorrowPlanning`, if still useful
|
||||||
|
|
||||||
|
Current conditions and hourly forecast can remain in the module snapshot and
|
||||||
|
data package if useful for Scriptorium, but they do not need to render in the
|
||||||
|
initial Tomorrow template unless a later design calls for them.
|
||||||
|
|
||||||
|
## Daypart Forecast
|
||||||
|
|
||||||
|
The Daypart Forecast should be deterministic but composable. Avoid adding a
|
||||||
|
single prewritten Go `DaypartLine` string that makes template wording rigid.
|
||||||
|
|
||||||
|
Add presentation-friendly fields to `derived_daypart_summaries` only where they
|
||||||
|
avoid awkward template logic. Likely useful fields:
|
||||||
|
|
||||||
|
- display name, such as `Overnight` or `Morning`;
|
||||||
|
- lower-case dominant condition text for inline sentences;
|
||||||
|
- rounded temperature range phrase if available;
|
||||||
|
- precipitation mention flag using the existing hourly line mention threshold
|
||||||
|
concept, currently 20%;
|
||||||
|
- max precipitation probability and friendly max time;
|
||||||
|
- optional wind phrase or wind range only if deterministic wind wording is
|
||||||
|
clearly needed.
|
||||||
|
|
||||||
|
The initial implementation may keep daypart bullet wording simple. It should be
|
||||||
|
easy to revise the template text without editing Go unless new facts are
|
||||||
|
needed.
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
1. Report identity split
|
||||||
|
- Rename `report.DailyTomorrow` to `report.Tomorrow` with ID `tomorrow`.
|
||||||
|
- Update registry order, resolver references, CLI mapping, batch selection,
|
||||||
|
state metadata expectations, docs, and tests.
|
||||||
|
- Preserve `weatherreporter generate tomorrow`.
|
||||||
|
- Accept that workspace paths, RunIDs, distributor bundle IDs, and report
|
||||||
|
URLs change from `daily_tomorrow` to `tomorrow`.
|
||||||
|
|
||||||
|
2. GeneratedText contract
|
||||||
|
- Add `generatedtext.Tomorrow`, validation, normalized JSON output, and
|
||||||
|
tests.
|
||||||
|
- Add `tomorrow.generated_text.schema.json`.
|
||||||
|
- Add `internal/reporttemplate/prompts/tomorrow.generated_text.md` as the
|
||||||
|
maintained prompt source asset.
|
||||||
|
- Update app validation dispatch to use the Tomorrow schema.
|
||||||
|
|
||||||
|
3. Template and render context
|
||||||
|
- Add `internal/reporttemplate/templates/tomorrow.md.tmpl`.
|
||||||
|
- Add Tomorrow template/schema lookup entries.
|
||||||
|
- Add `BuildTomorrowRenderContext`.
|
||||||
|
- Update app render-context dispatch for template ID `tomorrow`.
|
||||||
|
- Persist render context in the existing generated-text-template workflow.
|
||||||
|
|
||||||
|
4. Module presentation fields
|
||||||
|
- Add only the daypart presentation fields needed by the template.
|
||||||
|
- Reuse the existing precipitation window hour-label fields.
|
||||||
|
- Keep deterministic weather derivation in Go and wording/layout in the
|
||||||
|
template.
|
||||||
|
|
||||||
|
5. Report definition conversion
|
||||||
|
- Change Tomorrow report definition to:
|
||||||
|
- `PromptID: "weather.tomorrow_generated_text"`
|
||||||
|
- `GenerationMode: generated_text_template`
|
||||||
|
- `TemplateID: "tomorrow"`
|
||||||
|
- `GeneratedTextSchemaID: "tomorrow"`
|
||||||
|
- `ArtifactGroup: "tomorrow"`
|
||||||
|
- `BatchOutputName: "tomorrow.md"`
|
||||||
|
- compatible prior IDs containing only `tomorrow`
|
||||||
|
- Review module composition and keep only modules used by the prompt,
|
||||||
|
template, or future inspection value.
|
||||||
|
|
||||||
|
6. Documentation and examples
|
||||||
|
- After implementation, update non-roadmap docs for implemented behavior:
|
||||||
|
`docs/cli.md`, `docs/operations.md`, `docs/internal/report-registry.md`,
|
||||||
|
`docs/internal/generatedtext.md`, `docs/internal/reporttemplate.md`, and
|
||||||
|
`docs/templates.md`.
|
||||||
|
- Update examples that refer to `reports.tomorrow` or report module
|
||||||
|
overrides if the config key changes.
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
- Report tests:
|
||||||
|
- registry contains `tomorrow`, not `daily_tomorrow`;
|
||||||
|
- `generate tomorrow` resolves report ID `tomorrow`;
|
||||||
|
- valid period remains next local civil day;
|
||||||
|
- evening batch still includes Tomorrow;
|
||||||
|
- RunID and artifact paths use `tomorrow`.
|
||||||
|
- GeneratedText tests:
|
||||||
|
- `summary` and non-empty `forecast_discussion` are required;
|
||||||
|
- `forecast_discussion` trims paragraph strings and rejects/omits blanks;
|
||||||
|
- optional `precipitation_timing` and `confidence` normalize correctly;
|
||||||
|
- unknown fields are rejected.
|
||||||
|
- Template tests:
|
||||||
|
- title renders as `<weekday>'s Weather`;
|
||||||
|
- forecast date and generated labels render;
|
||||||
|
- daypart bullets render in configured daypart order;
|
||||||
|
- precipitation section is omitted when no precipitation windows exist;
|
||||||
|
- precipitation section includes deterministic windows and optional LLM text
|
||||||
|
when windows exist;
|
||||||
|
- forecast discussion renders multiple paragraphs.
|
||||||
|
- App/CLI workflow tests:
|
||||||
|
- `weatherreporter generate tomorrow` uses structured Scriptorium output and
|
||||||
|
the template renderer;
|
||||||
|
- raw generated text, validated generated text, render context, report, and
|
||||||
|
metadata artifacts are persisted;
|
||||||
|
- optional `--out` behavior remains unchanged;
|
||||||
|
- distributor notification uses report ID/artifact group `tomorrow`.
|
||||||
|
|
||||||
|
Validation commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/report ./internal/generatedtext ./internal/reporttemplate ./internal/briefing ./internal/app ./internal/cli ./internal/state
|
||||||
|
go test ./...
|
||||||
|
go run ./cmd/weatherreporter --help
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None block implementation. Recommended defaults are:
|
||||||
|
|
||||||
|
- make `forecast_discussion` an array of strings for Tomorrow;
|
||||||
|
- keep Hourly GeneratedText unchanged for now;
|
||||||
|
- do not add Daily Today or generic Daily report splits in this change;
|
||||||
|
- do not preserve `daily_tomorrow` compatibility aliases.
|
||||||
301
docs/templates.md
Normal file
301
docs/templates.md
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
# 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 current implemented templates are:
|
||||||
|
|
||||||
|
- `internal/reporttemplate/templates/tomorrow.md.tmpl`
|
||||||
|
- `internal/reporttemplate/templates/hourly.md.tmpl`
|
||||||
|
|
||||||
|
Templates are rendered from structured contexts such as `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 currently registered.
|
||||||
|
- 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 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
| 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.ThunderMentioned` | bool | Whether thunder is mentioned in the forecast text. |
|
||||||
|
|
||||||
|
### 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. |
|
||||||
|
|
||||||
|
### 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.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 currently exercise the hourly template through
|
||||||
|
`internal/generatedtext/render_context_test.go` and
|
||||||
|
`internal/reporttemplate/reporttemplate_test.go`.
|
||||||
@@ -185,6 +185,52 @@ 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: 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: 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.
|
||||||
@@ -195,10 +241,115 @@ Diagnostic: inspect stdout for the JSON summary and stderr for compact status
|
|||||||
lines.
|
lines.
|
||||||
|
|
||||||
Safe fix: use the failed report's artifact paths from the summary, then inspect
|
Safe fix: use the failed report's artifact paths from the summary, then inspect
|
||||||
metadata, sources, briefing, and data package for that RunID.
|
metadata, sources, module snapshot, and data package for that RunID.
|
||||||
|
|
||||||
Relevant docs: [CLI reference](cli.md), [Operations guide](operations.md).
|
Relevant docs: [CLI reference](cli.md), [Operations guide](operations.md).
|
||||||
|
|
||||||
|
## Invalid Secrets Directory
|
||||||
|
|
||||||
|
Symptom: config loading fails with `read secrets directory`, `secret file`, or
|
||||||
|
environment variable name context.
|
||||||
|
|
||||||
|
Likely cause: `secrets.directory` points to a missing directory or contains an
|
||||||
|
invalid entry. Secret entries must be regular files directly under the
|
||||||
|
configured directory, and file basenames must match
|
||||||
|
`[A-Za-z_][A-Za-z0-9_]*`.
|
||||||
|
|
||||||
|
Diagnostic: list the configured directory and inspect entry names and file
|
||||||
|
types. Do not print secret file contents.
|
||||||
|
|
||||||
|
Safe fix: create the directory, remove subdirectories or symlinks, fix invalid
|
||||||
|
filenames, and ensure the weatherreporter process can read each secret file.
|
||||||
|
|
||||||
|
Relevant docs: [Configuration reference](config.md).
|
||||||
|
|
||||||
|
## Distributor Token Is Missing
|
||||||
|
|
||||||
|
Symptom: notification fails with a message that the distributor token
|
||||||
|
environment variable is not set.
|
||||||
|
|
||||||
|
Likely cause: `notify.distributor.enabled` is true, but the environment
|
||||||
|
variable named by `notify.distributor.token_env` was not populated directly or
|
||||||
|
through `secrets.directory`.
|
||||||
|
|
||||||
|
Diagnostic: check `notify.distributor.token_env`, then verify a matching secret
|
||||||
|
file exists under `secrets.directory` or that the process environment includes
|
||||||
|
the variable. Do not print the token value.
|
||||||
|
|
||||||
|
Safe fix: create a readable secret file whose basename matches `token_env`, or
|
||||||
|
set the environment variable through the service manager.
|
||||||
|
|
||||||
|
Relevant docs: [Configuration reference](config.md),
|
||||||
|
[Operations guide](operations.md).
|
||||||
|
|
||||||
|
## Distributor Upload Conflict
|
||||||
|
|
||||||
|
Symptom: notification fails with idempotency conflict context.
|
||||||
|
|
||||||
|
Likely cause: the same idempotency key was reused for different bundle content
|
||||||
|
within the same distributor token and pipeline. By default the bundle ID is a
|
||||||
|
stable report-stream identity and the idempotency key appends RunID.
|
||||||
|
|
||||||
|
Diagnostic: inspect the failed batch JSON or stderr line for pipeline, bundle,
|
||||||
|
and idempotency context. Compare the configured templates with the report RunID
|
||||||
|
and report path.
|
||||||
|
|
||||||
|
Also inspect the notification artifact linked from metadata. It records the
|
||||||
|
rendered pipeline ID, bundle 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
|
||||||
|
report, but do not reuse the same rendered key for different generated report
|
||||||
|
content.
|
||||||
|
|
||||||
|
Relevant docs: [Operations guide](operations.md),
|
||||||
|
[Distributor adapter internals](internal/distributor-adapter.md).
|
||||||
|
|
||||||
|
## Distributor Upload Rejected
|
||||||
|
|
||||||
|
Symptom: notification fails with distributor upload rejection, HTTP status, or
|
||||||
|
bundle validation context.
|
||||||
|
|
||||||
|
Likely cause: the distributor endpoint rejected the token, pipeline ID, bundle
|
||||||
|
ID, idempotency key, source file, or one of the rendered bundle paths.
|
||||||
|
|
||||||
|
Diagnostic: inspect stdout JSON or stderr status lines for
|
||||||
|
`notificationError`. Confirm `notify.distributor.endpoint`,
|
||||||
|
`notify.distributor.pipeline_id_template`,
|
||||||
|
`notify.distributor.report_path_templates`, and token configuration. Token
|
||||||
|
values are redacted from weatherreporter errors.
|
||||||
|
|
||||||
|
If the upload was accepted but destination output did not change, inspect the
|
||||||
|
notification artifact's `runStatus.report`. Distributor actions such as
|
||||||
|
`replace_older`, `skip_same`, `skip_destination_newer`, or `failed` explain how
|
||||||
|
the destination handled the uploaded bundle.
|
||||||
|
|
||||||
|
Safe fix: fix the endpoint, token, templates, or distributor-side upload
|
||||||
|
configuration. The weatherreporter upload source is the managed Markdown report,
|
||||||
|
not `--out` or `--out-dir` copies.
|
||||||
|
|
||||||
|
Relevant docs: [Configuration reference](config.md),
|
||||||
|
[Operations guide](operations.md),
|
||||||
|
[Distributor adapter internals](internal/distributor-adapter.md).
|
||||||
|
|
||||||
|
## Distributor Unavailable
|
||||||
|
|
||||||
|
Symptom: notification fails with network, timeout, or service unavailable
|
||||||
|
context.
|
||||||
|
|
||||||
|
Likely cause: the configured distributor endpoint is unreachable, slow, or
|
||||||
|
temporarily unavailable.
|
||||||
|
|
||||||
|
Diagnostic: check network access from the weatherreporter host to
|
||||||
|
`notify.distributor.endpoint`. For batch runs, inspect which reports have
|
||||||
|
`notificationStatus: "failed"`.
|
||||||
|
|
||||||
|
Safe fix: restore distributor service availability and rerun the affected
|
||||||
|
report or batch. Stable idempotency keys make retrying the same generated report
|
||||||
|
safe unless the distributor reports a conflict.
|
||||||
|
|
||||||
|
Relevant docs: [Operations guide](operations.md).
|
||||||
|
|
||||||
## Unknown RunID
|
## Unknown RunID
|
||||||
|
|
||||||
Symptom: an inspect command fails with `metadata for run id ... was not found`.
|
Symptom: an inspect command fails with `metadata for run id ... was not found`.
|
||||||
|
|||||||
@@ -11,6 +11,22 @@ location:
|
|||||||
name: Brentwood
|
name: Brentwood
|
||||||
region: St. Louis Metro
|
region: St. Louis Metro
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
directory: ""
|
||||||
|
|
||||||
|
notify:
|
||||||
|
distributor:
|
||||||
|
enabled: false
|
||||||
|
endpoint: https://distributor.example.com
|
||||||
|
token_env: DISTRIBUTOR_UPLOAD_TOKEN
|
||||||
|
timeout: 30s
|
||||||
|
failure_policy: error
|
||||||
|
pipeline_id_template: "weatherreporter.{report_id}"
|
||||||
|
bundle_id_template: "weatherreporter.{location_id}.{report_id}"
|
||||||
|
idempotency_key_template: "{bundle_id}.{run_id}"
|
||||||
|
report_path_templates:
|
||||||
|
- "{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md"
|
||||||
|
|
||||||
missing_source:
|
missing_source:
|
||||||
default: warn
|
default: warn
|
||||||
sources:
|
sources:
|
||||||
@@ -26,6 +42,7 @@ workspace:
|
|||||||
reports_dir: reports
|
reports_dir: reports
|
||||||
data_packages_dir: data-packages
|
data_packages_dir: data-packages
|
||||||
preflight_dir: preflight
|
preflight_dir: preflight
|
||||||
|
notifications_dir: notifications
|
||||||
|
|
||||||
dayparts:
|
dayparts:
|
||||||
- name: overnight
|
- name: overnight
|
||||||
@@ -49,3 +66,41 @@ recent_change:
|
|||||||
precip_probability_points: 20
|
precip_probability_points: 20
|
||||||
wind_gust_miles_per_hour: 10
|
wind_gust_miles_per_hour: 10
|
||||||
precip_timing_shift_minutes: 120
|
precip_timing_shift_minutes: 120
|
||||||
|
|
||||||
|
reports:
|
||||||
|
daily:
|
||||||
|
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
|
||||||
|
- outdoor_windows
|
||||||
|
- hourly_forecast
|
||||||
|
hourly:
|
||||||
|
deterministic_modules:
|
||||||
|
- metadata
|
||||||
|
- current_conditions
|
||||||
|
- hourly_forecast
|
||||||
|
- precip_timing
|
||||||
|
- alert_digest
|
||||||
|
- spc_convective_outlooks
|
||||||
|
- id: area_forecast_discussion
|
||||||
|
options:
|
||||||
|
sections:
|
||||||
|
- key_messages
|
||||||
|
- short_term
|
||||||
|
- spc_convective_discussion
|
||||||
|
- weather_story
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -3,3 +3,5 @@ module gitea.maximumdirect.net/eric/weatherreporter
|
|||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
require gopkg.in/yaml.v3 v3.0.1
|
require gopkg.in/yaml.v3 v3.0.1
|
||||||
|
|
||||||
|
require gitea.maximumdirect.net/eric/distributor v0.5.0
|
||||||
|
|||||||
48
go.sum
48
go.sum
@@ -1,3 +1,51 @@
|
|||||||
|
gitea.maximumdirect.net/eric/distributor v0.5.0 h1:+al7Bw+kMv6V35a3Sm5rUtCTQhwOn5b9x3RsclPMKJk=
|
||||||
|
gitea.maximumdirect.net/eric/distributor v0.5.0/go.mod h1:G03FCFZPHpsUKC6SeMgTdbfNRpPQBdyTtDUj04e1Tu8=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11/go.mod h1:dnakxebH6UwFvcvujL0LVggYQ8nEvBGjU4G/V79Nv94=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.20/go.mod h1:PuwEpciweIXGULWeOeSTXtSbH4CW9mWdWrhdCKQI1sM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 h1:0w6dCiO8iez+YKwRhRBlL1CH/E3GTfdkuzrwj1by8vo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25/go.mod h1:9FDWUothyr5RCRAHc45XOiVCzUR8n/IhCYX+uVqw6vk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 h1:A1PmWU2zfkIm9EyFlJncFXL4W4phML+h8KjltUsCvNQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26/go.mod h1:dY4MRzXEizrD4hqtpKvWVGPX7QleSGGVY+EBolo1RmM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 h1:W/EyPFl9A5rXrtoilfwHYEvzHER+K4SpBPtMXi24Mos=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18/go.mod h1:UG50K+pvd/uy6xExbobg0rjqFBFZe6I3l75EPDZw4tg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 h1:dD3dhHNglpd98gs72my22Ndqi1hqQGllFFg1F+twfxg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25/go.mod h1:0yAbjPfd64gG7mj85RW+fMEYdfBgCRZw8g/oWcL1pjc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 h1:2pQEbwf+/6EDbiit/GcBE2K4IUpMZymaA0kOz3xK978=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25/go.mod h1:KvT6NCcQ0EZ+ZkVRrlBMt04Po3ok23YELEp7WimhLhM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 h1:ie4ElCmUKS26pzrZcIk/lmt4yWjAqLLcawstyQCh298=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2/go.mod h1:zjsomFeX5duj+4PlMB+o4JoWTIx+G0XMyzjYrUbQkN0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 h1:1VwbP3qMNfxUDEXWki4rCE5iA+44VA1lokTz9HasGzw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1/go.mod h1:vUtyoSj0OPji3kjIVSc/GlKuWEiL33f/WFxl6dmpy/A=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 h1:N6pIsdFOW1Kd9S4KyFKXdGRBojPPxkP32+uHFWLv4Hc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19/go.mod h1:3gt5WJArFooNmyLONS+h/R4J+o86II8du38IgCwj9dE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 h1:hc+lBYiiTr8Zk4MTzIsQ92MeDWCIDvWGmzKUWOaBcOg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2/go.mod h1:hU6fqB3OJA6/ePheD47LQnxvjYk6br6PtQxs+Q9ojvk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3SJXQNEgksORW3Js=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8=
|
||||||
|
github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
|
||||||
|
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||||
|
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||||
|
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||||
|
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
|
||||||
|
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
|
||||||
|
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||||
|
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
|
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||||
|
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||||
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
|
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
371
internal/adapters/distributor/client.go
Normal file
371
internal/adapters/distributor/client.go
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
// Package distributor adapts weatherreporter report artifacts to distributor uploads.
|
||||||
|
package distributor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
distributorbundle "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
|
||||||
|
distributorupload "gitea.maximumdirect.net/eric/distributor/pkg/upload"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
Endpoint string
|
||||||
|
TokenEnv string
|
||||||
|
Timeout time.Duration
|
||||||
|
newUploadClient uploadClientFactory
|
||||||
|
}
|
||||||
|
|
||||||
|
type UploadRequest struct {
|
||||||
|
PipelineID string
|
||||||
|
BundleID string
|
||||||
|
IdempotencyKey string
|
||||||
|
Files []UploadFile
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type UploadFile struct {
|
||||||
|
SourcePath string
|
||||||
|
BundlePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UploadResult struct {
|
||||||
|
RunID string
|
||||||
|
Status string
|
||||||
|
UploadStatus string
|
||||||
|
StatusError string
|
||||||
|
RunStatus *RunStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunStatus struct {
|
||||||
|
RunID string
|
||||||
|
PipelineID string
|
||||||
|
Status string
|
||||||
|
AcceptedAt time.Time
|
||||||
|
StartedAt *time.Time
|
||||||
|
FinishedAt *time.Time
|
||||||
|
Report json.RawMessage
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
type IdempotencyConflictError struct {
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *IdempotencyConflictError) Error() string {
|
||||||
|
if e == nil || e.Err == nil {
|
||||||
|
return "distributor idempotency conflict"
|
||||||
|
}
|
||||||
|
return e.Err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *IdempotencyConflictError) Unwrap() error {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
type uploadClientFactory func(endpoint, token string, timeout time.Duration) (uploadClient, error)
|
||||||
|
|
||||||
|
type uploadClient interface {
|
||||||
|
UploadFiles(ctx context.Context, opts uploadFilesOptions) (uploadFilesResult, error)
|
||||||
|
Status(ctx context.Context, runID string) (runStatus, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type uploadFilesOptions struct {
|
||||||
|
PipelineID string
|
||||||
|
BundleID string
|
||||||
|
IdempotencyKey string
|
||||||
|
Files []UploadFile
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type uploadFilesResult struct {
|
||||||
|
RunID string
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
type runStatus struct {
|
||||||
|
RunID string
|
||||||
|
PipelineID string
|
||||||
|
Status string
|
||||||
|
AcceptedAt time.Time
|
||||||
|
StartedAt *time.Time
|
||||||
|
FinishedAt *time.Time
|
||||||
|
Report json.RawMessage
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusPollInterval = 250 * time.Millisecond
|
||||||
|
|
||||||
|
func New(cfg config.DistributorNotifyConfig) *Client {
|
||||||
|
return newClient(cfg, newDistributorUploadClient)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newClient(cfg config.DistributorNotifyConfig, factory uploadClientFactory) *Client {
|
||||||
|
if factory == nil {
|
||||||
|
factory = newDistributorUploadClient
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
Endpoint: cfg.Endpoint,
|
||||||
|
TokenEnv: cfg.TokenEnv,
|
||||||
|
Timeout: cfg.Timeout,
|
||||||
|
newUploadClient: factory,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Upload(ctx context.Context, req UploadRequest) (UploadResult, error) {
|
||||||
|
if c == nil {
|
||||||
|
return UploadResult{}, fmt.Errorf("distributor client is nil")
|
||||||
|
}
|
||||||
|
if c.Endpoint == "" {
|
||||||
|
return UploadResult{}, fmt.Errorf("distributor endpoint is required")
|
||||||
|
}
|
||||||
|
if c.TokenEnv == "" {
|
||||||
|
return UploadResult{}, fmt.Errorf("distributor token environment variable is required")
|
||||||
|
}
|
||||||
|
if req.PipelineID == "" {
|
||||||
|
return UploadResult{}, fmt.Errorf("distributor pipeline id is required")
|
||||||
|
}
|
||||||
|
if req.BundleID == "" {
|
||||||
|
return UploadResult{}, fmt.Errorf("distributor bundle id is required")
|
||||||
|
}
|
||||||
|
if req.IdempotencyKey == "" {
|
||||||
|
return UploadResult{}, fmt.Errorf("distributor idempotency key is required for bundle %q", req.BundleID)
|
||||||
|
}
|
||||||
|
if len(req.Files) == 0 {
|
||||||
|
return UploadResult{}, fmt.Errorf("distributor upload files are required for bundle %q", req.BundleID)
|
||||||
|
}
|
||||||
|
for i, file := range req.Files {
|
||||||
|
if file.SourcePath == "" {
|
||||||
|
return UploadResult{}, fmt.Errorf("distributor source path is required for bundle %q file %d", req.BundleID, i)
|
||||||
|
}
|
||||||
|
if file.BundlePath == "" {
|
||||||
|
return UploadResult{}, fmt.Errorf("distributor bundle path is required for bundle %q file %d", req.BundleID, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.newUploadClient == nil {
|
||||||
|
return UploadResult{}, fmt.Errorf("distributor upload client factory is required for endpoint %q", c.Endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
token := os.Getenv(c.TokenEnv)
|
||||||
|
if token == "" {
|
||||||
|
return UploadResult{}, fmt.Errorf("distributor token environment variable %q is not set", c.TokenEnv)
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadClient, err := c.newUploadClient(c.Endpoint, token, c.Timeout)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, fmt.Errorf("create distributor upload client for endpoint %q: %w", c.Endpoint, redactToken(err, token))
|
||||||
|
}
|
||||||
|
|
||||||
|
runCtx := ctx
|
||||||
|
if runCtx == nil {
|
||||||
|
runCtx = context.Background()
|
||||||
|
}
|
||||||
|
cancel := func() {}
|
||||||
|
if c.Timeout > 0 {
|
||||||
|
runCtx, cancel = context.WithTimeout(runCtx, c.Timeout)
|
||||||
|
}
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
result, err := uploadClient.UploadFiles(runCtx, uploadFilesOptions{
|
||||||
|
PipelineID: req.PipelineID,
|
||||||
|
BundleID: req.BundleID,
|
||||||
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
|
Files: append([]UploadFile(nil), req.Files...),
|
||||||
|
CreatedAt: req.CreatedAt,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, wrapUploadError(err, uploadErrorContext{
|
||||||
|
Endpoint: c.Endpoint,
|
||||||
|
PipelineID: req.PipelineID,
|
||||||
|
BundleID: req.BundleID,
|
||||||
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
|
SourcePaths: uploadSourcePaths(req.Files),
|
||||||
|
BundlePaths: uploadBundlePaths(req.Files),
|
||||||
|
Token: token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadResult := UploadResult{
|
||||||
|
RunID: result.RunID,
|
||||||
|
Status: result.Status,
|
||||||
|
UploadStatus: result.Status,
|
||||||
|
}
|
||||||
|
status, statusErr := waitForRunStatus(runCtx, uploadClient, result.RunID, c.Timeout > 0)
|
||||||
|
if status.RunID != "" || status.Status != "" {
|
||||||
|
uploadResult.RunStatus = &RunStatus{
|
||||||
|
RunID: status.RunID,
|
||||||
|
PipelineID: status.PipelineID,
|
||||||
|
Status: status.Status,
|
||||||
|
AcceptedAt: status.AcceptedAt,
|
||||||
|
StartedAt: status.StartedAt,
|
||||||
|
FinishedAt: status.FinishedAt,
|
||||||
|
Report: append(json.RawMessage(nil), status.Report...),
|
||||||
|
Error: redactTokenString(status.Error, token),
|
||||||
|
}
|
||||||
|
if status.Status != "" {
|
||||||
|
uploadResult.Status = status.Status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if statusErr != nil {
|
||||||
|
uploadResult.StatusError = redactTokenString(statusErr.Error(), token)
|
||||||
|
return uploadResult, nil
|
||||||
|
}
|
||||||
|
if status.Status == "failed" {
|
||||||
|
return uploadResult, fmt.Errorf("distributor run %q failed: %s", status.RunID, uploadResult.RunStatus.Error)
|
||||||
|
}
|
||||||
|
return uploadResult, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForRunStatus(ctx context.Context, client uploadClient, runID string, poll bool) (runStatus, error) {
|
||||||
|
status, err := client.Status(ctx, runID)
|
||||||
|
if err != nil || terminalRunStatus(status.Status) || !poll {
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
timer := time.NewTimer(statusPollInterval)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
return status, fmt.Errorf("distributor run %q did not reach terminal status before timeout: %w", runID, ctx.Err())
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
|
||||||
|
next, err := client.Status(ctx, runID)
|
||||||
|
if err != nil {
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
status = next
|
||||||
|
if terminalRunStatus(status.Status) {
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func terminalRunStatus(status string) bool {
|
||||||
|
return status == "succeeded" || status == "failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
type distributorUploadClient struct {
|
||||||
|
client *distributorupload.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDistributorUploadClient(endpoint, token string, timeout time.Duration) (uploadClient, error) {
|
||||||
|
httpClient := (*http.Client)(nil)
|
||||||
|
if timeout > 0 {
|
||||||
|
httpClient = &http.Client{Timeout: timeout}
|
||||||
|
}
|
||||||
|
client, err := distributorupload.NewClient(distributorupload.ClientOptions{
|
||||||
|
Endpoint: endpoint,
|
||||||
|
Token: token,
|
||||||
|
HTTPClient: httpClient,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return distributorUploadClient{client: client}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c distributorUploadClient) UploadFiles(ctx context.Context, opts uploadFilesOptions) (uploadFilesResult, error) {
|
||||||
|
files := make([]distributorbundle.BundleFile, 0, len(opts.Files))
|
||||||
|
for _, file := range opts.Files {
|
||||||
|
files = append(files, distributorbundle.BundleFile{
|
||||||
|
SourcePath: file.SourcePath,
|
||||||
|
Path: file.BundlePath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
result, err := c.client.UploadFiles(ctx, distributorupload.UploadFilesOptions{
|
||||||
|
PipelineID: opts.PipelineID,
|
||||||
|
ID: opts.BundleID,
|
||||||
|
Created: opts.CreatedAt,
|
||||||
|
IdempotencyKey: opts.IdempotencyKey,
|
||||||
|
Files: files,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return uploadFilesResult{}, err
|
||||||
|
}
|
||||||
|
return uploadFilesResult{
|
||||||
|
RunID: result.RunID,
|
||||||
|
Status: result.Status,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c distributorUploadClient) Status(ctx context.Context, runID string) (runStatus, error) {
|
||||||
|
status, err := c.client.Status(ctx, runID)
|
||||||
|
if err != nil {
|
||||||
|
return runStatus{}, err
|
||||||
|
}
|
||||||
|
return runStatus{
|
||||||
|
RunID: status.RunID,
|
||||||
|
PipelineID: status.PipelineID,
|
||||||
|
Status: status.Status,
|
||||||
|
AcceptedAt: status.AcceptedAt,
|
||||||
|
StartedAt: status.StartedAt,
|
||||||
|
FinishedAt: status.FinishedAt,
|
||||||
|
Report: append(json.RawMessage(nil), status.Report...),
|
||||||
|
Error: status.Error,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type uploadErrorContext struct {
|
||||||
|
Endpoint string
|
||||||
|
PipelineID string
|
||||||
|
BundleID string
|
||||||
|
IdempotencyKey string
|
||||||
|
SourcePaths []string
|
||||||
|
BundlePaths []string
|
||||||
|
Token string
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapUploadError(err error, ctx uploadErrorContext) error {
|
||||||
|
var conflict *distributorupload.IdempotencyConflictError
|
||||||
|
isConflict := errors.As(err, &conflict)
|
||||||
|
err = redactToken(err, ctx.Token)
|
||||||
|
if isConflict {
|
||||||
|
return &IdempotencyConflictError{
|
||||||
|
Err: fmt.Errorf("upload distributor bundle %q to pipeline %q at endpoint %q with idempotency key %q from sources %q as bundle paths %q: idempotency conflict: %w", ctx.BundleID, ctx.PipelineID, ctx.Endpoint, ctx.IdempotencyKey, ctx.SourcePaths, ctx.BundlePaths, err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("upload distributor bundle %q to pipeline %q at endpoint %q with idempotency key %q from sources %q as bundle paths %q: %w", ctx.BundleID, ctx.PipelineID, ctx.Endpoint, ctx.IdempotencyKey, ctx.SourcePaths, ctx.BundlePaths, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadSourcePaths(files []UploadFile) []string {
|
||||||
|
paths := make([]string, 0, len(files))
|
||||||
|
for _, file := range files {
|
||||||
|
paths = append(paths, file.SourcePath)
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadBundlePaths(files []UploadFile) []string {
|
||||||
|
paths := make([]string, 0, len(files))
|
||||||
|
for _, file := range files {
|
||||||
|
paths = append(paths, file.BundlePath)
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
func redactToken(err error, token string) error {
|
||||||
|
if err == nil || token == "" {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return errors.New(redactTokenString(err.Error(), token))
|
||||||
|
}
|
||||||
|
|
||||||
|
func redactTokenString(value, token string) string {
|
||||||
|
if token == "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(value, token, "[redacted]")
|
||||||
|
}
|
||||||
406
internal/adapters/distributor/client_test.go
Normal file
406
internal/adapters/distributor/client_test.go
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
package distributor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
distributorupload "gitea.maximumdirect.net/eric/distributor/pkg/upload"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUploadUsesConfiguredClientAndFiles(t *testing.T) {
|
||||||
|
cfg := config.Defaults().Notify.Distributor
|
||||||
|
cfg.Endpoint = "https://distributor.example.test"
|
||||||
|
cfg.TokenEnv = "DISTRIBUTOR_UPLOAD_TOKEN"
|
||||||
|
cfg.Timeout = 15 * time.Second
|
||||||
|
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||||
|
|
||||||
|
factory := &fakeUploadFactory{
|
||||||
|
client: &fakeUploadClient{
|
||||||
|
result: uploadFilesResult{RunID: "run-123", Status: "accepted"},
|
||||||
|
status: runStatus{RunID: "run-123", PipelineID: "reports", Status: "succeeded", Report: json.RawMessage(`{"actions":[{"action":"replace_older"}]}`)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client := newClient(cfg, factory.newClient)
|
||||||
|
|
||||||
|
result, err := client.Upload(context.Background(), UploadRequest{
|
||||||
|
PipelineID: "weatherreporter.daily",
|
||||||
|
BundleID: "weatherreporter.home.daily.run",
|
||||||
|
IdempotencyKey: "weatherreporter.home.daily.run",
|
||||||
|
Files: []UploadFile{
|
||||||
|
{SourcePath: "/tmp/report.md", BundlePath: "2026-06-07/daily/report.md"},
|
||||||
|
{SourcePath: "/tmp/report.md", BundlePath: "2026-06-07/daily/latest.md"},
|
||||||
|
},
|
||||||
|
CreatedAt: time.Date(2026, 6, 7, 12, 0, 0, 123, time.UTC),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Upload() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.RunID != "run-123" || result.Status != "succeeded" || result.UploadStatus != "accepted" {
|
||||||
|
t.Fatalf("result = %#v, want accepted run", result)
|
||||||
|
}
|
||||||
|
if result.RunStatus == nil || result.RunStatus.PipelineID != "reports" || !strings.Contains(string(result.RunStatus.Report), "replace_older") {
|
||||||
|
t.Fatalf("RunStatus = %#v, want parsed run report", result.RunStatus)
|
||||||
|
}
|
||||||
|
if factory.endpoint != cfg.Endpoint {
|
||||||
|
t.Fatalf("factory endpoint = %q, want %q", factory.endpoint, cfg.Endpoint)
|
||||||
|
}
|
||||||
|
if factory.token != "secret-token" {
|
||||||
|
t.Fatalf("factory token = %q, want secret-token", factory.token)
|
||||||
|
}
|
||||||
|
if factory.timeout != 15*time.Second {
|
||||||
|
t.Fatalf("factory timeout = %s, want 15s", factory.timeout)
|
||||||
|
}
|
||||||
|
got := factory.client.opts
|
||||||
|
if got.PipelineID != "weatherreporter.daily" {
|
||||||
|
t.Fatalf("PipelineID = %q, want weatherreporter.daily", got.PipelineID)
|
||||||
|
}
|
||||||
|
if got.BundleID != "weatherreporter.home.daily.run" {
|
||||||
|
t.Fatalf("BundleID = %q, want weatherreporter.home.daily.run", got.BundleID)
|
||||||
|
}
|
||||||
|
if got.IdempotencyKey != "weatherreporter.home.daily.run" {
|
||||||
|
t.Fatalf("IdempotencyKey = %q, want weatherreporter.home.daily.run", got.IdempotencyKey)
|
||||||
|
}
|
||||||
|
if len(got.Files) != 2 {
|
||||||
|
t.Fatalf("files = %#v, want two mappings", got.Files)
|
||||||
|
}
|
||||||
|
if got.Files[0].SourcePath != "/tmp/report.md" || got.Files[0].BundlePath != "2026-06-07/daily/report.md" {
|
||||||
|
t.Fatalf("first file = %#v, want archive mapping", got.Files[0])
|
||||||
|
}
|
||||||
|
if got.Files[1].SourcePath != "/tmp/report.md" || got.Files[1].BundlePath != "2026-06-07/daily/latest.md" {
|
||||||
|
t.Fatalf("second file = %#v, want latest mapping", got.Files[1])
|
||||||
|
}
|
||||||
|
if got.CreatedAt.IsZero() {
|
||||||
|
t.Fatal("CreatedAt is zero, want generated report timestamp")
|
||||||
|
}
|
||||||
|
if factory.client.statusRunID != "run-123" {
|
||||||
|
t.Fatalf("Status runID = %q, want run-123", factory.client.statusRunID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadRejectsMissingInputs(t *testing.T) {
|
||||||
|
cfg := config.Defaults().Notify.Distributor
|
||||||
|
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*Client, *UploadRequest)
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Token",
|
||||||
|
mutate: func(c *Client, req *UploadRequest) {
|
||||||
|
t.Setenv(c.TokenEnv, "")
|
||||||
|
},
|
||||||
|
wantErr: "token environment variable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PipelineID",
|
||||||
|
mutate: func(c *Client, req *UploadRequest) {
|
||||||
|
req.PipelineID = ""
|
||||||
|
},
|
||||||
|
wantErr: "pipeline id is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Files",
|
||||||
|
mutate: func(c *Client, req *UploadRequest) {
|
||||||
|
req.Files = nil
|
||||||
|
},
|
||||||
|
wantErr: "upload files are required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SourcePath",
|
||||||
|
mutate: func(c *Client, req *UploadRequest) {
|
||||||
|
req.Files[0].SourcePath = ""
|
||||||
|
},
|
||||||
|
wantErr: "source path is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "BundlePath",
|
||||||
|
mutate: func(c *Client, req *UploadRequest) {
|
||||||
|
req.Files[0].BundlePath = ""
|
||||||
|
},
|
||||||
|
wantErr: "bundle path is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UploadClientFactory",
|
||||||
|
mutate: func(c *Client, req *UploadRequest) {
|
||||||
|
c.newUploadClient = nil
|
||||||
|
},
|
||||||
|
wantErr: "upload client factory is required",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||||
|
client := newClient(cfg, (&fakeUploadFactory{client: &fakeUploadClient{}}).newClient)
|
||||||
|
req := validUploadRequest()
|
||||||
|
tt.mutate(client, &req)
|
||||||
|
|
||||||
|
_, err := client.Upload(context.Background(), req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Upload() error = nil, want error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
|
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "secret-token") {
|
||||||
|
t.Fatalf("error = %q, want no token value", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadWrapsFactoryErrorWithoutToken(t *testing.T) {
|
||||||
|
cfg := config.Defaults().Notify.Distributor
|
||||||
|
cfg.Endpoint = "https://distributor.example.test"
|
||||||
|
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||||
|
factory := &fakeUploadFactory{
|
||||||
|
err: fmt.Errorf("factory failed with secret-token"),
|
||||||
|
}
|
||||||
|
client := newClient(cfg, factory.newClient)
|
||||||
|
|
||||||
|
_, err := client.Upload(context.Background(), validUploadRequest())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Upload() error = nil, want error")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "secret-token") {
|
||||||
|
t.Fatalf("error = %q, want no token value", err.Error())
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), cfg.Endpoint) {
|
||||||
|
t.Fatalf("error = %q, want endpoint context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadWrapsUploadFailureWithContextWithoutToken(t *testing.T) {
|
||||||
|
cfg := config.Defaults().Notify.Distributor
|
||||||
|
cfg.Endpoint = "https://distributor.example.test"
|
||||||
|
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||||
|
factory := &fakeUploadFactory{
|
||||||
|
client: &fakeUploadClient{err: fmt.Errorf("server rejected secret-token")},
|
||||||
|
}
|
||||||
|
client := newClient(cfg, factory.newClient)
|
||||||
|
req := validUploadRequest()
|
||||||
|
|
||||||
|
_, err := client.Upload(context.Background(), req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Upload() error = nil, want error")
|
||||||
|
}
|
||||||
|
for _, want := range []string{cfg.Endpoint, req.PipelineID, req.BundleID, req.IdempotencyKey, req.Files[0].SourcePath, req.Files[0].BundlePath, req.Files[1].BundlePath} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Fatalf("error = %q, want context %q", err.Error(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "secret-token") {
|
||||||
|
t.Fatalf("error = %q, want no token value", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadReturnsAcceptedWhenStatusLookupFails(t *testing.T) {
|
||||||
|
cfg := config.Defaults().Notify.Distributor
|
||||||
|
cfg.Endpoint = "https://distributor.example.test"
|
||||||
|
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||||
|
factory := &fakeUploadFactory{
|
||||||
|
client: &fakeUploadClient{
|
||||||
|
result: uploadFilesResult{RunID: "run-123", Status: "accepted"},
|
||||||
|
statusErr: fmt.Errorf("status rejected secret-token"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client := newClient(cfg, factory.newClient)
|
||||||
|
|
||||||
|
result, err := client.Upload(context.Background(), validUploadRequest())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Upload() error = %v, want accepted upload despite status lookup failure", err)
|
||||||
|
}
|
||||||
|
if result.Status != "accepted" || result.StatusError == "" {
|
||||||
|
t.Fatalf("result = %#v, want accepted status with status error", result)
|
||||||
|
}
|
||||||
|
if strings.Contains(result.StatusError, "secret-token") {
|
||||||
|
t.Fatalf("StatusError = %q, want token redacted", result.StatusError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadPollsUntilTerminalStatus(t *testing.T) {
|
||||||
|
cfg := config.Defaults().Notify.Distributor
|
||||||
|
cfg.Endpoint = "https://distributor.example.test"
|
||||||
|
cfg.Timeout = 2 * time.Second
|
||||||
|
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||||
|
factory := &fakeUploadFactory{
|
||||||
|
client: &fakeUploadClient{
|
||||||
|
result: uploadFilesResult{RunID: "run-123", Status: "accepted"},
|
||||||
|
statuses: []runStatus{
|
||||||
|
{RunID: "run-123", Status: "accepted"},
|
||||||
|
{RunID: "run-123", Status: "succeeded", Report: json.RawMessage(`{"actions":[{"action":"replace_older"}]}`)},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client := newClient(cfg, factory.newClient)
|
||||||
|
|
||||||
|
result, err := client.Upload(context.Background(), validUploadRequest())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Upload() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Status != "succeeded" || result.RunStatus == nil || !strings.Contains(string(result.RunStatus.Report), "replace_older") {
|
||||||
|
t.Fatalf("result = %#v, want terminal succeeded status with run report", result)
|
||||||
|
}
|
||||||
|
if factory.client.statusCalls != 2 {
|
||||||
|
t.Fatalf("status calls = %d, want 2", factory.client.statusCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadReturnsLatestStatusWhenPollingTimesOut(t *testing.T) {
|
||||||
|
cfg := config.Defaults().Notify.Distributor
|
||||||
|
cfg.Endpoint = "https://distributor.example.test"
|
||||||
|
cfg.Timeout = time.Millisecond
|
||||||
|
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||||
|
factory := &fakeUploadFactory{
|
||||||
|
client: &fakeUploadClient{
|
||||||
|
result: uploadFilesResult{RunID: "run-123", Status: "accepted"},
|
||||||
|
status: runStatus{RunID: "run-123", Status: "running"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client := newClient(cfg, factory.newClient)
|
||||||
|
|
||||||
|
result, err := client.Upload(context.Background(), validUploadRequest())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Upload() error = %v, want accepted upload with status timeout recorded", err)
|
||||||
|
}
|
||||||
|
if result.Status != "running" || result.StatusError == "" {
|
||||||
|
t.Fatalf("result = %#v, want latest status and status timeout", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadFailsWhenDistributorRunFailed(t *testing.T) {
|
||||||
|
cfg := config.Defaults().Notify.Distributor
|
||||||
|
cfg.Endpoint = "https://distributor.example.test"
|
||||||
|
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||||
|
factory := &fakeUploadFactory{
|
||||||
|
client: &fakeUploadClient{
|
||||||
|
result: uploadFilesResult{RunID: "run-123", Status: "accepted"},
|
||||||
|
status: runStatus{
|
||||||
|
RunID: "run-123",
|
||||||
|
Status: "failed",
|
||||||
|
Error: "destination rejected secret-token",
|
||||||
|
Report: json.RawMessage(`{"actions":[{"action":"failed"}]}`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client := newClient(cfg, factory.newClient)
|
||||||
|
|
||||||
|
result, err := client.Upload(context.Background(), validUploadRequest())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Upload() error = nil, want failed distributor run error")
|
||||||
|
}
|
||||||
|
if result.RunStatus == nil || result.RunStatus.Status != "failed" || !strings.Contains(string(result.RunStatus.Report), "failed") {
|
||||||
|
t.Fatalf("result = %#v, want failed run status report", result)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "secret-token") || strings.Contains(result.RunStatus.Error, "secret-token") {
|
||||||
|
t.Fatalf("error/result leaked token: err=%q result=%#v", err.Error(), result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadPreservesIdempotencyConflictDiagnosis(t *testing.T) {
|
||||||
|
cfg := config.Defaults().Notify.Distributor
|
||||||
|
cfg.Endpoint = "https://distributor.example.test"
|
||||||
|
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||||
|
factory := &fakeUploadFactory{
|
||||||
|
client: &fakeUploadClient{
|
||||||
|
err: &distributorupload.IdempotencyConflictError{
|
||||||
|
HTTPError: distributorupload.HTTPError{
|
||||||
|
StatusCode: 409,
|
||||||
|
Status: "409 Conflict",
|
||||||
|
Message: "conflicting upload for secret-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client := newClient(cfg, factory.newClient)
|
||||||
|
|
||||||
|
_, err := client.Upload(context.Background(), validUploadRequest())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Upload() error = nil, want error")
|
||||||
|
}
|
||||||
|
var conflict *IdempotencyConflictError
|
||||||
|
if !errors.As(err, &conflict) {
|
||||||
|
t.Fatalf("Upload() error = %T %v, want IdempotencyConflictError", err, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "idempotency conflict") {
|
||||||
|
t.Fatalf("error = %q, want idempotency conflict diagnosis", err.Error())
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "secret-token") {
|
||||||
|
t.Fatalf("error = %q, want no token value", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validUploadRequest() UploadRequest {
|
||||||
|
return UploadRequest{
|
||||||
|
PipelineID: "weatherreporter.daily",
|
||||||
|
BundleID: "weatherreporter.home.daily.run",
|
||||||
|
IdempotencyKey: "weatherreporter.home.daily.run",
|
||||||
|
Files: []UploadFile{
|
||||||
|
{SourcePath: "/tmp/report.md", BundlePath: "2026-06-07/daily/report.md"},
|
||||||
|
{SourcePath: "/tmp/report.md", BundlePath: "2026-06-07/daily/latest.md"},
|
||||||
|
},
|
||||||
|
CreatedAt: time.Date(2026, 6, 7, 12, 0, 0, 123, time.UTC),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeUploadFactory struct {
|
||||||
|
endpoint string
|
||||||
|
token string
|
||||||
|
timeout time.Duration
|
||||||
|
client *fakeUploadClient
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeUploadFactory) newClient(endpoint, token string, timeout time.Duration) (uploadClient, error) {
|
||||||
|
f.endpoint = endpoint
|
||||||
|
f.token = token
|
||||||
|
f.timeout = timeout
|
||||||
|
if f.err != nil {
|
||||||
|
return nil, f.err
|
||||||
|
}
|
||||||
|
return f.client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeUploadClient struct {
|
||||||
|
opts uploadFilesOptions
|
||||||
|
statusRunID string
|
||||||
|
statusCalls int
|
||||||
|
result uploadFilesResult
|
||||||
|
status runStatus
|
||||||
|
statuses []runStatus
|
||||||
|
err error
|
||||||
|
statusErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fakeUploadClient) UploadFiles(ctx context.Context, opts uploadFilesOptions) (uploadFilesResult, error) {
|
||||||
|
c.opts = opts
|
||||||
|
if c.err != nil {
|
||||||
|
return uploadFilesResult{}, c.err
|
||||||
|
}
|
||||||
|
return c.result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fakeUploadClient) Status(ctx context.Context, runID string) (runStatus, error) {
|
||||||
|
c.statusRunID = runID
|
||||||
|
c.statusCalls++
|
||||||
|
if c.statusErr != nil {
|
||||||
|
return runStatus{}, c.statusErr
|
||||||
|
}
|
||||||
|
if len(c.statuses) > 0 {
|
||||||
|
index := c.statusCalls - 1
|
||||||
|
if index >= len(c.statuses) {
|
||||||
|
index = len(c.statuses) - 1
|
||||||
|
}
|
||||||
|
return c.statuses[index], nil
|
||||||
|
}
|
||||||
|
return c.status, nil
|
||||||
|
}
|
||||||
@@ -79,6 +79,12 @@ type RunRequest struct {
|
|||||||
OutputPath string
|
OutputPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StructuredRunRequest struct {
|
||||||
|
PromptID string
|
||||||
|
DataPackagePath string
|
||||||
|
OutputPath string
|
||||||
|
}
|
||||||
|
|
||||||
type RenderResult struct {
|
type RenderResult struct {
|
||||||
Command []string `json:"command"`
|
Command []string `json:"command"`
|
||||||
Stdout string `json:"stdout"`
|
Stdout string `json:"stdout"`
|
||||||
@@ -98,6 +104,16 @@ type RunResult struct {
|
|||||||
OutputPath string `json:"outputPath"`
|
OutputPath string `json:"outputPath"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StructuredRunResult struct {
|
||||||
|
Command []string `json:"command"`
|
||||||
|
Stdout string `json:"stdout"`
|
||||||
|
Stderr string `json:"stderr"`
|
||||||
|
StdoutTruncated bool `json:"stdoutTruncated,omitempty"`
|
||||||
|
StderrTruncated bool `json:"stderrTruncated,omitempty"`
|
||||||
|
ExitCode int `json:"exitCode"`
|
||||||
|
OutputPath string `json:"outputPath"`
|
||||||
|
}
|
||||||
|
|
||||||
func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, error) {
|
func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, error) {
|
||||||
if req.PromptID == "" {
|
if req.PromptID == "" {
|
||||||
return nil, fmt.Errorf("prompt id is required")
|
return nil, fmt.Errorf("prompt id is required")
|
||||||
@@ -152,6 +168,35 @@ func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r Runner) StructuredRun(ctx context.Context, req StructuredRunRequest) (*StructuredRunResult, error) {
|
||||||
|
if req.PromptID == "" {
|
||||||
|
return nil, fmt.Errorf("prompt id is required")
|
||||||
|
}
|
||||||
|
if req.DataPackagePath == "" {
|
||||||
|
return nil, fmt.Errorf("data package path is required")
|
||||||
|
}
|
||||||
|
if req.OutputPath == "" {
|
||||||
|
return nil, fmt.Errorf("output path is required")
|
||||||
|
}
|
||||||
|
execution, err := r.execute(ctx, r.structuredRunArgs(req))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("run scriptorium structured output: %w", err)
|
||||||
|
}
|
||||||
|
result := &StructuredRunResult{
|
||||||
|
Command: execution.argv(),
|
||||||
|
Stdout: string(execution.result.Stdout),
|
||||||
|
Stderr: string(execution.result.Stderr),
|
||||||
|
StdoutTruncated: execution.result.StdoutTruncated,
|
||||||
|
StderrTruncated: execution.result.StderrTruncated,
|
||||||
|
ExitCode: execution.result.ExitCode,
|
||||||
|
OutputPath: req.OutputPath,
|
||||||
|
}
|
||||||
|
if execution.result.ExitCode != 0 {
|
||||||
|
return result, fmt.Errorf("scriptorium structured run exited with code %d: %s", execution.result.ExitCode, result.Stderr)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
type execution struct {
|
type execution struct {
|
||||||
binary string
|
binary string
|
||||||
args []string
|
args []string
|
||||||
@@ -212,6 +257,14 @@ func (r Runner) runArgs(req RunRequest) []string {
|
|||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r Runner) structuredRunArgs(req StructuredRunRequest) []string {
|
||||||
|
return r.runArgs(RunRequest{
|
||||||
|
PromptID: req.PromptID,
|
||||||
|
DataPackagePath: req.DataPackagePath,
|
||||||
|
OutputPath: req.OutputPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type limitedBuffer struct {
|
type limitedBuffer struct {
|
||||||
data []byte
|
data []byte
|
||||||
limit int
|
limit int
|
||||||
|
|||||||
@@ -20,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.daily_report",
|
||||||
DataPackagePath: "/tmp/data_package.json",
|
DataPackagePath: "/tmp/data_package.yaml",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Render() error = %v", err)
|
t.Fatalf("Render() error = %v", err)
|
||||||
@@ -31,7 +31,7 @@ func TestRenderConstructsCommand(t *testing.T) {
|
|||||||
"--config", "/etc/scriptorium.yml",
|
"--config", "/etc/scriptorium.yml",
|
||||||
"--profile", "weather",
|
"--profile", "weather",
|
||||||
"--prompt", "weather.daily_report",
|
"--prompt", "weather.daily_report",
|
||||||
"--input", "data_package=/tmp/data_package.json",
|
"--input", "data_package=/tmp/data_package.yaml",
|
||||||
"--format", "json",
|
"--format", "json",
|
||||||
}
|
}
|
||||||
if commands.name != "/usr/local/bin/scriptorium" {
|
if commands.name != "/usr/local/bin/scriptorium" {
|
||||||
@@ -57,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.daily_report",
|
||||||
DataPackagePath: "/tmp/data_package.json",
|
DataPackagePath: "/tmp/data_package.yaml",
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("Render() error = nil, want nonzero exit error")
|
t.Fatal("Render() error = nil, want nonzero exit error")
|
||||||
@@ -85,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.daily_report",
|
||||||
DataPackagePath: "/tmp/data_package.json",
|
DataPackagePath: "/tmp/data_package.yaml",
|
||||||
OutputPath: "/tmp/daily.md",
|
OutputPath: "/tmp/daily.md",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -97,7 +97,7 @@ func TestRunConstructsCommand(t *testing.T) {
|
|||||||
"--config", "/etc/scriptorium.yml",
|
"--config", "/etc/scriptorium.yml",
|
||||||
"--profile", "weather",
|
"--profile", "weather",
|
||||||
"--prompt", "weather.daily_report",
|
"--prompt", "weather.daily_report",
|
||||||
"--input", "data_package=/tmp/data_package.json",
|
"--input", "data_package=/tmp/data_package.yaml",
|
||||||
"--out", "/tmp/daily.md",
|
"--out", "/tmp/daily.md",
|
||||||
}
|
}
|
||||||
if commands.name != "/usr/local/bin/scriptorium" {
|
if commands.name != "/usr/local/bin/scriptorium" {
|
||||||
@@ -130,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.daily_report",
|
||||||
DataPackagePath: "/tmp/data_package.json",
|
DataPackagePath: "/tmp/data_package.yaml",
|
||||||
OutputPath: "/tmp/daily.md",
|
OutputPath: "/tmp/daily.md",
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -147,17 +147,169 @@ func TestRunReturnsResultForValidationExit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStructuredRunConstructsCommandWithoutSchemaFlags(t *testing.T) {
|
||||||
|
commands := &fakeCommands{result: CommandResult{
|
||||||
|
Stdout: []byte(`{"summary":"ok"}`),
|
||||||
|
Stderr: []byte("wrote generated text"),
|
||||||
|
StdoutTruncated: true,
|
||||||
|
}}
|
||||||
|
runner := Runner{
|
||||||
|
Binary: "/usr/local/bin/scriptorium",
|
||||||
|
ConfigPath: "/etc/scriptorium.yml",
|
||||||
|
Profile: "weather",
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
Commands: commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
|
||||||
|
PromptID: "weather.hourly_generated_text",
|
||||||
|
DataPackagePath: "/tmp/hourly.data_package.yaml",
|
||||||
|
OutputPath: "/tmp/hourly.generated_text.raw.json",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StructuredRun() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantArgs := []string{
|
||||||
|
"run",
|
||||||
|
"--config", "/etc/scriptorium.yml",
|
||||||
|
"--profile", "weather",
|
||||||
|
"--prompt", "weather.hourly_generated_text",
|
||||||
|
"--input", "data_package=/tmp/hourly.data_package.yaml",
|
||||||
|
"--out", "/tmp/hourly.generated_text.raw.json",
|
||||||
|
}
|
||||||
|
if commands.name != "/usr/local/bin/scriptorium" {
|
||||||
|
t.Fatalf("command name = %q, want custom binary", commands.name)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(commands.args, wantArgs) {
|
||||||
|
t.Fatalf("args = %#v, want %#v", commands.args, wantArgs)
|
||||||
|
}
|
||||||
|
for _, disallowed := range []string{"--format", "--schema", "--schema-path", "--json-schema"} {
|
||||||
|
if containsArg(commands.args, disallowed) {
|
||||||
|
t.Fatalf("args = %#v, should not include %q", commands.args, disallowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if commands.timeout != 30*time.Second {
|
||||||
|
t.Fatalf("timeout = %s, want 30s", commands.timeout)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(result.Command, append([]string{"/usr/local/bin/scriptorium"}, wantArgs...)) {
|
||||||
|
t.Fatalf("result command = %#v, want full argv", result.Command)
|
||||||
|
}
|
||||||
|
if result.Stdout != `{"summary":"ok"}` || result.Stderr != "wrote generated text" || !result.StdoutTruncated {
|
||||||
|
t.Fatalf("result = %#v, want captured output and truncation flags", result)
|
||||||
|
}
|
||||||
|
if result.OutputPath != "/tmp/hourly.generated_text.raw.json" {
|
||||||
|
t.Fatalf("OutputPath = %q, want generated text raw path", result.OutputPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStructuredRunReturnsResultForNonzeroExit(t *testing.T) {
|
||||||
|
runner := Runner{
|
||||||
|
Commands: &fakeCommands{
|
||||||
|
result: CommandResult{
|
||||||
|
Stdout: []byte(`{"summary":"partial"}`),
|
||||||
|
Stderr: []byte("structured output failed"),
|
||||||
|
ExitCode: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
|
||||||
|
PromptID: "weather.hourly_generated_text",
|
||||||
|
DataPackagePath: "/tmp/hourly.data_package.yaml",
|
||||||
|
OutputPath: "/tmp/hourly.generated_text.raw.json",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("StructuredRun() error = nil, want nonzero exit error")
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("StructuredRun() result = nil, want captured result")
|
||||||
|
}
|
||||||
|
if result.ExitCode != 3 {
|
||||||
|
t.Fatalf("ExitCode = %d, want 3", result.ExitCode)
|
||||||
|
}
|
||||||
|
if result.Stdout != `{"summary":"partial"}` || result.OutputPath != "/tmp/hourly.generated_text.raw.json" {
|
||||||
|
t.Fatalf("result = %#v, want captured result fields", result)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "structured output failed") {
|
||||||
|
t.Fatalf("error = %q, want stderr context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStructuredRunValidatesRequiredFieldsBeforeExecution(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
req StructuredRunRequest
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "prompt id",
|
||||||
|
req: StructuredRunRequest{
|
||||||
|
DataPackagePath: "/tmp/hourly.data_package.yaml",
|
||||||
|
OutputPath: "/tmp/hourly.generated_text.raw.json",
|
||||||
|
},
|
||||||
|
want: "prompt id is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "data package path",
|
||||||
|
req: StructuredRunRequest{
|
||||||
|
PromptID: "weather.hourly_generated_text",
|
||||||
|
OutputPath: "/tmp/hourly.generated_text.raw.json",
|
||||||
|
},
|
||||||
|
want: "data package path is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "output path",
|
||||||
|
req: StructuredRunRequest{
|
||||||
|
PromptID: "weather.hourly_generated_text",
|
||||||
|
DataPackagePath: "/tmp/hourly.data_package.yaml",
|
||||||
|
},
|
||||||
|
want: "output path is required",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
commands := &fakeCommands{}
|
||||||
|
runner := Runner{Commands: commands}
|
||||||
|
result, err := runner.StructuredRun(context.Background(), test.req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("StructuredRun() error = nil, want validation error")
|
||||||
|
}
|
||||||
|
if result != nil {
|
||||||
|
t.Fatalf("StructuredRun() result = %#v, want nil", result)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("StructuredRun() error = %v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
if commands.calls != 0 {
|
||||||
|
t.Fatalf("commands calls = %d, want no subprocess execution", commands.calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type fakeCommands struct {
|
type fakeCommands struct {
|
||||||
name string
|
name string
|
||||||
args []string
|
args []string
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
result CommandResult
|
result CommandResult
|
||||||
err error
|
err error
|
||||||
|
calls int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeCommands) Run(_ context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) {
|
func (f *fakeCommands) Run(_ context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) {
|
||||||
|
f.calls++
|
||||||
f.name = name
|
f.name = name
|
||||||
f.args = append([]string{}, args...)
|
f.args = append([]string{}, args...)
|
||||||
f.timeout = timeout
|
f.timeout = timeout
|
||||||
return f.result, f.err
|
return f.result, f.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func containsArg(args []string, want string) bool {
|
||||||
|
for _, arg := range args {
|
||||||
|
if arg == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package weatherapi adapts the internal weather API to forecast bundles.
|
// Package weatherapi adapts the internal weather API to weather data bundles.
|
||||||
package weatherapi
|
package weatherapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -18,7 +18,12 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
"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/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
convectiveOutlooksEndpoint = "/outlooks/convective"
|
||||||
|
sourceSPCConvectiveOutlooks = "spc_convective_outlooks"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
@@ -83,11 +88,11 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
|
|||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) FetchBundle(ctx context.Context) (*forecast.Bundle, error) {
|
func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
|
||||||
fetchedAt := c.now()
|
fetchedAt := c.now()
|
||||||
builder := bundleBuilder{
|
builder := bundleBuilder{
|
||||||
client: c,
|
client: c,
|
||||||
bundle: &forecast.Bundle{FetchedAt: fetchedAt},
|
bundle: &weatherdata.Bundle{FetchedAt: fetchedAt},
|
||||||
fetchedAt: fetchedAt,
|
fetchedAt: fetchedAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +117,7 @@ func (c *Client) FetchBundle(ctx context.Context) (*forecast.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.addStub("daily", "daily forecast data is not available from the weather API yet"); err != nil {
|
if err := builder.fetchSPCConvectiveOutlooks(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +126,7 @@ func (c *Client) FetchBundle(ctx context.Context) (*forecast.Bundle, error) {
|
|||||||
|
|
||||||
type bundleBuilder struct {
|
type bundleBuilder struct {
|
||||||
client *Client
|
client *Client
|
||||||
bundle *forecast.Bundle
|
bundle *weatherdata.Bundle
|
||||||
fetchedAt time.Time
|
fetchedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +138,7 @@ func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
|
|||||||
if raw == nil {
|
if raw == nil {
|
||||||
return b.handleMissing(&source, "observation data is missing", false)
|
return b.handleMissing(&source, "observation data is missing", false)
|
||||||
}
|
}
|
||||||
var observation forecast.Observation
|
var observation weatherdata.Observation
|
||||||
if err := decodeSource(raw, &observation); err != nil {
|
if err := decodeSource(raw, &observation); err != nil {
|
||||||
return b.handleMalformed(&source, err, false)
|
return b.handleMalformed(&source, err, false)
|
||||||
}
|
}
|
||||||
@@ -151,7 +156,7 @@ func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
|
|||||||
if raw == nil {
|
if raw == nil {
|
||||||
return b.handleMissing(&source, "current conditions data is missing", false)
|
return b.handleMissing(&source, "current conditions data is missing", false)
|
||||||
}
|
}
|
||||||
var current forecast.Current
|
var current weatherdata.Current
|
||||||
if err := decodeSource(raw, ¤t); err != nil {
|
if err := decodeSource(raw, ¤t); err != nil {
|
||||||
return b.handleMalformed(&source, err, false)
|
return b.handleMalformed(&source, err, false)
|
||||||
}
|
}
|
||||||
@@ -168,7 +173,7 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
|
|||||||
if raw == nil {
|
if raw == nil {
|
||||||
return b.handleMissing(&source, "hourly forecast data is missing", true)
|
return b.handleMissing(&source, "hourly forecast data is missing", true)
|
||||||
}
|
}
|
||||||
var hourly forecast.ForecastRun
|
var hourly weatherdata.ForecastRun
|
||||||
if err := decodeSource(raw, &hourly); err != nil {
|
if err := decodeSource(raw, &hourly); err != nil {
|
||||||
return fmt.Errorf("decode hourly forecast from %s: %w", source.Endpoint, err)
|
return fmt.Errorf("decode hourly forecast from %s: %w", source.Endpoint, err)
|
||||||
}
|
}
|
||||||
@@ -190,7 +195,7 @@ func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
|
|||||||
if raw == nil {
|
if raw == nil {
|
||||||
return b.handleMissing(&source, "narrative forecast data is missing", false)
|
return b.handleMissing(&source, "narrative forecast data is missing", false)
|
||||||
}
|
}
|
||||||
var narrative forecast.ForecastRun
|
var narrative weatherdata.ForecastRun
|
||||||
if err := decodeSource(raw, &narrative); err != nil {
|
if err := decodeSource(raw, &narrative); err != nil {
|
||||||
return b.handleMalformed(&source, err, false)
|
return b.handleMalformed(&source, err, false)
|
||||||
}
|
}
|
||||||
@@ -210,11 +215,11 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
|||||||
return b.handleMissing(&source, "active alerts data is missing", false)
|
return b.handleMissing(&source, "active alerts data is missing", false)
|
||||||
}
|
}
|
||||||
if isJSONNull(raw) {
|
if isJSONNull(raw) {
|
||||||
b.bundle.Alerts = &forecast.AlertRun{Raw: append(json.RawMessage(nil), raw...)}
|
b.bundle.Alerts = &weatherdata.AlertRun{Raw: append(json.RawMessage(nil), raw...)}
|
||||||
b.addSource(source)
|
b.addSource(source)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var alerts forecast.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, false)
|
||||||
}
|
}
|
||||||
@@ -235,7 +240,7 @@ func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
|||||||
if raw == nil {
|
if raw == nil {
|
||||||
return b.handleMissing(&source, "forecast discussion data is missing", false)
|
return b.handleMissing(&source, "forecast discussion data is missing", false)
|
||||||
}
|
}
|
||||||
var discussion forecast.Discussion
|
var discussion weatherdata.Discussion
|
||||||
if err := decodeSource(raw, &discussion); err != nil {
|
if err := decodeSource(raw, &discussion); err != nil {
|
||||||
return b.handleMalformed(&source, err, false)
|
return b.handleMalformed(&source, err, false)
|
||||||
}
|
}
|
||||||
@@ -254,7 +259,7 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
|||||||
if raw == nil {
|
if raw == nil {
|
||||||
return b.handleMissing(&source, "NWS weather story data is missing", false)
|
return b.handleMissing(&source, "NWS weather story data is missing", false)
|
||||||
}
|
}
|
||||||
var story forecast.WeatherStory
|
var story weatherdata.WeatherStory
|
||||||
if err := decodeSource(raw, &story); err != nil {
|
if err := decodeSource(raw, &story); err != nil {
|
||||||
return b.handleMalformed(&source, err, false)
|
return b.handleMalformed(&source, err, false)
|
||||||
}
|
}
|
||||||
@@ -267,16 +272,30 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) addStub(sourceName string, message string) error {
|
func (b *bundleBuilder) fetchSPCConvectiveOutlooks(ctx context.Context) error {
|
||||||
source := forecast.Source{
|
raw, source, err := b.client.fetch(ctx, sourceSPCConvectiveOutlooks, convectiveOutlooksEndpoint, queryOptions{timezone: true, omitUnits: true})
|
||||||
Name: sourceName,
|
if err != nil {
|
||||||
FetchedAt: b.fetchedAt,
|
return err
|
||||||
Missing: true,
|
|
||||||
}
|
}
|
||||||
return b.applyMissingPolicy(&source, "missing_source", message)
|
if raw == nil {
|
||||||
|
return b.handleMissing(&source, "SPC convective outlook data is missing", false)
|
||||||
|
}
|
||||||
|
var run weatherdata.ConvectiveOutlookRun
|
||||||
|
if err := decodeSource(raw, &run); err != nil {
|
||||||
|
return b.handleMalformed(&source, err, false)
|
||||||
|
}
|
||||||
|
if run.IssuedAt != nil {
|
||||||
|
source.IssuedAt = run.IssuedAt
|
||||||
|
} else {
|
||||||
|
source.IssuedAt = run.AsOf
|
||||||
|
}
|
||||||
|
source.UpdatedAt = run.UpdatedAt
|
||||||
|
b.bundle.SPCConvectiveOutlooks = &run
|
||||||
|
b.addSource(source)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) handleMissing(source *forecast.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 {
|
||||||
return fmt.Errorf("%s from %s is required", message, source.Endpoint)
|
return fmt.Errorf("%s from %s is required", message, source.Endpoint)
|
||||||
@@ -284,7 +303,7 @@ func (b *bundleBuilder) handleMissing(source *forecast.Source, message string, r
|
|||||||
return b.applyMissingPolicy(source, "missing_source", message)
|
return b.applyMissingPolicy(source, "missing_source", message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) handleMalformed(source *forecast.Source, err error, required bool) error {
|
func (b *bundleBuilder) handleMalformed(source *weatherdata.Source, err error, required bool) error {
|
||||||
if required {
|
if required {
|
||||||
return fmt.Errorf("decode %s from %s: %w", source.Name, source.Endpoint, err)
|
return fmt.Errorf("decode %s from %s: %w", source.Name, source.Endpoint, err)
|
||||||
}
|
}
|
||||||
@@ -292,13 +311,13 @@ func (b *bundleBuilder) handleMalformed(source *forecast.Source, err error, requ
|
|||||||
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) applyMissingPolicy(source *forecast.Source, code string, message string) error {
|
func (b *bundleBuilder) applyMissingPolicy(source *weatherdata.Source, code string, message string) error {
|
||||||
policy := b.client.policyFor(source.Name)
|
policy := b.client.policyFor(source.Name)
|
||||||
if policy == config.MissingSourceError {
|
if policy == config.MissingSourceError {
|
||||||
return fmt.Errorf("%s: %s", source.Name, message)
|
return fmt.Errorf("%s: %s", source.Name, message)
|
||||||
}
|
}
|
||||||
if policy == config.MissingSourceWarn {
|
if policy == config.MissingSourceWarn {
|
||||||
warning := forecast.SourceWarning{
|
warning := weatherdata.SourceWarning{
|
||||||
Source: source.Name,
|
Source: source.Name,
|
||||||
Code: code,
|
Code: code,
|
||||||
Severity: "warning",
|
Severity: "warning",
|
||||||
@@ -313,7 +332,7 @@ func (b *bundleBuilder) applyMissingPolicy(source *forecast.Source, code string,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) addSource(source forecast.Source) {
|
func (b *bundleBuilder) addSource(source weatherdata.Source) {
|
||||||
b.bundle.Sources = append(b.bundle.Sources, source)
|
b.bundle.Sources = append(b.bundle.Sources, source)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,33 +354,33 @@ type envelope struct {
|
|||||||
Data json.RawMessage `json:"data"`
|
Data json.RawMessage `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string, opts queryOptions) (json.RawMessage, forecast.Source, error) {
|
func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string, opts queryOptions) (json.RawMessage, weatherdata.Source, error) {
|
||||||
reqURL := c.endpointURL(endpoint, opts)
|
reqURL := c.endpointURL(endpoint, opts)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, forecast.Source{}, fmt.Errorf("create request for %s: %w", endpoint, err)
|
return nil, weatherdata.Source{}, fmt.Errorf("create request for %s: %w", endpoint, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, forecast.Source{}, fmt.Errorf("fetch %s: %w", endpoint, err)
|
return nil, weatherdata.Source{}, fmt.Errorf("fetch %s: %w", endpoint, err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, forecast.Source{}, fmt.Errorf("read %s response: %w", endpoint, err)
|
return nil, weatherdata.Source{}, fmt.Errorf("read %s response: %w", endpoint, err)
|
||||||
}
|
}
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
return nil, forecast.Source{}, fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
|
return nil, weatherdata.Source{}, fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
}
|
}
|
||||||
|
|
||||||
var env envelope
|
var env envelope
|
||||||
if err := json.Unmarshal(body, &env); err != nil {
|
if err := json.Unmarshal(body, &env); err != nil {
|
||||||
return nil, forecast.Source{}, fmt.Errorf("decode %s envelope: %w", endpoint, err)
|
return nil, weatherdata.Source{}, fmt.Errorf("decode %s envelope: %w", endpoint, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
source := forecast.Source{
|
source := weatherdata.Source{
|
||||||
Name: sourceName,
|
Name: sourceName,
|
||||||
Endpoint: endpoint,
|
Endpoint: endpoint,
|
||||||
Query: queryMap(reqURL.Query()),
|
Query: queryMap(reqURL.Query()),
|
||||||
@@ -430,7 +449,7 @@ func sourceHash(raw json.RawMessage) (string, error) {
|
|||||||
return hex.EncodeToString(sum[:]), nil
|
return hex.EncodeToString(sum[:]), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func SaveBundle(path string, bundle *forecast.Bundle) error {
|
func SaveBundle(path string, bundle *weatherdata.Bundle) error {
|
||||||
if err := fileutil.WriteJSONAtomic(path, bundle); err != nil {
|
if err := fileutil.WriteJSONAtomic(path, bundle); err != nil {
|
||||||
return fmt.Errorf("save bundle: %w", err)
|
return fmt.Errorf("save bundle: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFetchBundleFromFixtures(t *testing.T) {
|
func TestFetchBundleFromFixtures(t *testing.T) {
|
||||||
@@ -55,11 +55,20 @@ 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 bundle.SPCConvectiveOutlooks == nil || len(bundle.SPCConvectiveOutlooks.Outlooks) != 1 {
|
||||||
|
t.Fatalf("SPCConvectiveOutlooks = %#v, want one outlook", bundle.SPCConvectiveOutlooks)
|
||||||
|
}
|
||||||
|
if len(bundle.SPCConvectiveOutlooks.Outlooks[0].Geometry) == 0 {
|
||||||
|
t.Fatalf("SPCConvectiveOutlooks.Outlooks[0].Geometry is empty, want GeoJSON")
|
||||||
|
}
|
||||||
|
if len(bundle.SPCConvectiveOutlooks.Discussions) != 1 || bundle.SPCConvectiveOutlooks.Discussions[0].Headline != "Severe storms possible" {
|
||||||
|
t.Fatalf("SPCConvectiveOutlooks.Discussions = %#v, want one discussion", bundle.SPCConvectiveOutlooks.Discussions)
|
||||||
|
}
|
||||||
if len(bundle.Sources) != 8 {
|
if len(bundle.Sources) != 8 {
|
||||||
t.Fatalf("Sources length = %d, want 8", len(bundle.Sources))
|
t.Fatalf("Sources length = %d, want 8", len(bundle.Sources))
|
||||||
}
|
}
|
||||||
if len(bundle.Warnings) != 1 {
|
if len(bundle.Warnings) != 0 {
|
||||||
t.Fatalf("Warnings length = %d, want daily warning", len(bundle.Warnings))
|
t.Fatalf("Warnings length = %d, want no warnings", len(bundle.Warnings))
|
||||||
}
|
}
|
||||||
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 +79,9 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
|||||||
if !containsPath(requested, "/weatherstories/latest") {
|
if !containsPath(requested, "/weatherstories/latest") {
|
||||||
t.Fatalf("requested paths = %v, want weather story endpoint", requested)
|
t.Fatalf("requested paths = %v, want weather story endpoint", requested)
|
||||||
}
|
}
|
||||||
|
if !containsPath(requested, convectiveOutlooksEndpoint) {
|
||||||
|
t.Fatalf("requested paths = %v, want convective outlook endpoint", requested)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
||||||
@@ -92,6 +104,12 @@ func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if strings.HasPrefix(rawURL, convectiveOutlooksEndpoint) {
|
||||||
|
if strings.Contains(rawURL, "units=") || strings.Contains(rawURL, "precision=") || !strings.Contains(rawURL, "tz=America%2FChicago") {
|
||||||
|
t.Fatalf("convective outlook request %q should use format and tz only", rawURL)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
if !strings.Contains(rawURL, "units=us") {
|
if !strings.Contains(rawURL, "units=us") {
|
||||||
t.Fatalf("request %q missing units=us", rawURL)
|
t.Fatalf("request %q missing units=us", rawURL)
|
||||||
}
|
}
|
||||||
@@ -127,6 +145,25 @@ func TestFetchBundleRecordsSourceHash(t *testing.T) {
|
|||||||
if story.IssuedAt == nil || story.UpdatedAt == nil {
|
if story.IssuedAt == nil || story.UpdatedAt == nil {
|
||||||
t.Fatalf("weather story source timestamps = issued %#v updated %#v, want both", story.IssuedAt, story.UpdatedAt)
|
t.Fatalf("weather story source timestamps = issued %#v updated %#v, want both", story.IssuedAt, story.UpdatedAt)
|
||||||
}
|
}
|
||||||
|
outlooks := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
|
||||||
|
if outlooks.Endpoint != convectiveOutlooksEndpoint {
|
||||||
|
t.Fatalf("convective outlook endpoint = %q, want %s", outlooks.Endpoint, convectiveOutlooksEndpoint)
|
||||||
|
}
|
||||||
|
if outlooks.Query["format"] != "json" || outlooks.Query["tz"] != "America/Chicago" || outlooks.Query["units"] != "" || outlooks.Query["precision"] != "" {
|
||||||
|
t.Fatalf("convective outlook query = %#v, want format and tz only", outlooks.Query)
|
||||||
|
}
|
||||||
|
if outlooks.DataSHA256 != hashFixtureData(t, "convective_outlooks.json") {
|
||||||
|
t.Fatalf("convective outlook DataSHA256 = %q, want fixture hash", outlooks.DataSHA256)
|
||||||
|
}
|
||||||
|
if outlooks.Missing {
|
||||||
|
t.Fatal("convective outlook source Missing = true, want false")
|
||||||
|
}
|
||||||
|
if outlooks.IssuedAt == nil || outlooks.IssuedAt.Format(time.RFC3339) != "2026-05-29T15:45:00Z" {
|
||||||
|
t.Fatalf("convective outlook IssuedAt = %#v, want run issuedAt", outlooks.IssuedAt)
|
||||||
|
}
|
||||||
|
if outlooks.UpdatedAt == nil || outlooks.UpdatedAt.Format(time.RFC3339) != "2026-05-29T16:05:00Z" {
|
||||||
|
t.Fatalf("convective outlook UpdatedAt = %#v, want run updatedAt", outlooks.UpdatedAt)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHTTPErrorIsActionable(t *testing.T) {
|
func TestHTTPErrorIsActionable(t *testing.T) {
|
||||||
@@ -189,6 +226,59 @@ func TestNullAlertsMeansNoActiveAlerts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMissingSPCConvectiveOutlooksUsesPolicy(t *testing.T) {
|
||||||
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
|
convectiveOutlooksEndpoint: {status: http.StatusOK, body: `{"data": null}`},
|
||||||
|
}, nil)
|
||||||
|
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
|
||||||
|
sourceSPCConvectiveOutlooks: config.MissingSourceWarn,
|
||||||
|
})
|
||||||
|
|
||||||
|
bundle, err := client.FetchBundle(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchBundle() error = %v", err)
|
||||||
|
}
|
||||||
|
if bundle.SPCConvectiveOutlooks != nil {
|
||||||
|
t.Fatalf("SPCConvectiveOutlooks = %#v, want nil for missing source", bundle.SPCConvectiveOutlooks)
|
||||||
|
}
|
||||||
|
source := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
|
||||||
|
if !source.Missing || len(source.Warnings) != 1 {
|
||||||
|
t.Fatalf("convective outlook source = %#v, want missing source warning", source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptySPCConvectiveOutlooksAreCheckedData(t *testing.T) {
|
||||||
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
|
convectiveOutlooksEndpoint: {status: http.StatusOK, body: `{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`},
|
||||||
|
}, nil)
|
||||||
|
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
|
||||||
|
sourceSPCConvectiveOutlooks: config.MissingSourceWarn,
|
||||||
|
})
|
||||||
|
|
||||||
|
bundle, err := client.FetchBundle(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchBundle() error = %v", err)
|
||||||
|
}
|
||||||
|
if bundle.SPCConvectiveOutlooks == nil {
|
||||||
|
t.Fatal("SPCConvectiveOutlooks = nil, want checked empty run")
|
||||||
|
}
|
||||||
|
if len(bundle.SPCConvectiveOutlooks.Outlooks) != 0 || len(bundle.SPCConvectiveOutlooks.Discussions) != 0 {
|
||||||
|
t.Fatalf("SPCConvectiveOutlooks = %#v, want empty arrays", bundle.SPCConvectiveOutlooks)
|
||||||
|
}
|
||||||
|
source := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
|
||||||
|
if source.Missing || len(source.Warnings) != 0 {
|
||||||
|
t.Fatalf("convective outlook source = %#v, want non-missing source without warnings", source)
|
||||||
|
}
|
||||||
|
if source.IssuedAt == nil || source.IssuedAt.Format(time.RFC3339) != "2026-05-29T16:00:00Z" {
|
||||||
|
t.Fatalf("convective outlook IssuedAt = %#v, want fallback to asOf", source.IssuedAt)
|
||||||
|
}
|
||||||
|
for _, warning := range bundle.Warnings {
|
||||||
|
if warning.Source == sourceSPCConvectiveOutlooks {
|
||||||
|
t.Fatalf("warnings = %#v, want no convective outlook warning", bundle.Warnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
|
func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -197,7 +287,7 @@ func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
|
|||||||
wantWarns int
|
wantWarns int
|
||||||
wantSource bool
|
wantSource bool
|
||||||
}{
|
}{
|
||||||
{name: "warn", policy: config.MissingSourceWarn, wantWarns: 2, wantSource: true},
|
{name: "warn", policy: config.MissingSourceWarn, wantWarns: 1, wantSource: true},
|
||||||
{name: "none", policy: config.MissingSourceNone, wantWarns: 0, wantSource: true},
|
{name: "none", policy: config.MissingSourceNone, wantWarns: 0, wantSource: true},
|
||||||
{name: "error", policy: config.MissingSourceError, wantErr: true},
|
{name: "error", policy: config.MissingSourceError, wantErr: true},
|
||||||
}
|
}
|
||||||
@@ -363,13 +453,14 @@ type handlerOverride struct {
|
|||||||
func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server {
|
func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
fixtures := map[string]string{
|
fixtures := map[string]string{
|
||||||
"/observations": "observations.json",
|
"/observations": "observations.json",
|
||||||
"/conditions/current": "current.json",
|
"/conditions/current": "current.json",
|
||||||
"/forecast/hourly": "hourly.json",
|
"/forecast/hourly": "hourly.json",
|
||||||
"/forecast/narrative": "narrative.json",
|
"/forecast/narrative": "narrative.json",
|
||||||
"/alerts/active": "alerts.json",
|
"/alerts/active": "alerts.json",
|
||||||
"/discussion": "discussion.json",
|
"/discussion": "discussion.json",
|
||||||
"/weatherstories/latest": "weather_story.json",
|
"/weatherstories/latest": "weather_story.json",
|
||||||
|
convectiveOutlooksEndpoint: "convective_outlooks.json",
|
||||||
}
|
}
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if requested != nil {
|
if requested != nil {
|
||||||
@@ -423,7 +514,7 @@ func containsPath(requested []string, path string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func sourceByName(t *testing.T, sources []forecast.Source, name string) forecast.Source {
|
func sourceByName(t *testing.T, sources []weatherdata.Source, name string) weatherdata.Source {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, source := range sources {
|
for _, source := range sources {
|
||||||
if source.Name == name {
|
if source.Name == name {
|
||||||
@@ -431,7 +522,7 @@ func sourceByName(t *testing.T, sources []forecast.Source, name string) forecast
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
t.Fatalf("source %q not found in %#v", name, sources)
|
t.Fatalf("source %q not found in %#v", name, sources)
|
||||||
return forecast.Source{}
|
return weatherdata.Source{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func hashFixtureData(t *testing.T, fixture string) string {
|
func hashFixtureData(t *testing.T, fixture string) string {
|
||||||
|
|||||||
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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -6,11 +6,12 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
type InspectReportsRequest struct {
|
type InspectReportsRequest struct {
|
||||||
@@ -24,11 +25,11 @@ type InspectRunRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SourceInspection struct {
|
type SourceInspection struct {
|
||||||
RunID string `json:"runId"`
|
RunID string `json:"runId"`
|
||||||
ReportID report.ID `json:"reportId"`
|
ReportID report.ID `json:"reportId"`
|
||||||
SourceLocation string `json:"sourceLocation,omitempty"`
|
SourceLocation string `json:"sourceLocation,omitempty"`
|
||||||
Sources []briefing.SourceMetadata `json:"sources,omitempty"`
|
Sources []briefing.SourceMetadata `json:"sources,omitempty"`
|
||||||
Warnings []forecast.SourceWarning `json:"warnings,omitempty"`
|
Warnings []weatherdata.SourceWarning `json:"warnings,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func InspectReports(ctx context.Context, req InspectReportsRequest) ([]state.ReportRecord, error) {
|
func InspectReports(ctx context.Context, req InspectReportsRequest) ([]state.ReportRecord, error) {
|
||||||
@@ -44,12 +45,12 @@ func InspectMetadata(ctx context.Context, req InspectRunRequest) (state.Metadata
|
|||||||
return inspection.metadata, err
|
return inspection.metadata, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func InspectBriefing(ctx context.Context, req InspectRunRequest) (briefing.Package, error) {
|
func InspectModules(ctx context.Context, req InspectRunRequest) (module.Snapshot, error) {
|
||||||
inspection, err := inspectRun(ctx, req)
|
inspection, err := inspectRun(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return briefing.Package{}, err
|
return module.Snapshot{}, err
|
||||||
}
|
}
|
||||||
return inspection.store.LoadBriefing(ctx, inspection.metadata.BriefingPath)
|
return inspection.store.LoadModuleSnapshot(ctx, inspection.metadata.ModuleSnapshotPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func InspectDataPackage(ctx context.Context, req InspectRunRequest) (promptinput.Package, error) {
|
func InspectDataPackage(ctx context.Context, req InspectRunRequest) (promptinput.Package, error) {
|
||||||
|
|||||||
60
internal/briefing/alert_digest_module.go
Normal file
60
internal/briefing/alert_digest_module.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AlertDigestModule struct {
|
||||||
|
Checked bool `json:"checked"`
|
||||||
|
ActiveCount int `json:"active_count"`
|
||||||
|
RelevantCount int `json:"relevant_count"`
|
||||||
|
Missing bool `json:"missing,omitempty"`
|
||||||
|
Relevant []AlertSummary `json:"relevant,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertSummary struct {
|
||||||
|
Event string `json:"event,omitempty"`
|
||||||
|
Headline string `json:"headline,omitempty"`
|
||||||
|
Severity string `json:"severity,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAlertDigestModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
value := alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps)
|
||||||
|
if value == nil {
|
||||||
|
value = &AlertDigestModule{}
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: *value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func alertDigest(collected facts.CollectedFacts, overlaps []forecast.AlertOverlap) *AlertDigestModule {
|
||||||
|
missing := sourceMissing(collected.SourceProvenance, "alerts")
|
||||||
|
if collected.Alerts == nil && !missing {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
value := &AlertDigestModule{Missing: missing}
|
||||||
|
if collected.Alerts != nil {
|
||||||
|
value.Checked = true
|
||||||
|
value.ActiveCount = len(collected.Alerts.Alerts)
|
||||||
|
}
|
||||||
|
value.RelevantCount = len(overlaps)
|
||||||
|
for _, overlap := range overlaps {
|
||||||
|
value.Relevant = append(value.Relevant, AlertSummary{
|
||||||
|
Event: overlap.Event,
|
||||||
|
Headline: overlap.Headline,
|
||||||
|
Severity: overlap.Severity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceMissing(sources []weatherdata.Source, name string) bool {
|
||||||
|
for _, source := range sources {
|
||||||
|
if source.Name == name && source.Missing {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
67
internal/briefing/area_forecast_discussion_module.go
Normal file
67
internal/briefing/area_forecast_discussion_module.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AreaForecastDiscussionModule struct {
|
||||||
|
Product string `json:"product,omitempty"`
|
||||||
|
KeyMessages []string `json:"key_messages,omitempty"`
|
||||||
|
ShortTerm string `json:"short_term,omitempty"`
|
||||||
|
LongTerm string `json:"long_term,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAreaForecastDiscussionModule(ctx ModuleContext, options any) (*module.Output, error) {
|
||||||
|
discussion := ctx.Collected.Discussion
|
||||||
|
if discussion == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
opts, ok := options.(module.AreaForecastDiscussionOptions)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("area forecast discussion options have type %T", options)
|
||||||
|
}
|
||||||
|
sections, err := areaForecastDiscussionSections(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
value := AreaForecastDiscussionModule{}
|
||||||
|
if sections["product"] {
|
||||||
|
value.Product = discussion.Product
|
||||||
|
}
|
||||||
|
if sections["key_messages"] {
|
||||||
|
value.KeyMessages = append([]string(nil), discussion.KeyMessages...)
|
||||||
|
}
|
||||||
|
if sections["short_term"] && discussion.ShortTerm != nil {
|
||||||
|
value.ShortTerm = discussion.ShortTerm.Text
|
||||||
|
}
|
||||||
|
if sections["long_term"] && discussion.LongTerm != nil {
|
||||||
|
value.LongTerm = discussion.LongTerm.Text
|
||||||
|
}
|
||||||
|
if value.Product == "" && len(value.KeyMessages) == 0 && value.ShortTerm == "" && value.LongTerm == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.AreaForecastDiscussion, StanzaName: "area_forecast_discussion", Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func areaForecastDiscussionSections(options module.AreaForecastDiscussionOptions) (map[string]bool, error) {
|
||||||
|
if len(options.Sections) == 0 {
|
||||||
|
return map[string]bool{
|
||||||
|
"product": true,
|
||||||
|
"key_messages": true,
|
||||||
|
"short_term": true,
|
||||||
|
"long_term": true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
sections := map[string]bool{}
|
||||||
|
for _, section := range options.Sections {
|
||||||
|
switch section {
|
||||||
|
case "product", "key_messages", "short_term", "long_term":
|
||||||
|
sections[section] = true
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("area forecast discussion section %q is not supported", section)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sections, nil
|
||||||
|
}
|
||||||
572
internal/briefing/base_modules_test.go
Normal file
572
internal/briefing/base_modules_test.go
Normal file
@@ -0,0 +1,572 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBaseModulesBuildAvailableSourceOutputs(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
id module.ID
|
||||||
|
stanza string
|
||||||
|
}{
|
||||||
|
{id: module.Metadata, stanza: "metadata"},
|
||||||
|
{id: module.CurrentConditions, stanza: "current_conditions"},
|
||||||
|
{id: module.NarrativeForecast, stanza: "narrative_forecast"},
|
||||||
|
{id: module.HourlyForecast, stanza: "hourly_forecast"},
|
||||||
|
{id: module.AlertDigest, stanza: "alert_digest"},
|
||||||
|
{id: module.AreaForecastDiscussion, stanza: "area_forecast_discussion"},
|
||||||
|
{id: module.WeatherStory, stanza: "weather_story"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(string(tt.id), func(t *testing.T) {
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: tt.id})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
if output == nil {
|
||||||
|
t.Fatal("BuildModule() output = nil, want stanza")
|
||||||
|
}
|
||||||
|
if output.ID != tt.id || output.StanzaName != tt.stanza {
|
||||||
|
t.Fatalf("output = %#v, want id %q stanza %q", output, tt.id, tt.stanza)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(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)
|
||||||
|
}
|
||||||
|
value := moduleValue[HourlyForecastModule](t, output)
|
||||||
|
if value.Product != "hourly" || value.SourceLocationID != "test-grid" || len(value.Periods) != 1 {
|
||||||
|
t.Fatalf("HourlyForecast = %#v, want hourly metadata and one valid-period period", value)
|
||||||
|
}
|
||||||
|
period := value.Periods[0]
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if period.PeriodBegins != "2026-05-29 at 8:00 AM" || period.PeriodEnds != "2026-05-29 at 9:00 AM" {
|
||||||
|
t.Fatalf("HourlyForecast period times = %q/%q, want friendly local time labels", period.PeriodBegins, period.PeriodEnds)
|
||||||
|
}
|
||||||
|
if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
|
||||||
|
t.Fatalf("HourlyForecast period = %#v, want compass wind and precip chance", period)
|
||||||
|
}
|
||||||
|
if !period.MentionPrecipitation {
|
||||||
|
t.Fatalf("MentionPrecipitation = false, want true for default threshold")
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(output.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal hourly forecast: %v", err)
|
||||||
|
}
|
||||||
|
jsonText := string(data)
|
||||||
|
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) {
|
||||||
|
t.Fatalf("hourly json = %s, want field %s", jsonText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(jsonText, "wind_direction_degrees") || strings.Contains(jsonText, "Tomorrow") {
|
||||||
|
t.Fatalf("hourly json = %s, want valid-period prompt fields only", jsonText)
|
||||||
|
}
|
||||||
|
if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) {
|
||||||
|
t.Fatalf("hourly json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHourlyForecastModuleRejectsUnsupportedReports(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Weekend)
|
||||||
|
|
||||||
|
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `module "hourly_forecast" is not compatible with report "weekend"`) {
|
||||||
|
t.Fatalf("BuildModule() error = %v, want incompatible report", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHourlyForecastModuleBuildsForHourly(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Hourly)
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
value := moduleValue[HourlyForecastModule](t, output)
|
||||||
|
if len(value.Periods) != 1 || value.Periods[0].TextDescription != "Showers likely." {
|
||||||
|
t.Fatalf("HourlyForecast = %#v, want hourly report period", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.NarrativeForecast})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
value := moduleValue[NarrativeForecastModule](t, output)
|
||||||
|
if value.Product != "narrative" || value.SourceLocationID != "test-grid" || len(value.Periods) != 1 {
|
||||||
|
t.Fatalf("NarrativeForecast = %#v, want narrative metadata and one valid-period period", value)
|
||||||
|
}
|
||||||
|
period := value.Periods[0]
|
||||||
|
if period.Name != "Today" || period.TextDescription != "Morning storms, then partly sunny." {
|
||||||
|
t.Fatalf("NarrativeForecast period = %#v, want Today narrative", period)
|
||||||
|
}
|
||||||
|
if period.PeriodBegins != "2026-05-29 at 6:00 AM" || period.PeriodEnds != "2026-05-29 at 6:00 PM" {
|
||||||
|
t.Fatalf("NarrativeForecast period times = %q/%q, want friendly local time labels", period.PeriodBegins, period.PeriodEnds)
|
||||||
|
}
|
||||||
|
if period.IsDay == nil || !*period.IsDay || period.TemperatureF == nil || *period.TemperatureF != 81 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 60 {
|
||||||
|
t.Fatalf("NarrativeForecast period = %#v, want day, temperature, and precip values", period)
|
||||||
|
}
|
||||||
|
if period.WindDirection != "NE" {
|
||||||
|
t.Fatalf("NarrativeForecast period wind direction = %q, want NE", period.WindDirection)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(output.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal narrative forecast: %v", err)
|
||||||
|
}
|
||||||
|
jsonText := string(data)
|
||||||
|
for _, field := range []string{"source_location_id", "period_begins", "period_ends", "text_description", "temperature_f", "wind_speed_mph", "wind_direction", "probability_of_precipitation_percent"} {
|
||||||
|
if !strings.Contains(jsonText, field) {
|
||||||
|
t.Fatalf("narrative json = %s, want field %s", jsonText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(jsonText, "wind_direction_degrees") {
|
||||||
|
t.Fatalf("narrative json = %s, want compass wind_direction without degrees field", jsonText)
|
||||||
|
}
|
||||||
|
if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) {
|
||||||
|
t.Fatalf("narrative json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText)
|
||||||
|
}
|
||||||
|
if strings.Contains(jsonText, "Tomorrow night") {
|
||||||
|
t.Fatalf("narrative json = %s, want only valid-period narrative periods", jsonText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNarrativeForecastModuleRejectsUnsupportedReports(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Weekend)
|
||||||
|
|
||||||
|
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.NarrativeForecast})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `module "narrative_forecast" is not compatible with report "weekend"`) {
|
||||||
|
t.Fatalf("BuildModule() error = %v, want incompatible report", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.Metadata})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
value := moduleValue[MetadataModule](t, output)
|
||||||
|
if value.RunID == "" || value.ReportID != report.DailyToday || value.PromptID != "weather.daily_report" {
|
||||||
|
t.Fatalf("metadata = %#v, want report identity", value)
|
||||||
|
}
|
||||||
|
if value.Location == nil || value.Location.Name != "Brentwood" {
|
||||||
|
t.Fatalf("Location = %#v, want configured location", value.Location)
|
||||||
|
}
|
||||||
|
if len(value.SourceWarnings) != 1 || value.SourceWarnings[0].CompletenessImpact != "source omitted" {
|
||||||
|
t.Fatalf("SourceWarnings = %#v, want warning summary", value.SourceWarnings)
|
||||||
|
}
|
||||||
|
if value.Alerts == nil || !value.Alerts.Checked || value.Alerts.ActiveCount != 1 || value.Alerts.RelevantCount != 1 {
|
||||||
|
t.Fatalf("Alerts = %#v, want checked alert status", value.Alerts)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(output.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal metadata: %v", err)
|
||||||
|
}
|
||||||
|
jsonText := string(data)
|
||||||
|
if !strings.Contains(jsonText, "source_warnings") || strings.Contains(jsonText, "endpoint") || strings.Contains(jsonText, "dataSha256") {
|
||||||
|
t.Fatalf("metadata json = %s, want source warning summary without transport provenance", jsonText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(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)
|
||||||
|
}
|
||||||
|
value := moduleValue[CurrentConditionsModule](t, output)
|
||||||
|
if value.ConditionText != "Partly cloudy" || value.ConditionTextLower != "partly cloudy" || value.TemperatureF == nil || *value.TemperatureF != 74 {
|
||||||
|
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" {
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal current conditions: %v", err)
|
||||||
|
}
|
||||||
|
jsonText := string(data)
|
||||||
|
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) {
|
||||||
|
t.Fatalf("current json = %s, want field %s", jsonText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(jsonText, "wind_direction_degrees") {
|
||||||
|
t.Fatalf("current json = %s, want compass wind_direction without degrees field", jsonText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Collected.Alerts = &weatherdata.AlertRun{}
|
||||||
|
ctx.Derived.AlertOverlaps = nil
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.AlertDigest})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(checked empty) error = %v", err)
|
||||||
|
}
|
||||||
|
checked := moduleValue[AlertDigestModule](t, output)
|
||||||
|
if !checked.Checked || checked.ActiveCount != 0 || checked.RelevantCount != 0 || checked.Missing {
|
||||||
|
t.Fatalf("checked empty alert digest = %#v, want checked/no active", checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Collected.Alerts = nil
|
||||||
|
ctx.Collected.SourceProvenance = []weatherdata.Source{{Name: "alerts", Missing: true}}
|
||||||
|
output, err = registry.BuildModule(ctx, module.ConfigItem{ID: module.AlertDigest})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(missing) error = %v", err)
|
||||||
|
}
|
||||||
|
missing := moduleValue[AlertDigestModule](t, output)
|
||||||
|
if missing.Checked || !missing.Missing {
|
||||||
|
t.Fatalf("missing alert digest = %#v, want missing unchecked source", missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBaseModulesOmitMissingOptionalOutputs(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Collected.Current = nil
|
||||||
|
ctx.Collected.Narrative = nil
|
||||||
|
ctx.Collected.Hourly = nil
|
||||||
|
ctx.Derived.ValidPeriodNarrativePeriods = nil
|
||||||
|
ctx.Derived.ValidPeriodHourlyPeriods = nil
|
||||||
|
ctx.Collected.Discussion = nil
|
||||||
|
ctx.Collected.WeatherStory = nil
|
||||||
|
|
||||||
|
for _, id := range []module.ID{module.CurrentConditions, module.NarrativeForecast, module.HourlyForecast, module.AreaForecastDiscussion, module.WeatherStory} {
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: id})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(%s) error = %v", id, err)
|
||||||
|
}
|
||||||
|
if output != nil {
|
||||||
|
t.Fatalf("BuildModule(%s) output = %#v, want omitted", id, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAreaForecastDiscussionAndWeatherStoryModules(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
|
||||||
|
afdOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.AreaForecastDiscussion})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(afd) error = %v", err)
|
||||||
|
}
|
||||||
|
afd := moduleValue[AreaForecastDiscussionModule](t, afdOutput)
|
||||||
|
if len(afd.KeyMessages) != 1 || afd.ShortTerm != "Showers increase this afternoon." || afd.LongTerm != "Periodic rain chances continue." {
|
||||||
|
t.Fatalf("AFD = %#v, want discussion sections", afd)
|
||||||
|
}
|
||||||
|
|
||||||
|
storyOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.WeatherStory})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(weather story) error = %v", err)
|
||||||
|
}
|
||||||
|
story := moduleValue[WeatherStoryModule](t, storyOutput)
|
||||||
|
if !story.Available || story.Title != "Rain Chances" || story.Description != "Scattered showers are possible." {
|
||||||
|
t.Fatalf("WeatherStory = %#v, want structured story fields", story)
|
||||||
|
}
|
||||||
|
if story.PeriodBegins != "2026-05-29 at 6:00 AM" || story.PeriodEnds != "2026-05-29 at 6:00 PM" {
|
||||||
|
t.Fatalf("WeatherStory period = %q/%q, want friendly local period labels", story.PeriodBegins, story.PeriodEnds)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(storyOutput.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal weather story: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "download_url") || !strings.Contains(string(data), "period_begins") || !strings.Contains(string(data), "period_ends") {
|
||||||
|
t.Fatalf("weather story json = %s, want snake_case download_url", string(data))
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), "start_time") || strings.Contains(string(data), "end_time") {
|
||||||
|
t.Fatalf("weather story json = %s, want period_begins/period_ends instead of start_time/end_time", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{
|
||||||
|
ID: module.AreaForecastDiscussion,
|
||||||
|
Options: module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
afd := moduleValue[AreaForecastDiscussionModule](t, output)
|
||||||
|
if afd.ShortTerm != "Showers increase this afternoon." {
|
||||||
|
t.Fatalf("ShortTerm = %q, want selected short term section", afd.ShortTerm)
|
||||||
|
}
|
||||||
|
if afd.Product != "" || len(afd.KeyMessages) != 0 || afd.LongTerm != "" {
|
||||||
|
t.Fatalf("AFD = %#v, want only short_term section", afd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAreaForecastDiscussionModuleUsesHourlyDefaultSections(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Hourly)
|
||||||
|
var item module.ConfigItem
|
||||||
|
for _, candidate := range ctx.Resolved.Definition.Modules {
|
||||||
|
if candidate.ID == module.AreaForecastDiscussion {
|
||||||
|
item = candidate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if item.ID == "" {
|
||||||
|
t.Fatal("hourly default modules missing area_forecast_discussion")
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, item)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
afd := moduleValue[AreaForecastDiscussionModule](t, output)
|
||||||
|
if len(afd.KeyMessages) != 1 || afd.ShortTerm != "Showers increase this afternoon." {
|
||||||
|
t.Fatalf("AFD = %#v, want key messages and short term", afd)
|
||||||
|
}
|
||||||
|
if afd.Product != "" || afd.LongTerm != "" {
|
||||||
|
t.Fatalf("AFD = %#v, want product and long term omitted", afd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testModuleContext() ModuleContext {
|
||||||
|
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
|
||||||
|
definition := report.DefaultRegistry().MustLookup(report.DailyToday)
|
||||||
|
resolved := report.Resolved{
|
||||||
|
Definition: definition,
|
||||||
|
GeneratedAt: generatedAt,
|
||||||
|
Timezone: "America/Chicago",
|
||||||
|
ValidPeriod: timeutil.Period{
|
||||||
|
Start: mustParseModuleTime("2026-05-29T00:00:00-05:00"),
|
||||||
|
End: mustParseModuleTime("2026-05-30T00:00:00-05:00"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
isDay := true
|
||||||
|
tempF := 74.4
|
||||||
|
apparentF := 75.6
|
||||||
|
humidity := 70.6
|
||||||
|
windMph := 8.4
|
||||||
|
windDirection := 190.0
|
||||||
|
narrativeTempF := 81.0
|
||||||
|
narrativePop := 60.0
|
||||||
|
narrativeWind := 12.0
|
||||||
|
narrativeWindDirection := 45.0
|
||||||
|
hourlyTempF := 76.0
|
||||||
|
hourlyPop := 70.0
|
||||||
|
hourlyHumidity := 66.0
|
||||||
|
hourlyWindMph := 14.0
|
||||||
|
updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00")
|
||||||
|
return ModuleContext{
|
||||||
|
Resolved: resolved,
|
||||||
|
Collected: facts.CollectedFacts{
|
||||||
|
Current: &weatherdata.Current{
|
||||||
|
ConditionText: "Partly cloudy",
|
||||||
|
IsDay: &isDay,
|
||||||
|
TemperatureF: &tempF,
|
||||||
|
ApparentTemperatureF: &apparentF,
|
||||||
|
RelativeHumidityPercent: &humidity,
|
||||||
|
WindSpeedMph: &windMph,
|
||||||
|
WindDirectionDegrees: &windDirection,
|
||||||
|
},
|
||||||
|
Narrative: &weatherdata.ForecastRun{
|
||||||
|
LocationID: "test-grid",
|
||||||
|
LocationName: "Testville",
|
||||||
|
IssuedAt: mustParseModuleTime("2026-05-29T10:30:00-05:00"),
|
||||||
|
UpdatedAt: &updatedAt,
|
||||||
|
Product: "narrative",
|
||||||
|
Periods: []weatherdata.ForecastPeriod{
|
||||||
|
{
|
||||||
|
Name: "Today",
|
||||||
|
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
|
||||||
|
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
|
||||||
|
IsDay: &isDay,
|
||||||
|
TextDescription: "Morning storms, then partly sunny.",
|
||||||
|
TemperatureF: floatPtr(narrativeTempF),
|
||||||
|
WindSpeedMph: &narrativeWind,
|
||||||
|
WindDirectionDegrees: &narrativeWindDirection,
|
||||||
|
ProbabilityOfPrecipitationPercent: &narrativePop,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Hourly: &weatherdata.ForecastRun{
|
||||||
|
LocationID: "test-grid",
|
||||||
|
LocationName: "Testville",
|
||||||
|
IssuedAt: mustParseModuleTime("2026-05-29T10:30:00-05:00"),
|
||||||
|
UpdatedAt: &updatedAt,
|
||||||
|
Product: "hourly",
|
||||||
|
Periods: []weatherdata.ForecastPeriod{
|
||||||
|
{
|
||||||
|
StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"),
|
||||||
|
EndTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"),
|
||||||
|
TextDescription: "Showers likely.",
|
||||||
|
TemperatureF: &hourlyTempF,
|
||||||
|
WindSpeedMph: &hourlyWindMph,
|
||||||
|
WindDirectionDegrees: &windDirection,
|
||||||
|
ProbabilityOfPrecipitationPercent: &hourlyPop,
|
||||||
|
RelativeHumidityPercent: &hourlyHumidity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
StartTime: mustParseModuleTime("2026-05-30T08:00:00-05:00"),
|
||||||
|
EndTime: mustParseModuleTime("2026-05-30T09:00:00-05:00"),
|
||||||
|
TextDescription: "Tomorrow showers.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{
|
||||||
|
json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate"}`),
|
||||||
|
}},
|
||||||
|
Discussion: &weatherdata.Discussion{
|
||||||
|
Product: "discussion",
|
||||||
|
KeyMessages: []string{"Scattered showers are possible."},
|
||||||
|
ShortTerm: &weatherdata.DiscussionSection{Text: "Showers increase this afternoon."},
|
||||||
|
LongTerm: &weatherdata.DiscussionSection{Text: "Periodic rain chances continue."},
|
||||||
|
},
|
||||||
|
WeatherStory: &weatherdata.WeatherStory{
|
||||||
|
OfficeID: "LSX",
|
||||||
|
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
|
||||||
|
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
|
||||||
|
UpdatedAt: &updatedAt,
|
||||||
|
Title: "Rain Chances",
|
||||||
|
Description: "Scattered showers are possible.",
|
||||||
|
AltText: "Weather story graphic with rain chances.",
|
||||||
|
Priority: true,
|
||||||
|
Order: 1,
|
||||||
|
DownloadURL: "https://example.invalid/story.png",
|
||||||
|
},
|
||||||
|
SourceProvenance: []weatherdata.Source{{Name: "alerts", FetchedAt: generatedAt}},
|
||||||
|
SourceWarnings: []weatherdata.SourceWarning{{
|
||||||
|
Source: "daily",
|
||||||
|
Code: "missing_source",
|
||||||
|
Severity: "warning",
|
||||||
|
Message: "daily source is missing",
|
||||||
|
Endpoint: "/forecast/daily",
|
||||||
|
CompletenessImpact: "source omitted",
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
Derived: facts.DerivedFacts{
|
||||||
|
ValidPeriodHourlyPeriods: []weatherdata.ForecastPeriod{
|
||||||
|
{
|
||||||
|
StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"),
|
||||||
|
EndTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"),
|
||||||
|
TextDescription: "Showers likely.",
|
||||||
|
TemperatureF: &hourlyTempF,
|
||||||
|
WindSpeedMph: &hourlyWindMph,
|
||||||
|
WindDirectionDegrees: &windDirection,
|
||||||
|
ProbabilityOfPrecipitationPercent: &hourlyPop,
|
||||||
|
RelativeHumidityPercent: &hourlyHumidity,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ValidPeriodNarrativePeriods: []weatherdata.ForecastPeriod{
|
||||||
|
{
|
||||||
|
Name: "Today",
|
||||||
|
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
|
||||||
|
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
|
||||||
|
IsDay: &isDay,
|
||||||
|
TextDescription: "Morning storms, then partly sunny.",
|
||||||
|
TemperatureF: floatPtr(narrativeTempF),
|
||||||
|
WindSpeedMph: &narrativeWind,
|
||||||
|
WindDirectionDegrees: &narrativeWindDirection,
|
||||||
|
ProbabilityOfPrecipitationPercent: &narrativePop,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AlertOverlaps: []forecast.AlertOverlap{{
|
||||||
|
Event: "Flood Watch",
|
||||||
|
Headline: "Flooding possible",
|
||||||
|
Severity: "Moderate",
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
Units: "us",
|
||||||
|
Timezone: "America/Chicago",
|
||||||
|
Location: &LocationContext{
|
||||||
|
ID: "home",
|
||||||
|
Name: "Brentwood",
|
||||||
|
Region: "St. Louis Metro",
|
||||||
|
Timezone: "America/Chicago",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func moduleValue[T any](t *testing.T, output *module.Output) T {
|
||||||
|
t.Helper()
|
||||||
|
var value T
|
||||||
|
data, err := json.Marshal(output.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal module value: %v", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &value); err != nil {
|
||||||
|
t.Fatalf("decode module value: %v", err)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustParseModuleTime(value string) time.Time {
|
||||||
|
parsed, err := time.Parse(time.RFC3339, value)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
68
internal/briefing/current_conditions_module.go
Normal file
68
internal/briefing/current_conditions_module.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CurrentConditionsModule struct {
|
||||||
|
ConditionText string `json:"condition_text,omitempty"`
|
||||||
|
ConditionTextLower string `json:"condition_text_lower,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"`
|
||||||
|
WindDirectionText string `json:"wind_direction_text,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
current := ctx.Collected.Current
|
||||||
|
if current == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
value := CurrentConditionsModule{
|
||||||
|
ConditionText: current.ConditionText,
|
||||||
|
ConditionTextLower: strings.ToLower(current.ConditionText),
|
||||||
|
IsDay: copyBool(current.IsDay),
|
||||||
|
TemperatureC: roundedInt(current.TemperatureC),
|
||||||
|
TemperatureF: roundedInt(current.TemperatureF),
|
||||||
|
ApparentTemperatureC: roundedInt(current.ApparentTemperatureC),
|
||||||
|
ApparentTemperatureF: roundedInt(current.ApparentTemperatureF),
|
||||||
|
DewpointC: roundedInt(current.DewpointC),
|
||||||
|
DewpointF: roundedInt(current.DewpointF),
|
||||||
|
RelativeHumidityPercent: roundedInt(current.RelativeHumidityPercent),
|
||||||
|
WindSpeedKmh: roundedInt(current.WindSpeedKmh),
|
||||||
|
WindSpeedMph: roundedInt(current.WindSpeedMph),
|
||||||
|
WindDirection: windDirectionLabel(current.WindDirectionDegrees),
|
||||||
|
WindDirectionText: windDirectionTextLabel(current.WindDirectionDegrees),
|
||||||
|
}
|
||||||
|
if value.isEmpty() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v CurrentConditionsModule) isEmpty() bool {
|
||||||
|
return v.ConditionText == "" &&
|
||||||
|
v.ConditionTextLower == "" &&
|
||||||
|
v.IsDay == nil &&
|
||||||
|
v.TemperatureC == nil &&
|
||||||
|
v.TemperatureF == nil &&
|
||||||
|
v.ApparentTemperatureC == nil &&
|
||||||
|
v.ApparentTemperatureF == nil &&
|
||||||
|
v.DewpointC == nil &&
|
||||||
|
v.DewpointF == nil &&
|
||||||
|
v.RelativeHumidityPercent == nil &&
|
||||||
|
v.WindSpeedKmh == nil &&
|
||||||
|
v.WindSpeedMph == nil &&
|
||||||
|
v.WindDirection == "" &&
|
||||||
|
v.WindDirectionText == ""
|
||||||
|
}
|
||||||
@@ -1,327 +0,0 @@
|
|||||||
package briefing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
|
||||||
bundle := loadBundleFixture(t)
|
|
||||||
currentIsDay := true
|
|
||||||
currentTemp := 75.9
|
|
||||||
currentFeelsLike := 76.1
|
|
||||||
currentHumidity := 56.0
|
|
||||||
currentWind := 10.7
|
|
||||||
bundle.Current = &forecast.Current{
|
|
||||||
ConditionText: "Partly cloudy",
|
|
||||||
IsDay: ¤tIsDay,
|
|
||||||
TemperatureF: ¤tTemp,
|
|
||||||
ApparentTemperatureF: ¤tFeelsLike,
|
|
||||||
RelativeHumidityPercent: ¤tHumidity,
|
|
||||||
WindSpeedMph: ¤tWind,
|
|
||||||
}
|
|
||||||
bundle.Sources[0].DataSHA256 = "abc123"
|
|
||||||
bundle.Warnings = []forecast.SourceWarning{{Source: "daily", Code: "missing_source", Severity: "warning"}}
|
|
||||||
location := mustLocation(t)
|
|
||||||
resolved := mustResolveDaily(t, location)
|
|
||||||
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildDailySummary() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
pkg, err := BuildDaily(BuildContext{
|
|
||||||
Resolved: resolved,
|
|
||||||
Bundle: bundle,
|
|
||||||
Units: "us",
|
|
||||||
Timezone: "America/Chicago",
|
|
||||||
Location: &LocationContext{
|
|
||||||
ID: "home",
|
|
||||||
Name: "Brentwood",
|
|
||||||
Region: "St. Louis Metro",
|
|
||||||
Timezone: "America/Chicago",
|
|
||||||
},
|
|
||||||
}, summary)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildDaily() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if pkg.Metadata.SchemaVersion != SchemaVersion {
|
|
||||||
t.Fatalf("SchemaVersion = %q, want %q", pkg.Metadata.SchemaVersion, SchemaVersion)
|
|
||||||
}
|
|
||||||
if !strings.Contains(pkg.Metadata.RunID, "daily_today") {
|
|
||||||
t.Fatalf("RunID = %q, want report id", pkg.Metadata.RunID)
|
|
||||||
}
|
|
||||||
if pkg.Metadata.ReportID != report.DailyToday {
|
|
||||||
t.Fatalf("ReportID = %q, want daily_today", pkg.Metadata.ReportID)
|
|
||||||
}
|
|
||||||
if pkg.Metadata.Units != "us" || pkg.Metadata.Timezone != "America/Chicago" {
|
|
||||||
t.Fatalf("metadata units/timezone = %q/%q", pkg.Metadata.Units, pkg.Metadata.Timezone)
|
|
||||||
}
|
|
||||||
if pkg.Metadata.Location == nil || pkg.Metadata.Location.ID != "home" || pkg.Metadata.Location.Name != "Brentwood" || pkg.Metadata.Location.Region != "St. Louis Metro" || pkg.Metadata.Location.Timezone != "America/Chicago" {
|
|
||||||
t.Fatalf("metadata location = %#v, want configured prompt location", pkg.Metadata.Location)
|
|
||||||
}
|
|
||||||
if pkg.CurrentConditions == nil || pkg.CurrentConditions.ConditionText != "Partly cloudy" || pkg.CurrentConditions.TemperatureF == nil || *pkg.CurrentConditions.TemperatureF != currentTemp || pkg.CurrentConditions.RelativeHumidityPercent == nil || *pkg.CurrentConditions.RelativeHumidityPercent != currentHumidity {
|
|
||||||
t.Fatalf("CurrentConditions = %#v, want current conditions from bundle", pkg.CurrentConditions)
|
|
||||||
}
|
|
||||||
if len(pkg.Metadata.Sources) != 1 || pkg.Metadata.Sources[0].DataSHA256 != "abc123" {
|
|
||||||
t.Fatalf("Sources = %#v, want source hash", pkg.Metadata.Sources)
|
|
||||||
}
|
|
||||||
if len(pkg.Metadata.SourceWarnings) != 1 {
|
|
||||||
t.Fatalf("SourceWarnings length = %d, want 1", len(pkg.Metadata.SourceWarnings))
|
|
||||||
}
|
|
||||||
if pkg.Daily == nil {
|
|
||||||
t.Fatal("Daily = nil")
|
|
||||||
}
|
|
||||||
if len(pkg.Daily.Dayparts) != 5 {
|
|
||||||
t.Fatalf("Dayparts length = %d, want 5", len(pkg.Daily.Dayparts))
|
|
||||||
}
|
|
||||||
if len(pkg.Daily.RelevantAlerts) != 1 {
|
|
||||||
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.Daily.RelevantAlerts))
|
|
||||||
}
|
|
||||||
if len(pkg.Daily.NarrativePeriods) != 1 {
|
|
||||||
t.Fatalf("NarrativePeriods length = %d, want 1", len(pkg.Daily.NarrativePeriods))
|
|
||||||
}
|
|
||||||
if len(pkg.Daily.Discussion.KeyMessages) != 1 {
|
|
||||||
t.Fatalf("Discussion key messages length = %d, want 1", len(pkg.Daily.Discussion.KeyMessages))
|
|
||||||
}
|
|
||||||
if pkg.Daily.Discussion.ShortTerm != "Morning showers taper as a weak boundary shifts east." {
|
|
||||||
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Daily.Discussion.ShortTerm)
|
|
||||||
}
|
|
||||||
if pkg.Daily.Discussion.LongTerm != "Warmer and more humid conditions return with periodic rain chances." {
|
|
||||||
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Daily.Discussion.LongTerm)
|
|
||||||
}
|
|
||||||
if pkg.Daily.OutdoorWindows.Best == nil || pkg.Daily.OutdoorWindows.Worst == nil {
|
|
||||||
t.Fatalf("OutdoorWindows = %#v, want best and worst", pkg.Daily.OutdoorWindows)
|
|
||||||
}
|
|
||||||
if pkg.Daily.BottomLine.Summary == "" {
|
|
||||||
t.Fatal("BottomLine summary is empty")
|
|
||||||
}
|
|
||||||
if _, err := json.Marshal(pkg); err != nil {
|
|
||||||
t.Fatalf("briefing package is not JSON inspectable: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDailyBriefingQuietWeather(t *testing.T) {
|
|
||||||
location := mustLocation(t)
|
|
||||||
resolved := mustResolveDaily(t, location)
|
|
||||||
bundle := &forecast.Bundle{
|
|
||||||
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{
|
|
||||||
quietHour("2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", 72),
|
|
||||||
}},
|
|
||||||
Alerts: &forecast.AlertRun{},
|
|
||||||
Sources: []forecast.Source{
|
|
||||||
{Name: "hourly", FetchedAt: time.Now()},
|
|
||||||
{Name: "alerts", Endpoint: "/alerts/active", FetchedAt: time.Now()},
|
|
||||||
{Name: "current", Endpoint: "/conditions/current", FetchedAt: time.Now(), Missing: true},
|
|
||||||
},
|
|
||||||
Warnings: []forecast.SourceWarning{{Source: "current", Code: "missing_source", Severity: "warning"}},
|
|
||||||
}
|
|
||||||
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildDailySummary() error = %v", err)
|
|
||||||
}
|
|
||||||
pkg, err := BuildDaily(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}, summary)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildDaily() error = %v", err)
|
|
||||||
}
|
|
||||||
if pkg.Daily.BottomLine.Summary != "Conditions: Clear." {
|
|
||||||
t.Fatalf("BottomLine summary = %q, want clear conditions", pkg.Daily.BottomLine.Summary)
|
|
||||||
}
|
|
||||||
if pkg.CurrentConditions != nil {
|
|
||||||
t.Fatalf("CurrentConditions = %#v, want nil when current conditions are missing", pkg.CurrentConditions)
|
|
||||||
}
|
|
||||||
if len(pkg.Metadata.SourceWarnings) != 1 || pkg.Metadata.SourceWarnings[0].Source != "current" {
|
|
||||||
t.Fatalf("SourceWarnings = %#v, want current missing-source warning", pkg.Metadata.SourceWarnings)
|
|
||||||
}
|
|
||||||
if len(pkg.Daily.RelevantAlerts) != 0 {
|
|
||||||
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts))
|
|
||||||
}
|
|
||||||
if pkg.Metadata.Alerts == nil {
|
|
||||||
t.Fatal("Metadata.Alerts = nil, want checked no-active-alerts status")
|
|
||||||
}
|
|
||||||
if !pkg.Metadata.Alerts.Checked || pkg.Metadata.Alerts.ActiveCount != 0 || pkg.Metadata.Alerts.RelevantCount != 0 || pkg.Metadata.Alerts.Missing {
|
|
||||||
t.Fatalf("Metadata.Alerts = %#v, want checked no-active-alerts status", pkg.Metadata.Alerts)
|
|
||||||
}
|
|
||||||
data, err := json.Marshal(pkg.Metadata.Alerts)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal alert metadata: %v", err)
|
|
||||||
}
|
|
||||||
if strings.Contains(string(data), `"missing"`) {
|
|
||||||
t.Fatalf("alert metadata includes missing for checked empty alerts:\n%s", string(data))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDailyBriefingAlertExclusion(t *testing.T) {
|
|
||||||
location := mustLocation(t)
|
|
||||||
resolved := mustResolveDaily(t, location)
|
|
||||||
bundle := loadBundleFixture(t)
|
|
||||||
bundle.Alerts = &forecast.AlertRun{Alerts: []json.RawMessage{
|
|
||||||
json.RawMessage(`{"event":"Future Watch","effective":"2026-06-01T00:00:00-05:00","expires":"2026-06-01T06:00:00-05:00"}`),
|
|
||||||
}}
|
|
||||||
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildDailySummary() error = %v", err)
|
|
||||||
}
|
|
||||||
pkg, err := BuildDaily(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}, summary)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildDaily() error = %v", err)
|
|
||||||
}
|
|
||||||
if len(pkg.Daily.RelevantAlerts) != 0 {
|
|
||||||
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTomorrowBriefingIncludesPlanningInputs(t *testing.T) {
|
|
||||||
location := mustLocation(t)
|
|
||||||
resolved, err := report.Resolve(report.DailyTomorrow, report.ResolveRequest{
|
|
||||||
Now: mustParse("2026-05-29T18:00:00-05:00"),
|
|
||||||
Location: location,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve tomorrow: %v", err)
|
|
||||||
}
|
|
||||||
precip := 70.0
|
|
||||||
wind := 34.0
|
|
||||||
summary := &forecast.DailySummary{
|
|
||||||
Date: "2026-05-30",
|
|
||||||
Period: resolved.ValidPeriod,
|
|
||||||
Dayparts: []forecast.DaypartSummary{
|
|
||||||
{
|
|
||||||
Name: "overnight",
|
|
||||||
Period: timeutil.Period{
|
|
||||||
Start: mustParse("2026-05-30T00:00:00-05:00"),
|
|
||||||
End: mustParse("2026-05-30T06:00:00-05:00"),
|
|
||||||
},
|
|
||||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
|
||||||
Value: 40,
|
|
||||||
Time: mustParse("2026-05-30T03:00:00-05:00"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "morning",
|
|
||||||
Period: timeutil.Period{
|
|
||||||
Start: mustParse("2026-05-30T06:00:00-05:00"),
|
|
||||||
End: mustParse("2026-05-30T12:00:00-05:00"),
|
|
||||||
},
|
|
||||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
|
||||||
Value: precip,
|
|
||||||
Time: mustParse("2026-05-30T08:00:00-05:00"),
|
|
||||||
},
|
|
||||||
PeakWindGust: &forecast.TimedValue{
|
|
||||||
Value: wind,
|
|
||||||
Time: mustParse("2026-05-30T09:00:00-05:00"),
|
|
||||||
},
|
|
||||||
Indicators: forecast.Indicators{Snow: true},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
pkg, err := BuildDaily(BuildContext{
|
|
||||||
Resolved: resolved,
|
|
||||||
Units: "us",
|
|
||||||
Timezone: "America/Chicago",
|
|
||||||
}, summary)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildDaily() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if pkg.Metadata.ReportID != report.DailyTomorrow || pkg.Metadata.Variant != "tomorrow" {
|
|
||||||
t.Fatalf("metadata report/variant = %q/%q, want tomorrow", pkg.Metadata.ReportID, pkg.Metadata.Variant)
|
|
||||||
}
|
|
||||||
if pkg.Daily.ForecastSummaryDate != "2026-05-30" {
|
|
||||||
t.Fatalf("ForecastSummaryDate = %q, want 2026-05-30", pkg.Daily.ForecastSummaryDate)
|
|
||||||
}
|
|
||||||
if pkg.Daily.Planning == nil {
|
|
||||||
t.Fatal("Planning = nil, want tomorrow planning inputs")
|
|
||||||
}
|
|
||||||
if len(pkg.Daily.Planning.MorningReadiness) == 0 || len(pkg.Daily.Planning.CommuteSchoolWorkdayConcerns) == 0 || len(pkg.Daily.Planning.OvernightChangeWatch) == 0 {
|
|
||||||
t.Fatalf("Planning = %#v, want populated planning inputs", pkg.Daily.Planning)
|
|
||||||
}
|
|
||||||
if !strings.Contains(strings.Join(pkg.Daily.Planning.MorningReadiness, " "), "precipitation") {
|
|
||||||
t.Fatalf("MorningReadiness = %#v, want precipitation note", pkg.Daily.Planning.MorningReadiness)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSaveBriefingPackage(t *testing.T) {
|
|
||||||
pkg := Package{Metadata: Metadata{SchemaVersion: SchemaVersion}}
|
|
||||||
path := filepath.Join(t.TempDir(), "nested", "briefing.json")
|
|
||||||
if err := Save(path, pkg); err != nil {
|
|
||||||
t.Fatalf("Save() error = %v", err)
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read briefing: %v", err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(data), SchemaVersion) {
|
|
||||||
t.Fatalf("saved briefing missing schema version:\n%s", string(data))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadBundleFixture(t *testing.T) *forecast.Bundle {
|
|
||||||
t.Helper()
|
|
||||||
data, err := os.ReadFile(filepath.Join("..", "forecast", "testdata", "daily_bundle.json"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read bundle fixture: %v", err)
|
|
||||||
}
|
|
||||||
var bundle forecast.Bundle
|
|
||||||
if err := json.Unmarshal(data, &bundle); err != nil {
|
|
||||||
t.Fatalf("decode bundle fixture: %v", err)
|
|
||||||
}
|
|
||||||
return &bundle
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustResolveDaily(t *testing.T, location *time.Location) report.Resolved {
|
|
||||||
t.Helper()
|
|
||||||
resolved, err := report.Resolve(report.DailyToday, report.ResolveRequest{
|
|
||||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
|
||||||
Location: location,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve daily: %v", err)
|
|
||||||
}
|
|
||||||
return resolved
|
|
||||||
}
|
|
||||||
|
|
||||||
func defaultDayparts() []forecast.DaypartDefinition {
|
|
||||||
return []forecast.DaypartDefinition{
|
|
||||||
{Name: "overnight", Start: "00:00", End: "06:00"},
|
|
||||||
{Name: "morning", Start: "06:00", End: "10:00"},
|
|
||||||
{Name: "midday", Start: "10:00", End: "15:00"},
|
|
||||||
{Name: "afternoon", Start: "15:00", End: "17:00"},
|
|
||||||
{Name: "evening", Start: "17:00", End: "24:00"},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func quietHour(start string, end string, temperature float64) forecast.ForecastPeriod {
|
|
||||||
return forecast.ForecastPeriod{
|
|
||||||
StartTime: mustParse(start),
|
|
||||||
EndTime: mustParse(end),
|
|
||||||
TextDescription: "Clear",
|
|
||||||
TemperatureF: &temperature,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustLocation(t *testing.T) *time.Location {
|
|
||||||
t.Helper()
|
|
||||||
location, err := time.LoadLocation("America/Chicago")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load location: %v", err)
|
|
||||||
}
|
|
||||||
return location
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustParse(value string) time.Time {
|
|
||||||
parsed, err := time.Parse(time.RFC3339, value)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return parsed
|
|
||||||
}
|
|
||||||
153
internal/briefing/derived_daily_summary_module.go
Normal file
153
internal/briefing/derived_daily_summary_module.go
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DerivedDailySummaryModule struct {
|
||||||
|
Date string `json:"date,omitempty"`
|
||||||
|
HighTempF *int `json:"high_temp_f,omitempty"`
|
||||||
|
LowTempF *int `json:"low_temp_f,omitempty"`
|
||||||
|
DailyPrecipitationProbability *int `json:"daily_precipitation_probability,omitempty"`
|
||||||
|
MostLikelyPrecipitationHour string `json:"most_likely_precipitation_hour,omitempty"`
|
||||||
|
ThunderMentioned bool `json:"thunder_mentioned"`
|
||||||
|
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||||
|
HeatIndexMaxF *int `json:"heat_index_max_f,omitempty"`
|
||||||
|
DominantConditions []string `json:"dominant_conditions,omitempty"`
|
||||||
|
Hazards []string `json:"hazards,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDerivedDailySummaryModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
summary := ctx.Derived.FirstDailySummary()
|
||||||
|
if summary == nil {
|
||||||
|
return nil, fmt.Errorf("daily summary facts are required")
|
||||||
|
}
|
||||||
|
value, err := derivedDailySummaryValue(*summary, ctx.Derived.PrecipTiming, ctx.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.PrecipTiming, timezone string) (DerivedDailySummaryModule, error) {
|
||||||
|
value := DerivedDailySummaryModule{
|
||||||
|
Date: friendlyDateLabel(summary.Date, timezone),
|
||||||
|
ThunderMentioned: timing.ThunderMentioned,
|
||||||
|
}
|
||||||
|
conditions := map[string]struct{}{}
|
||||||
|
hazards := map[string]struct{}{}
|
||||||
|
var temperature forecast.Range
|
||||||
|
var apparent forecast.Range
|
||||||
|
var maxPop *forecast.TimedValue
|
||||||
|
var maxGust *forecast.TimedValue
|
||||||
|
for _, daypart := range summary.Dayparts {
|
||||||
|
addRange(&temperature, daypart.Temperature)
|
||||||
|
addRange(&apparent, daypart.ApparentTemperature)
|
||||||
|
maxTimedValue(&maxPop, daypart.MaxPrecipitationProbability)
|
||||||
|
maxTimedValue(&maxGust, daypart.PeakWindGust)
|
||||||
|
if daypart.DominantCondition != "" {
|
||||||
|
conditions[daypart.DominantCondition] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, hazard := range hazardsForIndicators(daypart.Indicators) {
|
||||||
|
hazards[hazard] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, alert := range summary.AlertOverlaps {
|
||||||
|
if alert.Event != "" {
|
||||||
|
hazards[alert.Event] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
narrativeTemperature := narrativeTemperatureRange(summary.NarrativePeriods)
|
||||||
|
if narrativeTemperature.Max != nil {
|
||||||
|
value.HighTempF = roundedInt(narrativeTemperature.Max)
|
||||||
|
} else {
|
||||||
|
value.HighTempF = roundedInt(temperature.Max)
|
||||||
|
}
|
||||||
|
if narrativeTemperature.Min != nil {
|
||||||
|
value.LowTempF = roundedInt(narrativeTemperature.Min)
|
||||||
|
} else {
|
||||||
|
value.LowTempF = roundedInt(temperature.Min)
|
||||||
|
}
|
||||||
|
value.HeatIndexMaxF = roundedInt(apparent.Max)
|
||||||
|
narrativePrecipitation := narrativeMaxPrecipitation(summary.NarrativePeriods)
|
||||||
|
if narrativePrecipitation != nil {
|
||||||
|
value.DailyPrecipitationProbability = roundedInt(&narrativePrecipitation.Value)
|
||||||
|
} else if maxPop != nil {
|
||||||
|
value.DailyPrecipitationProbability = roundedInt(&maxPop.Value)
|
||||||
|
}
|
||||||
|
if maxPop != nil {
|
||||||
|
value.MostLikelyPrecipitationHour = mostLikelyPrecipitationHour(maxPop, timezone)
|
||||||
|
}
|
||||||
|
if maxGust != nil {
|
||||||
|
value.MaxWindGustMph = roundedInt(&maxGust.Value)
|
||||||
|
}
|
||||||
|
value.DominantConditions = sortedSet(conditions)
|
||||||
|
value.Hazards = sortedSet(hazards)
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func narrativeTemperatureRange(periods []weatherdata.ForecastPeriod) forecast.Range {
|
||||||
|
var out forecast.Range
|
||||||
|
for _, period := range periods {
|
||||||
|
addNarrativeHigh(&out, period.TemperatureFMax)
|
||||||
|
addNarrativeLow(&out, period.TemperatureFMin)
|
||||||
|
if period.TemperatureF != nil && period.IsDay != nil {
|
||||||
|
if *period.IsDay {
|
||||||
|
addNarrativeHigh(&out, period.TemperatureF)
|
||||||
|
} else {
|
||||||
|
addNarrativeLow(&out, period.TemperatureF)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func addNarrativeHigh(target *forecast.Range, value *float64) {
|
||||||
|
if value == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if target.Max == nil || *value > *target.Max {
|
||||||
|
copied := *value
|
||||||
|
target.Max = &copied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addNarrativeLow(target *forecast.Range, value *float64) {
|
||||||
|
if value == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if target.Min == nil || *value < *target.Min {
|
||||||
|
copied := *value
|
||||||
|
target.Min = &copied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func narrativeMaxPrecipitation(periods []weatherdata.ForecastPeriod) *forecast.TimedValue {
|
||||||
|
var maxPop *forecast.TimedValue
|
||||||
|
for _, period := range periods {
|
||||||
|
if period.ProbabilityOfPrecipitationPercent == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value := forecast.TimedValue{
|
||||||
|
Value: *period.ProbabilityOfPrecipitationPercent,
|
||||||
|
Time: period.StartTime,
|
||||||
|
}
|
||||||
|
maxTimedValue(&maxPop, &value)
|
||||||
|
}
|
||||||
|
return maxPop
|
||||||
|
}
|
||||||
|
|
||||||
|
func mostLikelyPrecipitationHour(maxPop *forecast.TimedValue, timezone string) string {
|
||||||
|
if maxPop == nil || maxPop.Value <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
percent := roundedInt(&maxPop.Value)
|
||||||
|
if percent == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d%% at %s", *percent, clockLabel(maxPop.Time, timezone))
|
||||||
|
}
|
||||||
337
internal/briefing/derived_daypart_summaries_module.go
Normal file
337
internal/briefing/derived_daypart_summaries_module.go
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DerivedDaypartSummaryModule 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"`
|
||||||
|
TemperaturePhraseF string `json:"temperature_phrase_f,omitempty"`
|
||||||
|
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
||||||
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||||
|
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||||
|
MaxPopTimeLabel string `json:"max_pop_time_label,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"`
|
||||||
|
DominantConditionLower string `json:"dominant_condition_lower,omitempty"`
|
||||||
|
DominantConditionDisplay string `json:"dominant_condition_display,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) {
|
||||||
|
if len(ctx.Derived.DaypartSummaries) == 0 {
|
||||||
|
return nil, fmt.Errorf("daypart summary facts are required")
|
||||||
|
}
|
||||||
|
value := map[string]DerivedDaypartSummaryModule{}
|
||||||
|
prefixDates := multipleSummaryDates(ctx.Derived.DailySummaries)
|
||||||
|
for _, daypart := range ctx.Derived.DaypartSummaries {
|
||||||
|
key := daypartKey(daypart, prefixDates)
|
||||||
|
value[key] = derivedDaypartSummaryValue(daypart, ctx.Timezone)
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
|
||||||
|
temperature := daypartTemperatureDisplay(daypart)
|
||||||
|
value := DerivedDaypartSummaryModule{
|
||||||
|
Date: localDateLabel(daypart.Period.Start, timezone),
|
||||||
|
DisplayName: titleWord(strings.TrimSpace(daypart.Name)),
|
||||||
|
PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone),
|
||||||
|
PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone),
|
||||||
|
TempRangeF: rangeLabel(daypart.Temperature),
|
||||||
|
TemperaturePhraseF: temperaturePhraseF(daypart.Temperature),
|
||||||
|
TemperatureTrend: temperature.Trend,
|
||||||
|
TemperatureStartPhraseF: temperature.StartPhrase,
|
||||||
|
TemperatureEndPhraseF: temperature.EndPhrase,
|
||||||
|
TemperaturePeakPhraseF: temperature.PeakPhrase,
|
||||||
|
TemperatureSteadyPhraseF: temperature.SteadyPhrase,
|
||||||
|
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
|
||||||
|
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 {
|
||||||
|
value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value)
|
||||||
|
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 {
|
||||||
|
value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value)
|
||||||
|
value.MaxWindGustTime = clockLabel(daypart.PeakWindGust.Time, timezone)
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, summary := range summaries {
|
||||||
|
seen[summary.Date] = struct{}{}
|
||||||
|
}
|
||||||
|
return len(seen) > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func daypartKey(daypart forecast.DaypartSummary, prefixDate bool) string {
|
||||||
|
key := normalizedKey(daypart.Name)
|
||||||
|
if key == "" {
|
||||||
|
key = "unnamed"
|
||||||
|
}
|
||||||
|
if !prefixDate {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return daypart.Period.Start.Format(timeutil.DateLayout) + "_" + key
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedKey(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(), "_")
|
||||||
|
}
|
||||||
488
internal/briefing/derived_modules_test.go
Normal file
488
internal/briefing/derived_modules_test.go
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := derivedModuleContext(report.DailyToday)
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
value := moduleValue[DerivedDailySummaryModule](t, output)
|
||||||
|
|
||||||
|
if value.Date != "Friday, May 29, 2026" {
|
||||||
|
t.Fatalf("Date = %q, want friendly local date", value.Date)
|
||||||
|
}
|
||||||
|
if value.HighTempF == nil || *value.HighTempF != 88 || value.LowTempF == nil || *value.LowTempF != 64 {
|
||||||
|
t.Fatalf("daily temperatures = %#v/%#v, want narrative 88/64", value.HighTempF, value.LowTempF)
|
||||||
|
}
|
||||||
|
if value.DailyPrecipitationProbability == nil || *value.DailyPrecipitationProbability != 55 {
|
||||||
|
t.Fatalf("DailyPrecipitationProbability = %#v, want narrative 55", value.DailyPrecipitationProbability)
|
||||||
|
}
|
||||||
|
if value.MostLikelyPrecipitationHour != "80% at 12 PM" || !value.ThunderMentioned {
|
||||||
|
t.Fatalf("precip timing = %#v, want most likely hour and thunder", value)
|
||||||
|
}
|
||||||
|
if !containsString(value.DominantConditions, "Thunderstorms with gusty wind") || containsString(value.DominantConditions, "Morning storms, then partly sunny.") {
|
||||||
|
t.Fatalf("DominantConditions = %#v, want daypart conditions rather than narrative conditions", value.DominantConditions)
|
||||||
|
}
|
||||||
|
if value.MaxWindGustMph == nil || *value.MaxWindGustMph != 42 {
|
||||||
|
t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph)
|
||||||
|
}
|
||||||
|
if value.HeatIndexMaxF == nil || *value.HeatIndexMaxF != 101 {
|
||||||
|
t.Fatalf("HeatIndexMaxF = %#v, want 101", value.HeatIndexMaxF)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(output.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal daily summary: %v", err)
|
||||||
|
}
|
||||||
|
jsonText := string(data)
|
||||||
|
for _, field := range []string{"high_temp_f", "low_temp_f", "daily_precipitation_probability", "most_likely_precipitation_hour", "heat_index_max_f"} {
|
||||||
|
if !strings.Contains(jsonText, field) {
|
||||||
|
t.Fatalf("daily json = %s, want field %s", jsonText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, removed := range []string{"max_pop_percent", "max_pop_window", "first_precip_hour", "last_precip_hour"} {
|
||||||
|
if strings.Contains(jsonText, removed) {
|
||||||
|
t.Fatalf("daily json = %s, want removed field %s omitted", jsonText, removed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(jsonText, "qpf") {
|
||||||
|
t.Fatalf("daily json = %s, want no QPF fields without upstream QPF facts", jsonText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := derivedModuleContext(report.DailyToday)
|
||||||
|
ctx.Derived.DailySummaries[0].NarrativePeriods = nil
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
value := moduleValue[DerivedDailySummaryModule](t, output)
|
||||||
|
|
||||||
|
if value.HighTempF == nil || *value.HighTempF != 96 || value.LowTempF == nil || *value.LowTempF != 31 {
|
||||||
|
t.Fatalf("daily temperatures = %#v/%#v, want fallback 96/31", value.HighTempF, value.LowTempF)
|
||||||
|
}
|
||||||
|
if value.DailyPrecipitationProbability == nil || *value.DailyPrecipitationProbability != 80 {
|
||||||
|
t.Fatalf("DailyPrecipitationProbability = %#v, want hourly fallback 80", value.DailyPrecipitationProbability)
|
||||||
|
}
|
||||||
|
if len(value.DominantConditions) == 0 || !containsString(value.DominantConditions, "Thunderstorms with gusty wind") {
|
||||||
|
t.Fatalf("DominantConditions = %#v, want fallback daypart conditions", value.DominantConditions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := derivedModuleContext(report.DailyToday)
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(rainy) error = %v", err)
|
||||||
|
}
|
||||||
|
rainy := moduleValue[PrecipTimingModule](t, output)
|
||||||
|
if rainy.MaxPopPercent == nil || *rainy.MaxPopPercent != 80 || rainy.MaxPopTime != "12 PM" || rainy.ProbabilityThreshold != forecast.DefaultPrecipWindowProbabilityThreshold || !rainy.ThunderMentioned {
|
||||||
|
t.Fatalf("rainy precip timing = %#v, want peak, threshold, and thunder", rainy)
|
||||||
|
}
|
||||||
|
if len(rainy.PrecipitationWindows) != 2 {
|
||||||
|
t.Fatalf("rainy precipitation windows = %#v, want two windows", rainy.PrecipitationWindows)
|
||||||
|
}
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(output.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal precip timing: %v", err)
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), `"start"`) || strings.Contains(string(data), `"end"`) {
|
||||||
|
t.Fatalf("precip timing json = %s, want period_begins/period_ends instead of start/end", string(data))
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), "first_precip_hour") || strings.Contains(string(data), "last_precip_hour") {
|
||||||
|
t.Fatalf("precip timing json = %s, want no ambiguous first/last fields", string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Derived.PrecipTiming = forecast.BuildPrecipTiming([]weatherdata.ForecastPeriod{derivedHour("2026-05-29T10:00:00-05:00", "Sunny", 0, 70, nil, 5)})
|
||||||
|
output, err = registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(dry) error = %v", err)
|
||||||
|
}
|
||||||
|
dry := moduleValue[PrecipTimingModule](t, output)
|
||||||
|
if len(dry.PrecipitationWindows) != 0 || dry.ThunderMentioned {
|
||||||
|
t.Fatalf("dry precip timing = %#v, want no precip windows and no thunder", dry)
|
||||||
|
}
|
||||||
|
if dry.MaxPopPercent == nil || *dry.MaxPopPercent != 0 {
|
||||||
|
t.Fatalf("dry MaxPopPercent = %#v, want checked zero", dry.MaxPopPercent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrecipTimingModuleUsesDerivedTimingWithoutDaypartSummaries(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := derivedModuleContext(report.Hourly)
|
||||||
|
ctx.Derived.DailySummaries = nil
|
||||||
|
ctx.Derived.DaypartSummaries = nil
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
value := moduleValue[PrecipTimingModule](t, output)
|
||||||
|
if value.MaxPopPercent == nil || *value.MaxPopPercent != 80 || len(value.PrecipitationWindows) != 2 {
|
||||||
|
t.Fatalf("precip timing = %#v, want derived timing without daypart summaries", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := derivedModuleContext(report.DailyToday)
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
value := moduleValue[map[string]DerivedDaypartSummaryModule](t, output)
|
||||||
|
|
||||||
|
morning, ok := value["morning"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("daypart keys = %#v, want configured morning key", value)
|
||||||
|
}
|
||||||
|
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 {
|
||||||
|
t.Fatalf("morning = %#v, want temp range and precip peak", morning)
|
||||||
|
}
|
||||||
|
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 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"]
|
||||||
|
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 {
|
||||||
|
t.Fatalf("afternoon = %#v, want heat and wind hazard values", afternoon)
|
||||||
|
}
|
||||||
|
if !overnight.Cold {
|
||||||
|
t.Fatalf("overnight = %#v, want cold hazard", overnight)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(output.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal daypart summaries: %v", err)
|
||||||
|
}
|
||||||
|
jsonText := string(data)
|
||||||
|
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) {
|
||||||
|
t.Fatalf("daypart json = %s, want field %s", jsonText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := derivedModuleContext(report.Tomorrow)
|
||||||
|
|
||||||
|
outdoorOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(outdoor windows) error = %v", err)
|
||||||
|
}
|
||||||
|
outdoor := moduleValue[OutdoorWindowsModule](t, outdoorOutput)
|
||||||
|
if outdoor.Best == nil || outdoor.Worst == nil {
|
||||||
|
t.Fatalf("outdoor windows = %#v, want best and worst", outdoor)
|
||||||
|
}
|
||||||
|
if outdoor.Best.Daypart != "overnight" || outdoor.Worst.Daypart != "afternoon" {
|
||||||
|
t.Fatalf("outdoor windows = %#v, want quiet overnight and stormy afternoon", outdoor)
|
||||||
|
}
|
||||||
|
if outdoor.Best.PeriodBegins != "2026-05-29 at 12:00 AM" || outdoor.Best.PeriodEnds != "2026-05-29 at 6:00 AM" {
|
||||||
|
t.Fatalf("best outdoor period = %#v, want overnight period labels", outdoor.Best)
|
||||||
|
}
|
||||||
|
if outdoor.Worst.PeriodBegins != "2026-05-29 at 12:00 PM" || outdoor.Worst.PeriodEnds != "2026-05-29 at 6:00 PM" {
|
||||||
|
t.Fatalf("worst outdoor period = %#v, want afternoon period labels", outdoor.Worst)
|
||||||
|
}
|
||||||
|
|
||||||
|
planningOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TomorrowPlanning})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(tomorrow planning) error = %v", err)
|
||||||
|
}
|
||||||
|
planning := moduleValue[TomorrowPlanningModule](t, planningOutput)
|
||||||
|
if len(planning.MorningReadiness) == 0 || len(planning.CommuteSchoolWorkdayConcerns) == 0 || len(planning.OvernightChangeWatch) == 0 {
|
||||||
|
t.Fatalf("tomorrow planning = %#v, want daily planning notes", planning)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(planningOutput.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal tomorrow planning: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "morning_readiness") || strings.Contains(string(data), "morningReadiness") {
|
||||||
|
t.Fatalf("tomorrow planning json = %s, want snake_case fields", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDerivedModulesHandleMissingData(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := derivedModuleContext(report.DailyToday)
|
||||||
|
ctx.Derived.DailySummaries = nil
|
||||||
|
ctx.Derived.DaypartSummaries = nil
|
||||||
|
|
||||||
|
if _, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary}); err == nil {
|
||||||
|
t.Fatal("BuildModule(derived daily summary) error = nil, want required facts error")
|
||||||
|
}
|
||||||
|
if _, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries}); err == nil {
|
||||||
|
t.Fatal("BuildModule(daypart summaries) error = nil, want required facts error")
|
||||||
|
}
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(outdoor windows) error = %v", err)
|
||||||
|
}
|
||||||
|
windows := moduleValue[OutdoorWindowsModule](t, output)
|
||||||
|
if windows.Best != nil || windows.Worst != nil {
|
||||||
|
t.Fatalf("outdoor windows = %#v, want empty output with missing dayparts", windows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func derivedModuleContext(id report.ID) ModuleContext {
|
||||||
|
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
|
||||||
|
definition := report.DefaultRegistry().MustLookup(id)
|
||||||
|
summary := forecast.DailySummary{
|
||||||
|
Date: "2026-05-29",
|
||||||
|
Period: timeutil.Period{
|
||||||
|
Start: mustParseModuleTime("2026-05-29T00:00:00-05:00"),
|
||||||
|
End: mustParseModuleTime("2026-05-30T00:00:00-05:00"),
|
||||||
|
},
|
||||||
|
Dayparts: []forecast.DaypartSummary{
|
||||||
|
derivedDaypart("overnight", "2026-05-29T00:00:00-05:00", "2026-05-29T06:00:00-05:00", "Clear and cold", 31, nil, 0, 5),
|
||||||
|
derivedDaypart("morning", "2026-05-29T06:00:00-05:00", "2026-05-29T12:00:00-05:00", "Showers", 58, nil, 60, 15),
|
||||||
|
derivedDaypart("afternoon", "2026-05-29T12:00:00-05:00", "2026-05-29T18:00:00-05:00", "Thunderstorms with gusty wind", 96, floatPtr(101), 80, 42),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
hours := []weatherdata.ForecastPeriod{
|
||||||
|
derivedHour("2026-05-29T00:00:00-05:00", "Clear and cold", 0, 31, nil, 5),
|
||||||
|
derivedHour("2026-05-29T08:00:00-05:00", "Showers", 60, 58, nil, 15),
|
||||||
|
derivedHour("2026-05-29T09:00:00-05:00", "Dry break", 20, 62, nil, 10),
|
||||||
|
derivedHour("2026-05-29T12:00:00-05:00", "Thunderstorms with gusty wind", 80, 96, floatPtr(101), 42),
|
||||||
|
derivedHour("2026-05-29T13:00:00-05:00", "Heavy rain", 70, 82, nil, 30),
|
||||||
|
derivedHour("2026-05-29T14:00:00-05:00", "Drying out", 20, 78, nil, 12),
|
||||||
|
}
|
||||||
|
narrative := []weatherdata.ForecastPeriod{
|
||||||
|
{
|
||||||
|
Name: "Today",
|
||||||
|
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
|
||||||
|
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
|
||||||
|
IsDay: boolPtr(true),
|
||||||
|
TextDescription: "Morning storms, then partly sunny.",
|
||||||
|
TemperatureFMax: floatPtr(88),
|
||||||
|
ProbabilityOfPrecipitationPercent: floatPtr(55),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Tonight",
|
||||||
|
StartTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
|
||||||
|
EndTime: mustParseModuleTime("2026-05-30T00:00:00-05:00"),
|
||||||
|
IsDay: boolPtr(false),
|
||||||
|
TextDescription: "Clouds linger tonight.",
|
||||||
|
TemperatureFMin: floatPtr(64),
|
||||||
|
ProbabilityOfPrecipitationPercent: floatPtr(30),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
summary.Dayparts[2].AlertOverlaps = []forecast.AlertOverlap{{Event: "Severe Thunderstorm Watch"}}
|
||||||
|
summary.NarrativePeriods = append([]weatherdata.ForecastPeriod(nil), narrative...)
|
||||||
|
return ModuleContext{
|
||||||
|
Resolved: report.Resolved{
|
||||||
|
Definition: definition,
|
||||||
|
GeneratedAt: generatedAt,
|
||||||
|
Timezone: "America/Chicago",
|
||||||
|
ValidPeriod: summary.Period,
|
||||||
|
},
|
||||||
|
Collected: facts.CollectedFacts{
|
||||||
|
Narrative: &weatherdata.ForecastRun{
|
||||||
|
IssuedAt: mustParseModuleTime("2026-05-29T10:30:00-05:00"),
|
||||||
|
Product: "narrative",
|
||||||
|
Periods: append([]weatherdata.ForecastPeriod(nil), narrative...),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Derived: facts.DerivedFacts{
|
||||||
|
ValidPeriodHourlyPeriods: hours,
|
||||||
|
ValidPeriodNarrativePeriods: narrative,
|
||||||
|
DailySummaries: []forecast.DailySummary{summary},
|
||||||
|
DaypartSummaries: append([]forecast.DaypartSummary(nil), summary.Dayparts...),
|
||||||
|
PrecipTiming: forecast.BuildPrecipTiming(hours),
|
||||||
|
},
|
||||||
|
Units: "us",
|
||||||
|
Timezone: "America/Chicago",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
return forecast.SummarizeDaypart(name, timeutil.Period{
|
||||||
|
Start: mustParseModuleTime(start),
|
||||||
|
End: mustParseModuleTime(end),
|
||||||
|
}, []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 {
|
||||||
|
startTime := mustParseModuleTime(start)
|
||||||
|
endTime := startTime.Add(time.Hour)
|
||||||
|
return weatherdata.ForecastPeriod{
|
||||||
|
StartTime: startTime,
|
||||||
|
EndTime: endTime,
|
||||||
|
TextDescription: text,
|
||||||
|
TemperatureF: floatPtr(temperature),
|
||||||
|
ApparentTemperatureF: apparent,
|
||||||
|
ProbabilityOfPrecipitationPercent: floatPtr(precip),
|
||||||
|
WindGustMph: floatPtr(gust),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func floatPtr(value float64) *float64 {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolPtr(value bool) *bool {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsString(values []string, want string) bool {
|
||||||
|
for _, value := range values {
|
||||||
|
if value == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
142
internal/briefing/hourly_forecast_module.go
Normal file
142
internal/briefing/hourly_forecast_module.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultHourlyForecastPrecipMentionProbabilityThreshold = 20
|
||||||
|
|
||||||
|
type HourlyForecastModule 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 []HourlyForecastPeriod `json:"periods,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HourlyForecastPeriod struct {
|
||||||
|
HourLabel string `json:"hour_label,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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildHourlyForecastModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
hourly := ctx.Collected.Hourly
|
||||||
|
if hourly == nil || len(ctx.Derived.ValidPeriodHourlyPeriods) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
value := HourlyForecastModule{
|
||||||
|
Product: hourly.Product,
|
||||||
|
IssuedAt: hourly.IssuedAt,
|
||||||
|
UpdatedAt: copyTime(hourly.UpdatedAt),
|
||||||
|
SourceLocation: hourly.LocationName,
|
||||||
|
SourceLocationID: hourly.LocationID,
|
||||||
|
Periods: hourlyForecastPeriods(ctx.Derived.ValidPeriodHourlyPeriods, ctx.Timezone),
|
||||||
|
}
|
||||||
|
if value.isEmpty() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.HourlyForecast, StanzaName: "hourly_forecast", Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
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),
|
||||||
|
MentionPrecipitation: mentionHourlyForecastPrecipitation(period.ProbabilityOfPrecipitationPercent, threshold),
|
||||||
|
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 mentionHourlyForecastPrecipitation(probability *float64, threshold float64) bool {
|
||||||
|
return probability != nil && *probability >= threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v HourlyForecastModule) isEmpty() bool {
|
||||||
|
return v.Product == "" &&
|
||||||
|
v.IssuedAt.IsZero() &&
|
||||||
|
v.UpdatedAt == nil &&
|
||||||
|
v.SourceLocation == "" &&
|
||||||
|
v.SourceLocationID == "" &&
|
||||||
|
len(v.Periods) == 0
|
||||||
|
}
|
||||||
64
internal/briefing/metadata_module.go
Normal file
64
internal/briefing/metadata_module.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MetadataModule struct {
|
||||||
|
RunID string `json:"run_id"`
|
||||||
|
ReportID report.ID `json:"report_id"`
|
||||||
|
Variant string `json:"variant,omitempty"`
|
||||||
|
PromptID string `json:"prompt_id"`
|
||||||
|
GeneratedAt time.Time `json:"generated_at"`
|
||||||
|
Units string `json:"units"`
|
||||||
|
Timezone string `json:"timezone"`
|
||||||
|
ValidPeriod timeutil.Period `json:"valid_period"`
|
||||||
|
Location *LocationContext `json:"location,omitempty"`
|
||||||
|
SourceWarnings []SourceWarningSummary `json:"source_warnings,omitempty"`
|
||||||
|
Alerts *AlertDigestModule `json:"alerts,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SourceWarningSummary struct {
|
||||||
|
Source string `json:"source"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
CompletenessImpact string `json:"completeness_impact,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMetadataModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
metadata := ctx.Resolved.Metadata()
|
||||||
|
value := MetadataModule{
|
||||||
|
RunID: metadata.RunID,
|
||||||
|
ReportID: metadata.ReportID,
|
||||||
|
Variant: variantForReport(metadata.ReportID),
|
||||||
|
PromptID: metadata.PromptID,
|
||||||
|
GeneratedAt: metadata.GeneratedAt,
|
||||||
|
Units: ctx.Units,
|
||||||
|
Timezone: ctx.Timezone,
|
||||||
|
ValidPeriod: metadata.ValidPeriod,
|
||||||
|
Location: copyLocation(ctx.Location),
|
||||||
|
SourceWarnings: sourceWarningSummaries(ctx.Collected.SourceWarnings),
|
||||||
|
Alerts: alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps),
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceWarningSummaries(warnings []weatherdata.SourceWarning) []SourceWarningSummary {
|
||||||
|
out := make([]SourceWarningSummary, 0, len(warnings))
|
||||||
|
for _, warning := range warnings {
|
||||||
|
out = append(out, SourceWarningSummary{
|
||||||
|
Source: warning.Source,
|
||||||
|
Code: warning.Code,
|
||||||
|
Severity: warning.Severity,
|
||||||
|
Message: warning.Message,
|
||||||
|
CompletenessImpact: warning.CompletenessImpact,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
166
internal/briefing/module_format_helpers.go
Normal file
166
internal/briefing/module_format_helpers.go
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func rangeLabel(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 && *low == *high {
|
||||||
|
return fmt.Sprintf("%d", *low)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d-%d", *low, *high)
|
||||||
|
}
|
||||||
|
if value.Min != nil {
|
||||||
|
low := roundedInt(value.Min)
|
||||||
|
return fmt.Sprintf("%d", *low)
|
||||||
|
}
|
||||||
|
high := roundedInt(value.Max)
|
||||||
|
return fmt.Sprintf("%d", *high)
|
||||||
|
}
|
||||||
|
|
||||||
|
func daypartApparentRangeLabel(value forecast.Range) string {
|
||||||
|
if value.Min == nil && value.Max == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return rangeLabel(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func roundedInt(value *float64) *int {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rounded := int(*value + 0.5)
|
||||||
|
if *value < 0 {
|
||||||
|
rounded = int(*value - 0.5)
|
||||||
|
}
|
||||||
|
return &rounded
|
||||||
|
}
|
||||||
|
|
||||||
|
func windDirectionLabel(degrees *float64) string {
|
||||||
|
if degrees == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
labels := []string{"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"}
|
||||||
|
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 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 {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return clockLabel(value.Time, timezone)
|
||||||
|
}
|
||||||
|
|
||||||
|
func friendlyPeriodBeginsLabel(period timeutil.Period, timezone string) string {
|
||||||
|
if !period.IsValid() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return friendlyDateTimeLabel(period.Start, timezone)
|
||||||
|
}
|
||||||
|
|
||||||
|
func friendlyPeriodEndsLabel(period timeutil.Period, timezone string) string {
|
||||||
|
if !period.IsValid() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return friendlyDateTimeLabel(period.End, timezone)
|
||||||
|
}
|
||||||
|
|
||||||
|
func friendlyDateTimeLabel(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("2006-01-02 at 3:04 PM")
|
||||||
|
}
|
||||||
|
|
||||||
|
func friendlyDateLabel(date string, timezone string) string {
|
||||||
|
location, err := timeutil.LoadLocation(timezone)
|
||||||
|
if err != nil {
|
||||||
|
location = time.UTC
|
||||||
|
}
|
||||||
|
parsed, err := time.ParseInLocation(timeutil.DateLayout, date, location)
|
||||||
|
if err != nil {
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
return parsed.Format("Monday, January 2, 2006")
|
||||||
|
}
|
||||||
|
|
||||||
|
func localDateLabel(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(timeutil.DateLayout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clockLabel(value time.Time, timezone string) string {
|
||||||
|
location, err := timeutil.LoadLocation(timezone)
|
||||||
|
if err != nil {
|
||||||
|
location = time.UTC
|
||||||
|
}
|
||||||
|
label := value.In(location).Format("3 PM")
|
||||||
|
if label == "12 AM" && value.In(location).Minute() == 0 {
|
||||||
|
return "12 AM"
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
51
internal/briefing/module_format_helpers_test.go
Normal file
51
internal/briefing/module_format_helpers_test.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestWindDirectionLabelUsesSixteenPointCompass(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
degrees *float64
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "nil", degrees: nil, want: ""},
|
||||||
|
{name: "north", degrees: floatPtr(0), want: "N"},
|
||||||
|
{name: "below first boundary", degrees: floatPtr(11.24), want: "N"},
|
||||||
|
{name: "at first boundary", degrees: floatPtr(11.25), want: "NNE"},
|
||||||
|
{name: "northeast", degrees: floatPtr(45), want: "NE"},
|
||||||
|
{name: "south", degrees: floatPtr(180), want: "S"},
|
||||||
|
{name: "wrap to north", degrees: floatPtr(348.75), want: "N"},
|
||||||
|
{name: "full rotation", degrees: floatPtr(360), want: "N"},
|
||||||
|
{name: "negative normalizes", degrees: floatPtr(-45), want: "NW"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := windDirectionLabel(tt.degrees); got != tt.want {
|
||||||
|
t.Fatalf("windDirectionLabel(%v) = %q, want %q", tt.degrees, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
387
internal/briefing/modules.go
Normal file
387
internal/briefing/modules.go
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModuleContext struct {
|
||||||
|
Resolved report.Resolved
|
||||||
|
Collected facts.CollectedFacts
|
||||||
|
Derived facts.DerivedFacts
|
||||||
|
Units string
|
||||||
|
Timezone string
|
||||||
|
Location *LocationContext
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModuleBuilder func(ModuleContext, any) (*module.Output, error)
|
||||||
|
|
||||||
|
type ModuleDefinition struct {
|
||||||
|
ID module.ID
|
||||||
|
StanzaName string
|
||||||
|
DefaultOptions any
|
||||||
|
RequiredCollected []module.FactRequirement
|
||||||
|
RequiredDerived []module.FactRequirement
|
||||||
|
SupportedReports []report.ID
|
||||||
|
MissingData module.MissingDataBehavior
|
||||||
|
AllowDuplicate bool
|
||||||
|
Builder ModuleBuilder
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModuleRegistry struct {
|
||||||
|
definitions map[module.ID]ModuleDefinition
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultModuleRegistry() (ModuleRegistry, error) {
|
||||||
|
return NewModuleRegistry(defaultModuleDefinitions())
|
||||||
|
}
|
||||||
|
|
||||||
|
func MustDefaultModuleRegistry() ModuleRegistry {
|
||||||
|
registry, err := DefaultModuleRegistry()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return registry
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewModuleRegistry(definitions []ModuleDefinition) (ModuleRegistry, error) {
|
||||||
|
registry := ModuleRegistry{definitions: map[module.ID]ModuleDefinition{}}
|
||||||
|
seenStanzas := map[string]module.ID{}
|
||||||
|
for i, definition := range definitions {
|
||||||
|
if definition.ID == "" {
|
||||||
|
return ModuleRegistry{}, fmt.Errorf("module definition[%d].id is required", i)
|
||||||
|
}
|
||||||
|
if definition.StanzaName == "" {
|
||||||
|
return ModuleRegistry{}, fmt.Errorf("module %q stanza name is required", definition.ID)
|
||||||
|
}
|
||||||
|
if _, ok := registry.definitions[definition.ID]; ok {
|
||||||
|
return ModuleRegistry{}, fmt.Errorf("duplicate module definition %q", definition.ID)
|
||||||
|
}
|
||||||
|
if definition.Builder == nil {
|
||||||
|
return ModuleRegistry{}, fmt.Errorf("module %q has no builder", definition.ID)
|
||||||
|
}
|
||||||
|
if definition.MissingData == module.MissingDataWarn {
|
||||||
|
return ModuleRegistry{}, fmt.Errorf("module %q uses unsupported missing data behavior %q", definition.ID, definition.MissingData)
|
||||||
|
}
|
||||||
|
if existingID, ok := seenStanzas[definition.StanzaName]; ok {
|
||||||
|
return ModuleRegistry{}, fmt.Errorf("duplicate stanza name %q for modules %q and %q", definition.StanzaName, existingID, definition.ID)
|
||||||
|
}
|
||||||
|
seenStanzas[definition.StanzaName] = definition.ID
|
||||||
|
registry.definitions[definition.ID] = definition
|
||||||
|
}
|
||||||
|
return registry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r ModuleRegistry) Lookup(id module.ID) (ModuleDefinition, error) {
|
||||||
|
definition, ok := r.definitions[id]
|
||||||
|
if !ok {
|
||||||
|
return ModuleDefinition{}, fmt.Errorf("unknown module %q", id)
|
||||||
|
}
|
||||||
|
return definition, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (*module.Output, error) {
|
||||||
|
definition, err := r.Lookup(item.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !definition.SupportsReport(ctx.Resolved.Definition.ID) {
|
||||||
|
return nil, fmt.Errorf("module %q is not compatible with report %q", item.ID, ctx.Resolved.Definition.ID)
|
||||||
|
}
|
||||||
|
if err := definition.ValidateOptions(item.Options); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if definition.Builder == nil {
|
||||||
|
return nil, fmt.Errorf("module %q has no builder", item.ID)
|
||||||
|
}
|
||||||
|
missing := missingRequirements(definition, ctx)
|
||||||
|
if len(missing) > 0 {
|
||||||
|
switch definition.MissingData {
|
||||||
|
case module.MissingDataOmit:
|
||||||
|
return nil, nil
|
||||||
|
case module.MissingDataError:
|
||||||
|
return nil, fmt.Errorf("module %q missing required facts: %s", item.ID, strings.Join(missing, ", "))
|
||||||
|
case module.MissingDataEmpty:
|
||||||
|
case module.MissingDataWarn:
|
||||||
|
return nil, fmt.Errorf("module %q uses unsupported missing data behavior %q", item.ID, definition.MissingData)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("module %q has unknown missing data behavior %q", item.ID, definition.MissingData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
options := item.Options
|
||||||
|
if options == nil {
|
||||||
|
options = definition.DefaultOptions
|
||||||
|
}
|
||||||
|
output, err := definition.Builder(ctx, options)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if output == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if output.ID != definition.ID {
|
||||||
|
return nil, fmt.Errorf("module %q produced output id %q", definition.ID, output.ID)
|
||||||
|
}
|
||||||
|
if output.StanzaName != definition.StanzaName {
|
||||||
|
return nil, fmt.Errorf("module %q produced stanza %q, want %q", definition.ID, output.StanzaName, definition.StanzaName)
|
||||||
|
}
|
||||||
|
return output, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func missingRequirements(definition ModuleDefinition, ctx ModuleContext) []string {
|
||||||
|
var missing []string
|
||||||
|
for _, requirement := range definition.RequiredCollected {
|
||||||
|
if !collectedFactAvailable(requirement, ctx) {
|
||||||
|
missing = append(missing, string(requirement))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, requirement := range definition.RequiredDerived {
|
||||||
|
if !derivedFactAvailable(requirement, ctx) {
|
||||||
|
missing = append(missing, string(requirement))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return missing
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContext) bool {
|
||||||
|
switch requirement {
|
||||||
|
case module.CollectedCurrentConditions:
|
||||||
|
return ctx.Collected.Current != nil
|
||||||
|
case module.CollectedNarrativeForecast:
|
||||||
|
return ctx.Collected.Narrative != nil
|
||||||
|
case module.CollectedHourlyForecast:
|
||||||
|
return ctx.Collected.Hourly != nil
|
||||||
|
case module.CollectedAlerts:
|
||||||
|
return ctx.Collected.Alerts != nil
|
||||||
|
case module.CollectedDiscussion:
|
||||||
|
return ctx.Collected.Discussion != nil
|
||||||
|
case module.CollectedWeatherStory:
|
||||||
|
return ctx.Collected.WeatherStory != nil
|
||||||
|
case module.CollectedSPCConvectiveOutlooks:
|
||||||
|
return ctx.Collected.SPCConvectiveOutlooks != nil
|
||||||
|
case module.CollectedSourceMetadata:
|
||||||
|
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func derivedFactAvailable(requirement module.FactRequirement, ctx ModuleContext) bool {
|
||||||
|
switch requirement {
|
||||||
|
case module.RequiresDerivedHourlyPeriods:
|
||||||
|
return len(ctx.Derived.ValidPeriodHourlyPeriods) > 0
|
||||||
|
case module.RequiresDerivedNarrativePeriods:
|
||||||
|
return len(ctx.Derived.ValidPeriodNarrativePeriods) > 0
|
||||||
|
case module.RequiresDerivedAlertOverlaps:
|
||||||
|
return true
|
||||||
|
case module.RequiresDerivedDailySummaries:
|
||||||
|
return len(ctx.Derived.DailySummaries) > 0
|
||||||
|
case module.RequiresDerivedDaypartSummaries:
|
||||||
|
return len(ctx.Derived.DaypartSummaries) > 0
|
||||||
|
case module.RequiresDerivedPrecipTiming:
|
||||||
|
return true
|
||||||
|
case module.RequiresDerivedSPCConvectiveOutlooks:
|
||||||
|
return ctx.Derived.SPCConvectiveOutlooks != nil
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r ModuleRegistry) ValidateComposition(reportID report.ID, items []module.ConfigItem) error {
|
||||||
|
seenModules := map[module.ID]struct{}{}
|
||||||
|
seenStanzas := map[string]module.ID{}
|
||||||
|
for i, item := range items {
|
||||||
|
definition, err := r.Lookup(item.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("modules[%d]: %w", i, err)
|
||||||
|
}
|
||||||
|
if _, ok := seenModules[item.ID]; ok && !definition.AllowDuplicate {
|
||||||
|
return fmt.Errorf("modules[%d]: duplicate module %q", i, item.ID)
|
||||||
|
}
|
||||||
|
seenModules[item.ID] = struct{}{}
|
||||||
|
if existingID, ok := seenStanzas[definition.StanzaName]; ok {
|
||||||
|
return fmt.Errorf("modules[%d]: duplicate stanza name %q for modules %q and %q", i, definition.StanzaName, existingID, item.ID)
|
||||||
|
}
|
||||||
|
seenStanzas[definition.StanzaName] = item.ID
|
||||||
|
if !definition.SupportsReport(reportID) {
|
||||||
|
return fmt.Errorf("modules[%d]: module %q is not compatible with report %q", i, item.ID, reportID)
|
||||||
|
}
|
||||||
|
if err := definition.ValidateOptions(item.Options); err != nil {
|
||||||
|
return fmt.Errorf("modules[%d]: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d ModuleDefinition) SupportsReport(id report.ID) bool {
|
||||||
|
if len(d.SupportedReports) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, supported := range d.SupportedReports {
|
||||||
|
if supported == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d ModuleDefinition) ValidateOptions(options any) error {
|
||||||
|
if options == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if d.DefaultOptions == nil {
|
||||||
|
return fmt.Errorf("module %q does not accept options", d.ID)
|
||||||
|
}
|
||||||
|
want := reflect.TypeOf(d.DefaultOptions)
|
||||||
|
got := reflect.TypeOf(options)
|
||||||
|
if got == want {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if got.Kind() == reflect.Pointer && got.Elem() == want {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("module %q options have type %s, want %s", d.ID, got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultModuleDefinitions() []ModuleDefinition {
|
||||||
|
allReports := []report.ID{report.DailyToday, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm}
|
||||||
|
daypartReports := []report.ID{report.DailyToday, report.Tomorrow, report.ThreeDay, report.Weekend}
|
||||||
|
return []ModuleDefinition{
|
||||||
|
{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
DefaultOptions: module.MetadataOptions{},
|
||||||
|
RequiredCollected: []module.FactRequirement{module.CollectedSourceMetadata},
|
||||||
|
SupportedReports: allReports,
|
||||||
|
MissingData: module.MissingDataEmpty,
|
||||||
|
Builder: buildMetadataModule,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.CurrentConditions,
|
||||||
|
StanzaName: "current_conditions",
|
||||||
|
DefaultOptions: module.CurrentConditionsOptions{},
|
||||||
|
RequiredCollected: []module.FactRequirement{module.CollectedCurrentConditions},
|
||||||
|
SupportedReports: allReports,
|
||||||
|
MissingData: module.MissingDataOmit,
|
||||||
|
Builder: buildCurrentConditionsModule,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.NarrativeForecast,
|
||||||
|
StanzaName: "narrative_forecast",
|
||||||
|
DefaultOptions: module.NarrativeForecastOptions{},
|
||||||
|
RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast},
|
||||||
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods},
|
||||||
|
SupportedReports: []report.ID{report.DailyToday, report.Tomorrow},
|
||||||
|
MissingData: module.MissingDataOmit,
|
||||||
|
Builder: buildNarrativeForecastModule,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.HourlyForecast,
|
||||||
|
StanzaName: "hourly_forecast",
|
||||||
|
DefaultOptions: module.HourlyForecastOptions{},
|
||||||
|
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
|
||||||
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
|
||||||
|
SupportedReports: []report.ID{report.DailyToday, report.Tomorrow, report.Hourly},
|
||||||
|
MissingData: module.MissingDataOmit,
|
||||||
|
Builder: buildHourlyForecastModule,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.DerivedDailySummary,
|
||||||
|
StanzaName: "derived_daily_summary",
|
||||||
|
DefaultOptions: module.DerivedDailySummaryOptions{},
|
||||||
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming},
|
||||||
|
SupportedReports: []report.ID{report.DailyToday, report.Tomorrow},
|
||||||
|
MissingData: module.MissingDataError,
|
||||||
|
Builder: buildDerivedDailySummaryModule,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.DerivedDaypartSummaries,
|
||||||
|
StanzaName: "derived_daypart_summaries",
|
||||||
|
DefaultOptions: module.DerivedDaypartSummariesOptions{},
|
||||||
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
|
||||||
|
SupportedReports: daypartReports,
|
||||||
|
MissingData: module.MissingDataError,
|
||||||
|
Builder: buildDerivedDaypartSummariesModule,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.PrecipTiming,
|
||||||
|
StanzaName: "precip_timing",
|
||||||
|
DefaultOptions: module.PrecipTimingOptions{},
|
||||||
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedPrecipTiming},
|
||||||
|
SupportedReports: allReports,
|
||||||
|
MissingData: module.MissingDataEmpty,
|
||||||
|
Builder: buildPrecipTimingModule,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.AlertDigest,
|
||||||
|
StanzaName: "alert_digest",
|
||||||
|
DefaultOptions: module.AlertDigestOptions{},
|
||||||
|
RequiredCollected: []module.FactRequirement{module.CollectedAlerts},
|
||||||
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedAlertOverlaps},
|
||||||
|
SupportedReports: allReports,
|
||||||
|
MissingData: module.MissingDataEmpty,
|
||||||
|
Builder: buildAlertDigestModule,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.SPCConvectiveOutlooks,
|
||||||
|
StanzaName: string(module.SPCConvectiveOutlooks),
|
||||||
|
DefaultOptions: module.SPCConvectiveOutlooksOptions{},
|
||||||
|
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
|
||||||
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
|
||||||
|
SupportedReports: allReports,
|
||||||
|
MissingData: module.MissingDataEmpty,
|
||||||
|
Builder: buildSPCConvectiveOutlooksModule,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.AreaForecastDiscussion,
|
||||||
|
StanzaName: "area_forecast_discussion",
|
||||||
|
DefaultOptions: module.AreaForecastDiscussionOptions{},
|
||||||
|
RequiredCollected: []module.FactRequirement{module.CollectedDiscussion},
|
||||||
|
SupportedReports: allReports,
|
||||||
|
MissingData: module.MissingDataOmit,
|
||||||
|
Builder: buildAreaForecastDiscussionModule,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.SPCConvectiveDiscussion,
|
||||||
|
StanzaName: string(module.SPCConvectiveDiscussion),
|
||||||
|
DefaultOptions: module.SPCConvectiveDiscussionOptions{},
|
||||||
|
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
|
||||||
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
|
||||||
|
SupportedReports: allReports,
|
||||||
|
MissingData: module.MissingDataOmit,
|
||||||
|
Builder: buildSPCConvectiveDiscussionModule,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.WeatherStory,
|
||||||
|
StanzaName: "weather_story",
|
||||||
|
DefaultOptions: module.WeatherStoryOptions{},
|
||||||
|
RequiredCollected: []module.FactRequirement{module.CollectedWeatherStory},
|
||||||
|
SupportedReports: allReports,
|
||||||
|
MissingData: module.MissingDataOmit,
|
||||||
|
Builder: buildWeatherStoryModule,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.OutdoorWindows,
|
||||||
|
StanzaName: "outdoor_windows",
|
||||||
|
DefaultOptions: module.OutdoorWindowsOptions{},
|
||||||
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
|
||||||
|
SupportedReports: daypartReports,
|
||||||
|
MissingData: module.MissingDataEmpty,
|
||||||
|
Builder: buildOutdoorWindowsModule,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.TomorrowPlanning,
|
||||||
|
StanzaName: "tomorrow_planning",
|
||||||
|
DefaultOptions: module.TomorrowPlanningOptions{},
|
||||||
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
||||||
|
SupportedReports: []report.ID{report.Tomorrow},
|
||||||
|
MissingData: module.MissingDataEmpty,
|
||||||
|
Builder: buildTomorrowPlanningModule,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
198
internal/briefing/modules_test.go
Normal file
198
internal/briefing/modules_test.go
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
for _, definition := range report.DefaultRegistry().All() {
|
||||||
|
if err := registry.ValidateComposition(definition.ID, definition.Modules); err != nil {
|
||||||
|
t.Fatalf("ValidateComposition(%s) error = %v", definition.ID, err)
|
||||||
|
}
|
||||||
|
for _, item := range definition.Modules {
|
||||||
|
moduleDefinition, err := registry.Lookup(item.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Lookup(%s) error = %v", item.ID, err)
|
||||||
|
}
|
||||||
|
if moduleDefinition.Builder == nil {
|
||||||
|
t.Fatalf("report %s module %s has no builder", definition.ID, item.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
for _, definition := range report.DefaultRegistry().All() {
|
||||||
|
t.Run(string(definition.ID), func(t *testing.T) {
|
||||||
|
ctx := derivedModuleContext(definition.ID)
|
||||||
|
var outputs []module.Output
|
||||||
|
for _, item := range definition.Modules {
|
||||||
|
output, err := registry.BuildModule(ctx, item)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(%s) error = %v", item.ID, err)
|
||||||
|
}
|
||||||
|
if output != nil {
|
||||||
|
outputs = append(outputs, *output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
snapshot, err := module.NewSnapshot(outputs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSnapshot() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(snapshot.Outputs) == 0 {
|
||||||
|
t.Fatal("snapshot outputs = 0, want default report modules")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHourlyDefaultModuleOptions(t *testing.T) {
|
||||||
|
definition := report.DefaultRegistry().MustLookup(report.Hourly)
|
||||||
|
var found bool
|
||||||
|
for _, item := range definition.Modules {
|
||||||
|
if item.ID != module.AreaForecastDiscussion {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
options, ok := item.Options.(module.AreaForecastDiscussionOptions)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", item.Options)
|
||||||
|
}
|
||||||
|
if strings.Join(options.Sections, ",") != "key_messages,short_term" {
|
||||||
|
t.Fatalf("AFD sections = %#v, want key messages and short term", options.Sections)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("hourly default modules missing area_forecast_discussion")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.ID("unknown")}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `unknown module "unknown"`) {
|
||||||
|
t.Fatalf("error = %v, want unknown module", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryRejectsDuplicateModuleIDs(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{
|
||||||
|
{ID: module.Metadata},
|
||||||
|
{ID: module.Metadata},
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `duplicate module "metadata"`) {
|
||||||
|
t.Fatalf("error = %v, want duplicate module", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryRejectsDuplicateStanzaNames(t *testing.T) {
|
||||||
|
_, err := NewModuleRegistry([]ModuleDefinition{
|
||||||
|
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}, Builder: noopModuleBuilder},
|
||||||
|
{ID: module.CurrentConditions, StanzaName: "metadata", DefaultOptions: module.CurrentConditionsOptions{}, Builder: noopModuleBuilder},
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `duplicate stanza name "metadata"`) {
|
||||||
|
t.Fatalf("error = %v, want duplicate stanza name", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.TomorrowPlanning}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `module "tomorrow_planning" is not compatible with report "daily_today"`) {
|
||||||
|
t.Fatalf("error = %v, want incompatible report", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryRejectsHourlyIncompatibleModules(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
for _, id := range []module.ID{
|
||||||
|
module.NarrativeForecast,
|
||||||
|
module.DerivedDailySummary,
|
||||||
|
module.DerivedDaypartSummaries,
|
||||||
|
module.OutdoorWindows,
|
||||||
|
module.TomorrowPlanning,
|
||||||
|
} {
|
||||||
|
t.Run(string(id), func(t *testing.T) {
|
||||||
|
err := registry.ValidateComposition(report.Hourly, []module.ConfigItem{{ID: id}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `not compatible with report "hourly"`) {
|
||||||
|
t.Fatalf("ValidateComposition() error = %v, want incompatible hourly module", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryRejectsDefinitionsWithoutBuilders(t *testing.T) {
|
||||||
|
_, err := NewModuleRegistry([]ModuleDefinition{
|
||||||
|
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}},
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `module "metadata" has no builder`) {
|
||||||
|
t.Fatalf("error = %v, want missing builder", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryRejectsUnsupportedMissingDataWarn(t *testing.T) {
|
||||||
|
_, err := NewModuleRegistry([]ModuleDefinition{
|
||||||
|
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}, MissingData: module.MissingDataWarn, Builder: noopModuleBuilder},
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `unsupported missing data behavior`) {
|
||||||
|
t.Fatalf("error = %v, want unsupported missing-data behavior", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{
|
||||||
|
{ID: module.Metadata, Options: module.CurrentConditionsOptions{}},
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `module "metadata" options have type module.CurrentConditionsOptions, want module.MetadataOptions`) {
|
||||||
|
t.Fatalf("error = %v, want invalid option shape", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{
|
||||||
|
{ID: module.Metadata, Options: module.MetadataOptions{}},
|
||||||
|
{ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateComposition() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveOutlookCollectedRequirementAvailability(t *testing.T) {
|
||||||
|
ctx := ModuleContext{}
|
||||||
|
if collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
|
||||||
|
t.Fatal("collectedFactAvailable() = true, want false without source")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Collected = facts.CollectedFacts{SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{}}
|
||||||
|
if !collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
|
||||||
|
t.Fatal("collectedFactAvailable() = false, want true with checked source")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveOutlookDerivedRequirementAvailability(t *testing.T) {
|
||||||
|
ctx := ModuleContext{}
|
||||||
|
if derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
|
||||||
|
t.Fatal("derivedFactAvailable() = true, want false without derived outlooks")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Derived = facts.DerivedFacts{SPCConvectiveOutlooks: []weatherdata.ConvectiveOutlook{}}
|
||||||
|
if !derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
|
||||||
|
t.Fatal("derivedFactAvailable() = false, want true for checked empty derived outlooks")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func noopModuleBuilder(ModuleContext, any) (*module.Output, error) {
|
||||||
|
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil
|
||||||
|
}
|
||||||
93
internal/briefing/narrative_forecast_module.go
Normal file
93
internal/briefing/narrative_forecast_module.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NarrativeForecastModule 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 []NarrativeForecastPeriod `json:"periods,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NarrativeForecastPeriod struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
|
IsDay *bool `json:"is_day,omitempty"`
|
||||||
|
TextDescription string `json:"text_description,omitempty"`
|
||||||
|
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||||
|
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"`
|
||||||
|
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"`
|
||||||
|
ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildNarrativeForecastModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
narrative := ctx.Collected.Narrative
|
||||||
|
if narrative == nil || len(ctx.Derived.ValidPeriodNarrativePeriods) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
value := NarrativeForecastModule{
|
||||||
|
Product: narrative.Product,
|
||||||
|
IssuedAt: narrative.IssuedAt,
|
||||||
|
UpdatedAt: copyTime(narrative.UpdatedAt),
|
||||||
|
SourceLocation: narrative.LocationName,
|
||||||
|
SourceLocationID: narrative.LocationID,
|
||||||
|
Periods: narrativeForecastPeriods(ctx.Derived.ValidPeriodNarrativePeriods, ctx.Timezone),
|
||||||
|
}
|
||||||
|
if value.isEmpty() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.NarrativeForecast, StanzaName: "narrative_forecast", Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func narrativeForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []NarrativeForecastPeriod {
|
||||||
|
out := make([]NarrativeForecastPeriod, 0, len(periods))
|
||||||
|
for _, period := range periods {
|
||||||
|
validPeriod := timeutil.Period{Start: period.StartTime, End: period.EndTime}
|
||||||
|
out = append(out, NarrativeForecastPeriod{
|
||||||
|
Name: period.Name,
|
||||||
|
PeriodBegins: friendlyPeriodBeginsLabel(validPeriod, timezone),
|
||||||
|
PeriodEnds: friendlyPeriodEndsLabel(validPeriod, timezone),
|
||||||
|
IsDay: copyBool(period.IsDay),
|
||||||
|
TextDescription: 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),
|
||||||
|
WindSpeedKmh: copyFloat(period.WindSpeedKmh),
|
||||||
|
WindSpeedMph: copyFloat(period.WindSpeedMph),
|
||||||
|
WindGustKmh: copyFloat(period.WindGustKmh),
|
||||||
|
WindGustMph: copyFloat(period.WindGustMph),
|
||||||
|
WindDirection: windDirectionLabel(period.WindDirectionDegrees),
|
||||||
|
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v NarrativeForecastModule) isEmpty() bool {
|
||||||
|
return v.Product == "" &&
|
||||||
|
v.IssuedAt.IsZero() &&
|
||||||
|
v.UpdatedAt == nil &&
|
||||||
|
v.SourceLocation == "" &&
|
||||||
|
v.SourceLocationID == "" &&
|
||||||
|
len(v.Periods) == 0
|
||||||
|
}
|
||||||
38
internal/briefing/outdoor_windows_module.go
Normal file
38
internal/briefing/outdoor_windows_module.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
|
||||||
|
type OutdoorWindowsModule struct {
|
||||||
|
Best *OutdoorWindowModule `json:"best,omitempty"`
|
||||||
|
Worst *OutdoorWindowModule `json:"worst,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OutdoorWindowModule struct {
|
||||||
|
Daypart string `json:"daypart"`
|
||||||
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
|
Reasons []string `json:"reasons,omitempty"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries)
|
||||||
|
value := OutdoorWindowsModule{
|
||||||
|
Best: outdoorWindowValue(windows.Best, ctx.Timezone),
|
||||||
|
Worst: outdoorWindowValue(windows.Worst, ctx.Timezone),
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func outdoorWindowValue(window *OutdoorWindow, timezone string) *OutdoorWindowModule {
|
||||||
|
if window == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &OutdoorWindowModule{
|
||||||
|
Daypart: window.Daypart,
|
||||||
|
PeriodBegins: friendlyPeriodBeginsLabel(window.Period, timezone),
|
||||||
|
PeriodEnds: friendlyPeriodEndsLabel(window.Period, timezone),
|
||||||
|
Reasons: append([]string(nil), window.Reasons...),
|
||||||
|
Score: window.Score,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,43 +1,29 @@
|
|||||||
// Package briefing builds report-specific structured briefing packages.
|
// Package briefing builds prompt-facing module values.
|
||||||
package briefing
|
package briefing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
const SchemaVersion = "weatherreporter.briefing.v1"
|
|
||||||
|
|
||||||
type Package struct {
|
|
||||||
Metadata Metadata `json:"metadata"`
|
|
||||||
CurrentConditions *CurrentConditionsContext `json:"currentConditions,omitempty"`
|
|
||||||
Daily *Daily `json:"daily,omitempty"`
|
|
||||||
ThreeDay *ThreeDay `json:"threeDay,omitempty"`
|
|
||||||
Weekend *Weekend `json:"weekend,omitempty"`
|
|
||||||
Storm *Storm `json:"storm,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Metadata struct {
|
type Metadata struct {
|
||||||
SchemaVersion string `json:"schemaVersion"`
|
RunID string `json:"runId"`
|
||||||
RunID string `json:"runId"`
|
ReportID report.ID `json:"reportId"`
|
||||||
ReportID report.ID `json:"reportId"`
|
Variant string `json:"variant,omitempty"`
|
||||||
Variant string `json:"variant,omitempty"`
|
PromptID string `json:"promptId"`
|
||||||
PromptID string `json:"promptId"`
|
GeneratedAt time.Time `json:"generatedAt"`
|
||||||
GeneratedAt time.Time `json:"generatedAt"`
|
Units string `json:"units"`
|
||||||
Units string `json:"units"`
|
Timezone string `json:"timezone"`
|
||||||
Timezone string `json:"timezone"`
|
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||||
ValidPeriod timeutil.Period `json:"validPeriod"`
|
Location *LocationContext `json:"location,omitempty"`
|
||||||
Location *LocationContext `json:"location,omitempty"`
|
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
||||||
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
SourceLocation string `json:"sourceLocation,omitempty"`
|
||||||
SourceLocation string `json:"sourceLocation,omitempty"`
|
Sources []SourceMetadata `json:"sources,omitempty"`
|
||||||
Sources []SourceMetadata `json:"sources,omitempty"`
|
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
|
||||||
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"`
|
Alerts *AlertStatus `json:"alerts,omitempty"`
|
||||||
Alerts *AlertStatus `json:"alerts,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type LocationContext struct {
|
type LocationContext struct {
|
||||||
@@ -47,30 +33,15 @@ type LocationContext struct {
|
|||||||
Timezone string `json:"timezone,omitempty"`
|
Timezone string `json:"timezone,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CurrentConditionsContext struct {
|
|
||||||
ConditionText string `json:"conditionText,omitempty"`
|
|
||||||
IsDay *bool `json:"isDay,omitempty"`
|
|
||||||
TemperatureC *float64 `json:"temperatureC,omitempty"`
|
|
||||||
TemperatureF *float64 `json:"temperatureF,omitempty"`
|
|
||||||
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty"`
|
|
||||||
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"`
|
|
||||||
DewpointC *float64 `json:"dewpointC,omitempty"`
|
|
||||||
DewpointF *float64 `json:"dewpointF,omitempty"`
|
|
||||||
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"`
|
|
||||||
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"`
|
|
||||||
WindSpeedMph *float64 `json:"windSpeedMph,omitempty"`
|
|
||||||
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SourceMetadata struct {
|
type SourceMetadata struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Endpoint string `json:"endpoint,omitempty"`
|
Endpoint string `json:"endpoint,omitempty"`
|
||||||
FetchedAt time.Time `json:"fetchedAt"`
|
FetchedAt time.Time `json:"fetchedAt"`
|
||||||
IssuedAt *time.Time `json:"issuedAt,omitempty"`
|
IssuedAt *time.Time `json:"issuedAt,omitempty"`
|
||||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||||
DataSHA256 string `json:"dataSha256,omitempty"`
|
DataSHA256 string `json:"dataSha256,omitempty"`
|
||||||
Missing bool `json:"missing,omitempty"`
|
Missing bool `json:"missing,omitempty"`
|
||||||
Warnings []forecast.SourceWarning `json:"warnings,omitempty"`
|
Warnings []weatherdata.SourceWarning `json:"warnings,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AlertStatus struct {
|
type AlertStatus struct {
|
||||||
@@ -82,7 +53,7 @@ type AlertStatus struct {
|
|||||||
|
|
||||||
type BuildContext struct {
|
type BuildContext struct {
|
||||||
Resolved report.Resolved
|
Resolved report.Resolved
|
||||||
Bundle *forecast.Bundle
|
Bundle *weatherdata.Bundle
|
||||||
Units string
|
Units string
|
||||||
Timezone string
|
Timezone string
|
||||||
Location *LocationContext
|
Location *LocationContext
|
||||||
@@ -92,7 +63,6 @@ func BuildMetadata(ctx BuildContext) Metadata {
|
|||||||
metadata := ctx.Resolved.Metadata()
|
metadata := ctx.Resolved.Metadata()
|
||||||
sourceLocationID, sourceLocation := sourceLocation(ctx.Bundle)
|
sourceLocationID, sourceLocation := sourceLocation(ctx.Bundle)
|
||||||
return Metadata{
|
return Metadata{
|
||||||
SchemaVersion: SchemaVersion,
|
|
||||||
RunID: metadata.RunID,
|
RunID: metadata.RunID,
|
||||||
ReportID: metadata.ReportID,
|
ReportID: metadata.ReportID,
|
||||||
Variant: variantForReport(metadata.ReportID),
|
Variant: variantForReport(metadata.ReportID),
|
||||||
@@ -110,13 +80,6 @@ func BuildMetadata(ctx BuildContext) Metadata {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildPackage(ctx BuildContext) Package {
|
|
||||||
return Package{
|
|
||||||
Metadata: BuildMetadata(ctx),
|
|
||||||
CurrentConditions: currentConditions(ctx.Bundle),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyLocation(location *LocationContext) *LocationContext {
|
func copyLocation(location *LocationContext) *LocationContext {
|
||||||
if location == nil {
|
if location == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -125,43 +88,15 @@ func copyLocation(location *LocationContext) *LocationContext {
|
|||||||
return &copied
|
return &copied
|
||||||
}
|
}
|
||||||
|
|
||||||
func currentConditions(bundle *forecast.Bundle) *CurrentConditionsContext {
|
func copyBool(value *bool) *bool {
|
||||||
if bundle == nil || bundle.Current == nil {
|
if value == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
current := bundle.Current
|
copied := *value
|
||||||
context := CurrentConditionsContext{
|
return &copied
|
||||||
ConditionText: current.ConditionText,
|
|
||||||
IsDay: copyBool(current.IsDay),
|
|
||||||
TemperatureC: copyFloat(current.TemperatureC),
|
|
||||||
TemperatureF: copyFloat(current.TemperatureF),
|
|
||||||
ApparentTemperatureC: copyFloat(current.ApparentTemperatureC),
|
|
||||||
ApparentTemperatureF: copyFloat(current.ApparentTemperatureF),
|
|
||||||
DewpointC: copyFloat(current.DewpointC),
|
|
||||||
DewpointF: copyFloat(current.DewpointF),
|
|
||||||
RelativeHumidityPercent: copyFloat(current.RelativeHumidityPercent),
|
|
||||||
WindSpeedKmh: copyFloat(current.WindSpeedKmh),
|
|
||||||
WindSpeedMph: copyFloat(current.WindSpeedMph),
|
|
||||||
WindDirectionDegrees: copyFloat(current.WindDirectionDegrees),
|
|
||||||
}
|
|
||||||
if context.ConditionText == "" &&
|
|
||||||
context.IsDay == nil &&
|
|
||||||
context.TemperatureC == nil &&
|
|
||||||
context.TemperatureF == nil &&
|
|
||||||
context.ApparentTemperatureC == nil &&
|
|
||||||
context.ApparentTemperatureF == nil &&
|
|
||||||
context.DewpointC == nil &&
|
|
||||||
context.DewpointF == nil &&
|
|
||||||
context.RelativeHumidityPercent == nil &&
|
|
||||||
context.WindSpeedKmh == nil &&
|
|
||||||
context.WindSpeedMph == nil &&
|
|
||||||
context.WindDirectionDegrees == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &context
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyBool(value *bool) *bool {
|
func copyInt(value *int) *int {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -177,18 +112,19 @@ func copyFloat(value *float64) *float64 {
|
|||||||
return &copied
|
return &copied
|
||||||
}
|
}
|
||||||
|
|
||||||
func Save(path string, pkg Package) error {
|
func copyTime(value *time.Time) *time.Time {
|
||||||
if err := fileutil.WriteJSONAtomic(path, pkg); err != nil {
|
if value == nil {
|
||||||
return fmt.Errorf("save briefing package: %w", err)
|
return nil
|
||||||
}
|
}
|
||||||
return nil
|
copied := *value
|
||||||
|
return &copied
|
||||||
}
|
}
|
||||||
|
|
||||||
func sourceLocation(bundle *forecast.Bundle) (string, string) {
|
func sourceLocation(bundle *weatherdata.Bundle) (string, string) {
|
||||||
if bundle == nil {
|
if bundle == nil {
|
||||||
return "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
for _, run := range []*forecast.ForecastRun{bundle.Hourly, bundle.Narrative, bundle.Daily} {
|
for _, run := range []*weatherdata.ForecastRun{bundle.Hourly, bundle.Narrative, bundle.Daily} {
|
||||||
if run == nil {
|
if run == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -199,7 +135,7 @@ func sourceLocation(bundle *forecast.Bundle) (string, string) {
|
|||||||
return "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func sourceMetadata(bundle *forecast.Bundle) []SourceMetadata {
|
func sourceMetadata(bundle *weatherdata.Bundle) []SourceMetadata {
|
||||||
if bundle == nil {
|
if bundle == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -219,14 +155,14 @@ func sourceMetadata(bundle *forecast.Bundle) []SourceMetadata {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func sourceWarnings(bundle *forecast.Bundle) []forecast.SourceWarning {
|
func sourceWarnings(bundle *weatherdata.Bundle) []weatherdata.SourceWarning {
|
||||||
if bundle == nil {
|
if bundle == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return bundle.Warnings
|
return bundle.Warnings
|
||||||
}
|
}
|
||||||
|
|
||||||
func alertStatus(bundle *forecast.Bundle) *AlertStatus {
|
func alertStatus(bundle *weatherdata.Bundle) *AlertStatus {
|
||||||
if bundle == nil {
|
if bundle == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -247,21 +183,11 @@ func alertStatus(bundle *forecast.Bundle) *AlertStatus {
|
|||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
|
|
||||||
func setRelevantAlertCount(metadata *Metadata, count int) {
|
|
||||||
if metadata.Alerts == nil {
|
|
||||||
if count == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
metadata.Alerts = &AlertStatus{}
|
|
||||||
}
|
|
||||||
metadata.Alerts.RelevantCount = count
|
|
||||||
}
|
|
||||||
|
|
||||||
func variantForReport(id report.ID) string {
|
func variantForReport(id report.ID) string {
|
||||||
switch id {
|
switch id {
|
||||||
case report.DailyToday:
|
case report.DailyToday:
|
||||||
return "today"
|
return "today"
|
||||||
case report.DailyTomorrow:
|
case report.Tomorrow:
|
||||||
return "tomorrow"
|
return "tomorrow"
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
55
internal/briefing/precip_timing_module.go
Normal file
55
internal/briefing/precip_timing_module.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PrecipTimingModule struct {
|
||||||
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||||
|
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||||
|
ProbabilityThreshold float64 `json:"probability_threshold"`
|
||||||
|
PrecipitationWindows []PrecipitationWindowModule `json:"precipitation_windows,omitempty"`
|
||||||
|
ThunderMentioned bool `json:"thunder_mentioned"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PrecipitationWindowModule struct {
|
||||||
|
PeriodBegins string `json:"period_begins"`
|
||||||
|
PeriodBeginsHourLabel string `json:"period_begins_hour_label,omitempty"`
|
||||||
|
PeriodEnds string `json:"period_ends,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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPrecipTimingModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
value := precipTimingValue(ctx.Derived.PrecipTiming, ctx.Timezone)
|
||||||
|
return &module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func precipTimingValue(timing forecast.PrecipTiming, timezone string) PrecipTimingModule {
|
||||||
|
value := PrecipTimingModule{
|
||||||
|
ProbabilityThreshold: timing.ProbabilityThreshold,
|
||||||
|
ThunderMentioned: timing.ThunderMentioned,
|
||||||
|
}
|
||||||
|
if timing.MaxPrecipitationProbability != nil {
|
||||||
|
value.MaxPopPercent = roundedInt(&timing.MaxPrecipitationProbability.Value)
|
||||||
|
value.MaxPopTime = clockLabel(timing.MaxPrecipitationProbability.Time, timezone)
|
||||||
|
}
|
||||||
|
for _, window := range timing.PrecipitationWindows {
|
||||||
|
item := PrecipitationWindowModule{
|
||||||
|
PeriodBegins: friendlyDateTimeLabel(window.Start, timezone),
|
||||||
|
PeriodBeginsHourLabel: hourMinuteLabel(window.Start, timezone),
|
||||||
|
}
|
||||||
|
if window.End != nil {
|
||||||
|
item.PeriodEnds = friendlyDateTimeLabel(*window.End, timezone)
|
||||||
|
item.PeriodEndsHourLabel = hourMinuteLabel(*window.End, timezone)
|
||||||
|
}
|
||||||
|
item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value)
|
||||||
|
item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone)
|
||||||
|
item.MaxPopHourLabel = hourMinuteLabel(window.MaxPrecipitationProbability.Time, timezone)
|
||||||
|
value.PrecipitationWindows = append(value.PrecipitationWindows, item)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
94
internal/briefing/spc_convective_discussion_module.go
Normal file
94
internal/briefing/spc_convective_discussion_module.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultSPCConvectiveDiscussionMinimumSeverityRank = 3
|
||||||
|
const spcCategoricalOutlookType = "categorical"
|
||||||
|
|
||||||
|
type SPCConvectiveDiscussionModule struct {
|
||||||
|
IncludedBecause string `json:"included_because"`
|
||||||
|
Discussions []SPCConvectiveDiscussionRecord `json:"discussions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SPCConvectiveDiscussionRecord struct {
|
||||||
|
Day int `json:"day,omitempty"`
|
||||||
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
|
Headline string `json:"headline,omitempty"`
|
||||||
|
Summary string `json:"summary,omitempty"`
|
||||||
|
Discussion string `json:"discussion,omitempty"`
|
||||||
|
UpdatedAt string `json:"updated_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSPCConvectiveDiscussionModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
qualifyingPeriods := spcConvectiveDiscussionQualifyingPeriods(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod)
|
||||||
|
if len(qualifyingPeriods) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
records := spcConvectiveDiscussionRecords(qualifyingPeriods, ctx.Derived.SPCConvectiveDiscussions, ctx.Timezone)
|
||||||
|
if len(records) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
value := SPCConvectiveDiscussionModule{
|
||||||
|
IncludedBecause: fmt.Sprintf("%s severity_rank >= %d", spcCategoricalOutlookType, defaultSPCConvectiveDiscussionMinimumSeverityRank),
|
||||||
|
Discussions: records,
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.SPCConvectiveDiscussion, StanzaName: string(module.SPCConvectiveDiscussion), Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func spcConvectiveDiscussionQualifyingPeriods(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period) map[int]timeutil.Period {
|
||||||
|
periods := map[int]timeutil.Period{}
|
||||||
|
for _, outlook := range outlooks {
|
||||||
|
if outlook.OutlookType != spcCategoricalOutlookType {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if outlook.SeverityRank == nil || *outlook.SeverityRank < defaultSPCConvectiveDiscussionMinimumSeverityRank {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
|
||||||
|
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(reportPeriod) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if existing, ok := periods[outlook.Day]; ok {
|
||||||
|
if outlookPeriod.Start.Before(existing.Start) {
|
||||||
|
existing.Start = outlookPeriod.Start
|
||||||
|
}
|
||||||
|
if outlookPeriod.End.After(existing.End) {
|
||||||
|
existing.End = outlookPeriod.End
|
||||||
|
}
|
||||||
|
periods[outlook.Day] = existing
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
periods[outlook.Day] = outlookPeriod
|
||||||
|
}
|
||||||
|
return periods
|
||||||
|
}
|
||||||
|
|
||||||
|
func spcConvectiveDiscussionRecords(qualifyingPeriods map[int]timeutil.Period, discussions []weatherdata.ConvectiveOutlookDiscussion, timezone string) []SPCConvectiveDiscussionRecord {
|
||||||
|
records := make([]SPCConvectiveDiscussionRecord, 0, len(discussions))
|
||||||
|
for _, discussion := range discussions {
|
||||||
|
period, ok := qualifyingPeriods[discussion.Day]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if discussion.Discussion == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
records = append(records, SPCConvectiveDiscussionRecord{
|
||||||
|
Day: discussion.Day,
|
||||||
|
PeriodBegins: friendlyPeriodBeginsLabel(period, timezone),
|
||||||
|
PeriodEnds: friendlyPeriodEndsLabel(period, timezone),
|
||||||
|
Headline: discussion.Headline,
|
||||||
|
Summary: discussion.Summary,
|
||||||
|
Discussion: discussion.Discussion,
|
||||||
|
UpdatedAt: friendlyOptionalTime(discussion.UpdatedAt, timezone),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
317
internal/briefing/spc_convective_discussion_module_test.go
Normal file
317
internal/briefing/spc_convective_discussion_module_test.go
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSPCConvectiveDiscussionModuleOmitsBelowThresholdButOutlookRemains(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := spcConvectiveDiscussionContext(2, []weatherdata.ConvectiveOutlookDiscussion{
|
||||||
|
spcDiscussion(1, "Lower risk", "General thunderstorms.", "No organized severe weather is expected.", "2026-05-29T08:30:00-05:00"),
|
||||||
|
})
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(discussion) error = %v", err)
|
||||||
|
}
|
||||||
|
if output != nil {
|
||||||
|
t.Fatalf("discussion output = %#v, want omitted below threshold", output)
|
||||||
|
}
|
||||||
|
|
||||||
|
outlookOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(outlooks) error = %v", err)
|
||||||
|
}
|
||||||
|
outlookValue := moduleValue[SPCConvectiveOutlooksModule](t, outlookOutput)
|
||||||
|
if !outlookValue.Checked || outlookValue.OutlookCount != 1 {
|
||||||
|
t.Fatalf("outlook value = %#v, want lower-risk outlook still emitted", outlookValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveDiscussionModuleIncludesEqualThresholdDiscussion(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := spcConvectiveDiscussionContext(3, []weatherdata.ConvectiveOutlookDiscussion{
|
||||||
|
spcDiscussion(1, "Severe storms possible", "Scattered severe storms are possible.", "A few storms may become severe during the afternoon.", "2026-05-29T08:30:00-05:00"),
|
||||||
|
})
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
if output == nil || output.ID != module.SPCConvectiveDiscussion || output.StanzaName != "spc_convective_discussion" {
|
||||||
|
t.Fatalf("output = %#v, want spc convective discussion stanza", output)
|
||||||
|
}
|
||||||
|
value := moduleValue[SPCConvectiveDiscussionModule](t, output)
|
||||||
|
if value.IncludedBecause != "categorical severity_rank >= 3" || len(value.Discussions) != 1 {
|
||||||
|
t.Fatalf("value = %#v, want threshold reason and one discussion", value)
|
||||||
|
}
|
||||||
|
discussion := value.Discussions[0]
|
||||||
|
if discussion.Day != 1 || discussion.Headline != "Severe storms possible" || discussion.Summary == "" || discussion.Discussion == "" {
|
||||||
|
t.Fatalf("discussion = %#v, want prompt-facing discussion fields", discussion)
|
||||||
|
}
|
||||||
|
if discussion.PeriodBegins != "2026-05-29 at 11:00 AM" || discussion.PeriodEnds != "2026-05-30 at 7:00 AM" {
|
||||||
|
t.Fatalf("Period = %q/%q, want qualifying outlook valid period", discussion.PeriodBegins, discussion.PeriodEnds)
|
||||||
|
}
|
||||||
|
if discussion.UpdatedAt != "2026-05-29 at 8:30 AM" {
|
||||||
|
t.Fatalf("UpdatedAt = %q, want friendly local time", discussion.UpdatedAt)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(output.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
for _, field := range []string{"included_because", "discussions", "period_begins", "period_ends", "headline", "summary", "discussion", "updated_at"} {
|
||||||
|
if !strings.Contains(text, field) {
|
||||||
|
t.Fatalf("json = %s, want field %s", text, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(text, `"period":`) {
|
||||||
|
t.Fatalf("json = %s, want period_begins/period_ends instead of period", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveDiscussionModuleIncludesAboveThresholdDiscussion(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := spcConvectiveDiscussionContext(4, []weatherdata.ConvectiveOutlookDiscussion{
|
||||||
|
spcDiscussion(1, "Enhanced severe risk", "Numerous severe storms are possible.", "Severe storms may produce damaging winds.", "2026-05-29T09:15:00-05:00"),
|
||||||
|
})
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
value := moduleValue[SPCConvectiveDiscussionModule](t, output)
|
||||||
|
if len(value.Discussions) != 1 || value.Discussions[0].Headline != "Enhanced severe risk" {
|
||||||
|
t.Fatalf("value = %#v, want above-threshold discussion", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveDiscussionModuleIgnoresHighRankNonCategoricalOutlook(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
rank := 30
|
||||||
|
ctx := testModuleContext()
|
||||||
|
outlook := weatherdata.ConvectiveOutlook{
|
||||||
|
ID: "day1-wind-30",
|
||||||
|
Day: 1,
|
||||||
|
OutlookType: "wind",
|
||||||
|
Label: "30%",
|
||||||
|
LabelText: "30% Wind Risk",
|
||||||
|
SeverityRank: &rank,
|
||||||
|
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||||
|
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||||
|
}
|
||||||
|
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||||
|
}
|
||||||
|
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||||
|
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
|
||||||
|
spcDiscussion(1, "Wind risk discussion", "High wind probabilities.", "This discussion should not be emitted from wind severity rank.", "2026-05-29T08:30:00-05:00"),
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
if output != nil {
|
||||||
|
t.Fatalf("output = %#v, want non-categorical outlook ignored for discussion threshold", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveDiscussionModuleRequiresCategoricalThresholdForMixedSameDayOutlooks(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
rank2 := 2
|
||||||
|
rank30 := 30
|
||||||
|
ctx := testModuleContext()
|
||||||
|
categorical := weatherdata.ConvectiveOutlook{
|
||||||
|
ID: "day1-marginal",
|
||||||
|
Day: 1,
|
||||||
|
OutlookType: "categorical",
|
||||||
|
Label: "MRGL",
|
||||||
|
LabelText: "Marginal Risk",
|
||||||
|
SeverityRank: &rank2,
|
||||||
|
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||||
|
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||||
|
}
|
||||||
|
wind := weatherdata.ConvectiveOutlook{
|
||||||
|
ID: "day1-wind-30",
|
||||||
|
Day: 1,
|
||||||
|
OutlookType: "wind",
|
||||||
|
Label: "30%",
|
||||||
|
LabelText: "30% Wind Risk",
|
||||||
|
SeverityRank: &rank30,
|
||||||
|
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||||
|
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||||
|
}
|
||||||
|
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{categorical, wind},
|
||||||
|
}
|
||||||
|
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{categorical, wind}
|
||||||
|
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
|
||||||
|
spcDiscussion(1, "Mixed risk discussion", "Only wind is high.", "This discussion should not be emitted without categorical threshold.", "2026-05-29T08:30:00-05:00"),
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
if output != nil {
|
||||||
|
t.Fatalf("output = %#v, want mixed day omitted when categorical outlook is below threshold", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveDiscussionModuleIncludesOnlyQualifyingDays(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
rank2 := 2
|
||||||
|
rank4 := 4
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Resolved.ValidPeriod.Start = mustParseModuleTime("2026-05-30T00:00:00-05:00")
|
||||||
|
ctx.Resolved.ValidPeriod.End = mustParseModuleTime("2026-05-31T00:00:00-05:00")
|
||||||
|
day1Outlook := weatherdata.ConvectiveOutlook{
|
||||||
|
ID: "day1-marginal",
|
||||||
|
Day: 1,
|
||||||
|
OutlookType: "categorical",
|
||||||
|
Label: "MRGL",
|
||||||
|
LabelText: "Marginal Risk",
|
||||||
|
SeverityRank: &rank2,
|
||||||
|
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||||
|
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||||
|
}
|
||||||
|
day2Outlook := weatherdata.ConvectiveOutlook{
|
||||||
|
ID: "day2-enhanced",
|
||||||
|
Day: 2,
|
||||||
|
OutlookType: "categorical",
|
||||||
|
Label: "ENH",
|
||||||
|
LabelText: "Enhanced Risk",
|
||||||
|
SeverityRank: &rank4,
|
||||||
|
ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||||
|
ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"),
|
||||||
|
}
|
||||||
|
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{day1Outlook, day2Outlook},
|
||||||
|
}
|
||||||
|
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{day1Outlook, day2Outlook}
|
||||||
|
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
|
||||||
|
spcDiscussion(1, "Day 1 regional discussion", "Marginal risk discussion.", "Day 1 text should not be emitted.", "2026-05-29T08:30:00-05:00"),
|
||||||
|
spcDiscussion(2, "Day 2 regional discussion", "Enhanced risk discussion.", "Day 2 text should be emitted.", "2026-05-30T08:30:00-05:00"),
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
value := moduleValue[SPCConvectiveDiscussionModule](t, output)
|
||||||
|
if len(value.Discussions) != 1 {
|
||||||
|
t.Fatalf("Discussions = %#v, want only the qualifying day discussion", value.Discussions)
|
||||||
|
}
|
||||||
|
if value.Discussions[0].Day != 2 || value.Discussions[0].Headline != "Day 2 regional discussion" {
|
||||||
|
t.Fatalf("Discussions[0] = %#v, want day 2 discussion only", value.Discussions[0])
|
||||||
|
}
|
||||||
|
if value.Discussions[0].PeriodBegins != "2026-05-30 at 7:00 AM" || value.Discussions[0].PeriodEnds != "2026-05-31 at 7:00 AM" {
|
||||||
|
t.Fatalf("Period = %q/%q, want qualifying day 2 outlook period", value.Discussions[0].PeriodBegins, value.Discussions[0].PeriodEnds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveDiscussionModuleOmitsNonOverlappingQualifyingOutlook(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
rank := 5
|
||||||
|
ctx := testModuleContext()
|
||||||
|
outlook := weatherdata.ConvectiveOutlook{
|
||||||
|
ID: "day2-enhanced",
|
||||||
|
Day: 2,
|
||||||
|
OutlookType: "categorical",
|
||||||
|
Label: "ENH",
|
||||||
|
LabelText: "Enhanced Risk",
|
||||||
|
SeverityRank: &rank,
|
||||||
|
ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||||
|
ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"),
|
||||||
|
}
|
||||||
|
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||||
|
}
|
||||||
|
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||||
|
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
|
||||||
|
spcDiscussion(2, "Day 2 regional discussion", "Enhanced risk discussion.", "Day 2 text should not be emitted for today.", "2026-05-30T08:30:00-05:00"),
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
if output != nil {
|
||||||
|
t.Fatalf("output = %#v, want non-overlapping discussion omitted", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveDiscussionModuleOmitsMissingDiscussionText(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := spcConvectiveDiscussionContext(3, []weatherdata.ConvectiveOutlookDiscussion{
|
||||||
|
{Day: 1, Headline: "Severe storms possible", Summary: "Scattered severe storms are possible.", UpdatedAt: ptrModuleTime("2026-05-29T08:30:00-05:00")},
|
||||||
|
})
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
if output != nil {
|
||||||
|
t.Fatalf("output = %#v, want omitted when discussion text is missing", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveDiscussionModuleOmitsMissingSource(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Collected.SPCConvectiveOutlooks = nil
|
||||||
|
ctx.Derived.SPCConvectiveOutlooks = nil
|
||||||
|
ctx.Derived.SPCConvectiveDiscussions = nil
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
if output != nil {
|
||||||
|
t.Fatalf("output = %#v, want omitted for missing optional source", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func spcConvectiveDiscussionContext(rank int, discussions []weatherdata.ConvectiveOutlookDiscussion) ModuleContext {
|
||||||
|
ctx := testModuleContext()
|
||||||
|
outlook := weatherdata.ConvectiveOutlook{
|
||||||
|
ID: "day1-categorical",
|
||||||
|
Day: 1,
|
||||||
|
OutlookType: "categorical",
|
||||||
|
Label: "SLGT",
|
||||||
|
LabelText: "Slight Risk",
|
||||||
|
SeverityRank: &rank,
|
||||||
|
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||||
|
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||||
|
}
|
||||||
|
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||||
|
AsOf: ptrModuleTime("2026-05-29T08:00:00-05:00"),
|
||||||
|
IssuedAt: ptrModuleTime("2026-05-29T07:45:00-05:00"),
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||||
|
}
|
||||||
|
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||||
|
ctx.Derived.SPCConvectiveDiscussions = discussions
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func spcDiscussion(day int, headline string, summary string, discussion string, updatedAt string) weatherdata.ConvectiveOutlookDiscussion {
|
||||||
|
return weatherdata.ConvectiveOutlookDiscussion{
|
||||||
|
Day: day,
|
||||||
|
Headline: headline,
|
||||||
|
Summary: summary,
|
||||||
|
Discussion: discussion,
|
||||||
|
UpdatedAt: ptrModuleTime(updatedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptrModuleTime(value string) *time.Time {
|
||||||
|
parsed := mustParseModuleTime(value)
|
||||||
|
return &parsed
|
||||||
|
}
|
||||||
92
internal/briefing/spc_convective_outlooks_module.go
Normal file
92
internal/briefing/spc_convective_outlooks_module.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SPCConvectiveOutlooksModule struct {
|
||||||
|
Checked bool `json:"checked"`
|
||||||
|
AsOf string `json:"as_of,omitempty"`
|
||||||
|
IssuedAt string `json:"issued_at,omitempty"`
|
||||||
|
LocationID string `json:"location_id,omitempty"`
|
||||||
|
LocationName string `json:"location_name,omitempty"`
|
||||||
|
OutlookCount int `json:"outlook_count"`
|
||||||
|
Outlooks []SPCConvectiveOutlookRecord `json:"outlooks,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SPCConvectiveOutlookRecord struct {
|
||||||
|
Day int `json:"day,omitempty"`
|
||||||
|
OutlookType string `json:"outlook_type,omitempty"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
LabelText string `json:"label_text,omitempty"`
|
||||||
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
|
IssuedAt string `json:"issued_at,omitempty"`
|
||||||
|
ContainsLocation bool `json:"contains_location"`
|
||||||
|
ImageURL string `json:"image_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSPCConvectiveOutlooksModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
value := SPCConvectiveOutlooksModule{}
|
||||||
|
run := ctx.Collected.SPCConvectiveOutlooks
|
||||||
|
if run != nil {
|
||||||
|
value.Checked = true
|
||||||
|
value.AsOf = friendlyOptionalTime(run.AsOf, ctx.Timezone)
|
||||||
|
value.IssuedAt = friendlyOptionalTime(run.IssuedAt, ctx.Timezone)
|
||||||
|
value.LocationID = run.LocationID
|
||||||
|
value.LocationName = run.LocationName
|
||||||
|
}
|
||||||
|
source, ok := sourceByName(ctx.Collected.SourceProvenance, string(module.SPCConvectiveOutlooks))
|
||||||
|
if value.Checked && value.AsOf == "" && ok && !source.FetchedAt.IsZero() {
|
||||||
|
value.AsOf = friendlyDateTimeLabel(source.FetchedAt, ctx.Timezone)
|
||||||
|
}
|
||||||
|
if value.Checked && value.IssuedAt == "" && ok {
|
||||||
|
value.IssuedAt = friendlyOptionalTime(source.IssuedAt, ctx.Timezone)
|
||||||
|
}
|
||||||
|
|
||||||
|
value.Outlooks = spcConvectiveOutlookRecords(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod, ctx.Timezone)
|
||||||
|
value.OutlookCount = len(value.Outlooks)
|
||||||
|
return &module.Output{ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func spcConvectiveOutlookRecords(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period, timezone string) []SPCConvectiveOutlookRecord {
|
||||||
|
records := make([]SPCConvectiveOutlookRecord, 0, len(outlooks))
|
||||||
|
for _, outlook := range outlooks {
|
||||||
|
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
|
||||||
|
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(reportPeriod) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
records = append(records, SPCConvectiveOutlookRecord{
|
||||||
|
Day: outlook.Day,
|
||||||
|
OutlookType: outlook.OutlookType,
|
||||||
|
Label: outlook.Label,
|
||||||
|
LabelText: outlook.LabelText,
|
||||||
|
PeriodBegins: friendlyPeriodBeginsLabel(outlookPeriod, timezone),
|
||||||
|
PeriodEnds: friendlyPeriodEndsLabel(outlookPeriod, timezone),
|
||||||
|
IssuedAt: friendlyOptionalTime(outlook.IssuedAt, timezone),
|
||||||
|
ContainsLocation: outlook.ContainsLocation,
|
||||||
|
ImageURL: outlook.ImageURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
func friendlyOptionalTime(value *time.Time, timezone string) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return friendlyDateTimeLabel(*value, timezone)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceByName(sources []weatherdata.Source, name string) (weatherdata.Source, bool) {
|
||||||
|
for _, source := range sources {
|
||||||
|
if source.Name == name {
|
||||||
|
return source, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return weatherdata.Source{}, false
|
||||||
|
}
|
||||||
169
internal/briefing/spc_convective_outlooks_module_test.go
Normal file
169
internal/briefing/spc_convective_outlooks_module_test.go
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
rank := 3
|
||||||
|
asOf := mustParseModuleTime("2026-05-29T14:00:00Z")
|
||||||
|
issuedAt := mustParseModuleTime("2026-05-29T13:45:00Z")
|
||||||
|
expiresAt := mustParseModuleTime("2026-05-30T07:00:00-05:00")
|
||||||
|
outlook := weatherdata.ConvectiveOutlook{
|
||||||
|
ID: "day1-categorical-slight",
|
||||||
|
Provider: "spc",
|
||||||
|
Product: "convective_outlook",
|
||||||
|
Day: 1,
|
||||||
|
OutlookType: "categorical",
|
||||||
|
Label: "SLGT",
|
||||||
|
LabelText: "Slight Risk",
|
||||||
|
Forecaster: "Smith",
|
||||||
|
SeverityRank: &rank,
|
||||||
|
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||||
|
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||||
|
IssuedAt: &issuedAt,
|
||||||
|
ExpiresAt: &expiresAt,
|
||||||
|
SourceURL: "https://www.spc.noaa.gov/products/outlook/day1otlk.html",
|
||||||
|
ImageURL: "https://www.spc.noaa.gov/products/outlook/day1probotlk_2000_torn.gif",
|
||||||
|
ContainsLocation: true,
|
||||||
|
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[]}`),
|
||||||
|
}
|
||||||
|
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||||
|
LocationID: "nws-lsx-grid-90-74",
|
||||||
|
LocationName: "St. Louis, MO",
|
||||||
|
AsOf: &asOf,
|
||||||
|
IssuedAt: &issuedAt,
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||||
|
}
|
||||||
|
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
if output == nil || output.ID != module.SPCConvectiveOutlooks || output.StanzaName != "spc_convective_outlooks" {
|
||||||
|
t.Fatalf("output = %#v, want spc convective outlook output", output)
|
||||||
|
}
|
||||||
|
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
|
||||||
|
if !value.Checked || value.OutlookCount != 1 || value.AsOf != "2026-05-29 at 9:00 AM" || value.IssuedAt != "2026-05-29 at 8:45 AM" {
|
||||||
|
t.Fatalf("SPCConvectiveOutlooksModule = %#v, want checked source timing and one outlook", value)
|
||||||
|
}
|
||||||
|
if value.LocationID != "nws-lsx-grid-90-74" || value.LocationName != "St. Louis, MO" {
|
||||||
|
t.Fatalf("source location = %q/%q, want Weather API location", value.LocationID, value.LocationName)
|
||||||
|
}
|
||||||
|
if len(value.Outlooks) != 1 {
|
||||||
|
t.Fatalf("Outlooks length = %d, want 1", len(value.Outlooks))
|
||||||
|
}
|
||||||
|
got := value.Outlooks[0]
|
||||||
|
if got.Day != 1 || got.OutlookType != "categorical" || got.Label != "SLGT" || got.LabelText != "Slight Risk" {
|
||||||
|
t.Fatalf("outlook = %#v, want categorical slight risk fields", got)
|
||||||
|
}
|
||||||
|
if got.PeriodBegins != "2026-05-29 at 11:00 AM" || got.PeriodEnds != "2026-05-30 at 7:00 AM" || got.IssuedAt != "2026-05-29 at 8:45 AM" {
|
||||||
|
t.Fatalf("outlook times = %#v, want friendly local labels", got)
|
||||||
|
}
|
||||||
|
if !got.ContainsLocation || got.ImageURL == "" {
|
||||||
|
t.Fatalf("outlook = %#v, want location flag and image URL", got)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(output.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
for _, field := range []string{"checked", "as_of", "issued_at", "location_id", "location_name", "outlook_count", "outlooks", "period_begins", "period_ends", "contains_location", "image_url"} {
|
||||||
|
if !strings.Contains(text, field) {
|
||||||
|
t.Fatalf("json = %s, want field %s", text, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, omitted := range []string{"geometry", "coordinates", "forecaster", "provider", "severity_rank", "expires_at", "source_url", "valid_start", "valid_end", `"period":`} {
|
||||||
|
if strings.Contains(text, omitted) {
|
||||||
|
t.Fatalf("json = %s, want prompt-safe outlook without %s", text, omitted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveOutlooksModuleSkipsNonOverlappingOutlooks(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
rank := 5
|
||||||
|
outlook := weatherdata.ConvectiveOutlook{
|
||||||
|
ID: "tomorrow-enhanced",
|
||||||
|
Day: 2,
|
||||||
|
OutlookType: "categorical",
|
||||||
|
Label: "ENH",
|
||||||
|
LabelText: "Enhanced Risk",
|
||||||
|
SeverityRank: &rank,
|
||||||
|
ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||||
|
ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"),
|
||||||
|
ContainsLocation: true,
|
||||||
|
}
|
||||||
|
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||||
|
AsOf: ptrModuleTime("2026-05-29T14:00:00Z"),
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||||
|
}
|
||||||
|
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
|
||||||
|
if value.OutlookCount != 0 || len(value.Outlooks) != 0 {
|
||||||
|
t.Fatalf("value = %#v, want non-overlapping outlook omitted", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveOutlooksModuleBuildsCheckedEmptyStanza(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
asOf := mustParseModuleTime("2026-05-29T14:00:00Z")
|
||||||
|
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||||
|
LocationID: "nws-lsx-grid-90-74",
|
||||||
|
LocationName: "St. Louis, MO",
|
||||||
|
AsOf: &asOf,
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{},
|
||||||
|
}
|
||||||
|
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
|
||||||
|
if !value.Checked || value.OutlookCount != 0 || len(value.Outlooks) != 0 {
|
||||||
|
t.Fatalf("checked empty value = %#v, want checked source with no retained outlooks", value)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(output.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), "outlooks") {
|
||||||
|
t.Fatalf("json = %s, want empty outlook list omitted", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveOutlooksModuleBuildsUncheckedStanzaForMissingSource(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Collected.SPCConvectiveOutlooks = nil
|
||||||
|
ctx.Collected.SourceProvenance = []weatherdata.Source{{
|
||||||
|
Name: string(module.SPCConvectiveOutlooks),
|
||||||
|
Missing: true,
|
||||||
|
}}
|
||||||
|
ctx.Derived.SPCConvectiveOutlooks = nil
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
|
||||||
|
if value.Checked || value.OutlookCount != 0 || value.AsOf != "" || value.IssuedAt != "" {
|
||||||
|
t.Fatalf("missing source value = %#v, want unchecked empty stanza", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
package briefing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Storm struct {
|
|
||||||
TimingWindow timeutil.Period `json:"timingWindow"`
|
|
||||||
EventHeadlines []string `json:"eventHeadlines,omitempty"`
|
|
||||||
Hazards []string `json:"hazards,omitempty"`
|
|
||||||
MostLikelyScenario []string `json:"mostLikelyScenario,omitempty"`
|
|
||||||
ReasonableWorstCase []string `json:"reasonableWorstCase,omitempty"`
|
|
||||||
ConfidenceInputs []string `json:"confidenceInputs,omitempty"`
|
|
||||||
WhatToWatchNext []string `json:"whatToWatchNext,omitempty"`
|
|
||||||
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
|
|
||||||
HourlyPeriods []forecast.ForecastPeriod `json:"hourlyPeriods,omitempty"`
|
|
||||||
DailyPeriods []forecast.ForecastPeriod `json:"dailyPeriods,omitempty"`
|
|
||||||
NarrativePeriods []forecast.ForecastPeriod `json:"narrativePeriods,omitempty"`
|
|
||||||
WindowSummary forecast.DaypartSummary `json:"windowSummary"`
|
|
||||||
Discussion DiscussionContext `json:"discussion,omitempty"`
|
|
||||||
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildStorm(ctx BuildContext) (Package, error) {
|
|
||||||
if ctx.Resolved.Definition.ID != report.Storm {
|
|
||||||
return Package{}, fmt.Errorf("storm briefing requires a storm report definition")
|
|
||||||
}
|
|
||||||
if ctx.Bundle == nil {
|
|
||||||
return Package{}, fmt.Errorf("forecast bundle is required")
|
|
||||||
}
|
|
||||||
period := ctx.Resolved.ValidPeriod
|
|
||||||
hourly := forecast.SelectHourlyPeriods(ctx.Bundle.Hourly, period)
|
|
||||||
narrative := forecast.SelectNarrativePeriods(ctx.Bundle, period)
|
|
||||||
daily := forecast.SelectHourlyPeriods(ctx.Bundle.Daily, period)
|
|
||||||
alerts := forecast.AlertOverlaps(ctx.Bundle.Alerts, period)
|
|
||||||
summary := forecast.SummarizeDaypart("storm window", period, hourly)
|
|
||||||
summary.AlertOverlaps = alerts
|
|
||||||
|
|
||||||
storm := &Storm{
|
|
||||||
TimingWindow: period,
|
|
||||||
EventHeadlines: stormHeadlines(alerts),
|
|
||||||
Hazards: stormHazards(alerts, summary),
|
|
||||||
MostLikelyScenario: mostLikelyStormScenario(hourly, narrative, summary),
|
|
||||||
ReasonableWorstCase: reasonableWorstCase(alerts, summary),
|
|
||||||
ConfidenceInputs: stormConfidenceInputs(ctx.Bundle),
|
|
||||||
WhatToWatchNext: stormWatchItems(alerts, summary, ctx.Bundle),
|
|
||||||
RelevantAlerts: alerts,
|
|
||||||
HourlyPeriods: hourly,
|
|
||||||
DailyPeriods: daily,
|
|
||||||
NarrativePeriods: narrative,
|
|
||||||
WindowSummary: summary,
|
|
||||||
Discussion: buildDiscussion(ctx.Bundle.Discussion),
|
|
||||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
|
||||||
}
|
|
||||||
pkg := buildPackage(ctx)
|
|
||||||
pkg.Storm = storm
|
|
||||||
setRelevantAlertCount(&pkg.Metadata, len(alerts))
|
|
||||||
return pkg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func stormHeadlines(alerts []forecast.AlertOverlap) []string {
|
|
||||||
var headlines []string
|
|
||||||
for _, alert := range alerts {
|
|
||||||
if alert.Headline != "" {
|
|
||||||
headlines = appendUnique(headlines, alert.Headline)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if alert.Event != "" {
|
|
||||||
headlines = appendUnique(headlines, alert.Event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(headlines) == 0 {
|
|
||||||
return []string{"No active alert headline overlaps the selected storm window."}
|
|
||||||
}
|
|
||||||
return headlines
|
|
||||||
}
|
|
||||||
|
|
||||||
func stormHazards(alerts []forecast.AlertOverlap, summary forecast.DaypartSummary) []string {
|
|
||||||
hazards := map[string]struct{}{}
|
|
||||||
for _, alert := range alerts {
|
|
||||||
if alert.Event != "" {
|
|
||||||
hazards[alert.Event] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, hazard := range hazardsForIndicators(summary.Indicators) {
|
|
||||||
hazards[hazard] = struct{}{}
|
|
||||||
}
|
|
||||||
if summary.MaxPrecipitationProbability != nil && summary.MaxPrecipitationProbability.Value >= 50 {
|
|
||||||
hazards["precipitation"] = struct{}{}
|
|
||||||
}
|
|
||||||
if summary.PeakWindGust != nil && summary.PeakWindGust.Value >= 30 {
|
|
||||||
hazards["wind"] = struct{}{}
|
|
||||||
}
|
|
||||||
out := sortedSet(hazards)
|
|
||||||
if len(out) == 0 {
|
|
||||||
return []string{"No storm-specific hazard signal stands out in the selected source data."}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func mostLikelyStormScenario(hourly []forecast.ForecastPeriod, narrative []forecast.ForecastPeriod, summary forecast.DaypartSummary) []string {
|
|
||||||
var items []string
|
|
||||||
if summary.DominantCondition != "" {
|
|
||||||
items = append(items, "Dominant hourly condition: "+summary.DominantCondition+".")
|
|
||||||
}
|
|
||||||
if summary.MaxPrecipitationProbability != nil {
|
|
||||||
items = append(items, fmt.Sprintf("Peak precipitation chance is near %.0f%% around %s.", summary.MaxPrecipitationProbability.Value, summary.MaxPrecipitationProbability.Time.Format("15:04")))
|
|
||||||
}
|
|
||||||
if summary.PeakWindGust != nil {
|
|
||||||
items = append(items, fmt.Sprintf("Peak wind gust is near %.0f mph around %s.", summary.PeakWindGust.Value, summary.PeakWindGust.Time.Format("15:04")))
|
|
||||||
}
|
|
||||||
for _, period := range narrative {
|
|
||||||
if period.TextDescription != "" {
|
|
||||||
items = append(items, "Narrative guidance: "+period.TextDescription)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(items) == 0 && len(hourly) > 0 {
|
|
||||||
items = append(items, "Hourly forecast periods are available, but no focused storm signal is prominent.")
|
|
||||||
}
|
|
||||||
if len(items) == 0 {
|
|
||||||
items = append(items, "No active storm signal is evident from the selected forecast window.")
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
func reasonableWorstCase(alerts []forecast.AlertOverlap, summary forecast.DaypartSummary) []string {
|
|
||||||
var items []string
|
|
||||||
for _, alert := range alerts {
|
|
||||||
label := alert.Event
|
|
||||||
if label == "" {
|
|
||||||
label = alert.Headline
|
|
||||||
}
|
|
||||||
if label != "" {
|
|
||||||
items = appendUnique(items, "Alert scenario to consider: "+label+".")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if summary.Indicators.Wind {
|
|
||||||
items = appendUnique(items, "Wind impacts could be higher where stronger gusts occur.")
|
|
||||||
}
|
|
||||||
if summary.Indicators.Snow || summary.Indicators.Ice {
|
|
||||||
items = appendUnique(items, "Wintry precipitation could create travel impacts if it overlaps the event window.")
|
|
||||||
}
|
|
||||||
if len(items) == 0 {
|
|
||||||
items = append(items, "No clear reasonable worst-case signal is represented in the selected data.")
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
func stormConfidenceInputs(bundle *forecast.Bundle) []string {
|
|
||||||
var items []string
|
|
||||||
if bundle == nil {
|
|
||||||
return []string{"No source bundle was available for confidence context."}
|
|
||||||
}
|
|
||||||
if bundle.Discussion != nil {
|
|
||||||
items = appendUnique(items, bundle.Discussion.KeyMessages...)
|
|
||||||
if bundle.Discussion.ShortTerm != nil && bundle.Discussion.ShortTerm.Text != "" {
|
|
||||||
items = appendUnique(items, "Short-term discussion is available for confidence context.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if bundle.WeatherStory != nil {
|
|
||||||
if bundle.WeatherStory.Title != "" {
|
|
||||||
items = appendUnique(items, "Weather story: "+bundle.WeatherStory.Title+".")
|
|
||||||
} else {
|
|
||||||
items = appendUnique(items, "Weather story source is available.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, warning := range bundle.Warnings {
|
|
||||||
if warning.Code != "" {
|
|
||||||
items = appendUnique(items, "Source warning: "+warning.Code+".")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(items) == 0 {
|
|
||||||
items = append(items, "No explicit confidence or uncertainty signal was available from the selected source context.")
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
func stormWatchItems(alerts []forecast.AlertOverlap, summary forecast.DaypartSummary, bundle *forecast.Bundle) []string {
|
|
||||||
var items []string
|
|
||||||
if len(alerts) > 0 {
|
|
||||||
items = append(items, "Watch for alert extensions, cancellations, or upgrades.")
|
|
||||||
}
|
|
||||||
if summary.MaxPrecipitationProbability != nil {
|
|
||||||
items = append(items, "Watch precipitation timing and probability trends.")
|
|
||||||
}
|
|
||||||
if summary.PeakWindGust != nil {
|
|
||||||
items = append(items, "Watch wind gust trends.")
|
|
||||||
}
|
|
||||||
if bundle != nil && bundle.Discussion != nil {
|
|
||||||
items = append(items, "Watch the next forecast discussion update for confidence changes.")
|
|
||||||
}
|
|
||||||
if len(items) == 0 {
|
|
||||||
items = append(items, "Watch for new alerts or stronger wording if the weather pattern changes.")
|
|
||||||
}
|
|
||||||
return appendUnique(nil, items...)
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
package briefing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestStormBriefingWithActiveAlert(t *testing.T) {
|
|
||||||
location := mustLocation(t)
|
|
||||||
resolved, err := report.Resolve(report.Storm, report.ResolveRequest{
|
|
||||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
|
||||||
Location: location,
|
|
||||||
StormStart: mustParse("2026-05-29T06:00:00-05:00"),
|
|
||||||
StormEnd: mustParse("2026-05-29T12:00:00-05:00"),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve storm: %v", err)
|
|
||||||
}
|
|
||||||
precip := 80.0
|
|
||||||
gust := 42.0
|
|
||||||
bundle := &forecast.Bundle{
|
|
||||||
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{
|
|
||||||
StartTime: mustParse("2026-05-29T07:00:00-05:00"),
|
|
||||||
EndTime: mustParse("2026-05-29T08:00:00-05:00"),
|
|
||||||
TextDescription: "Severe thunderstorms and gusty wind",
|
|
||||||
ProbabilityOfPrecipitationPercent: &precip,
|
|
||||||
WindGustMph: &gust,
|
|
||||||
}}},
|
|
||||||
Daily: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{
|
|
||||||
StartTime: mustParse("2026-05-29T06:00:00-05:00"),
|
|
||||||
EndTime: mustParse("2026-05-29T18:00:00-05:00"),
|
|
||||||
TextDescription: "Storms likely.",
|
|
||||||
}}},
|
|
||||||
Narrative: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{
|
|
||||||
StartTime: mustParse("2026-05-29T06:00:00-05:00"),
|
|
||||||
EndTime: mustParse("2026-05-29T18:00:00-05:00"),
|
|
||||||
TextDescription: "Damaging wind possible in stronger storms.",
|
|
||||||
}}},
|
|
||||||
Alerts: &forecast.AlertRun{Alerts: []json.RawMessage{
|
|
||||||
json.RawMessage(`{"event":"Severe Thunderstorm Warning","headline":"Severe storms near Testville","severity":"Severe","effective":"2026-05-29T06:30:00-05:00","expires":"2026-05-29T08:30:00-05:00"}`),
|
|
||||||
}},
|
|
||||||
Discussion: &forecast.Discussion{
|
|
||||||
Product: "discussion",
|
|
||||||
KeyMessages: []string{"Storms may intensify quickly."},
|
|
||||||
ShortTerm: &forecast.DiscussionSection{Text: "Short-term storm coverage peaks this morning."},
|
|
||||||
LongTerm: &forecast.DiscussionSection{Text: "Long-term pattern stays unsettled after the event."},
|
|
||||||
},
|
|
||||||
WeatherStory: &forecast.WeatherStory{
|
|
||||||
OfficeID: "LSX",
|
|
||||||
StartTime: mustParse("2026-05-29T06:00:00Z"),
|
|
||||||
EndTime: mustParse("2026-05-29T18:00:00Z"),
|
|
||||||
Title: "Storm Risk",
|
|
||||||
Description: "Strong storms are possible.",
|
|
||||||
AltText: "Weather story graphic showing storm risk.",
|
|
||||||
Order: 1,
|
|
||||||
},
|
|
||||||
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
|
|
||||||
}
|
|
||||||
|
|
||||||
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildStorm() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if pkg.Metadata.ReportID != report.Storm {
|
|
||||||
t.Fatalf("ReportID = %q, want storm", pkg.Metadata.ReportID)
|
|
||||||
}
|
|
||||||
if pkg.Storm == nil {
|
|
||||||
t.Fatal("Storm = nil")
|
|
||||||
}
|
|
||||||
if len(pkg.Storm.RelevantAlerts) != 1 || len(pkg.Storm.EventHeadlines) != 1 {
|
|
||||||
t.Fatalf("alerts/headlines = %#v/%#v, want alert inputs", pkg.Storm.RelevantAlerts, pkg.Storm.EventHeadlines)
|
|
||||||
}
|
|
||||||
if !pkg.Storm.TimingWindow.Start.Equal(resolved.ValidPeriod.Start) || !pkg.Storm.TimingWindow.End.Equal(resolved.ValidPeriod.End) {
|
|
||||||
t.Fatalf("TimingWindow = %#v, want resolved valid period %#v", pkg.Storm.TimingWindow, resolved.ValidPeriod)
|
|
||||||
}
|
|
||||||
if !strings.Contains(strings.Join(pkg.Storm.Hazards, ","), "Severe Thunderstorm Warning") {
|
|
||||||
t.Fatalf("Hazards = %#v, want alert event", pkg.Storm.Hazards)
|
|
||||||
}
|
|
||||||
if len(pkg.Storm.HourlyPeriods) != 1 || len(pkg.Storm.DailyPeriods) != 1 || len(pkg.Storm.NarrativePeriods) != 1 {
|
|
||||||
t.Fatalf("selected periods hourly/daily/narrative = %d/%d/%d, want selected source periods", len(pkg.Storm.HourlyPeriods), len(pkg.Storm.DailyPeriods), len(pkg.Storm.NarrativePeriods))
|
|
||||||
}
|
|
||||||
if pkg.Storm.WeatherStory == nil {
|
|
||||||
t.Fatal("WeatherStory = nil, want available story context")
|
|
||||||
}
|
|
||||||
if pkg.Storm.WeatherStory.Title != "Storm Risk" || pkg.Storm.WeatherStory.Description != "Strong storms are possible." {
|
|
||||||
t.Fatalf("WeatherStory = %#v, want structured story context", pkg.Storm.WeatherStory)
|
|
||||||
}
|
|
||||||
if pkg.Storm.Discussion.ShortTerm != "Short-term storm coverage peaks this morning." {
|
|
||||||
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Storm.Discussion.ShortTerm)
|
|
||||||
}
|
|
||||||
if pkg.Storm.Discussion.LongTerm != "Long-term pattern stays unsettled after the event." {
|
|
||||||
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Storm.Discussion.LongTerm)
|
|
||||||
}
|
|
||||||
if len(pkg.Storm.WhatToWatchNext) == 0 {
|
|
||||||
t.Fatal("WhatToWatchNext length = 0, want watch inputs")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStormBriefingWithDiscussionButNoAlert(t *testing.T) {
|
|
||||||
location := mustLocation(t)
|
|
||||||
resolved, err := report.Resolve(report.Storm, report.ResolveRequest{
|
|
||||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
|
||||||
Location: location,
|
|
||||||
StormStart: mustParse("2026-05-29T06:00:00-05:00"),
|
|
||||||
StormEnd: mustParse("2026-05-29T12:00:00-05:00"),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve storm: %v", err)
|
|
||||||
}
|
|
||||||
bundle := &forecast.Bundle{
|
|
||||||
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{StartTime: mustParse("2026-05-29T07:00:00-05:00"), EndTime: mustParse("2026-05-29T08:00:00-05:00"), TextDescription: "Showers"}}},
|
|
||||||
Alerts: &forecast.AlertRun{},
|
|
||||||
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Confidence is moderate."}},
|
|
||||||
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
|
|
||||||
}
|
|
||||||
|
|
||||||
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildStorm() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(pkg.Storm.RelevantAlerts) != 0 {
|
|
||||||
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Storm.RelevantAlerts))
|
|
||||||
}
|
|
||||||
if !strings.Contains(strings.Join(pkg.Storm.EventHeadlines, " "), "No active alert") {
|
|
||||||
t.Fatalf("EventHeadlines = %#v, want no-alert fallback", pkg.Storm.EventHeadlines)
|
|
||||||
}
|
|
||||||
if !strings.Contains(strings.Join(pkg.Storm.ConfidenceInputs, " "), "Confidence is moderate") {
|
|
||||||
t.Fatalf("ConfidenceInputs = %#v, want discussion key message", pkg.Storm.ConfidenceInputs)
|
|
||||||
}
|
|
||||||
if len(pkg.Storm.MostLikelyScenario) == 0 {
|
|
||||||
t.Fatal("MostLikelyScenario length = 0, want forecast scenario inputs")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStormBriefingQuietWindow(t *testing.T) {
|
|
||||||
location := mustLocation(t)
|
|
||||||
resolved, err := report.Resolve(report.Storm, report.ResolveRequest{
|
|
||||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
|
||||||
Location: location,
|
|
||||||
StormStart: mustParse("2026-05-29T06:00:00-05:00"),
|
|
||||||
StormEnd: mustParse("2026-05-29T12:00:00-05:00"),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve storm: %v", err)
|
|
||||||
}
|
|
||||||
bundle := &forecast.Bundle{
|
|
||||||
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{StartTime: mustParse("2026-05-29T07:00:00-05:00"), EndTime: mustParse("2026-05-29T08:00:00-05:00"), TextDescription: "Clear"}}},
|
|
||||||
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
|
|
||||||
}
|
|
||||||
|
|
||||||
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildStorm() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(pkg.Storm.Hazards) != 1 || !strings.Contains(pkg.Storm.Hazards[0], "No storm-specific") {
|
|
||||||
t.Fatalf("Hazards = %#v, want quiet hazard fallback", pkg.Storm.Hazards)
|
|
||||||
}
|
|
||||||
if !strings.Contains(strings.Join(pkg.Storm.WhatToWatchNext, " "), "new alerts") {
|
|
||||||
t.Fatalf("WhatToWatchNext = %#v, want watch fallback", pkg.Storm.WhatToWatchNext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,120 +5,27 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Daily struct {
|
|
||||||
BottomLine BottomLine `json:"bottomLine"`
|
|
||||||
Dayparts []forecast.DaypartSummary `json:"dayparts"`
|
|
||||||
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
|
|
||||||
OutdoorWindows OutdoorWindows `json:"outdoorWindows"`
|
|
||||||
Planning *TomorrowPlanning `json:"planning,omitempty"`
|
|
||||||
NarrativePeriods []forecast.ForecastPeriod `json:"narrativePeriods,omitempty"`
|
|
||||||
Discussion DiscussionContext `json:"discussion,omitempty"`
|
|
||||||
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
|
|
||||||
ForecastSummaryDate string `json:"forecastSummaryDate"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type BottomLine struct {
|
|
||||||
Summary string `json:"summary"`
|
|
||||||
Hazards []string `json:"hazards,omitempty"`
|
|
||||||
Temperature forecast.Range `json:"temperature,omitempty"`
|
|
||||||
MaxPrecipProbability *forecast.TimedValue `json:"maxPrecipitationProbability,omitempty"`
|
|
||||||
PeakWindGust *forecast.TimedValue `json:"peakWindGust,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type OutdoorWindows struct {
|
type OutdoorWindows struct {
|
||||||
Best *OutdoorWindow `json:"best,omitempty"`
|
Best *OutdoorWindow
|
||||||
Worst *OutdoorWindow `json:"worst,omitempty"`
|
Worst *OutdoorWindow
|
||||||
}
|
}
|
||||||
|
|
||||||
type OutdoorWindow struct {
|
type OutdoorWindow struct {
|
||||||
Daypart string `json:"daypart"`
|
Daypart string
|
||||||
Start string `json:"start"`
|
Period timeutil.Period
|
||||||
End string `json:"end"`
|
Reasons []string
|
||||||
Reasons []string `json:"reasons,omitempty"`
|
Score float64
|
||||||
Score float64 `json:"score"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type TomorrowPlanning struct {
|
type TomorrowPlanning struct {
|
||||||
MorningReadiness []string `json:"morningReadiness,omitempty"`
|
MorningReadiness []string
|
||||||
CommuteSchoolWorkdayConcerns []string `json:"commuteSchoolWorkdayConcerns,omitempty"`
|
CommuteSchoolWorkdayConcerns []string
|
||||||
OvernightChangeWatch []string `json:"overnightChangeWatch,omitempty"`
|
OvernightChangeWatch []string
|
||||||
}
|
|
||||||
|
|
||||||
type DiscussionContext struct {
|
|
||||||
Product string `json:"product,omitempty"`
|
|
||||||
KeyMessages []string `json:"keyMessages,omitempty"`
|
|
||||||
ShortTerm string `json:"shortTerm,omitempty"`
|
|
||||||
LongTerm string `json:"longTerm,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type WeatherStoryContext struct {
|
|
||||||
Available bool `json:"available"`
|
|
||||||
OfficeID string `json:"officeId,omitempty"`
|
|
||||||
StartTime time.Time `json:"startTime"`
|
|
||||||
EndTime time.Time `json:"endTime"`
|
|
||||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
|
||||||
Title string `json:"title,omitempty"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
AltText string `json:"altText,omitempty"`
|
|
||||||
Priority bool `json:"priority"`
|
|
||||||
Order int `json:"order"`
|
|
||||||
DownloadURL string `json:"downloadUrl,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, error) {
|
|
||||||
if ctx.Resolved.Definition.ID != report.DailyToday && ctx.Resolved.Definition.ID != report.DailyTomorrow {
|
|
||||||
return Package{}, fmt.Errorf("daily briefing requires a daily report definition")
|
|
||||||
}
|
|
||||||
if summary == nil {
|
|
||||||
return Package{}, fmt.Errorf("daily forecast summary is required")
|
|
||||||
}
|
|
||||||
pkg := buildPackage(ctx)
|
|
||||||
pkg.Daily = &Daily{
|
|
||||||
BottomLine: buildBottomLine(summary),
|
|
||||||
Dayparts: summary.Dayparts,
|
|
||||||
RelevantAlerts: summary.AlertOverlaps,
|
|
||||||
OutdoorWindows: buildOutdoorWindows(summary.Dayparts),
|
|
||||||
NarrativePeriods: summary.NarrativePeriods,
|
|
||||||
Discussion: buildDiscussion(summary.Discussion),
|
|
||||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
|
||||||
ForecastSummaryDate: summary.Date,
|
|
||||||
}
|
|
||||||
setRelevantAlertCount(&pkg.Metadata, len(summary.AlertOverlaps))
|
|
||||||
if ctx.Resolved.Definition.ID == report.DailyTomorrow {
|
|
||||||
pkg.Daily.Planning = buildTomorrowPlanning(summary)
|
|
||||||
}
|
|
||||||
return pkg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildBottomLine(summary *forecast.DailySummary) BottomLine {
|
|
||||||
bottomLine := BottomLine{}
|
|
||||||
conditions := map[string]struct{}{}
|
|
||||||
hazards := map[string]struct{}{}
|
|
||||||
for _, daypart := range summary.Dayparts {
|
|
||||||
addRange(&bottomLine.Temperature, daypart.Temperature)
|
|
||||||
maxTimedValue(&bottomLine.MaxPrecipProbability, daypart.MaxPrecipitationProbability)
|
|
||||||
maxTimedValue(&bottomLine.PeakWindGust, daypart.PeakWindGust)
|
|
||||||
if daypart.DominantCondition != "" {
|
|
||||||
conditions[daypart.DominantCondition] = struct{}{}
|
|
||||||
}
|
|
||||||
for _, hazard := range hazardsForIndicators(daypart.Indicators) {
|
|
||||||
hazards[hazard] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, alert := range summary.AlertOverlaps {
|
|
||||||
if alert.Event != "" {
|
|
||||||
hazards[alert.Event] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bottomLine.Hazards = sortedSet(hazards)
|
|
||||||
bottomLine.Summary = bottomLineText(sortedSet(conditions), bottomLine.Hazards)
|
|
||||||
return bottomLine
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
|
func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
|
||||||
@@ -250,51 +157,6 @@ func daypartNamed(dayparts []forecast.DaypartSummary, name string) *forecast.Day
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildDiscussion(discussion *forecast.Discussion) DiscussionContext {
|
|
||||||
if discussion == nil {
|
|
||||||
return DiscussionContext{}
|
|
||||||
}
|
|
||||||
ctx := DiscussionContext{
|
|
||||||
Product: discussion.Product,
|
|
||||||
KeyMessages: discussion.KeyMessages,
|
|
||||||
}
|
|
||||||
if discussion.ShortTerm != nil {
|
|
||||||
ctx.ShortTerm = discussion.ShortTerm.Text
|
|
||||||
}
|
|
||||||
if discussion.LongTerm != nil {
|
|
||||||
ctx.LongTerm = discussion.LongTerm.Text
|
|
||||||
}
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildWeatherStory(bundle *forecast.Bundle) *WeatherStoryContext {
|
|
||||||
if bundle == nil || bundle.WeatherStory == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
story := bundle.WeatherStory
|
|
||||||
return &WeatherStoryContext{
|
|
||||||
Available: true,
|
|
||||||
OfficeID: story.OfficeID,
|
|
||||||
StartTime: story.StartTime,
|
|
||||||
EndTime: story.EndTime,
|
|
||||||
UpdatedAt: copyTime(story.UpdatedAt),
|
|
||||||
Title: story.Title,
|
|
||||||
Description: story.Description,
|
|
||||||
AltText: story.AltText,
|
|
||||||
Priority: story.Priority,
|
|
||||||
Order: story.Order,
|
|
||||||
DownloadURL: story.DownloadURL,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyTime(value *time.Time) *time.Time {
|
|
||||||
if value == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
copied := *value
|
|
||||||
return &copied
|
|
||||||
}
|
|
||||||
|
|
||||||
func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
|
func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
|
||||||
score := 0.0
|
score := 0.0
|
||||||
reasons := []string{}
|
reasons := []string{}
|
||||||
@@ -328,27 +190,12 @@ 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func bottomLineText(conditions []string, hazards []string) string {
|
|
||||||
if len(conditions) == 0 && len(hazards) == 0 {
|
|
||||||
return "Quiet weather is expected."
|
|
||||||
}
|
|
||||||
parts := []string{}
|
|
||||||
if len(conditions) > 0 {
|
|
||||||
parts = append(parts, "Conditions: "+strings.Join(conditions, "; "))
|
|
||||||
}
|
|
||||||
if len(hazards) > 0 {
|
|
||||||
parts = append(parts, "Watch points: "+strings.Join(hazards, "; "))
|
|
||||||
}
|
|
||||||
return strings.Join(parts, ". ") + "."
|
|
||||||
}
|
|
||||||
|
|
||||||
func hazardsForIndicators(indicators forecast.Indicators) []string {
|
func hazardsForIndicators(indicators forecast.Indicators) []string {
|
||||||
var hazards []string
|
var hazards []string
|
||||||
if indicators.Snow {
|
if indicators.Snow {
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
package briefing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ThreeDay struct {
|
|
||||||
Days []OutlookDay `json:"days"`
|
|
||||||
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
|
|
||||||
Discussion DiscussionContext `json:"discussion,omitempty"`
|
|
||||||
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type OutlookDay struct {
|
|
||||||
Date string `json:"date"`
|
|
||||||
Period timeutil.Period `json:"period"`
|
|
||||||
OverallCharacter string `json:"overallCharacter"`
|
|
||||||
Temperature forecast.Range `json:"temperature,omitempty"`
|
|
||||||
MaxPrecipitationProbability *forecast.TimedValue `json:"maxPrecipitationProbability,omitempty"`
|
|
||||||
PeakWindGust *forecast.TimedValue `json:"peakWindGust,omitempty"`
|
|
||||||
Risks []string `json:"risks,omitempty"`
|
|
||||||
OutdoorWindows OutdoorWindows `json:"outdoorWindows"`
|
|
||||||
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
|
|
||||||
Dayparts []forecast.DaypartSummary `json:"dayparts"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildThreeDay(ctx BuildContext, summaries []forecast.DailySummary) (Package, error) {
|
|
||||||
if ctx.Resolved.Definition.ID != report.ThreeDay {
|
|
||||||
return Package{}, fmt.Errorf("3-day briefing requires a 3-day report definition")
|
|
||||||
}
|
|
||||||
if len(summaries) == 0 {
|
|
||||||
return Package{}, fmt.Errorf("3-day forecast summaries are required")
|
|
||||||
}
|
|
||||||
pkg := buildPackage(ctx)
|
|
||||||
pkg.ThreeDay = &ThreeDay{
|
|
||||||
Discussion: buildDiscussion(summaries[0].Discussion),
|
|
||||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
|
||||||
}
|
|
||||||
for _, summary := range summaries {
|
|
||||||
day := buildOutlookDay(summary)
|
|
||||||
pkg.ThreeDay.Days = append(pkg.ThreeDay.Days, day)
|
|
||||||
}
|
|
||||||
pkg.ThreeDay.RelevantAlerts = collectOutlookAlerts(pkg.ThreeDay.Days)
|
|
||||||
setRelevantAlertCount(&pkg.Metadata, len(pkg.ThreeDay.RelevantAlerts))
|
|
||||||
return pkg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectOutlookAlerts(days []OutlookDay) []forecast.AlertOverlap {
|
|
||||||
alerts := map[string]forecast.AlertOverlap{}
|
|
||||||
for _, day := range days {
|
|
||||||
for _, alert := range day.RelevantAlerts {
|
|
||||||
key := alert.Event
|
|
||||||
if key == "" {
|
|
||||||
key = alert.Headline
|
|
||||||
}
|
|
||||||
if key != "" {
|
|
||||||
alerts[key] = alert
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
keys := make([]string, 0, len(alerts))
|
|
||||||
for key := range alerts {
|
|
||||||
keys = append(keys, key)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
out := make([]forecast.AlertOverlap, 0, len(keys))
|
|
||||||
for _, key := range keys {
|
|
||||||
out = append(out, alerts[key])
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildOutlookDay(summary forecast.DailySummary) OutlookDay {
|
|
||||||
day := OutlookDay{
|
|
||||||
Date: summary.Date,
|
|
||||||
Period: summary.Period,
|
|
||||||
OutdoorWindows: buildOutdoorWindows(summary.Dayparts),
|
|
||||||
RelevantAlerts: summary.AlertOverlaps,
|
|
||||||
Dayparts: summary.Dayparts,
|
|
||||||
}
|
|
||||||
conditions := map[string]struct{}{}
|
|
||||||
risks := map[string]struct{}{}
|
|
||||||
for _, daypart := range summary.Dayparts {
|
|
||||||
addRange(&day.Temperature, daypart.Temperature)
|
|
||||||
maxTimedValue(&day.MaxPrecipitationProbability, daypart.MaxPrecipitationProbability)
|
|
||||||
maxTimedValue(&day.PeakWindGust, daypart.PeakWindGust)
|
|
||||||
if daypart.DominantCondition != "" {
|
|
||||||
conditions[daypart.DominantCondition] = struct{}{}
|
|
||||||
}
|
|
||||||
for _, risk := range hazardsForIndicators(daypart.Indicators) {
|
|
||||||
risks[risk] = struct{}{}
|
|
||||||
}
|
|
||||||
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 50 {
|
|
||||||
risks["precipitation"] = struct{}{}
|
|
||||||
}
|
|
||||||
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
|
||||||
risks["wind"] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, alert := range summary.AlertOverlaps {
|
|
||||||
if alert.Event != "" {
|
|
||||||
risks[alert.Event] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
day.Risks = sortedSet(risks)
|
|
||||||
day.OverallCharacter = outlookCharacter(sortedSet(conditions), day.Risks)
|
|
||||||
return day
|
|
||||||
}
|
|
||||||
|
|
||||||
func outlookCharacter(conditions []string, risks []string) string {
|
|
||||||
if len(conditions) == 0 && len(risks) == 0 {
|
|
||||||
return "Quiet weather is expected."
|
|
||||||
}
|
|
||||||
parts := []string{}
|
|
||||||
if len(conditions) > 0 {
|
|
||||||
parts = append(parts, strings.Join(conditions, "; "))
|
|
||||||
}
|
|
||||||
if len(risks) > 0 {
|
|
||||||
parts = append(parts, "risks: "+strings.Join(risks, "; "))
|
|
||||||
}
|
|
||||||
return strings.Join(parts, ". ") + "."
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
package briefing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestThreeDayBriefingBuildsOutlookDays(t *testing.T) {
|
|
||||||
location := mustLocation(t)
|
|
||||||
resolved, err := report.Resolve(report.ThreeDay, report.ResolveRequest{
|
|
||||||
Now: mustParse("2026-05-29T06:00:00-05:00"),
|
|
||||||
Location: location,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve 3-day: %v", err)
|
|
||||||
}
|
|
||||||
precip := 70.0
|
|
||||||
gust := 35.0
|
|
||||||
summaries := []forecast.DailySummary{
|
|
||||||
{
|
|
||||||
Date: "2026-05-29",
|
|
||||||
Period: timeutil.Period{
|
|
||||||
Start: mustParse("2026-05-29T06:00:00-05:00"),
|
|
||||||
End: mustParse("2026-05-30T00:00:00-05:00"),
|
|
||||||
},
|
|
||||||
Dayparts: []forecast.DaypartSummary{
|
|
||||||
{
|
|
||||||
Name: "morning",
|
|
||||||
DominantCondition: "Showers and thunderstorms",
|
|
||||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
|
||||||
Value: precip,
|
|
||||||
Time: mustParse("2026-05-29T09:00:00-05:00"),
|
|
||||||
},
|
|
||||||
PeakWindGust: &forecast.TimedValue{
|
|
||||||
Value: gust,
|
|
||||||
Time: mustParse("2026-05-29T10:00:00-05:00"),
|
|
||||||
},
|
|
||||||
Indicators: forecast.Indicators{Wind: true},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
|
|
||||||
Discussion: &forecast.Discussion{
|
|
||||||
Product: "discussion",
|
|
||||||
KeyMessages: []string{"Unsettled stretch."},
|
|
||||||
ShortTerm: &forecast.DiscussionSection{Text: "Short-term rain chances remain focused today."},
|
|
||||||
LongTerm: &forecast.DiscussionSection{Text: "Long-term warmth builds into the weekend."},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Date: "2026-05-30",
|
|
||||||
Period: timeutil.Period{
|
|
||||||
Start: mustParse("2026-05-30T00:00:00-05:00"),
|
|
||||||
End: mustParse("2026-05-31T00:00:00-05:00"),
|
|
||||||
},
|
|
||||||
Dayparts: []forecast.DaypartSummary{{Name: "afternoon", DominantCondition: "Clear"}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
pkg, err := BuildThreeDay(BuildContext{
|
|
||||||
Resolved: resolved,
|
|
||||||
Units: "us",
|
|
||||||
Timezone: "America/Chicago",
|
|
||||||
}, summaries)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildThreeDay() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if pkg.Metadata.ReportID != report.ThreeDay {
|
|
||||||
t.Fatalf("ReportID = %q, want three_day", pkg.Metadata.ReportID)
|
|
||||||
}
|
|
||||||
if pkg.ThreeDay == nil {
|
|
||||||
t.Fatal("ThreeDay = nil")
|
|
||||||
}
|
|
||||||
if len(pkg.ThreeDay.Days) != 2 {
|
|
||||||
t.Fatalf("Days length = %d, want 2", len(pkg.ThreeDay.Days))
|
|
||||||
}
|
|
||||||
first := pkg.ThreeDay.Days[0]
|
|
||||||
if !strings.Contains(first.OverallCharacter, "Showers") || !strings.Contains(strings.Join(first.Risks, ","), "wind") {
|
|
||||||
t.Fatalf("first day = %#v, want conditions and risks", first)
|
|
||||||
}
|
|
||||||
if len(pkg.ThreeDay.RelevantAlerts) != 1 {
|
|
||||||
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.ThreeDay.RelevantAlerts))
|
|
||||||
}
|
|
||||||
if pkg.ThreeDay.Discussion.ShortTerm != "Short-term rain chances remain focused today." {
|
|
||||||
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.ThreeDay.Discussion.ShortTerm)
|
|
||||||
}
|
|
||||||
if pkg.ThreeDay.Discussion.LongTerm != "Long-term warmth builds into the weekend." {
|
|
||||||
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.ThreeDay.Discussion.LongTerm)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
24
internal/briefing/tomorrow_planning_module.go
Normal file
24
internal/briefing/tomorrow_planning_module.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
|
||||||
|
type TomorrowPlanningModule struct {
|
||||||
|
MorningReadiness []string `json:"morning_readiness,omitempty"`
|
||||||
|
CommuteSchoolWorkdayConcerns []string `json:"commute_school_workday_concerns,omitempty"`
|
||||||
|
OvernightChangeWatch []string `json:"overnight_change_watch,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTomorrowPlanningModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
summary := ctx.Derived.FirstDailySummary()
|
||||||
|
if summary == nil {
|
||||||
|
return &module.Output{ID: module.TomorrowPlanning, StanzaName: "tomorrow_planning", Value: TomorrowPlanningModule{}}, nil
|
||||||
|
}
|
||||||
|
planning := buildTomorrowPlanning(summary)
|
||||||
|
value := TomorrowPlanningModule{}
|
||||||
|
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.TomorrowPlanning, StanzaName: "tomorrow_planning", Value: value}, nil
|
||||||
|
}
|
||||||
44
internal/briefing/weather_story_module.go
Normal file
44
internal/briefing/weather_story_module.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WeatherStoryModule struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
OfficeID string `json:"office_id,omitempty"`
|
||||||
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
|
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
AltText string `json:"alt_text,omitempty"`
|
||||||
|
Priority bool `json:"priority"`
|
||||||
|
Order int `json:"order"`
|
||||||
|
DownloadURL string `json:"download_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
story := ctx.Collected.WeatherStory
|
||||||
|
if story == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
period := timeutil.Period{Start: story.StartTime, End: story.EndTime}
|
||||||
|
value := WeatherStoryModule{
|
||||||
|
Available: true,
|
||||||
|
OfficeID: story.OfficeID,
|
||||||
|
PeriodBegins: friendlyPeriodBeginsLabel(period, ctx.Timezone),
|
||||||
|
PeriodEnds: friendlyPeriodEndsLabel(period, ctx.Timezone),
|
||||||
|
UpdatedAt: copyTime(story.UpdatedAt),
|
||||||
|
Title: story.Title,
|
||||||
|
Description: story.Description,
|
||||||
|
AltText: story.AltText,
|
||||||
|
Priority: story.Priority,
|
||||||
|
Order: story.Order,
|
||||||
|
DownloadURL: story.DownloadURL,
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.WeatherStory, StanzaName: "weather_story", Value: value}, nil
|
||||||
|
}
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
package briefing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Weekend struct {
|
|
||||||
Days []OutlookDay `json:"days"`
|
|
||||||
Planning WeekendPlanning `json:"planning"`
|
|
||||||
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
|
|
||||||
Discussion DiscussionContext `json:"discussion,omitempty"`
|
|
||||||
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type WeekendPlanning struct {
|
|
||||||
BestOutdoorWindows []OutdoorWindow `json:"bestOutdoorWindows,omitempty"`
|
|
||||||
WorstWeatherWindows []OutdoorWindow `json:"worstWeatherWindows,omitempty"`
|
|
||||||
RainStormTiming []string `json:"rainStormTiming,omitempty"`
|
|
||||||
ComfortConcerns []string `json:"comfortConcerns,omitempty"`
|
|
||||||
UncertaintyInputs []string `json:"uncertaintyInputs,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildWeekend(ctx BuildContext, summaries []forecast.DailySummary) (Package, error) {
|
|
||||||
if ctx.Resolved.Definition.ID != report.Weekend {
|
|
||||||
return Package{}, fmt.Errorf("weekend briefing requires a weekend report definition")
|
|
||||||
}
|
|
||||||
if len(summaries) == 0 {
|
|
||||||
return Package{}, fmt.Errorf("weekend forecast summaries are required")
|
|
||||||
}
|
|
||||||
pkg := buildPackage(ctx)
|
|
||||||
pkg.Weekend = &Weekend{
|
|
||||||
Discussion: buildDiscussion(summaries[0].Discussion),
|
|
||||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
|
||||||
}
|
|
||||||
for _, summary := range summaries {
|
|
||||||
pkg.Weekend.Days = append(pkg.Weekend.Days, buildOutlookDay(summary))
|
|
||||||
}
|
|
||||||
pkg.Weekend.RelevantAlerts = collectOutlookAlerts(pkg.Weekend.Days)
|
|
||||||
setRelevantAlertCount(&pkg.Metadata, len(pkg.Weekend.RelevantAlerts))
|
|
||||||
pkg.Weekend.Planning = buildWeekendPlanning(pkg.Weekend.Days, pkg.Weekend.Discussion, ctx.Bundle)
|
|
||||||
return pkg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildWeekendPlanning(days []OutlookDay, discussion DiscussionContext, bundle *forecast.Bundle) WeekendPlanning {
|
|
||||||
planning := WeekendPlanning{}
|
|
||||||
for _, day := range days {
|
|
||||||
if day.OutdoorWindows.Best != nil {
|
|
||||||
window := *day.OutdoorWindows.Best
|
|
||||||
window.Daypart = day.Date + " " + window.Daypart
|
|
||||||
planning.BestOutdoorWindows = append(planning.BestOutdoorWindows, window)
|
|
||||||
}
|
|
||||||
if day.OutdoorWindows.Worst != nil {
|
|
||||||
window := *day.OutdoorWindows.Worst
|
|
||||||
window.Daypart = day.Date + " " + window.Daypart
|
|
||||||
planning.WorstWeatherWindows = append(planning.WorstWeatherWindows, window)
|
|
||||||
}
|
|
||||||
for _, daypart := range day.Dayparts {
|
|
||||||
planning.RainStormTiming = appendUnique(planning.RainStormTiming, weekendRainStormNotes(day.Date, daypart)...)
|
|
||||||
planning.ComfortConcerns = appendUnique(planning.ComfortConcerns, weekendComfortNotes(day.Date, daypart)...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, discussion.KeyMessages...)
|
|
||||||
if discussion.ShortTerm != "" {
|
|
||||||
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, "Short-term discussion available for confidence context.")
|
|
||||||
}
|
|
||||||
if discussion.LongTerm != "" {
|
|
||||||
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, "Long-term discussion available for uncertainty context.")
|
|
||||||
}
|
|
||||||
if bundle != nil {
|
|
||||||
for _, warning := range bundle.Warnings {
|
|
||||||
if warning.Code != "" {
|
|
||||||
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, "Source warning: "+warning.Code+".")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(planning.RainStormTiming) == 0 {
|
|
||||||
planning.RainStormTiming = append(planning.RainStormTiming, "No focused rain or storm timing stands out in the available weekend forecast.")
|
|
||||||
}
|
|
||||||
if len(planning.ComfortConcerns) == 0 {
|
|
||||||
planning.ComfortConcerns = append(planning.ComfortConcerns, "No major heat, cold, or wind comfort concern stands out in the available weekend forecast.")
|
|
||||||
}
|
|
||||||
if len(planning.UncertaintyInputs) == 0 {
|
|
||||||
planning.UncertaintyInputs = append(planning.UncertaintyInputs, "No explicit confidence or uncertainty signal was available from the selected source context.")
|
|
||||||
}
|
|
||||||
return planning
|
|
||||||
}
|
|
||||||
|
|
||||||
func weekendRainStormNotes(date string, daypart forecast.DaypartSummary) []string {
|
|
||||||
notes := []string{}
|
|
||||||
label := weekendWindowLabel(date, daypart.Name)
|
|
||||||
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 {
|
|
||||||
notes = append(notes, fmt.Sprintf("%s precipitation chance peaks near %.0f%%.", label, daypart.MaxPrecipitationProbability.Value))
|
|
||||||
}
|
|
||||||
return notes
|
|
||||||
}
|
|
||||||
|
|
||||||
func weekendComfortNotes(date string, daypart forecast.DaypartSummary) []string {
|
|
||||||
notes := []string{}
|
|
||||||
label := weekendWindowLabel(date, daypart.Name)
|
|
||||||
if daypart.Indicators.Heat {
|
|
||||||
notes = append(notes, label+" heat may affect outdoor comfort.")
|
|
||||||
}
|
|
||||||
if daypart.Indicators.Cold {
|
|
||||||
notes = append(notes, label+" cold may affect outdoor comfort.")
|
|
||||||
}
|
|
||||||
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 25 {
|
|
||||||
notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", label, daypart.PeakWindGust.Value))
|
|
||||||
}
|
|
||||||
return notes
|
|
||||||
}
|
|
||||||
|
|
||||||
func weekendWindowLabel(date string, daypart string) string {
|
|
||||||
if daypart == "" {
|
|
||||||
return date
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(date + " " + daypart)
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
package briefing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) {
|
|
||||||
location := mustLocation(t)
|
|
||||||
resolved, err := report.Resolve(report.Weekend, report.ResolveRequest{
|
|
||||||
Now: mustParse("2026-05-29T08:00:00-05:00"),
|
|
||||||
Location: location,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve weekend: %v", err)
|
|
||||||
}
|
|
||||||
precip := 70.0
|
|
||||||
gust := 32.0
|
|
||||||
summaries := []forecast.DailySummary{
|
|
||||||
{
|
|
||||||
Date: "2026-05-30",
|
|
||||||
Period: timeutil.Period{
|
|
||||||
Start: mustParse("2026-05-30T00:00:00-05:00"),
|
|
||||||
End: mustParse("2026-05-31T00:00:00-05:00"),
|
|
||||||
},
|
|
||||||
Dayparts: []forecast.DaypartSummary{
|
|
||||||
{
|
|
||||||
Name: "afternoon",
|
|
||||||
DominantCondition: "Showers and thunderstorms",
|
|
||||||
Period: timeutil.Period{
|
|
||||||
Start: mustParse("2026-05-30T12:00:00-05:00"),
|
|
||||||
End: mustParse("2026-05-30T18:00:00-05:00"),
|
|
||||||
},
|
|
||||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
|
||||||
Value: precip,
|
|
||||||
Time: mustParse("2026-05-30T15:00:00-05:00"),
|
|
||||||
},
|
|
||||||
PeakWindGust: &forecast.TimedValue{
|
|
||||||
Value: gust,
|
|
||||||
Time: mustParse("2026-05-30T16:00:00-05:00"),
|
|
||||||
},
|
|
||||||
Indicators: forecast.Indicators{Wind: true},
|
|
||||||
HourlyPeriods: []forecast.ForecastPeriod{
|
|
||||||
{
|
|
||||||
StartTime: mustParse("2026-05-30T15:00:00-05:00"),
|
|
||||||
EndTime: mustParse("2026-05-30T16:00:00-05:00"),
|
|
||||||
TextDescription: "Showers and thunderstorms",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
|
|
||||||
Discussion: &forecast.Discussion{
|
|
||||||
Product: "discussion",
|
|
||||||
KeyMessages: []string{"Timing may shift."},
|
|
||||||
ShortTerm: &forecast.DiscussionSection{Text: "Short-term showers exit before the weekend."},
|
|
||||||
LongTerm: &forecast.DiscussionSection{Text: "Long-term weekend rain timing remains uncertain."},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
pkg, err := BuildWeekend(BuildContext{
|
|
||||||
Resolved: resolved,
|
|
||||||
Units: "us",
|
|
||||||
Timezone: "America/Chicago",
|
|
||||||
}, summaries)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildWeekend() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if pkg.Metadata.ReportID != report.Weekend {
|
|
||||||
t.Fatalf("ReportID = %q, want weekend", pkg.Metadata.ReportID)
|
|
||||||
}
|
|
||||||
if pkg.Weekend == nil {
|
|
||||||
t.Fatal("Weekend = nil")
|
|
||||||
}
|
|
||||||
if len(pkg.Weekend.Days) != 1 {
|
|
||||||
t.Fatalf("Days length = %d, want 1", len(pkg.Weekend.Days))
|
|
||||||
}
|
|
||||||
if len(pkg.Weekend.Planning.WorstWeatherWindows) == 0 {
|
|
||||||
t.Fatalf("WorstWeatherWindows = %#v, want weather window", pkg.Weekend.Planning.WorstWeatherWindows)
|
|
||||||
}
|
|
||||||
if !strings.Contains(strings.Join(pkg.Weekend.Planning.RainStormTiming, " "), "precipitation") {
|
|
||||||
t.Fatalf("RainStormTiming = %#v, want precipitation timing", pkg.Weekend.Planning.RainStormTiming)
|
|
||||||
}
|
|
||||||
if len(pkg.Weekend.Planning.UncertaintyInputs) == 0 {
|
|
||||||
t.Fatal("UncertaintyInputs length = 0, want discussion context")
|
|
||||||
}
|
|
||||||
if pkg.Weekend.Discussion.ShortTerm != "Short-term showers exit before the weekend." {
|
|
||||||
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Weekend.Discussion.ShortTerm)
|
|
||||||
}
|
|
||||||
if pkg.Weekend.Discussion.LongTerm != "Long-term weekend rain timing remains uncertain." {
|
|
||||||
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Weekend.Discussion.LongTerm)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
// Package changes compares structured briefing snapshots.
|
// Package changes compares structured module snapshots.
|
||||||
package changes
|
package changes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Thresholds struct {
|
type Thresholds struct {
|
||||||
@@ -25,83 +26,170 @@ type Change struct {
|
|||||||
Current string `json:"current,omitempty"`
|
Current string `json:"current,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func CompareDaily(previous briefing.Package, current briefing.Package, thresholds Thresholds) ([]Change, error) {
|
func CompareDaily(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) {
|
||||||
if previous.Daily == nil {
|
previousSummary, err := requiredStanza[dailySummaryStanza](previous, "derived_daily_summary")
|
||||||
return nil, fmt.Errorf("previous daily briefing is required")
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("previous daily summary: %w", err)
|
||||||
}
|
}
|
||||||
if current.Daily == nil {
|
currentSummary, err := requiredStanza[dailySummaryStanza](current, "derived_daily_summary")
|
||||||
return nil, fmt.Errorf("current daily briefing is required")
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("current daily summary: %w", err)
|
||||||
}
|
}
|
||||||
|
previousDayparts, err := requiredStanza[map[string]daypartSummaryStanza](previous, "derived_daypart_summaries")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("previous daypart summaries: %w", err)
|
||||||
|
}
|
||||||
|
currentDayparts, err := requiredStanza[map[string]daypartSummaryStanza](current, "derived_daypart_summaries")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("current daypart summaries: %w", err)
|
||||||
|
}
|
||||||
|
previousAlerts, _, err := module.StanzaValue[alertDigestStanza](previous, "alert_digest")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
currentAlerts, _, err := module.StanzaValue[alertDigestStanza](current, "alert_digest")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
previousTiming, previousHasTiming, err := module.StanzaValue[precipTimingStanza](previous, "precip_timing")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
currentTiming, currentHasTiming, err := module.StanzaValue[precipTimingStanza](current, "precip_timing")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
var changes []Change
|
var changes []Change
|
||||||
changes = append(changes, compareTemperature(previous.Daily.BottomLine.Temperature, current.Daily.BottomLine.Temperature, thresholds.TemperatureDegrees)...)
|
changes = append(changes, compareTemperatureValues("Low", previousSummary.LowTempF, currentSummary.LowTempF, thresholds.TemperatureDegrees)...)
|
||||||
changes = append(changes, comparePrecipitation(previous.Daily.BottomLine.MaxPrecipProbability, current.Daily.BottomLine.MaxPrecipProbability, thresholds)...)
|
changes = append(changes, compareTemperatureValues("High", previousSummary.HighTempF, currentSummary.HighTempF, thresholds.TemperatureDegrees)...)
|
||||||
changes = append(changes, compareWind(previous.Daily.BottomLine.PeakWindGust, current.Daily.BottomLine.PeakWindGust, float64(thresholds.WindGustMilesPerHour))...)
|
changes = append(changes, comparePrecipitationValues(previousSummary.DailyPrecipitationProbability, currentSummary.DailyPrecipitationProbability, thresholds.PrecipProbabilityPoints, "")...)
|
||||||
changes = append(changes, compareAlerts(previous.Daily.RelevantAlerts, current.Daily.RelevantAlerts)...)
|
if previousHasTiming && currentHasTiming {
|
||||||
changes = append(changes, compareIndicators(aggregateIndicators(previous.Daily.Dayparts), aggregateIndicators(current.Daily.Dayparts))...)
|
changes = append(changes, comparePrecipTiming(previousTiming.MaxPopTime, currentTiming.MaxPopTime, thresholds.PrecipTimingShiftMinutes, "")...)
|
||||||
|
}
|
||||||
|
changes = append(changes, compareWindValues(previousSummary.MaxWindGustMph, currentSummary.MaxWindGustMph, thresholds.WindGustMilesPerHour, "")...)
|
||||||
|
changes = append(changes, compareAlerts(previousAlerts.Relevant, currentAlerts.Relevant)...)
|
||||||
|
changes = append(changes, compareIndicators(aggregateIndicators(previousDayparts), aggregateIndicators(currentDayparts), "")...)
|
||||||
|
sortChanges(changes)
|
||||||
return changes, nil
|
return changes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func compareTemperature(previous forecast.Range, current forecast.Range, threshold float64) []Change {
|
type dailySummaryStanza struct {
|
||||||
var changes []Change
|
Date string `json:"date,omitempty"`
|
||||||
if previous.Min != nil && current.Min != nil && differenceAtLeast(*previous.Min, *current.Min, threshold) {
|
HighTempF *int `json:"high_temp_f,omitempty"`
|
||||||
changes = append(changes, Change{
|
LowTempF *int `json:"low_temp_f,omitempty"`
|
||||||
Type: "temperature_shift",
|
DailyPrecipitationProbability *int `json:"daily_precipitation_probability,omitempty"`
|
||||||
Message: fmt.Sprintf("Low temperature changed from %.0f to %.0f.", *previous.Min, *current.Min),
|
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||||
Previous: fmt.Sprintf("%.0f", *previous.Min),
|
|
||||||
Current: fmt.Sprintf("%.0f", *current.Min),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if previous.Max != nil && current.Max != nil && differenceAtLeast(*previous.Max, *current.Max, threshold) {
|
|
||||||
changes = append(changes, Change{
|
|
||||||
Type: "temperature_shift",
|
|
||||||
Message: fmt.Sprintf("High temperature changed from %.0f to %.0f.", *previous.Max, *current.Max),
|
|
||||||
Previous: fmt.Sprintf("%.0f", *previous.Max),
|
|
||||||
Current: fmt.Sprintf("%.0f", *current.Max),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return changes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func comparePrecipitation(previous *forecast.TimedValue, current *forecast.TimedValue, thresholds Thresholds) []Change {
|
type daypartSummaryStanza struct {
|
||||||
|
Date string `json:"date,omitempty"`
|
||||||
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
|
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||||
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||||
|
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||||
|
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||||
|
Snow bool `json:"snow,omitempty"`
|
||||||
|
Ice bool `json:"ice,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type precipTimingStanza struct {
|
||||||
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||||
|
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type alertDigestStanza struct {
|
||||||
|
Relevant []alertSummaryStanza `json:"relevant,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type alertSummaryStanza struct {
|
||||||
|
Event string `json:"event,omitempty"`
|
||||||
|
Headline string `json:"headline,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type indicators struct {
|
||||||
|
Snow bool
|
||||||
|
Ice bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func requiredStanza[T any](snapshot module.Snapshot, name string) (T, error) {
|
||||||
|
value, ok, err := module.StanzaValue[T](snapshot, name)
|
||||||
|
if err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return value, fmt.Errorf("stanza %q is required", name)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareTemperatureValues(label string, previous *int, current *int, threshold float64) []Change {
|
||||||
if previous == nil || current == nil {
|
if previous == nil || current == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var changes []Change
|
if !differenceAtLeast(float64(*previous), float64(*current), threshold) {
|
||||||
previousCategory := precipitationCategory(previous.Value)
|
|
||||||
currentCategory := precipitationCategory(current.Value)
|
|
||||||
if previousCategory != currentCategory || differenceAtLeast(previous.Value, current.Value, float64(thresholds.PrecipProbabilityPoints)) {
|
|
||||||
changes = append(changes, Change{
|
|
||||||
Type: "precip_probability_change",
|
|
||||||
Message: fmt.Sprintf("Peak precipitation chance changed from %.0f%% (%s) to %.0f%% (%s).", previous.Value, previousCategory, current.Value, currentCategory),
|
|
||||||
Previous: fmt.Sprintf("%.0f%% %s", previous.Value, previousCategory),
|
|
||||||
Current: fmt.Sprintf("%.0f%% %s", current.Value, currentCategory),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
shiftMinutes := int(math.Abs(current.Time.Sub(previous.Time).Minutes()))
|
|
||||||
if thresholds.PrecipTimingShiftMinutes > 0 && shiftMinutes >= thresholds.PrecipTimingShiftMinutes {
|
|
||||||
changes = append(changes, Change{
|
|
||||||
Type: "precip_timing_shift",
|
|
||||||
Message: fmt.Sprintf("Peak precipitation timing shifted from %s to %s.", clock(previous.Time), clock(current.Time)),
|
|
||||||
Previous: clock(previous.Time),
|
|
||||||
Current: clock(current.Time),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return changes
|
|
||||||
}
|
|
||||||
|
|
||||||
func compareWind(previous *forecast.TimedValue, current *forecast.TimedValue, threshold float64) []Change {
|
|
||||||
if previous == nil || current == nil || !differenceAtLeast(previous.Value, current.Value, threshold) {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return []Change{{
|
return []Change{{
|
||||||
Type: "wind_gust_change",
|
Type: "temperature_shift",
|
||||||
Message: fmt.Sprintf("Peak wind gust changed from %.0f mph to %.0f mph.", previous.Value, current.Value),
|
Message: fmt.Sprintf("%s temperature changed from %d to %d.", label, *previous, *current),
|
||||||
Previous: fmt.Sprintf("%.0f mph", previous.Value),
|
Previous: fmt.Sprintf("%d", *previous),
|
||||||
Current: fmt.Sprintf("%.0f mph", current.Value),
|
Current: fmt.Sprintf("%d", *current),
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func compareAlerts(previous []forecast.AlertOverlap, current []forecast.AlertOverlap) []Change {
|
func comparePrecipitationValues(previous *int, current *int, threshold int, prefix string) []Change {
|
||||||
|
if previous == nil || current == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
previousCategory := precipitationCategory(float64(*previous))
|
||||||
|
currentCategory := precipitationCategory(float64(*current))
|
||||||
|
if previousCategory == currentCategory && !differenceAtLeast(float64(*previous), float64(*current), float64(threshold)) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
changeType := prefix + "precip_probability_change"
|
||||||
|
return []Change{{
|
||||||
|
Type: changeType,
|
||||||
|
Message: fmt.Sprintf("Peak precipitation chance changed from %d%% (%s) to %d%% (%s).", *previous, previousCategory, *current, currentCategory),
|
||||||
|
Previous: fmt.Sprintf("%d%% %s", *previous, previousCategory),
|
||||||
|
Current: fmt.Sprintf("%d%% %s", *current, currentCategory),
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func comparePrecipTiming(previous string, current string, thresholdMinutes int, prefix string) []Change {
|
||||||
|
if thresholdMinutes <= 0 || previous == "" || current == "" || previous == current {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
previousTime, previousOK := parseClock(previous)
|
||||||
|
currentTime, currentOK := parseClock(current)
|
||||||
|
if !previousOK || !currentOK {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if int(math.Abs(currentTime.Sub(previousTime).Minutes())) < thresholdMinutes {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []Change{{
|
||||||
|
Type: prefix + "precip_timing_shift",
|
||||||
|
Message: fmt.Sprintf("Peak precipitation timing shifted from %s to %s.", previous, current),
|
||||||
|
Previous: previous,
|
||||||
|
Current: current,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareWindValues(previous *int, current *int, threshold int, prefix string) []Change {
|
||||||
|
if previous == nil || current == nil || !differenceAtLeast(float64(*previous), float64(*current), float64(threshold)) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []Change{{
|
||||||
|
Type: prefix + "wind_gust_change",
|
||||||
|
Message: fmt.Sprintf("Peak wind gust changed from %d mph to %d mph.", *previous, *current),
|
||||||
|
Previous: fmt.Sprintf("%d mph", *previous),
|
||||||
|
Current: fmt.Sprintf("%d mph", *current),
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareAlerts(previous []alertSummaryStanza, current []alertSummaryStanza) []Change {
|
||||||
previousSet := alertSet(previous)
|
previousSet := alertSet(previous)
|
||||||
currentSet := alertSet(current)
|
currentSet := alertSet(current)
|
||||||
var changes []Change
|
var changes []Change
|
||||||
@@ -119,7 +207,7 @@ func compareAlerts(previous []forecast.AlertOverlap, current []forecast.AlertOve
|
|||||||
return changes
|
return changes
|
||||||
}
|
}
|
||||||
|
|
||||||
func compareIndicators(previous forecast.Indicators, current forecast.Indicators) []Change {
|
func compareIndicators(previous indicators, current indicators, prefix string) []Change {
|
||||||
var changes []Change
|
var changes []Change
|
||||||
for _, item := range []struct {
|
for _, item := range []struct {
|
||||||
name string
|
name string
|
||||||
@@ -132,7 +220,7 @@ func compareIndicators(previous forecast.Indicators, current forecast.Indicators
|
|||||||
if item.previous == item.current {
|
if item.previous == item.current {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
changeType := item.name + "_risk_change"
|
changeType := prefix + item.name + "_risk_change"
|
||||||
if item.current {
|
if item.current {
|
||||||
changes = append(changes, Change{Type: changeType, Message: fmt.Sprintf("%s risk is now present.", item.name), Current: "present"})
|
changes = append(changes, Change{Type: changeType, Message: fmt.Sprintf("%s risk is now present.", item.name), Current: "present"})
|
||||||
} else {
|
} else {
|
||||||
@@ -142,16 +230,16 @@ func compareIndicators(previous forecast.Indicators, current forecast.Indicators
|
|||||||
return changes
|
return changes
|
||||||
}
|
}
|
||||||
|
|
||||||
func aggregateIndicators(dayparts []forecast.DaypartSummary) forecast.Indicators {
|
func aggregateIndicators(dayparts map[string]daypartSummaryStanza) indicators {
|
||||||
out := forecast.Indicators{}
|
out := indicators{}
|
||||||
for _, daypart := range dayparts {
|
for _, daypart := range dayparts {
|
||||||
out.Snow = out.Snow || daypart.Indicators.Snow
|
out.Snow = out.Snow || daypart.Snow
|
||||||
out.Ice = out.Ice || daypart.Indicators.Ice
|
out.Ice = out.Ice || daypart.Ice
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func alertSet(alerts []forecast.AlertOverlap) map[string]struct{} {
|
func alertSet(alerts []alertSummaryStanza) map[string]struct{} {
|
||||||
out := map[string]struct{}{}
|
out := map[string]struct{}{}
|
||||||
for _, alert := range alerts {
|
for _, alert := range alerts {
|
||||||
event := alert.Event
|
event := alert.Event
|
||||||
@@ -185,10 +273,6 @@ func differenceAtLeast(previous float64, current float64, threshold float64) boo
|
|||||||
return math.Abs(current-previous) >= threshold
|
return math.Abs(current-previous) >= threshold
|
||||||
}
|
}
|
||||||
|
|
||||||
func clock(t time.Time) string {
|
|
||||||
return t.Format("15:04")
|
|
||||||
}
|
|
||||||
|
|
||||||
func sortChanges(items []Change) {
|
func sortChanges(items []Change) {
|
||||||
sort.SliceStable(items, func(i, j int) bool {
|
sort.SliceStable(items, func(i, j int) bool {
|
||||||
if items[i].Type == items[j].Type {
|
if items[i].Type == items[j].Type {
|
||||||
@@ -197,3 +281,33 @@ func sortChanges(items []Change) {
|
|||||||
return items[i].Type < items[j].Type
|
return items[i].Type < items[j].Type
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseClock(value string) (time.Time, bool) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
for _, layout := range []string{"3 PM", "3:04 PM", "15:04"} {
|
||||||
|
if parsed, err := time.Parse(layout, value); err == nil {
|
||||||
|
return parsed, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTempRange(value string) (*int, *int) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(value, "-")
|
||||||
|
if len(parts) == 1 {
|
||||||
|
if parsed, err := strconv.Atoi(strings.TrimSpace(parts[0])); err == nil {
|
||||||
|
return &parsed, &parsed
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
minValue, minErr := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||||
|
maxValue, maxErr := strconv.Atoi(strings.TrimSpace(parts[len(parts)-1]))
|
||||||
|
if minErr != nil || maxErr != nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &minValue, &maxValue
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
package changes
|
package changes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCompareDailyNoMeaningfulChanges(t *testing.T) {
|
func TestCompareDailyNoMeaningfulChanges(t *testing.T) {
|
||||||
previous := dailyBriefing(60, 70, 30, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
previous := dailySnapshot(t, 60, 70, 30, "8 AM", nil, false)
|
||||||
current := dailyBriefing(61, 71, 35, at("2026-05-29T08:30:00Z"), nil, forecast.Indicators{})
|
current := dailySnapshot(t, 61, 71, 35, "8:30 AM", nil, false)
|
||||||
|
|
||||||
changes, err := CompareDaily(previous, current, testThresholds())
|
changes, err := CompareDaily(previous, current, testThresholds())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -22,8 +21,8 @@ func TestCompareDailyNoMeaningfulChanges(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCompareDailyTemperatureThreshold(t *testing.T) {
|
func TestCompareDailyTemperatureThreshold(t *testing.T) {
|
||||||
previous := dailyBriefing(50, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
previous := dailySnapshot(t, 50, 70, 10, "8 AM", nil, false)
|
||||||
current := dailyBriefing(58, 79, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
current := dailySnapshot(t, 58, 79, 10, "8 AM", nil, false)
|
||||||
|
|
||||||
changes, err := CompareDaily(previous, current, testThresholds())
|
changes, err := CompareDaily(previous, current, testThresholds())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -35,8 +34,8 @@ func TestCompareDailyTemperatureThreshold(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCompareDailyPrecipTimingShift(t *testing.T) {
|
func TestCompareDailyPrecipTimingShift(t *testing.T) {
|
||||||
previous := dailyBriefing(60, 70, 60, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
previous := dailySnapshot(t, 60, 70, 60, "8 AM", nil, false)
|
||||||
current := dailyBriefing(60, 70, 60, at("2026-05-29T11:00:00Z"), nil, forecast.Indicators{})
|
current := dailySnapshot(t, 60, 70, 60, "11 AM", nil, false)
|
||||||
|
|
||||||
changes, err := CompareDaily(previous, current, testThresholds())
|
changes, err := CompareDaily(previous, current, testThresholds())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -48,8 +47,8 @@ func TestCompareDailyPrecipTimingShift(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCompareDailyAlertAddedAndRemoved(t *testing.T) {
|
func TestCompareDailyAlertAddedAndRemoved(t *testing.T) {
|
||||||
previous := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), []string{"Wind Advisory"}, forecast.Indicators{})
|
previous := dailySnapshot(t, 60, 70, 10, "8 AM", []string{"Wind Advisory"}, false)
|
||||||
current := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), []string{"Flood Watch"}, forecast.Indicators{})
|
current := dailySnapshot(t, 60, 70, 10, "8 AM", []string{"Flood Watch"}, false)
|
||||||
|
|
||||||
changes, err := CompareDaily(previous, current, testThresholds())
|
changes, err := CompareDaily(previous, current, testThresholds())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -61,8 +60,8 @@ func TestCompareDailyAlertAddedAndRemoved(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCompareDailyIndicatorChange(t *testing.T) {
|
func TestCompareDailyIndicatorChange(t *testing.T) {
|
||||||
previous := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
previous := dailySnapshot(t, 60, 70, 10, "8 AM", nil, false)
|
||||||
current := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{Snow: true})
|
current := dailySnapshot(t, 60, 70, 10, "8 AM", nil, true)
|
||||||
|
|
||||||
changes, err := CompareDaily(previous, current, testThresholds())
|
changes, err := CompareDaily(previous, current, testThresholds())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -73,26 +72,50 @@ func TestCompareDailyIndicatorChange(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func dailyBriefing(low float64, high float64, precip float64, precipTime time.Time, alerts []string, indicators forecast.Indicators) briefing.Package {
|
func TestCompareDailyRequiresComparisonStanzas(t *testing.T) {
|
||||||
alertOverlaps := make([]forecast.AlertOverlap, 0, len(alerts))
|
_, err := CompareDaily(snapshot(t), dailySnapshot(t, 60, 70, 10, "8 AM", nil, false), testThresholds())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("CompareDaily() error = nil, want missing stanza error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "derived_daily_summary") {
|
||||||
|
t.Fatalf("error = %q, want derived_daily_summary context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dailySnapshot(t *testing.T, low int, high int, precip int, precipTime string, alerts []string, snow bool) module.Snapshot {
|
||||||
|
t.Helper()
|
||||||
|
relevant := make([]alertSummaryStanza, 0, len(alerts))
|
||||||
for _, alert := range alerts {
|
for _, alert := range alerts {
|
||||||
alertOverlaps = append(alertOverlaps, forecast.AlertOverlap{Event: alert})
|
relevant = append(relevant, alertSummaryStanza{Event: alert})
|
||||||
}
|
}
|
||||||
return briefing.Package{
|
return snapshot(t,
|
||||||
Daily: &briefing.Daily{
|
module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: dailySummaryStanza{
|
||||||
BottomLine: briefing.BottomLine{
|
Date: "2026-05-29",
|
||||||
Temperature: forecast.Range{Min: &low, Max: &high},
|
HighTempF: &high,
|
||||||
MaxPrecipProbability: &forecast.TimedValue{
|
LowTempF: &low,
|
||||||
Value: precip,
|
DailyPrecipitationProbability: &precip,
|
||||||
Time: precipTime,
|
}},
|
||||||
},
|
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
|
||||||
|
"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,
|
||||||
},
|
},
|
||||||
RelevantAlerts: alertOverlaps,
|
}},
|
||||||
Dayparts: []forecast.DaypartSummary{
|
module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: precipTimingStanza{MaxPopPercent: &precip, MaxPopTime: precipTime}},
|
||||||
{Name: "morning", Indicators: indicators},
|
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: alertDigestStanza{Relevant: relevant}},
|
||||||
},
|
)
|
||||||
},
|
}
|
||||||
|
|
||||||
|
func snapshot(t *testing.T, outputs ...module.Output) module.Snapshot {
|
||||||
|
t.Helper()
|
||||||
|
snapshot, err := module.NewSnapshot(outputs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSnapshot() error = %v", err)
|
||||||
}
|
}
|
||||||
|
return snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
func testThresholds() Thresholds {
|
func testThresholds() Thresholds {
|
||||||
@@ -113,11 +136,3 @@ func countType(changes []Change, changeType string) int {
|
|||||||
}
|
}
|
||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
func at(value string) time.Time {
|
|
||||||
parsed, err := time.Parse(time.RFC3339, value)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return parsed
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,77 +2,143 @@ package changes
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"sort"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
)
|
)
|
||||||
|
|
||||||
func CompareThreeDay(previous briefing.Package, current briefing.Package, thresholds Thresholds) ([]Change, error) {
|
func CompareThreeDay(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) {
|
||||||
if previous.ThreeDay == nil {
|
previousDayparts, err := requiredStanza[map[string]daypartSummaryStanza](previous, "derived_daypart_summaries")
|
||||||
return nil, fmt.Errorf("previous 3-day briefing is required")
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("previous daypart summaries: %w", err)
|
||||||
}
|
}
|
||||||
if current.ThreeDay == nil {
|
currentDayparts, err := requiredStanza[map[string]daypartSummaryStanza](current, "derived_daypart_summaries")
|
||||||
return nil, fmt.Errorf("current 3-day briefing is required")
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("current daypart summaries: %w", err)
|
||||||
}
|
}
|
||||||
previousDays := outlookDaysByDate(previous.ThreeDay.Days)
|
previousDays := outlookDaysFromDayparts(previousDayparts)
|
||||||
currentDays := outlookDaysByDate(current.ThreeDay.Days)
|
currentDays := outlookDaysFromDayparts(currentDayparts)
|
||||||
|
return compareOutlookDays(previousDays, currentDays, thresholds, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
type outlookDay struct {
|
||||||
|
Date string
|
||||||
|
LowTempF *int
|
||||||
|
HighTempF *int
|
||||||
|
MaxPopPercent *int
|
||||||
|
MaxPopTime string
|
||||||
|
MaxWindGustMph *int
|
||||||
|
Indicators indicators
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareOutlookDays(previousDays map[string]outlookDay, currentDays map[string]outlookDay, thresholds Thresholds, prefix string) ([]Change, error) {
|
||||||
var changes []Change
|
var changes []Change
|
||||||
for date, currentDay := range currentDays {
|
for date, currentDay := range currentDays {
|
||||||
previousDay, ok := previousDays[date]
|
previousDay, ok := previousDays[date]
|
||||||
if !ok {
|
if !ok {
|
||||||
changes = append(changes, Change{Type: "outlook_day_added", Message: fmt.Sprintf("Outlook day added: %s.", date), Current: date})
|
changes = append(changes, Change{Type: prefix + "outlook_day_added", Message: fmt.Sprintf("Outlook day added: %s.", date), Current: date})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
changes = append(changes, compareOutlookDay(date, previousDay, currentDay, thresholds)...)
|
changes = append(changes, compareOutlookDay(date, previousDay, currentDay, thresholds, prefix)...)
|
||||||
}
|
}
|
||||||
for date := range previousDays {
|
for date := range previousDays {
|
||||||
if _, ok := currentDays[date]; !ok {
|
if _, ok := currentDays[date]; !ok {
|
||||||
changes = append(changes, Change{Type: "outlook_day_removed", Message: fmt.Sprintf("Outlook day removed: %s.", date), Previous: date})
|
changes = append(changes, Change{Type: prefix + "outlook_day_removed", Message: fmt.Sprintf("Outlook day removed: %s.", date), Previous: date})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sortChanges(changes)
|
sortChanges(changes)
|
||||||
return changes, nil
|
return changes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func compareOutlookDay(date string, previous briefing.OutlookDay, current briefing.OutlookDay, thresholds Thresholds) []Change {
|
func compareOutlookDay(date string, previous outlookDay, current outlookDay, thresholds Thresholds, prefix string) []Change {
|
||||||
var changes []Change
|
var changes []Change
|
||||||
for _, change := range compareTemperature(previous.Temperature, current.Temperature, thresholds.TemperatureDegrees) {
|
for _, change := range compareTemperatureValues("Low", previous.LowTempF, current.LowTempF, thresholds.TemperatureDegrees) {
|
||||||
|
change.Message = date + ": " + change.Message
|
||||||
|
change.Type = prefix + "outlook_" + change.Type
|
||||||
|
changes = append(changes, change)
|
||||||
|
}
|
||||||
|
for _, change := range compareTemperatureValues("High", previous.HighTempF, current.HighTempF, thresholds.TemperatureDegrees) {
|
||||||
|
change.Message = date + ": " + change.Message
|
||||||
|
change.Type = prefix + "outlook_" + change.Type
|
||||||
|
changes = append(changes, change)
|
||||||
|
}
|
||||||
|
for _, change := range comparePrecipitationValues(previous.MaxPopPercent, current.MaxPopPercent, thresholds.PrecipProbabilityPoints, prefix+"outlook_") {
|
||||||
change.Message = date + ": " + change.Message
|
change.Message = date + ": " + change.Message
|
||||||
changes = append(changes, change)
|
changes = append(changes, change)
|
||||||
}
|
}
|
||||||
for _, change := range comparePrecipitation(previous.MaxPrecipitationProbability, current.MaxPrecipitationProbability, thresholds) {
|
for _, change := range comparePrecipTiming(previous.MaxPopTime, current.MaxPopTime, thresholds.PrecipTimingShiftMinutes, prefix+"outlook_") {
|
||||||
change.Message = date + ": " + change.Message
|
change.Message = date + ": " + change.Message
|
||||||
changes = append(changes, change)
|
changes = append(changes, change)
|
||||||
changes[len(changes)-1].Type = "outlook_" + change.Type
|
|
||||||
}
|
}
|
||||||
for _, change := range compareWind(previous.PeakWindGust, current.PeakWindGust, float64(thresholds.WindGustMilesPerHour)) {
|
for _, change := range compareWindValues(previous.MaxWindGustMph, current.MaxWindGustMph, thresholds.WindGustMilesPerHour, prefix+"outlook_") {
|
||||||
change.Message = date + ": " + change.Message
|
change.Message = date + ": " + change.Message
|
||||||
change.Type = "outlook_" + change.Type
|
|
||||||
changes = append(changes, change)
|
changes = append(changes, change)
|
||||||
}
|
}
|
||||||
for _, change := range compareAlerts(previous.RelevantAlerts, current.RelevantAlerts) {
|
for _, change := range compareIndicators(previous.Indicators, current.Indicators, prefix+"outlook_") {
|
||||||
change.Message = date + ": " + change.Message
|
change.Message = date + ": " + change.Message
|
||||||
change.Type = "outlook_" + change.Type
|
|
||||||
changes = append(changes, change)
|
|
||||||
}
|
|
||||||
for _, change := range compareIndicators(aggregateIndicators(previous.Dayparts), aggregateIndicators(current.Dayparts)) {
|
|
||||||
change.Message = date + ": " + change.Message
|
|
||||||
change.Type = "outlook_" + change.Type
|
|
||||||
changes = append(changes, change)
|
changes = append(changes, change)
|
||||||
}
|
}
|
||||||
return changes
|
return changes
|
||||||
}
|
}
|
||||||
|
|
||||||
func outlookDaysByDate(days []briefing.OutlookDay) map[string]briefing.OutlookDay {
|
func outlookDaysFromDayparts(dayparts map[string]daypartSummaryStanza) map[string]outlookDay {
|
||||||
out := map[string]briefing.OutlookDay{}
|
out := map[string]outlookDay{}
|
||||||
for _, day := range days {
|
var keys []string
|
||||||
date := day.Date
|
for key := range dayparts {
|
||||||
if date == "" && !day.Period.Start.IsZero() {
|
keys = append(keys, key)
|
||||||
date = day.Period.Start.Format(time.DateOnly)
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, key := range keys {
|
||||||
|
daypart := dayparts[key]
|
||||||
|
date := daypartDate(daypart)
|
||||||
|
if date == "" {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if date != "" {
|
day := out[date]
|
||||||
out[date] = day
|
day.Date = date
|
||||||
|
low, high := parseTempRange(daypart.TempRangeF)
|
||||||
|
day.LowTempF = minInt(day.LowTempF, low)
|
||||||
|
day.HighTempF = maxInt(day.HighTempF, high)
|
||||||
|
day.MaxPopPercent = maxInt(day.MaxPopPercent, daypart.MaxPopPercent)
|
||||||
|
if daypart.MaxPopPercent != nil && day.MaxPopPercent != nil && *daypart.MaxPopPercent == *day.MaxPopPercent {
|
||||||
|
day.MaxPopTime = daypart.MaxPopTime
|
||||||
}
|
}
|
||||||
|
day.MaxWindGustMph = maxInt(day.MaxWindGustMph, daypart.MaxWindGustMph)
|
||||||
|
day.Indicators.Snow = day.Indicators.Snow || daypart.Snow
|
||||||
|
day.Indicators.Ice = day.Indicators.Ice || daypart.Ice
|
||||||
|
out[date] = day
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func daypartDate(daypart daypartSummaryStanza) string {
|
||||||
|
return daypart.Date
|
||||||
|
}
|
||||||
|
|
||||||
|
func minInt(a *int, b *int) *int {
|
||||||
|
if a == nil {
|
||||||
|
return copyInt(b)
|
||||||
|
}
|
||||||
|
if b != nil && *b < *a {
|
||||||
|
return copyInt(b)
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
func maxInt(a *int, b *int) *int {
|
||||||
|
if a == nil {
|
||||||
|
return copyInt(b)
|
||||||
|
}
|
||||||
|
if b != nil && *b > *a {
|
||||||
|
return copyInt(b)
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyInt(value *int) *int {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
copied := *value
|
||||||
|
return &copied
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,41 +2,13 @@ package changes
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCompareThreeDayDetectsDayChanges(t *testing.T) {
|
func TestCompareThreeDayDetectsDayChanges(t *testing.T) {
|
||||||
previousTemp := 70.0
|
previous := outlookSnapshot(t, "2026-05-29", "70", 20, "9 AM", false)
|
||||||
currentTemp := 78.0
|
current := outlookSnapshot(t, "2026-05-29", "78", 70, "12 PM", true)
|
||||||
previousPrecip := 20.0
|
|
||||||
currentPrecip := 70.0
|
|
||||||
previous := briefing.Package{
|
|
||||||
Metadata: briefing.Metadata{ReportID: report.ThreeDay},
|
|
||||||
ThreeDay: &briefing.ThreeDay{Days: []briefing.OutlookDay{{
|
|
||||||
Date: "2026-05-29",
|
|
||||||
Temperature: forecast.Range{Max: &previousTemp},
|
|
||||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
|
||||||
Value: previousPrecip,
|
|
||||||
Time: time.Date(2026, 5, 29, 9, 0, 0, 0, time.UTC),
|
|
||||||
},
|
|
||||||
}}},
|
|
||||||
}
|
|
||||||
current := briefing.Package{
|
|
||||||
Metadata: briefing.Metadata{ReportID: report.ThreeDay},
|
|
||||||
ThreeDay: &briefing.ThreeDay{Days: []briefing.OutlookDay{{
|
|
||||||
Date: "2026-05-29",
|
|
||||||
Temperature: forecast.Range{Max: ¤tTemp},
|
|
||||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
|
||||||
Value: currentPrecip,
|
|
||||||
Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC),
|
|
||||||
},
|
|
||||||
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Snow: true}}},
|
|
||||||
}}},
|
|
||||||
}
|
|
||||||
|
|
||||||
changes, err := CompareThreeDay(previous, current, Thresholds{
|
changes, err := CompareThreeDay(previous, current, Thresholds{
|
||||||
TemperatureDegrees: 5,
|
TemperatureDegrees: 5,
|
||||||
@@ -50,17 +22,22 @@ func TestCompareThreeDayDetectsDayChanges(t *testing.T) {
|
|||||||
if len(changes) == 0 {
|
if len(changes) == 0 {
|
||||||
t.Fatal("changes length = 0, want detected 3-day changes")
|
t.Fatal("changes length = 0, want detected 3-day changes")
|
||||||
}
|
}
|
||||||
var foundPrecip bool
|
if countType(changes, "outlook_precip_probability_change") == 0 || countType(changes, "outlook_snow_risk_change") == 0 {
|
||||||
var foundSnow bool
|
|
||||||
for _, change := range changes {
|
|
||||||
if change.Type == "outlook_precip_probability_change" {
|
|
||||||
foundPrecip = true
|
|
||||||
}
|
|
||||||
if change.Type == "outlook_snow_risk_change" {
|
|
||||||
foundSnow = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !foundPrecip || !foundSnow {
|
|
||||||
t.Fatalf("changes = %#v, want precipitation and snow changes", changes)
|
t.Fatalf("changes = %#v, want precipitation and snow changes", changes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func outlookSnapshot(t *testing.T, date string, tempRange string, precip int, precipTime string, snow bool) module.Snapshot {
|
||||||
|
t.Helper()
|
||||||
|
return snapshot(t, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
|
||||||
|
date + "_morning": {
|
||||||
|
Date: date,
|
||||||
|
PeriodBegins: date + " at 6:00 AM",
|
||||||
|
PeriodEnds: date + " at 10:00 AM",
|
||||||
|
TempRangeF: tempRange,
|
||||||
|
MaxPopPercent: &precip,
|
||||||
|
MaxPopTime: precipTime,
|
||||||
|
Snow: snow,
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,24 +3,17 @@ package changes
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
)
|
)
|
||||||
|
|
||||||
func CompareWeekend(previous briefing.Package, current briefing.Package, thresholds Thresholds) ([]Change, error) {
|
func CompareWeekend(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) {
|
||||||
if previous.Weekend == nil {
|
previousDayparts, err := requiredStanza[map[string]daypartSummaryStanza](previous, "derived_daypart_summaries")
|
||||||
return nil, fmt.Errorf("previous weekend briefing is required")
|
|
||||||
}
|
|
||||||
if current.Weekend == nil {
|
|
||||||
return nil, fmt.Errorf("current weekend briefing is required")
|
|
||||||
}
|
|
||||||
previousOutlook := briefing.Package{ThreeDay: &briefing.ThreeDay{Days: previous.Weekend.Days}}
|
|
||||||
currentOutlook := briefing.Package{ThreeDay: &briefing.ThreeDay{Days: current.Weekend.Days}}
|
|
||||||
changes, err := CompareThreeDay(previousOutlook, currentOutlook, thresholds)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("previous weekend daypart summaries: %w", err)
|
||||||
}
|
}
|
||||||
for i := range changes {
|
currentDayparts, err := requiredStanza[map[string]daypartSummaryStanza](current, "derived_daypart_summaries")
|
||||||
changes[i].Type = "weekend_" + changes[i].Type
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("current weekend daypart summaries: %w", err)
|
||||||
}
|
}
|
||||||
return changes, nil
|
return compareOutlookDays(outlookDaysFromDayparts(previousDayparts), outlookDaysFromDayparts(currentDayparts), thresholds, "weekend_")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,10 @@
|
|||||||
package changes
|
package changes
|
||||||
|
|
||||||
import (
|
import "testing"
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCompareWeekendDetectsOutlookChanges(t *testing.T) {
|
func TestCompareWeekendDetectsOutlookChanges(t *testing.T) {
|
||||||
previousTemp := 70.0
|
previous := outlookSnapshot(t, "2026-05-30", "70", 10, "9 AM", false)
|
||||||
currentTemp := 78.0
|
current := outlookSnapshot(t, "2026-05-30", "78", 10, "9 AM", true)
|
||||||
previous := briefing.Package{
|
|
||||||
Metadata: briefing.Metadata{ReportID: report.Weekend},
|
|
||||||
Weekend: &briefing.Weekend{Days: []briefing.OutlookDay{{
|
|
||||||
Date: "2026-05-30",
|
|
||||||
Temperature: forecast.Range{Max: &previousTemp},
|
|
||||||
}}},
|
|
||||||
}
|
|
||||||
current := briefing.Package{
|
|
||||||
Metadata: briefing.Metadata{ReportID: report.Weekend},
|
|
||||||
Weekend: &briefing.Weekend{Days: []briefing.OutlookDay{{
|
|
||||||
Date: "2026-05-30",
|
|
||||||
Temperature: forecast.Range{Max: ¤tTemp},
|
|
||||||
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Snow: true}}},
|
|
||||||
}}},
|
|
||||||
}
|
|
||||||
|
|
||||||
changes, err := CompareWeekend(previous, current, Thresholds{TemperatureDegrees: 5})
|
changes, err := CompareWeekend(previous, current, Thresholds{TemperatureDegrees: 5})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -34,10 +13,7 @@ func TestCompareWeekendDetectsOutlookChanges(t *testing.T) {
|
|||||||
if len(changes) == 0 {
|
if len(changes) == 0 {
|
||||||
t.Fatal("changes length = 0, want weekend changes")
|
t.Fatal("changes length = 0, want weekend changes")
|
||||||
}
|
}
|
||||||
for _, change := range changes {
|
if countType(changes, "weekend_outlook_snow_risk_change") == 0 {
|
||||||
if change.Type == "weekend_outlook_snow_risk_change" {
|
t.Fatalf("changes = %#v, want snow risk change", changes)
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
t.Fatalf("changes = %#v, want snow risk change", changes)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ Usage:
|
|||||||
weatherreporter --help
|
weatherreporter --help
|
||||||
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
||||||
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
|
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
|
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
|
||||||
@@ -26,7 +27,7 @@ Usage:
|
|||||||
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
|
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
|
||||||
weatherreporter inspect reports [--config PATH] [--limit N]
|
weatherreporter inspect reports [--config PATH] [--limit N]
|
||||||
weatherreporter inspect metadata [--config PATH] RUN_ID
|
weatherreporter inspect metadata [--config PATH] RUN_ID
|
||||||
weatherreporter inspect briefing [--config PATH] RUN_ID
|
weatherreporter inspect modules [--config PATH] RUN_ID
|
||||||
weatherreporter inspect data-package [--config PATH] RUN_ID
|
weatherreporter inspect data-package [--config PATH] RUN_ID
|
||||||
weatherreporter inspect prior [--config PATH] RUN_ID
|
weatherreporter inspect prior [--config PATH] RUN_ID
|
||||||
weatherreporter inspect sources [--config PATH] RUN_ID
|
weatherreporter inspect sources [--config PATH] RUN_ID
|
||||||
@@ -36,7 +37,7 @@ Options:
|
|||||||
--config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml.
|
--config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml.
|
||||||
--units VALUE Override weather API units.
|
--units VALUE Override weather API units.
|
||||||
--tz NAME Override weather API timezone.
|
--tz NAME Override weather API timezone.
|
||||||
--out PATH Write an extra Markdown report copy for generate commands.
|
--out PATH Write an extra Markdown report copy where supported by the generate command.
|
||||||
--out-dir PATH Write extra Markdown report copies for run commands.
|
--out-dir PATH Write extra Markdown report copies for run commands.
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -118,8 +119,8 @@ var inspectRunCommands = []inspectRunCommand{
|
|||||||
{Name: "metadata", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
|
{Name: "metadata", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
|
||||||
return app.InspectMetadata(ctx, req)
|
return app.InspectMetadata(ctx, req)
|
||||||
}},
|
}},
|
||||||
{Name: "briefing", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
|
{Name: "modules", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
|
||||||
return app.InspectBriefing(ctx, req)
|
return app.InspectModules(ctx, req)
|
||||||
}},
|
}},
|
||||||
{Name: "data-package", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
|
{Name: "data-package", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
|
||||||
return app.InspectDataPackage(ctx, req)
|
return app.InspectDataPackage(ctx, req)
|
||||||
@@ -352,11 +353,21 @@ func writeRunLogs(stderr io.Writer, result *app.BatchResult) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, item := range result.Reports {
|
for _, item := range result.Reports {
|
||||||
|
notificationFields := ""
|
||||||
|
if item.NotificationStatus != "" {
|
||||||
|
notificationFields += fmt.Sprintf(" notificationStatus=%q", item.NotificationStatus)
|
||||||
|
}
|
||||||
|
if item.NotificationRunID != "" {
|
||||||
|
notificationFields += fmt.Sprintf(" notificationRunId=%q", item.NotificationRunID)
|
||||||
|
}
|
||||||
|
if item.NotificationError != "" {
|
||||||
|
notificationFields += fmt.Sprintf(" notificationError=%q", item.NotificationError)
|
||||||
|
}
|
||||||
if item.Status == "failed" {
|
if item.Status == "failed" {
|
||||||
_, _ = fmt.Fprintf(stderr, "report=%s status=failed error=%q\n", item.ReportID, item.Error)
|
_, _ = fmt.Fprintf(stderr, "report=%s status=failed error=%q%s\n", item.ReportID, item.Error, notificationFields)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
_, _ = fmt.Fprintf(stderr, "report=%s status=succeeded output=%q\n", item.ReportID, item.OutputPath)
|
_, _ = fmt.Fprintf(stderr, "report=%s status=succeeded output=%q%s\n", item.ReportID, item.OutputPath, notificationFields)
|
||||||
}
|
}
|
||||||
_, _ = 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)
|
||||||
}
|
}
|
||||||
@@ -376,6 +387,8 @@ func reportKind(value string) (app.ReportKind, bool) {
|
|||||||
return app.ReportDaily, true
|
return app.ReportDaily, true
|
||||||
case string(app.ReportTomorrow):
|
case string(app.ReportTomorrow):
|
||||||
return app.ReportTomorrow, true
|
return app.ReportTomorrow, true
|
||||||
|
case string(app.ReportHourly):
|
||||||
|
return app.ReportHourly, true
|
||||||
case string(app.ReportThreeDay):
|
case string(app.ReportThreeDay):
|
||||||
return app.ReportThreeDay, true
|
return app.ReportThreeDay, true
|
||||||
case string(app.ReportWeekend):
|
case string(app.ReportWeekend):
|
||||||
|
|||||||
@@ -28,6 +28,17 @@ func TestRunHelpLongFlag(t *testing.T) {
|
|||||||
if !strings.Contains(stdout.String(), "generate daily") {
|
if !strings.Contains(stdout.String(), "generate daily") {
|
||||||
t.Fatalf("help output missing generate command:\n%s", stdout.String())
|
t.Fatalf("help output missing generate command:\n%s", stdout.String())
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "weatherreporter generate hourly") {
|
||||||
|
t.Fatalf("help output missing hourly generate command:\n%s", stdout.String())
|
||||||
|
}
|
||||||
|
removedGenerateCommand := "generate " + strings.Join([]string{"near", "term"}, "-")
|
||||||
|
if strings.Contains(stdout.String(), removedGenerateCommand) {
|
||||||
|
t.Fatalf("help output includes retired generate command:\n%s", stdout.String())
|
||||||
|
}
|
||||||
|
removedInspectCommand := "inspect " + "briefing"
|
||||||
|
if !strings.Contains(stdout.String(), "inspect modules") || strings.Contains(stdout.String(), removedInspectCommand) {
|
||||||
|
t.Fatalf("help output has wrong inspect commands:\n%s", stdout.String())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunHelpShortFlag(t *testing.T) {
|
func TestRunHelpShortFlag(t *testing.T) {
|
||||||
@@ -86,12 +97,12 @@ func TestRunGenerateStormWritesMarkdownReport(t *testing.T) {
|
|||||||
if !strings.Contains(string(report), "# Daily Report") {
|
if !strings.Contains(string(report), "# Daily Report") {
|
||||||
t.Fatalf("report output missing markdown:\n%s", string(report))
|
t.Fatalf("report output missing markdown:\n%s", string(report))
|
||||||
}
|
}
|
||||||
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.json")
|
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.yaml")
|
||||||
data, err := os.ReadFile(dataPackagePath)
|
data, err := os.ReadFile(dataPackagePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read managed data package: %v", err)
|
t.Fatalf("read managed data package: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(data), `"storm"`) || !strings.Contains(string(data), `"weather.storm_report"`) {
|
if !strings.Contains(string(data), "id: storm") || !strings.Contains(string(data), "prompt_id: weather.storm_report") {
|
||||||
t.Fatalf("data package output missing storm content:\n%s", string(data))
|
t.Fatalf("data package output missing storm content:\n%s", string(data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,22 +130,22 @@ func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read report: %v", err)
|
t.Fatalf("read report: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(report), "# Daily Report") {
|
if !strings.Contains(string(report), "# Saturday's Weather") {
|
||||||
t.Fatalf("report output missing markdown:\n%s", string(report))
|
t.Fatalf("report output missing markdown:\n%s", string(report))
|
||||||
}
|
}
|
||||||
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.json")
|
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml")
|
||||||
data, err := os.ReadFile(dataPackagePath)
|
data, err := os.ReadFile(dataPackagePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read managed data package: %v", err)
|
t.Fatalf("read managed data package: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(data), `"daily_tomorrow"`) || !strings.Contains(string(data), `"planning"`) {
|
if !strings.Contains(string(data), "id: tomorrow") || !strings.Contains(string(data), "tomorrow_planning:") {
|
||||||
t.Fatalf("data package output missing tomorrow content:\n%s", string(data))
|
t.Fatalf("data package output missing tomorrow content:\n%s", string(data))
|
||||||
}
|
}
|
||||||
reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "daily", "*.md"))
|
reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "tomorrow", "*.md"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("glob managed report: %v", err)
|
t.Fatalf("glob managed report: %v", err)
|
||||||
}
|
}
|
||||||
if len(reportMatches) != 1 || !strings.Contains(filepath.Base(reportMatches[0]), "daily_tomorrow") {
|
if len(reportMatches) != 1 || !strings.Contains(filepath.Base(reportMatches[0]), "tomorrow") {
|
||||||
t.Fatalf("managed reports = %#v, want tomorrow report", reportMatches)
|
t.Fatalf("managed reports = %#v, want tomorrow report", reportMatches)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,18 +171,18 @@ func TestRunEveningGeneratesTomorrowReport(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.json"))
|
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("glob data package: %v", err)
|
t.Fatalf("glob data package: %v", err)
|
||||||
}
|
}
|
||||||
if len(dataPackageMatches) != 1 {
|
if len(dataPackageMatches) != 1 {
|
||||||
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
|
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
|
||||||
}
|
}
|
||||||
reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "daily", "*.md"))
|
reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "tomorrow", "*.md"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("glob managed report: %v", err)
|
t.Fatalf("glob managed report: %v", err)
|
||||||
}
|
}
|
||||||
if len(reportMatches) != 1 || !strings.Contains(filepath.Base(reportMatches[0]), "daily_tomorrow") {
|
if len(reportMatches) != 1 || !strings.Contains(filepath.Base(reportMatches[0]), "tomorrow") {
|
||||||
t.Fatalf("managed reports = %#v, want only tomorrow report", reportMatches)
|
t.Fatalf("managed reports = %#v, want only tomorrow report", reportMatches)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,7 +217,7 @@ func TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) {
|
|||||||
if !strings.Contains(string(report), "# Daily Report") {
|
if !strings.Contains(string(report), "# Daily Report") {
|
||||||
t.Fatalf("report output missing markdown:\n%s", string(report))
|
t.Fatalf("report output missing markdown:\n%s", string(report))
|
||||||
}
|
}
|
||||||
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-29", "*.data_package.json"))
|
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-29", "*.data_package.yaml"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("glob data package: %v", err)
|
t.Fatalf("glob data package: %v", err)
|
||||||
}
|
}
|
||||||
@@ -217,7 +228,7 @@ func TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read managed data package: %v", err)
|
t.Fatalf("read managed data package: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(data), `"three_day"`) || !strings.Contains(string(data), `"threeDay"`) {
|
if !strings.Contains(string(data), "id: three_day") || !strings.Contains(string(data), "derived_daypart_summaries:") {
|
||||||
t.Fatalf("data package output missing 3-day content:\n%s", string(data))
|
t.Fatalf("data package output missing 3-day content:\n%s", string(data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,7 +263,7 @@ func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) {
|
|||||||
if !strings.Contains(string(report), "# Daily Report") {
|
if !strings.Contains(string(report), "# Daily Report") {
|
||||||
t.Fatalf("report output missing markdown:\n%s", string(report))
|
t.Fatalf("report output missing markdown:\n%s", string(report))
|
||||||
}
|
}
|
||||||
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.json"))
|
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("glob data package: %v", err)
|
t.Fatalf("glob data package: %v", err)
|
||||||
}
|
}
|
||||||
@@ -263,7 +274,7 @@ func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read managed data package: %v", err)
|
t.Fatalf("read managed data package: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(data), `"weekend"`) || !strings.Contains(string(data), `"planning"`) {
|
if !strings.Contains(string(data), "id: weekend") || !strings.Contains(string(data), "derived_daypart_summaries:") {
|
||||||
t.Fatalf("data package output missing weekend content:\n%s", string(data))
|
t.Fatalf("data package output missing weekend content:\n%s", string(data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -289,7 +300,7 @@ func TestRunMorningIncludesWeekendExceptSunday(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
weekendPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.json"))
|
weekendPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("glob weekend packages: %v", err)
|
t.Fatalf("glob weekend packages: %v", err)
|
||||||
}
|
}
|
||||||
@@ -333,11 +344,11 @@ func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
|
|||||||
if !strings.Contains(stderr.String(), "status=failed") || !strings.Contains(stderr.String(), "status=succeeded") {
|
if !strings.Contains(stderr.String(), "status=failed") || !strings.Contains(stderr.String(), "status=succeeded") {
|
||||||
t.Fatalf("stderr missing structured report logs:\n%s", stderr.String())
|
t.Fatalf("stderr missing structured report logs:\n%s", stderr.String())
|
||||||
}
|
}
|
||||||
dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.json"))
|
dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("glob daily packages: %v", err)
|
t.Fatalf("glob daily packages: %v", err)
|
||||||
}
|
}
|
||||||
weekendPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.json"))
|
weekendPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("glob weekend packages: %v", err)
|
t.Fatalf("glob weekend packages: %v", err)
|
||||||
}
|
}
|
||||||
@@ -346,6 +357,88 @@ func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBatchOutputIncludesNotificationDetails(t *testing.T) {
|
||||||
|
result := &app.BatchResult{
|
||||||
|
Batch: app.BatchMorning,
|
||||||
|
Total: 2,
|
||||||
|
Succeeded: 1,
|
||||||
|
Failed: 1,
|
||||||
|
Reports: []app.BatchReportResult{
|
||||||
|
{
|
||||||
|
ReportID: "daily_today",
|
||||||
|
Status: "succeeded",
|
||||||
|
OutputPath: "/tmp/daily.md",
|
||||||
|
NotificationStatus: "accepted",
|
||||||
|
NotificationRunID: "distributor-run-1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ReportID: "three_day",
|
||||||
|
Status: "failed",
|
||||||
|
Error: "notify report three_day: upload failed",
|
||||||
|
NotificationStatus: "failed",
|
||||||
|
NotificationError: "notify report three_day: upload failed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
|
if err := writeJSON(&stdout, result); err != nil {
|
||||||
|
t.Fatalf("writeJSON() error = %v", err)
|
||||||
|
}
|
||||||
|
writeRunLogs(&stderr, result)
|
||||||
|
|
||||||
|
var decoded app.BatchResult
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
|
||||||
|
t.Fatalf("decode batch JSON: %v\n%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
if decoded.Reports[0].NotificationStatus != "accepted" || decoded.Reports[0].NotificationRunID != "distributor-run-1" {
|
||||||
|
t.Fatalf("success notification fields = %#v", decoded.Reports[0])
|
||||||
|
}
|
||||||
|
if decoded.Reports[1].NotificationStatus != "failed" || !strings.Contains(decoded.Reports[1].NotificationError, "upload failed") {
|
||||||
|
t.Fatalf("failure notification fields = %#v", decoded.Reports[1])
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), `notificationStatus="accepted"`) || !strings.Contains(stderr.String(), `notificationRunId="distributor-run-1"`) {
|
||||||
|
t.Fatalf("stderr missing success notification fields:\n%s", stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), `notificationStatus="failed"`) || !strings.Contains(stderr.String(), `notificationError="notify report three_day: upload failed"`) {
|
||||||
|
t.Fatalf("stderr missing failure notification fields:\n%s", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchOutputDoesNotExposeSecretLikeNotificationErrors(t *testing.T) {
|
||||||
|
result := &app.BatchResult{
|
||||||
|
Batch: app.BatchMorning,
|
||||||
|
Total: 1,
|
||||||
|
Failed: 1,
|
||||||
|
Reports: []app.BatchReportResult{
|
||||||
|
{
|
||||||
|
ReportID: "daily_today",
|
||||||
|
Status: "failed",
|
||||||
|
Error: "notify report daily_today: upload failed: [redacted]",
|
||||||
|
NotificationStatus: "failed",
|
||||||
|
NotificationError: "notify report daily_today: upload failed: [redacted]",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
|
if err := writeJSON(&stdout, result); err != nil {
|
||||||
|
t.Fatalf("writeJSON() error = %v", err)
|
||||||
|
}
|
||||||
|
writeRunLogs(&stderr, result)
|
||||||
|
|
||||||
|
for _, output := range []string{stdout.String(), stderr.String()} {
|
||||||
|
if strings.Contains(output, "DISTRIBUTOR_SECRET_TOKEN") {
|
||||||
|
t.Fatalf("output contains token value:\n%s", output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "[redacted]") {
|
||||||
|
t.Fatalf("output missing redacted marker:\n%s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) {
|
func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) {
|
||||||
server := dailyServer(t)
|
server := dailyServer(t)
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
@@ -384,6 +477,108 @@ func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunEveningReportsNotificationSuccess(t *testing.T) {
|
||||||
|
server := dailyServer(t)
|
||||||
|
distributorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/runs/distributor-run-1" {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"run_id":"distributor-run-1","pipeline_id":"weatherreporter.tomorrow","status":"succeeded","report":{"actions":[{"action":"replace_older"}]}}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/v1/pipelines/weatherreporter.tomorrow/upload" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
_, _ = w.Write([]byte(`{"run_id":"distributor-run-1","status":"accepted"}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(distributorServer.Close)
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
||||||
|
configPath := filepath.Join(tempDir, "config.yml")
|
||||||
|
workspaceRoot := filepath.Join(tempDir, "workspace")
|
||||||
|
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\nnotify:\n distributor:\n enabled: true\n endpoint: " + distributorServer.URL + "\n token_env: CLI_DISTRIBUTOR_TOKEN\n pipeline_id_template: weatherreporter.{artifact_group}\n"
|
||||||
|
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("CLI_DISTRIBUTOR_TOKEN", "cli-secret-token")
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
runner := Runner{Clock: fixedClock()}
|
||||||
|
|
||||||
|
err := runner.Run(context.Background(), []string{
|
||||||
|
"run", "evening",
|
||||||
|
"--config", configPath,
|
||||||
|
}, &stdout, &stderr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary app.BatchResult
|
||||||
|
if decodeErr := json.Unmarshal(stdout.Bytes(), &summary); decodeErr != nil {
|
||||||
|
t.Fatalf("decode summary: %v\n%s", decodeErr, stdout.String())
|
||||||
|
}
|
||||||
|
if len(summary.Reports) != 1 {
|
||||||
|
t.Fatalf("reports = %#v, want one report", summary.Reports)
|
||||||
|
}
|
||||||
|
if summary.Reports[0].NotificationStatus != "succeeded" || summary.Reports[0].NotificationRunID != "distributor-run-1" || summary.Reports[0].NotificationPipelineID != "weatherreporter.tomorrow" {
|
||||||
|
t.Fatalf("notification fields = %#v", summary.Reports[0])
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), `notificationStatus="succeeded"`) || !strings.Contains(stderr.String(), `notificationRunId="distributor-run-1"`) {
|
||||||
|
t.Fatalf("stderr missing notification fields:\n%s", stderr.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(stdout.String(), "cli-secret-token") || strings.Contains(stderr.String(), "cli-secret-token") {
|
||||||
|
t.Fatalf("output contains token value\nstdout=%s\nstderr=%s", stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunEveningReportsNotificationFailureWithoutToken(t *testing.T) {
|
||||||
|
server := dailyServer(t)
|
||||||
|
distributorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"rejected cli-secret-token","retryable":false}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(distributorServer.Close)
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
||||||
|
configPath := filepath.Join(tempDir, "config.yml")
|
||||||
|
workspaceRoot := filepath.Join(tempDir, "workspace")
|
||||||
|
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\nnotify:\n distributor:\n enabled: true\n endpoint: " + distributorServer.URL + "\n token_env: CLI_DISTRIBUTOR_TOKEN\n pipeline_id_template: weatherreporter.{artifact_group}\n"
|
||||||
|
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("CLI_DISTRIBUTOR_TOKEN", "cli-secret-token")
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
runner := Runner{Clock: fixedClock()}
|
||||||
|
|
||||||
|
err := runner.Run(context.Background(), []string{
|
||||||
|
"run", "evening",
|
||||||
|
"--config", configPath,
|
||||||
|
}, &stdout, &stderr)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Run() error = nil, want notification failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary app.BatchResult
|
||||||
|
if decodeErr := json.Unmarshal(stdout.Bytes(), &summary); decodeErr != nil {
|
||||||
|
t.Fatalf("decode summary: %v\n%s", decodeErr, stdout.String())
|
||||||
|
}
|
||||||
|
if len(summary.Reports) != 1 || summary.Reports[0].NotificationStatus != "failed" {
|
||||||
|
t.Fatalf("summary reports = %#v, want failed notification", summary.Reports)
|
||||||
|
}
|
||||||
|
for _, output := range []string{stdout.String(), stderr.String(), err.Error()} {
|
||||||
|
if strings.Contains(output, "cli-secret-token") {
|
||||||
|
t.Fatalf("output contains token value:\n%s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, output := range []string{stdout.String(), stderr.String()} {
|
||||||
|
if !strings.Contains(output, "[redacted]") {
|
||||||
|
t.Fatalf("output missing redaction marker:\n%s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunMorningGeneratesDailyAndThreeDayOnSunday(t *testing.T) {
|
func TestRunMorningGeneratesDailyAndThreeDayOnSunday(t *testing.T) {
|
||||||
server := dailyServer(t)
|
server := dailyServer(t)
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
@@ -405,11 +600,11 @@ func TestRunMorningGeneratesDailyAndThreeDayOnSunday(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.json"))
|
dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.yaml"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("glob daily packages: %v", err)
|
t.Fatalf("glob daily packages: %v", err)
|
||||||
}
|
}
|
||||||
threeDayPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-31", "*.data_package.json"))
|
threeDayPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-31", "*.data_package.yaml"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("glob 3-day packages: %v", err)
|
t.Fatalf("glob 3-day packages: %v", err)
|
||||||
}
|
}
|
||||||
@@ -450,7 +645,7 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
|||||||
if !strings.Contains(string(report), "# Daily Report") {
|
if !strings.Contains(string(report), "# Daily Report") {
|
||||||
t.Fatalf("report output missing markdown:\n%s", string(report))
|
t.Fatalf("report output missing markdown:\n%s", string(report))
|
||||||
}
|
}
|
||||||
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.json"))
|
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("glob data package: %v", err)
|
t.Fatalf("glob data package: %v", err)
|
||||||
}
|
}
|
||||||
@@ -461,26 +656,15 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read managed data package: %v", err)
|
t.Fatalf("read managed data package: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(data), `data_package.v1`) || !strings.Contains(string(data), `"daily_today"`) {
|
if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v2") || !strings.Contains(string(data), "id: daily_today") {
|
||||||
t.Fatalf("data package output missing expected content:\n%s", string(data))
|
t.Fatalf("data package output missing expected content:\n%s", string(data))
|
||||||
}
|
}
|
||||||
var decoded struct {
|
if !strings.Contains(string(data), "location:") ||
|
||||||
Briefing struct {
|
!strings.Contains(string(data), "id: home") ||
|
||||||
Metadata struct {
|
!strings.Contains(string(data), "name: Brentwood") ||
|
||||||
Location struct {
|
!strings.Contains(string(data), "region: St. Louis Metro") ||
|
||||||
ID string `json:"id"`
|
!strings.Contains(string(data), "timezone: UTC") {
|
||||||
Name string `json:"name"`
|
t.Fatalf("data package missing configured location with overridden timezone:\n%s", string(data))
|
||||||
Region string `json:"region"`
|
|
||||||
Timezone string `json:"timezone"`
|
|
||||||
} `json:"location"`
|
|
||||||
} `json:"metadata"`
|
|
||||||
} `json:"briefing"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
|
||||||
t.Fatalf("decode data package: %v", err)
|
|
||||||
}
|
|
||||||
if decoded.Briefing.Metadata.Location.ID != "home" || decoded.Briefing.Metadata.Location.Name != "Brentwood" || decoded.Briefing.Metadata.Location.Region != "St. Louis Metro" || decoded.Briefing.Metadata.Location.Timezone != "UTC" {
|
|
||||||
t.Fatalf("location = %#v, want configured location with overridden timezone", decoded.Briefing.Metadata.Location)
|
|
||||||
}
|
}
|
||||||
preflightMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "preflight", "daily", "2026-05-29", "*.render.json"))
|
preflightMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "preflight", "daily", "2026-05-29", "*.render.json"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -505,6 +689,63 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
|
||||||
|
server := dailyServer(t)
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
scriptoriumPath := writeStructuredOutputScriptorium(t, tempDir)
|
||||||
|
workspaceRoot := filepath.Join(tempDir, "workspace")
|
||||||
|
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
|
||||||
|
outPath := filepath.Join(tempDir, "hourly.md")
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
runner := Runner{Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 11, 0, 0, 0, time.UTC)}}
|
||||||
|
|
||||||
|
err := runner.Run(context.Background(), []string{
|
||||||
|
"generate", "hourly",
|
||||||
|
"--config", configPath,
|
||||||
|
"--out", outPath,
|
||||||
|
}, &stdout, &stderr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
report, err := os.ReadFile(outPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read report: %v", err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"# Hourly Report",
|
||||||
|
"Storm chances increase through late morning.",
|
||||||
|
"A cold front is moving into the region.",
|
||||||
|
"A front will keep the region unsettled.",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(string(report), want) {
|
||||||
|
t.Fatalf("report output missing %q:\n%s", want, string(report))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "hourly", "2026-05-29", "*.data_package.yaml")
|
||||||
|
dataPackage, err := os.ReadFile(dataPackagePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read managed data package: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(dataPackage), "id: hourly") ||
|
||||||
|
!strings.Contains(string(dataPackage), "prompt_id: weather.hourly_generated_text") ||
|
||||||
|
!strings.Contains(string(dataPackage), "hourly_forecast:") {
|
||||||
|
t.Fatalf("data package output missing hourly content:\n%s", string(dataPackage))
|
||||||
|
}
|
||||||
|
rawGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.generated_text.raw.json")
|
||||||
|
validatedGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.generated_text.json")
|
||||||
|
renderContextPath := oneArtifact(t, workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.render_context.json")
|
||||||
|
managedReportPath := oneArtifact(t, workspaceRoot, "reports", "hourly", "*.md")
|
||||||
|
assertFileContains(t, rawGeneratedTextPath, `"summary": " Storm chances increase through late morning. "`)
|
||||||
|
assertFileContains(t, validatedGeneratedTextPath, `"summary":"Storm chances increase through late morning."`)
|
||||||
|
assertFileContains(t, renderContextPath, `"Report": {`)
|
||||||
|
assertFileContains(t, renderContextPath, `"Title": "Hourly Report"`)
|
||||||
|
assertFileContains(t, renderContextPath, `"Modules": {`)
|
||||||
|
assertFileContains(t, renderContextPath, `"Collected": {`)
|
||||||
|
assertFileContains(t, renderContextPath, `"Derived": {`)
|
||||||
|
assertFileContains(t, managedReportPath, "# Hourly Report")
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunInspectGeneratedArtifacts(t *testing.T) {
|
func TestRunInspectGeneratedArtifacts(t *testing.T) {
|
||||||
server := dailyServer(t)
|
server := dailyServer(t)
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
@@ -527,14 +768,14 @@ func TestRunInspectGeneratedArtifacts(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run(generate) error = %v", err)
|
t.Fatalf("Run(generate) error = %v", err)
|
||||||
}
|
}
|
||||||
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.json"))
|
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("glob data package: %v", err)
|
t.Fatalf("glob data package: %v", err)
|
||||||
}
|
}
|
||||||
if len(dataPackageMatches) != 1 {
|
if len(dataPackageMatches) != 1 {
|
||||||
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
|
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
|
||||||
}
|
}
|
||||||
runID := strings.TrimSuffix(filepath.Base(dataPackageMatches[0]), ".data_package.json")
|
runID := strings.TrimSuffix(filepath.Base(dataPackageMatches[0]), ".data_package.yaml")
|
||||||
|
|
||||||
stdout.Reset()
|
stdout.Reset()
|
||||||
err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", configPath, "--limit", "1"}, &stdout, &stderr)
|
err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", configPath, "--limit", "1"}, &stdout, &stderr)
|
||||||
@@ -545,7 +786,8 @@ func TestRunInspectGeneratedArtifacts(t *testing.T) {
|
|||||||
t.Fatalf("inspect reports output missing run:\n%s", stdout.String())
|
t.Fatalf("inspect reports output missing run:\n%s", stdout.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, command := range []string{"metadata", "briefing", "data-package", "sources"} {
|
var sourcesOutput string
|
||||||
|
for _, command := range []string{"metadata", "modules", "data-package", "sources"} {
|
||||||
stdout.Reset()
|
stdout.Reset()
|
||||||
err = runner.Run(context.Background(), []string{"inspect", command, "--config", configPath, runID}, &stdout, &stderr)
|
err = runner.Run(context.Background(), []string{"inspect", command, "--config", configPath, runID}, &stdout, &stderr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -554,9 +796,12 @@ func TestRunInspectGeneratedArtifacts(t *testing.T) {
|
|||||||
if !strings.Contains(stdout.String(), runID) {
|
if !strings.Contains(stdout.String(), runID) {
|
||||||
t.Fatalf("inspect %s output missing run id:\n%s", command, stdout.String())
|
t.Fatalf("inspect %s output missing run id:\n%s", command, stdout.String())
|
||||||
}
|
}
|
||||||
|
if command == "sources" {
|
||||||
|
sourcesOutput = stdout.String()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !strings.Contains(stdout.String(), `"warnings"`) {
|
if !strings.Contains(sourcesOutput, `"name": "narrative"`) || strings.Contains(sourcesOutput, `"name": "daily"`) {
|
||||||
t.Fatalf("inspect sources output missing warnings:\n%s", stdout.String())
|
t.Fatalf("inspect sources output missing narrative source or has unexpected daily source:\n%s", sourcesOutput)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,7 +833,7 @@ func TestRunInspectRunCommandsParseRunIDAndConfig(t *testing.T) {
|
|||||||
t.Fatalf("write config: %v", err)
|
t.Fatalf("write config: %v", err)
|
||||||
}
|
}
|
||||||
runner := Runner{Clock: fixedClock()}
|
runner := Runner{Clock: fixedClock()}
|
||||||
commands := []string{"metadata", "briefing", "data-package", "prior", "sources"}
|
commands := []string{"metadata", "modules", "data-package", "prior", "sources"}
|
||||||
|
|
||||||
for _, command := range commands {
|
for _, command := range commands {
|
||||||
t.Run(command+" requires run id", func(t *testing.T) {
|
t.Run(command+" requires run id", func(t *testing.T) {
|
||||||
@@ -628,6 +873,7 @@ func TestResolveGenerateCommands(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{name: "daily", args: []string{"daily", "--date", "2026-05-29"}, want: app.ReportDaily},
|
{name: "daily", args: []string{"daily", "--date", "2026-05-29"}, want: app.ReportDaily},
|
||||||
{name: "tomorrow", args: []string{"tomorrow"}, want: app.ReportTomorrow},
|
{name: "tomorrow", args: []string{"tomorrow"}, want: app.ReportTomorrow},
|
||||||
|
{name: "hourly", args: []string{"hourly"}, want: app.ReportHourly},
|
||||||
{name: "three-day", args: []string{"three-day"}, want: app.ReportThreeDay},
|
{name: "three-day", args: []string{"three-day"}, want: app.ReportThreeDay},
|
||||||
{name: "weekend", args: []string{"weekend"}, want: app.ReportWeekend},
|
{name: "weekend", args: []string{"weekend"}, want: app.ReportWeekend},
|
||||||
{name: "storm", args: []string{"storm", "--start", "2026-05-29T18:00", "--end", "2026-05-30T06:00"}, want: app.ReportStorm},
|
{name: "storm", args: []string{"storm", "--start", "2026-05-29T18:00", "--end", "2026-05-30T06:00"}, want: app.ReportStorm},
|
||||||
@@ -646,6 +892,55 @@ func TestResolveGenerateCommands(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveGenerateHourlyAppliesSharedFlags(t *testing.T) {
|
||||||
|
runner := Runner{Clock: fixedClock()}
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
if err := os.WriteFile(configPath, []byte("weather_api:\n units: metric\n timezone: UTC\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := runner.resolveGenerate([]string{"hourly", "--config", configPath, "--units", "us", "--tz", "America/Chicago", "--out", "./hourly.md"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveGenerate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Report != app.ReportHourly {
|
||||||
|
t.Fatalf("Report = %q, want hourly", req.Report)
|
||||||
|
}
|
||||||
|
if req.Config.WeatherAPI.Units != "us" {
|
||||||
|
t.Fatalf("Units = %q, want us", req.Config.WeatherAPI.Units)
|
||||||
|
}
|
||||||
|
if req.Config.WeatherAPI.Timezone != "America/Chicago" {
|
||||||
|
t.Fatalf("Timezone = %q, want America/Chicago", req.Config.WeatherAPI.Timezone)
|
||||||
|
}
|
||||||
|
if req.OutputPath != "./hourly.md" {
|
||||||
|
t.Fatalf("OutputPath = %q, want ./hourly.md", req.OutputPath)
|
||||||
|
}
|
||||||
|
if !req.Date.IsZero() || !req.StormStart.IsZero() || !req.StormEnd.IsZero() {
|
||||||
|
t.Fatalf("date/storm bounds = %s/%s/%s, want unset for hourly", req.Date, req.StormStart, req.StormEnd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveGenerateHourlyRejectsDateAndStormBounds(t *testing.T) {
|
||||||
|
runner := Runner{Clock: fixedClock()}
|
||||||
|
|
||||||
|
for _, args := range [][]string{
|
||||||
|
{"hourly", "--date", "2026-05-29"},
|
||||||
|
{"hourly", "--start", "2026-05-29T18:00"},
|
||||||
|
{"hourly", "--end", "2026-05-29T20:00"},
|
||||||
|
{"hourly", "--hours", "6"},
|
||||||
|
{"hourly", "--duration", "6h"},
|
||||||
|
} {
|
||||||
|
_, err := runner.resolveGenerate(args)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("resolveGenerate(%v) error = nil, want flag error", args)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "flag provided but not defined") {
|
||||||
|
t.Fatalf("resolveGenerate(%v) error = %q, want undefined flag error", args, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveGenerateDailyDefaultsDateInConfiguredTimezone(t *testing.T) {
|
func TestResolveGenerateDailyDefaultsDateInConfiguredTimezone(t *testing.T) {
|
||||||
runner := Runner{Clock: fixedClock()}
|
runner := Runner{Clock: fixedClock()}
|
||||||
|
|
||||||
@@ -678,6 +973,19 @@ func TestResolveGenerateAppliesSharedFlags(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveGenerateRejectsRetiredHourlyCommand(t *testing.T) {
|
||||||
|
runner := Runner{Clock: fixedClock()}
|
||||||
|
retired := strings.Join([]string{"near", "term"}, "-")
|
||||||
|
|
||||||
|
_, err := runner.resolveGenerate([]string{retired})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("resolveGenerate() error = nil, want unknown report")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "unknown generate report") {
|
||||||
|
t.Fatalf("error = %q, want unknown generate report", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) {
|
func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) {
|
||||||
runner := Runner{Clock: fixedClock()}
|
runner := Runner{Clock: fixedClock()}
|
||||||
|
|
||||||
@@ -815,6 +1123,8 @@ func dailyServer(t *testing.T) *httptest.Server {
|
|||||||
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}}`))
|
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}}`))
|
||||||
case "/weatherstories/latest":
|
case "/weatherstories/latest":
|
||||||
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
|
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
|
||||||
|
case "/outlooks/convective":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`))
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
@@ -845,6 +1155,17 @@ func oneArtifact(t *testing.T, root string, parts ...string) string {
|
|||||||
return matches[0]
|
return matches[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assertFileContains(t *testing.T, path string, want string) {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), want) {
|
||||||
|
t.Fatalf("%s missing %q:\n%s", path, want, string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func writeFakeScriptorium(t *testing.T, dir string) string {
|
func writeFakeScriptorium(t *testing.T, dir string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
path := filepath.Join(dir, "scriptorium")
|
path := filepath.Join(dir, "scriptorium")
|
||||||
@@ -855,13 +1176,91 @@ if [ "$1" = "render" ]; then
|
|||||||
fi
|
fi
|
||||||
if [ "$1" = "run" ]; then
|
if [ "$1" = "run" ]; then
|
||||||
out=""
|
out=""
|
||||||
|
prompt=""
|
||||||
while [ "$#" -gt 0 ]; do
|
while [ "$#" -gt 0 ]; do
|
||||||
if [ "$1" = "--out" ]; then
|
if [ "$1" = "--out" ]; then
|
||||||
shift
|
shift
|
||||||
out="$1"
|
out="$1"
|
||||||
|
elif [ "$1" = "--prompt" ]; then
|
||||||
|
shift
|
||||||
|
prompt="$1"
|
||||||
fi
|
fi
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
|
if [ "$prompt" = "weather.tomorrow_generated_text" ]; then
|
||||||
|
cat > "$out" <<'JSON'
|
||||||
|
{
|
||||||
|
"summary": "Tomorrow starts with showers before improving.",
|
||||||
|
"forecast_discussion": [
|
||||||
|
"Morning showers should taper as drier air arrives.",
|
||||||
|
"Afternoon conditions trend quieter."
|
||||||
|
],
|
||||||
|
"precipitation_timing": "The best rain chance is during the morning."
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
printf 'wrote generated text\n' >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
printf '# Daily Report\n\nGenerated by fake scriptorium.\n' > "$out"
|
||||||
|
printf 'wrote report\n' >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
printf 'unexpected command\n' >&2
|
||||||
|
exit 1
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o700); err != nil {
|
||||||
|
t.Fatalf("write fake scriptorium: %v", err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeStructuredOutputScriptorium(t *testing.T, dir string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(dir, "scriptorium")
|
||||||
|
body := `#!/bin/sh
|
||||||
|
if [ "$1" = "render" ]; then
|
||||||
|
printf '{"ok":true,"argv":"%s"}' "$*"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ "$1" = "run" ]; then
|
||||||
|
out=""
|
||||||
|
prompt=""
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
if [ "$1" = "--out" ]; then
|
||||||
|
shift
|
||||||
|
out="$1"
|
||||||
|
elif [ "$1" = "--prompt" ]; then
|
||||||
|
shift
|
||||||
|
prompt="$1"
|
||||||
|
fi
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
if [ "$prompt" = "weather.hourly_generated_text" ]; then
|
||||||
|
cat > "$out" <<'JSON'
|
||||||
|
{
|
||||||
|
"summary": " Storm chances increase through late morning. ",
|
||||||
|
"forecast_discussion": "A front will keep the region unsettled.",
|
||||||
|
"precipitation_timing": "A cold front is moving into the region.",
|
||||||
|
"confidence": "Medium"
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
printf 'wrote generated text\n' >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ "$prompt" = "weather.tomorrow_generated_text" ]; then
|
||||||
|
cat > "$out" <<'JSON'
|
||||||
|
{
|
||||||
|
"summary": "Tomorrow starts with showers before improving.",
|
||||||
|
"forecast_discussion": [
|
||||||
|
"Morning showers should taper as drier air arrives.",
|
||||||
|
"Afternoon conditions trend quieter."
|
||||||
|
],
|
||||||
|
"precipitation_timing": "The best rain chance is during the morning."
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
printf 'wrote generated text\n' >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
printf '# Daily Report\n\nGenerated by fake scriptorium.\n' > "$out"
|
printf '# Daily Report\n\nGenerated by fake scriptorium.\n' > "$out"
|
||||||
printf 'wrote report\n' >&2
|
printf 'wrote report\n' >&2
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -2,24 +2,36 @@
|
|||||||
// precedence, and validation.
|
// precedence, and validation.
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
type MissingSourcePolicy string
|
type MissingSourcePolicy string
|
||||||
|
type NotifyFailurePolicy string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
MissingSourceError MissingSourcePolicy = "error"
|
MissingSourceError MissingSourcePolicy = "error"
|
||||||
MissingSourceWarn MissingSourcePolicy = "warn"
|
MissingSourceWarn MissingSourcePolicy = "warn"
|
||||||
MissingSourceNone MissingSourcePolicy = "none"
|
MissingSourceNone MissingSourcePolicy = "none"
|
||||||
|
|
||||||
|
NotifyFailureError NotifyFailurePolicy = "error"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
WeatherAPI WeatherAPIConfig `yaml:"weather_api"`
|
WeatherAPI WeatherAPIConfig `yaml:"weather_api"`
|
||||||
Location LocationConfig `yaml:"location"`
|
Location LocationConfig `yaml:"location"`
|
||||||
MissingSource MissingSourceConfig `yaml:"missing_source"`
|
Secrets SecretsConfig `yaml:"secrets"`
|
||||||
Scriptorium ScriptoriumConfig `yaml:"scriptorium"`
|
Notify NotifyConfig `yaml:"notify"`
|
||||||
Workspace WorkspaceConfig `yaml:"workspace"`
|
MissingSource MissingSourceConfig `yaml:"missing_source"`
|
||||||
Dayparts []DaypartConfig `yaml:"dayparts"`
|
Scriptorium ScriptoriumConfig `yaml:"scriptorium"`
|
||||||
RecentChange RecentChangeConfig `yaml:"recent_change"`
|
Workspace WorkspaceConfig `yaml:"workspace"`
|
||||||
|
Dayparts []DaypartConfig `yaml:"dayparts"`
|
||||||
|
RecentChange RecentChangeConfig `yaml:"recent_change"`
|
||||||
|
Reports map[string]ReportConfig `yaml:"reports"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WeatherAPIConfig struct {
|
type WeatherAPIConfig struct {
|
||||||
@@ -37,6 +49,26 @@ type LocationConfig struct {
|
|||||||
Region string `yaml:"region"`
|
Region string `yaml:"region"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SecretsConfig struct {
|
||||||
|
Directory string `yaml:"directory"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotifyConfig struct {
|
||||||
|
Distributor DistributorNotifyConfig `yaml:"distributor"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DistributorNotifyConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
Endpoint string `yaml:"endpoint"`
|
||||||
|
TokenEnv string `yaml:"token_env"`
|
||||||
|
Timeout time.Duration `yaml:"timeout"`
|
||||||
|
FailurePolicy NotifyFailurePolicy `yaml:"failure_policy"`
|
||||||
|
PipelineIDTemplate string `yaml:"pipeline_id_template"`
|
||||||
|
BundleIDTemplate string `yaml:"bundle_id_template"`
|
||||||
|
IdempotencyKeyTemplate string `yaml:"idempotency_key_template"`
|
||||||
|
ReportPathTemplates []string `yaml:"report_path_templates"`
|
||||||
|
}
|
||||||
|
|
||||||
type MissingSourceConfig struct {
|
type MissingSourceConfig struct {
|
||||||
Default MissingSourcePolicy `yaml:"default"`
|
Default MissingSourcePolicy `yaml:"default"`
|
||||||
Sources map[string]MissingSourcePolicy `yaml:"sources"`
|
Sources map[string]MissingSourcePolicy `yaml:"sources"`
|
||||||
@@ -51,11 +83,12 @@ type ScriptoriumConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type WorkspaceConfig struct {
|
type WorkspaceConfig struct {
|
||||||
Root string `yaml:"root"`
|
Root string `yaml:"root"`
|
||||||
SnapshotsDir string `yaml:"snapshots_dir"`
|
SnapshotsDir string `yaml:"snapshots_dir"`
|
||||||
ReportsDir string `yaml:"reports_dir"`
|
ReportsDir string `yaml:"reports_dir"`
|
||||||
DataPackagesDir string `yaml:"data_packages_dir"`
|
DataPackagesDir string `yaml:"data_packages_dir"`
|
||||||
PreflightDir string `yaml:"preflight_dir"`
|
PreflightDir string `yaml:"preflight_dir"`
|
||||||
|
NotificationsDir string `yaml:"notifications_dir"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DaypartConfig struct {
|
type DaypartConfig struct {
|
||||||
@@ -70,3 +103,71 @@ type RecentChangeConfig struct {
|
|||||||
WindGustMilesPerHour int `yaml:"wind_gust_miles_per_hour"`
|
WindGustMilesPerHour int `yaml:"wind_gust_miles_per_hour"`
|
||||||
PrecipTimingShiftMinutes int `yaml:"precip_timing_shift_minutes"`
|
PrecipTimingShiftMinutes int `yaml:"precip_timing_shift_minutes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ReportConfig struct {
|
||||||
|
DeterministicModules []ModuleConfigItem `yaml:"deterministic_modules"`
|
||||||
|
deterministicModulesSet bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModuleConfigItem struct {
|
||||||
|
ID module.ID `yaml:"id"`
|
||||||
|
Options any `yaml:"options,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ReportConfig) UnmarshalYAML(value *yaml.Node) error {
|
||||||
|
if value.Kind != yaml.MappingNode {
|
||||||
|
return fmt.Errorf("report 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 "deterministic_modules":
|
||||||
|
if err := node.Decode(&c.DeterministicModules); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.deterministicModulesSet = true
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown report entry field %q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ModuleConfigItem) UnmarshalYAML(value *yaml.Node) error {
|
||||||
|
switch value.Kind {
|
||||||
|
case yaml.ScalarNode:
|
||||||
|
if value.Value == "" {
|
||||||
|
return fmt.Errorf("module id is required")
|
||||||
|
}
|
||||||
|
m.ID = module.ID(value.Value)
|
||||||
|
return nil
|
||||||
|
case yaml.MappingNode:
|
||||||
|
var sawID bool
|
||||||
|
for i := 0; i < len(value.Content); i += 2 {
|
||||||
|
key := value.Content[i].Value
|
||||||
|
node := value.Content[i+1]
|
||||||
|
switch key {
|
||||||
|
case "id":
|
||||||
|
if err := node.Decode(&m.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sawID = true
|
||||||
|
case "options":
|
||||||
|
var options any
|
||||||
|
if err := node.Decode(&options); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.Options = options
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown module entry field %q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawID || m.ID == "" {
|
||||||
|
return fmt.Errorf("module id is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("module entry must be a string or mapping")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDefaults(t *testing.T) {
|
func TestDefaults(t *testing.T) {
|
||||||
@@ -26,6 +29,39 @@ func TestDefaults(t *testing.T) {
|
|||||||
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
|
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
|
||||||
t.Fatalf("Location = %#v, want home/Brentwood/St. Louis Metro", cfg.Location)
|
t.Fatalf("Location = %#v, want home/Brentwood/St. Louis Metro", cfg.Location)
|
||||||
}
|
}
|
||||||
|
if cfg.Secrets.Directory != "" {
|
||||||
|
t.Fatalf("Secrets.Directory = %q, want empty", cfg.Secrets.Directory)
|
||||||
|
}
|
||||||
|
if cfg.Notify.Distributor.Enabled {
|
||||||
|
t.Fatalf("Notify.Distributor.Enabled = true, want false")
|
||||||
|
}
|
||||||
|
if cfg.Notify.Distributor.Endpoint != "https://distributor.example.com" {
|
||||||
|
t.Fatalf("Notify.Distributor.Endpoint = %q, want default endpoint", cfg.Notify.Distributor.Endpoint)
|
||||||
|
}
|
||||||
|
if cfg.Notify.Distributor.TokenEnv != "DISTRIBUTOR_UPLOAD_TOKEN" {
|
||||||
|
t.Fatalf("Notify.Distributor.TokenEnv = %q, want DISTRIBUTOR_UPLOAD_TOKEN", cfg.Notify.Distributor.TokenEnv)
|
||||||
|
}
|
||||||
|
if cfg.Notify.Distributor.Timeout != 30*time.Second {
|
||||||
|
t.Fatalf("Notify.Distributor.Timeout = %s, want 30s", cfg.Notify.Distributor.Timeout)
|
||||||
|
}
|
||||||
|
if cfg.Notify.Distributor.FailurePolicy != NotifyFailureError {
|
||||||
|
t.Fatalf("Notify.Distributor.FailurePolicy = %q, want error", cfg.Notify.Distributor.FailurePolicy)
|
||||||
|
}
|
||||||
|
if cfg.Notify.Distributor.PipelineIDTemplate != "" {
|
||||||
|
t.Fatalf("Notify.Distributor.PipelineIDTemplate = %q, want empty", cfg.Notify.Distributor.PipelineIDTemplate)
|
||||||
|
}
|
||||||
|
if cfg.Notify.Distributor.BundleIDTemplate != "weatherreporter.{location_id}.{report_id}" {
|
||||||
|
t.Fatalf("Notify.Distributor.BundleIDTemplate = %q, want default", cfg.Notify.Distributor.BundleIDTemplate)
|
||||||
|
}
|
||||||
|
if cfg.Notify.Distributor.IdempotencyKeyTemplate != "{bundle_id}.{run_id}" {
|
||||||
|
t.Fatalf("Notify.Distributor.IdempotencyKeyTemplate = %q, want default", cfg.Notify.Distributor.IdempotencyKeyTemplate)
|
||||||
|
}
|
||||||
|
wantReportPaths := []string{
|
||||||
|
"{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md",
|
||||||
|
}
|
||||||
|
if strings.Join(cfg.Notify.Distributor.ReportPathTemplates, "\n") != strings.Join(wantReportPaths, "\n") {
|
||||||
|
t.Fatalf("Notify.Distributor.ReportPathTemplates = %#v, want %#v", cfg.Notify.Distributor.ReportPathTemplates, wantReportPaths)
|
||||||
|
}
|
||||||
if cfg.MissingSource.Default != MissingSourceWarn {
|
if cfg.MissingSource.Default != MissingSourceWarn {
|
||||||
t.Fatalf("MissingSource.Default = %q, want warn", cfg.MissingSource.Default)
|
t.Fatalf("MissingSource.Default = %q, want warn", cfg.MissingSource.Default)
|
||||||
}
|
}
|
||||||
@@ -49,6 +85,28 @@ func TestLoadExampleConfig(t *testing.T) {
|
|||||||
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
|
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
|
||||||
t.Fatalf("Location = %#v, want example location", cfg.Location)
|
t.Fatalf("Location = %#v, want example location", cfg.Location)
|
||||||
}
|
}
|
||||||
|
if cfg.Notify.Distributor.PipelineIDTemplate != "weatherreporter.{report_id}" {
|
||||||
|
t.Fatalf("PipelineIDTemplate = %q, want example pipeline template", cfg.Notify.Distributor.PipelineIDTemplate)
|
||||||
|
}
|
||||||
|
if len(cfg.Notify.Distributor.ReportPathTemplates) != 1 {
|
||||||
|
t.Fatalf("ReportPathTemplates = %#v, want example archive path", cfg.Notify.Distributor.ReportPathTemplates)
|
||||||
|
}
|
||||||
|
overrides := cfg.ReportModuleOverrides()
|
||||||
|
hourly := overrides[report.Hourly]
|
||||||
|
if len(hourly) != 9 {
|
||||||
|
t.Fatalf("hourly example override length = %d, want 9", len(hourly))
|
||||||
|
}
|
||||||
|
if hourly[0].ID != module.Metadata ||
|
||||||
|
hourly[1].ID != module.CurrentConditions ||
|
||||||
|
hourly[2].ID != module.HourlyForecast ||
|
||||||
|
hourly[3].ID != module.PrecipTiming ||
|
||||||
|
hourly[4].ID != module.AlertDigest ||
|
||||||
|
hourly[5].ID != module.SPCConvectiveOutlooks ||
|
||||||
|
hourly[6].ID != module.AreaForecastDiscussion ||
|
||||||
|
hourly[7].ID != module.SPCConvectiveDiscussion ||
|
||||||
|
hourly[8].ID != module.WeatherStory {
|
||||||
|
t.Fatalf("hourly example override = %#v, want configured module order", hourly)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadMinimalExampleConfig(t *testing.T) {
|
func TestLoadMinimalExampleConfig(t *testing.T) {
|
||||||
@@ -69,11 +127,234 @@ func TestLoadMinimalExampleConfig(t *testing.T) {
|
|||||||
if cfg.Workspace.Root != "workspace" {
|
if cfg.Workspace.Root != "workspace" {
|
||||||
t.Fatalf("Workspace.Root = %q, want default workspace", cfg.Workspace.Root)
|
t.Fatalf("Workspace.Root = %q, want default workspace", cfg.Workspace.Root)
|
||||||
}
|
}
|
||||||
|
if cfg.Workspace.NotificationsDir != "notifications" {
|
||||||
|
t.Fatalf("Workspace.NotificationsDir = %q, want notifications", cfg.Workspace.NotificationsDir)
|
||||||
|
}
|
||||||
if cfg.Location.Name != "Brentwood" {
|
if cfg.Location.Name != "Brentwood" {
|
||||||
t.Fatalf("Location.Name = %q, want default Brentwood", cfg.Location.Name)
|
t.Fatalf("Location.Name = %q, want default Brentwood", cfg.Location.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadReportModuleOverrides(t *testing.T) {
|
||||||
|
path := writeConfig(t, `
|
||||||
|
reports:
|
||||||
|
daily:
|
||||||
|
deterministic_modules:
|
||||||
|
- metadata
|
||||||
|
- current_conditions
|
||||||
|
- alert_digest
|
||||||
|
- spc_convective_outlooks
|
||||||
|
- id: area_forecast_discussion
|
||||||
|
options:
|
||||||
|
sections:
|
||||||
|
- short_term
|
||||||
|
- spc_convective_discussion
|
||||||
|
`)
|
||||||
|
|
||||||
|
cfg, err := LoadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadFile() error = %v", err)
|
||||||
|
}
|
||||||
|
overrides := cfg.ReportModuleOverrides()
|
||||||
|
items := overrides[report.DailyToday]
|
||||||
|
if len(items) != 6 {
|
||||||
|
t.Fatalf("daily override length = %d, want 6", len(items))
|
||||||
|
}
|
||||||
|
if items[0].ID != module.Metadata ||
|
||||||
|
items[1].ID != module.CurrentConditions ||
|
||||||
|
items[2].ID != module.AlertDigest ||
|
||||||
|
items[3].ID != module.SPCConvectiveOutlooks ||
|
||||||
|
items[4].ID != module.AreaForecastDiscussion ||
|
||||||
|
items[5].ID != module.SPCConvectiveDiscussion {
|
||||||
|
t.Fatalf("daily override = %#v, want configured module order", items)
|
||||||
|
}
|
||||||
|
options, ok := items[4].Options.(module.AreaForecastDiscussionOptions)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", items[4].Options)
|
||||||
|
}
|
||||||
|
if strings.Join(options.Sections, ",") != "short_term" {
|
||||||
|
t.Fatalf("AFD sections = %#v, want short_term", options.Sections)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadHourlyReportModuleOverrides(t *testing.T) {
|
||||||
|
path := writeConfig(t, `
|
||||||
|
reports:
|
||||||
|
hourly:
|
||||||
|
deterministic_modules:
|
||||||
|
- metadata
|
||||||
|
- hourly_forecast
|
||||||
|
- precip_timing
|
||||||
|
- alert_digest
|
||||||
|
`)
|
||||||
|
|
||||||
|
cfg, err := LoadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadFile() error = %v", err)
|
||||||
|
}
|
||||||
|
overrides := cfg.ReportModuleOverrides()
|
||||||
|
items := overrides[report.Hourly]
|
||||||
|
if len(items) != 4 {
|
||||||
|
t.Fatalf("hourly override length = %d, want 4", len(items))
|
||||||
|
}
|
||||||
|
if items[0].ID != module.Metadata ||
|
||||||
|
items[1].ID != module.HourlyForecast ||
|
||||||
|
items[2].ID != module.PrecipTiming ||
|
||||||
|
items[3].ID != module.AlertDigest {
|
||||||
|
t.Fatalf("hourly override = %#v, want configured module order", items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReportModuleOverrideValidation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
yaml string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "UnknownReport",
|
||||||
|
yaml: `
|
||||||
|
reports:
|
||||||
|
moon:
|
||||||
|
deterministic_modules:
|
||||||
|
- metadata
|
||||||
|
`,
|
||||||
|
wantErr: "reports.moon",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UnknownModule",
|
||||||
|
yaml: `
|
||||||
|
reports:
|
||||||
|
daily:
|
||||||
|
deterministic_modules:
|
||||||
|
- missing_module
|
||||||
|
`,
|
||||||
|
wantErr: `unknown module "missing_module"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DuplicateModule",
|
||||||
|
yaml: `
|
||||||
|
reports:
|
||||||
|
daily:
|
||||||
|
deterministic_modules:
|
||||||
|
- metadata
|
||||||
|
- metadata
|
||||||
|
`,
|
||||||
|
wantErr: `duplicate module "metadata"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IncompatibleModule",
|
||||||
|
yaml: `
|
||||||
|
reports:
|
||||||
|
daily:
|
||||||
|
deterministic_modules:
|
||||||
|
- tomorrow_planning
|
||||||
|
`,
|
||||||
|
wantErr: `not compatible with report "daily_today"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "RemovedPlaceholderModule",
|
||||||
|
yaml: `
|
||||||
|
reports:
|
||||||
|
daily:
|
||||||
|
deterministic_modules:
|
||||||
|
- forecast_delta
|
||||||
|
`,
|
||||||
|
wantErr: `unknown module "forecast_delta"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "RetiredTomorrowReportID",
|
||||||
|
yaml: `
|
||||||
|
reports:
|
||||||
|
daily_tomorrow:
|
||||||
|
deterministic_modules:
|
||||||
|
- metadata
|
||||||
|
`,
|
||||||
|
wantErr: "reports.daily_tomorrow is not a known report",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "InvalidOptions",
|
||||||
|
yaml: `
|
||||||
|
reports:
|
||||||
|
daily:
|
||||||
|
deterministic_modules:
|
||||||
|
- id: metadata
|
||||||
|
options:
|
||||||
|
sections:
|
||||||
|
- short_term
|
||||||
|
`,
|
||||||
|
wantErr: "options are invalid",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DuplicateReportAlias",
|
||||||
|
yaml: `
|
||||||
|
reports:
|
||||||
|
daily:
|
||||||
|
deterministic_modules:
|
||||||
|
- metadata
|
||||||
|
daily_today:
|
||||||
|
deterministic_modules:
|
||||||
|
- current_conditions
|
||||||
|
`,
|
||||||
|
wantErr: "duplicates report override",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HourlyIncompatibleDailyModule",
|
||||||
|
yaml: `
|
||||||
|
reports:
|
||||||
|
hourly:
|
||||||
|
deterministic_modules:
|
||||||
|
- derived_daily_summary
|
||||||
|
`,
|
||||||
|
wantErr: `not compatible with report "hourly"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UnknownReportField",
|
||||||
|
yaml: `
|
||||||
|
reports:
|
||||||
|
daily:
|
||||||
|
modules:
|
||||||
|
- metadata
|
||||||
|
`,
|
||||||
|
wantErr: `unknown report entry field "modules"`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := LoadFile(writeConfig(t, tt.yaml))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("LoadFile() error = nil, want validation error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
|
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReportModuleOverrideRejectsRetiredHourlyKeys(t *testing.T) {
|
||||||
|
for _, key := range []string{
|
||||||
|
strings.Join([]string{"near", "term"}, "_"),
|
||||||
|
strings.Join([]string{"near", "term"}, "-"),
|
||||||
|
} {
|
||||||
|
t.Run(key, func(t *testing.T) {
|
||||||
|
_, err := LoadFile(writeConfig(t, `
|
||||||
|
reports:
|
||||||
|
`+key+`:
|
||||||
|
deterministic_modules:
|
||||||
|
- metadata
|
||||||
|
`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("LoadFile() error = nil, want unknown report")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "is not a known report") {
|
||||||
|
t.Fatalf("error = %q, want unknown report", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExplicitMissingConfigReturnsError(t *testing.T) {
|
func TestExplicitMissingConfigReturnsError(t *testing.T) {
|
||||||
_, err := LoadFile(filepath.Join(t.TempDir(), "missing.yml"))
|
_, err := LoadFile(filepath.Join(t.TempDir(), "missing.yml"))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -84,6 +365,15 @@ func TestExplicitMissingConfigReturnsError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeConfig(t *testing.T, contents string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||||
|
t.Fatalf("write config fixture: %v", err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
func TestInvalidConfigProducesActionableError(t *testing.T) {
|
func TestInvalidConfigProducesActionableError(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "config.yml")
|
path := filepath.Join(dir, "config.yml")
|
||||||
@@ -112,3 +402,475 @@ func TestLoadAppliesOverrides(t *testing.T) {
|
|||||||
t.Fatalf("Timezone = %q, want +09:30", cfg.WeatherAPI.Timezone)
|
t.Fatalf("Timezone = %q, want +09:30", cfg.WeatherAPI.Timezone)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDisabledDistributorNotifyAcceptsOmittedFields(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yml")
|
||||||
|
if err := os.WriteFile(path, []byte("notify:\n distributor:\n enabled: false\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write config fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadFile() error = %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Notify.Distributor.Enabled {
|
||||||
|
t.Fatalf("Notify.Distributor.Enabled = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnabledDistributorNotifyValidation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*Config)
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Endpoint",
|
||||||
|
mutate: func(cfg *Config) {
|
||||||
|
cfg.Notify.Distributor.Endpoint = "distributor.example.com"
|
||||||
|
},
|
||||||
|
wantErr: "notify.distributor.endpoint",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TokenEnvEmpty",
|
||||||
|
mutate: func(cfg *Config) {
|
||||||
|
cfg.Notify.Distributor.TokenEnv = ""
|
||||||
|
},
|
||||||
|
wantErr: "notify.distributor.token_env",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TokenEnvInvalid",
|
||||||
|
mutate: func(cfg *Config) {
|
||||||
|
cfg.Notify.Distributor.TokenEnv = "1TOKEN"
|
||||||
|
},
|
||||||
|
wantErr: "notify.distributor.token_env",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Timeout",
|
||||||
|
mutate: func(cfg *Config) {
|
||||||
|
cfg.Notify.Distributor.Timeout = 0
|
||||||
|
},
|
||||||
|
wantErr: "notify.distributor.timeout",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "FailurePolicy",
|
||||||
|
mutate: func(cfg *Config) {
|
||||||
|
cfg.Notify.Distributor.FailurePolicy = "warn"
|
||||||
|
},
|
||||||
|
wantErr: "notify.distributor.failure_policy",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PipelineTemplateEmpty",
|
||||||
|
mutate: func(cfg *Config) {
|
||||||
|
cfg.Notify.Distributor.PipelineIDTemplate = ""
|
||||||
|
},
|
||||||
|
wantErr: "notify.distributor.pipeline_id_template",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PipelineTemplateUnknown",
|
||||||
|
mutate: func(cfg *Config) {
|
||||||
|
cfg.Notify.Distributor.PipelineIDTemplate = "{unknown}"
|
||||||
|
},
|
||||||
|
wantErr: "notify.distributor.pipeline_id_template",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PipelineTemplateRenderedEmpty",
|
||||||
|
mutate: func(cfg *Config) {
|
||||||
|
cfg.Notify.Distributor.PipelineIDTemplate = " "
|
||||||
|
},
|
||||||
|
wantErr: "notify.distributor.pipeline_id_template",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "BundleTemplate",
|
||||||
|
mutate: func(cfg *Config) {
|
||||||
|
cfg.Notify.Distributor.BundleIDTemplate = "{unknown}"
|
||||||
|
},
|
||||||
|
wantErr: "notify.distributor.bundle_id_template",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IdempotencyTemplate",
|
||||||
|
mutate: func(cfg *Config) {
|
||||||
|
cfg.Notify.Distributor.IdempotencyKeyTemplate = "{unknown}"
|
||||||
|
},
|
||||||
|
wantErr: "notify.distributor.idempotency_key_template",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ReportPathTemplatesEmpty",
|
||||||
|
mutate: func(cfg *Config) {
|
||||||
|
cfg.Notify.Distributor.ReportPathTemplates = nil
|
||||||
|
},
|
||||||
|
wantErr: "notify.distributor.report_path_templates",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ReportPathTemplateUnknown",
|
||||||
|
mutate: func(cfg *Config) {
|
||||||
|
cfg.Notify.Distributor.ReportPathTemplates = []string{"{unknown}"}
|
||||||
|
},
|
||||||
|
wantErr: "notify.distributor.report_path_templates",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ReportPathTemplateInvalidPath",
|
||||||
|
mutate: func(cfg *Config) {
|
||||||
|
cfg.Notify.Distributor.ReportPathTemplates = []string{"/{batch_output_name}"}
|
||||||
|
},
|
||||||
|
wantErr: "notify.distributor.report_path_templates",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ReportPathTemplateDuplicatePath",
|
||||||
|
mutate: func(cfg *Config) {
|
||||||
|
cfg.Notify.Distributor.ReportPathTemplates = []string{"latest.md", "latest.md"}
|
||||||
|
},
|
||||||
|
wantErr: "notify.distributor.report_path_templates",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cfg := Defaults()
|
||||||
|
cfg.Notify.Distributor.Enabled = true
|
||||||
|
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
|
||||||
|
tt.mutate(&cfg)
|
||||||
|
|
||||||
|
err := Validate(cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Validate() error = nil, want error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
|
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDistributorTemplateRendering(t *testing.T) {
|
||||||
|
values := DistributorTemplateValues{
|
||||||
|
LocationID: "home",
|
||||||
|
ReportID: "daily",
|
||||||
|
RunID: "20260607T120000Z",
|
||||||
|
ArtifactGroup: "daily",
|
||||||
|
BatchOutputName: "daily.md",
|
||||||
|
ValidStartDate: "2026-06-07",
|
||||||
|
ValidEndDate: "2026-06-08",
|
||||||
|
ValidStartTime: "1800",
|
||||||
|
ValidEndTime: "0600",
|
||||||
|
ValidStartStamp: "2026-06-07T1800",
|
||||||
|
ValidEndStamp: "2026-06-08T0600",
|
||||||
|
BundleID: "weatherreporter.home.daily",
|
||||||
|
}
|
||||||
|
|
||||||
|
bundleID, err := RenderDistributorBundleID("weatherreporter.{location_id}.{report_id}", values)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderDistributorBundleID() error = %v", err)
|
||||||
|
}
|
||||||
|
if bundleID != "weatherreporter.home.daily" {
|
||||||
|
t.Fatalf("bundleID = %q, want rendered value", bundleID)
|
||||||
|
}
|
||||||
|
|
||||||
|
pipelineID, err := RenderDistributorPipelineID("weatherreporter.{artifact_group}.{bundle_id}", values)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderDistributorPipelineID() error = %v", err)
|
||||||
|
}
|
||||||
|
if pipelineID != "weatherreporter.daily.weatherreporter.home.daily" {
|
||||||
|
t.Fatalf("pipelineID = %q, want rendered pipeline ID", pipelineID)
|
||||||
|
}
|
||||||
|
|
||||||
|
idempotencyKey, err := RenderDistributorIdempotencyKey("{bundle_id}.{run_id}", values)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderDistributorIdempotencyKey() error = %v", err)
|
||||||
|
}
|
||||||
|
if idempotencyKey != "weatherreporter.home.daily.20260607T120000Z" {
|
||||||
|
t.Fatalf("idempotencyKey = %q, want rendered run key", idempotencyKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
reportPaths, err := RenderDistributorReportPaths([]string{
|
||||||
|
"{valid_start_date}/{artifact_group}/{valid_start_stamp}-{valid_end_stamp}-{run_id}.md",
|
||||||
|
"{valid_start_date}/{artifact_group}/latest.md",
|
||||||
|
}, values)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderDistributorReportPaths() error = %v", err)
|
||||||
|
}
|
||||||
|
wantPaths := []string{
|
||||||
|
"2026-06-07/daily/2026-06-07T1800-2026-06-08T0600-20260607T120000Z.md",
|
||||||
|
"2026-06-07/daily/latest.md",
|
||||||
|
}
|
||||||
|
if strings.Join(reportPaths, "\n") != strings.Join(wantPaths, "\n") {
|
||||||
|
t.Fatalf("reportPaths = %#v, want %#v", reportPaths, wantPaths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDistributorTemplateRejectsUnknownAndMalformedVariables(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
template string
|
||||||
|
}{
|
||||||
|
{name: "Unknown", template: "{unknown}"},
|
||||||
|
{name: "Unclosed", template: "{location_id"},
|
||||||
|
{name: "Unopened", template: "location_id}"},
|
||||||
|
{name: "Empty", template: "{}"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := RenderDistributorBundleID(tt.template, DistributorTemplateValues{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("RenderDistributorBundleID() error = nil, want error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDistributorReportPathValidation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{name: "Simple", path: "daily.md", ok: true},
|
||||||
|
{name: "Nested", path: "reports/daily.md", ok: true},
|
||||||
|
{name: "Empty", path: "", ok: false},
|
||||||
|
{name: "Absolute", path: "/reports/daily.md", ok: false},
|
||||||
|
{name: "WindowsAbsolute", path: "C:/reports/daily.md", ok: false},
|
||||||
|
{name: "Backslash", path: `reports\daily.md`, ok: false},
|
||||||
|
{name: "CurrentSegment", path: "reports/./daily.md", ok: false},
|
||||||
|
{name: "ParentSegment", path: "reports/../daily.md", ok: false},
|
||||||
|
{name: "EmptySegment", path: "reports//daily.md", ok: false},
|
||||||
|
{name: "Manifest", path: "reports/manifest.json", ok: false},
|
||||||
|
{name: "DistributorMetadata", path: "reports/.distributor.json", ok: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := ValidateDistributorReportPath("test.path", tt.path)
|
||||||
|
if tt.ok && err != nil {
|
||||||
|
t.Fatalf("ValidateDistributorReportPath() error = %v", err)
|
||||||
|
}
|
||||||
|
if !tt.ok && err == nil {
|
||||||
|
t.Fatal("ValidateDistributorReportPath() error = nil, want error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDistributorReportPathRenderingRejectsInvalidValues(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
batchOutputName string
|
||||||
|
}{
|
||||||
|
{name: "Absolute", batchOutputName: "/daily.md"},
|
||||||
|
{name: "Backslash", batchOutputName: `reports\daily.md`},
|
||||||
|
{name: "CurrentSegment", batchOutputName: "./daily.md"},
|
||||||
|
{name: "ParentSegment", batchOutputName: "../daily.md"},
|
||||||
|
{name: "EmptySegment", batchOutputName: "reports//daily.md"},
|
||||||
|
{name: "Manifest", batchOutputName: "manifest.json"},
|
||||||
|
{name: "DistributorMetadata", batchOutputName: ".distributor.json"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := RenderDistributorReportPaths([]string{"{batch_output_name}"}, DistributorTemplateValues{
|
||||||
|
BatchOutputName: tt.batchOutputName,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("RenderDistributorReportPaths() error = nil, want error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadFileLoadsSecretsBeforeReturningNotifyConfig(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
secretsDir := filepath.Join(dir, "secrets")
|
||||||
|
if err := os.Mkdir(secretsDir, 0o700); err != nil {
|
||||||
|
t.Fatalf("create secrets directory: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(secretsDir, "DISTRIBUTOR_UPLOAD_TOKEN"), []byte("loaded-token"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write secret: %v", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "config.yml")
|
||||||
|
configYAML := "secrets:\n" +
|
||||||
|
" directory: " + secretsDir + "\n" +
|
||||||
|
"notify:\n" +
|
||||||
|
" distributor:\n" +
|
||||||
|
" enabled: true\n" +
|
||||||
|
" pipeline_id_template: weatherreporter.{report_id}\n"
|
||||||
|
if err := os.WriteFile(path, []byte(configYAML), 0o600); err != nil {
|
||||||
|
t.Fatalf("write config fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("DISTRIBUTOR_UPLOAD_TOKEN", "")
|
||||||
|
cfg, err := LoadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadFile() error = %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Notify.Distributor.TokenEnv != "DISTRIBUTOR_UPLOAD_TOKEN" {
|
||||||
|
t.Fatalf("TokenEnv = %q, want DISTRIBUTOR_UPLOAD_TOKEN", cfg.Notify.Distributor.TokenEnv)
|
||||||
|
}
|
||||||
|
if got := os.Getenv(cfg.Notify.Distributor.TokenEnv); got != "loaded-token" {
|
||||||
|
t.Fatalf("environment value = %q, want loaded-token", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSecretsDisabledLeavesEnvironmentUnchanged(t *testing.T) {
|
||||||
|
t.Setenv("WEATHERREPORTER_DISABLED_SECRET", "original")
|
||||||
|
|
||||||
|
if err := loadSecrets(SecretsConfig{}); err != nil {
|
||||||
|
t.Fatalf("loadSecrets() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := os.Getenv("WEATHERREPORTER_DISABLED_SECRET"); got != "original" {
|
||||||
|
t.Fatalf("environment value = %q, want original", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadFileLoadsSecretsDirectory(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
secretsDir := filepath.Join(dir, "secrets")
|
||||||
|
if err := os.Mkdir(secretsDir, 0o700); err != nil {
|
||||||
|
t.Fatalf("create secrets directory: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(secretsDir, "WEATHERREPORTER_SECRET"), []byte("from-file"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write secret: %v", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "config.yml")
|
||||||
|
if err := os.WriteFile(path, []byte("secrets:\n directory: "+secretsDir+"\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write config fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("WEATHERREPORTER_SECRET", "")
|
||||||
|
if _, err := LoadFile(path); err != nil {
|
||||||
|
t.Fatalf("LoadFile() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := os.Getenv("WEATHERREPORTER_SECRET"); got != "from-file" {
|
||||||
|
t.Fatalf("environment value = %q, want from-file", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSecretsOverwritesExistingEnvironment(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "WEATHERREPORTER_SECRET"), []byte("from-file"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write secret: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("WEATHERREPORTER_SECRET", "existing")
|
||||||
|
|
||||||
|
if err := loadSecrets(SecretsConfig{Directory: dir}); err != nil {
|
||||||
|
t.Fatalf("loadSecrets() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := os.Getenv("WEATHERREPORTER_SECRET"); got != "from-file" {
|
||||||
|
t.Fatalf("environment value = %q, want from-file", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSecretsTrimsOneTrailingLineEnding(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "LF", input: "value\n", want: "value"},
|
||||||
|
{name: "CRLF", input: "value\r\n", want: "value"},
|
||||||
|
{name: "TwoLF", input: "value\n\n", want: "value\n"},
|
||||||
|
{name: "LoneCR", input: "value\r", want: "value\r"},
|
||||||
|
{name: "NoNewline", input: "value", want: "value"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "WEATHERREPORTER_SECRET"), []byte(tt.input), 0o600); err != nil {
|
||||||
|
t.Fatalf("write secret: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("WEATHERREPORTER_SECRET", "")
|
||||||
|
|
||||||
|
if err := loadSecrets(SecretsConfig{Directory: dir}); err != nil {
|
||||||
|
t.Fatalf("loadSecrets() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := os.Getenv("WEATHERREPORTER_SECRET"); got != tt.want {
|
||||||
|
t.Fatalf("environment value = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSecretsRejectsInvalidDirectoryEntries(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setup func(t *testing.T, dir string)
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "InvalidFilename",
|
||||||
|
setup: func(t *testing.T, dir string) {
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "1INVALID"), []byte("secret-value"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write invalid secret: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
wantErr: "invalid environment variable name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Subdirectory",
|
||||||
|
setup: func(t *testing.T, dir string) {
|
||||||
|
if err := os.Mkdir(filepath.Join(dir, "SUBDIR"), 0o700); err != nil {
|
||||||
|
t.Fatalf("create subdirectory: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
wantErr: "not a directory",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Symlink",
|
||||||
|
setup: func(t *testing.T, dir string) {
|
||||||
|
target := filepath.Join(dir, "TARGET")
|
||||||
|
if err := os.WriteFile(target, []byte("secret-value"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write target: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(target, filepath.Join(dir, "SYMLINK")); err != nil {
|
||||||
|
t.Fatalf("create symlink: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
wantErr: "not a symlink",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Unreadable",
|
||||||
|
setup: func(t *testing.T, dir string) {
|
||||||
|
path := filepath.Join(dir, "UNREADABLE")
|
||||||
|
if err := os.WriteFile(path, []byte("secret-value"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write unreadable secret: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(path, 0o000); err != nil {
|
||||||
|
t.Fatalf("chmod unreadable secret: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = os.Chmod(path, 0o600)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
wantErr: "read secret file",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
tt.setup(t, dir)
|
||||||
|
|
||||||
|
err := loadSecrets(SecretsConfig{Directory: dir})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("loadSecrets() error = nil, want error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
|
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "secret-value") {
|
||||||
|
t.Fatalf("error = %q, want no secret value", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSecretsRejectsMissingDirectory(t *testing.T) {
|
||||||
|
err := loadSecrets(SecretsConfig{Directory: filepath.Join(t.TempDir(), "missing")})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("loadSecrets() error = nil, want missing directory error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "read secrets directory") {
|
||||||
|
t.Fatalf("error = %q, want read secrets directory context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,24 @@ func Defaults() Config {
|
|||||||
Name: "Brentwood",
|
Name: "Brentwood",
|
||||||
Region: "St. Louis Metro",
|
Region: "St. Louis Metro",
|
||||||
},
|
},
|
||||||
|
Secrets: SecretsConfig{
|
||||||
|
Directory: "",
|
||||||
|
},
|
||||||
|
Notify: NotifyConfig{
|
||||||
|
Distributor: DistributorNotifyConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Endpoint: "https://distributor.example.com",
|
||||||
|
TokenEnv: "DISTRIBUTOR_UPLOAD_TOKEN",
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
FailurePolicy: NotifyFailureError,
|
||||||
|
PipelineIDTemplate: "",
|
||||||
|
BundleIDTemplate: "weatherreporter.{location_id}.{report_id}",
|
||||||
|
IdempotencyKeyTemplate: "{bundle_id}.{run_id}",
|
||||||
|
ReportPathTemplates: []string{
|
||||||
|
"{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
MissingSource: MissingSourceConfig{
|
MissingSource: MissingSourceConfig{
|
||||||
Default: MissingSourceWarn,
|
Default: MissingSourceWarn,
|
||||||
Sources: map[string]MissingSourcePolicy{},
|
Sources: map[string]MissingSourcePolicy{},
|
||||||
@@ -27,11 +45,12 @@ func Defaults() Config {
|
|||||||
Timeout: 2 * time.Minute,
|
Timeout: 2 * time.Minute,
|
||||||
},
|
},
|
||||||
Workspace: WorkspaceConfig{
|
Workspace: WorkspaceConfig{
|
||||||
Root: "workspace",
|
Root: "workspace",
|
||||||
SnapshotsDir: "snapshots",
|
SnapshotsDir: "snapshots",
|
||||||
ReportsDir: "reports",
|
ReportsDir: "reports",
|
||||||
DataPackagesDir: "data-packages",
|
DataPackagesDir: "data-packages",
|
||||||
PreflightDir: "preflight",
|
PreflightDir: "preflight",
|
||||||
|
NotificationsDir: "notifications",
|
||||||
},
|
},
|
||||||
Dayparts: []DaypartConfig{
|
Dayparts: []DaypartConfig{
|
||||||
{Name: "overnight", Start: "00:00", End: "06:00"},
|
{Name: "overnight", Start: "00:00", End: "06:00"},
|
||||||
@@ -46,5 +65,6 @@ func Defaults() Config {
|
|||||||
WindGustMilesPerHour: 10,
|
WindGustMilesPerHour: 10,
|
||||||
PrecipTimingShiftMinutes: 120,
|
PrecipTimingShiftMinutes: 120,
|
||||||
},
|
},
|
||||||
|
Reports: map[string]ReportConfig{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ func Load(opts LoadOptions) (Config, error) {
|
|||||||
cfg.WeatherAPI.Timezone = opts.Timezone
|
cfg.WeatherAPI.Timezone = opts.Timezone
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := normalizeReportModules(&cfg); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := loadSecrets(cfg.Secrets); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
if err := Validate(cfg); err != nil {
|
if err := Validate(cfg); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
@@ -57,5 +65,8 @@ func mergeFile(cfg *Config, path string) error {
|
|||||||
if cfg.MissingSource.Sources == nil {
|
if cfg.MissingSource.Sources == nil {
|
||||||
cfg.MissingSource.Sources = map[string]MissingSourcePolicy{}
|
cfg.MissingSource.Sources = map[string]MissingSourcePolicy{}
|
||||||
}
|
}
|
||||||
|
if cfg.Reports == nil {
|
||||||
|
cfg.Reports = map[string]ReportConfig{}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
201
internal/config/notify_templates.go
Normal file
201
internal/config/notify_templates.go
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DistributorTemplateValues struct {
|
||||||
|
LocationID string
|
||||||
|
ReportID string
|
||||||
|
RunID string
|
||||||
|
ArtifactGroup string
|
||||||
|
BatchOutputName string
|
||||||
|
ValidStartDate string
|
||||||
|
ValidEndDate string
|
||||||
|
ValidStartTime string
|
||||||
|
ValidEndTime string
|
||||||
|
ValidStartStamp string
|
||||||
|
ValidEndStamp string
|
||||||
|
BundleID string
|
||||||
|
}
|
||||||
|
|
||||||
|
var distributorTemplateVariables = map[string]struct{}{
|
||||||
|
"location_id": {},
|
||||||
|
"report_id": {},
|
||||||
|
"run_id": {},
|
||||||
|
"artifact_group": {},
|
||||||
|
"batch_output_name": {},
|
||||||
|
"valid_start_date": {},
|
||||||
|
"valid_end_date": {},
|
||||||
|
"valid_start_time": {},
|
||||||
|
"valid_end_time": {},
|
||||||
|
"valid_start_stamp": {},
|
||||||
|
"valid_end_stamp": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
var distributorIdempotencyTemplateVariables = map[string]struct{}{
|
||||||
|
"location_id": {},
|
||||||
|
"report_id": {},
|
||||||
|
"run_id": {},
|
||||||
|
"artifact_group": {},
|
||||||
|
"batch_output_name": {},
|
||||||
|
"valid_start_date": {},
|
||||||
|
"valid_end_date": {},
|
||||||
|
"valid_start_time": {},
|
||||||
|
"valid_end_time": {},
|
||||||
|
"valid_start_stamp": {},
|
||||||
|
"valid_end_stamp": {},
|
||||||
|
"bundle_id": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
var distributorPipelineTemplateVariables = distributorIdempotencyTemplateVariables
|
||||||
|
|
||||||
|
func RenderDistributorBundleID(template string, values DistributorTemplateValues) (string, error) {
|
||||||
|
return renderDistributorTemplate("notify.distributor.bundle_id_template", template, values, distributorTemplateVariables)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RenderDistributorPipelineID(template string, values DistributorTemplateValues) (string, error) {
|
||||||
|
rendered, err := renderDistributorTemplate("notify.distributor.pipeline_id_template", template, values, distributorPipelineTemplateVariables)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(rendered) == "" {
|
||||||
|
return "", fmt.Errorf("notify.distributor.pipeline_id_template renders an empty pipeline id")
|
||||||
|
}
|
||||||
|
return rendered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RenderDistributorIdempotencyKey(template string, values DistributorTemplateValues) (string, error) {
|
||||||
|
return renderDistributorTemplate("notify.distributor.idempotency_key_template", template, values, distributorIdempotencyTemplateVariables)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RenderDistributorReportPaths(templates []string, values DistributorTemplateValues) ([]string, error) {
|
||||||
|
if len(templates) == 0 {
|
||||||
|
return nil, fmt.Errorf("notify.distributor.report_path_templates must contain at least one entry")
|
||||||
|
}
|
||||||
|
paths := make([]string, 0, len(templates))
|
||||||
|
seen := make(map[string]struct{}, len(templates))
|
||||||
|
for i, template := range templates {
|
||||||
|
name := fmt.Sprintf("notify.distributor.report_path_templates[%d]", i)
|
||||||
|
rendered, err := renderDistributorTemplate(name, template, values, distributorTemplateVariables)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := ValidateDistributorReportPath(name, rendered); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, ok := seen[rendered]; ok {
|
||||||
|
return nil, fmt.Errorf("notify.distributor.report_path_templates renders duplicate path %q", rendered)
|
||||||
|
}
|
||||||
|
seen[rendered] = struct{}{}
|
||||||
|
paths = append(paths, rendered)
|
||||||
|
}
|
||||||
|
return paths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDistributorTemplate(name, template string, allowed map[string]struct{}) error {
|
||||||
|
_, err := renderDistributorTemplate(name, template, DistributorTemplateValues{}, allowed)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderDistributorTemplate(name, template string, values DistributorTemplateValues, 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(distributorTemplateValue(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 {
|
||||||
|
switch variable {
|
||||||
|
case "location_id":
|
||||||
|
return values.LocationID
|
||||||
|
case "report_id":
|
||||||
|
return values.ReportID
|
||||||
|
case "run_id":
|
||||||
|
return values.RunID
|
||||||
|
case "artifact_group":
|
||||||
|
return values.ArtifactGroup
|
||||||
|
case "batch_output_name":
|
||||||
|
return values.BatchOutputName
|
||||||
|
case "valid_start_date":
|
||||||
|
return values.ValidStartDate
|
||||||
|
case "valid_end_date":
|
||||||
|
return values.ValidEndDate
|
||||||
|
case "valid_start_time":
|
||||||
|
return values.ValidStartTime
|
||||||
|
case "valid_end_time":
|
||||||
|
return values.ValidEndTime
|
||||||
|
case "valid_start_stamp":
|
||||||
|
return values.ValidStartStamp
|
||||||
|
case "valid_end_stamp":
|
||||||
|
return values.ValidEndStamp
|
||||||
|
case "bundle_id":
|
||||||
|
return values.BundleID
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateDistributorReportPath(name, path string) error {
|
||||||
|
if path == "" {
|
||||||
|
return fmt.Errorf("%s renders an empty path", name)
|
||||||
|
}
|
||||||
|
if isDistributorAbsolutePath(path) {
|
||||||
|
return fmt.Errorf("%s must render a relative path", name)
|
||||||
|
}
|
||||||
|
if strings.Contains(path, "\\") {
|
||||||
|
return fmt.Errorf("%s must not render backslashes", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
segments := strings.Split(path, "/")
|
||||||
|
for _, segment := range segments {
|
||||||
|
if segment == "" {
|
||||||
|
return fmt.Errorf("%s must not render empty path segments", name)
|
||||||
|
}
|
||||||
|
if segment == "." || segment == ".." {
|
||||||
|
return fmt.Errorf("%s must not render . or .. path segments", name)
|
||||||
|
}
|
||||||
|
if segment == "manifest.json" || segment == ".distributor.json" {
|
||||||
|
return fmt.Errorf("%s must not render reserved path segment %q", name, segment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDistributorAbsolutePath(path string) bool {
|
||||||
|
if filepath.IsAbs(path) || strings.HasPrefix(path, "/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if len(path) >= 3 && isASCIIAlpha(path[0]) && path[1] == ':' && (path[2] == '/' || path[2] == '\\') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isASCIIAlpha(ch byte) bool {
|
||||||
|
return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z')
|
||||||
|
}
|
||||||
127
internal/config/reports.go
Normal file
127
internal/config/reports.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (cfg Config) ReportModuleOverrides() map[report.ID][]module.ConfigItem {
|
||||||
|
overrides := map[report.ID][]module.ConfigItem{}
|
||||||
|
for key, reportCfg := range cfg.Reports {
|
||||||
|
if !reportCfg.deterministicModulesSet {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, err := reportIDForConfigKey(key)
|
||||||
|
if err != nil {
|
||||||
|
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 {
|
||||||
|
if cfg.Reports == nil {
|
||||||
|
cfg.Reports = map[string]ReportConfig{}
|
||||||
|
}
|
||||||
|
moduleRegistry, err := briefing.DefaultModuleRegistry()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("initialize module registry: %w", err)
|
||||||
|
}
|
||||||
|
reportRegistry := report.DefaultRegistry()
|
||||||
|
seenReports := map[report.ID]string{}
|
||||||
|
for key, reportCfg := range cfg.Reports {
|
||||||
|
reportID, err := reportIDForConfigKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if previous, ok := seenReports[reportID]; ok {
|
||||||
|
return fmt.Errorf("reports.%s duplicates report override %q", key, previous)
|
||||||
|
}
|
||||||
|
seenReports[reportID] = key
|
||||||
|
if _, err := reportRegistry.Lookup(reportID); err != nil {
|
||||||
|
return fmt.Errorf("reports.%s: %w", key, err)
|
||||||
|
}
|
||||||
|
if !reportCfg.deterministicModulesSet {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items := make([]module.ConfigItem, 0, len(reportCfg.DeterministicModules))
|
||||||
|
for i, rawItem := range reportCfg.DeterministicModules {
|
||||||
|
options, err := normalizeModuleOptions(moduleRegistry, rawItem.ID, rawItem.Options)
|
||||||
|
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 {
|
||||||
|
return fmt.Errorf("reports.%s.deterministic_modules: %w", key, err)
|
||||||
|
}
|
||||||
|
cfg.Reports[key] = reportCfg
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeModuleOptions(registry briefing.ModuleRegistry, id module.ID, raw any) (any, error) {
|
||||||
|
if raw == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
definition, err := registry.Lookup(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if definition.DefaultOptions == nil {
|
||||||
|
return nil, fmt.Errorf("module %q does not accept options", id)
|
||||||
|
}
|
||||||
|
optionType := reflect.TypeOf(definition.DefaultOptions)
|
||||||
|
normalized, err := decodeKnownOptions(raw, optionType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("module %q options are invalid: %w", id, err)
|
||||||
|
}
|
||||||
|
return normalized, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeKnownOptions(raw any, optionType reflect.Type) (any, error) {
|
||||||
|
data, err := yaml.Marshal(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
target := reflect.New(optionType)
|
||||||
|
decoder := yaml.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.KnownFields(true)
|
||||||
|
if err := decoder.Decode(target.Interface()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
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":
|
||||||
|
return report.Tomorrow, nil
|
||||||
|
case "hourly":
|
||||||
|
return report.Hourly, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
63
internal/config/secrets.go
Normal file
63
internal/config/secrets.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var secretNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||||
|
|
||||||
|
func loadSecrets(cfg SecretsConfig) error {
|
||||||
|
if cfg.Directory == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(cfg.Directory)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read secrets directory %q: %w", cfg.Directory, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("secrets directory %q contains an empty filename", cfg.Directory)
|
||||||
|
}
|
||||||
|
if !secretNamePattern.MatchString(name) {
|
||||||
|
return fmt.Errorf("secret file %q has invalid environment variable name", name)
|
||||||
|
}
|
||||||
|
if entry.Type()&os.ModeSymlink != 0 {
|
||||||
|
return fmt.Errorf("secret file %q must be a regular file, not a symlink", name)
|
||||||
|
}
|
||||||
|
if entry.IsDir() {
|
||||||
|
return fmt.Errorf("secret file %q must be a regular file, not a directory", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inspect secret file %q: %w", name, err)
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
return fmt.Errorf("secret file %q must be a regular file", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(cfg.Directory, name)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read secret file %q: %w", name, err)
|
||||||
|
}
|
||||||
|
value := string(data)
|
||||||
|
if strings.HasSuffix(value, "\r\n") {
|
||||||
|
value = strings.TrimSuffix(value, "\r\n")
|
||||||
|
} else {
|
||||||
|
value = strings.TrimSuffix(value, "\n")
|
||||||
|
}
|
||||||
|
if err := os.Setenv(name, value); err != nil {
|
||||||
|
return fmt.Errorf("set environment variable from secret file %q: %w", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func Validate(cfg Config) error {
|
func Validate(cfg Config) error {
|
||||||
|
if err := normalizeReportModules(&cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if cfg.WeatherAPI.BaseURL != "" {
|
if cfg.WeatherAPI.BaseURL != "" {
|
||||||
parsed, err := url.Parse(cfg.WeatherAPI.BaseURL)
|
parsed, err := url.Parse(cfg.WeatherAPI.BaseURL)
|
||||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||||
@@ -49,6 +52,10 @@ func Validate(cfg Config) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := validateDistributorNotify(cfg.Notify.Distributor); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.Scriptorium.Binary == "" {
|
if cfg.Scriptorium.Binary == "" {
|
||||||
return fmt.Errorf("scriptorium.binary is required")
|
return fmt.Errorf("scriptorium.binary is required")
|
||||||
}
|
}
|
||||||
@@ -75,6 +82,76 @@ func Validate(cfg Config) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateDistributorNotify(cfg DistributorNotifyConfig) error {
|
||||||
|
if !cfg.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(cfg.Endpoint)
|
||||||
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||||
|
return fmt.Errorf("notify.distributor.endpoint must be an absolute URL when enabled")
|
||||||
|
}
|
||||||
|
if cfg.TokenEnv == "" {
|
||||||
|
return fmt.Errorf("notify.distributor.token_env is required when enabled")
|
||||||
|
}
|
||||||
|
if !secretNamePattern.MatchString(cfg.TokenEnv) {
|
||||||
|
return fmt.Errorf("notify.distributor.token_env must be a valid environment variable name")
|
||||||
|
}
|
||||||
|
if cfg.Timeout <= 0 {
|
||||||
|
return fmt.Errorf("notify.distributor.timeout must be greater than zero when enabled")
|
||||||
|
}
|
||||||
|
if cfg.FailurePolicy != NotifyFailureError {
|
||||||
|
return fmt.Errorf("notify.distributor.failure_policy must be error when enabled")
|
||||||
|
}
|
||||||
|
if cfg.PipelineIDTemplate == "" {
|
||||||
|
return fmt.Errorf("notify.distributor.pipeline_id_template is required when enabled")
|
||||||
|
}
|
||||||
|
if err := validateDistributorTemplate("notify.distributor.pipeline_id_template", cfg.PipelineIDTemplate, distributorPipelineTemplateVariables); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cfg.BundleIDTemplate == "" {
|
||||||
|
return fmt.Errorf("notify.distributor.bundle_id_template is required when enabled")
|
||||||
|
}
|
||||||
|
if err := validateDistributorTemplate("notify.distributor.bundle_id_template", cfg.BundleIDTemplate, distributorTemplateVariables); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cfg.IdempotencyKeyTemplate == "" {
|
||||||
|
return fmt.Errorf("notify.distributor.idempotency_key_template is required when enabled")
|
||||||
|
}
|
||||||
|
if err := validateDistributorTemplate("notify.distributor.idempotency_key_template", cfg.IdempotencyKeyTemplate, distributorIdempotencyTemplateVariables); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(cfg.ReportPathTemplates) == 0 {
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
values.BundleID = bundleID
|
||||||
|
if _, err := RenderDistributorPipelineID(cfg.PipelineIDTemplate, values); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := RenderDistributorReportPaths(cfg.ReportPathTemplates, 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:
|
||||||
|
|||||||
229
internal/facts/facts.go
Normal file
229
internal/facts/facts.go
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
// Package facts defines collected and derived report facts.
|
||||||
|
package facts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CollectedFacts struct {
|
||||||
|
FetchedAt time.Time
|
||||||
|
Observation *weatherdata.Observation
|
||||||
|
Current *weatherdata.Current
|
||||||
|
Hourly *weatherdata.ForecastRun
|
||||||
|
Narrative *weatherdata.ForecastRun
|
||||||
|
Alerts *weatherdata.AlertRun
|
||||||
|
Discussion *weatherdata.Discussion
|
||||||
|
Daily *weatherdata.ForecastRun
|
||||||
|
WeatherStory *weatherdata.WeatherStory
|
||||||
|
SPCConvectiveOutlooks *weatherdata.ConvectiveOutlookRun
|
||||||
|
|
||||||
|
SourceProvenance []weatherdata.Source
|
||||||
|
SourceWarnings []weatherdata.SourceWarning
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildCollected(bundle *weatherdata.Bundle) CollectedFacts {
|
||||||
|
if bundle == nil {
|
||||||
|
return CollectedFacts{}
|
||||||
|
}
|
||||||
|
return CollectedFacts{
|
||||||
|
FetchedAt: bundle.FetchedAt,
|
||||||
|
Observation: bundle.Observation,
|
||||||
|
Current: bundle.Current,
|
||||||
|
Hourly: bundle.Hourly,
|
||||||
|
Narrative: bundle.Narrative,
|
||||||
|
Alerts: bundle.Alerts,
|
||||||
|
Discussion: bundle.Discussion,
|
||||||
|
Daily: bundle.Daily,
|
||||||
|
WeatherStory: bundle.WeatherStory,
|
||||||
|
SPCConvectiveOutlooks: bundle.SPCConvectiveOutlooks,
|
||||||
|
SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...),
|
||||||
|
SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f CollectedFacts) Bundle() *weatherdata.Bundle {
|
||||||
|
return &weatherdata.Bundle{
|
||||||
|
FetchedAt: f.FetchedAt,
|
||||||
|
Observation: f.Observation,
|
||||||
|
Current: f.Current,
|
||||||
|
Hourly: f.Hourly,
|
||||||
|
Narrative: f.Narrative,
|
||||||
|
Alerts: f.Alerts,
|
||||||
|
Discussion: f.Discussion,
|
||||||
|
Daily: f.Daily,
|
||||||
|
WeatherStory: f.WeatherStory,
|
||||||
|
SPCConvectiveOutlooks: f.SPCConvectiveOutlooks,
|
||||||
|
Sources: append([]weatherdata.Source(nil), f.SourceProvenance...),
|
||||||
|
Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type BuildDerivedRequest struct {
|
||||||
|
Resolved report.Resolved
|
||||||
|
Timezone string
|
||||||
|
Dayparts []forecast.DaypartDefinition
|
||||||
|
Collected CollectedFacts
|
||||||
|
}
|
||||||
|
|
||||||
|
type DerivedFacts struct {
|
||||||
|
ValidPeriodHourlyPeriods []weatherdata.ForecastPeriod
|
||||||
|
ValidPeriodNarrativePeriods []weatherdata.ForecastPeriod
|
||||||
|
ValidPeriodDailyPeriods []weatherdata.ForecastPeriod
|
||||||
|
AlertOverlaps []forecast.AlertOverlap
|
||||||
|
SPCConvectiveOutlooks []weatherdata.ConvectiveOutlook
|
||||||
|
SPCConvectiveDiscussions []weatherdata.ConvectiveOutlookDiscussion
|
||||||
|
DailySummaries []forecast.DailySummary
|
||||||
|
DaypartSummaries []forecast.DaypartSummary
|
||||||
|
PrecipTiming forecast.PrecipTiming
|
||||||
|
StormWindowSummary *forecast.DaypartSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f DerivedFacts) FirstDailySummary() *forecast.DailySummary {
|
||||||
|
if len(f.DailySummaries) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &f.DailySummaries[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) {
|
||||||
|
if !req.Resolved.ValidPeriod.IsValid() {
|
||||||
|
return DerivedFacts{}, fmt.Errorf("resolved valid period is required")
|
||||||
|
}
|
||||||
|
location, err := timeutil.LoadLocation(req.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return DerivedFacts{}, fmt.Errorf("load report timezone %q: %w", req.Timezone, err)
|
||||||
|
}
|
||||||
|
bundle := req.Collected.Bundle()
|
||||||
|
period := req.Resolved.ValidPeriod
|
||||||
|
spcOutlooks, spcDiscussions := selectSPCConvectiveOutlooks(req.Collected.SPCConvectiveOutlooks, period)
|
||||||
|
derived := DerivedFacts{
|
||||||
|
ValidPeriodHourlyPeriods: forecast.SelectHourlyPeriods(req.Collected.Hourly, period),
|
||||||
|
ValidPeriodNarrativePeriods: forecast.SelectHourlyPeriods(req.Collected.Narrative, period),
|
||||||
|
ValidPeriodDailyPeriods: forecast.SelectHourlyPeriods(req.Collected.Daily, period),
|
||||||
|
AlertOverlaps: forecast.AlertOverlaps(req.Collected.Alerts, period),
|
||||||
|
SPCConvectiveOutlooks: spcOutlooks,
|
||||||
|
SPCConvectiveDiscussions: spcDiscussions,
|
||||||
|
}
|
||||||
|
derived.PrecipTiming = forecast.BuildPrecipTiming(derived.ValidPeriodHourlyPeriods)
|
||||||
|
|
||||||
|
switch req.Resolved.Definition.ID {
|
||||||
|
case report.Hourly:
|
||||||
|
case report.DailyToday, report.Tomorrow:
|
||||||
|
summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts)
|
||||||
|
if err != nil {
|
||||||
|
return DerivedFacts{}, err
|
||||||
|
}
|
||||||
|
derived.DailySummaries = []forecast.DailySummary{*summary}
|
||||||
|
case report.ThreeDay, report.Weekend:
|
||||||
|
summaries, err := forecast.BuildPeriodDailySummaries(bundle, period, location, req.Dayparts)
|
||||||
|
if err != nil {
|
||||||
|
return DerivedFacts{}, err
|
||||||
|
}
|
||||||
|
derived.DailySummaries = summaries
|
||||||
|
case report.Storm:
|
||||||
|
summary := forecast.SummarizeDaypart("storm window", period, derived.ValidPeriodHourlyPeriods)
|
||||||
|
summary.AlertOverlaps = derived.AlertOverlaps
|
||||||
|
derived.StormWindowSummary = &summary
|
||||||
|
default:
|
||||||
|
return DerivedFacts{}, fmt.Errorf("derived facts are not implemented for report %q", req.Resolved.Definition.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
derived.DaypartSummaries = collectDaypartSummaries(derived)
|
||||||
|
return derived, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectDaypartSummaries(derived DerivedFacts) []forecast.DaypartSummary {
|
||||||
|
var out []forecast.DaypartSummary
|
||||||
|
for _, summary := range derived.DailySummaries {
|
||||||
|
out = append(out, summary.Dayparts...)
|
||||||
|
}
|
||||||
|
if derived.StormWindowSummary != nil {
|
||||||
|
out = append(out, *derived.StormWindowSummary)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
503
internal/facts/facts_test.go
Normal file
503
internal/facts/facts_test.go
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
package facts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
|
||||||
|
fetchedAt := mustParse("2026-05-29T10:00:00Z")
|
||||||
|
bundle := &weatherdata.Bundle{
|
||||||
|
FetchedAt: fetchedAt,
|
||||||
|
Current: &weatherdata.Current{ConditionText: "Clear"},
|
||||||
|
Hourly: &weatherdata.ForecastRun{Product: "hourly"},
|
||||||
|
SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{
|
||||||
|
Product: "convective_outlook",
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{{
|
||||||
|
ID: "day1-categorical-slight",
|
||||||
|
Label: "SLGT",
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
Sources: []weatherdata.Source{{Name: "hourly"}},
|
||||||
|
Warnings: []weatherdata.SourceWarning{{Source: "discussion", Code: "missing_source"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
collected := BuildCollected(bundle)
|
||||||
|
if collected.FetchedAt != fetchedAt || collected.Current.ConditionText != "Clear" || collected.Hourly.Product != "hourly" {
|
||||||
|
t.Fatalf("CollectedFacts = %#v, want source facts copied from bundle", collected)
|
||||||
|
}
|
||||||
|
if collected.SPCConvectiveOutlooks == nil || collected.SPCConvectiveOutlooks.Outlooks[0].Label != "SLGT" {
|
||||||
|
t.Fatalf("SPCConvectiveOutlooks = %#v, want source copied from bundle", collected.SPCConvectiveOutlooks)
|
||||||
|
}
|
||||||
|
if len(collected.SourceProvenance) != 1 || collected.SourceProvenance[0].Name != "hourly" {
|
||||||
|
t.Fatalf("SourceProvenance = %#v, want hourly source", collected.SourceProvenance)
|
||||||
|
}
|
||||||
|
if len(collected.SourceWarnings) != 1 || collected.SourceWarnings[0].Source != "discussion" {
|
||||||
|
t.Fatalf("SourceWarnings = %#v, want discussion warning", collected.SourceWarnings)
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle.Sources[0].Name = "changed"
|
||||||
|
bundle.Warnings[0].Source = "changed"
|
||||||
|
if collected.SourceProvenance[0].Name != "hourly" || collected.SourceWarnings[0].Source != "discussion" {
|
||||||
|
t.Fatalf("collected source slices changed after bundle mutation: %#v %#v", collected.SourceProvenance, collected.SourceWarnings)
|
||||||
|
}
|
||||||
|
|
||||||
|
roundTrip := collected.Bundle()
|
||||||
|
if roundTrip.SPCConvectiveOutlooks == nil || roundTrip.SPCConvectiveOutlooks.Outlooks[0].ID != "day1-categorical-slight" {
|
||||||
|
t.Fatalf("Bundle().SPCConvectiveOutlooks = %#v, want collected source restored", roundTrip.SPCConvectiveOutlooks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) {
|
||||||
|
location := testLocation()
|
||||||
|
resolved := resolveForTest(t, report.DailyToday, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||||
|
derived, err := BuildDerived(BuildDerivedRequest{
|
||||||
|
Resolved: resolved,
|
||||||
|
Timezone: location.String(),
|
||||||
|
Dayparts: testDayparts(),
|
||||||
|
Collected: BuildCollected(testBundle(location)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildDerived() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(derived.ValidPeriodHourlyPeriods) != 4 {
|
||||||
|
t.Fatalf("ValidPeriodHourlyPeriods length = %d, want 4", len(derived.ValidPeriodHourlyPeriods))
|
||||||
|
}
|
||||||
|
if len(derived.ValidPeriodNarrativePeriods) != 1 {
|
||||||
|
t.Fatalf("ValidPeriodNarrativePeriods length = %d, want 1", len(derived.ValidPeriodNarrativePeriods))
|
||||||
|
}
|
||||||
|
if len(derived.AlertOverlaps) != 1 || derived.AlertOverlaps[0].Event != "Flood Watch" {
|
||||||
|
t.Fatalf("AlertOverlaps = %#v, want Flood Watch overlap", derived.AlertOverlaps)
|
||||||
|
}
|
||||||
|
if len(derived.DailySummaries) != 1 || len(derived.DailySummaries[0].Dayparts) != 3 {
|
||||||
|
t.Fatalf("DailySummaries = %#v, want one summary with dayparts", derived.DailySummaries)
|
||||||
|
}
|
||||||
|
if len(derived.DaypartSummaries) != 3 {
|
||||||
|
t.Fatalf("DaypartSummaries length = %d, want 3", len(derived.DaypartSummaries))
|
||||||
|
}
|
||||||
|
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 valid-period rain start", 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 closed window end", derived.PrecipTiming.LastPrecipitation)
|
||||||
|
}
|
||||||
|
if len(derived.PrecipTiming.PrecipitationWindows) != 2 {
|
||||||
|
t.Fatalf("PrecipitationWindows = %#v, want two threshold windows", derived.PrecipTiming.PrecipitationWindows)
|
||||||
|
}
|
||||||
|
if !derived.PrecipTiming.ThunderMentioned {
|
||||||
|
t.Fatal("PrecipTiming.ThunderMentioned = false, want true")
|
||||||
|
}
|
||||||
|
morning := derived.DailySummaries[0].Dayparts[0]
|
||||||
|
if len(morning.HourlyPeriods) != 1 || morning.MaxPrecipitationProbability == nil || morning.MaxPrecipitationProbability.Value != 60 {
|
||||||
|
t.Fatalf("morning summary = %#v, want sliced hour with precip max", morning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDerivedOutlookBuildsPartialDaySummariesWithMissingOptionalSources(t *testing.T) {
|
||||||
|
location := testLocation()
|
||||||
|
resolved := resolveForTest(t, report.ThreeDay, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||||
|
bundle := testBundle(location)
|
||||||
|
bundle.Narrative = nil
|
||||||
|
bundle.Alerts = nil
|
||||||
|
bundle.Discussion = nil
|
||||||
|
bundle.WeatherStory = nil
|
||||||
|
|
||||||
|
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.DailySummaries) != 3 {
|
||||||
|
t.Fatalf("DailySummaries length = %d, want 3 partial-day summaries", len(derived.DailySummaries))
|
||||||
|
}
|
||||||
|
if len(derived.ValidPeriodNarrativePeriods) != 0 {
|
||||||
|
t.Fatalf("ValidPeriodNarrativePeriods length = %d, want 0 for missing optional source", len(derived.ValidPeriodNarrativePeriods))
|
||||||
|
}
|
||||||
|
if len(derived.AlertOverlaps) != 0 {
|
||||||
|
t.Fatalf("AlertOverlaps = %#v, want none for missing optional alerts", derived.AlertOverlaps)
|
||||||
|
}
|
||||||
|
if derived.DailySummaries[0].Period.Start.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" {
|
||||||
|
t.Fatalf("first summary start = %s, want valid-period start", derived.DailySummaries[0].Period.Start.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDerivedWeekendAndTomorrow(t *testing.T) {
|
||||||
|
location := testLocation()
|
||||||
|
for _, id := range []report.ID{report.Tomorrow, report.Weekend} {
|
||||||
|
resolved := resolveForTest(t, id, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||||
|
derived, err := BuildDerived(BuildDerivedRequest{
|
||||||
|
Resolved: resolved,
|
||||||
|
Timezone: location.String(),
|
||||||
|
Dayparts: testDayparts(),
|
||||||
|
Collected: BuildCollected(testBundle(location)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildDerived(%s) error = %v", id, err)
|
||||||
|
}
|
||||||
|
if len(derived.DailySummaries) == 0 {
|
||||||
|
t.Fatalf("BuildDerived(%s) DailySummaries length = 0, want summaries", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDerivedHourlyUsesRollingWindowFacts(t *testing.T) {
|
||||||
|
location := testLocation()
|
||||||
|
resolved := resolveForTest(t, report.Hourly, mustParse("2026-05-29T08:30:00-05:00"), location)
|
||||||
|
bundle := testBundle(location)
|
||||||
|
bundle.Alerts = &weatherdata.AlertRun{Alerts: []json.RawMessage{
|
||||||
|
json.RawMessage(`{"event":"Expired Advisory","headline":"Ends at start","severity":"Minor","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T08:30:00-05:00"}`),
|
||||||
|
json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T11:00:00-05:00","expires":"2026-05-29T15:00:00-05:00"}`),
|
||||||
|
json.RawMessage(`{"event":"Evening Advisory","headline":"Starts at end","severity":"Minor","effective":"2026-05-29T14:30:00-05:00","expires":"2026-05-29T18:00:00-05:00"}`),
|
||||||
|
}}
|
||||||
|
bundle.SPCConvectiveOutlooks = testConvectiveOutlookRun(location)
|
||||||
|
|
||||||
|
derived, err := BuildDerived(BuildDerivedRequest{
|
||||||
|
Resolved: resolved,
|
||||||
|
Timezone: location.String(),
|
||||||
|
Dayparts: testDayparts(),
|
||||||
|
Collected: BuildCollected(bundle),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildDerived() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(derived.ValidPeriodHourlyPeriods) != 4 {
|
||||||
|
t.Fatalf("ValidPeriodHourlyPeriods length = %d, want 4 rolling-window hours", len(derived.ValidPeriodHourlyPeriods))
|
||||||
|
}
|
||||||
|
if derived.ValidPeriodHourlyPeriods[0].StartTime.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" ||
|
||||||
|
derived.ValidPeriodHourlyPeriods[3].StartTime.Format(time.RFC3339) != "2026-05-29T14:00:00-05:00" {
|
||||||
|
t.Fatalf("ValidPeriodHourlyPeriods = %#v, want only hours overlapping 8:30 AM-2:30 PM", derived.ValidPeriodHourlyPeriods)
|
||||||
|
}
|
||||||
|
if len(derived.ValidPeriodNarrativePeriods) != 1 {
|
||||||
|
t.Fatalf("ValidPeriodNarrativePeriods length = %d, want overlapping narrative period", len(derived.ValidPeriodNarrativePeriods))
|
||||||
|
}
|
||||||
|
if len(derived.DailySummaries) != 0 || len(derived.DaypartSummaries) != 0 || derived.StormWindowSummary != nil {
|
||||||
|
t.Fatalf("hourly summaries daily=%#v daypart=%#v storm=%#v, want none", derived.DailySummaries, derived.DaypartSummaries, derived.StormWindowSummary)
|
||||||
|
}
|
||||||
|
if derived.PrecipTiming.FirstPrecipitation == nil || derived.PrecipTiming.FirstPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" {
|
||||||
|
t.Fatalf("PrecipTiming.FirstPrecipitation = %#v, want first selected rainy hour", derived.PrecipTiming.FirstPrecipitation)
|
||||||
|
}
|
||||||
|
if derived.PrecipTiming.LastPrecipitation == nil || derived.PrecipTiming.LastPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T14:00:00-05:00" {
|
||||||
|
t.Fatalf("PrecipTiming.LastPrecipitation = %#v, want final selected rainy window end", derived.PrecipTiming.LastPrecipitation)
|
||||||
|
}
|
||||||
|
if len(derived.PrecipTiming.PrecipitationWindows) != 2 {
|
||||||
|
t.Fatalf("PrecipTiming.PrecipitationWindows = %#v, want two hourly windows", derived.PrecipTiming.PrecipitationWindows)
|
||||||
|
}
|
||||||
|
if len(derived.AlertOverlaps) != 1 || derived.AlertOverlaps[0].Event != "Flood Watch" {
|
||||||
|
t.Fatalf("AlertOverlaps = %#v, want only alert overlapping hourly period", derived.AlertOverlaps)
|
||||||
|
}
|
||||||
|
if derived.AlertOverlaps[0].Overlap.End.Format(time.RFC3339) != "2026-05-29T14:30:00-05:00" {
|
||||||
|
t.Fatalf("Alert overlap end = %s, want clipped to hourly end", derived.AlertOverlaps[0].Overlap.End.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
if got, want := outlookIDs(derived.SPCConvectiveOutlooks), []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic"}; strings.Join(got, ",") != strings.Join(want, ",") {
|
||||||
|
t.Fatalf("SPCConvectiveOutlooks IDs = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
if got, want := discussionHeadlines(derived.SPCConvectiveDiscussions), []string{"day1 early", "day1 late"}; strings.Join(got, ",") != strings.Join(want, ",") {
|
||||||
|
t.Fatalf("SPCConvectiveDiscussions = %#v, want retained discussions for overlapping SPC day %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDerivedStormBuildsWindowSummary(t *testing.T) {
|
||||||
|
location := testLocation()
|
||||||
|
resolved := resolveStormForTest(t, location)
|
||||||
|
derived, err := BuildDerived(BuildDerivedRequest{
|
||||||
|
Resolved: resolved,
|
||||||
|
Timezone: location.String(),
|
||||||
|
Dayparts: testDayparts(),
|
||||||
|
Collected: BuildCollected(testBundle(location)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildDerived() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(derived.ValidPeriodHourlyPeriods) != 2 {
|
||||||
|
t.Fatalf("ValidPeriodHourlyPeriods length = %d, want 2 storm-window hours", len(derived.ValidPeriodHourlyPeriods))
|
||||||
|
}
|
||||||
|
if len(derived.ValidPeriodDailyPeriods) != 1 {
|
||||||
|
t.Fatalf("ValidPeriodDailyPeriods length = %d, want 1 daily period", len(derived.ValidPeriodDailyPeriods))
|
||||||
|
}
|
||||||
|
if derived.StormWindowSummary == nil {
|
||||||
|
t.Fatal("StormWindowSummary = nil, want summary")
|
||||||
|
}
|
||||||
|
if derived.StormWindowSummary.MaxPrecipitationProbability == nil || derived.StormWindowSummary.MaxPrecipitationProbability.Value != 80 {
|
||||||
|
t.Fatalf("StormWindowSummary = %#v, want peak precipitation", derived.StormWindowSummary)
|
||||||
|
}
|
||||||
|
if len(derived.StormWindowSummary.AlertOverlaps) != 1 {
|
||||||
|
t.Fatalf("StormWindowSummary.AlertOverlaps length = %d, want 1", len(derived.StormWindowSummary.AlertOverlaps))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDerivedSelectsSPCConvectiveOutlooksByValidPeriod(t *testing.T) {
|
||||||
|
location := testLocation()
|
||||||
|
now := mustParse("2026-05-29T08:00:00-05:00")
|
||||||
|
bundle := testBundle(location)
|
||||||
|
bundle.SPCConvectiveOutlooks = testConvectiveOutlookRun(location)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
resolved report.Resolved
|
||||||
|
wantOutlookIDs []string
|
||||||
|
wantDiscussion []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "daily today",
|
||||||
|
resolved: resolveForTest(t, report.DailyToday, now, location),
|
||||||
|
wantOutlookIDs: []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic"},
|
||||||
|
wantDiscussion: []string{"day1 early", "day1 late"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "daily tomorrow",
|
||||||
|
resolved: resolveForTest(t, report.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.DailyToday, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||||
|
bundle := testBundle(location)
|
||||||
|
bundle.SPCConvectiveOutlooks = nil
|
||||||
|
|
||||||
|
derived, err := BuildDerived(BuildDerivedRequest{
|
||||||
|
Resolved: resolved,
|
||||||
|
Timezone: location.String(),
|
||||||
|
Dayparts: testDayparts(),
|
||||||
|
Collected: BuildCollected(bundle),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildDerived(missing source) error = %v", err)
|
||||||
|
}
|
||||||
|
if derived.SPCConvectiveOutlooks != nil || derived.SPCConvectiveDiscussions != nil {
|
||||||
|
t.Fatalf("missing source derived outlooks=%#v discussions=%#v, want nil slices", derived.SPCConvectiveOutlooks, derived.SPCConvectiveDiscussions)
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{Outlooks: []weatherdata.ConvectiveOutlook{}, Discussions: []weatherdata.ConvectiveOutlookDiscussion{}}
|
||||||
|
derived, err = BuildDerived(BuildDerivedRequest{
|
||||||
|
Resolved: resolved,
|
||||||
|
Timezone: location.String(),
|
||||||
|
Dayparts: testDayparts(),
|
||||||
|
Collected: BuildCollected(bundle),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildDerived(checked empty source) error = %v", err)
|
||||||
|
}
|
||||||
|
if derived.SPCConvectiveOutlooks == nil || len(derived.SPCConvectiveOutlooks) != 0 {
|
||||||
|
t.Fatalf("checked empty outlooks = %#v, want non-nil empty slice", derived.SPCConvectiveOutlooks)
|
||||||
|
}
|
||||||
|
if derived.SPCConvectiveDiscussions == nil || len(derived.SPCConvectiveDiscussions) != 0 {
|
||||||
|
t.Fatalf("checked empty discussions = %#v, want non-nil empty slice", derived.SPCConvectiveDiscussions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBundle(location *time.Location) *weatherdata.Bundle {
|
||||||
|
return &weatherdata.Bundle{
|
||||||
|
FetchedAt: mustParse("2026-05-29T10:00:00Z"),
|
||||||
|
Current: &weatherdata.Current{ConditionText: "Cloudy"},
|
||||||
|
Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
|
||||||
|
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Showers", 60, 10),
|
||||||
|
hour(location, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00", "Thunderstorms", 80, 35),
|
||||||
|
hour(location, "2026-05-29T13:00:00-05:00", "2026-05-29T14:00:00-05:00", "Heavy rain", 70, 25),
|
||||||
|
hour(location, "2026-05-29T14:00:00-05:00", "2026-05-29T15:00:00-05:00", "Hot", 10, 15),
|
||||||
|
hour(location, "2026-05-30T09:00:00-05:00", "2026-05-30T10:00:00-05:00", "Clear", 0, 5),
|
||||||
|
}},
|
||||||
|
Narrative: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
|
||||||
|
hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T18:00:00-05:00", "Storm chances peak midday.", 70, 20),
|
||||||
|
}},
|
||||||
|
Daily: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
|
||||||
|
hour(location, "2026-05-29T00:00:00-05:00", "2026-05-30T00:00:00-05:00", "Storms", 70, 20),
|
||||||
|
}},
|
||||||
|
Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{
|
||||||
|
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"}`),
|
||||||
|
}},
|
||||||
|
Discussion: &weatherdata.Discussion{Product: "discussion", KeyMessages: []string{"Storm confidence is moderate."}},
|
||||||
|
WeatherStory: &weatherdata.WeatherStory{Title: "Storm Risk"},
|
||||||
|
Sources: []weatherdata.Source{{Name: "hourly"}},
|
||||||
|
Warnings: []weatherdata.SourceWarning{{Source: "daily", Code: "missing_source"}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testConvectiveOutlookRun(location *time.Location) *weatherdata.ConvectiveOutlookRun {
|
||||||
|
rank1 := 1
|
||||||
|
rank2 := 2
|
||||||
|
rank3 := 3
|
||||||
|
rank4 := 4
|
||||||
|
rank5 := 5
|
||||||
|
return &weatherdata.ConvectiveOutlookRun{
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{
|
||||||
|
convectiveOutlook(location, "mon-outside", 4, "categorical", "MDT", &rank5, "2026-06-01T00:00:00-05:00", "2026-06-02T00:00:00-05:00"),
|
||||||
|
convectiveOutlook(location, "sun-slight", 3, "categorical", "SLGT", &rank3, "2026-05-31T06:00:00-05:00", "2026-06-01T00:00:00-05:00"),
|
||||||
|
convectiveOutlook(location, "fri-low", 1, "categorical", "MRGL", &rank1, "2026-05-29T06:00:00-05:00", "2026-05-29T12:00:00-05:00"),
|
||||||
|
convectiveOutlook(location, "fri-missing-rank", 1, "categorical", "GEN", nil, "2026-05-29T08:00:00-05:00", "2026-05-29T10:00:00-05:00"),
|
||||||
|
convectiveOutlook(location, "sat-enhanced", 2, "categorical", "ENH", &rank4, "2026-05-30T01:00:00-05:00", "2026-05-30T12:00:00-05:00"),
|
||||||
|
convectiveOutlook(location, "fri-high", 1, "categorical", "SLGT", &rank3, "2026-05-29T07:00:00-05:00", "2026-05-29T11:00:00-05:00"),
|
||||||
|
convectiveOutlook(location, "fri-probabilistic", 1, "probabilistic", "5%", &rank2, "2026-05-29T05:00:00-05:00", "2026-05-29T10:00:00-05:00"),
|
||||||
|
convectiveOutlook(location, "fri-storm", 1, "categorical", "SLGT", &rank2, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00"),
|
||||||
|
},
|
||||||
|
Discussions: []weatherdata.ConvectiveOutlookDiscussion{
|
||||||
|
{Day: 4, Headline: "day4", UpdatedAt: ptrTime(mustParse("2026-05-31T10:00:00-05:00"))},
|
||||||
|
{Day: 1, Headline: "day1 late", UpdatedAt: ptrTime(mustParse("2026-05-29T09:00:00-05:00"))},
|
||||||
|
{Day: 3, Headline: "day3", UpdatedAt: ptrTime(mustParse("2026-05-31T08:00:00-05:00"))},
|
||||||
|
{Day: 1, Headline: "day1 early", UpdatedAt: ptrTime(mustParse("2026-05-29T08:00:00-05:00"))},
|
||||||
|
{Day: 2, Headline: "day2", UpdatedAt: ptrTime(mustParse("2026-05-30T08:00:00-05:00"))},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func convectiveOutlook(location *time.Location, id string, day int, outlookType string, label string, rank *int, validFrom string, validTo string) weatherdata.ConvectiveOutlook {
|
||||||
|
return weatherdata.ConvectiveOutlook{
|
||||||
|
ID: id,
|
||||||
|
Day: day,
|
||||||
|
OutlookType: outlookType,
|
||||||
|
Label: label,
|
||||||
|
SeverityRank: rank,
|
||||||
|
ValidFrom: mustParse(validFrom).In(location),
|
||||||
|
ValidTo: mustParse(validTo).In(location),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func outlookIDs(outlooks []weatherdata.ConvectiveOutlook) []string {
|
||||||
|
out := make([]string, 0, len(outlooks))
|
||||||
|
for _, outlook := range outlooks {
|
||||||
|
out = append(out, outlook.ID)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func discussionHeadlines(discussions []weatherdata.ConvectiveOutlookDiscussion) []string {
|
||||||
|
out := make([]string, 0, len(discussions))
|
||||||
|
for _, discussion := range discussions {
|
||||||
|
out = append(out, discussion.Headline)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptrTime(value time.Time) *time.Time {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
|
func hour(location *time.Location, start string, end string, text string, precip float64, gust float64) weatherdata.ForecastPeriod {
|
||||||
|
temperature := 70.0
|
||||||
|
return weatherdata.ForecastPeriod{
|
||||||
|
StartTime: mustParse(start).In(location),
|
||||||
|
EndTime: mustParse(end).In(location),
|
||||||
|
TextDescription: text,
|
||||||
|
TemperatureF: &temperature,
|
||||||
|
ProbabilityOfPrecipitationPercent: &precip,
|
||||||
|
WindGustMph: &gust,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDayparts() []forecast.DaypartDefinition {
|
||||||
|
return []forecast.DaypartDefinition{
|
||||||
|
{Name: "morning", Start: "06:00", End: "12:00"},
|
||||||
|
{Name: "afternoon", Start: "12:00", End: "18:00"},
|
||||||
|
{Name: "evening", Start: "18:00", End: "24:00"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveForTest(t *testing.T, id report.ID, now time.Time, location *time.Location) report.Resolved {
|
||||||
|
t.Helper()
|
||||||
|
resolved, err := report.Resolve(id, report.ResolveRequest{Now: now, Location: location})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve %s: %v", id, err)
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveStormForTest(t *testing.T, location *time.Location) report.Resolved {
|
||||||
|
t.Helper()
|
||||||
|
resolved, err := report.Resolve(report.Storm, report.ResolveRequest{
|
||||||
|
Now: mustParse("2026-05-29T08:00:00-05:00"),
|
||||||
|
Location: location,
|
||||||
|
StormStart: mustParse("2026-05-29T11:30:00-05:00"),
|
||||||
|
StormEnd: mustParse("2026-05-29T13:30:00-05:00"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve storm: %v", err)
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLocation() *time.Location {
|
||||||
|
location, err := time.LoadLocation("America/Chicago")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return location
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustParse(value string) time.Time {
|
||||||
|
parsed, err := time.Parse(time.RFC3339, value)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DaypartDefinition struct {
|
type DaypartDefinition struct {
|
||||||
@@ -48,6 +49,6 @@ func ResolveDayparts(date time.Time, location *time.Location, definitions []Dayp
|
|||||||
return windows, nil
|
return windows, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func PeriodForForecastPeriod(period ForecastPeriod) timeutil.Period {
|
func PeriodForForecastPeriod(period weatherdata.ForecastPeriod) timeutil.Period {
|
||||||
return timeutil.Period{Start: period.StartTime, End: period.EndTime}
|
return timeutil.Period{Start: period.StartTime, End: period.EndTime}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,32 +8,33 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DailySummary struct {
|
type DailySummary struct {
|
||||||
Date string `json:"date"`
|
Date string `json:"date"`
|
||||||
Period timeutil.Period `json:"period"`
|
Period timeutil.Period `json:"period"`
|
||||||
Dayparts []DaypartSummary `json:"dayparts"`
|
Dayparts []DaypartSummary `json:"dayparts"`
|
||||||
NarrativePeriods []ForecastPeriod `json:"narrativePeriods,omitempty"`
|
NarrativePeriods []weatherdata.ForecastPeriod `json:"narrativePeriods,omitempty"`
|
||||||
AlertOverlaps []AlertOverlap `json:"alertOverlaps,omitempty"`
|
AlertOverlaps []AlertOverlap `json:"alertOverlaps,omitempty"`
|
||||||
Discussion *Discussion `json:"discussion,omitempty"`
|
Discussion *weatherdata.Discussion `json:"discussion,omitempty"`
|
||||||
SourceWarnings []SourceWarning `json:"sourceWarnings,omitempty"`
|
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
|
||||||
SourceProvenance []Source `json:"sourceProvenance,omitempty"`
|
SourceProvenance []weatherdata.Source `json:"sourceProvenance,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DaypartSummary struct {
|
type DaypartSummary struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Period timeutil.Period `json:"period"`
|
Period timeutil.Period `json:"period"`
|
||||||
HourlyPeriods []ForecastPeriod `json:"hourlyPeriods"`
|
HourlyPeriods []weatherdata.ForecastPeriod `json:"hourlyPeriods"`
|
||||||
Temperature Range `json:"temperature,omitempty"`
|
Temperature Range `json:"temperature,omitempty"`
|
||||||
ApparentTemperature Range `json:"apparentTemperature,omitempty"`
|
ApparentTemperature Range `json:"apparentTemperature,omitempty"`
|
||||||
MaxPrecipitationProbability *TimedValue `json:"maxPrecipitationProbability,omitempty"`
|
MaxPrecipitationProbability *TimedValue `json:"maxPrecipitationProbability,omitempty"`
|
||||||
PeakWindSpeed *TimedValue `json:"peakWindSpeed,omitempty"`
|
PeakWindSpeed *TimedValue `json:"peakWindSpeed,omitempty"`
|
||||||
PeakWindGust *TimedValue `json:"peakWindGust,omitempty"`
|
PeakWindGust *TimedValue `json:"peakWindGust,omitempty"`
|
||||||
DominantCondition string `json:"dominantCondition,omitempty"`
|
DominantCondition string `json:"dominantCondition,omitempty"`
|
||||||
NotableConditions []string `json:"notableConditions,omitempty"`
|
NotableConditions []string `json:"notableConditions,omitempty"`
|
||||||
Indicators Indicators `json:"indicators"`
|
Indicators Indicators `json:"indicators"`
|
||||||
AlertOverlaps []AlertOverlap `json:"alertOverlaps,omitempty"`
|
AlertOverlaps []AlertOverlap `json:"alertOverlaps,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Range struct {
|
type Range struct {
|
||||||
@@ -46,6 +47,8 @@ type TimedValue struct {
|
|||||||
Time time.Time `json:"time"`
|
Time time.Time `json:"time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DefaultPrecipWindowProbabilityThreshold = 40
|
||||||
|
|
||||||
type Indicators struct {
|
type Indicators struct {
|
||||||
Snow bool `json:"snow,omitempty"`
|
Snow bool `json:"snow,omitempty"`
|
||||||
Ice bool `json:"ice,omitempty"`
|
Ice bool `json:"ice,omitempty"`
|
||||||
@@ -64,7 +67,106 @@ type AlertOverlap struct {
|
|||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildDailySummary(bundle *Bundle, date time.Time, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) {
|
type PrecipTiming struct {
|
||||||
|
MaxPrecipitationProbability *TimedValue `json:"maxPrecipitationProbability,omitempty"`
|
||||||
|
FirstPrecipitation *TimedValue `json:"firstPrecipitation,omitempty"`
|
||||||
|
LastPrecipitation *TimedValue `json:"lastPrecipitation,omitempty"`
|
||||||
|
ProbabilityThreshold float64 `json:"probabilityThreshold"`
|
||||||
|
PrecipitationWindows []PrecipitationWindow `json:"precipitationWindows,omitempty"`
|
||||||
|
ThunderMentioned bool `json:"thunderMentioned,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PrecipitationWindow struct {
|
||||||
|
Start time.Time `json:"start"`
|
||||||
|
End *time.Time `json:"end,omitempty"`
|
||||||
|
MaxPrecipitationProbability TimedValue `json:"maxPrecipitationProbability"`
|
||||||
|
ProbabilityThreshold float64 `json:"probabilityThreshold"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildPrecipTiming(periods []weatherdata.ForecastPeriod) PrecipTiming {
|
||||||
|
return buildPrecipTimingWithThreshold(periods, DefaultPrecipWindowProbabilityThreshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPrecipTimingWithThreshold(periods []weatherdata.ForecastPeriod, threshold float64) PrecipTiming {
|
||||||
|
timing := PrecipTiming{ProbabilityThreshold: threshold}
|
||||||
|
sorted := append([]weatherdata.ForecastPeriod(nil), periods...)
|
||||||
|
sort.SliceStable(sorted, func(i int, j int) bool {
|
||||||
|
return sorted[i].StartTime.Before(sorted[j].StartTime)
|
||||||
|
})
|
||||||
|
|
||||||
|
var active *PrecipitationWindow
|
||||||
|
var activeLastEnd time.Time
|
||||||
|
closeActive := func() {
|
||||||
|
if active == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
end := activeLastEnd
|
||||||
|
active.End = &end
|
||||||
|
timing.PrecipitationWindows = append(timing.PrecipitationWindows, *active)
|
||||||
|
active = nil
|
||||||
|
}
|
||||||
|
startActive := func(forecastPeriod weatherdata.ForecastPeriod, probability float64) {
|
||||||
|
active = &PrecipitationWindow{
|
||||||
|
Start: forecastPeriod.StartTime,
|
||||||
|
MaxPrecipitationProbability: TimedValue{
|
||||||
|
Value: probability,
|
||||||
|
Time: forecastPeriod.StartTime,
|
||||||
|
},
|
||||||
|
ProbabilityThreshold: threshold,
|
||||||
|
}
|
||||||
|
activeLastEnd = forecastPeriod.EndTime
|
||||||
|
if timing.FirstPrecipitation == nil {
|
||||||
|
timing.FirstPrecipitation = &TimedValue{
|
||||||
|
Value: probability,
|
||||||
|
Time: forecastPeriod.StartTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, forecastPeriod := range sorted {
|
||||||
|
setMaxTimedValue(&timing.MaxPrecipitationProbability, forecastPeriod.ProbabilityOfPrecipitationPercent, forecastPeriod.StartTime)
|
||||||
|
if forecastPeriod.ProbabilityOfPrecipitationPercent == nil || *forecastPeriod.ProbabilityOfPrecipitationPercent < threshold {
|
||||||
|
closeActive()
|
||||||
|
} else {
|
||||||
|
probability := *forecastPeriod.ProbabilityOfPrecipitationPercent
|
||||||
|
if active == nil {
|
||||||
|
startActive(forecastPeriod, probability)
|
||||||
|
} else {
|
||||||
|
if forecastPeriod.StartTime.After(activeLastEnd) {
|
||||||
|
closeActive()
|
||||||
|
startActive(forecastPeriod, probability)
|
||||||
|
}
|
||||||
|
if probability > active.MaxPrecipitationProbability.Value {
|
||||||
|
active.MaxPrecipitationProbability = TimedValue{
|
||||||
|
Value: probability,
|
||||||
|
Time: forecastPeriod.StartTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if forecastPeriod.EndTime.After(activeLastEnd) {
|
||||||
|
activeLastEnd = forecastPeriod.EndTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mentionsThunder(forecastPeriod.TextDescription) {
|
||||||
|
timing.ThunderMentioned = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if active != nil {
|
||||||
|
timing.PrecipitationWindows = append(timing.PrecipitationWindows, *active)
|
||||||
|
}
|
||||||
|
if len(timing.PrecipitationWindows) > 0 {
|
||||||
|
final := timing.PrecipitationWindows[len(timing.PrecipitationWindows)-1]
|
||||||
|
if final.End != nil {
|
||||||
|
timing.LastPrecipitation = &TimedValue{
|
||||||
|
Value: final.MaxPrecipitationProbability.Value,
|
||||||
|
Time: *final.End,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return timing
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
}
|
}
|
||||||
@@ -99,7 +201,7 @@ func BuildDailySummary(bundle *Bundle, date time.Time, location *time.Location,
|
|||||||
return summary, nil
|
return summary, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildPeriodDailySummaries(bundle *Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) ([]DailySummary, error) {
|
func BuildPeriodDailySummaries(bundle *weatherdata.Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) ([]DailySummary, error) {
|
||||||
if !period.IsValid() {
|
if !period.IsValid() {
|
||||||
return nil, fmt.Errorf("valid forecast period is required")
|
return nil, fmt.Errorf("valid forecast period is required")
|
||||||
}
|
}
|
||||||
@@ -121,7 +223,7 @@ func BuildPeriodDailySummaries(bundle *Bundle, period timeutil.Period, location
|
|||||||
return summaries, nil
|
return summaries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildDailySummaryForPeriod(bundle *Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) {
|
func buildDailySummaryForPeriod(bundle *weatherdata.Bundle, period timeutil.Period, 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")
|
||||||
}
|
}
|
||||||
@@ -155,11 +257,11 @@ func buildDailySummaryForPeriod(bundle *Bundle, period timeutil.Period, location
|
|||||||
return summary, nil
|
return summary, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func SelectHourlyPeriods(run *ForecastRun, period timeutil.Period) []ForecastPeriod {
|
func SelectHourlyPeriods(run *weatherdata.ForecastRun, period timeutil.Period) []weatherdata.ForecastPeriod {
|
||||||
if run == nil {
|
if run == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var selected []ForecastPeriod
|
var selected []weatherdata.ForecastPeriod
|
||||||
for _, forecastPeriod := range run.Periods {
|
for _, forecastPeriod := range run.Periods {
|
||||||
if PeriodForForecastPeriod(forecastPeriod).Overlaps(period) {
|
if PeriodForForecastPeriod(forecastPeriod).Overlaps(period) {
|
||||||
selected = append(selected, forecastPeriod)
|
selected = append(selected, forecastPeriod)
|
||||||
@@ -171,21 +273,21 @@ func SelectHourlyPeriods(run *ForecastRun, period timeutil.Period) []ForecastPer
|
|||||||
return selected
|
return selected
|
||||||
}
|
}
|
||||||
|
|
||||||
func SelectNarrativePeriods(bundle *Bundle, period timeutil.Period) []ForecastPeriod {
|
func SelectNarrativePeriods(bundle *weatherdata.Bundle, period timeutil.Period) []weatherdata.ForecastPeriod {
|
||||||
if bundle == nil || bundle.Narrative == nil {
|
if bundle == nil || bundle.Narrative == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return SelectHourlyPeriods(bundle.Narrative, period)
|
return SelectHourlyPeriods(bundle.Narrative, period)
|
||||||
}
|
}
|
||||||
|
|
||||||
func SelectDiscussion(bundle *Bundle) *Discussion {
|
func SelectDiscussion(bundle *weatherdata.Bundle) *weatherdata.Discussion {
|
||||||
if bundle == nil {
|
if bundle == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return bundle.Discussion
|
return bundle.Discussion
|
||||||
}
|
}
|
||||||
|
|
||||||
func SummarizeDaypart(name string, period timeutil.Period, periods []ForecastPeriod) DaypartSummary {
|
func SummarizeDaypart(name string, period timeutil.Period, periods []weatherdata.ForecastPeriod) DaypartSummary {
|
||||||
summary := DaypartSummary{
|
summary := DaypartSummary{
|
||||||
Name: name,
|
Name: name,
|
||||||
Period: period,
|
Period: period,
|
||||||
@@ -215,7 +317,7 @@ func SummarizeDaypart(name string, period timeutil.Period, periods []ForecastPer
|
|||||||
return summary
|
return summary
|
||||||
}
|
}
|
||||||
|
|
||||||
func periodTemperatureValues(period ForecastPeriod) []*float64 {
|
func periodTemperatureValues(period weatherdata.ForecastPeriod) []*float64 {
|
||||||
values := []*float64{}
|
values := []*float64{}
|
||||||
values = append(values, valueFromPointers(period.TemperatureF, period.TemperatureC)...)
|
values = append(values, valueFromPointers(period.TemperatureF, period.TemperatureC)...)
|
||||||
values = append(values, valueFromPointers(period.TemperatureFMin, period.TemperatureCMin)...)
|
values = append(values, valueFromPointers(period.TemperatureFMin, period.TemperatureCMin)...)
|
||||||
@@ -297,7 +399,11 @@ func indicatorsForText(text string) Indicators {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func numericIndicators(period ForecastPeriod) Indicators {
|
func mentionsThunder(text string) bool {
|
||||||
|
return strings.Contains(strings.ToLower(text), "thunder")
|
||||||
|
}
|
||||||
|
|
||||||
|
func numericIndicators(period weatherdata.ForecastPeriod) Indicators {
|
||||||
windGust := firstValue(period.WindGustMph, period.WindGustKmh)
|
windGust := firstValue(period.WindGustMph, period.WindGustKmh)
|
||||||
windSpeed := firstValue(period.WindSpeedMph, period.WindSpeedKmh)
|
windSpeed := firstValue(period.WindSpeedMph, period.WindSpeedKmh)
|
||||||
indicators := Indicators{}
|
indicators := Indicators{}
|
||||||
@@ -325,7 +431,7 @@ func mergeIndicators(left Indicators, right Indicators) Indicators {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func AlertOverlaps(alertRun *AlertRun, period timeutil.Period) []AlertOverlap {
|
func AlertOverlaps(alertRun *weatherdata.AlertRun, period timeutil.Period) []AlertOverlap {
|
||||||
if alertRun == nil {
|
if alertRun == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBuildDailySummaryGroupsDaypartsAndComputesMetrics(t *testing.T) {
|
func TestBuildDailySummaryGroupsDaypartsAndComputesMetrics(t *testing.T) {
|
||||||
@@ -72,7 +73,7 @@ func TestBuildDailySummaryFromFixtureBundle(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read fixture bundle: %v", err)
|
t.Fatalf("read fixture bundle: %v", err)
|
||||||
}
|
}
|
||||||
var bundle Bundle
|
var bundle weatherdata.Bundle
|
||||||
if err := json.Unmarshal(data, &bundle); err != nil {
|
if err := json.Unmarshal(data, &bundle); err != nil {
|
||||||
t.Fatalf("decode fixture bundle: %v", err)
|
t.Fatalf("decode fixture bundle: %v", err)
|
||||||
}
|
}
|
||||||
@@ -98,7 +99,7 @@ func TestBuildDailySummaryFromFixtureBundle(t *testing.T) {
|
|||||||
func TestOvernightGroupingAcrossMidnight(t *testing.T) {
|
func TestOvernightGroupingAcrossMidnight(t *testing.T) {
|
||||||
location := time.FixedZone("Test", -5*60*60)
|
location := time.FixedZone("Test", -5*60*60)
|
||||||
date := time.Date(2026, 5, 29, 12, 0, 0, 0, location)
|
date := time.Date(2026, 5, 29, 12, 0, 0, 0, location)
|
||||||
bundle := &Bundle{Hourly: &ForecastRun{Periods: []ForecastPeriod{
|
bundle := &weatherdata.Bundle{Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
|
||||||
hour(location, "2026-05-29T23:00:00-05:00", "2026-05-30T00:00:00-05:00", "Snow", 31, nil, nil, nil, nil),
|
hour(location, "2026-05-29T23:00:00-05:00", "2026-05-30T00:00:00-05:00", "Snow", 31, nil, nil, nil, nil),
|
||||||
hour(location, "2026-05-30T05:00:00-05:00", "2026-05-30T06:00:00-05:00", "Fog", 30, nil, nil, nil, nil),
|
hour(location, "2026-05-30T05:00:00-05:00", "2026-05-30T06:00:00-05:00", "Fog", 30, nil, nil, nil, nil),
|
||||||
hour(location, "2026-05-30T06:00:00-05:00", "2026-05-30T07:00:00-05:00", "Clear", 35, nil, nil, nil, nil),
|
hour(location, "2026-05-30T06:00:00-05:00", "2026-05-30T07:00:00-05:00", "Clear", 35, nil, nil, nil, nil),
|
||||||
@@ -122,7 +123,7 @@ func TestOvernightGroupingAcrossMidnight(t *testing.T) {
|
|||||||
func TestBoundaryTimestampsAtDaypartEdges(t *testing.T) {
|
func TestBoundaryTimestampsAtDaypartEdges(t *testing.T) {
|
||||||
location := time.FixedZone("Test", -5*60*60)
|
location := time.FixedZone("Test", -5*60*60)
|
||||||
date := time.Date(2026, 5, 29, 12, 0, 0, 0, location)
|
date := time.Date(2026, 5, 29, 12, 0, 0, 0, location)
|
||||||
bundle := &Bundle{Hourly: &ForecastRun{Periods: []ForecastPeriod{
|
bundle := &weatherdata.Bundle{Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
|
||||||
hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Before", 55, nil, nil, nil, nil),
|
hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Before", 55, nil, nil, nil, nil),
|
||||||
hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T07:00:00-05:00", "Start", 56, nil, nil, nil, nil),
|
hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T07:00:00-05:00", "Start", 56, nil, nil, nil, nil),
|
||||||
hour(location, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00", "After", 70, nil, nil, nil, nil),
|
hour(location, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00", "After", 70, nil, nil, nil, nil),
|
||||||
@@ -145,7 +146,7 @@ func TestBoundaryTimestampsAtDaypartEdges(t *testing.T) {
|
|||||||
|
|
||||||
func TestBuildDailySummaryRequiresHourlyData(t *testing.T) {
|
func TestBuildDailySummaryRequiresHourlyData(t *testing.T) {
|
||||||
location := time.UTC
|
location := time.UTC
|
||||||
_, err := BuildDailySummary(&Bundle{}, time.Now(), location, []DaypartDefinition{
|
_, err := BuildDailySummary(&weatherdata.Bundle{}, time.Now(), location, []DaypartDefinition{
|
||||||
{Name: "morning", Start: "06:00", End: "12:00"},
|
{Name: "morning", Start: "06:00", End: "12:00"},
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -155,7 +156,7 @@ func TestBuildDailySummaryRequiresHourlyData(t *testing.T) {
|
|||||||
|
|
||||||
func TestBuildPeriodDailySummariesClipsPartialDays(t *testing.T) {
|
func TestBuildPeriodDailySummariesClipsPartialDays(t *testing.T) {
|
||||||
location := time.FixedZone("Test", -5*60*60)
|
location := time.FixedZone("Test", -5*60*60)
|
||||||
bundle := &Bundle{Hourly: &ForecastRun{Periods: []ForecastPeriod{
|
bundle := &weatherdata.Bundle{Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
|
||||||
hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Before", 50, nil, nil, nil, nil),
|
hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Before", 50, nil, nil, nil, nil),
|
||||||
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Showers", 60, nil, ptr(60), nil, nil),
|
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Showers", 60, nil, ptr(60), nil, nil),
|
||||||
hour(location, "2026-05-30T14:00:00-05:00", "2026-05-30T15:00:00-05:00", "Hot", 95, nil, nil, nil, nil),
|
hour(location, "2026-05-30T14:00:00-05:00", "2026-05-30T15:00:00-05:00", "Hot", 95, nil, nil, nil, nil),
|
||||||
@@ -191,7 +192,7 @@ func TestBuildPeriodDailySummariesClipsPartialDays(t *testing.T) {
|
|||||||
func TestAlertOverlap(t *testing.T) {
|
func TestAlertOverlap(t *testing.T) {
|
||||||
location := time.FixedZone("Test", -5*60*60)
|
location := time.FixedZone("Test", -5*60*60)
|
||||||
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"}`)
|
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 := &AlertRun{Alerts: []json.RawMessage{raw}}
|
alertRun := &weatherdata.AlertRun{Alerts: []json.RawMessage{raw}}
|
||||||
period := timeutil.Period{
|
period := timeutil.Period{
|
||||||
Start: mustParse("2026-05-29T06:00:00-05:00").In(location),
|
Start: mustParse("2026-05-29T06:00:00-05:00").In(location),
|
||||||
End: mustParse("2026-05-29T09:00:00-05:00").In(location),
|
End: mustParse("2026-05-29T09:00:00-05:00").In(location),
|
||||||
@@ -209,6 +210,113 @@ func TestAlertOverlap(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildPrecipTimingBuildsThresholdWindows(t *testing.T) {
|
||||||
|
location := time.FixedZone("Test", -5*60*60)
|
||||||
|
periods := []weatherdata.ForecastPeriod{
|
||||||
|
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Cloudy", 70, nil, ptr(0), nil, nil),
|
||||||
|
hour(location, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00", "Thunderstorms", 70, nil, ptr(80), nil, nil),
|
||||||
|
hour(location, "2026-05-29T11:00:00-05:00", "2026-05-29T12:00:00-05:00", "Brief lull", 70, nil, ptr(39.999), nil, nil),
|
||||||
|
hour(location, "2026-05-29T13:00:00-05:00", "2026-05-29T14:00:00-05:00", "Unknown rain chance", 70, nil, nil, nil, nil),
|
||||||
|
hour(location, "2026-05-29T10:00:00-05:00", "2026-05-29T11:00:00-05:00", "Rain likely", 70, nil, ptr(60), nil, nil),
|
||||||
|
hour(location, "2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", "Showers", 70, nil, ptr(40), nil, nil),
|
||||||
|
}
|
||||||
|
|
||||||
|
timing := BuildPrecipTiming(periods)
|
||||||
|
|
||||||
|
if timing.FirstPrecipitation == nil || timing.FirstPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T09:00:00-05:00" {
|
||||||
|
t.Fatalf("FirstPrecipitation = %#v, want first threshold window start", timing.FirstPrecipitation)
|
||||||
|
}
|
||||||
|
if timing.LastPrecipitation == nil || timing.LastPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T13:00:00-05:00" {
|
||||||
|
t.Fatalf("LastPrecipitation = %#v, want final closed threshold window end", timing.LastPrecipitation)
|
||||||
|
}
|
||||||
|
if timing.MaxPrecipitationProbability == nil || timing.MaxPrecipitationProbability.Value != 80 {
|
||||||
|
t.Fatalf("MaxPrecipitationProbability = %#v, want 80", timing.MaxPrecipitationProbability)
|
||||||
|
}
|
||||||
|
if timing.ProbabilityThreshold != DefaultPrecipWindowProbabilityThreshold {
|
||||||
|
t.Fatalf("ProbabilityThreshold = %v, want default threshold", timing.ProbabilityThreshold)
|
||||||
|
}
|
||||||
|
if len(timing.PrecipitationWindows) != 2 {
|
||||||
|
t.Fatalf("PrecipitationWindows length = %d, want 2: %#v", len(timing.PrecipitationWindows), timing.PrecipitationWindows)
|
||||||
|
}
|
||||||
|
first := timing.PrecipitationWindows[0]
|
||||||
|
if first.Start.Format(time.RFC3339) != "2026-05-29T09:00:00-05:00" || first.End == nil || first.End.Format(time.RFC3339) != "2026-05-29T11:00:00-05:00" {
|
||||||
|
t.Fatalf("first window = %#v, want 9-11 AM", first)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
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" {
|
||||||
|
t.Fatalf("second window = %#v, want noon-1 PM", second)
|
||||||
|
}
|
||||||
|
if !timing.ThunderMentioned {
|
||||||
|
t.Fatal("ThunderMentioned = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPrecipTimingLeavesFinalWindowOpen(t *testing.T) {
|
||||||
|
location := time.FixedZone("Test", -5*60*60)
|
||||||
|
periods := []weatherdata.ForecastPeriod{
|
||||||
|
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Cloudy", 70, nil, ptr(0), nil, nil),
|
||||||
|
hour(location, "2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", "Showers", 70, nil, ptr(45), nil, nil),
|
||||||
|
hour(location, "2026-05-29T10:00:00-05:00", "2026-05-29T11:00:00-05:00", "Rain likely", 70, nil, ptr(60), nil, nil),
|
||||||
|
}
|
||||||
|
|
||||||
|
timing := BuildPrecipTiming(periods)
|
||||||
|
|
||||||
|
if len(timing.PrecipitationWindows) != 1 {
|
||||||
|
t.Fatalf("PrecipitationWindows length = %d, want 1", len(timing.PrecipitationWindows))
|
||||||
|
}
|
||||||
|
if timing.PrecipitationWindows[0].End != nil {
|
||||||
|
t.Fatalf("open window End = %v, want nil", timing.PrecipitationWindows[0].End)
|
||||||
|
}
|
||||||
|
if timing.LastPrecipitation != nil {
|
||||||
|
t.Fatalf("LastPrecipitation = %#v, want nil for open final window", timing.LastPrecipitation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPrecipTimingSupportsNonDefaultThreshold(t *testing.T) {
|
||||||
|
location := time.FixedZone("Test", -5*60*60)
|
||||||
|
periods := []weatherdata.ForecastPeriod{
|
||||||
|
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Showers", 70, nil, ptr(50), nil, nil),
|
||||||
|
hour(location, "2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", "Rain likely", 70, nil, ptr(60), nil, nil),
|
||||||
|
hour(location, "2026-05-29T10:00:00-05:00", "2026-05-29T11:00:00-05:00", "Showers", 70, nil, ptr(55), nil, nil),
|
||||||
|
hour(location, "2026-05-29T11:00:00-05:00", "2026-05-29T12:00:00-05:00", "Drying out", 70, nil, ptr(20), nil, nil),
|
||||||
|
}
|
||||||
|
|
||||||
|
timing := buildPrecipTimingWithThreshold(periods, 55)
|
||||||
|
|
||||||
|
if timing.ProbabilityThreshold != 55 {
|
||||||
|
t.Fatalf("ProbabilityThreshold = %v, want 55", timing.ProbabilityThreshold)
|
||||||
|
}
|
||||||
|
if len(timing.PrecipitationWindows) != 1 {
|
||||||
|
t.Fatalf("PrecipitationWindows length = %d, want 1", len(timing.PrecipitationWindows))
|
||||||
|
}
|
||||||
|
window := timing.PrecipitationWindows[0]
|
||||||
|
if window.Start.Format(time.RFC3339) != "2026-05-29T09:00:00-05:00" || window.End == nil || window.End.Format(time.RFC3339) != "2026-05-29T11:00:00-05:00" {
|
||||||
|
t.Fatalf("window = %#v, want 9-11 AM", window)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPrecipTimingHandlesDryForecast(t *testing.T) {
|
||||||
|
location := time.FixedZone("Test", -5*60*60)
|
||||||
|
periods := []weatherdata.ForecastPeriod{
|
||||||
|
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Sunny", 70, nil, ptr(0), nil, nil),
|
||||||
|
}
|
||||||
|
|
||||||
|
timing := BuildPrecipTiming(periods)
|
||||||
|
|
||||||
|
if timing.FirstPrecipitation != nil || timing.LastPrecipitation != nil || len(timing.PrecipitationWindows) != 0 || timing.ThunderMentioned {
|
||||||
|
t.Fatalf("dry timing = %#v, want no precip timing and no thunder", timing)
|
||||||
|
}
|
||||||
|
if timing.ProbabilityThreshold != DefaultPrecipWindowProbabilityThreshold {
|
||||||
|
t.Fatalf("ProbabilityThreshold = %v, want default threshold", timing.ProbabilityThreshold)
|
||||||
|
}
|
||||||
|
if timing.MaxPrecipitationProbability == nil || timing.MaxPrecipitationProbability.Value != 0 {
|
||||||
|
t.Fatalf("dry max precip = %#v, want checked zero chance", timing.MaxPrecipitationProbability)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestThresholdHelpers(t *testing.T) {
|
func TestThresholdHelpers(t *testing.T) {
|
||||||
if !DifferenceAtLeast(50, 56, 5) {
|
if !DifferenceAtLeast(50, 56, 5) {
|
||||||
t.Fatal("DifferenceAtLeast = false, want true")
|
t.Fatal("DifferenceAtLeast = false, want true")
|
||||||
@@ -221,31 +329,31 @@ func TestThresholdHelpers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBundle(location *time.Location) *Bundle {
|
func testBundle(location *time.Location) *weatherdata.Bundle {
|
||||||
return &Bundle{
|
return &weatherdata.Bundle{
|
||||||
Hourly: &ForecastRun{Periods: []ForecastPeriod{
|
Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
|
||||||
hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Cloudy", 55, nil, nil, nil, nil),
|
hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Cloudy", 55, nil, nil, nil, nil),
|
||||||
hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T07:00:00-05:00", "Thunderstorms and gusty wind", 58, ptr(57), ptr(70), ptr(22), ptr(40)),
|
hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T07:00:00-05:00", "Thunderstorms and gusty wind", 58, ptr(57), ptr(70), ptr(22), ptr(40)),
|
||||||
hour(location, "2026-05-29T11:00:00-05:00", "2026-05-29T12:00:00-05:00", "Thunderstorms and gusty wind", 72, ptr(74), ptr(60), ptr(18), ptr(35)),
|
hour(location, "2026-05-29T11:00:00-05:00", "2026-05-29T12:00:00-05:00", "Thunderstorms and gusty wind", 72, ptr(74), ptr(60), ptr(18), ptr(35)),
|
||||||
hour(location, "2026-05-29T14:00:00-05:00", "2026-05-29T15:00:00-05:00", "Hot and sunny", 96, ptr(100), ptr(5), ptr(10), ptr(12)),
|
hour(location, "2026-05-29T14:00:00-05:00", "2026-05-29T15:00:00-05:00", "Hot and sunny", 96, ptr(100), ptr(5), ptr(10), ptr(12)),
|
||||||
}},
|
}},
|
||||||
Narrative: &ForecastRun{Periods: []ForecastPeriod{
|
Narrative: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
|
||||||
hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T18:00:00-05:00", "Storms early, hot later.", 96, nil, nil, nil, nil),
|
hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T18:00:00-05:00", "Storms early, hot later.", 96, nil, nil, nil, nil),
|
||||||
}},
|
}},
|
||||||
Alerts: &AlertRun{Alerts: []json.RawMessage{
|
Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{
|
||||||
json.RawMessage(`{"event":"Severe Thunderstorm Watch","headline":"Storms possible","severity":"Severe","effective":"2026-05-29T06:30:00-05:00","expires":"2026-05-29T11:30:00-05:00"}`),
|
json.RawMessage(`{"event":"Severe Thunderstorm Watch","headline":"Storms possible","severity":"Severe","effective":"2026-05-29T06:30:00-05:00","expires":"2026-05-29T11:30:00-05:00"}`),
|
||||||
}},
|
}},
|
||||||
Discussion: &Discussion{Product: "discussion", KeyMessages: []string{"Storms possible."}},
|
Discussion: &weatherdata.Discussion{Product: "discussion", KeyMessages: []string{"Storms possible."}},
|
||||||
Sources: []Source{{Name: "hourly"}},
|
Sources: []weatherdata.Source{{Name: "hourly"}},
|
||||||
Warnings: []SourceWarning{{Source: "daily", Code: "missing_source"}},
|
Warnings: []weatherdata.SourceWarning{{Source: "daily", Code: "missing_source"}},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func hour(location *time.Location, start string, end string, text string, temperature float64, apparent *float64, precip *float64, wind *float64, gust *float64) ForecastPeriod {
|
func hour(location *time.Location, start string, end string, text string, temperature float64, apparent *float64, precip *float64, wind *float64, gust *float64) weatherdata.ForecastPeriod {
|
||||||
startTime := mustParse(start).In(location)
|
startTime := mustParse(start).In(location)
|
||||||
endTime := mustParse(end).In(location)
|
endTime := mustParse(end).In(location)
|
||||||
temp := temperature
|
temp := temperature
|
||||||
return ForecastPeriod{
|
return weatherdata.ForecastPeriod{
|
||||||
StartTime: startTime,
|
StartTime: startTime,
|
||||||
EndTime: endTime,
|
EndTime: endTime,
|
||||||
TextDescription: text,
|
TextDescription: text,
|
||||||
|
|||||||
52
internal/generatedtext/hourly.go
Normal file
52
internal/generatedtext/hourly.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// Package generatedtext validates structured LLM text and render contexts.
|
||||||
|
package generatedtext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Hourly struct {
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
ForecastDiscussion string `json:"forecast_discussion"`
|
||||||
|
PrecipitationTiming string `json:"precipitation_timing,omitempty"`
|
||||||
|
Confidence string `json:"confidence,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateHourly(data []byte) (Hourly, []byte, error) {
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
|
||||||
|
var value Hourly
|
||||||
|
if err := decoder.Decode(&value); err != nil {
|
||||||
|
return Hourly{}, nil, fmt.Errorf("decode hourly generated text: %w", err)
|
||||||
|
}
|
||||||
|
var extra any
|
||||||
|
if err := decoder.Decode(&extra); err != nil {
|
||||||
|
if err != io.EOF {
|
||||||
|
return Hourly{}, nil, fmt.Errorf("decode hourly generated text: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Hourly{}, nil, fmt.Errorf("decode hourly generated text: multiple JSON values")
|
||||||
|
}
|
||||||
|
|
||||||
|
value.Summary = strings.TrimSpace(value.Summary)
|
||||||
|
value.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 := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return Hourly{}, nil, fmt.Errorf("normalize hourly generated text: %w", 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
373
internal/generatedtext/render_context.go
Normal file
373
internal/generatedtext/render_context.go
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
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 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Tomorrow, collected facts.CollectedFacts, derived facts.DerivedFacts) (TomorrowRenderContext, error) {
|
||||||
|
location, err := timeutil.LoadLocation(metadata.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowRenderContext{}, fmt.Errorf("build tomorrow render context: %w", err)
|
||||||
|
}
|
||||||
|
if metadata.GeneratedAt.IsZero() {
|
||||||
|
return TomorrowRenderContext{}, fmt.Errorf("build tomorrow render context: generatedAt is required")
|
||||||
|
}
|
||||||
|
if !metadata.ValidPeriod.IsValid() {
|
||||||
|
return TomorrowRenderContext{}, fmt.Errorf("build tomorrow render context: valid period is required")
|
||||||
|
}
|
||||||
|
modules, err := tomorrowTemplateModules(snapshot, derived)
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowRenderContext{}, err
|
||||||
|
}
|
||||||
|
forecastDate := metadata.ValidPeriod.Start.In(location)
|
||||||
|
forecastDayName := forecastDate.Format("Monday")
|
||||||
|
return TomorrowRenderContext{
|
||||||
|
Report: TomorrowReportContext{
|
||||||
|
Title: forecastDayName + "'s Weather",
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
GeneratedText: generated,
|
||||||
|
Modules: modules,
|
||||||
|
Collected: collected,
|
||||||
|
Derived: derived,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, error) {
|
||||||
|
metadata, err := optionalStanza[briefing.MetadataModule](snapshot, string(module.Metadata))
|
||||||
|
if err != nil {
|
||||||
|
return HourlyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
current, err := optionalStanza[briefing.CurrentConditionsModule](snapshot, string(module.CurrentConditions))
|
||||||
|
if err != nil {
|
||||||
|
return HourlyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
hourly, err := optionalStanza[briefing.HourlyForecastModule](snapshot, string(module.HourlyForecast))
|
||||||
|
if err != nil {
|
||||||
|
return HourlyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
precip, err := optionalStanza[briefing.PrecipTimingModule](snapshot, string(module.PrecipTiming))
|
||||||
|
if err != nil {
|
||||||
|
return HourlyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
alerts, err := optionalStanza[briefing.AlertDigestModule](snapshot, string(module.AlertDigest))
|
||||||
|
if err != nil {
|
||||||
|
return HourlyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
outlooks, err := optionalStanza[briefing.SPCConvectiveOutlooksModule](snapshot, string(module.SPCConvectiveOutlooks))
|
||||||
|
if err != nil {
|
||||||
|
return HourlyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
discussion, err := optionalStanza[briefing.AreaForecastDiscussionModule](snapshot, string(module.AreaForecastDiscussion))
|
||||||
|
if err != nil {
|
||||||
|
return HourlyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
spcDiscussion, err := optionalStanza[briefing.SPCConvectiveDiscussionModule](snapshot, string(module.SPCConvectiveDiscussion))
|
||||||
|
if err != nil {
|
||||||
|
return HourlyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
story, err := optionalStanza[briefing.WeatherStoryModule](snapshot, string(module.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) {
|
||||||
|
metadata, err := optionalStanza[briefing.MetadataModule](snapshot, string(module.Metadata))
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowTemplateModules{}, err
|
||||||
|
}
|
||||||
|
current, err := optionalStanza[briefing.CurrentConditionsModule](snapshot, string(module.CurrentConditions))
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowTemplateModules{}, err
|
||||||
|
}
|
||||||
|
hourly, err := optionalStanza[briefing.HourlyForecastModule](snapshot, string(module.HourlyForecast))
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowTemplateModules{}, err
|
||||||
|
}
|
||||||
|
daily, err := optionalStanza[briefing.DerivedDailySummaryModule](snapshot, string(module.DerivedDailySummary))
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowTemplateModules{}, err
|
||||||
|
}
|
||||||
|
dayparts, err := optionalStanza[map[string]briefing.DerivedDaypartSummaryModule](snapshot, string(module.DerivedDaypartSummaries))
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowTemplateModules{}, err
|
||||||
|
}
|
||||||
|
precip, err := optionalStanza[briefing.PrecipTimingModule](snapshot, string(module.PrecipTiming))
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowTemplateModules{}, err
|
||||||
|
}
|
||||||
|
alerts, err := optionalStanza[briefing.AlertDigestModule](snapshot, string(module.AlertDigest))
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowTemplateModules{}, err
|
||||||
|
}
|
||||||
|
outlooks, err := optionalStanza[briefing.SPCConvectiveOutlooksModule](snapshot, string(module.SPCConvectiveOutlooks))
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowTemplateModules{}, err
|
||||||
|
}
|
||||||
|
discussion, err := optionalStanza[briefing.AreaForecastDiscussionModule](snapshot, string(module.AreaForecastDiscussion))
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowTemplateModules{}, err
|
||||||
|
}
|
||||||
|
spcDiscussion, err := optionalStanza[briefing.SPCConvectiveDiscussionModule](snapshot, string(module.SPCConvectiveDiscussion))
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowTemplateModules{}, err
|
||||||
|
}
|
||||||
|
story, err := optionalStanza[briefing.WeatherStoryModule](snapshot, string(module.WeatherStory))
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowTemplateModules{}, err
|
||||||
|
}
|
||||||
|
planning, err := optionalStanza[briefing.TomorrowPlanningModule](snapshot, string(module.TomorrowPlanning))
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowTemplateModules{}, err
|
||||||
|
}
|
||||||
|
return TomorrowTemplateModules{
|
||||||
|
Metadata: metadata,
|
||||||
|
CurrentConditions: current,
|
||||||
|
HourlyForecast: hourly,
|
||||||
|
DerivedDailySummary: daily,
|
||||||
|
DerivedDaypartSummaries: dayparts,
|
||||||
|
Dayparts: orderedTomorrowDayparts(dayparts, derived.DaypartSummaries),
|
||||||
|
PrecipTiming: precip,
|
||||||
|
AlertDigest: alerts,
|
||||||
|
SPCConvectiveOutlooks: outlooks,
|
||||||
|
AreaForecastDiscussion: discussion,
|
||||||
|
SPCConvectiveDiscussion: spcDiscussion,
|
||||||
|
WeatherStory: story,
|
||||||
|
TomorrowPlanning: planning,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func optionalStanza[T any](snapshot module.Snapshot, name string) (*T, error) {
|
||||||
|
output, ok := snapshot.LookupStanza(name)
|
||||||
|
if !ok || output.Value == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
value, _, err := module.StanzaValue[T](snapshot, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build render context: %w", err)
|
||||||
|
}
|
||||||
|
return &value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderedTomorrowDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []TomorrowDaypartContext {
|
||||||
|
if dayparts == nil || len(*dayparts) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]TomorrowDaypartContext, 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, TomorrowDaypartContext{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, TomorrowDaypartContext{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")
|
||||||
|
}
|
||||||
581
internal/generatedtext/render_context_test.go
Normal file
581
internal/generatedtext/render_context_test.go
Normal file
@@ -0,0 +1,581 @@
|
|||||||
|
package generatedtext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/reporttemplate"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildHourlyRenderContext(t *testing.T) {
|
||||||
|
metadata := testMetadata()
|
||||||
|
snapshot := testSnapshot(t)
|
||||||
|
generated := Hourly{
|
||||||
|
Summary: "Storm chances increase through late morning.",
|
||||||
|
ForecastDiscussion: "A front will keep the region unsettled.",
|
||||||
|
PrecipitationTiming: "A cold front is moving into the region.",
|
||||||
|
Confidence: "Medium confidence in timing.",
|
||||||
|
}
|
||||||
|
collected := testCollected()
|
||||||
|
derived := testDerived()
|
||||||
|
ctx, err := BuildHourlyRenderContext(metadata, snapshot, generated, collected, derived)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildHourlyRenderContext() error = %v", err)
|
||||||
|
}
|
||||||
|
if ctx.Report.Title != "Hourly Report" {
|
||||||
|
t.Fatalf("Report.Title = %q, want Hourly Report", ctx.Report.Title)
|
||||||
|
}
|
||||||
|
if ctx.Report.LocationName != "Brentwood, MO" {
|
||||||
|
t.Fatalf("Report.LocationName = %q, want Brentwood, MO", ctx.Report.LocationName)
|
||||||
|
}
|
||||||
|
if ctx.Report.ValidPeriodLabel != "2026-05-29 at 8:30 AM to 2026-05-29 at 2:30 PM" {
|
||||||
|
t.Fatalf("Report.ValidPeriodLabel = %q, want friendly period", ctx.Report.ValidPeriodLabel)
|
||||||
|
}
|
||||||
|
if ctx.Report.GeneratedAtLabel != "Friday, May 29, 2026 at 8:30 AM" {
|
||||||
|
t.Fatalf("Report.GeneratedAtLabel = %q, want friendly generated-at label", ctx.Report.GeneratedAtLabel)
|
||||||
|
}
|
||||||
|
if ctx.Modules.CurrentConditions == nil || ctx.Modules.CurrentConditions.ConditionText != "Partly cloudy" || ctx.Modules.CurrentConditions.TemperatureF == nil || *ctx.Modules.CurrentConditions.TemperatureF != 74 {
|
||||||
|
t.Fatalf("Modules.CurrentConditions = %#v, want structured current conditions", ctx.Modules.CurrentConditions)
|
||||||
|
}
|
||||||
|
if ctx.Modules.HourlyForecast == nil || len(ctx.Modules.HourlyForecast.Periods) != 2 {
|
||||||
|
t.Fatalf("Modules.HourlyForecast = %#v, want 2 periods", ctx.Modules.HourlyForecast)
|
||||||
|
}
|
||||||
|
if period := ctx.Modules.HourlyForecast.Periods[1]; period.PeriodBegins != "2026-05-29 at 10:00 AM" || period.TextDescription != "Showers" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
|
||||||
|
t.Fatalf("Modules.HourlyForecast.Periods[1] = %#v, want 10 AM showers row", period)
|
||||||
|
}
|
||||||
|
if ctx.Modules.PrecipTiming == nil || ctx.Modules.PrecipTiming.MaxPopPercent == nil || *ctx.Modules.PrecipTiming.MaxPopPercent != 70 {
|
||||||
|
t.Fatalf("Modules.PrecipTiming = %#v, want max pop", ctx.Modules.PrecipTiming)
|
||||||
|
}
|
||||||
|
if ctx.Modules.AlertDigest == nil || len(ctx.Modules.AlertDigest.Relevant) != 1 || ctx.Modules.AlertDigest.Relevant[0].Event != "Flood Watch" {
|
||||||
|
t.Fatalf("Modules.AlertDigest = %#v, want alert", ctx.Modules.AlertDigest)
|
||||||
|
}
|
||||||
|
if ctx.Modules.SPCConvectiveOutlooks == nil || len(ctx.Modules.SPCConvectiveOutlooks.Outlooks) != 1 || ctx.Modules.SPCConvectiveOutlooks.Outlooks[0].LabelText != "Slight Risk" {
|
||||||
|
t.Fatalf("Modules.SPCConvectiveOutlooks = %#v, want outlook", ctx.Modules.SPCConvectiveOutlooks)
|
||||||
|
}
|
||||||
|
if ctx.Modules.AreaForecastDiscussion == nil || ctx.Modules.AreaForecastDiscussion.ShortTerm != "Short-term discussion favors increasing rain coverage." {
|
||||||
|
t.Fatalf("Modules.AreaForecastDiscussion = %#v, want discussion text", ctx.Modules.AreaForecastDiscussion)
|
||||||
|
}
|
||||||
|
if ctx.Modules.SPCConvectiveDiscussion == nil || len(ctx.Modules.SPCConvectiveDiscussion.Discussions) != 1 || ctx.Modules.SPCConvectiveDiscussion.Discussions[0].Headline != "Mesoscale discussion" {
|
||||||
|
t.Fatalf("Modules.SPCConvectiveDiscussion = %#v, want discussion", ctx.Modules.SPCConvectiveDiscussion)
|
||||||
|
}
|
||||||
|
if ctx.Modules.WeatherStory == nil || ctx.Modules.WeatherStory.Title != "Morning storms" {
|
||||||
|
t.Fatalf("Modules.WeatherStory = %#v, want story", ctx.Modules.WeatherStory)
|
||||||
|
}
|
||||||
|
if !ctx.Collected.FetchedAt.Equal(collected.FetchedAt) {
|
||||||
|
t.Fatalf("Collected.FetchedAt = %s, want %s", ctx.Collected.FetchedAt, collected.FetchedAt)
|
||||||
|
}
|
||||||
|
if !ctx.Derived.PrecipTiming.ThunderMentioned {
|
||||||
|
t.Fatalf("Derived.PrecipTiming.ThunderMentioned = false, want true")
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered, err := reporttemplate.Render("hourly", ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render() error = %v", err)
|
||||||
|
}
|
||||||
|
text := string(rendered)
|
||||||
|
for _, want := range []string{
|
||||||
|
"# Hourly Report",
|
||||||
|
"**Updated:** Friday, May 29, 2026 at 8:30 AM",
|
||||||
|
"Storm chances increase through late morning.",
|
||||||
|
"- **10:00 AM:** 75°F and showers. Probability of precipitation is 70%.",
|
||||||
|
"- **Flood Watch**: Flood Watch until early afternoon",
|
||||||
|
"A cold front is moving into the region.",
|
||||||
|
"A front will keep the region unsettled.",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Fatalf("rendered template missing %q:\n%s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildHourlyRenderContextAllowsOmittedOptionalModules(t *testing.T) {
|
||||||
|
snapshot, err := module.NewSnapshot([]module.Output{
|
||||||
|
{ID: module.CurrentConditions, StanzaName: string(module.CurrentConditions), Value: briefing.CurrentConditionsModule{}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSnapshot() error = %v", err)
|
||||||
|
}
|
||||||
|
ctx, err := BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{
|
||||||
|
Summary: "Storm chances increase.",
|
||||||
|
ForecastDiscussion: "A front will keep the region unsettled.",
|
||||||
|
}, testCollected(), facts.DerivedFacts{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildHourlyRenderContext() error = %v", err)
|
||||||
|
}
|
||||||
|
if ctx.Modules.HourlyForecast != nil {
|
||||||
|
t.Fatalf("Modules.HourlyForecast = %#v, want nil for omitted module", ctx.Modules.HourlyForecast)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildTomorrowRenderContext(t *testing.T) {
|
||||||
|
metadata := testTomorrowMetadata()
|
||||||
|
snapshot := testTomorrowSnapshot(t)
|
||||||
|
generated := Tomorrow{
|
||||||
|
Summary: "Tomorrow starts quiet, then showers become more likely later in the day.",
|
||||||
|
ForecastDiscussion: []string{
|
||||||
|
"Morning conditions should stay mostly dry.",
|
||||||
|
"Rain chances increase during the afternoon as deeper moisture arrives.",
|
||||||
|
},
|
||||||
|
PrecipitationTiming: "The most likely rain window is from midafternoon into early evening.",
|
||||||
|
}
|
||||||
|
collected := testCollected()
|
||||||
|
derived := testTomorrowDerived()
|
||||||
|
|
||||||
|
ctx, err := BuildTomorrowRenderContext(metadata, snapshot, generated, collected, derived)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildTomorrowRenderContext() error = %v", err)
|
||||||
|
}
|
||||||
|
if ctx.Report.Title != "Monday's Weather" {
|
||||||
|
t.Fatalf("Report.Title = %q, want Monday's Weather", ctx.Report.Title)
|
||||||
|
}
|
||||||
|
if ctx.Report.ForecastDateLabel != "Monday, June 15, 2026" || ctx.Report.ForecastDayName != "Monday" {
|
||||||
|
t.Fatalf("forecast date labels = %q/%q, want Monday labels", ctx.Report.ForecastDateLabel, ctx.Report.ForecastDayName)
|
||||||
|
}
|
||||||
|
if ctx.Report.GeneratedAtLabel != "Sunday, June 14, 2026 at 9:14 AM" {
|
||||||
|
t.Fatalf("Report.GeneratedAtLabel = %q, want friendly generated-at label", ctx.Report.GeneratedAtLabel)
|
||||||
|
}
|
||||||
|
if ctx.Modules.Metadata == nil || ctx.Modules.Metadata.ReportID != report.Tomorrow {
|
||||||
|
t.Fatalf("Modules.Metadata = %#v, want tomorrow metadata", ctx.Modules.Metadata)
|
||||||
|
}
|
||||||
|
if ctx.Modules.CurrentConditions == nil || ctx.Modules.HourlyForecast == nil {
|
||||||
|
t.Fatalf("current/hourly modules = %#v/%#v, want available in render context", ctx.Modules.CurrentConditions, ctx.Modules.HourlyForecast)
|
||||||
|
}
|
||||||
|
if ctx.Modules.DerivedDailySummary == nil || ctx.Modules.DerivedDailySummary.HighTempF == nil || *ctx.Modules.DerivedDailySummary.HighTempF != 74 {
|
||||||
|
t.Fatalf("Modules.DerivedDailySummary = %#v, want daily summary", ctx.Modules.DerivedDailySummary)
|
||||||
|
}
|
||||||
|
if ctx.Modules.DerivedDaypartSummaries == nil || len(*ctx.Modules.DerivedDaypartSummaries) != 2 {
|
||||||
|
t.Fatalf("Modules.DerivedDaypartSummaries = %#v, want daypart map", ctx.Modules.DerivedDaypartSummaries)
|
||||||
|
}
|
||||||
|
if len(ctx.Modules.Dayparts) != 2 || ctx.Modules.Dayparts[0].Key != "morning" || ctx.Modules.Dayparts[1].Key != "afternoon" {
|
||||||
|
t.Fatalf("Modules.Dayparts = %#v, want configured order", ctx.Modules.Dayparts)
|
||||||
|
}
|
||||||
|
if ctx.Modules.PrecipTiming == nil || len(ctx.Modules.PrecipTiming.PrecipitationWindows) != 1 {
|
||||||
|
t.Fatalf("Modules.PrecipTiming = %#v, want precipitation window", ctx.Modules.PrecipTiming)
|
||||||
|
}
|
||||||
|
if ctx.Modules.AlertDigest == nil || ctx.Modules.SPCConvectiveOutlooks == nil || ctx.Modules.AreaForecastDiscussion == nil || ctx.Modules.SPCConvectiveDiscussion == nil || ctx.Modules.WeatherStory == nil || ctx.Modules.TomorrowPlanning == nil {
|
||||||
|
t.Fatalf("optional modules missing from render context: %#v", ctx.Modules)
|
||||||
|
}
|
||||||
|
if !ctx.Collected.FetchedAt.Equal(collected.FetchedAt) {
|
||||||
|
t.Fatalf("Collected.FetchedAt = %s, want %s", ctx.Collected.FetchedAt, collected.FetchedAt)
|
||||||
|
}
|
||||||
|
if len(ctx.Derived.DaypartSummaries) != 2 {
|
||||||
|
t.Fatalf("Derived.DaypartSummaries = %#v, want passthrough facts", ctx.Derived.DaypartSummaries)
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered, err := reporttemplate.Render("tomorrow", ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render() error = %v", err)
|
||||||
|
}
|
||||||
|
text := string(rendered)
|
||||||
|
for _, want := range []string{
|
||||||
|
"# Monday's Weather",
|
||||||
|
"**Forecast date:** Monday, June 15, 2026",
|
||||||
|
"Tomorrow starts quiet, then showers become more likely later in the day.",
|
||||||
|
"- **Morning:** Partly cloudy, with temperatures in the low 60s.",
|
||||||
|
"- **Afternoon:** Showers, with temperatures in the mid 70s. Chance of precipitation is 70%.",
|
||||||
|
"## Precipitation Timing",
|
||||||
|
"- **3:00 PM** to **6:00 PM**: Precipitation is expected during this period. The peak precipitation chance is 70% at 3:00 PM.",
|
||||||
|
"The most likely rain window is from midafternoon into early evening.",
|
||||||
|
"Morning conditions should stay mostly dry.",
|
||||||
|
"Rain chances increase during the afternoon as deeper moisture arrives.",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Fatalf("rendered template missing %q:\n%s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertOrderedText(t, text, []string{
|
||||||
|
"# Monday's Weather",
|
||||||
|
"## Daypart Forecast",
|
||||||
|
"- **Morning:**",
|
||||||
|
"- **Afternoon:**",
|
||||||
|
"## Precipitation Timing",
|
||||||
|
"## Forecast Discussion",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildTomorrowRenderContextAllowsOmittedOptionalModules(t *testing.T) {
|
||||||
|
snapshot, err := module.NewSnapshot(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSnapshot() error = %v", err)
|
||||||
|
}
|
||||||
|
ctx, err := BuildTomorrowRenderContext(testTomorrowMetadata(), snapshot, Tomorrow{
|
||||||
|
Summary: "Dry weather is expected tomorrow.",
|
||||||
|
ForecastDiscussion: []string{"High pressure keeps conditions quiet."},
|
||||||
|
}, testCollected(), facts.DerivedFacts{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildTomorrowRenderContext() error = %v", err)
|
||||||
|
}
|
||||||
|
if ctx.Modules.PrecipTiming != nil || len(ctx.Modules.Dayparts) != 0 {
|
||||||
|
t.Fatalf("Modules = %#v, want omitted optional modules", ctx.Modules)
|
||||||
|
}
|
||||||
|
rendered, err := reporttemplate.Render("tomorrow", ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render() error = %v", err)
|
||||||
|
}
|
||||||
|
text := string(rendered)
|
||||||
|
if strings.Contains(text, "## Precipitation Timing") {
|
||||||
|
t.Fatalf("rendered template included precipitation section without windows:\n%s", text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, "- No daypart forecast details are available.") {
|
||||||
|
t.Fatalf("rendered template missing daypart fallback:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMetadata() briefing.Metadata {
|
||||||
|
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
|
||||||
|
return briefing.Metadata{
|
||||||
|
RunID: "run-1",
|
||||||
|
ReportID: report.Hourly,
|
||||||
|
PromptID: "weather.hourly_generated_text",
|
||||||
|
GeneratedAt: generatedAt,
|
||||||
|
Units: "imperial",
|
||||||
|
Timezone: "America/Chicago",
|
||||||
|
ValidPeriod: timeutil.Period{
|
||||||
|
Start: generatedAt,
|
||||||
|
End: generatedAt.Add(6 * time.Hour),
|
||||||
|
},
|
||||||
|
Location: &briefing.LocationContext{
|
||||||
|
Name: "Brentwood",
|
||||||
|
Region: "MO",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTomorrowMetadata() briefing.Metadata {
|
||||||
|
generatedAt := time.Date(2026, 6, 14, 14, 14, 0, 0, time.UTC)
|
||||||
|
return briefing.Metadata{
|
||||||
|
RunID: "run-tomorrow",
|
||||||
|
ReportID: report.Tomorrow,
|
||||||
|
PromptID: "weather.tomorrow_generated_text",
|
||||||
|
GeneratedAt: generatedAt,
|
||||||
|
Units: "imperial",
|
||||||
|
Timezone: "America/Chicago",
|
||||||
|
ValidPeriod: timeutil.Period{
|
||||||
|
Start: time.Date(2026, 6, 15, 5, 0, 0, 0, time.UTC),
|
||||||
|
End: time.Date(2026, 6, 16, 5, 0, 0, 0, time.UTC),
|
||||||
|
},
|
||||||
|
Location: &briefing.LocationContext{
|
||||||
|
Name: "Brentwood",
|
||||||
|
Region: "MO",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCollected() facts.CollectedFacts {
|
||||||
|
return facts.CollectedFacts{FetchedAt: time.Date(2026, 5, 29, 13, 31, 0, 0, time.UTC)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDerived() facts.DerivedFacts {
|
||||||
|
return facts.DerivedFacts{PrecipTiming: forecast.PrecipTiming{ThunderMentioned: true}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTomorrowDerived() facts.DerivedFacts {
|
||||||
|
morningStart := time.Date(2026, 6, 15, 11, 0, 0, 0, time.UTC)
|
||||||
|
afternoonStart := time.Date(2026, 6, 15, 17, 0, 0, 0, time.UTC)
|
||||||
|
return facts.DerivedFacts{
|
||||||
|
DaypartSummaries: []forecast.DaypartSummary{
|
||||||
|
{
|
||||||
|
Name: "morning",
|
||||||
|
Period: timeutil.Period{
|
||||||
|
Start: morningStart,
|
||||||
|
End: afternoonStart,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "afternoon",
|
||||||
|
Period: timeutil.Period{
|
||||||
|
Start: afternoonStart,
|
||||||
|
End: time.Date(2026, 6, 15, 23, 0, 0, 0, time.UTC),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSnapshot(t *testing.T) module.Snapshot {
|
||||||
|
t.Helper()
|
||||||
|
snapshot, err := module.NewSnapshot([]module.Output{
|
||||||
|
{
|
||||||
|
ID: module.CurrentConditions,
|
||||||
|
StanzaName: string(module.CurrentConditions),
|
||||||
|
Value: briefing.CurrentConditionsModule{
|
||||||
|
ConditionText: "Partly cloudy",
|
||||||
|
ConditionTextLower: "partly cloudy",
|
||||||
|
TemperatureF: intPtr(74),
|
||||||
|
ApparentTemperatureF: intPtr(76),
|
||||||
|
RelativeHumidityPercent: intPtr(71),
|
||||||
|
WindSpeedMph: intPtr(8),
|
||||||
|
WindDirection: "S",
|
||||||
|
WindDirectionText: "south",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.HourlyForecast,
|
||||||
|
StanzaName: string(module.HourlyForecast),
|
||||||
|
Value: briefing.HourlyForecastModule{
|
||||||
|
Periods: []briefing.HourlyForecastPeriod{
|
||||||
|
{
|
||||||
|
HourLabel: "9:00 AM",
|
||||||
|
PeriodBegins: "2026-05-29 at 9:00 AM",
|
||||||
|
TextDescription: "Cloudy",
|
||||||
|
TextDescriptionLower: "cloudy",
|
||||||
|
TemperatureF: floatPtr(74),
|
||||||
|
ProbabilityOfPrecipitationPercent: floatPtr(30),
|
||||||
|
MentionPrecipitation: true,
|
||||||
|
WindSpeedMph: floatPtr(8),
|
||||||
|
WindDirection: "S",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
HourLabel: "10:00 AM",
|
||||||
|
PeriodBegins: "2026-05-29 at 10:00 AM",
|
||||||
|
TextDescription: "Showers",
|
||||||
|
TextDescriptionLower: "showers",
|
||||||
|
TemperatureF: floatPtr(75),
|
||||||
|
ProbabilityOfPrecipitationPercent: floatPtr(70),
|
||||||
|
MentionPrecipitation: true,
|
||||||
|
WindSpeedMph: floatPtr(10),
|
||||||
|
WindGustMph: floatPtr(18),
|
||||||
|
WindDirection: "S",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.PrecipTiming,
|
||||||
|
StanzaName: string(module.PrecipTiming),
|
||||||
|
Value: briefing.PrecipTimingModule{
|
||||||
|
MaxPopPercent: intPtr(70),
|
||||||
|
MaxPopTime: "10 AM",
|
||||||
|
ProbabilityThreshold: 50,
|
||||||
|
PrecipitationWindows: []briefing.PrecipitationWindowModule{
|
||||||
|
{
|
||||||
|
PeriodBegins: "2026-05-29 at 10:00 AM",
|
||||||
|
PeriodBeginsHourLabel: "10:00 AM",
|
||||||
|
PeriodEnds: "2026-05-29 at 12:00 PM",
|
||||||
|
PeriodEndsHourLabel: "12:00 PM",
|
||||||
|
MaxPopPercent: intPtr(70),
|
||||||
|
MaxPopTime: "10 AM",
|
||||||
|
MaxPopHourLabel: "10:00 AM",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ThunderMentioned: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.AlertDigest,
|
||||||
|
StanzaName: string(module.AlertDigest),
|
||||||
|
Value: briefing.AlertDigestModule{
|
||||||
|
Checked: true,
|
||||||
|
ActiveCount: 1,
|
||||||
|
RelevantCount: 1,
|
||||||
|
Relevant: []briefing.AlertSummary{
|
||||||
|
{Event: "Flood Watch", Headline: "Flood Watch until early afternoon", Severity: "Moderate"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.SPCConvectiveOutlooks,
|
||||||
|
StanzaName: string(module.SPCConvectiveOutlooks),
|
||||||
|
Value: briefing.SPCConvectiveOutlooksModule{
|
||||||
|
Checked: true,
|
||||||
|
OutlookCount: 1,
|
||||||
|
Outlooks: []briefing.SPCConvectiveOutlookRecord{
|
||||||
|
{
|
||||||
|
Label: "SLGT",
|
||||||
|
LabelText: "Slight Risk",
|
||||||
|
PeriodBegins: "2026-05-29 at 7:00 AM",
|
||||||
|
PeriodEnds: "2026-05-29 at 3:00 PM",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.AreaForecastDiscussion,
|
||||||
|
StanzaName: string(module.AreaForecastDiscussion),
|
||||||
|
Value: briefing.AreaForecastDiscussionModule{
|
||||||
|
KeyMessages: []string{"Storms are most likely late morning."},
|
||||||
|
ShortTerm: "Short-term discussion favors increasing rain coverage.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.SPCConvectiveDiscussion,
|
||||||
|
StanzaName: string(module.SPCConvectiveDiscussion),
|
||||||
|
Value: briefing.SPCConvectiveDiscussionModule{
|
||||||
|
IncludedBecause: "categorical severity_rank >= 3",
|
||||||
|
Discussions: []briefing.SPCConvectiveDiscussionRecord{
|
||||||
|
{Headline: "Mesoscale discussion", Summary: "Strong storms may develop late morning."},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.WeatherStory,
|
||||||
|
StanzaName: string(module.WeatherStory),
|
||||||
|
Value: briefing.WeatherStoryModule{
|
||||||
|
Available: true,
|
||||||
|
Title: "Morning storms",
|
||||||
|
Description: "Morning storms remain the main story.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSnapshot() error = %v", err)
|
||||||
|
}
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTomorrowSnapshot(t *testing.T) module.Snapshot {
|
||||||
|
t.Helper()
|
||||||
|
snapshot, err := module.NewSnapshot([]module.Output{
|
||||||
|
{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: string(module.Metadata),
|
||||||
|
Value: briefing.MetadataModule{
|
||||||
|
RunID: "run-tomorrow",
|
||||||
|
ReportID: report.Tomorrow,
|
||||||
|
PromptID: "weather.tomorrow_generated_text",
|
||||||
|
GeneratedAt: testTomorrowMetadata().GeneratedAt,
|
||||||
|
Units: "imperial",
|
||||||
|
Timezone: "America/Chicago",
|
||||||
|
ValidPeriod: testTomorrowMetadata().ValidPeriod,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.CurrentConditions,
|
||||||
|
StanzaName: string(module.CurrentConditions),
|
||||||
|
Value: briefing.CurrentConditionsModule{
|
||||||
|
ConditionTextLower: "clear",
|
||||||
|
TemperatureF: intPtr(58),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.HourlyForecast,
|
||||||
|
StanzaName: string(module.HourlyForecast),
|
||||||
|
Value: briefing.HourlyForecastModule{
|
||||||
|
Periods: []briefing.HourlyForecastPeriod{
|
||||||
|
{HourLabel: "7:00 AM", TextDescriptionLower: "partly cloudy", TemperatureF: floatPtr(63)},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.DerivedDailySummary,
|
||||||
|
StanzaName: string(module.DerivedDailySummary),
|
||||||
|
Value: briefing.DerivedDailySummaryModule{
|
||||||
|
Date: "Monday, June 15, 2026",
|
||||||
|
HighTempF: intPtr(74),
|
||||||
|
LowTempF: intPtr(61),
|
||||||
|
DailyPrecipitationProbability: intPtr(70),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.DerivedDaypartSummaries,
|
||||||
|
StanzaName: string(module.DerivedDaypartSummaries),
|
||||||
|
Value: map[string]briefing.DerivedDaypartSummaryModule{
|
||||||
|
"afternoon": {
|
||||||
|
DisplayName: "Afternoon",
|
||||||
|
TemperatureTrend: "steady",
|
||||||
|
TemperatureSteadyPhraseF: "mid 70s",
|
||||||
|
DominantConditionDisplay: "Showers",
|
||||||
|
DominantConditionLower: "showers",
|
||||||
|
MaxPopPercent: intPtr(70),
|
||||||
|
MaxPopTimeLabel: "3:00 PM",
|
||||||
|
MentionPrecipitation: true,
|
||||||
|
},
|
||||||
|
"morning": {
|
||||||
|
DisplayName: "Morning",
|
||||||
|
TemperatureTrend: "steady",
|
||||||
|
TemperatureSteadyPhraseF: "low 60s",
|
||||||
|
DominantConditionDisplay: "Partly cloudy",
|
||||||
|
DominantConditionLower: "partly cloudy",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.PrecipTiming,
|
||||||
|
StanzaName: string(module.PrecipTiming),
|
||||||
|
Value: briefing.PrecipTimingModule{
|
||||||
|
MaxPopPercent: intPtr(70),
|
||||||
|
MaxPopTime: "3 PM",
|
||||||
|
PrecipitationWindows: []briefing.PrecipitationWindowModule{
|
||||||
|
{PeriodBeginsHourLabel: "3:00 PM", PeriodEndsHourLabel: "6:00 PM", MaxPopPercent: intPtr(70), MaxPopHourLabel: "3:00 PM"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.AlertDigest,
|
||||||
|
StanzaName: string(module.AlertDigest),
|
||||||
|
Value: briefing.AlertDigestModule{
|
||||||
|
Checked: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.SPCConvectiveOutlooks,
|
||||||
|
StanzaName: string(module.SPCConvectiveOutlooks),
|
||||||
|
Value: briefing.SPCConvectiveOutlooksModule{
|
||||||
|
Checked: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.AreaForecastDiscussion,
|
||||||
|
StanzaName: string(module.AreaForecastDiscussion),
|
||||||
|
Value: briefing.AreaForecastDiscussionModule{
|
||||||
|
KeyMessages: []string{"Rain chances increase late."},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.SPCConvectiveDiscussion,
|
||||||
|
StanzaName: string(module.SPCConvectiveDiscussion),
|
||||||
|
Value: briefing.SPCConvectiveDiscussionModule{
|
||||||
|
IncludedBecause: "convective outlook",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.WeatherStory,
|
||||||
|
StanzaName: string(module.WeatherStory),
|
||||||
|
Value: briefing.WeatherStoryModule{
|
||||||
|
Available: true,
|
||||||
|
Title: "Rain returns",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: module.TomorrowPlanning,
|
||||||
|
StanzaName: string(module.TomorrowPlanning),
|
||||||
|
Value: briefing.TomorrowPlanningModule{
|
||||||
|
MorningReadiness: []string{"Take sunglasses early."},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSnapshot() error = %v", err)
|
||||||
|
}
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
func floatPtr(value float64) *float64 {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
|
func intPtr(value int) *int {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertOrderedText(t *testing.T, text string, ordered []string) {
|
||||||
|
t.Helper()
|
||||||
|
previousIndex := -1
|
||||||
|
for _, want := range ordered {
|
||||||
|
index := strings.Index(text, want)
|
||||||
|
if index < 0 {
|
||||||
|
t.Fatalf("text missing %q:\n%s", want, text)
|
||||||
|
}
|
||||||
|
if index <= previousIndex {
|
||||||
|
t.Fatalf("%q appears out of order in:\n%s", want, text)
|
||||||
|
}
|
||||||
|
previousIndex = index
|
||||||
|
}
|
||||||
|
}
|
||||||
62
internal/generatedtext/tomorrow.go
Normal file
62
internal/generatedtext/tomorrow.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package generatedtext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
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) {
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
|
||||||
|
var value Tomorrow
|
||||||
|
if err := decoder.Decode(&value); err != nil {
|
||||||
|
return Tomorrow{}, nil, fmt.Errorf("decode tomorrow generated text: %w", err)
|
||||||
|
}
|
||||||
|
var extra any
|
||||||
|
if err := decoder.Decode(&extra); err != nil {
|
||||||
|
if err != io.EOF {
|
||||||
|
return Tomorrow{}, nil, fmt.Errorf("decode tomorrow generated text: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Tomorrow{}, nil, fmt.Errorf("decode tomorrow generated text: multiple JSON values")
|
||||||
|
}
|
||||||
|
|
||||||
|
value.Summary = strings.TrimSpace(value.Summary)
|
||||||
|
value.PrecipitationTiming = strings.TrimSpace(value.PrecipitationTiming)
|
||||||
|
value.Confidence = strings.TrimSpace(value.Confidence)
|
||||||
|
value.ForecastDiscussion = trimNonEmpty(value.ForecastDiscussion)
|
||||||
|
if value.Summary == "" {
|
||||||
|
return Tomorrow{}, nil, fmt.Errorf("tomorrow generated text summary is required")
|
||||||
|
}
|
||||||
|
if len(value.ForecastDiscussion) == 0 {
|
||||||
|
return Tomorrow{}, nil, fmt.Errorf("tomorrow generated text forecast discussion is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return Tomorrow{}, nil, fmt.Errorf("normalize tomorrow generated text: %w", 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
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user