66 Commits

Author SHA1 Message Date
f149563c68 Refactor the yaml data package to group data sources by category 2026-06-10 11:50:22 -05:00
276e4f1189 Implement friendly time formatting in select modules 2026-06-10 10:36:52 -05:00
cb42cad6a6 Add an hourly forecast module and adjust the daily summary for consistency 2026-06-10 10:00:36 -05:00
ef044327c6 Refactored the derived_daily_summary module to utilize narrative forecast data where available 2026-06-10 09:35:39 -05:00
d1d0df11a8 Update wind direction and precipitation window presentation 2026-06-10 09:24:06 -05:00
c3da3af2f4 Add a new narrative forecast module and remove the unimplemented daily forecast stub 2026-06-10 08:24:50 -05:00
7b760a0823 Refactor to separate each module and report into an individual file 2026-06-10 08:04:06 -05:00
1e9c29aa55 Cleanup after implementation of the module architecture and remove completed roadmap files 2026-06-10 07:49:29 -05:00
1ddd88231a Clarify module package documentation 2026-06-09 21:42:38 +00:00
d665049f05 Record final module cutover validation 2026-06-09 21:38:24 +00:00
1af6169999 Align docs with module prompt packages 2026-06-09 21:37:28 +00:00
468197f7e0 Remove obsolete briefing snapshot artifacts 2026-06-09 21:33:34 +00:00
816cfb24aa Compare recent changes from module snapshots 2026-06-09 21:20:53 +00:00
479d144592 Run report generation through module snapshots 2026-06-09 21:12:13 +00:00
0b516d9762 Write prompt data packages as YAML 2026-06-09 21:08:17 +00:00
2483c2362d Persist module snapshots for generated reports 2026-06-09 20:58:35 +00:00
40639309b1 Add configurable report module composition 2026-06-09 20:51:22 +00:00
eefb0681dc Implement derived forecast modules 2026-06-09 20:43:53 +00:00
9501dad1dc Implement source-oriented briefing modules 2026-06-09 20:35:57 +00:00
24dba3bd60 Add module contracts and registry validation 2026-06-09 20:30:49 +00:00
e9508089ab Add collected and derived fact contracts 2026-06-09 20:23:53 +00:00
454f47b2b5 Split weather data types from forecast derivation 2026-06-09 20:16:47 +00:00
d8b417458b Add a roadmap and a staged implementation plan to move weatherreporter toward deterministic, reusable briefing modules that can
be composed per report type
2026-06-09 15:09:31 -05:00
195a130124 Updated defaults to upload only the current generated report path 2026-06-09 11:26:50 -05:00
8577fc29e4 Updated the distributor bundle path template 2026-06-08 10:29:42 -05:00
d71c7e4d28 Implement the distributor v0.5 PipelineID update 2026-06-08 07:04:31 -05:00
9bc8156615 Update default config example to use a stable distributor bundle_id and a unique per-run idempotency_key 2026-06-07 20:46:19 -05:00
183b23cf5a Implemented debug artifacts for the distributor notification adapter 2026-06-07 20:21:26 -05:00
138bc4e7e4 Refresh distributor documentation 2026-06-07 23:50:55 +00:00
e2529cfdf2 Validate distributor notification implementation 2026-06-07 23:47:47 +00:00
b982b27f84 Document distributor notification behavior 2026-06-07 23:46:39 +00:00
c573cd5b4d Report distributor notification outcomes in batches 2026-06-07 23:42:27 +00:00
c2758d7a91 Notify distributor after report generation 2026-06-07 23:38:03 +00:00
64cae8c4d9 Add distributor upload adapter 2026-06-07 23:34:21 +00:00
a2ba6f5382 Add distributor notification config validation 2026-06-07 23:18:39 +00:00
7c8d9191c1 Add file-backed environment secrets 2026-06-07 23:13:54 +00:00
9677835d84 Add an implementation plan to support distributor uploads for produced artifacts 2026-06-07 18:04:40 -05:00
63749a9572 Implement support for NWS weather stories 2026-05-30 07:47:49 -05:00
9ff90d33fc Add Woodpecker CI support 2026-05-30 07:47:13 -05:00
942e8ff591 Added a field with the current local date to the data package 2026-05-29 23:29:04 -05:00
0b050256f9 Add a current conditions block to the briefing/data-package 2026-05-29 20:04:27 -05:00
8a762bf34f Added a location block to the data package 2026-05-29 19:57:08 -05:00
42defcf4b9 Implemented the AFD Short Term / Long Term coverage 2026-05-29 19:42:07 -05:00
3e93a97d10 Improve handling of alerts when no active alerts are present 2026-05-29 19:34:23 -05:00
26e6f33cde Update default timezone, dayparts, and related tests 2026-05-29 19:22:02 -05:00
1bc0739d31 Removed detection for "thunder" as a special weather indicator 2026-05-29 19:15:32 -05:00
8476dab844 Consolidate future roadmap items 2026-05-29 18:27:34 -05:00
745992886c Remove the redundant DefaultOutputName report definition field 2026-05-29 18:25:38 -05:00
8089f62806 Refresh cleanup-related documentation 2026-05-29 20:49:19 +00:00
a34aec1dd2 Align cleanup documentation 2026-05-29 20:46:05 +00:00
448bd1e510 Remove legacy daily report wrappers 2026-05-29 20:44:25 +00:00
4e23e1e11f Deduplicate adapter execution and storm parsing 2026-05-29 20:41:47 +00:00
5d3b850e46 Simplify inspect command handling 2026-05-29 20:39:17 +00:00
4f45dee332 Remove unused report output config 2026-05-29 20:36:29 +00:00
1355605e70 Centralize atomic artifact writes 2026-05-29 20:33:35 +00:00
7dc2ac9253 Centralize report path policy 2026-05-29 20:28:06 +00:00
6915bf1ba2 Removed the documentation roadmap, and added a new cleanup roadmap based upon the code quality audit 2026-05-29 15:23:27 -05:00
ac6ede8f9c Audit code quality and deduplication opportunities 2026-05-29 15:17:24 -05:00
bcb4a64c68 Complete documentation validation pass 2026-05-29 20:05:20 +00:00
7a970148f3 Validate examples and clean up roadmap 2026-05-29 20:02:56 +00:00
0759e1598f Align policy documentation with contributor workflow 2026-05-29 19:59:13 +00:00
25ad8959a6 Document implemented integration contracts 2026-05-29 19:56:33 +00:00
4f530b2b6a Document internal component boundaries 2026-05-29 19:54:22 +00:00
f23af43013 Add operational troubleshooting documentation 2026-05-29 19:49:47 +00:00
62827cf56d Align baseline user documentation 2026-05-29 19:47:01 +00:00
5c9333feec Add a documentation update roadmap 2026-05-29 14:42:32 -05:00
111 changed files with 11987 additions and 5569 deletions

3
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# Compiled application binary
# Compiled application binary and testing workspace
/weatherreporter
/workspace
# ---> Go
# If you prefer the allow list template instead of the deny list, see community template:

50
.woodpecker/release.yml Normal file
View File

@@ -0,0 +1,50 @@
when:
- event: tag
steps:
- name: build-release-assets
image: golang:1.25
commands:
- |
set -eu
version="$CI_COMMIT_TAG"
dist="dist"
pkg="gitea.maximumdirect.net/eric/weatherreporter/cmd/weatherreporter"
rm -rf "$dist"
mkdir -p "$dist"
build_binary() {
goos="$1"
goarch="$2"
suffix="$3"
output="$dist/weatherreporter-$version-$goos-$goarch$suffix"
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \
go build -trimpath -ldflags "-s -w -X gitea.maximumdirect.net/eric/weatherreporter/internal/buildinfo.Version=$version" \
-o "$output" "$pkg"
}
build_binary linux amd64 ""
build_binary linux arm64 ""
build_binary darwin amd64 ""
build_binary darwin arm64 ""
build_binary windows amd64 ".exe"
build_binary windows arm64 ".exe"
- name: publish-release
image: woodpeckerci/plugin-release
depends_on:
- build-release-assets
settings:
api_key:
from_secret: GITEA_RELEASE_TOKEN
files:
- dist/weatherreporter-*
checksum: sha256
checksum-file: SHA256SUMS
checksum-flatten: true
file-exists: skip
overwrite: false
prerelease: false

View File

@@ -1,21 +1,15 @@
# weatherreporter
`weatherreporter` is a Go application for preparing human-facing weather
reports from normalized forecast data.
The application can currently generate Daily Today, Daily Tomorrow, 3-Day
Outlook, Weekend Outlook, and manual Storm Report Markdown reports through `scriptorium`, with
inspectable briefing, prompt input, preflight, report, and metadata artifacts
under the configured workspace.
reports from normalized forecast data. It builds JSON module snapshots, passes
YAML prompt data packages to `scriptorium`, and keeps inspectable artifacts
under a local workspace. It can also upload successfully generated managed
Markdown reports to a configured `distributor` HTTP upload endpoint.
## Quickstart
```sh
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
weatherreporter generate tomorrow --out ./tomorrow.md
weatherreporter generate three-day --out ./three-day.md
weatherreporter generate weekend --out ./weekend.md
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
```
## Documentation
@@ -23,6 +17,6 @@ weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 -
- [CLI reference](docs/cli.md)
- [Configuration reference](docs/config.md)
- [Operations guide](docs/operations.md)
- [Troubleshooting](docs/troubleshooting.md)
- [Architecture policy](docs/policy/architecture.md)
- [Development policy](docs/policy/development.md)
- [Implementation roadmap](docs/roadmap/initial.md)

View File

@@ -1,11 +1,7 @@
# Weatherreporter CLI
`weatherreporter generate daily`, `weatherreporter generate tomorrow`,
`weatherreporter generate three-day`, `weatherreporter generate weekend`,
`weatherreporter generate storm`,
`weatherreporter run morning`, and `weatherreporter run evening` currently
write Markdown reports through `scriptorium`, after writing managed preparation
artifacts and running `scriptorium render` as a preflight check.
`weatherreporter` generates Markdown weather reports, runs scheduled report
batches, and inspects stored artifacts.
## Shortest Useful Command
@@ -13,95 +9,94 @@ artifacts and running `scriptorium render` as a preflight check.
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
```
The command parses flags, loads configuration, fetches weather data, builds a
Daily briefing, writes workspace artifacts, invokes
`scriptorium render --input data_package=<managed_path> --format json`, then
invokes `scriptorium run --input data_package=<managed_path> --out <managed_report>`.
When `--out` is supplied, it also writes a copy of the Markdown report to that
path.
This loads configuration, fetches weather data, writes managed workspace
artifacts, runs `scriptorium render` as a preflight check, runs
`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.
For tomorrow planning:
```sh
weatherreporter generate tomorrow --out ./tomorrow.md
weatherreporter run evening
```
For the 3-Day Outlook:
```sh
weatherreporter generate three-day --out ./three-day.md
weatherreporter generate weekend --out ./weekend.md
```
For a focused manual Storm Report:
```sh
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
```
## Command Overview
## Commands
```text
weatherreporter generate daily
weatherreporter generate tomorrow
weatherreporter generate three-day
weatherreporter generate weekend
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
weatherreporter run morning
weatherreporter run evening
weatherreporter inspect reports
weatherreporter inspect metadata RUN_ID
weatherreporter inspect briefing RUN_ID
weatherreporter inspect data-package RUN_ID
weatherreporter inspect prior RUN_ID
weatherreporter inspect sources RUN_ID
weatherreporter --help
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
weatherreporter run morning [--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 metadata [--config PATH] RUN_ID
weatherreporter inspect modules [--config PATH] RUN_ID
weatherreporter inspect data-package [--config PATH] RUN_ID
weatherreporter inspect prior [--config PATH] RUN_ID
weatherreporter inspect sources [--config PATH] RUN_ID
```
`generate daily`, `generate tomorrow`, `generate three-day`,
`generate weekend`, and `generate storm` write a briefing snapshot, prompt
input data package, render preflight output, Markdown report, and metadata file
under the configured workspace. `generate storm` requires explicit `--start`
and `--end` bounds for the event window. `run evening` generates the Tomorrow
Planning Brief. `run morning` generates Daily Today and the 3-Day Outlook, plus
Weekend Outlook except on Sunday. Run commands continue remaining reports after
an independent report failure, print a JSON aggregate summary to stdout, write
compact report status logs to stderr, and return nonzero when any report
failed.
`generate` commands write a JSON module snapshot, YAML data package, preflight
artifact, managed Markdown report, and metadata under the configured workspace.
`--out` writes an extra Markdown copy for the operator; distributor
notification uses the managed report path, not the extra copy. `generate storm`
requires explicit event-window bounds with `--start` and `--end`.
`inspect` commands read the configured workspace and emit JSON to stdout. They
do not fetch weather data or invoke `scriptorium`.
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch
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
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.
`inspect` commands read existing workspace artifacts and emit JSON to stdout.
They do not fetch weather data or invoke `scriptorium`.
## Flags
- `-h`, `--help`: show help.
- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`.
- `--units VALUE`: override configured Weather API units.
- `--tz NAME`: override configured Weather API timezone.
- `--out PATH`: optional Markdown report copy for `generate daily`, `generate tomorrow`, `generate three-day`, `generate weekend`, and `generate storm`.
- `--out-dir PATH`: optional directory for extra Markdown report copies from `run morning` and `run evening`.
- `--units VALUE`: override configured Weather API units for `generate` and `run`.
- `--tz NAME`: override configured Weather API timezone for `generate` and `run`.
- `--out PATH`: write an extra Markdown report copy for `generate` commands.
- `--out-dir PATH`: write extra Markdown report copies for `run morning` and `run evening`.
- `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone.
- `--start TIME`: required start time for `generate storm`.
- `--end TIME`: required end time for `generate storm`.
- `--limit N`: maximum report records for `inspect reports`; defaults to 20,
and `0` means no limit.
- `--limit N`: maximum records for `inspect reports`; defaults to `20`, and `0` means no limit.
Storm times accept `YYYY-MM-DDTHH:MM` in the configured timezone or RFC3339
timestamps with explicit offsets.
Distributor notification is configured only through `notify.distributor`; there
are no distributor-specific CLI flags.
## Common Workflows
```sh
weatherreporter generate tomorrow --out ./tomorrow.md
weatherreporter generate three-day --out ./three-day.md
weatherreporter generate weekend --out ./weekend.md
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
weatherreporter run morning --out-dir ./reports
weatherreporter run evening --out-dir ./reports
```
## Inspection
```sh
weatherreporter inspect reports --limit 10
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 prior 20260529T100000.000000000Z_daily_today
weatherreporter inspect sources 20260529T100000.000000000Z_daily_today
```
`inspect reports` lists recent generated runs with artifact paths and warning
counts. The other commands require a RunID. `inspect prior` returns the prior
comparable snapshot metadata selected from stored metadata, or `null` when no
prior comparable snapshot exists. `inspect sources` shows source provenance and
source warnings without dumping full weather payloads.
`inspect reports` lists recent generated runs with artifact paths and source
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
`null` when none exists. `inspect sources` shows source provenance and source
warnings without dumping full weather payloads.

View File

@@ -1,46 +1,234 @@
# Weatherreporter Configuration
Configuration is loaded from `/usr/local/etc/weatherreporter/config.yml` by
default. Use `--config PATH` to load a different file. CLI flags override file
values.
Configuration is YAML. By default, `weatherreporter` reads:
If the default file is absent, built-in defaults are used.
```text
/usr/local/etc/weatherreporter/config.yml
```
Use `--config PATH` to load a different file. If the default file is absent,
built-in defaults are used. If `--config PATH` points to a missing file, loading
fails.
Precedence is:
1. CLI flags
2. configuration file
3. built-in defaults
The CLI configuration overrides are `--units` and `--tz`. Output flags control
report copies for the current command but do not change configuration files.
Environment variables do not override configuration fields.
## Minimal Config
See [examples/minimal-config.yml](../examples/minimal-config.yml).
```yaml
weather_api:
base_url: https://weather.api.example.com/
```
`weather_api.base_url` is required for commands that fetch weather data. Other
fields fall back to defaults.
## Production-Oriented Config
See [examples/config.yml](../examples/config.yml).
See [examples/config.yml](../examples/config.yml). The example is loaded by the
config test suite.
## Reference
## Field Reference
- `weather_api.base_url`: single Weather API endpoint base URL, required when fetching weather data.
- `weather_api.timeout`: HTTP timeout duration. Default: `10s`.
- `weather_api.precision`: numeric precision hint. Default: `1`.
- `weather_api.units`: Weather API units. Default: `us`.
- `weather_api.timezone`: report timezone. Accepts IANA names, configured aliases such as `Chicago` and `Stl`, US timezone abbreviations, and UTC offsets such as `-5` or `+09:30`. Default: `Chicago`.
- `weather_api.format`: Weather API response format. Default: `json`.
- `missing_source.default`: one of `error`, `warn`, or `none`. Default: `warn`.
- `missing_source.sources`: optional per-source missing-source policy overrides.
- `scriptorium.binary`: `scriptorium` executable name. Default: `scriptorium`.
- `scriptorium.config_path`: optional `scriptorium` config path.
- `scriptorium.profile`: optional `scriptorium` profile.
- `scriptorium.timeout`: subprocess timeout. Default: `2m`.
- `scriptorium.extra_args`: optional extra arguments reserved for the adapter.
- `workspace.root`: workspace root. Default: `workspace`.
- `workspace.snapshots_dir`: snapshot directory under the workspace.
- `workspace.reports_dir`: managed report directory under the workspace.
- `workspace.data_packages_dir`: prompt input package directory under the workspace.
- `workspace.preflight_dir`: preflight output directory under the workspace.
- `reports.output_dir`: report output directory. Default: `reports`.
- `reports.paths`: optional report-specific output paths.
- `dayparts`: named daypart definitions with `start` and `end` `HH:MM` values.
- `recent_change.temperature_degrees`: temperature change threshold.
- `recent_change.precip_probability_points`: precipitation probability threshold.
- `recent_change.wind_gust_miles_per_hour`: wind gust change threshold.
- `recent_change.precip_timing_shift_minutes`: precipitation timing shift threshold.
### `weather_api`
- `base_url`: absolute base URL for the Weather API. Required for generation and fetch workflows.
- `timeout`: HTTP timeout duration. Default: `10s`.
- `precision`: numeric precision query value. Default: `1`.
- `units`: Weather API units query value. Default: `us`.
- `timezone`: report timezone and Weather API timezone query value where supported. Default: `America/Chicago`.
- `format`: Weather API response format. Must be `json`. Default: `json`.
Timezone values may be IANA names, configured aliases such as `Chicago` and
`Stl`, US timezone abbreviations, or UTC offsets such as `-5` and `+09:30`.
### `location`
`location` is descriptive prompt context included in module metadata and
Scriptorium data packages. It does not select a Weather API endpoint or enable
multiple configured forecast locations.
- `id`: short local identifier. Default: `home`.
- `name`: human-readable location name. Default: `Brentwood`.
- `region`: broader forecast area context. Default: `St. Louis Metro`.
The prompt-facing location object also includes `timezone`, derived from the
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
`scriptorium run` 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`
- `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.
Hourly forecast data is required for generated reports. Optional sources use
the missing-source policy.
### `scriptorium`
- `binary`: `scriptorium` executable name or path. Default: `scriptorium`.
- `config_path`: optional Scriptorium config path passed to the adapter.
- `profile`: optional Scriptorium profile passed to the adapter.
- `timeout`: subprocess timeout. Default: `2m`.
- `extra_args`: optional additional arguments passed to Scriptorium commands.
### `workspace`
- `root`: workspace root for managed artifacts. Default: `workspace`.
- `snapshots_dir`: module snapshot and metadata directory under `workspace.root`. Default: `snapshots`.
- `reports_dir`: managed Markdown report directory under `workspace.root`. Default: `reports`.
- `data_packages_dir`: prompt input package directory under `workspace.root`. Default: `data-packages`.
- `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.root`.
### `dayparts`
`dayparts` is a list of named local-time windows used by forecast derivation.
Each entry has:
- `name`
- `start`
- `end`
`start` and `end` use `HH:MM`. The default entries are overnight, morning,
midday, afternoon, and evening.
### `recent_change`
- `temperature_degrees`: temperature change threshold. Default: `5`.
- `precip_probability_points`: precipitation probability threshold. Default: `20`.
- `wind_gust_miles_per_hour`: wind gust change threshold. Default: `10`.
- `precip_timing_shift_minutes`: precipitation timing shift threshold. Default: `120`.
Recent Changes are added to prompt input when a prior comparable module
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`, `three_day`, `weekend`, and
`storm`. Canonical report IDs such as `daily_today` and `daily_tomorrow` 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
- id: area_forecast_discussion
options:
sections:
- short_term
- hourly_forecast
```
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
Configuration files should not contain raw secrets. Use `secrets.directory` to
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
- [examples/minimal-config.yml](../examples/minimal-config.yml): smallest
useful config for generation and fetching.
- [examples/config.yml](../examples/config.yml): production-oriented config
covering maintained fields.
Both example files are loaded by the config test suite.

View 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.

View 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.

View 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.

View File

@@ -1,34 +1,17 @@
# weatherreporter Subprocess Integration
# Scriptorium Integration
This document describes the external Scriptorium CLI contract used by
`weatherreporter`.
## Purpose
This document defines the supported subprocess contract for weatherreporter invoking Scriptorium through the public CLI.
`weatherreporter` invokes Scriptorium as a subprocess to preflight prompt input
and generate Markdown reports. This page documents the CLI surface the adapter
uses, not the full Scriptorium product.
This is a CLI contract, not an internal Go package integration.
## Commands Used
## Supported Commands
weatherreporter should invoke:
- `scriptorium run`
- `scriptorium render`
Use `run` for generation.
Use `render` for preflight/debug output without LLM execution.
## Recommended Invocation Shapes
Run:
```bash
scriptorium run \
--prompt <prompt_id> \
--input data_package=<path> \
--out <artifact_path>
```
Render:
Render preflight:
```bash
scriptorium render \
@@ -37,78 +20,79 @@ scriptorium render \
--format json
```
weatherreporter may add:
Report generation:
- `--config <path>`
- `--profile <profile_id>`
- repeatable `--input name=path`
- repeatable `--var name=value`
- runtime overrides when explicitly needed (`--model`, `--llm-base-url`, `--timeout`, etc.)
```bash
scriptorium run \
--prompt <prompt_id> \
--input data_package=<path> \
--out <artifact_path>
```
## Config And Directory Behavior
`weatherreporter` always passes prompt input as
`--input data_package=<path>`. The data package is structured YAML created by
`internal/promptinput`; module snapshots remain separate JSON artifacts for
inspection and Recent Changes.
weatherreporter can rely on resolved app config or pass explicit paths.
## Configured Arguments
- default config search order:
1. `/usr/local/etc/scriptorium/config.yml`
2. `/etc/scriptorium/config.yml`
- explicit `--config` requires file existence and valid syntax
- CLI flags override config values
The adapter can prepend configured flags before prompt-specific arguments:
## Profile Selection
- `--config <path>` from `scriptorium.config_path`
- `--profile <profile>` from `scriptorium.profile`
Profile selection follows runner behavior:
It appends `scriptorium.extra_args` after the built-in arguments. Extra
arguments are passed directly as argv items.
1. explicit `--profile`
2. prompt `default_profile`
3. error if neither is available
`scriptorium.binary` selects the executable name or path. If unset inside the
adapter, it falls back to `scriptorium`.
weatherreporter should treat prompt/profile IDs as deployment configuration, not hardcoded logic.
## Execution Behavior
## Input And Variable Contract
The adapter runs Scriptorium without shell interpolation. Arguments are passed
through `exec.CommandContext`.
- Inputs use repeated `--input name=path`.
- Input names must match prompt definition input names.
- Variables use repeated `--var name=value` for small metadata values.
- Prefer file inputs for large content.
`scriptorium.timeout` limits each subprocess call when configured. Context
cancellation or timeout returns an execution error.
## Environment Contract
Stdout and stderr are captured separately. Each stream is capped at 1 MiB and
the result records whether truncation occurred.
- Pass through required API-key environment variables referenced by `api_key_env`.
- Never pass raw API keys via CLI arguments.
- Keep subprocess environment scoped to required variables.
## Results
## Output And Error Handling
Render results include:
`run`:
- full argv recorded as `command`
- stdout
- stderr
- exit code
- truncation flags when applicable
- stdout: artifact body unless `--out` is used
- `--out`: writes artifact to file
- stderr: success summary and errors
Run results include the same fields plus the requested output path.
`render`:
`weatherreporter` persists render preflight JSON when orchestration reaches the
preflight save point. The final Markdown artifact is written by Scriptorium to
the `--out` path.
- stdout: prepared-run output unless `--out` is used
- stderr: errors
## Failure Behavior
weatherreporter should capture stdout and stderr separately.
The adapter validates required request fields before starting Scriptorium:
## Exit Status Contract
- prompt ID
- data package path
- output path for `run`
- `0`: success
- `1`: parse/config/load/render/generation/IO/runtime error
- `2`: run completed but validation failed
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
the adapter, even if Scriptorium wrote output to the requested artifact path.
A `run` exit code `2` can still produce output (stdout or `--out`).
Subprocess start failures, context cancellation, and timeouts return errors
without fabricating a successful result.
## Security Notes
- Treat generated artifacts and stderr logs as potentially sensitive.
- Avoid logging full rendered prompts by default in production contexts.
- Use controlled output paths and access controls for persisted artifacts.
## Canonical References
- CLI behavior: [CLI reference](https://gitea.maximumdirect.net/eric/scriptorium/docs/cli.md)
- Config behavior: [Configuration reference](https://gitea.maximumdirect.net/eric/scriptorium/docs/config.md)
- Operations and failure handling: [Operations guide](https://gitea.maximumdirect.net/eric/scriptorium/docs/operations.md), [Troubleshooting](https://gitea.maximumdirect.net/eric/scriptorium/docs/troubleshooting.md)
- The adapter does not invoke a shell.
- Generated artifacts, rendered prompt context, stdout, and stderr can contain
operationally sensitive data.
- API keys should be provided through the Scriptorium environment or
Scriptorium configuration, not through `weatherreporter` CLI arguments.

View File

@@ -1,354 +1,132 @@
# weatherapi External API
# Weather API Integration
This document describes the public HTTP API exposed by `weatherapi` for external consumers.
This document describes the external Weather API contract used by
`weatherreporter`.
## Purpose
`weatherreporter` uses a configured Weather API base URL to fetch normalized
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
upstream service.
## Base URL
The service is typically served at your deployment host, for example:
`weather_api.base_url` must be an absolute URL. Adapter requests join this base
URL with the endpoint paths listed below. Generation and explicit bundle fetches
fail before any HTTP request when the base URL is empty or not absolute.
- `https://weather.api.rakestrawhome.com`
The HTTP client uses `weather_api.timeout`.
All paths below are relative to the service root.
## Response Envelope
## Common Conventions
### Response envelope
All endpoints return a top-level envelope:
- `data`: endpoint payload or `null` when no current/latest resource is available.
JSON example:
Every response used by the adapter must be JSON with a top-level `data` field:
```json
{
"data": {"...": "..."}
"data": {}
}
```
### Output format
For most sources, `data: null` is treated as a missing source. Missing optional
sources follow the configured missing-source policy. Missing hourly forecast
data fails bundle fetching because hourly periods are required for report
generation.
Supported via `format` query parameter (case-insensitive):
`/alerts/active` is the exception: a successful response with `data: null`
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.
- `json` (default)
- `xml`
- `text`
Malformed JSON envelopes, non-2xx statuses, and response read failures include
endpoint context in returned errors. Decode errors include source context when
they fail the fetch; optional malformed sources follow the missing-source policy.
### Units
## Query Parameters
Supported via `units` query parameter (case-insensitive):
The adapter sends these query parameters:
- `metric` (default)
- `us`
- `format`: from `weather_api.format`; configuration validation requires `json`
- `units`: from `weather_api.units`
- `precision`: from `weather_api.precision` on observations, current
conditions, hourly forecast, and narrative forecast requests
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast, and
discussion requests
Endpoints that include unit-based numeric fields return either metric or US field variants depending on this value.
Alerts do not receive `precision` or `tz`. Weather story requests receive only
`format=json`.
### Precision
## Endpoints Used
Supported where documented via `precision` query parameter:
The adapter fetches these endpoints once per bundle:
- integer range: `0` to `2`
- controls decimal rounding of numeric output fields
- `/observations`
- `/conditions/current`
- `/forecast/hourly`
- `/forecast/narrative`
- `/alerts/active`
- `/discussion`
- `/weatherstories/latest`
### Timezone (`tz` / `TZ`)
`weatherreporter` does not call day-slice forecast endpoints or discussion
subsection endpoints. Report-period selection and daypart summarization happen
inside Go after the full hourly and narrative products are fetched.
Supported where documented:
## Required And Optional Sources
- accepted values include:
- IANA timezone names (example: `America/Chicago`)
- common US abbreviations (example: `CDT`, `EST`)
- UTC offsets in `±H`, `±HH`, or `±HH:MM` (example: `-5`, `+09:30`)
- aliases including `Chicago` and `Stl`
- `tz` and `TZ` are treated equivalently
- if both are provided, they must match exactly or the request fails
Hourly forecast is required:
Timezone affects datetime rendering and day-slice filtering for `/today` and `/tomorrow` forecast routes.
- `data: null` for `/forecast/hourly` fails the fetch.
- an hourly forecast with no `periods` fails the fetch.
- malformed hourly data fails the fetch.
### Query validation
Other fetched sources are optional and follow `missing_source.default` or a
source-specific `missing_source.sources` policy:
- Unknown query parameters are rejected with `400 Bad Request`.
- Invalid parameter values are rejected with `400 Bad Request`.
- `observations` for `/observations`
- `current` for `/conditions/current`
- `narrative` for `/forecast/narrative`
- `alerts` for `/alerts/active`
- `discussion` for `/discussion`
- `weather_story` for `/weatherstories/latest`
Error response body follows the service error envelope; exact fields may vary by error type.
Policy behavior:
## Endpoints
- `error`: fail the fetch for that source
- `warn`: omit the source data, add a warning, and continue
- `none`: omit the source data and continue without a warning
## `GET /observations`
For `/alerts/active`, an HTTP error or missing `data` field still fails or
follows the relevant error path, but explicit `data: null` is not a
missing-source condition.
Returns the latest weather observation.
## Source Identity
Query parameters:
For source payloads accepted into the bundle, including the explicit `null`
alerts payload, the adapter records:
- `units`: `metric` | `us`
- `format`: `json` | `xml` | `text`
- `precision`: `0..2`
- source name
- endpoint path
- query parameters sent
- fetch time
- source issue and update timestamps when present in the payload
- SHA-256 hash of the compact raw `data` JSON
Response `data` fields:
Warnings are recorded both on the affected source and on the bundle-level
warnings list.
- `stationId` (string, optional)
- `stationName` (string, optional)
- `timestamp` (RFC3339 datetime, required)
- `conditionCode` (integer WMO code, required)
- `isDay` (boolean, optional)
- `textDescription` (string, optional)
- Metric mode fields:
- `temperatureC`, `dewpointC`, `windSpeedKmh`, `windGustKmh`, `barometricPressurePa`, `visibilityMeters`, `relativeHumidityPercent`, `apparentTemperatureC` (number, optional)
- `windDirectionDegrees` (number, optional)
- US mode fields:
- `temperatureF`, `dewpointF`, `windSpeedMph`, `windGustMph`, `barometricPressureInHg`, `visibilityMiles`, `relativeHumidityPercent`, `apparentTemperatureF` (number, optional)
- `windDirectionDegrees` (number, optional)
- `presentWeather` (array, optional)
## Compatibility Assumptions
## `GET /alerts/active`
The adapter expects payload fields compatible with the internal forecast bundle
types in `internal/forecast/bundle.go`, including:
Returns the latest active alert run.
- observation timestamps and observation values
- current condition values
- forecast run metadata and `periods`
- active alert run data
- discussion metadata, key messages, and short/long-term section text
- latest weather story title, description, timing, priority, order, alt text,
and download URL
Query parameters:
- `units`: `metric` | `us` (accepted; does not materially alter alert payload)
- `format`: `json` | `xml` | `text`
Response `data` fields:
- Weather alert run object from canonical model (includes run metadata and active alerts list).
## `GET /conditions/current`
Returns current conditions synthesized from latest observation/forecast data.
Query parameters:
- `units`: `metric` | `us`
- `format`: `json` | `xml` | `text`
- `precision`: `0..2`
Response `data` fields:
- Common:
- `conditionText` (string, optional)
- `isDay` (boolean, optional)
- `relativeHumidityPercent` (number, optional)
- `windDirectionDegrees` (number, optional)
- Metric mode:
- `temperatureC`, `apparentTemperatureC`, `dewpointC`, `windSpeedKmh` (number, optional)
- US mode:
- `temperatureF`, `apparentTemperatureF`, `dewpointF`, `windSpeedMph` (number, optional)
## Forecast endpoints
- `GET /forecast/hourly`
- `GET /forecast/hourly/today`
- `GET /forecast/hourly/tomorrow`
- `GET /forecast/narrative`
- `GET /forecast/narrative/today`
- `GET /forecast/narrative/tomorrow`
Query parameters:
- `units`: `metric` | `us`
- `format`: `json` | `xml` | `text`
- `precision`: `0..2`
- `tz` or `TZ`: timezone selector
Day-slice routes:
- `/today` returns periods with `period.startTime` in the current calendar day for the resolved timezone.
- `/tomorrow` returns periods with `period.startTime` in the next calendar day for the resolved timezone.
Response `data` fields:
- Run-level:
- `locationId` (string, optional)
- `locationName` (string, optional)
- `issuedAt` (RFC3339 datetime, required)
- `updatedAt` (RFC3339 datetime, optional)
- `product` (string, required; e.g. `hourly`, `narrative`)
- `latitude`, `longitude` (number, optional)
- Metric mode: `elevationMeters` (number, optional)
- US mode: `elevationFeet` (number, optional)
- `periods` (array, required)
- Period fields:
- `startTime`, `endTime` (RFC3339 datetime, required)
- `name` (string, optional)
- `isDay` (boolean, optional)
- `conditionCode` (integer WMO code, optional)
- `textDescription` (string, optional)
- Metric mode (optional):
- `temperatureC`, `temperatureCMin`, `temperatureCMax`, `dewpointC`, `windSpeedKmh`, `windGustKmh`, `barometricPressurePa`, `visibilityMeters`, `apparentTemperatureC`, `cloudCoverPercent`, `probabilityOfPrecipitationPercent`, `precipitationAmountMm`, `snowfallDepthMM`, `uvIndex`, `relativeHumidityPercent`, `windDirectionDegrees`
- US mode (optional):
- `temperatureF`, `temperatureFMin`, `temperatureFMax`, `dewpointF`, `windSpeedMph`, `windGustMph`, `barometricPressureInHg`, `visibilityMiles`, `apparentTemperatureF`, `cloudCoverPercent`, `probabilityOfPrecipitationPercent`, `precipitationAmountIn`, `snowfallDepthIn`, `uvIndex`, `relativeHumidityPercent`, `windDirectionDegrees`
Notes:
- Narrative periods may omit `conditionCode`.
- Text format uses forecast-specific templates (`hourly` and `narrative`).
## Discussion endpoints
- `GET /discussion`
- `GET /discussion/key-messages`
- `GET /discussion/short-term`
- `GET /discussion/long-term`
Query parameters:
- `units`: `metric` | `us` (accepted; does not materially alter discussion payload)
- `format`: `json` | `xml` | `text`
- `tz` or `TZ`: timezone selector
Response `data` fields:
- `/discussion`:
- `officeId` (string, optional)
- `officeName` (string, optional)
- `product` (string, required)
- `issuedAt` (RFC3339 datetime, required)
- `updatedAt` (RFC3339 datetime, optional)
- `keyMessages` (array of string)
- `shortTerm` (object, optional)
- `longTerm` (object, optional)
- `/discussion/key-messages`:
- `officeId`, `officeName`, `product`, `issuedAt`, `updatedAt`
- `keyMessages` (array of string)
- `/discussion/short-term`:
- `officeId`, `officeName`, `product`, `issuedAt`, `updatedAt`
- `shortTerm` (object, optional)
- `/discussion/long-term`:
- `officeId`, `officeName`, `product`, `issuedAt`, `updatedAt`
- `longTerm` (object, optional)
Discussion section object fields:
- `title` (string, optional)
- `narrative` (string, optional)
- `issuedAt` (RFC3339 datetime, optional)
## Examples
### Observation (JSON, metric)
```http
GET /observations?format=json&units=metric&precision=1
```
```json
{
"data": {
"stationId": "KSTL",
"timestamp": "2026-05-29T14:00:00Z",
"conditionCode": 3,
"isDay": true,
"textDescription": "Partly cloudy",
"temperatureC": 24.4,
"windSpeedKmh": 17.2,
"relativeHumidityPercent": 56.0
}
}
```
### Alerts (JSON)
```http
GET /alerts/active?format=json
```
```json
{
"data": {
"asOf": "2026-05-29T14:00:00Z",
"alerts": []
}
}
```
### Current conditions (JSON, US)
```http
GET /conditions/current?format=json&units=us&precision=1
```
```json
{
"data": {
"conditionText": "Partly cloudy",
"isDay": true,
"temperatureF": 75.9,
"apparentTemperatureF": 76.1,
"windSpeedMph": 10.7,
"relativeHumidityPercent": 56.0
}
}
```
### Forecast narrative (JSON, optional `conditionCode`)
```http
GET /forecast/narrative?format=json&units=metric&precision=1&tz=America/Chicago
```
```json
{
"data": {
"locationId": "nws-lsx-grid-90-74",
"issuedAt": "2026-05-29T10:30:00-05:00",
"product": "narrative",
"periods": [
{
"startTime": "2026-05-29T13:00:00-05:00",
"endTime": "2026-05-29T19:00:00-05:00",
"name": "Today",
"isDay": true,
"textDescription": "Partly sunny, with a high near 81.",
"temperatureC": 27.2,
"windSpeedKmh": 18.0,
"probabilityOfPrecipitationPercent": 10.0
}
]
}
}
```
### Forecast hourly today (text)
```http
GET /forecast/hourly/today?format=text&units=us&precision=1&tz=CDT
```
```text
<plain text forecast output>
```
### Discussion key messages (JSON)
```http
GET /discussion/key-messages?format=json&tz=Chicago
```
```json
{
"data": {
"officeId": "LSX",
"product": "discussion",
"issuedAt": "2026-05-29T09:25:00-05:00",
"keyMessages": [
"Scattered showers possible this evening.",
"Warmer temperatures this weekend."
]
}
}
```
### Invalid timezone error example
```http
GET /forecast/narrative?tz=not-a-timezone
```
```json
{
"error": {
"code": "invalid_parameter",
"message": "tz must be a valid timezone"
}
}
```
The adapter intentionally keeps upstream transport and envelope details inside
`internal/adapters/weatherapi`; downstream packages consume the normalized
bundle.

View File

@@ -0,0 +1,148 @@
# App Orchestration Internals
This document describes the workflow coordinator in `internal/app`.
## Purpose
`internal/app` coordinates the top-level use cases after CLI parsing and config
loading are complete. It resolves report definitions, fetches weather data,
builds collected and derived facts, builds module snapshots and prompt-input
artifacts, invokes Scriptorium through the adapter boundary, optionally
notifies distributor through an app-owned notifier boundary, persists managed
state, runs batches, and reads existing artifacts for inspection.
## Inputs And Outputs
Inputs:
- `GenerateRequest` for one report command
- `BatchRequest` for morning or evening batch commands
- `FetchBundleRequest` for explicit bundle fetch and save workflows
- `ReportRequest` for single-report generation
- resolved report definitions from `internal/report`
- weather data bundles from `internal/adapters/weatherapi`
- prior snapshots loaded from `internal/state`
- optional renderer, notifier, and state-store fakes for tests
Outputs:
- generated report results with JSON module snapshot, YAML data package,
preflight, report, metadata, prior snapshot, Recent Changes, Scriptorium
result details, and notification result when attempted
- batch summaries with per-report status, artifact paths, error text, and
notification outcome when attempted
- saved Weather API bundle JSON for fetch workflows
- inspection JSON values for reports, metadata, module snapshots, data
packages, prior snapshots, and source provenance
## Boundaries
`internal/app` owns workflow order and request composition. It does not parse
CLI flags, load YAML files directly, implement HTTP transport, own fact
derivation algorithms, define report periods, compare rendered Markdown, or
construct Scriptorium argv.
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
subprocess behavior stays in `internal/adapters/scriptorium`. Distributor
upload behavior stays in `internal/adapters/distributor`. Filesystem layout and
persisted metadata stay in `internal/state`.
## Config Fields Used
- `weather_api.*` for Weather API client construction and module metadata
- `scriptorium.*` for renderer construction
- `workspace.*` for filesystem state
- `dayparts` for daily and outlook summarization
- `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
defaults.
## Generation Workflow
Single-report generation follows this order:
1. Resolve the command report to a `report.Resolved` value.
2. Create or use a filesystem store.
3. Locate any prior compatible snapshot through `internal/state`.
4. Fetch a Weather API bundle.
5. Build collected and derived facts once.
6. Execute configured modules and save the module snapshot.
7. Compute Recent Changes from structured prior and current module snapshots.
8. Build and save the YAML Scriptorium `data_package`.
9. Run Scriptorium render preflight.
10. Save preflight JSON when a render result is available.
11. Save metadata for inspection.
12. Run Scriptorium report generation to the managed report path.
13. Copy the managed report to the requested `--out` path when provided.
14. Save metadata with the managed report path.
15. If distributor notification is enabled, notify using the managed report
path as the source file.
16. Save a distributor notification debug artifact and update metadata with its
path.
If render preflight returns both a result and an error, preflight JSON and
metadata are persisted before the error is returned. If Scriptorium report
generation returns an error after writing output, the managed report and
metadata remain inspectable. Notification is not attempted after Weather API,
module snapshot, prompt input, render, Scriptorium run, or metadata-save
failures.
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
`run morning` resolves Daily Today, 3-Day Outlook, and Weekend Outlook except
on Sunday. `run evening` resolves Daily Tomorrow. Batch output copy names come
from report definitions. Batch generation continues independent reports after 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.
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 workflows load existing filesystem state only. They do not fetch
weather data or invoke Scriptorium. Run-specific inspect commands share the same
store and metadata lookup path, then load the requested artifact or derived
inspection view.
## Failure Behavior
- Resolve errors stop the requested workflow before fetching weather data.
- Weather API and module execution errors stop that report before Scriptorium
runs.
- Prompt input validation fails before render preflight.
- Render and run errors preserve Scriptorium stderr and exit-code context.
- 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.
- Batch failures are recorded per report and surfaced through an aggregate
batch error.
## Tests
Inspect:
- `internal/app/app_test.go`
- `internal/cli/root_test.go`
- `internal/state/filesystem_test.go`
## Invariants
- Report behavior is resolved through `internal/report`.
- Generated reports use the same app request and result types regardless of
report ID.
- Render preflight precedes Scriptorium report generation.
- Recent Changes are computed from structured module snapshots.
- Metadata links artifacts produced for a run.
- Distributor notification maps the managed Markdown report path to configured
bundle paths; extra output copies are not upload sources.

View File

@@ -1,84 +1,104 @@
# Briefing Internals
# Module Builder Internals
This document describes the implemented briefing package boundary.
This document describes module builder behavior in `internal/briefing`.
## Purpose
`internal/briefing` builds structured report-specific briefing packages from
forecast summaries and report metadata. The package currently implements Daily
Today, Daily Tomorrow, 3-Day Outlook, Weekend Outlook, and Storm Report
briefing content.
`internal/briefing` turns report metadata, collected weather data, and derived
forecast facts into prompt-facing module outputs. The package also owns the
module registry used to validate report composition and config overrides.
## Inputs and Outputs
Module outputs are structured prompt inputs. They are not rendered report prose
and they are not persisted by this package.
## Inputs And Outputs
Inputs:
- resolved report definition and valid period
- forecast bundle
- derived forecast summary or summaries
- configured units and timezone
- resolved report definition, generation time, timezone, and valid period
- collected facts built from `weatherdata.Bundle`
- derived daily, daypart, precipitation, alert, and storm-window facts where
required
- configured units, timezone, and descriptive location context
- typed module options from report defaults or config overrides
Output:
Outputs:
- `briefing.Package` JSON containing common metadata and report-specific
briefing content
- `ModuleDefinition` values with module ID, stanza name, option type,
supported reports, fact requirements, missing-data behavior, and builder
- `module.Output` values for source-oriented stanzas:
`metadata`, `current_conditions`, `narrative_forecast`, `hourly_forecast`,
`alert_digest`, `area_forecast_discussion`, and `weather_story`
- `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.
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
- Briefings are structured weather facts and context for later prompt input.
- This package does not fetch weather data, compare prior snapshots, build
`scriptorium` data packages, or render final report prose.
- This package selects and shapes already-collected weather facts for prompts.
- It validates module composition against report compatibility and option
types.
- It does not fetch weather data, compare prior snapshots, write module
snapshots, build YAML data packages, invoke Scriptorium, or write workflow
metadata.
## Behavior
## Config Fields Used
- Common metadata includes schema version, RunID, report ID, variant, prompt ID,
generation time, units, timezone, valid period, source location, source
provenance, hashes, and source warnings.
- Daily content includes bottom-line inputs, daypart summaries, relevant alerts,
outdoor window inputs, narrative periods, discussion context, and weather
story context when available.
- Daily Tomorrow also includes planning inputs for morning readiness,
commute/school/workday concerns, and what may change overnight.
- 3-Day content includes one summary per local day or partial day, with overall
character, temperature range, precipitation, wind, risk, outdoor-window, and
alert inputs, plus broader discussion and weather-story context when
available.
- Weekend content uses the same daily outlook summaries and adds planning
inputs for best outdoor windows, worst weather windows, rain/storm timing,
comfort concerns, and confidence or uncertainty context.
- Storm content uses the explicit event window and includes event headline
inputs, hazards, most-likely scenario inputs, reasonable worst-case inputs,
confidence and uncertainty inputs, watch items, active alerts, relevant
hourly and narrative forecast periods, and available discussion or weather
story context.
- Briefing JSON is written atomically by `briefing.Save`.
The app layer passes effective units, timezone, and location context into the
module context. `internal/facts` consumes daypart configuration before module
builders run. Configured `location` values are prompt context only; Weather API
`sourceLocationId` and `sourceLocation` remain source provenance.
`area_forecast_discussion` uses optional `sections` configuration to include a
subset of discussion fields.
## External Adapters Used
None directly.
## State Or Manifest Behavior
None. `internal/app` collects module outputs into a `module.Snapshot`, and
`internal/state` persists that snapshot.
## Skip And Resume Behavior
None. Builders either emit a module output, omit optional unavailable data, or
return an error for invalid required inputs.
## Failure Behavior
- Daily briefing construction requires a Daily report definition and a derived
daily forecast summary.
- 3-Day briefing construction requires a 3-Day report definition and at least
one derived daily summary in the outlook period.
- Weekend briefing construction requires a Weekend report definition and at
least one derived daily summary in the weekend period.
- Storm briefing construction requires a Storm Report definition and a forecast
bundle.
- Save failures include path and operation context.
- Required derived modules return errors when their dependent facts are not
available.
- Module registry construction rejects duplicate module IDs and duplicate
stanza names.
- Composition validation rejects unknown modules, duplicate modules,
incompatible report/module combinations, duplicate stanza names, and invalid
option shapes.
- Source-oriented module builders omit missing optional current conditions,
forecast discussion, and weather story stanzas.
- Alert digest output distinguishes checked empty alert data from missing alert
source data.
## Tests
Inspect:
- `internal/briefing/daily_test.go`
- `internal/briefing/three_day_test.go`
- `internal/briefing/weekend_test.go`
- `internal/briefing/storm_test.go`
- `internal/briefing/base_modules_test.go`
- `internal/briefing/derived_modules_test.go`
- `internal/briefing/modules_test.go`
- `internal/app/app_test.go`
- `internal/cli/root_test.go`
## Invariants
- Weather facts come from normalized and derived source data.
- Briefing output remains JSON-inspectable.
- LLM prompt input packaging and `scriptorium` execution remain outside this
boundary.
- Module outputs contain structured weather facts and source context.
- Common metadata includes RunID, report ID, prompt ID, valid period, source
provenance, source hashes, source warnings, and configured prompt location.
- Prompt input packaging and Scriptorium execution remain outside this package.

View File

@@ -1,62 +1,62 @@
# Changes Internals
This document describes the implemented structured change comparison boundary.
This document describes structured Recent Changes comparison.
## Purpose
`internal/changes` compares current and prior structured briefing snapshots and
produces compact change records for prompt input data packages.
`internal/changes` compares current and prior module snapshots and emits
compact change records for prompt input data packages.
## Inputs and Outputs
## Inputs And Outputs
Inputs:
- prior briefing package
- current briefing package
- configured Recent Changes thresholds
- prior module snapshot
- current module snapshot
- comparison thresholds from configuration
Output:
Outputs:
- ordered `changes.Change` records with type, message, previous value, and
current value where useful
- ordered `changes.Change` items with type, message, previous value, and current
value where useful
## Boundaries
- This package compares structured briefing data only.
- It does not read state directly, render Markdown, invoke `scriptorium`, or
compare generated report text.
- This package compares structured module snapshot data only.
- It does not read filesystem state, find prior snapshots, render Markdown,
invoke Scriptorium, or compare generated report text.
## Config Fields Used
The app maps these config fields into comparison thresholds:
The app maps these fields into comparison thresholds:
- `recent_change.temperature_degrees`
- `recent_change.precip_probability_points`
- `recent_change.wind_gust_miles_per_hour`
- `recent_change.precip_timing_shift_minutes`
## Behavior
## External Adapters Used
Daily, 3-Day, and Weekend comparison currently detect:
None.
- temperature changes crossing configured thresholds
- precipitation probability and timing changes
- alert additions and removals
- peak wind gust changes
- snow, ice, and thunder risk changes
## State Or Manifest Behavior
When no prior comparable snapshot exists, the app sends an empty Recent Changes
section in the data package. Daily Today and Daily Tomorrow are compatible for
same-valid-date comparison through the report registry. 3-Day Outlook compares
with prior 3-Day Outlook snapshots for the same valid local date. Weekend
Outlook compares with prior Weekend Outlook snapshots for the same weekend
window.
None directly. The app loads prior module snapshots through `internal/state`
before calling comparison functions.
## Skip And Resume Behavior
No resume behavior. When the app has no prior comparable snapshot, it sends an
empty Recent Changes list without calling a comparison function.
## Failure Behavior
Daily comparison requires both inputs to contain Daily briefing content. 3-Day
comparison requires both inputs to contain 3-Day briefing content. Weekend
comparison requires both inputs to contain Weekend briefing content.
- Daily comparison requires `derived_daily_summary` and
`derived_daypart_summaries` stanzas. It also uses `alert_digest` and
`precip_timing` when present.
- 3-Day comparison requires `derived_daypart_summaries`.
- Weekend comparison requires `derived_daypart_summaries`.
- Storm Report comparison returns no changes.
## Tests
@@ -70,5 +70,6 @@ Inspect:
## Invariants
- Recent Changes are based on structured snapshots, not Markdown report text.
- Comparison thresholds come from configuration.
- The comparison output remains compact enough for prompt input.
- Report compatibility is determined outside this package by report definitions
and state lookup.
- Output stays compact enough for prompt input.

View 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.

72
docs/internal/facts.md Normal file
View File

@@ -0,0 +1,72 @@
# 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
- `facts.DerivedFacts` with valid-period forecast slices, alert overlaps,
daily summaries, daypart summaries, and Storm Report window summary
## Boundaries
- This package owns fact assembly and reusable deterministic derivation for a
report run.
- 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.
- Missing optional narrative, alert, discussion, daily, or weather story data
produces empty or nil derived fields.
## 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.
- Source provenance and warnings stay separate from ordinary fact fields.
- Prompt-specific wording and one-off presentation decisions stay outside this
package.

View File

@@ -1,57 +1,66 @@
# Forecast Derivation Internals
This document describes the implemented deterministic forecast summarization
boundary.
This document describes deterministic forecast summarization in
`internal/forecast`.
## Purpose
`internal/forecast` converts a normalized forecast bundle into inspectable
daily and multi-day daypart summaries. These summaries are structured data for
later briefing builders; they are not rendered report text.
`internal/forecast` converts normalized weather data into daily and period
summaries used by fact builders and module builders.
## Inputs and Outputs
## Inputs And Outputs
Inputs:
- `forecast.Bundle`
- local date and timezone
- report period, for multi-day summaries
- configured daypart definitions with `HH:MM` start and end values
- `weatherdata.Bundle`
- local date or resolved report period
- timezone
- configured daypart definitions
Output:
Outputs:
- `forecast.DailySummary` with a civil-day period, daypart summaries, selected
narrative periods, alert overlaps, discussion context, source warnings, and
source provenance.
- `forecast.BuildPeriodDailySummaries` output with one clipped daily summary
for each local day or partial day in a report period.
- `forecast.DailySummary` for one local civil day
- one clipped daily summary per local day or partial day from
`BuildPeriodDailySummaries`
- daypart summaries with selected hourly periods, ranges, timed maximums,
conditions, indicators, and alert overlaps
## Boundaries
- This package groups and summarizes already-normalized forecast data.
- It does not fetch weather data, resolve report definitions, compare prior
snapshots, build prompt input packages, or call `scriptorium`.
- This package groups, selects, and summarizes already-normalized forecast
data.
- It does not perform HTTP calls, parse CLI flags, resolve report definitions,
compare prior snapshots, build prompt input packages, or invoke Scriptorium.
## Behavior
## Config Fields Used
- Daypart windows use half-open intervals.
- Overnight dayparts are supported when the end clock is not after the start
clock.
- Hourly forecast periods are selected by overlap with the daypart window.
- Each daypart computes temperature range, apparent-temperature range, maximum
precipitation probability, peak wind speed, peak wind gust, dominant
condition, notable conditions, and basic weather indicators.
- Alerts are selected by overlap with the daily period and each daypart.
- Narrative periods and discussion context are selected as broader source
context for later briefing builders.
- Multi-day period summaries clip the first and last local days to the resolved
report period before selecting hourly periods and alerts.
- `dayparts[].name`
- `dayparts[].start`
- `dayparts[].end`
Threshold constants for basic indicators live in forecast code rather than
configuration.
## External Adapters Used
None directly. Forecast data arrives through `weatherdata.Bundle`.
## State Or Manifest Behavior
None. Source warnings and provenance from the bundle are carried into summaries
for later metadata and module output.
## Skip And Resume Behavior
None. Missing optional source context can produce empty selections, but missing
required hourly data fails summarization.
## Failure Behavior
- Missing hourly forecast data returns an error.
- Invalid daypart definitions return actionable parse errors.
- Alert records without parseable RFC3339 start/end fields are skipped.
- A nil bundle or missing hourly forecast data returns an error.
- Invalid daypart definitions return parse errors with context.
- Alert records without parseable RFC3339 timing are skipped.
- Empty selected periods produce empty summaries rather than generated prose.
## Tests
@@ -62,7 +71,6 @@ Inspect:
## Invariants
- Weather facts come from normalized source data, not generated prose.
- Outputs remain JSON-inspectable.
- Forecast derivation remains independent of CLI, HTTP adapters, and report
registry behavior.
- Go owns report-period selection and meteorological summarization.
- Weather facts come from normalized source data.
- Outputs remain JSON-inspectable and independent of CLI, state, and adapters.

98
docs/internal/module.md Normal file
View File

@@ -0,0 +1,98 @@
# 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`
- `area_forecast_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.
## Options
Most modules use an empty options struct. `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.
## 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.

View File

@@ -1,54 +1,121 @@
# Prompt Input Internals
This document describes the implemented prompt input package boundary.
This document describes YAML prompt data package construction in
`internal/promptinput`.
## Purpose
`internal/promptinput` converts a structured briefing package into the
`data_package` JSON file passed to `scriptorium` prompts.
`internal/promptinput` converts report metadata, an ordered module snapshot,
Recent Changes, and source warnings into the `data_package` file passed to
Scriptorium.
## Inputs and Outputs
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.
Input:
## Inputs And Outputs
- `briefing.Package` containing Daily-family, 3-Day Outlook, Weekend Outlook,
or Storm Report content
Inputs:
Output:
- report metadata from app/state orchestration
- `module.Snapshot`
- optional `[]changes.Change`
- `promptinput.Package` JSON with report metadata, briefing content, source
warnings, RunID, and a Recent Changes section.
Outputs:
- `promptinput.Package` with schema version, RunID, report metadata, named
module stanzas grouped for prompt presentation, Recent Changes, and source
warnings
- YAML bytes from `promptinput.MarshalYAML`
- YAML file written atomically by `promptinput.Save`
The YAML shape includes:
```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: {}
derived_summaries:
derived_daily_summary: {}
derived_daypart_summaries: {}
precip_timing: {}
outdoor_windows: {}
narrative_products:
narrative_forecast: {}
area_forecast_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.
Current categories are:
- `applicable_risk_products`: location-applicable alerts, warnings, outlooks,
discussions, and similar risk products.
- `derived_summaries`: deterministic summaries and calculated report facts.
- `narrative_products`: official narrative text products and forecast stories.
- `raw_data`: minimally transformed underlying weather data.
## Boundaries
- This package owns the prompt input schema and required-field validation.
- It does not fetch weather data, compute forecast summaries, compare prior
snapshots, or invoke `scriptorium`.
- This package owns prompt package schema, YAML marshaling, YAML loading, and
validation.
- It does not fetch weather data, derive forecast summaries, execute modules,
find prior snapshots, compare changes, choose artifact paths, or invoke
Scriptorium.
## Behavior
## Config Fields Used
- `promptinput.Build` copies report metadata from the briefing package.
- `promptinput.Validate` rejects missing or inconsistent required fields before
render preflight.
- `promptinput.Save` writes JSON atomically where practical.
- Recent Changes is present as an `items` list. It is empty when no prior
comparable snapshot exists or no meaningful changes are detected.
None directly. Config-derived values such as timezone, units, and prompt
location are already present in report metadata and module stanzas before this
package runs.
## External Adapters Used
None.
## State Or Manifest Behavior
`promptinput.Save` writes YAML atomically. Managed workspace paths are owned by
`internal/state`.
## Skip And Resume Behavior
None. Recent Changes is always present as an `items` list and may be empty.
## Failure Behavior
Validation errors name the missing or inconsistent field. Save failures include
the filesystem operation and path context.
Validation fails before render preflight when required top-level fields are
missing or inconsistent, when the valid period is invalid, or when no module
stanzas are present. Save failures include filesystem operation and path
context.
## Tests
Inspect:
- `internal/promptinput/package_test.go`
- `internal/changes/daily_test.go`
- `internal/app/app_test.go`
## Invariants
- Prompt input data remains structured JSON.
- Briefing metadata and top-level report metadata must agree.
- Recent Changes is not inferred from rendered report text.
- Scriptorium receives structured YAML through `--input data_package=<path>`.
- Module stanza order is deterministic within each prompt-facing category.
- 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.

View File

@@ -1,53 +1,85 @@
# Report Registry Internals
This document describes the implemented report identity and valid-period
boundary.
This document describes report identity, valid-period resolution, batch
membership, output naming, artifact grouping, and comparison declarations in
`internal/report`.
## Purpose
`internal/report` centralizes report IDs, prompt IDs, comparison strategies,
valid-period resolution, report metadata, and scheduled batch membership.
`internal/report` is the canonical source for report definitions. App, state,
module building, and CLI wiring consume resolved definitions instead of owning
report identity policy themselves.
## Inputs and Outputs
## Definition Fields
Inputs:
Each report definition declares:
- report ID or batch name
- generation time
- configured timezone
- optional Daily date override
- optional manual storm start and end times
- report ID and display name
- Scriptorium prompt ID
- valid-period resolver
- comparison strategy
- managed artifact group
- batch output copy filename
- generated-report eligibility
- prior-report compatibility list
- morning or evening batch membership
- default ordered module composition
Outputs:
## Reports
- `report.Resolved` values with definition metadata and half-open valid periods
- `report.Metadata` values suitable for later persisted run metadata
| Report | ID | Prompt | Artifact group | Batch copy | Prior compatibility |
| --- | --- | --- | --- | --- | --- |
| Daily Today | `daily_today` | `weather.daily_report` | `daily` | `daily.md` | Daily Today, Daily Tomorrow |
| Daily Tomorrow | `daily_tomorrow` | `weather.daily_report` | `daily` | `tomorrow.md` | Daily Today, Daily Tomorrow |
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `three-day` | `three-day.md` | 3-Day Outlook |
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `weekend` | `weekend.md` | Weekend Outlook |
| Storm Report | `storm` | `weather.storm_report` | `storm` | `storm.md` | Storm Report |
All report definitions are eligible for generation.
## Valid Periods
- Daily Today covers the selected local civil day, or the current local civil
day when no date override is supplied.
- Daily Tomorrow covers the next local civil day from generation time.
- 3-Day Outlook covers the interval from generation time through local midnight
three days later.
- Weekend Outlook covers the upcoming weekend window and is not scheduled for
Sunday morning batch resolution.
- Storm Report covers an explicit event window supplied by the caller.
Storm event windows can be parsed from local `YYYY-MM-DDTHH:MM` timestamps in
the configured timezone or RFC3339 timestamps with explicit offsets. End time
must be after start time.
## Boundaries
- This package defines report identity and time coverage only.
- It does not fetch weather data, build briefings, compare snapshots, write
state, or call `scriptorium`.
`internal/report` defines report metadata and time coverage. It does not fetch
weather data, build module values, compare snapshot contents, write state,
parse CLI flags, or invoke Scriptorium.
## Behavior
The CLI owns public command names. The app maps those command names to report
IDs, then uses the registry for report policy.
- Daily Today covers one configured local civil day.
- Daily Tomorrow covers the next configured local civil day.
- 3-Day Outlook covers generation time through local midnight after the second
following local civil day.
- Weekend Outlook covers Saturday 00:00 to Monday 00:00 Monday through
Thursday; Friday and Saturday cover the remaining weekend from Friday 18:00
or generation time, whichever is later.
- Manual Storm Report uses explicit start and end times.
- Morning batch resolves Daily Today and 3-Day Outlook, plus Weekend Outlook
except on Sunday.
- Evening batch resolves Daily Tomorrow.
## Config Fields Used
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
output path copying uses batch output names from report definitions.
## State And App Usage
- State paths use `ArtifactGroup`.
- Batch output copies use `BatchOutputName`.
- Generation checks `Generated`.
- Module composition defaults use `Modules`.
- Prior lookup checks `CompatiblePriorIDs` and the comparison strategy.
- RunIDs include the resolved report ID.
## Failure Behavior
- Unknown report and batch names return actionable errors.
- Sunday Weekend Outlook resolution returns an error.
- Storm windows require start and end, with end after start.
- Unknown report IDs and batch names return actionable errors.
- Weekend Outlook resolution returns an error when resolved directly on Sunday.
- Storm Report resolution requires start and end, with end after start.
## Tests
@@ -55,9 +87,13 @@ Inspect:
- `internal/report/period_test.go`
- `internal/app/app_test.go`
- `internal/cli/root_test.go`
## Invariants
- Report selection goes through the registry.
- Valid periods are independent of rendered report text.
- Prompt IDs and comparison strategies are declared with report definitions.
- Daily Today and Daily Tomorrow both use `weather.daily_report`.
- Valid periods are half-open intervals independent of rendered report text.
- Artifact grouping, batch output filenames, generated-report eligibility,
default module composition, comparison compatibility, and comparison strategy
are declared by report definition.

View File

@@ -1,59 +1,88 @@
# Scriptorium Adapter Internals
This document describes the implemented `scriptorium` subprocess adapter.
This document describes the subprocess adapter in
`internal/adapters/scriptorium`.
## Purpose
`internal/adapters/scriptorium` runs `scriptorium render` to preflight prompt
wiring and `scriptorium run` to generate report artifacts.
The adapter runs `scriptorium render` for prompt preflight and `scriptorium run`
for Markdown report generation. It isolates subprocess execution, argv
construction, timeout handling, output capture, and exit-code interpretation
from app and domain packages.
## Inputs and Outputs
## Inputs And Outputs
Input:
Inputs:
- prompt ID
- prompt input data package path
- YAML prompt input data package path
- report output path for `run`
- configured binary, config path, profile, timeout, and extra arguments
- context for cancellation
Output:
Outputs:
- captured stdout, with truncation tracking
- captured stderr, with truncation tracking
- argv used for execution
- captured stdout and stderr
- truncation flags for captured output
- exit code
- full argv used for inspection
- report output path for `run`
## Boundaries
- This adapter owns `scriptorium` CLI flag construction and subprocess
execution.
- It does not choose report types, build prompt input, fetch weather data, or
decide workflow order.
`internal/adapters/scriptorium` owns Scriptorium command construction and
subprocess execution. It does not choose report types, build prompt input,
fetch weather data, decide workflow order, or persist workflow metadata.
## Behavior
The adapter exposes request and result structs for render and run operations.
State persistence uses a state-owned preflight artifact shape; app
orchestration converts render results before saving.
The render invocation shape is:
## Config Fields Used
- `scriptorium.binary`
- `scriptorium.config_path`
- `scriptorium.profile`
- `scriptorium.timeout`
- `scriptorium.extra_args`
## Commands
Render preflight argv starts with:
```text
scriptorium render --prompt <prompt_id> --input data_package=<path> --format json
```
The run invocation shape is:
Report generation argv starts with:
```text
scriptorium run --prompt <prompt_id> --input data_package=<path> --out <artifact_path>
scriptorium run --prompt <prompt_id> --input data_package=<path> --out <path>
```
Configured `--config` and `--profile` values are added when present. Arguments
are passed directly as argv, not through a shell. Stdout and stderr are captured
separately. `SaveRenderResult` writes the captured result as JSON for inspection.
Configured `--config` and `--profile` flags are inserted after the subcommand
and before prompt-specific arguments. Extra arguments are appended after the
built-in arguments.
## Execution Behavior
The adapter runs commands without shell interpolation. The same private
execution path is used by render and run after command-specific request
validation and argv construction.
When `scriptorium.timeout` is greater than zero, each subprocess call uses a
context with that timeout. Stdout and stderr are captured separately, capped at
1 MiB each, and marked as truncated when the cap is reached.
## Failure Behavior
Nonzero render and run exits return both the captured result and an error
containing the exit code and stderr. Run exit code `2` is treated as an error
but may still produce a report artifact. Command execution respects context
cancellation and the configured timeout.
- Missing prompt ID or data package 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
operation context by the caller-facing method.
- Nonzero render and run exits return the captured result plus an error
containing the exit code and stderr.
## Tests
@@ -65,7 +94,8 @@ Inspect:
## Invariants
- `scriptorium` details stay inside the adapter package.
- The input name for prompt packages is always `data_package`.
- Render preflight remains orchestration behavior; this adapter only exposes the
subprocess operations.
- No shell interpolation is used.
- The Scriptorium input name is `data_package`.
- The file at the data package path is YAML produced by `internal/promptinput`.
- Render and run preserve command-specific result structs.
- Scriptorium-specific flags stay inside adapter and config boundaries.

View File

@@ -1,40 +1,44 @@
# State Internals
This document describes the implemented filesystem state boundary.
This document describes filesystem state in `internal/state`.
## Purpose
`internal/state` owns durable artifact paths, atomic JSON writes, metadata, and
prior comparable snapshot lookup.
`internal/state` owns managed workspace paths, atomic JSON writes, persisted
metadata, prior snapshot lookup, and read-only artifact inspection helpers.
## Inputs and Outputs
## Inputs And Outputs
Inputs:
- workspace configuration
- resolved report definition and valid period
- briefing package
- module snapshot
- prompt input data package
- `scriptorium render` result
- rendered report path preparation
- preflight artifact
- rendered report path preparation request
- RunID for inspection lookups
Outputs:
- briefing snapshot JSON
- prompt input data package JSON
- render preflight JSON
- Markdown report path
- metadata JSON
- prior comparable snapshot metadata when available
- prior briefing package when loaded by path
- module snapshot JSON path
- prompt input data package YAML path
- render preflight JSON path
- managed Markdown report path
- metadata JSON path
- prior comparable snapshot metadata
- loaded module snapshot or data package
- recent report records for inspection
- metadata and data package lookup by RunID
## Boundaries
- This package owns managed workspace layout and narrow path validation.
- It does not fetch weather data, derive forecasts, build prompt inputs, invoke
`scriptorium`, or compare briefing contents.
`internal/state` owns local filesystem layout, path validation, durable writes,
metadata reads, prior lookup, and report listing. It does not fetch weather
data, derive forecasts, build prompt input content, compare module contents,
invoke Scriptorium, import adapter result types, or parse CLI flags.
Preflight persistence uses the state-owned `PreflightArtifact` shape. The app
converts adapter render results into that shape before saving.
## Config Fields Used
@@ -43,29 +47,67 @@ Outputs:
- `workspace.reports_dir`
- `workspace.data_packages_dir`
- `workspace.preflight_dir`
- `workspace.notifications_dir`
Workspace subdirectories must be relative paths that stay under
`workspace.root`.
## State Behavior
## Managed Layout
Managed artifact names use RunID, which is generated from report generation time
and report ID. Metadata is stored beside briefing snapshots by report group and
valid local date. Prior snapshot lookup reads metadata for the same valid local
date and returns the latest earlier compatible run. Daily Today and Daily
Tomorrow are compatible with each other; 3-Day Outlook is compatible with prior
3-Day Outlook snapshots; Weekend Outlook is compatible with prior Weekend
Outlook snapshots for the same weekend window. The store can load a briefing
snapshot by path for structured comparison. The store can list metadata-backed
report records and load metadata or data packages by RunID for inspection. The
store prepares the managed Markdown report path before `scriptorium run` writes
it.
Paths are derived from the resolved report definition's artifact group, the
valid-period start date for dated artifacts, and the RunID.
```text
<workspace.root>/
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.modules.json
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.metadata.json
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.yaml
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json
notifications/<artifact_group>/<YYYY-MM-DD>/<run_id>.distributor.json
reports/<artifact_group>/<run_id>.md
```
Metadata is stored beside module snapshots and links the module snapshot, data
package, preflight, report paths, notification path when attempted, and
configured prompt location. Report listing walks metadata files under the
snapshots directory.
## Prior Lookup
Prior snapshot lookup reads stored metadata through the shared lookup path and
selects the latest earlier snapshot whose report ID is compatible with the
current report definition.
- Daily Today and Daily Tomorrow are compatible with each other for the same
valid local date.
- 3-Day Outlook compares with prior 3-Day snapshots for the same valid local
date.
- Weekend Outlook compares with prior Weekend snapshots for the same weekend
window.
- Storm Report has no prior lookup because explicit event-window comparison is
not searched by the filesystem store.
## Writes And Inspection
Durable JSON writes use shared atomic file helpers. Managed Markdown reports are
prepared by creating their parent directory; Scriptorium writes the report body
to the prepared path. Extra Markdown copies are handled by app orchestration.
Distributor notification debug artifacts are written atomically when
notification is attempted and include rendered distributor pipeline ID, bundle
ID, idempotency key, bundle paths, upload status, latest run status, and
redacted errors.
Inspection helpers read existing metadata, module snapshot, and data package
files. Missing metadata directories return no inspection records or no prior
snapshot rather than creating state.
## Failure Behavior
Writes are atomic where practical: JSON is written to a temporary file in the
target directory and then renamed into place. Invalid workspace paths and
missing required metadata fields produce actionable errors.
- Invalid workspace paths return validation errors.
- Missing required metadata fields prevent metadata writes.
- JSON writes use a temporary file followed by rename where practical.
- Read and decode failures include path context.
- Unknown RunIDs produce an actionable lookup error.
## Tests
@@ -77,5 +119,6 @@ Inspect:
## Invariants
- Managed paths stay under the configured workspace root.
- Metadata links the artifacts produced for a run.
- Artifact grouping comes from report definitions.
- Metadata links artifacts produced for a run.
- Prior lookup is based on structured metadata, not rendered report text.

View File

@@ -1,59 +1,77 @@
# Weather Data Internals
This document describes the implemented weather data ingestion boundary.
This document describes Weather API ingestion into `weatherdata.Bundle`.
## Purpose
`internal/adapters/weatherapi` fetches normalized weather data from one
configured weather API endpoint and assembles a `forecast.Bundle`.
configured Weather API endpoint and assembles the bundle consumed by forecast
derivation and module builders. Module builders expose normalized current
conditions and weather story context when those sources are available.
## Inputs and Outputs
## Inputs And Outputs
Input:
Inputs:
- `config.Config` with `weather_api.base_url`, `format`, `units`, `timezone`,
`precision`, timeout, and missing-source policy.
- `config.Config` with Weather API URL, timeout, format, units, timezone,
precision, and missing-source policy
- HTTP responses using the Weather API `data` envelope
Output:
Outputs:
- `forecast.Bundle` containing observation, current conditions, hourly forecast,
narrative forecast, alerts, discussion, stub source slots, provenance, and
source warnings.
- `weatherdata.Bundle` with observation, current conditions, hourly forecast,
narrative forecast, active alerts, discussion, latest weather story, source
records, and source warnings
- optional saved bundle JSON through app fetch helpers
## Boundaries
- The adapter performs HTTP calls and decoding only.
- Forecast derivation, daypart grouping, report periods, report rendering, and
`scriptorium` execution are outside this boundary.
- Hourly forecast data is required. Other missing or malformed source sections
use the configured missing-source policy.
- The adapter owns HTTP calls, response-envelope handling, source hashing, and
decoding into internal bundle types.
- It does not derive dayparts, resolve report periods, build module values, compare
snapshots, write report state, or invoke Scriptorium.
## External Adapter
## Config Fields Used
The adapter calls:
- `weather_api.base_url`
- `weather_api.timeout`
- `weather_api.format`
- `weather_api.units`
- `weather_api.timezone`
- `weather_api.precision`
- `missing_source.default`
- `missing_source.sources`
- `/observations`
- `/conditions/current`
- `/forecast/hourly`
- `/forecast/narrative`
- `/alerts/active`
- `/discussion`
## External Adapters Used
Forecast routes use the full-product endpoints, not day-slice endpoints.
- Weather API HTTP service
## State
See [Weather API integration](../integrations/weatherapi.md) for the external
contract used by this project.
`app.FetchAndSaveBundle` can save an inspectable bundle JSON file using an
atomic rename. No report state, snapshots, or prompt input packages are written
yet.
## State Or Manifest Behavior
The adapter records source name, endpoint, query, fetch time, source timestamps
when available, SHA-256 hash over compact raw `data` JSON, missing status, and
source warnings. Successful `data: null` responses from `/alerts/active`
represent a checked empty active-alert list, not a missing source.
`app.FetchAndSaveBundle` can write bundle JSON atomically for inspection.
## Skip And Resume Behavior
No resume behavior. Optional missing or malformed sources may be omitted,
warned, or treated as errors according to missing-source policy. Hourly forecast
data is required and cannot be skipped.
## Failure Behavior
- HTTP and envelope decode failures return actionable errors with endpoint
context.
- Missing hourly data fails the fetch.
- Missing or malformed optional sources follow `error`, `warn`, or `none`.
- Source identity uses SHA-256 over compacted raw `data` JSON.
- Missing or invalid `weather_api.base_url` prevents client construction.
- HTTP errors, response read failures, and envelope decode failures include
endpoint context.
- Missing hourly data or hourly forecasts with no periods fail bundle fetch.
- Optional sources follow missing-source policy.
- Explicit `data: null` from `/alerts/active` produces an empty, non-missing
alert run.
## Tests
@@ -65,5 +83,6 @@ Inspect:
## Invariants
- Weather facts come from normalized source data.
- External API details stay inside `internal/adapters/weatherapi`.
- Source provenance and warnings remain inspectable for later briefing builders.
- Full hourly and narrative products are fetched; Go owns report-period
selection.
- Source provenance and warnings remain inspectable downstream.

View File

@@ -1,8 +1,12 @@
# Weatherreporter Operations
This guide covers normal operation, generated artifacts, inspection, recovery,
and operational caveats. For symptom-specific diagnosis, see
[Troubleshooting](troubleshooting.md).
## Normal Workflow
The implemented generation workflows are:
Generation commands:
```text
weatherreporter generate daily --date 2026-05-29
@@ -10,22 +14,34 @@ weatherreporter generate tomorrow
weatherreporter generate three-day
weatherreporter generate weekend
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
```
Each command resolves a report period, fetches a Weather API bundle, builds a
JSON module snapshot, builds a YAML prompt input data package, runs
`scriptorium render`, runs `scriptorium run`, and writes managed artifacts under
the configured workspace.
When distributor notification is enabled, weatherreporter uploads the managed
Markdown report after `scriptorium run` succeeds and final metadata is saved.
`--out PATH` writes an extra Markdown copy for the current generated report; it
is not used as the distributor upload source.
Batch commands:
```text
weatherreporter run morning
weatherreporter run evening
```
These commands fetch weather data, build a briefing for the resolved valid
period, build the prompt input data package, run `scriptorium render`, run
`scriptorium run`, and write inspectable artifacts under the configured
workspace. The evening run resolves only the Tomorrow Planning Brief. The
morning run generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
except on Sunday. Storm Report generation is manual and uses the explicit
`--start` and `--end` bounds as its valid period.
Scheduled run commands print a JSON aggregate summary to stdout and compact
per-report status lines to stderr. If one report fails, remaining independent
reports are still attempted. The command returns nonzero after the run when any
report failed.
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch
commands print a JSON summary to stdout, write compact per-report status lines
to stderr, continue independent reports after one report fails, and return
nonzero when any report failed. When notification is configured, the summary and
status lines include notification status, accepted distributor run ID, or
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
@@ -36,33 +52,33 @@ workspace/
snapshots/
daily/
YYYY-MM-DD/
<run_id>.briefing.json
<run_id>.modules.json
<run_id>.metadata.json
three-day/
YYYY-MM-DD/
<run_id>.briefing.json
<run_id>.modules.json
<run_id>.metadata.json
weekend/
YYYY-MM-DD/
<run_id>.briefing.json
<run_id>.modules.json
<run_id>.metadata.json
storm/
YYYY-MM-DD/
<run_id>.briefing.json
<run_id>.modules.json
<run_id>.metadata.json
data-packages/
daily/
YYYY-MM-DD/
<run_id>.data_package.json
<run_id>.data_package.yaml
three-day/
YYYY-MM-DD/
<run_id>.data_package.json
<run_id>.data_package.yaml
weekend/
YYYY-MM-DD/
<run_id>.data_package.json
<run_id>.data_package.yaml
storm/
YYYY-MM-DD/
<run_id>.data_package.json
<run_id>.data_package.yaml
preflight/
daily/
YYYY-MM-DD/
@@ -76,6 +92,19 @@ workspace/
storm/
YYYY-MM-DD/
<run_id>.render.json
notifications/
daily/
YYYY-MM-DD/
<run_id>.distributor.json
three-day/
YYYY-MM-DD/
<run_id>.distributor.json
weekend/
YYYY-MM-DD/
<run_id>.distributor.json
storm/
YYYY-MM-DD/
<run_id>.distributor.json
reports/
daily/
<run_id>.md
@@ -87,96 +116,142 @@ workspace/
<run_id>.md
```
The Markdown report is written to a RunID-managed report path. When `--out` is
provided to `generate daily`, `generate tomorrow`, `generate three-day`,
`generate weekend`, or `generate storm`, the managed report is also copied to
that path.
Managed artifact filenames use the RunID, so repeated runs for the same valid
period do not overwrite each other.
For `run morning` and `run evening`, `--out-dir PATH` writes extra Markdown
copies using each report definition's default filename, such as `daily.md`,
`three-day.md`, `weekend.md`, or `tomorrow.md`.
## RunID And Metadata
## Run Identifiers
Run IDs are based on generation time plus report ID, such as:
RunIDs are based on generation time plus report ID:
```text
20260529T100000.123456789Z_daily_today
```
Managed artifact filenames use the RunID so repeated runs for the same valid
date do not overwrite each other.
## Metadata
Each generated report writes metadata that links:
- RunID
- report ID and prompt ID
- generation time and valid period
- RunID, report ID, variant, and prompt ID
- generation time, timezone, and valid period
- source location, source hashes, and source warnings
- briefing snapshot path
- module snapshot path
- prompt input data package path
- preflight output path
- rendered report path
- managed Markdown report path
- distributor notification debug artifact path, when notification is attempted
Run summaries include each report ID, prompt ID, RunID, status, error text when
applicable, valid period, and artifact paths known to the application.
Batch summaries include report status, error text when applicable, notification
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. Weather API, module snapshot,
data-package, render preflight, Scriptorium run, 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
Use `weatherreporter inspect reports` to list recent generated runs from the
configured workspace. The output includes RunID, report ID, valid period,
metadata path, briefing path, report path, and source warning count.
Run-specific inspection commands emit JSON for a single RunID:
Inspection commands read existing workspace artifacts and emit JSON to stdout.
They do not fetch weather data or run `scriptorium`.
```text
weatherreporter inspect reports --limit 10
weatherreporter inspect metadata RUN_ID
weatherreporter inspect briefing RUN_ID
weatherreporter inspect modules RUN_ID
weatherreporter inspect data-package RUN_ID
weatherreporter inspect prior RUN_ID
weatherreporter inspect sources RUN_ID
```
`inspect prior` shows the prior comparable snapshot selected from stored
metadata, or `null` when none exists. `inspect sources` shows source provenance
and source warnings without dumping full weather payloads.
Use `inspect reports` to find RunIDs and artifact paths. Use
`inspect metadata` to see the artifact links recorded for a run. Use
`inspect modules` to review the persisted ordered module snapshot, and
`inspect data-package` to review the structured prompt package used for
rendering. Use `inspect prior` to see the prior comparable snapshot selected for
Recent Changes, or `null` when none exists. Use `inspect sources` to review
source provenance and warnings without dumping full weather payloads.
## Recent Changes
When a prior comparable Daily briefing snapshot exists for the same valid local
date, the app compares structured briefing data before writing the prompt input
data package. Daily Today and Daily Tomorrow can compare with each other when
they cover the same valid local date. Meaningful changes are included under
`recentChanges.items`.
Recent Changes are computed from structured module snapshots, not rendered
Markdown or YAML text.
3-Day Outlook generation compares against a prior compatible 3-Day briefing
snapshot for the same valid local date when one exists.
Weekend Outlook generation compares against a prior compatible Weekend briefing
snapshot for the same weekend window when one exists. Friday evening and
Saturday runs may narrow the valid start while keeping the same Monday endpoint.
Storm Report generation currently leaves Recent Changes empty. Its explicit
event window is still recorded in briefing and metadata artifacts.
Daily Today and Daily Tomorrow can compare with each other when they cover the
same valid local date. 3-Day Outlook compares with prior compatible 3-Day
snapshots for the same valid local date. Weekend Outlook compares with prior
compatible Weekend snapshots for the same weekend window. Storm Report leaves
Recent Changes empty.
When no prior comparable snapshot exists, or no configured threshold is crossed,
the Recent Changes list is empty.
`recentChanges.items` is empty.
## Recovery
If render preflight exits nonzero after producing a result, the captured stdout,
stderr, exit code, and command are still written to the preflight artifact, and
metadata is still written for inspection.
A failed generation run may still leave useful artifacts:
If `scriptorium run` exits nonzero after writing a report, the generated report
and metadata remain available for inspection. Exit code `2` is still returned as
an error because it indicates validation failed, even if report output exists.
- If `scriptorium render` returns a result with a nonzero exit code, the
preflight JSON and metadata are written for inspection.
- If `scriptorium run` exits nonzero after writing a report, the managed report
and metadata remain available.
- 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
artifact paths for each failed report.
For scheduled runs, inspect stdout first for the aggregate JSON summary, then
use the per-report artifact paths in that summary to inspect briefing,
data-package, preflight, metadata, and rendered report files.
For a bad report, start with:
The application does not currently implement resume, cleanup, archive, or
remote storage behavior.
```text
weatherreporter inspect metadata RUN_ID
weatherreporter inspect sources RUN_ID
weatherreporter inspect modules RUN_ID
weatherreporter inspect data-package RUN_ID
weatherreporter inspect prior RUN_ID
```
## Operational Caveats
- The application uses one configured Weather API endpoint.
- The application writes local filesystem state only.
- The application does not implement resume, cleanup, archive, remote storage,
daemon operation, or automatic storm monitoring.
- Generated reports and Scriptorium stderr can contain sensitive operational
context. Store workspace artifacts with appropriate filesystem permissions.

View File

@@ -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 projects shape, boundaries, and invariants as the code evolves.
## 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.
`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
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.
@@ -46,16 +52,15 @@ Centralize configuration loading, processing, precedence, defaults, and validati
The goal is to make configuration discoverable and avoid implicit or hidden operational values. User-visible defaults and cross-package operational defaults should be defined in `internal/config/defaults.go`.
Unless documented otherwise, precedence is:
Configuration precedence is:
1. CLI flags
2. environment variables
3. configuration file
4. built-in defaults
2. configuration file
3. built-in defaults
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
@@ -65,13 +70,17 @@ External adapters belong under `internal/adapters/<name>`. If an adapter uses an
Adapters should be thin. Domain decisions belong in application/domain packages, not inside adapter glue.
## Modules, Stages, and Registries
## Components and Registries
When the application has stages or modules, each major stage/module should live in its own package and have an explicit input/output contract.
When the application has major workflow components, each component should live
near the package that owns its contract and have explicit inputs and outputs.
The orchestrator should be able to compose, skip, resume, or run individual stages/modules when their prerequisites are satisfied. Ordering should be explicit: use a default sequence, dependency graph, or documented orchestration rule.
The orchestrator should compose components in an explicit order using a default
sequence, dependency graph, or documented orchestration rule.
If users can select modules, stages, validators, renderers, or adapters, selection should go through a registry or equivalent mechanism rather than scattered conditionals.
If users can select components, validators, renderers, or adapters, selection
should go through a registry or equivalent mechanism rather than scattered
conditionals.
## Embedded Assets
@@ -87,11 +96,15 @@ Use structured logging where practical. Logs should describe operations, paths,
## Context, Timeouts, and Cancellation
Long-running operations should accept `context.Context`. External calls, subprocesses, HTTP requests, storage operations, and multi-stage workflows should respect cancellation and timeouts.
Long-running operations should accept `context.Context`. External calls,
subprocesses, HTTP requests, storage operations, and multi-step workflows should
respect cancellation and timeouts.
## State, Files, and Safety
If the application writes durable state, writes should be atomic where practical. Multi-step workflows should preserve enough state to support inspection, retry, or resume after failure.
If the application writes durable state, writes should be atomic where
practical. Multi-step workflows should preserve enough state to support
inspection and retry diagnosis after failure.
Code that deletes, moves, or overwrites files must use narrow, explicit paths. Avoid broad parent-directory operations. Cleanup that can cause data loss must be opt-in.
@@ -99,11 +112,14 @@ Code that deletes, moves, or overwrites files must use narrow, explicit paths. A
Core logic should be testable without real external services. Use fakes, fixtures, or local test doubles for adapters where practical.
Config examples should be load-tested. Important CLI workflows should have parser or command tests. Stage/module contracts should have focused tests that do not require running the full application unless end-to-end coverage is intentional.
Config examples should be load-tested. Important CLI workflows should have
parser or command tests. Component contracts should have focused tests that do
not require running the full application unless end-to-end coverage is
intentional.
## Documentation
Documentation should follow the project documentation policy. Keep user docs focused on implemented behavior. Put future, planned, or aspirational work only under `docs/roadmap/`.
When changing architecture, config, CLI behavior, adapters, or stage/module contracts, update the relevant docs and examples in the same change.
When changing architecture, config, CLI behavior, adapters, or component
contracts, update the relevant docs and examples in the same change.

View File

@@ -1,691 +1,204 @@
# Weatherreporter Package Layout
# Development Policy
This document defines the proposed package layout for `weatherreporter`, a Go application that prepares human-facing weather reports from normalized weather data collected by `weatherfeeder` and rendered through `scriptorium`.
This document is the contributor workflow policy for `weatherreporter`.
Developers and LLM coding agents should use it with
`docs/policy/architecture.md` and `docs/policy/documentation.md`.
The application should remain a small, explicit, dependency-light Go program. Domain logic should live outside CLI, transport, and external-adapter packages. External systems should be isolated behind narrow adapters. Report-specific behavior should be selected through a registry or equivalent mechanism rather than scattered conditionals.
## Repository Layout
## Architectural Summary
- `cmd/weatherreporter`: binary entry point.
- `internal/app`: orchestration for generation, batches, fetch helpers, and
inspection.
- `internal/cli`: command parsing, flag handling, help text, and JSON output.
- `internal/config`: configuration structs, defaults, loading, overrides, and
validation.
- `internal/fileutil`: shared atomic filesystem write and copy helpers.
- `internal/adapters/distributor`: Distributor upload adapter.
- `internal/adapters/weatherapi`: Weather API HTTP adapter.
- `internal/adapters/scriptorium`: Scriptorium subprocess adapter.
- `internal/weatherdata`: normalized weather source facts, source metadata, and
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,
and comparison declarations.
- `internal/briefing`: prompt-facing module value builders and module registry.
- `internal/changes`: structured Recent Changes comparison.
- `internal/promptinput`: Scriptorium `data_package` construction and
validation.
- `internal/state`: filesystem paths, atomic JSON writes, metadata, lookup, and
inspection support.
- `internal/timeutil`: clock, date, timezone, and period helpers.
- `docs`: user, operator, developer, integration, internal, policy, and roadmap
documentation.
- `examples`: maintained copyable examples.
`weatherreporter` is a deterministic weather briefing and report-preparation application. It should:
## Local Validation
1. Fetch normalized weather data from a single configured internal weather API endpoint backed by `weatherfeeder`.
2. Derive report-specific briefing packages from the normalized forecast bundle.
3. Compare current briefing snapshots against prior comparable snapshots to produce optional Recent Changes.
4. Build structured prompt input data packages for a specific report type.
5. Invoke `scriptorium` as an external prompt runner.
6. Persist the rendered Markdown report, briefing snapshot, prompt input package, and generation metadata.
Use focused checks while editing and broader checks before committing:
The preferred data flow is:
```text
weatherfeeder-backed internal API
-> weather API adapter
-> forecast bundle
-> report-specific briefing builder
-> recent-change comparison
-> prompt input data package
-> scriptorium subprocess adapter
-> Markdown report + metadata + stored snapshot
```bash
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
The application should not treat the LLM as the source of weather facts. The Go code should select the relevant data, compute daypart and period summaries, attach alerts and NWS context, identify meaningful changes, and send the LLM a curated briefing package. The LLM should synthesize and phrase the report for humans.
Useful focused checks:
## Proposed Directory Layout
```text
cmd/weatherreporter/
main.go
internal/app/
generate.go
scheduled.go
storm.go
internal/cli/
root.go
generate.go
run.go
inspect.go
internal/config/
config.go
defaults.go
load.go
validate.go
internal/adapters/weatherapi/
client.go
types.go
internal/adapters/scriptorium/
runner.go
types.go
internal/forecast/
bundle.go
dayparts.go
derive.go
select.go
thresholds.go
internal/report/
definition.go
registry.go
period.go
daily.go
tomorrow.go
three_day.go
weekend.go
storm.go
internal/briefing/
package.go
daily.go
tomorrow.go
three_day.go
weekend.go
storm.go
internal/changes/
compare.go
thresholds.go
summary.go
internal/state/
store.go
filesystem.go
metadata.go
internal/promptinput/
build.go
schema.go
internal/timeutil/
clock.go
periods.go
```bash
go test ./internal/cli ./internal/config
go test ./internal/app ./internal/state
go test ./internal/adapters/distributor ./internal/adapters/weatherapi ./internal/adapters/scriptorium
go test ./internal/forecast ./internal/report ./internal/briefing ./internal/changes ./internal/promptinput
```
This layout can be simplified during early prototyping if a package has only one file, but the package boundaries should remain conceptually stable.
Run `gofmt -w` on changed Go files before committing.
## Dependency Direction
## Coding Conventions
The intended dependency direction is:
- Keep domain logic out of `cmd`, `internal/cli`, and adapter packages.
- Prefer small explicit structs and functions over broad framework-style
abstractions.
- Keep package APIs narrow and named around implemented behavior.
- Return errors with operation, path, endpoint, report, or RunID context.
- Do not log or expose secrets.
- Use `context.Context` for external calls, subprocesses, and orchestrated
workflows that may be canceled.
- Use atomic writes for durable JSON artifacts where practical.
- Keep report selection and prompt IDs centralized in `internal/report`.
- 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
`internal/adapters/weatherapi`.
```text
cmd/weatherreporter
-> internal/cli
-> internal/app
-> internal/config
-> internal/report
-> internal/briefing
-> internal/forecast
-> internal/changes
-> internal/state
-> internal/adapters/*
```
## Dependency Policy
Rules:
Prefer the Go standard library. Add dependencies only when they materially
improve correctness, interoperability, security, or maintainability.
- `cmd/weatherreporter` should only bootstrap the CLI.
- `internal/cli` should parse commands and flags, then call `internal/app`.
- `internal/app` should orchestrate workflows but avoid embedding detailed forecast logic.
- `internal/adapters/*` should not contain domain policy.
- `internal/forecast`, `internal/report`, `internal/briefing`, and `internal/changes` should be testable without real external services.
- `internal/state` should expose a storage interface so filesystem state can later be replaced or supplemented.
- `scriptorium` details should not leak outside `internal/adapters/scriptorium`.
Current external dependencies:
## Package Responsibilities
- `gitea.maximumdirect.net/eric/distributor` for distributor source bundle
construction and HTTP upload client behavior.
- `gopkg.in/yaml.v3` for YAML configuration parsing.
### `cmd/weatherreporter`
When adding a dependency:
Entry point for the compiled binary.
- explain why the standard library is not enough;
- keep dependency types from leaking across unrelated package boundaries;
- add tests for the behavior the dependency supports;
- update this policy if the dependency becomes part of contributor workflow.
Responsibilities:
## Configuration Changes
- Construct the root command from `internal/cli`.
- Execute the command.
- Handle final process exit behavior.
Configuration is owned by `internal/config`.
Non-responsibilities:
When adding or changing a field:
- No configuration loading details.
- No forecast logic.
- No direct calls to weather APIs, state stores, or `scriptorium`.
- update `Config` and the nested config struct in `config.go`;
- add or adjust defaults in `defaults.go` when the field has a safe default;
- update loading or CLI override behavior in `load.go` only when needed;
- validate required values and accepted ranges in `validate.go`;
- add or update config tests;
- update `docs/config.md` and maintained examples when the field is user
visible;
- keep secrets out of example config files.
### `internal/cli`
Configuration precedence is:
Defines the user-facing command tree, flags, arguments, and command wiring.
1. CLI overrides supported by `config.LoadOptions`;
2. configuration file values;
3. built-in defaults.
Responsibilities:
The default config path is `/usr/local/etc/weatherreporter/config.yml`.
- Define commands such as:
- `weatherreporter generate daily`
- `weatherreporter generate tomorrow`
- `weatherreporter generate three-day`
- `weatherreporter generate weekend`
- `weatherreporter generate storm`
- `weatherreporter run morning`
- `weatherreporter run evening`
- `weatherreporter inspect snapshot`
- Use the Go standard library for CLI parsing unless future complexity justifies a dependency.
- Parse flags such as `--config`, `--units`, `--tz`, `--out`, optional Daily `--date`, and storm `--start`/`--end`, then convert them into app-layer request structs.
- Load configuration through `internal/config`.
- Present concise user-facing errors.
## CLI Changes
Non-responsibilities:
The CLI is owned by `internal/cli`.
- No report-building logic.
- No direct subprocess execution.
- No direct weather API calls.
- No state comparison logic.
When adding or changing a command or flag:
Suggested command shape:
- update help text and parser behavior together;
- convert parsed values into app-layer request structs;
- keep domain decisions in `internal/app` or domain packages;
- add parser or command tests in `internal/cli`;
- update `docs/cli.md`;
- update `docs/operations.md` or `docs/troubleshooting.md` when behavior affects
operators.
```text
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
weatherreporter generate tomorrow --out ./tomorrow.md
weatherreporter generate three-day --out ./three_day.md
weatherreporter generate weekend --out ./weekend.md
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
weatherreporter run morning
weatherreporter run evening
```
CLI commands should return concise actionable errors and avoid printing partial
JSON when command construction fails.
The MVP should not expose location selection. Source `locationId` and `locationName` values returned by the weather API may be retained as provenance.
## Components And Adapters
For `generate daily`, `--date` is optional. When provided, it must use `YYYY-MM-DD`; when omitted, it resolves to the current local date in the configured timezone.
Use existing package boundaries before adding a package.
### `internal/config`
Add a new internal component only when it owns a distinct implemented contract.
Define its inputs, outputs, state behavior, failure behavior, tests, and
invariants in `docs/internal/`.
Owns configuration structures, defaults, loading, precedence, and validation.
Adapters should stay thin:
Responsibilities:
- HTTP adapters own transport, request construction, envelope handling, and
decode boundaries.
- subprocess adapters own argv construction, timeout handling, stdout/stderr
capture, and exit-code interpretation.
- adapter packages should not own report selection, forecast summarization,
Recent Changes, or prompt input schema decisions.
- Define application configuration structs.
- Provide built-in defaults in `defaults.go`.
- Load YAML configuration from `/usr/local/etc/weatherreporter/config.yml` or a CLI-supplied path.
- Use `gopkg.in/yaml.v3` for YAML parsing.
- Apply precedence rules.
- Validate required settings.
- Normalize paths, durations, report settings, weather API units/timezone, missing-source policy, and daypart definitions.
When an external contract changes, update the matching file under
`docs/integrations/`.
Suggested configuration areas:
## Tests
- Weather API base URL, timeout, units, timezone, precision, and missing-source policy.
- `scriptorium` binary, config path, profile, timeout, and optional extra arguments.
- Workspace and output directories.
- Report enablement and output naming.
- Daypart definitions.
- Recent-change thresholds.
Core tests must not require live Weather API, Scriptorium, or distributor
services.
Initial defaults:
Preferred test patterns:
- Weather API units: `us`.
- Weather API timezone: `Chicago`.
- Weather API format: `json`.
- Missing-source policy: `warn`.
- fake command runners for subprocess behavior;
- `httptest.Server` for Weather API behavior;
- fake distributor upload clients for notification behavior;
- filesystem temp directories for state behavior;
- deterministic clocks for report periods and RunIDs;
- table tests for config validation, CLI parsing, period resolution, and
threshold behavior.
Missing-source policy should support a global default and per-source overrides. Valid policy values are `error`, `warn`, and `none`.
Add focused tests near the package that owns the behavior. Use app-level tests
for workflow ordering, persistence, and cross-package contracts.
Non-responsibilities:
## Examples
- No command execution.
- No HTTP calls.
- No report-building logic.
Examples under `examples/` must be real, maintained, and free of secrets.
### `internal/app`
When updating examples:
Application orchestration and top-level use cases.
- use implemented config fields only;
- avoid private endpoints and credentials;
- keep comments short and operationally useful;
- add or update validation coverage when a new example file is introduced;
- link maintained examples from `docs/config.md`.
Responsibilities:
Do not add generated report examples unless they can be kept current without
live external services.
- Implement use cases such as:
- Generate one report.
- Run the morning batch.
- Run the evening batch.
- Generate a manual storm report.
- Coordinate config, weather API adapter, report registry, briefing builders, state store, change comparison, prompt input builder, and `scriptorium` runner.
- Enforce workflow order.
- Ensure each generation run persists enough artifacts for inspection and future comparison.
## Documentation Checklist
The core generation workflow should be approximately:
Documentation updates are part of behavior changes.
```text
resolve report definition
resolve valid period
fetch current weather bundle
build current briefing package
load prior comparable briefing snapshot
compute recent changes
build prompt input data package
write data package
run scriptorium render preflight
invoke scriptorium run
persist report metadata, briefing snapshot, data package, preflight output, and rendered report
```
Update:
Non-responsibilities:
- `README.md` for project orientation or quickstart changes;
- `docs/cli.md` for command and flag changes;
- `docs/config.md` for config fields, defaults, and precedence changes;
- `docs/operations.md` for state, artifact, batch, inspection, and recovery
behavior;
- `docs/troubleshooting.md` for recurring operator-facing failure modes;
- `docs/internal/` for component contracts and invariants;
- `docs/integrations/` for external Weather API, Scriptorium, or distributor
contract changes;
- `docs/roadmap/` only for unimplemented or deferred work.
- No detailed daypart calculations.
- No direct parsing of NWS text unless delegated to domain packages.
- No direct shell command construction outside the `scriptorium` adapter.
### `internal/adapters/weatherapi`
HTTP adapter for the internal weather API backed by `weatherfeeder`.
Responsibilities:
- Fetch normalized weather data from the configured API base URL.
- Fan out to multiple weather API endpoints and assemble one internal `forecast.Bundle`.
- Decode API responses into adapter-owned DTOs or directly into stable internal types if those types are intentionally owned by `weatherreporter`.
- Apply request timeouts and context cancellation.
- Apply configured query defaults, including `format=json`, `units=us`, and `tz=Chicago` unless overridden.
- Fetch full `/forecast/hourly` and `/forecast/narrative` products, not day-slice endpoints, so Go domain code owns report-period selection.
- Record per-source provenance: endpoint, query, fetch time, issued/updated time when available, SHA-256 over canonical/minified raw `data` JSON, warnings, and missing-source status.
- Represent source warnings as first-class records with source name, code, severity, message, endpoint, and completeness impact.
- Require hourly forecast data for normal scheduled reports.
- Apply missing-source policy for `data:null`, malformed non-required sections, or unavailable upstream products.
- Return actionable errors containing endpoint and operation context.
Initial data categories:
- Latest observation.
- Current conditions.
- Hourly forecast data.
- NWS narrative forecast periods.
- NWS alerts.
- NWS forecast discussion.
Stubbed source slots until upstream support exists:
- Daily forecast data.
- NWS weather story.
Non-responsibilities:
- No daypart grouping.
- No Recent Changes comparison.
- No prompt input construction.
- No `scriptorium` calls.
### `internal/adapters/scriptorium`
Subprocess adapter for invoking `scriptorium`.
Responsibilities:
- Provide a narrow runner interface, such as:
```go
type Runner interface {
Render(ctx context.Context, req RenderRequest) (*RenderResult, error)
Run(ctx context.Context, req RunRequest) (*RunResult, error)
}
```
- Execute `scriptorium render` for preflight/debug output without LLM generation.
- Execute `scriptorium run` for report generation.
- Run `scriptorium render` as an always-on preflight before `scriptorium run` for MVP generated reports.
- Pass arguments as an argv slice, not through a shell.
- Pass large prompt input as `--input data_package=<path>`.
- Capture stdout/stderr with reasonable size limits.
- Treat nonzero exits as actionable errors, including exit code `2` from `run`, which may still produce output.
- Keep all `scriptorium`-specific flag details inside the adapter.
Suggested command forms:
```text
scriptorium render \
--prompt weather.daily_report \
--input data_package=./workspace/data-packages/daily/2026-05-29T050000-0500.data_package.json \
--format json
scriptorium run \
--prompt weather.daily_report \
--input data_package=./workspace/data-packages/daily/2026-05-29T050000-0500.data_package.json \
--out ./workspace/reports/daily/2026-05-29T050000-0500.md
```
Non-responsibilities:
- No weather logic.
- No report registry logic.
- No decision about which prompt to run.
Future note:
- A native LLM client can later replace or supplement this adapter behind a similar interface.
### `internal/forecast`
Core forecast-domain processing.
Responsibilities:
- Define the normalized `Bundle` consumed by report builders.
- Group hourly forecast data into configured dayparts.
- Compute derived facts, including:
- Temperature ranges.
- Apparent-temperature ranges, if available.
- Max precipitation probability.
- Peak wind and wind gusts.
- Precipitation windows.
- Thunder mentions.
- Snow/ice/freezing risk indicators.
- Alert overlap with relevant periods.
- Select forecast elements relevant to a report period.
- Provide threshold helpers for impact detection.
Non-responsibilities:
- No CLI behavior.
- No external API calls.
- No rendered prose.
- No direct `scriptorium` calls.
### `internal/report`
Report definitions, registry, period resolution, and report-level contracts.
Responsibilities:
- Define report IDs and report definition contracts.
- Register report types and variants.
- Resolve valid periods for each report.
- Associate report types with prompt IDs.
- Define comparison strategies and output naming behavior.
Suggested report definitions:
```text
daily_today -> prompt weather.daily_report
daily_tomorrow -> prompt weather.daily_report
three_day -> prompt weather.three_day_outlook
weekend -> prompt weather.weekend_outlook
storm -> prompt weather.storm_report
```
`weather.daily_report` should be the standard prompt for one local civil day, regardless of whether that day is today or tomorrow.
A report definition should describe:
- Report ID.
- Human-readable name.
- Prompt ID.
- Valid-period resolver.
- Briefing builder ID or function.
- Recent-change comparison strategy.
- Default output naming pattern.
- Whether the report participates in morning or evening scheduled batches.
Non-responsibilities:
- No detailed forecast computation.
- No state storage.
- No subprocess execution.
### `internal/briefing`
Builds report-specific briefing packages from forecast bundles and report definitions.
Responsibilities:
- Convert a forecast bundle into a report-specific structured briefing package.
- Keep each report's briefing shape explicit and testable.
- Attach relevant NWS narrative periods, alerts, forecast discussion context, and weather story context when available.
- Include metadata such as schema version, configured units/timezone, source warnings, and source provenance.
- Provide inputs suitable for `scriptorium` data packages.
Report-specific builders should exist for:
- Daily Report.
- Tomorrow Planning Brief.
- 3-Day Outlook.
- Weekend Outlook.
- Storm Report.
Non-responsibilities:
- No external API fetching.
- No final prose rendering.
- No state persistence, except through app orchestration.
Design note:
- This package is the architectural center of the application. A clean briefing package makes `scriptorium` a renderer rather than a source of weather reasoning.
### `internal/changes`
Structured comparison of current and prior briefing snapshots.
Responsibilities:
- Compare current briefing packages against prior comparable snapshots.
- Apply meaningful-change thresholds.
- Produce compact structured change summaries for prompt input data packages.
- Avoid comparison of rendered Markdown report text.
Comparable snapshot matching should be declared by each report definition. Daily Today, Daily Tomorrow, and compatible date slices from multi-day reports may compare by same valid local date when the report registry marks them compatible. Weekend compares by same weekend window. Storm compares by explicit event window.
Meaningful changes may include:
- Temperature changes crossing configured thresholds.
- Precipitation probability changes by category.
- Precipitation timing shifts.
- New, canceled, extended, upgraded, or expanded alerts.
- Wind gust threshold crossings.
- Snow/ice/freezing risk changes.
- Severe-weather wording or risk changes.
- Confidence or uncertainty changes, if represented in structured briefing data.
Non-responsibilities:
- No fetching prior state directly unless mediated through app/state contracts.
- No final report prose.
- No external calls.
### `internal/state`
Durable state store for reports, snapshots, data packages, preflight output, metadata, and comparison lookup.
Responsibilities:
- Persist generated report metadata.
- Persist briefing snapshots.
- Persist prompt input data packages.
- Persist `scriptorium render` preflight output for generated reports.
- Locate prior comparable snapshots for Recent Changes.
- Track RunID as generation timestamp plus report ID.
- Use timestamped managed report names to avoid overwriting prior runs for the same valid period.
- Use atomic writes where practical.
- Keep filesystem layout narrow and predictable.
Initial backend:
- Filesystem state.
Potential future backend:
- SQLite or another state database, behind the same store interface.
Suggested state layout:
```text
workspace/
snapshots/
daily/
2026-05-30/
2026-05-29T050000-0500.briefing.json
2026-05-29T050000-0500.metadata.json
three-day/
weekend/
storm/
reports/
daily/
2026-05-29T050000-0500.md
three-day/
weekend/
storm/
data-packages/
daily/
2026-05-29T050000-0500.data_package.json
preflight/
daily/
2026-05-29T050000-0500.render.json
```
Non-responsibilities:
- No weather derivation.
- No report prose generation.
- No CLI formatting decisions.
### `internal/promptinput`
Builds the final data package passed to `scriptorium`.
Responsibilities:
- Combine report metadata, briefing package, Recent Changes, selected source context, and source warnings into a prompt input document.
- Validate required data package fields before invoking `scriptorium`.
- Keep data package schemas explicit enough to test.
- Write data package files to the workspace when requested by the app layer.
Non-responsibilities:
- No weather API calls.
- No forecast derivation.
- No subprocess execution.
### `internal/timeutil`
Time, clock, and period helpers.
Responsibilities:
- Provide an injectable clock for deterministic tests.
- Resolve local dates using the configured report timezone.
- Handle daypart spans, including overnight windows.
- Normalize valid periods.
- Provide helpers for recurring scheduled batches.
Non-responsibilities:
- No report-specific forecast logic unless delegated by `internal/report`.
- No external calls.
## Report Types and Valid-Period Identity
Each generated report must be associated with explicit metadata:
- RunID.
- Report type.
- Report variant, if applicable.
- Generation time.
- Configured report timezone.
- Valid period start.
- Valid period end.
- Source location ID/name when provided by upstream.
- Source product timestamps and/or SHA-256 hashes.
- Source warnings.
- Briefing snapshot path.
- Prompt input data package path.
- Preflight output path.
- Rendered report path.
All valid periods should use the configured local timezone, default `Chicago`, and half-open `[start,end)` intervals.
Initial valid-period rules:
- Daily Today: current local civil day, `[00:00, next 00:00)`.
- Daily Tomorrow: next local civil day.
- 3-Day Outlook: generation time through local midnight after the second following local civil day.
- Weekend Outlook: Monday through Thursday covers Saturday 00:00 to Monday 00:00; Friday and Saturday cover `max(generation time, Friday 18:00)` to Monday 00:00; scheduled Sunday morning skips Weekend Outlook.
- Manual Storm Report: requires explicit `--start` and `--end`; accept `YYYY-MM-DDTHH:MM` interpreted in the configured timezone and RFC3339 timestamps with explicit offsets.
The valid period should identify what weather period the report covers, independent of when the report was generated.
Examples:
- A 5 PM Tomorrow Planning Brief for Saturday and a 5 AM Saturday Daily Report both cover the same valid date.
- A Saturday Weekend Outlook covers the remaining weekend, while a Friday Weekend Outlook may cover Friday evening through Sunday night.
- A Storm Report covers an explicit forecast event window, not a fixed calendar day.
This identity is required for reliable Recent Changes behavior.
## Scheduled Batch Semantics
The app should support scheduled batches but should not need to be a daemon in the initial version.
Suggested batches:
```text
morning:
- daily_today
- three_day
- weekend, except Sunday
evening:
- daily_tomorrow
```
Scheduled batches should continue independent reports after a report failure. The CLI should return nonzero if any report failed and should emit an aggregate run summary.
External scheduling should be handled by systemd timers, cron, or another orchestrator. `weatherreporter` should simply provide deterministic commands that can be scheduled.
## Storm Report Direction
The initial version should support manual Storm Report generation:
```text
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
```
Future storm monitoring should use a staged design:
```text
incoming weather data
-> deterministic candidate detector
-> LLM event evaluator
-> storm lifecycle state
-> storm report generation or skip decision
```
Potential storm lifecycle states:
```text
none -> monitoring -> active_report -> escalated -> deescalating -> resolved
```
This future behavior should not be built before the core scheduled reports are stable, but the package layout should leave room for it.
## Testing Expectations
Core tests should not require real external services.
Priority test areas:
- Configuration loading and validation, including defaults for `units=us`, `tz=Chicago`, and missing-source policy `warn`.
- Standard-library CLI command parsing, including `--units`, `--tz`, and storm `--start`/`--end`.
- Weather API fan-out, source provenance, `data:null`, and missing-source policy behavior.
- Daypart grouping, especially overnight periods.
- Valid-period resolution for each report type.
- Briefing package construction from fixtures.
- Recent Changes threshold behavior and compatible snapshot matching.
- Prior snapshot lookup.
- `scriptorium` adapter behavior using a fake executable or command runner, including both `render` and `run` with `--input data_package=<path>`.
- Batch partial-failure behavior and aggregate exit status.
## Design Invariants
Preserve these invariants as the project evolves:
- Weather facts come from normalized source data, not from the LLM.
- The LLM receives curated briefing packages, not unbounded raw weather payloads.
- Recent Changes are based on structured snapshot comparison, not Markdown diffing.
- Report types are registered or otherwise centrally defined.
- External integrations are thin adapters.
- CLI code wires workflows but does not own domain logic.
- The first durable state backend is filesystem-based and inspectable.
- `scriptorium` is an adapter boundary, not an application dependency that leaks across packages.
Non-roadmap docs must describe implemented behavior only.

148
docs/roadmap/future.md Normal file
View File

@@ -0,0 +1,148 @@
# Future Roadmap
This roadmap contains project work that is not implemented. Current behavior is
documented outside `docs/roadmap/`.
## Automatic Storm Monitoring
Manual Storm Report generation is implemented through
`weatherreporter generate storm --start TIME --end TIME`. Automatic storm-event
evaluation remains deferred.
Proposed direction:
1. detect candidate events deterministically from alerts, forecast discussion,
weather story context, hourly thresholds, and material forecast changes;
2. evaluate candidates through Scriptorium or another narrow evaluator adapter;
3. persist storm lifecycle state;
4. generate or update Storm Reports only when a meaningful event is present;
5. suppress ordinary low-impact thunder or rain chances.
Possible lifecycle states:
- `none`
- `monitoring`
- `active_report`
- `escalated`
- `deescalating`
- `resolved`
Acceptance criteria before implementation:
- scheduled reports and manual Storm Reports remain stable;
- candidate detection has fixture coverage;
- evaluator failures are inspectable and do not create noisy report output;
- manual Storm Report generation remains available.
## Future Report Types And Modules
The module-based prompt package architecture is implemented. Future work should
add only modules backed by implemented upstream facts and clear report needs.
Possible future report types:
- `next_6_hours` or another short-fuse planning report;
- 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;
- polling distributor status after upload acceptance;
- durable upload retry queues;
- distributor-specific CLI flags;
- making distributor scan the weatherreporter workspace;
- handling destination routing, Markdown-to-HTML transformation, public URLs,
or nginx layout inside weatherreporter.
Any distributor enhancement should preserve the existing adapter boundary:
weatherreporter selects explicit generated files and submits source bundles,
while distributor owns destination routing and publication behavior.
## Alternate Runtime Integrations
These ideas are not current behavior:
- native LLM client inside `weatherreporter`;
- database-backed state;
- public HTTP API;
- multi-location selection;
- daemon mode;
- multi-user authorization;
- 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
must not describe these as available behavior.
## Cleanup Refactors
The initial cleanup pass intentionally left these refactors out because the
current implementation does not yet make them worth the added abstraction.
Revisit these only when new source types, report types, operational
requirements, or recurring maintenance costs make the duplication materially
more expensive:
- Weather API optional-source specification/helper refactor: consider when
additional Weather API sources make per-source fan-out, policy handling, and
provenance wiring repetitive enough to obscure adapter behavior.
- Broad briefing weather-signal consolidation: consider when multiple module
builders repeatedly derive the same weather signals and tests begin to need
coordinated fixture updates.
- Generic workflow engine: defer unless generation, inspection, recovery, or
future background workflows gain enough shared step semantics to justify a
declared execution model.
- Cobra migration: defer while the standard-library CLI remains small,
explicit, and covered by parser tests.
- Manifest, resume, or progress system: defer until operators need resumable
runs, checkpoint recovery, or richer audit trails than the current durable
artifacts and metadata provide.
- Global test helper package: defer while package-local helpers keep tests
clear; revisit only if setup duplication starts to hide behavior.
- Logging subsystem: defer until there are recurring operator diagnostics that
cannot be handled with current errors, metadata, inspection commands, and
artifact output.
Any future implementation should preserve the existing public CLI, artifact
paths, report identities, module boundaries, and adapter boundaries unless a
separate roadmap explicitly changes them.

File diff suppressed because it is too large Load Diff

340
docs/troubleshooting.md Normal file
View File

@@ -0,0 +1,340 @@
# Weatherreporter Troubleshooting
This guide lists recurring failures with likely causes, diagnostics, and safe
fixes. See [CLI reference](cli.md), [Configuration reference](config.md), and
[Operations guide](operations.md) for normal usage.
## `weather_api.base_url is required`
Symptom: a generation command fails before fetching weather data.
Likely cause: no Weather API base URL is configured.
Diagnostic:
```sh
weatherreporter generate daily --config ./config.yml --date 2026-05-29
```
Safe fix: add `weather_api.base_url` to the config file, or pass the intended
config path with `--config`.
Relevant docs: [Configuration reference](config.md).
## `weather_api.base_url must be an absolute URL`
Symptom: config loading fails with a base URL validation error.
Likely cause: `weather_api.base_url` is missing a scheme or host.
Diagnostic: inspect the configured value in the file passed to `--config`.
Safe fix: use an absolute URL such as `https://weather.api.example.com/`.
Relevant docs: [Configuration reference](config.md).
## Invalid Timezone
Symptom: config loading fails with `weather_api.timezone` context, or a CLI
timezone override fails.
Likely cause: `weather_api.timezone` or `--tz` is not recognized.
Diagnostic:
```sh
weatherreporter generate daily --tz America/Chicago --date 2026-05-29
```
Safe fix: use an accepted timezone value, such as an IANA timezone name,
`Chicago`, `Stl`, a US timezone abbreviation, or a UTC offset.
Relevant docs: [Configuration reference](config.md).
## Storm Command Rejects Time Bounds
Symptom: `generate storm` fails with `requires --start`, `requires --end`, or
`requires --end after --start`.
Likely cause: the manual event window is missing or invalid.
Diagnostic:
```sh
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
```
Safe fix: provide both bounds. Use `YYYY-MM-DDTHH:MM` in the configured
timezone, or RFC3339 timestamps with explicit offsets.
Relevant docs: [CLI reference](cli.md).
## Weather API Fetch Fails
Symptom: generation fails with `fetch /...`, an HTTP status, or request context.
Likely cause: the configured Weather API endpoint is unreachable, returned a
non-2xx response, or returned an invalid response envelope.
Diagnostic:
```sh
weatherreporter generate daily --config ./config.yml --date 2026-05-29
```
Safe fix: verify `weather_api.base_url`, network access, and the Weather API
service response. The adapter fetches `/observations`, `/conditions/current`,
`/forecast/hourly`, `/forecast/narrative`, `/alerts/active`, and `/discussion`.
Relevant docs: [Configuration reference](config.md).
## Hourly Forecast Is Missing
Symptom: generation fails with hourly forecast context, such as missing hourly
data or an hourly forecast containing no periods.
Likely cause: hourly forecast data is required for generated reports.
Diagnostic: check the Weather API response for `/forecast/hourly`.
Safe fix: restore hourly forecast data at the Weather API. Missing-source
policy cannot make hourly optional.
Relevant docs: [Configuration reference](config.md), [Operations guide](operations.md).
## Source Warnings Appear
Symptom: generation succeeds, but metadata or `inspect sources` shows source
warnings.
Likely cause: an optional source was missing or malformed under a warning
missing-source policy.
Diagnostic:
```sh
weatherreporter inspect sources RUN_ID
weatherreporter inspect metadata RUN_ID
```
Safe fix: inspect the warning `source`, `code`, `message`, and `endpoint`. Fix
the upstream optional source, or intentionally change the relevant
`missing_source` policy.
Relevant docs: [Configuration reference](config.md), [Operations guide](operations.md).
## `scriptorium` Is Not Found Or Cannot Start
Symptom: generation fails with `run scriptorium render` or `run scriptorium`
and an executable or OS error.
Likely cause: the configured Scriptorium binary is unavailable or not
executable.
Diagnostic: check `scriptorium.binary` in config and run the same binary outside
`weatherreporter`.
Safe fix: install Scriptorium, update `scriptorium.binary`, or fix executable
permissions.
Relevant docs: [Configuration reference](config.md),
[Scriptorium integration](integrations/scriptorium.md).
## Render Preflight Fails
Symptom: generation fails with `scriptorium render exited with code ...`.
Likely cause: Scriptorium rejected the prompt, config, profile, or
`data_package` input before report generation.
Diagnostic:
```sh
weatherreporter inspect metadata RUN_ID
weatherreporter inspect data-package RUN_ID
```
Then read the preflight path from metadata. It contains captured stdout, stderr,
exit code, and command.
Safe fix: fix the Scriptorium configuration, prompt ID, profile, or data package
input indicated by stderr.
Relevant docs: [Operations guide](operations.md),
[Scriptorium integration](integrations/scriptorium.md).
## Scriptorium Run Fails
Symptom: generation fails with `scriptorium run exited with code ...`.
Likely cause: Scriptorium failed during report generation or validation.
Diagnostic:
```sh
weatherreporter inspect metadata RUN_ID
weatherreporter inspect data-package RUN_ID
```
If metadata includes a rendered report path, inspect that report as well. A
nonzero run can still leave a managed report artifact.
Safe fix: use the captured stderr and data package to fix the Scriptorium
prompt, profile, model configuration, or validation issue.
Relevant docs: [Operations guide](operations.md),
[Scriptorium integration](integrations/scriptorium.md).
## Batch Command Returns Nonzero
Symptom: `run morning` or `run evening` returns nonzero.
Likely cause: at least one report in the batch failed.
Diagnostic: inspect stdout for the JSON summary and stderr for compact status
lines.
Safe fix: use the failed report's artifact paths from the summary, then inspect
metadata, sources, module snapshot, and data package for that RunID.
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
Symptom: an inspect command fails with `metadata for run id ... was not found`.
Likely cause: the RunID is mistyped or the command is reading a different
workspace.
Diagnostic:
```sh
weatherreporter inspect reports --config ./config.yml --limit 20
```
Safe fix: copy a RunID from `inspect reports`, or use the same `--config` and
workspace that generated the report.
Relevant docs: [Operations guide](operations.md).
## Workspace Path Error
Symptom: startup or inspection fails with workspace path validation or
filesystem read/write context.
Likely cause: a workspace subdirectory is absolute, escapes `workspace.root`, or
the process cannot read or write the configured path.
Diagnostic: review `workspace.root`, `workspace.snapshots_dir`,
`workspace.reports_dir`, `workspace.data_packages_dir`, and
`workspace.preflight_dir`.
Safe fix: keep workspace subdirectories relative to `workspace.root`, and grant
the process appropriate filesystem permissions.
Relevant docs: [Configuration reference](config.md), [Operations guide](operations.md).

View File

@@ -1,11 +1,32 @@
weather_api:
base_url: https://weather.api.example.com/
base_url: https://weather.api.rakestrawhome.com/
timeout: 15s
precision: 1
units: us
timezone: Chicago
timezone: "America/Chicago"
format: json
location:
id: home
name: Brentwood
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.{artifact_group}"
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:
default: warn
sources:
@@ -21,9 +42,7 @@ workspace:
reports_dir: reports
data_packages_dir: data-packages
preflight_dir: preflight
reports:
output_dir: reports
notifications_dir: notifications
dayparts:
- name: overnight
@@ -31,12 +50,15 @@ dayparts:
end: "06:00"
- name: morning
start: "06:00"
end: "12:00"
end: "10:00"
- name: midday
start: "10:00"
end: "15:00"
- name: afternoon
start: "12:00"
end: "18:00"
start: "15:00"
end: "17:00"
- name: evening
start: "18:00"
start: "17:00"
end: "24:00"
recent_change:
@@ -44,3 +66,24 @@ recent_change:
precip_probability_points: 20
wind_gust_miles_per_hour: 10
precip_timing_shift_minutes: 120
reports:
daily:
deterministic_modules:
- metadata
- current_conditions
- narrative_forecast
- derived_daily_summary
- derived_daypart_summaries
- precip_timing
- alert_digest
- id: area_forecast_discussion
options:
sections:
- product
- key_messages
- short_term
- long_term
- weather_story
- outdoor_windows
- hourly_forecast

View File

@@ -0,0 +1,2 @@
weather_api:
base_url: https://weather.api.example.com/

2
go.mod
View File

@@ -3,3 +3,5 @@ module gitea.maximumdirect.net/eric/weatherreporter
go 1.26
require gopkg.in/yaml.v3 v3.0.1
require gitea.maximumdirect.net/eric/distributor v0.5.0

48
go.sum
View File

@@ -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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View 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]")
}

View 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
}

View File

@@ -3,12 +3,9 @@ package scriptorium
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"time"
)
@@ -108,29 +105,20 @@ func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, e
if req.DataPackagePath == "" {
return nil, fmt.Errorf("data package path is required")
}
binary := r.Binary
if binary == "" {
binary = "scriptorium"
}
commands := r.Commands
if commands == nil {
commands = ExecRunner{}
}
args := r.renderArgs(req)
commandResult, err := commands.Run(ctx, binary, args, r.Timeout)
execution, err := r.execute(ctx, r.renderArgs(req))
if err != nil {
return nil, fmt.Errorf("run scriptorium render: %w", err)
}
result := &RenderResult{
Command: append([]string{binary}, args...),
Stdout: string(commandResult.Stdout),
Stderr: string(commandResult.Stderr),
StdoutTruncated: commandResult.StdoutTruncated,
StderrTruncated: commandResult.StderrTruncated,
ExitCode: commandResult.ExitCode,
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,
}
if commandResult.ExitCode != 0 {
return result, fmt.Errorf("scriptorium render exited with code %d: %s", commandResult.ExitCode, result.Stderr)
if execution.result.ExitCode != 0 {
return result, fmt.Errorf("scriptorium render exited with code %d: %s", execution.result.ExitCode, result.Stderr)
}
return result, nil
}
@@ -145,6 +133,32 @@ func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
if req.OutputPath == "" {
return nil, fmt.Errorf("output path is required")
}
execution, err := r.execute(ctx, r.runArgs(req))
if err != nil {
return nil, fmt.Errorf("run scriptorium: %w", err)
}
result := &RunResult{
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 run exited with code %d: %s", execution.result.ExitCode, result.Stderr)
}
return result, nil
}
type execution struct {
binary string
args []string
result CommandResult
}
func (r Runner) execute(ctx context.Context, args []string) (execution, error) {
binary := r.Binary
if binary == "" {
binary = "scriptorium"
@@ -153,24 +167,15 @@ func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
if commands == nil {
commands = ExecRunner{}
}
args := r.runArgs(req)
commandResult, err := commands.Run(ctx, binary, args, r.Timeout)
result, err := commands.Run(ctx, binary, args, r.Timeout)
if err != nil {
return nil, fmt.Errorf("run scriptorium: %w", err)
return execution{}, err
}
result := &RunResult{
Command: append([]string{binary}, args...),
Stdout: string(commandResult.Stdout),
Stderr: string(commandResult.Stderr),
StdoutTruncated: commandResult.StdoutTruncated,
StderrTruncated: commandResult.StderrTruncated,
ExitCode: commandResult.ExitCode,
OutputPath: req.OutputPath,
}
if commandResult.ExitCode != 0 {
return result, fmt.Errorf("scriptorium run exited with code %d: %s", commandResult.ExitCode, result.Stderr)
}
return result, nil
return execution{binary: binary, args: args, result: result}, nil
}
func (e execution) argv() []string {
return append([]string{e.binary}, e.args...)
}
func (r Runner) renderArgs(req RenderRequest) []string {
@@ -207,37 +212,6 @@ func (r Runner) runArgs(req RunRequest) []string {
return args
}
func SaveRenderResult(path string, result *RenderResult) error {
if result == nil {
return fmt.Errorf("render result is required")
}
data, err := json.MarshalIndent(result, "", " ")
if err != nil {
return fmt.Errorf("marshal render result: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create preflight directory %q: %w", filepath.Dir(path), err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary preflight file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary preflight file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary preflight file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("save preflight %q: %w", path, err)
}
return nil
}
type limitedBuffer struct {
data []byte
limit int

View File

@@ -20,7 +20,7 @@ func TestRenderConstructsCommand(t *testing.T) {
result, err := runner.Render(context.Background(), RenderRequest{
PromptID: "weather.daily_report",
DataPackagePath: "/tmp/data_package.json",
DataPackagePath: "/tmp/data_package.yaml",
})
if err != nil {
t.Fatalf("Render() error = %v", err)
@@ -31,7 +31,7 @@ func TestRenderConstructsCommand(t *testing.T) {
"--config", "/etc/scriptorium.yml",
"--profile", "weather",
"--prompt", "weather.daily_report",
"--input", "data_package=/tmp/data_package.json",
"--input", "data_package=/tmp/data_package.yaml",
"--format", "json",
}
if commands.name != "/usr/local/bin/scriptorium" {
@@ -57,7 +57,7 @@ func TestRenderReturnsResultForNonzeroExit(t *testing.T) {
result, err := runner.Render(context.Background(), RenderRequest{
PromptID: "weather.daily_report",
DataPackagePath: "/tmp/data_package.json",
DataPackagePath: "/tmp/data_package.yaml",
})
if err == nil {
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{
PromptID: "weather.daily_report",
DataPackagePath: "/tmp/data_package.json",
DataPackagePath: "/tmp/data_package.yaml",
OutputPath: "/tmp/daily.md",
})
if err != nil {
@@ -97,7 +97,7 @@ func TestRunConstructsCommand(t *testing.T) {
"--config", "/etc/scriptorium.yml",
"--profile", "weather",
"--prompt", "weather.daily_report",
"--input", "data_package=/tmp/data_package.json",
"--input", "data_package=/tmp/data_package.yaml",
"--out", "/tmp/daily.md",
}
if commands.name != "/usr/local/bin/scriptorium" {
@@ -130,7 +130,7 @@ func TestRunReturnsResultForValidationExit(t *testing.T) {
result, err := runner.Run(context.Background(), RunRequest{
PromptID: "weather.daily_report",
DataPackagePath: "/tmp/data_package.json",
DataPackagePath: "/tmp/data_package.yaml",
OutputPath: "/tmp/daily.md",
})
if err == nil {

View File

@@ -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
import (
@@ -11,15 +11,14 @@ import (
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type Client struct {
@@ -84,11 +83,11 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
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()
builder := bundleBuilder{
client: c,
bundle: &forecast.Bundle{FetchedAt: fetchedAt},
bundle: &weatherdata.Bundle{FetchedAt: fetchedAt},
fetchedAt: fetchedAt,
}
@@ -110,10 +109,7 @@ func (c *Client) FetchBundle(ctx context.Context) (*forecast.Bundle, error) {
if err := builder.fetchDiscussion(ctx); err != nil {
return nil, err
}
if err := builder.addStub("daily", "daily forecast data is not available from the weather API yet"); err != nil {
return nil, err
}
if err := builder.addStub("weather_story", "NWS weather story is not available from the weather API yet"); err != nil {
if err := builder.fetchWeatherStory(ctx); err != nil {
return nil, err
}
@@ -122,7 +118,7 @@ func (c *Client) FetchBundle(ctx context.Context) (*forecast.Bundle, error) {
type bundleBuilder struct {
client *Client
bundle *forecast.Bundle
bundle *weatherdata.Bundle
fetchedAt time.Time
}
@@ -134,7 +130,7 @@ func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
if raw == nil {
return b.handleMissing(&source, "observation data is missing", false)
}
var observation forecast.Observation
var observation weatherdata.Observation
if err := decodeSource(raw, &observation); err != nil {
return b.handleMalformed(&source, err, false)
}
@@ -152,7 +148,7 @@ func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
if raw == nil {
return b.handleMissing(&source, "current conditions data is missing", false)
}
var current forecast.Current
var current weatherdata.Current
if err := decodeSource(raw, &current); err != nil {
return b.handleMalformed(&source, err, false)
}
@@ -169,7 +165,7 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
if raw == nil {
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 {
return fmt.Errorf("decode hourly forecast from %s: %w", source.Endpoint, err)
}
@@ -191,7 +187,7 @@ func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
if raw == nil {
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 {
return b.handleMalformed(&source, err, false)
}
@@ -203,14 +199,19 @@ func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
}
func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "alerts", "/alerts/active", queryOptions{})
raw, source, err := b.client.fetch(ctx, "alerts", "/alerts/active", queryOptions{allowNull: true})
if err != nil {
return err
}
if raw == nil {
return b.handleMissing(&source, "active alerts data is missing", false)
}
var alerts forecast.AlertRun
if isJSONNull(raw) {
b.bundle.Alerts = &weatherdata.AlertRun{Raw: append(json.RawMessage(nil), raw...)}
b.addSource(source)
return nil
}
var alerts weatherdata.AlertRun
if err := decodeSource(raw, &alerts); err != nil {
return b.handleMalformed(&source, err, false)
}
@@ -231,7 +232,7 @@ func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
if raw == nil {
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 {
return b.handleMalformed(&source, err, false)
}
@@ -242,16 +243,28 @@ func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
return nil
}
func (b *bundleBuilder) addStub(sourceName string, message string) error {
source := forecast.Source{
Name: sourceName,
FetchedAt: b.fetchedAt,
Missing: true,
func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "weather_story", "/weatherstories/latest", queryOptions{omitUnits: true})
if err != nil {
return err
}
return b.applyMissingPolicy(&source, "missing_source", message)
if raw == nil {
return b.handleMissing(&source, "NWS weather story data is missing", false)
}
var story weatherdata.WeatherStory
if err := decodeSource(raw, &story); err != nil {
return b.handleMalformed(&source, err, false)
}
if !story.StartTime.IsZero() {
source.IssuedAt = &story.StartTime
}
source.UpdatedAt = story.UpdatedAt
b.bundle.WeatherStory = &story
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
if required {
return fmt.Errorf("%s from %s is required", message, source.Endpoint)
@@ -259,7 +272,7 @@ func (b *bundleBuilder) handleMissing(source *forecast.Source, message string, r
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 {
return fmt.Errorf("decode %s from %s: %w", source.Name, source.Endpoint, err)
}
@@ -267,13 +280,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))
}
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)
if policy == config.MissingSourceError {
return fmt.Errorf("%s: %s", source.Name, message)
}
if policy == config.MissingSourceWarn {
warning := forecast.SourceWarning{
warning := weatherdata.SourceWarning{
Source: source.Name,
Code: code,
Severity: "warning",
@@ -288,7 +301,7 @@ func (b *bundleBuilder) applyMissingPolicy(source *forecast.Source, code string,
return nil
}
func (b *bundleBuilder) addSource(source forecast.Source) {
func (b *bundleBuilder) addSource(source weatherdata.Source) {
b.bundle.Sources = append(b.bundle.Sources, source)
}
@@ -302,45 +315,47 @@ func (c *Client) policyFor(source string) config.MissingSourcePolicy {
type queryOptions struct {
precision bool
timezone bool
allowNull bool
omitUnits bool
}
type envelope struct {
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)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), 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)
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()
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
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 {
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
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,
Endpoint: endpoint,
Query: queryMap(reqURL.Query()),
FetchedAt: c.now(),
}
if len(env.Data) == 0 || bytes.Equal(bytes.TrimSpace(env.Data), []byte("null")) {
if len(env.Data) == 0 || (isJSONNull(env.Data) && !opts.allowNull) {
source.Missing = true
return nil, source, nil
}
@@ -352,12 +367,18 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
return env.Data, source, nil
}
func isJSONNull(raw json.RawMessage) bool {
return bytes.Equal(bytes.TrimSpace(raw), []byte("null"))
}
func (c *Client) endpointURL(endpoint string, opts queryOptions) *url.URL {
reqURL := *c.baseURL
reqURL.Path = path.Join(c.baseURL.Path, endpoint)
query := reqURL.Query()
query.Set("format", c.format)
if !opts.omitUnits {
query.Set("units", c.units)
}
if opts.precision {
query.Set("precision", strconv.Itoa(c.precision))
}
@@ -397,30 +418,9 @@ func sourceHash(raw json.RawMessage) (string, error) {
return hex.EncodeToString(sum[:]), nil
}
func SaveBundle(path string, bundle *forecast.Bundle) error {
data, err := json.MarshalIndent(bundle, "", " ")
if err != nil {
return fmt.Errorf("marshal forecast bundle: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create bundle directory %q: %w", filepath.Dir(path), err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary bundle file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary bundle file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary bundle file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("save bundle %q: %w", path, err)
func SaveBundle(path string, bundle *weatherdata.Bundle) error {
if err := fileutil.WriteJSONAtomic(path, bundle); err != nil {
return fmt.Errorf("save bundle: %w", err)
}
return nil
}

View File

@@ -12,7 +12,7 @@ import (
"time"
"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) {
@@ -43,11 +43,23 @@ func TestFetchBundleFromFixtures(t *testing.T) {
if bundle.Discussion == nil || len(bundle.Discussion.KeyMessages) != 2 {
t.Fatalf("Discussion = %#v, want key messages", bundle.Discussion)
}
if len(bundle.Sources) != 8 {
t.Fatalf("Sources length = %d, want 8", len(bundle.Sources))
if bundle.Discussion.ShortTerm == nil || bundle.Discussion.ShortTerm.Text != "A weak boundary may trigger isolated showers." {
t.Fatalf("Discussion.ShortTerm = %#v, want short-term AFD text", bundle.Discussion.ShortTerm)
}
if len(bundle.Warnings) != 2 {
t.Fatalf("Warnings length = %d, want daily and weather story warnings", len(bundle.Warnings))
if bundle.Discussion.LongTerm == nil || bundle.Discussion.LongTerm.Text != "Warmer temperatures and periodic rain chances continue into the weekend." {
t.Fatalf("Discussion.LongTerm = %#v, want long-term AFD text", bundle.Discussion.LongTerm)
}
if bundle.WeatherStory == nil || bundle.WeatherStory.Title != "Several Chances for Rain Through Monday" {
t.Fatalf("WeatherStory = %#v, want latest weather story", bundle.WeatherStory)
}
if bundle.WeatherStory.UpdatedAt == nil {
t.Fatalf("WeatherStory.UpdatedAt = nil, want update timestamp")
}
if len(bundle.Sources) != 7 {
t.Fatalf("Sources length = %d, want 7", len(bundle.Sources))
}
if len(bundle.Warnings) != 0 {
t.Fatalf("Warnings length = %d, want no warnings", len(bundle.Warnings))
}
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
@@ -55,6 +67,9 @@ func TestFetchBundleFromFixtures(t *testing.T) {
if !containsPath(requested, "/forecast/narrative") || containsPath(requested, "/forecast/narrative/today") {
t.Fatalf("requested paths = %v, want full narrative endpoint only", requested)
}
if !containsPath(requested, "/weatherstories/latest") {
t.Fatalf("requested paths = %v, want weather story endpoint", requested)
}
}
func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
@@ -68,11 +83,20 @@ func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
}
for _, rawURL := range requested {
if !strings.Contains(rawURL, "format=json") || !strings.Contains(rawURL, "units=us") {
t.Fatalf("request %q missing format=json or units=us", rawURL)
if !strings.Contains(rawURL, "format=json") {
t.Fatalf("request %q missing format=json", rawURL)
}
if strings.HasPrefix(rawURL, "/weatherstories/") {
if strings.Contains(rawURL, "units=") || strings.Contains(rawURL, "precision=") || strings.Contains(rawURL, "tz=") {
t.Fatalf("weather story request %q should use format only", rawURL)
}
continue
}
if !strings.Contains(rawURL, "units=us") {
t.Fatalf("request %q missing units=us", rawURL)
}
if strings.HasPrefix(rawURL, "/forecast/") {
if !strings.Contains(rawURL, "precision=1") || !strings.Contains(rawURL, "tz=Chicago") {
if !strings.Contains(rawURL, "precision=1") || !strings.Contains(rawURL, "tz=America%2FChicago") {
t.Fatalf("forecast request %q missing precision or tz", rawURL)
}
}
@@ -93,6 +117,16 @@ func TestFetchBundleRecordsSourceHash(t *testing.T) {
if observation.DataSHA256 != want {
t.Fatalf("DataSHA256 = %q, want %q", observation.DataSHA256, want)
}
story := sourceByName(t, bundle.Sources, "weather_story")
if story.Endpoint != "/weatherstories/latest" {
t.Fatalf("weather story endpoint = %q, want /weatherstories/latest", story.Endpoint)
}
if story.DataSHA256 != hashFixtureData(t, "weather_story.json") {
t.Fatalf("weather story DataSHA256 = %q, want fixture hash", story.DataSHA256)
}
if story.IssuedAt == nil || story.UpdatedAt == nil {
t.Fatalf("weather story source timestamps = issued %#v updated %#v, want both", story.IssuedAt, story.UpdatedAt)
}
}
func TestHTTPErrorIsActionable(t *testing.T) {
@@ -125,6 +159,36 @@ func TestRequiredHourlyForecast(t *testing.T) {
}
}
func TestNullAlertsMeansNoActiveAlerts(t *testing.T) {
server := fixtureServer(t, map[string]handlerOverride{
"/alerts/active": {status: http.StatusOK, body: `{"data": null}`},
}, nil)
client := newTestClient(t, server.URL+"/", nil)
bundle, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
if bundle.Alerts == nil {
t.Fatal("Alerts = nil, want checked empty alert run")
}
if len(bundle.Alerts.Alerts) != 0 {
t.Fatalf("Alerts length = %d, want no active alerts", len(bundle.Alerts.Alerts))
}
source := sourceByName(t, bundle.Sources, "alerts")
if source.Missing {
t.Fatalf("alerts source Missing = true, want false")
}
if source.DataSHA256 == "" {
t.Fatal("alerts DataSHA256 is empty, want hash for explicit null payload")
}
for _, warning := range bundle.Warnings {
if warning.Source == "alerts" {
t.Fatalf("warnings = %#v, want no alerts warning", bundle.Warnings)
}
}
}
func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
tests := []struct {
name string
@@ -133,7 +197,7 @@ func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
wantWarns int
wantSource bool
}{
{name: "warn", policy: config.MissingSourceWarn, wantWarns: 3, wantSource: true},
{name: "warn", policy: config.MissingSourceWarn, wantWarns: 1, wantSource: true},
{name: "none", policy: config.MissingSourceNone, wantWarns: 0, wantSource: true},
{name: "error", policy: config.MissingSourceError, wantErr: true},
}
@@ -194,6 +258,45 @@ func TestMalformedNonRequiredSourceUsesPolicy(t *testing.T) {
}
}
func TestMissingWeatherStoryUsesPolicy(t *testing.T) {
server := fixtureServer(t, map[string]handlerOverride{
"/weatherstories/latest": {status: http.StatusOK, body: `{"data": null}`},
}, nil)
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
"weather_story": config.MissingSourceWarn,
})
bundle, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
if bundle.WeatherStory != nil {
t.Fatalf("WeatherStory = %#v, want nil for missing source", bundle.WeatherStory)
}
source := sourceByName(t, bundle.Sources, "weather_story")
if !source.Missing || len(source.Warnings) != 1 {
t.Fatalf("weather_story source = %#v, want missing source warning", source)
}
}
func TestMalformedWeatherStoryUsesPolicy(t *testing.T) {
server := fixtureServer(t, map[string]handlerOverride{
"/weatherstories/latest": {status: http.StatusOK, body: `{"data": {"startTime": 123}}`},
}, nil)
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
"weather_story": config.MissingSourceWarn,
})
bundle, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
source := sourceByName(t, bundle.Sources, "weather_story")
if !source.Missing || len(source.Warnings) != 1 || source.Warnings[0].Code != "malformed_source" {
t.Fatalf("weather_story source = %#v, want malformed source warning", source)
}
}
func TestContextCancellation(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
@@ -266,6 +369,7 @@ func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested
"/forecast/narrative": "narrative.json",
"/alerts/active": "alerts.json",
"/discussion": "discussion.json",
"/weatherstories/latest": "weather_story.json",
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if requested != nil {
@@ -319,7 +423,7 @@ func containsPath(requested []string, path string) bool {
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()
for _, source := range sources {
if source.Name == name {
@@ -327,7 +431,7 @@ func sourceByName(t *testing.T, sources []forecast.Source, name string) forecast
}
}
t.Fatalf("source %q not found in %#v", name, sources)
return forecast.Source{}
return weatherdata.Source{}
}
func hashFixtureData(t *testing.T, fixture string) string {

View File

@@ -9,8 +9,12 @@
"Warmer temperatures this weekend."
],
"shortTerm": {
"title": "Short Term",
"narrative": "A weak boundary may trigger isolated showers."
"qualifier": "(Through This Evening)",
"text": "A weak boundary may trigger isolated showers."
},
"longTerm": {
"qualifier": "(This Weekend)",
"text": "Warmer temperatures and periodic rain chances continue into the weekend."
}
}
}

View File

@@ -0,0 +1,14 @@
{
"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": "A stagnant weather pattern with low pressure over the Great Plains and high pressure over the Great Lakes will continue to produce scattered showers and thunderstorms, for areas mainly along and west of the Mississippi River today and Sunday.",
"altText": "This slide shows the forecast for today through Tuesday with icons for showers and thunderstorms and a picture of a cumulonimbus cloud on the right side.",
"priority": false,
"order": 1,
"downloadUrl": "https://api.weather.gov/offices/LSX/weatherstories/download/3228e499-2aae-45a8-9ff9-1c060311026f"
}
}

View File

@@ -3,22 +3,26 @@ package app
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/weatherapi"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type ReportKind string
@@ -46,6 +50,7 @@ type GenerateRequest struct {
Date time.Time
StormStart time.Time
StormEnd time.Time
Notifier Notifier
}
type BatchRequest struct {
@@ -55,6 +60,7 @@ type BatchRequest struct {
OutputDir string
Renderer Renderer
Store state.Store
Notifier Notifier
}
type FetchBundleRequest struct {
@@ -62,13 +68,15 @@ type FetchBundleRequest struct {
OutputPath string
}
type BriefingRequest struct {
type ModuleSnapshotRequest struct {
Config config.Config
Resolved report.Resolved
OutputPath string
}
type DailyBriefingRequest = BriefingRequest
type ReportFacts struct {
Collected facts.CollectedFacts
Derived facts.DerivedFacts
}
type ReportRequest struct {
Config config.Config
@@ -76,35 +84,27 @@ type ReportRequest struct {
OutputPath string
Renderer Renderer
Store state.Store
Notifier Notifier
}
type DailyReportRequest = ReportRequest
type BriefingResult struct {
Package briefing.Package
OutputPath string
}
type DailyBriefingResult = BriefingResult
type ReportResult struct {
Briefing briefing.Package
BriefingPath string
ModuleSnapshot module.Snapshot
ModuleSnapshotPath string
DataPackage promptinput.Package
DataPackagePath string
PreflightPath string
ReportPath string
OutputPath string
NotificationPath string
Metadata state.Metadata
MetadataPath string
PriorSnapshot *state.PriorSnapshot
RecentChanges []changes.Change
RenderResult *scriptorium.RenderResult
RunResult *scriptorium.RunResult
Notification *NotificationResult
}
type DailyReportResult = ReportResult
type BatchResult struct {
Batch BatchKind `json:"batch"`
StartedAt time.Time `json:"startedAt"`
@@ -122,9 +122,13 @@ type BatchReportResult struct {
RunID string `json:"runId"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
NotificationStatus string `json:"notificationStatus,omitempty"`
NotificationRunID string `json:"notificationRunId,omitempty"`
NotificationPipelineID string `json:"notificationPipelineId,omitempty"`
NotificationError string `json:"notificationError,omitempty"`
NotificationPath string `json:"notificationPath,omitempty"`
GeneratedAt time.Time `json:"generatedAt"`
ValidPeriod timeutil.Period `json:"validPeriod"`
BriefingPath string `json:"briefingPath,omitempty"`
DataPackagePath string `json:"dataPackagePath,omitempty"`
PreflightPath string `json:"preflightPath,omitempty"`
ReportPath string `json:"reportPath,omitempty"`
@@ -148,6 +152,55 @@ type Renderer interface {
Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error)
}
type Notifier interface {
Notify(context.Context, NotificationRequest) (*NotificationResult, error)
}
type NotificationRequest struct {
ReportID report.ID
RunID string
PipelineID string
BundleID string
IdempotencyKey string
ReportPath string
BundlePaths []string
CreatedAt time.Time
}
type NotificationResult struct {
BundleID string
IdempotencyKey string
RunID string
Status string
UploadStatus string
StatusError string
PipelineID string
AcceptedAt time.Time
StartedAt *time.Time
FinishedAt *time.Time
Report []byte
Error string
}
type NotificationError struct {
Request NotificationRequest
Err error
}
func (e *NotificationError) Error() string {
if e == nil || e.Err == nil {
return "notification failed"
}
return e.Err.Error()
}
func (e *NotificationError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}
func Generate(ctx context.Context, req GenerateRequest) error {
now := req.Now
if now.IsZero() {
@@ -157,11 +210,12 @@ func Generate(ctx context.Context, req GenerateRequest) error {
if err != nil {
return err
}
if isGeneratedReport(resolved.Definition.ID) {
if resolved.Definition.Generated {
_, err := GenerateReport(ctx, ReportRequest{
Config: req.Config,
Resolved: resolved,
OutputPath: req.OutputPath,
Notifier: req.Notifier,
})
return err
}
@@ -200,14 +254,13 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
startedAt := now
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
for _, resolved := range resolvedReports {
if !isGeneratedReport(resolved.Definition.ID) {
if !resolved.Definition.Generated {
return nil, fmt.Errorf("run is not implemented")
}
}
for _, resolved := range resolvedReports {
item := batchReportResult(resolved)
if paths, err := store.Paths(resolved); err == nil {
item.BriefingPath = paths.Briefing
item.DataPackagePath = paths.DataPackage
item.PreflightPath = paths.Preflight
item.ReportPath = paths.RenderedReport
@@ -220,19 +273,34 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
OutputPath: outputPath,
Renderer: req.Renderer,
Store: store,
Notifier: req.Notifier,
})
if err != nil {
item.Status = "failed"
item.Error = err.Error()
var notificationErr *NotificationError
if errors.As(err, &notificationErr) {
item.NotificationStatus = "failed"
item.NotificationError = notificationErr.Error()
item.NotificationPipelineID = notificationErr.Request.PipelineID
if paths, pathErr := store.Paths(resolved); pathErr == nil {
item.NotificationPath = paths.Notification
}
}
result.Failed++
} else {
item.Status = "succeeded"
item.BriefingPath = reportResult.BriefingPath
item.DataPackagePath = reportResult.DataPackagePath
item.PreflightPath = reportResult.PreflightPath
item.ReportPath = reportResult.ReportPath
item.OutputPath = reportResult.OutputPath
item.MetadataPath = reportResult.MetadataPath
item.NotificationPath = reportResult.NotificationPath
if reportResult.Notification != nil {
item.NotificationStatus = reportResult.Notification.Status
item.NotificationRunID = reportResult.Notification.RunID
item.NotificationPipelineID = reportResult.Notification.PipelineID
}
result.Succeeded++
}
result.Reports = append(result.Reports, item)
@@ -257,19 +325,10 @@ func batchReportResult(resolved report.Resolved) BatchReportResult {
}
func batchOutputPath(outputDir string, definition report.Definition) string {
if outputDir == "" || definition.DefaultOutputName == "" {
if outputDir == "" || definition.BatchOutputName == "" {
return ""
}
name := strings.ReplaceAll(definition.DefaultOutputName, "_", "-")
return filepath.Join(outputDir, name)
}
func isGeneratedReport(id report.ID) bool {
return isDailyReport(id) || id == report.ThreeDay || id == report.Weekend || id == report.Storm
}
func isDailyReport(id report.ID) bool {
return id == report.DailyToday || id == report.DailyTomorrow
return filepath.Join(outputDir, definition.BatchOutputName)
}
func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) {
@@ -281,7 +340,11 @@ func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error
if err != nil {
return report.Resolved{}, err
}
return report.DefaultRegistry().Resolve(id, report.ResolveRequest{
registry, err := reportRegistry(req.Config)
if err != nil {
return report.Resolved{}, err
}
return registry.Resolve(id, report.ResolveRequest{
Now: now,
Location: location,
Date: req.Date,
@@ -299,12 +362,24 @@ func ResolveBatch(req BatchRequest, now time.Time) ([]report.Resolved, error) {
if err != nil {
return nil, err
}
return report.DefaultRegistry().BatchReports(batch, report.ResolveRequest{
registry, err := reportRegistry(req.Config)
if err != nil {
return nil, err
}
return registry.BatchReports(batch, report.ResolveRequest{
Now: now,
Location: location,
})
}
func reportRegistry(cfg config.Config) (report.Registry, error) {
registry, err := report.DefaultRegistry().WithModuleOverrides(cfg.ReportModuleOverrides())
if err != nil {
return report.Registry{}, err
}
return registry, nil
}
func reportIDForCommand(kind ReportKind) (report.ID, error) {
switch kind {
case ReportDaily:
@@ -333,7 +408,7 @@ func reportBatchForCommand(kind BatchKind) (report.Batch, error) {
}
}
func FetchBundle(ctx context.Context, req FetchBundleRequest) (*forecast.Bundle, error) {
func FetchBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) {
client, err := weatherapi.New(req.Config)
if err != nil {
return nil, err
@@ -345,7 +420,7 @@ func FetchBundle(ctx context.Context, req FetchBundleRequest) (*forecast.Bundle,
return bundle, nil
}
func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*forecast.Bundle, error) {
func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) {
if req.OutputPath == "" {
return nil, fmt.Errorf("output path is required")
}
@@ -359,41 +434,6 @@ func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*forecast.
return bundle, nil
}
func GenerateDailyBriefing(ctx context.Context, req DailyBriefingRequest) (*DailyBriefingResult, error) {
return GenerateBriefing(ctx, req)
}
func GenerateBriefing(ctx context.Context, req BriefingRequest) (*BriefingResult, error) {
bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config})
if err != nil {
return nil, err
}
pkg, err := BuildBriefing(req, bundle)
if err != nil {
return nil, err
}
outputPath := req.OutputPath
if outputPath == "" {
store, err := defaultStore(req.Config)
if err != nil {
return nil, err
}
paths, err := store.Paths(req.Resolved)
if err != nil {
return nil, err
}
outputPath = paths.Briefing
}
if err := briefing.Save(outputPath, pkg); err != nil {
return nil, err
}
return &BriefingResult{Package: pkg, OutputPath: outputPath}, nil
}
func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyReportResult, error) {
return GenerateReport(ctx, req)
}
func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) {
store := req.Store
if store == nil {
@@ -416,24 +456,43 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
if err != nil {
return nil, err
}
briefingPackage, err := BuildBriefing(BriefingRequest{
reportFacts, err := BuildReportFacts(ModuleSnapshotRequest{
Config: req.Config,
Resolved: req.Resolved,
}, bundle)
if err != nil {
return nil, err
}
briefingPath, err := store.SaveBriefing(ctx, req.Resolved, briefingPackage)
moduleSnapshot, err := BuildModuleSnapshotFromFacts(ModuleSnapshotRequest{
Config: req.Config,
Resolved: req.Resolved,
}, reportFacts)
if err != nil {
return nil, err
}
recentChanges, err := recentChanges(ctx, store, priorSnapshot, briefingPackage, req.Config.RecentChange)
moduleSnapshotPath, err := store.SaveModuleSnapshot(ctx, req.Resolved, moduleSnapshot)
if err != nil {
return nil, err
}
dataPackage, err := promptinput.BuildWithRecentChanges(briefingPackage, recentChanges)
recentChanges, err := recentChanges(ctx, store, priorSnapshot, req.Resolved.Definition.ID, moduleSnapshot, req.Config.RecentChange)
if err != nil {
return nil, err
}
briefingMetadata := briefing.BuildMetadata(briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected))
metadata := state.BuildMetadataFromBriefingMetadata(req.Resolved, briefingMetadata, state.ArtifactPaths{
ModuleSnapshot: moduleSnapshotPath,
Metadata: paths.Metadata,
DataPackage: paths.DataPackage,
Preflight: paths.Preflight,
RenderedReport: paths.RenderedReport,
})
dataPackage, err := promptinput.Build(promptinput.BuildRequest{
Metadata: promptMetadata(metadata),
Modules: moduleSnapshot,
RecentChanges: recentChanges,
})
if err != nil {
return nil, err
}
@@ -441,6 +500,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
if err != nil {
return nil, err
}
metadata.DataPackagePath = dataPackagePath
renderer := req.Renderer
if renderer == nil {
@@ -460,18 +520,12 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
preflightPath := paths.Preflight
if renderResult != nil {
var err error
preflightPath, err = store.SavePreflight(ctx, req.Resolved, renderResult)
preflightPath, err = store.SavePreflight(ctx, req.Resolved, preflightArtifact(renderResult))
if err != nil {
return nil, err
}
}
metadata := state.BuildMetadata(req.Resolved, briefingPackage, state.ArtifactPaths{
Briefing: briefingPath,
Metadata: paths.Metadata,
DataPackage: dataPackagePath,
Preflight: preflightPath,
RenderedReport: paths.RenderedReport,
})
metadata.PreflightPath = preflightPath
metadataPath, metadataErr := store.SaveMetadata(ctx, metadata)
if metadataErr != nil {
return nil, metadataErr
@@ -490,7 +544,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
OutputPath: reportPath,
})
if runErr == nil && req.OutputPath != "" && req.OutputPath != reportPath {
if err := copyFileAtomic(reportPath, req.OutputPath); err != nil {
if err := fileutil.CopyFileAtomic(reportPath, req.OutputPath); err != nil {
return nil, err
}
}
@@ -507,96 +561,346 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
return nil, runErr
}
notification, notificationPath, err := notifyReport(ctx, req.Config, req.Resolved, reportPath, metadata, req.Notifier, store)
if notificationPath != "" {
metadata.NotificationPath = notificationPath
metadataPath, metadataErr = store.SaveMetadata(ctx, metadata)
if metadataErr != nil {
return nil, metadataErr
}
}
if err != nil {
return nil, err
}
return &ReportResult{
Briefing: briefingPackage,
BriefingPath: briefingPath,
ModuleSnapshot: moduleSnapshot,
ModuleSnapshotPath: moduleSnapshotPath,
DataPackage: dataPackage,
DataPackagePath: dataPackagePath,
PreflightPath: preflightPath,
ReportPath: reportPath,
OutputPath: outputPath,
NotificationPath: notificationPath,
Metadata: metadata,
MetadataPath: metadataPath,
PriorSnapshot: priorSnapshot,
RecentChanges: recentChanges,
RenderResult: renderResult,
RunResult: runResult,
Notification: notification,
}, nil
}
func BuildDailyBriefing(req DailyBriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) {
return BuildBriefing(req, bundle)
func notifyReport(ctx context.Context, cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata, notifier Notifier, store state.Store) (*NotificationResult, string, error) {
notifier, enabled := reportNotifier(cfg, notifier)
if !enabled {
return nil, "", nil
}
notificationRequest, err := buildNotificationRequest(cfg, resolved, reportPath, metadata)
if err != nil {
notificationPath, saveErr := saveNotificationArtifact(ctx, store, resolved, cfg, metadata, NotificationRequest{}, nil, err)
if saveErr != nil {
return nil, "", saveErr
}
return nil, notificationPath, err
}
result, err := notifier.Notify(ctx, notificationRequest)
notificationPath, saveErr := saveNotificationArtifact(ctx, store, resolved, cfg, metadata, notificationRequest, result, err)
if saveErr != nil {
return nil, "", saveErr
}
if err != nil {
return result, notificationPath, &NotificationError{
Request: notificationRequest,
Err: fmt.Errorf("notify report %q run %q from managed report %q: %w", resolved.Definition.ID, metadata.RunID, reportPath, err),
}
}
return result, notificationPath, nil
}
func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) {
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
if err != nil {
return briefing.Package{}, err
func reportNotifier(cfg config.Config, notifier Notifier) (Notifier, bool) {
if !cfg.Notify.Distributor.Enabled {
return noopNotifier{}, false
}
dayparts := make([]forecast.DaypartDefinition, 0, len(req.Config.Dayparts))
for _, daypart := range req.Config.Dayparts {
if notifier != nil {
return notifier, true
}
return distributorNotifier{
client: distributoradapter.New(cfg.Notify.Distributor),
}, true
}
func buildNotificationRequest(cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata) (NotificationRequest, error) {
values := config.DistributorTemplateValues{
LocationID: cfg.Location.ID,
ReportID: string(resolved.Definition.ID),
RunID: metadata.RunID,
ArtifactGroup: resolved.Definition.ArtifactGroup,
BatchOutputName: resolved.Definition.BatchOutputName,
}
if err := addDistributorValidPeriodValues(&values, resolved.ValidPeriod, cfg.WeatherAPI.Timezone); err != nil {
return NotificationRequest{}, err
}
bundleID, err := config.RenderDistributorBundleID(cfg.Notify.Distributor.BundleIDTemplate, values)
if err != nil {
return NotificationRequest{}, err
}
values.BundleID = bundleID
pipelineID, err := config.RenderDistributorPipelineID(cfg.Notify.Distributor.PipelineIDTemplate, values)
if err != nil {
return NotificationRequest{}, err
}
idempotencyKey, err := config.RenderDistributorIdempotencyKey(cfg.Notify.Distributor.IdempotencyKeyTemplate, values)
if err != nil {
return NotificationRequest{}, err
}
bundlePaths, err := config.RenderDistributorReportPaths(cfg.Notify.Distributor.ReportPathTemplates, values)
if err != nil {
return NotificationRequest{}, err
}
return NotificationRequest{
ReportID: resolved.Definition.ID,
RunID: metadata.RunID,
PipelineID: pipelineID,
BundleID: bundleID,
IdempotencyKey: idempotencyKey,
ReportPath: reportPath,
BundlePaths: bundlePaths,
CreatedAt: metadata.GeneratedAt,
}, nil
}
func addDistributorValidPeriodValues(values *config.DistributorTemplateValues, period timeutil.Period, timezone string) error {
location, err := timeutil.LoadLocation(timezone)
if err != nil {
return err
}
start := period.Start.In(location)
end := period.End.In(location)
values.ValidStartDate = start.Format(timeutil.DateLayout)
values.ValidEndDate = end.Format(timeutil.DateLayout)
values.ValidStartTime = start.Format("1504")
values.ValidEndTime = end.Format("1504")
values.ValidStartStamp = start.Format("2006-01-02T1504")
values.ValidEndStamp = end.Format("2006-01-02T1504")
return nil
}
func saveNotificationArtifact(ctx context.Context, store state.Store, resolved report.Resolved, cfg config.Config, metadata state.Metadata, req NotificationRequest, result *NotificationResult, notifyErr error) (string, error) {
if store == nil {
return "", fmt.Errorf("state store is required")
}
artifact := state.DistributorNotificationArtifact{
SchemaVersion: state.DistributorNotificationSchemaVersion,
RunID: metadata.RunID,
ReportID: resolved.Definition.ID,
AttemptedAt: time.Now(),
Endpoint: cfg.Notify.Distributor.Endpoint,
PipelineID: req.PipelineID,
BundleID: req.BundleID,
IdempotencyKey: req.IdempotencyKey,
SourcePath: req.ReportPath,
BundlePaths: append([]string(nil), req.BundlePaths...),
BundleCreated: req.CreatedAt,
Status: "attempted",
}
if result != nil {
artifact.Status = result.Status
artifact.Upload = &state.DistributorUploadResult{
RunID: result.RunID,
Status: result.UploadStatus,
}
if result.PipelineID != "" || !result.AcceptedAt.IsZero() || result.StartedAt != nil || result.FinishedAt != nil || len(result.Report) > 0 || result.Error != "" {
artifact.RunStatus = &state.DistributorRunStatus{
RunID: result.RunID,
PipelineID: result.PipelineID,
Status: result.Status,
AcceptedAt: result.AcceptedAt,
StartedAt: result.StartedAt,
FinishedAt: result.FinishedAt,
Report: append([]byte(nil), result.Report...),
Error: result.Error,
}
}
artifact.StatusError = result.StatusError
}
if notifyErr != nil {
artifact.Status = "failed"
artifact.Error = notifyErr.Error()
}
if artifact.Status == "" {
artifact.Status = "unknown"
}
return store.SaveDistributorNotification(ctx, resolved, artifact)
}
type noopNotifier struct{}
func (noopNotifier) Notify(context.Context, NotificationRequest) (*NotificationResult, error) {
return nil, nil
}
type distributorNotifier struct {
client *distributoradapter.Client
}
func (n distributorNotifier) Notify(ctx context.Context, req NotificationRequest) (*NotificationResult, error) {
result, err := n.client.Upload(ctx, distributoradapter.UploadRequest{
PipelineID: req.PipelineID,
BundleID: req.BundleID,
IdempotencyKey: req.IdempotencyKey,
Files: distributorUploadFiles(req.ReportPath, req.BundlePaths),
CreatedAt: req.CreatedAt,
})
notification := &NotificationResult{
PipelineID: req.PipelineID,
BundleID: req.BundleID,
IdempotencyKey: req.IdempotencyKey,
RunID: result.RunID,
Status: result.Status,
UploadStatus: result.UploadStatus,
StatusError: result.StatusError,
}
if result.RunStatus != nil {
if result.RunStatus.PipelineID != "" {
notification.PipelineID = result.RunStatus.PipelineID
}
notification.AcceptedAt = result.RunStatus.AcceptedAt
notification.StartedAt = result.RunStatus.StartedAt
notification.FinishedAt = result.RunStatus.FinishedAt
notification.Report = append([]byte(nil), result.RunStatus.Report...)
notification.Error = result.RunStatus.Error
}
if err != nil {
return notification, err
}
return notification, nil
}
func distributorUploadFiles(sourcePath string, bundlePaths []string) []distributoradapter.UploadFile {
files := make([]distributoradapter.UploadFile, 0, len(bundlePaths))
for _, bundlePath := range bundlePaths {
files = append(files, distributoradapter.UploadFile{
SourcePath: sourcePath,
BundlePath: bundlePath,
})
}
return files
}
func BuildModuleSnapshot(req ModuleSnapshotRequest, bundle *weatherdata.Bundle) (module.Snapshot, error) {
reportFacts, err := BuildReportFacts(req, bundle)
if err != nil {
return module.Snapshot{}, err
}
return BuildModuleSnapshotFromFacts(req, reportFacts)
}
func BuildReportFacts(req ModuleSnapshotRequest, bundle *weatherdata.Bundle) (ReportFacts, error) {
collected := facts.BuildCollected(bundle)
derived, err := buildDerivedFacts(req.Config, req.Resolved, collected)
if err != nil {
return ReportFacts{}, err
}
return ReportFacts{
Collected: collected,
Derived: derived,
}, nil
}
func BuildModuleSnapshotFromFacts(req ModuleSnapshotRequest, reportFacts ReportFacts) (module.Snapshot, error) {
if !req.Resolved.ValidPeriod.IsValid() {
return module.Snapshot{}, fmt.Errorf("resolved valid period is required")
}
registry, err := briefing.DefaultModuleRegistry()
if err != nil {
return module.Snapshot{}, err
}
moduleContext := briefing.ModuleContext{
Resolved: req.Resolved,
Collected: reportFacts.Collected,
Derived: reportFacts.Derived,
Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone,
Location: briefingLocation(req.Config),
}
var outputs []module.Output
for _, item := range req.Resolved.Definition.Modules {
output, err := registry.BuildModule(moduleContext, item)
if err != nil {
return module.Snapshot{}, err
}
if output == nil {
continue
}
outputs = append(outputs, *output)
}
return module.NewSnapshot(outputs)
}
func briefingBuildContext(cfg config.Config, resolved report.Resolved, collected facts.CollectedFacts) briefing.BuildContext {
return briefing.BuildContext{
Resolved: resolved,
Bundle: collected.Bundle(),
Units: cfg.WeatherAPI.Units,
Timezone: cfg.WeatherAPI.Timezone,
Location: briefingLocation(cfg),
}
}
func promptMetadata(metadata state.Metadata) promptinput.Metadata {
return promptinput.Metadata{
RunID: metadata.RunID,
ReportID: metadata.ReportID,
Variant: metadata.Variant,
PromptID: metadata.PromptID,
GeneratedAt: metadata.GeneratedAt,
Timezone: metadata.Timezone,
ValidPeriod: metadata.ValidPeriod,
SourceWarnings: metadata.SourceWarnings,
}
}
func buildDerivedFacts(cfg config.Config, resolved report.Resolved, collected facts.CollectedFacts) (facts.DerivedFacts, error) {
dayparts := make([]forecast.DaypartDefinition, 0, len(cfg.Dayparts))
for _, daypart := range cfg.Dayparts {
dayparts = append(dayparts, forecast.DaypartDefinition{
Name: daypart.Name,
Start: daypart.Start,
End: daypart.End,
})
}
switch req.Resolved.Definition.ID {
case report.DailyToday, report.DailyTomorrow:
summary, err := forecast.BuildDailySummary(bundle, req.Resolved.ValidPeriod.Start, location, dayparts)
if err != nil {
return briefing.Package{}, err
}
return briefing.BuildDaily(briefing.BuildContext{
Resolved: req.Resolved,
Bundle: bundle,
Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone,
}, summary)
case report.ThreeDay, report.Weekend:
summaries, err := forecast.BuildPeriodDailySummaries(bundle, req.Resolved.ValidPeriod, location, dayparts)
if err != nil {
return briefing.Package{}, err
}
if req.Resolved.Definition.ID == report.Weekend {
return briefing.BuildWeekend(briefing.BuildContext{
Resolved: req.Resolved,
Bundle: bundle,
Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone,
}, summaries)
}
return briefing.BuildThreeDay(briefing.BuildContext{
Resolved: req.Resolved,
Bundle: bundle,
Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone,
}, summaries)
case report.Storm:
return briefing.BuildStorm(briefing.BuildContext{
Resolved: req.Resolved,
Bundle: bundle,
Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone,
return facts.BuildDerived(facts.BuildDerivedRequest{
Resolved: resolved,
Timezone: cfg.WeatherAPI.Timezone,
Dayparts: dayparts,
Collected: collected,
})
default:
return briefing.Package{}, fmt.Errorf("briefing is not implemented for report %q", req.Resolved.Definition.ID)
}
func briefingLocation(cfg config.Config) *briefing.LocationContext {
location := briefing.LocationContext{
ID: cfg.Location.ID,
Name: cfg.Location.Name,
Region: cfg.Location.Region,
Timezone: cfg.WeatherAPI.Timezone,
}
if location.ID == "" && location.Name == "" && location.Region == "" && location.Timezone == "" {
return nil
}
return &location
}
func defaultStore(cfg config.Config) (*state.FilesystemStore, error) {
return state.NewFilesystemStore(cfg.Workspace)
}
func dailyRecentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) {
return recentChanges(ctx, store, priorSnapshot, current, cfg)
}
func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) {
func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, reportID report.ID, current module.Snapshot, cfg config.RecentChangeConfig) ([]changes.Change, error) {
if priorSnapshot == nil {
return nil, nil
}
previous, err := store.LoadBriefing(ctx, priorSnapshot.BriefingPath)
previous, err := store.LoadModuleSnapshot(ctx, priorSnapshot.ModuleSnapshotPath)
if err != nil {
return nil, err
}
@@ -606,7 +910,7 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.
WindGustMilesPerHour: cfg.WindGustMilesPerHour,
PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes,
}
switch current.Metadata.ReportID {
switch reportID {
case report.DailyToday, report.DailyTomorrow:
return changes.CompareDaily(previous, current, thresholds)
case report.ThreeDay:
@@ -618,29 +922,16 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.
}
}
func copyFileAtomic(source string, target string) error {
data, err := os.ReadFile(source)
if err != nil {
return fmt.Errorf("read rendered report %q: %w", source, err)
func preflightArtifact(result *scriptorium.RenderResult) state.PreflightArtifact {
if result == nil {
return state.PreflightArtifact{}
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return fmt.Errorf("create report output directory %q: %w", filepath.Dir(target), err)
return state.PreflightArtifact{
Command: append([]string(nil), result.Command...),
Stdout: result.Stdout,
Stderr: result.Stderr,
StdoutTruncated: result.StdoutTruncated,
StderrTruncated: result.StderrTruncated,
ExitCode: result.ExitCode,
}
tmp, err := os.CreateTemp(filepath.Dir(target), "."+filepath.Base(target)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary report output file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary report output file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary report output file: %w", err)
}
if err := os.Rename(tmpName, target); err != nil {
return fmt.Errorf("save report output %q: %w", target, err)
}
return nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,11 +6,12 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"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/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type InspectReportsRequest struct {
@@ -28,7 +29,7 @@ type SourceInspection struct {
ReportID report.ID `json:"reportId"`
SourceLocation string `json:"sourceLocation,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) {
@@ -40,59 +41,44 @@ func InspectReports(ctx context.Context, req InspectReportsRequest) ([]state.Rep
}
func InspectMetadata(ctx context.Context, req InspectRunRequest) (state.Metadata, error) {
store, err := defaultStore(req.Config)
if err != nil {
return state.Metadata{}, err
}
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
return metadata, err
inspection, err := inspectRun(ctx, req)
return inspection.metadata, err
}
func InspectBriefing(ctx context.Context, req InspectRunRequest) (briefing.Package, error) {
store, err := defaultStore(req.Config)
func InspectModules(ctx context.Context, req InspectRunRequest) (module.Snapshot, error) {
inspection, err := inspectRun(ctx, req)
if err != nil {
return briefing.Package{}, err
return module.Snapshot{}, err
}
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
if err != nil {
return briefing.Package{}, err
}
return store.LoadBriefing(ctx, metadata.BriefingPath)
return inspection.store.LoadModuleSnapshot(ctx, inspection.metadata.ModuleSnapshotPath)
}
func InspectDataPackage(ctx context.Context, req InspectRunRequest) (promptinput.Package, error) {
store, err := defaultStore(req.Config)
inspection, err := inspectRun(ctx, req)
if err != nil {
return promptinput.Package{}, err
}
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
if err != nil {
return promptinput.Package{}, err
}
return store.LoadDataPackage(ctx, metadata.DataPackagePath)
return inspection.store.LoadDataPackage(ctx, inspection.metadata.DataPackagePath)
}
func InspectPriorSnapshot(ctx context.Context, req InspectRunRequest) (*state.PriorSnapshot, error) {
store, err := defaultStore(req.Config)
inspection, err := inspectRun(ctx, req)
if err != nil {
return nil, err
}
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
resolved, err := resolvedFromMetadata(inspection.metadata)
if err != nil {
return nil, err
}
resolved, err := resolvedFromMetadata(metadata)
if err != nil {
return nil, err
}
return store.FindPriorSnapshot(ctx, resolved)
return inspection.store.FindPriorSnapshot(ctx, resolved)
}
func InspectSources(ctx context.Context, req InspectRunRequest) (SourceInspection, error) {
metadata, err := InspectMetadata(ctx, req)
inspection, err := inspectRun(ctx, req)
if err != nil {
return SourceInspection{}, err
}
metadata := inspection.metadata
return SourceInspection{
RunID: metadata.RunID,
ReportID: metadata.ReportID,
@@ -102,6 +88,23 @@ func InspectSources(ctx context.Context, req InspectRunRequest) (SourceInspectio
}, nil
}
type runInspection struct {
store *state.FilesystemStore
metadata state.Metadata
}
func inspectRun(ctx context.Context, req InspectRunRequest) (runInspection, error) {
store, err := defaultStore(req.Config)
if err != nil {
return runInspection{}, err
}
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
if err != nil {
return runInspection{}, err
}
return runInspection{store: store, metadata: metadata}, nil
}
func resolvedFromMetadata(metadata state.Metadata) (report.Resolved, error) {
definition, err := report.DefaultRegistry().Lookup(metadata.ReportID)
if err != nil {

View 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
}

View 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
}

View File

@@ -0,0 +1,493 @@
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.TextDescription != "Showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 {
t.Fatalf("HourlyForecast period = %#v, want hourly period facts", period)
}
if period.StartTime != "2026-05-29 at 8:00 AM" || period.EndTime != "2026-05-29 at 9:00 AM" {
t.Fatalf("HourlyForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime)
}
if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
t.Fatalf("HourlyForecast period = %#v, want compass wind and precip chance", period)
}
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", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} {
if !strings.Contains(jsonText, field) {
t.Fatalf("hourly json = %s, want field %s", jsonText, field)
}
}
if strings.Contains(jsonText, "wind_direction_degrees") || strings.Contains(jsonText, "Tomorrow") {
t.Fatalf("hourly json = %s, want valid-period prompt fields only", jsonText)
}
if strings.Contains(jsonText, `"start_time":"2026-05-29T`) || strings.Contains(jsonText, `"end_time":"2026-05-29T`) {
t.Fatalf("hourly json = %s, want friendly local start/end times", jsonText)
}
}
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 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.StartTime != "2026-05-29 at 6:00 AM" || period.EndTime != "2026-05-29 at 6:00 PM" {
t.Fatalf("NarrativeForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime)
}
if period.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", "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":"2026-05-29T`) || strings.Contains(jsonText, `"end_time":"2026-05-29T`) {
t.Fatalf("narrative json = %s, want friendly local start/end times", jsonText)
}
if strings.Contains(jsonText, "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.TemperatureF == nil || *value.TemperatureF != 74 {
t.Fatalf("CurrentConditions = %#v, want current condition facts", value)
}
if value.WindDirection != "S" {
t.Fatalf("WindDirection = %q, want S", value.WindDirection)
}
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", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction"} {
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)
}
data, err := json.Marshal(storyOutput.Value)
if err != nil {
t.Fatalf("Marshal weather story: %v", err)
}
if !strings.Contains(string(data), "download_url") {
t.Fatalf("weather story json = %s, want snake_case download_url", 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 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.0
apparentF := 76.0
humidity := 71.0
windMph := 8.0
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
}

View File

@@ -0,0 +1,58 @@
package briefing
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
type CurrentConditionsModule struct {
ConditionText string `json:"condition_text,omitempty"`
IsDay *bool `json:"is_day,omitempty"`
TemperatureC *float64 `json:"temperature_c,omitempty"`
TemperatureF *float64 `json:"temperature_f,omitempty"`
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"`
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"`
DewpointC *float64 `json:"dewpoint_c,omitempty"`
DewpointF *float64 `json:"dewpoint_f,omitempty"`
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
WindDirection string `json:"wind_direction,omitempty"`
}
func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, error) {
current := ctx.Collected.Current
if current == nil {
return nil, nil
}
value := CurrentConditionsModule{
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),
WindDirection: windDirectionLabel(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.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 == ""
}

View File

@@ -1,270 +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)
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",
}, 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 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) != 4 {
t.Fatalf("Dayparts length = %d, want 4", 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.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),
}},
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
}
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 len(pkg.Daily.RelevantAlerts) != 0 {
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts))
}
}
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{Thunder: 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: "12:00"},
{Name: "afternoon", Start: "12:00", End: "18:00"},
{Name: "evening", Start: "18: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
}

View 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))
}

View File

@@ -0,0 +1,108 @@
package briefing
import (
"fmt"
"strings"
"unicode"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
type DerivedDaypartSummaryModule struct {
Date string `json:"date,omitempty"`
Period string `json:"period,omitempty"`
TempRangeF string `json:"temp_range_f,omitempty"`
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"`
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
DominantCondition string `json:"dominant_condition,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 {
value := DerivedDaypartSummaryModule{
Date: localDateLabel(daypart.Period.Start, timezone),
Period: friendlyPeriodLabel(daypart.Period, timezone),
TempRangeF: rangeLabel(daypart.Temperature),
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
DominantCondition: 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)
}
if daypart.PeakWindGust != nil {
value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value)
value.MaxWindGustTime = clockLabel(daypart.PeakWindGust.Time, timezone)
}
return value
}
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(), "_")
}

View File

@@ -0,0 +1,342 @@
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].Start != "8 AM" || rainy.PrecipitationWindows[0].End != "9 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 {
t.Fatalf("first precipitation window = %#v, want 8-9 AM at 60%%", rainy.PrecipitationWindows[0])
}
if rainy.PrecipitationWindows[1].Start != "12 PM" || rainy.PrecipitationWindows[1].End != "2 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 {
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") {
t.Fatalf("precip timing json = %s, want threshold and windows", 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 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.Date != "2026-05-29" || morning.Period != "2026-05-29 at 6:00 AM to 2026-05-29 at 12:00 PM" {
t.Fatalf("morning period = %q/%q, want friendly local date and period labels", morning.Date, morning.Period)
}
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)
}
overnight := value["overnight"]
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", "period", "temp_range_f", "max_pop_percent", "max_wind_gust_mph", "dominant_condition"} {
if !strings.Contains(jsonText, field) {
t.Fatalf("daypart json = %s, want field %s", jsonText, field)
}
}
if strings.Contains(jsonText, `"period":{"start"`) || strings.Contains(jsonText, `T06:00:00`) {
t.Fatalf("daypart json = %s, want friendly period label instead of raw timestamps", jsonText)
}
}
func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyTomorrow)
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)
}
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 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
}

View File

@@ -0,0 +1,123 @@
package briefing
import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
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 {
StartTime string `json:"start_time,omitempty"`
EndTime string `json:"end_time,omitempty"`
Name string `json:"name,omitempty"`
IsDay *bool `json:"is_day,omitempty"`
ConditionCode *int `json:"condition_code,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"`
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"`
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 {
out := make([]HourlyForecastPeriod, 0, len(periods))
for _, period := range periods {
out = append(out, HourlyForecastPeriod{
StartTime: friendlyDateTimeLabel(period.StartTime, timezone),
EndTime: friendlyDateTimeLabel(period.EndTime, timezone),
Name: period.Name,
IsDay: copyBool(period.IsDay),
ConditionCode: copyInt(period.ConditionCode),
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),
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),
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 (v HourlyForecastModule) isEmpty() bool {
return v.Product == "" &&
v.IssuedAt.IsZero() &&
v.UpdatedAt == nil &&
v.SourceLocation == "" &&
v.SourceLocationID == "" &&
len(v.Periods) == 0
}

View 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
}

View File

@@ -0,0 +1,128 @@
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 timedClockLabel(value *forecast.TimedValue, timezone string) string {
if value == nil {
return ""
}
return clockLabel(value.Time, timezone)
}
func periodClockLabel(period timeutil.Period, timezone string) string {
if !period.IsValid() {
return ""
}
return clockLabel(period.Start, timezone) + "-" + clockLabel(period.End, timezone)
}
func friendlyPeriodLabel(period timeutil.Period, timezone string) string {
if !period.IsValid() {
return ""
}
return friendlyDateTimeLabel(period.Start, timezone) + " to " + 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
}

View File

@@ -0,0 +1,29 @@
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)
}
})
}
}

View File

@@ -0,0 +1,363 @@
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.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
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.DailyTomorrow, report.ThreeDay, report.Weekend, report.Storm}
daypartReports := []report.ID{report.DailyToday, report.DailyTomorrow, 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.DailyTomorrow},
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.DailyTomorrow},
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.DailyTomorrow},
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.RequiresDerivedDaypartSummaries},
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.AreaForecastDiscussion,
StanzaName: "area_forecast_discussion",
DefaultOptions: module.AreaForecastDiscussionOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedDiscussion},
SupportedReports: allReports,
MissingData: module.MissingDataOmit,
Builder: buildAreaForecastDiscussionModule,
},
{
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.DailyTomorrow},
MissingData: module.MissingDataEmpty,
Builder: buildTomorrowPlanningModule,
},
}
}

View File

@@ -0,0 +1,133 @@
package briefing
import (
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
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 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 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 noopModuleBuilder(ModuleContext, any) (*module.Output, error) {
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil
}

View File

@@ -0,0 +1,91 @@
package briefing
import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"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"`
StartTime string `json:"start_time,omitempty"`
EndTime string `json:"end_time,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 {
out = append(out, NarrativeForecastPeriod{
Name: period.Name,
StartTime: friendlyDateTimeLabel(period.StartTime, timezone),
EndTime: friendlyDateTimeLabel(period.EndTime, 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
}

View 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"`
Start string `json:"start"`
End string `json:"end"`
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),
Worst: outdoorWindowValue(windows.Worst),
}
return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil
}
func outdoorWindowValue(window *OutdoorWindow) *OutdoorWindowModule {
if window == nil {
return nil
}
return &OutdoorWindowModule{
Daypart: window.Daypart,
Start: window.Start,
End: window.End,
Reasons: append([]string(nil), window.Reasons...),
Score: window.Score,
}
}

View File

@@ -1,30 +1,15 @@
// Package briefing builds report-specific structured briefing packages.
// Package briefing builds prompt-facing module values.
package briefing
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"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"
)
const SchemaVersion = "weatherreporter.briefing.v1"
type Package struct {
Metadata Metadata `json:"metadata"`
Daily *Daily `json:"daily,omitempty"`
ThreeDay *ThreeDay `json:"threeDay,omitempty"`
Weekend *Weekend `json:"weekend,omitempty"`
Storm *Storm `json:"storm,omitempty"`
}
type Metadata struct {
SchemaVersion string `json:"schemaVersion"`
RunID string `json:"runId"`
ReportID report.ID `json:"reportId"`
Variant string `json:"variant,omitempty"`
@@ -33,10 +18,19 @@ type Metadata struct {
Units string `json:"units"`
Timezone string `json:"timezone"`
ValidPeriod timeutil.Period `json:"validPeriod"`
Location *LocationContext `json:"location,omitempty"`
SourceLocationID string `json:"sourceLocationId,omitempty"`
SourceLocation string `json:"sourceLocation,omitempty"`
Sources []SourceMetadata `json:"sources,omitempty"`
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"`
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
Alerts *AlertStatus `json:"alerts,omitempty"`
}
type LocationContext struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Region string `json:"region,omitempty"`
Timezone string `json:"timezone,omitempty"`
}
type SourceMetadata struct {
@@ -47,21 +41,28 @@ type SourceMetadata struct {
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
DataSHA256 string `json:"dataSha256,omitempty"`
Missing bool `json:"missing,omitempty"`
Warnings []forecast.SourceWarning `json:"warnings,omitempty"`
Warnings []weatherdata.SourceWarning `json:"warnings,omitempty"`
}
type AlertStatus struct {
Checked bool `json:"checked"`
ActiveCount int `json:"activeCount"`
RelevantCount int `json:"relevantCount"`
Missing bool `json:"missing,omitempty"`
}
type BuildContext struct {
Resolved report.Resolved
Bundle *forecast.Bundle
Bundle *weatherdata.Bundle
Units string
Timezone string
Location *LocationContext
}
func BuildMetadata(ctx BuildContext) Metadata {
metadata := ctx.Resolved.Metadata()
sourceLocationID, sourceLocation := sourceLocation(ctx.Bundle)
return Metadata{
SchemaVersion: SchemaVersion,
RunID: metadata.RunID,
ReportID: metadata.ReportID,
Variant: variantForReport(metadata.ReportID),
@@ -70,46 +71,60 @@ func BuildMetadata(ctx BuildContext) Metadata {
Units: ctx.Units,
Timezone: ctx.Timezone,
ValidPeriod: metadata.ValidPeriod,
Location: copyLocation(ctx.Location),
SourceLocationID: sourceLocationID,
SourceLocation: sourceLocation,
Sources: sourceMetadata(ctx.Bundle),
SourceWarnings: sourceWarnings(ctx.Bundle),
Alerts: alertStatus(ctx.Bundle),
}
}
func Save(path string, pkg Package) error {
data, err := json.MarshalIndent(pkg, "", " ")
if err != nil {
return fmt.Errorf("marshal briefing package: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create briefing directory %q: %w", filepath.Dir(path), err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary briefing file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary briefing file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary briefing file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("save briefing %q: %w", path, err)
}
func copyLocation(location *LocationContext) *LocationContext {
if location == nil {
return nil
}
copied := *location
return &copied
}
func sourceLocation(bundle *forecast.Bundle) (string, string) {
func copyBool(value *bool) *bool {
if value == nil {
return nil
}
copied := *value
return &copied
}
func copyInt(value *int) *int {
if value == nil {
return nil
}
copied := *value
return &copied
}
func copyFloat(value *float64) *float64 {
if value == nil {
return nil
}
copied := *value
return &copied
}
func copyTime(value *time.Time) *time.Time {
if value == nil {
return nil
}
copied := *value
return &copied
}
func sourceLocation(bundle *weatherdata.Bundle) (string, string) {
if bundle == nil {
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 {
continue
}
@@ -120,7 +135,7 @@ func sourceLocation(bundle *forecast.Bundle) (string, string) {
return "", ""
}
func sourceMetadata(bundle *forecast.Bundle) []SourceMetadata {
func sourceMetadata(bundle *weatherdata.Bundle) []SourceMetadata {
if bundle == nil {
return nil
}
@@ -140,13 +155,34 @@ func sourceMetadata(bundle *forecast.Bundle) []SourceMetadata {
return out
}
func sourceWarnings(bundle *forecast.Bundle) []forecast.SourceWarning {
func sourceWarnings(bundle *weatherdata.Bundle) []weatherdata.SourceWarning {
if bundle == nil {
return nil
}
return bundle.Warnings
}
func alertStatus(bundle *weatherdata.Bundle) *AlertStatus {
if bundle == nil {
return nil
}
status := &AlertStatus{}
if bundle.Alerts != nil {
status.Checked = true
status.ActiveCount = len(bundle.Alerts.Alerts)
}
for _, source := range bundle.Sources {
if source.Name == "alerts" && source.Missing {
status.Missing = true
break
}
}
if !status.Checked && !status.Missing {
return nil
}
return status
}
func variantForReport(id report.ID) string {
switch id {
case report.DailyToday:

View File

@@ -0,0 +1,49 @@
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 {
Start string `json:"start"`
End string `json:"end,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,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{
Start: clockLabel(window.Start, timezone),
}
if window.End != nil {
item.End = clockLabel(*window.End, timezone)
}
item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value)
item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone)
value.PrecipitationWindows = append(value.PrecipitationWindows, item)
}
return value
}

View File

@@ -1,200 +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),
}
return Package{
Metadata: BuildMetadata(ctx),
Storm: storm,
}, 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.Thunder {
items = appendUnique(items, "Thunderstorm timing or intensity could be more disruptive than the baseline forecast.")
}
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.Narrative != "" {
items = appendUnique(items, "Short-term discussion is available for confidence context.")
}
}
if bundle.WeatherStory != nil && len(bundle.WeatherStory.Raw) > 0 {
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...)
}

View File

@@ -1,147 +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."}},
WeatherStory: &forecast.WeatherStory{Raw: json.RawMessage(`{"headline":"Storm risk"}`)},
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 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)
}
}

View File

@@ -7,109 +7,25 @@ import (
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
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 {
Best *OutdoorWindow `json:"best,omitempty"`
Worst *OutdoorWindow `json:"worst,omitempty"`
Best *OutdoorWindow
Worst *OutdoorWindow
}
type OutdoorWindow struct {
Daypart string `json:"daypart"`
Start string `json:"start"`
End string `json:"end"`
Reasons []string `json:"reasons,omitempty"`
Score float64 `json:"score"`
Daypart string
Start string
End string
Reasons []string
Score float64
}
type TomorrowPlanning struct {
MorningReadiness []string `json:"morningReadiness,omitempty"`
CommuteSchoolWorkdayConcerns []string `json:"commuteSchoolWorkdayConcerns,omitempty"`
OvernightChangeWatch []string `json:"overnightChangeWatch,omitempty"`
}
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"`
Summary string `json:"summary,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 := Package{
Metadata: BuildMetadata(ctx),
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,
},
}
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
MorningReadiness []string
CommuteSchoolWorkdayConcerns []string
OvernightChangeWatch []string
}
func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
@@ -176,9 +92,6 @@ func readinessNotes(daypart forecast.DaypartSummary) []string {
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
notes = append(notes, fmt.Sprintf("Morning gusts may reach %.0f mph.", daypart.PeakWindGust.Value))
}
if daypart.Indicators.Thunder {
notes = append(notes, "Morning thunder could affect departure timing.")
}
if daypart.Indicators.Snow || daypart.Indicators.Ice {
notes = append(notes, "Morning wintry weather could affect surfaces and travel.")
}
@@ -203,9 +116,6 @@ func concernNotes(daypart forecast.DaypartSummary) []string {
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", prefix, daypart.PeakWindGust.Value))
}
if daypart.Indicators.Thunder {
notes = append(notes, prefix+" thunder may disrupt outdoor plans.")
}
if daypart.Indicators.Snow || daypart.Indicators.Ice {
notes = append(notes, prefix+" wintry weather may affect travel.")
}
@@ -229,9 +139,6 @@ func overnightWatchNotes(daypart forecast.DaypartSummary) []string {
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
notes = append(notes, fmt.Sprintf("Overnight gusts may reach %.0f mph before morning plans begin.", daypart.PeakWindGust.Value))
}
if daypart.Indicators.Thunder {
notes = append(notes, "Overnight storms could change morning impacts.")
}
if daypart.Indicators.Snow || daypart.Indicators.Ice {
notes = append(notes, "Overnight wintry weather could leave morning travel impacts.")
}
@@ -250,30 +157,6 @@ func daypartNamed(dayparts []forecast.DaypartSummary, name string) *forecast.Day
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.Narrative
}
if discussion.LongTerm != nil {
ctx.LongTerm = discussion.LongTerm.Narrative
}
return ctx
}
func buildWeatherStory(bundle *forecast.Bundle) *WeatherStoryContext {
if bundle == nil || bundle.WeatherStory == nil || len(bundle.WeatherStory.Raw) == 0 {
return nil
}
return &WeatherStoryContext{Available: true, Summary: string(bundle.WeatherStory.Raw)}
}
func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
score := 0.0
reasons := []string{}
@@ -293,10 +176,6 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
score += float64(len(daypart.AlertOverlaps)) * 100
reasons = append(reasons, "alert overlap")
}
if daypart.Indicators.Thunder {
score += 75
reasons = append(reasons, "thunder risk")
}
if daypart.Indicators.Heat || daypart.Indicators.Cold {
score += 25
if daypart.Indicators.Heat {
@@ -318,25 +197,8 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
}
}
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 {
var hazards []string
if indicators.Thunder {
hazards = append(hazards, "thunder")
}
if indicators.Snow {
hazards = append(hazards, "snow")
}

View File

@@ -1,129 +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 := Package{
Metadata: BuildMetadata(ctx),
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)
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, ". ") + "."
}

View File

@@ -1,83 +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{Thunder: true, Wind: true},
},
},
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Unsettled stretch."}},
},
{
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, ","), "thunder") {
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))
}
}

View 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
}

View File

@@ -0,0 +1,42 @@
package briefing
import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
)
type WeatherStoryModule struct {
Available bool `json:"available"`
OfficeID string `json:"office_id,omitempty"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
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
}
value := WeatherStoryModule{
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,
}
return &module.Output{ID: module.WeatherStory, StanzaName: "weather_story", Value: value}, nil
}

View File

@@ -1,125 +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 := Package{
Metadata: BuildMetadata(ctx),
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)
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))
}
if daypart.Indicators.Thunder {
notes = append(notes, label+" thunder risk is present.")
}
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)
}

View File

@@ -1,88 +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{Thunder: true, 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."}},
},
}
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, " "), "thunder") {
t.Fatalf("RainStormTiming = %#v, want thunder timing", pkg.Weekend.Planning.RainStormTiming)
}
if len(pkg.Weekend.Planning.UncertaintyInputs) == 0 {
t.Fatal("UncertaintyInputs length = 0, want discussion context")
}
}

View File

@@ -1,14 +1,15 @@
// Package changes compares structured briefing snapshots.
// Package changes compares structured module snapshots.
package changes
import (
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
)
type Thresholds struct {
@@ -25,83 +26,169 @@ type Change struct {
Current string `json:"current,omitempty"`
}
func CompareDaily(previous briefing.Package, current briefing.Package, thresholds Thresholds) ([]Change, error) {
if previous.Daily == nil {
return nil, fmt.Errorf("previous daily briefing is required")
func CompareDaily(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) {
previousSummary, err := requiredStanza[dailySummaryStanza](previous, "derived_daily_summary")
if err != nil {
return nil, fmt.Errorf("previous daily summary: %w", err)
}
if current.Daily == nil {
return nil, fmt.Errorf("current daily briefing is required")
currentSummary, err := requiredStanza[dailySummaryStanza](current, "derived_daily_summary")
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
changes = append(changes, compareTemperature(previous.Daily.BottomLine.Temperature, current.Daily.BottomLine.Temperature, thresholds.TemperatureDegrees)...)
changes = append(changes, comparePrecipitation(previous.Daily.BottomLine.MaxPrecipProbability, current.Daily.BottomLine.MaxPrecipProbability, thresholds)...)
changes = append(changes, compareWind(previous.Daily.BottomLine.PeakWindGust, current.Daily.BottomLine.PeakWindGust, float64(thresholds.WindGustMilesPerHour))...)
changes = append(changes, compareAlerts(previous.Daily.RelevantAlerts, current.Daily.RelevantAlerts)...)
changes = append(changes, compareIndicators(aggregateIndicators(previous.Daily.Dayparts), aggregateIndicators(current.Daily.Dayparts))...)
changes = append(changes, compareTemperatureValues("Low", previousSummary.LowTempF, currentSummary.LowTempF, thresholds.TemperatureDegrees)...)
changes = append(changes, compareTemperatureValues("High", previousSummary.HighTempF, currentSummary.HighTempF, thresholds.TemperatureDegrees)...)
changes = append(changes, comparePrecipitationValues(previousSummary.DailyPrecipitationProbability, currentSummary.DailyPrecipitationProbability, thresholds.PrecipProbabilityPoints, "")...)
if previousHasTiming && currentHasTiming {
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
}
func compareTemperature(previous forecast.Range, current forecast.Range, threshold float64) []Change {
var changes []Change
if previous.Min != nil && current.Min != nil && differenceAtLeast(*previous.Min, *current.Min, threshold) {
changes = append(changes, Change{
Type: "temperature_shift",
Message: fmt.Sprintf("Low temperature changed from %.0f to %.0f.", *previous.Min, *current.Min),
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
type dailySummaryStanza 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"`
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
}
func comparePrecipitation(previous *forecast.TimedValue, current *forecast.TimedValue, thresholds Thresholds) []Change {
type daypartSummaryStanza struct {
Date string `json:"date,omitempty"`
Period string `json:"period,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 {
return nil
}
var changes []Change
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) {
if !differenceAtLeast(float64(*previous), float64(*current), threshold) {
return nil
}
return []Change{{
Type: "wind_gust_change",
Message: fmt.Sprintf("Peak wind gust changed from %.0f mph to %.0f mph.", previous.Value, current.Value),
Previous: fmt.Sprintf("%.0f mph", previous.Value),
Current: fmt.Sprintf("%.0f mph", current.Value),
Type: "temperature_shift",
Message: fmt.Sprintf("%s temperature changed from %d to %d.", label, *previous, *current),
Previous: fmt.Sprintf("%d", *previous),
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)
currentSet := alertSet(current)
var changes []Change
@@ -119,21 +206,20 @@ func compareAlerts(previous []forecast.AlertOverlap, current []forecast.AlertOve
return changes
}
func compareIndicators(previous forecast.Indicators, current forecast.Indicators) []Change {
func compareIndicators(previous indicators, current indicators, prefix string) []Change {
var changes []Change
for _, item := range []struct {
name string
previous bool
current bool
}{
{name: "thunder", previous: previous.Thunder, current: current.Thunder},
{name: "snow", previous: previous.Snow, current: current.Snow},
{name: "ice", previous: previous.Ice, current: current.Ice},
} {
if item.previous == item.current {
continue
}
changeType := item.name + "_risk_change"
changeType := prefix + item.name + "_risk_change"
if item.current {
changes = append(changes, Change{Type: changeType, Message: fmt.Sprintf("%s risk is now present.", item.name), Current: "present"})
} else {
@@ -143,17 +229,16 @@ func compareIndicators(previous forecast.Indicators, current forecast.Indicators
return changes
}
func aggregateIndicators(dayparts []forecast.DaypartSummary) forecast.Indicators {
out := forecast.Indicators{}
func aggregateIndicators(dayparts map[string]daypartSummaryStanza) indicators {
out := indicators{}
for _, daypart := range dayparts {
out.Thunder = out.Thunder || daypart.Indicators.Thunder
out.Snow = out.Snow || daypart.Indicators.Snow
out.Ice = out.Ice || daypart.Indicators.Ice
out.Snow = out.Snow || daypart.Snow
out.Ice = out.Ice || daypart.Ice
}
return out
}
func alertSet(alerts []forecast.AlertOverlap) map[string]struct{} {
func alertSet(alerts []alertSummaryStanza) map[string]struct{} {
out := map[string]struct{}{}
for _, alert := range alerts {
event := alert.Event
@@ -187,10 +272,6 @@ func differenceAtLeast(previous float64, current float64, threshold float64) boo
return math.Abs(current-previous) >= threshold
}
func clock(t time.Time) string {
return t.Format("15:04")
}
func sortChanges(items []Change) {
sort.SliceStable(items, func(i, j int) bool {
if items[i].Type == items[j].Type {
@@ -199,3 +280,33 @@ func sortChanges(items []Change) {
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
}

View File

@@ -1,16 +1,15 @@
package changes
import (
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
)
func TestCompareDailyNoMeaningfulChanges(t *testing.T) {
previous := dailyBriefing(60, 70, 30, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
current := dailyBriefing(61, 71, 35, at("2026-05-29T08:30:00Z"), nil, forecast.Indicators{})
previous := dailySnapshot(t, 60, 70, 30, "8 AM", nil, false)
current := dailySnapshot(t, 61, 71, 35, "8:30 AM", nil, false)
changes, err := CompareDaily(previous, current, testThresholds())
if err != nil {
@@ -22,8 +21,8 @@ func TestCompareDailyNoMeaningfulChanges(t *testing.T) {
}
func TestCompareDailyTemperatureThreshold(t *testing.T) {
previous := dailyBriefing(50, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
current := dailyBriefing(58, 79, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
previous := dailySnapshot(t, 50, 70, 10, "8 AM", nil, false)
current := dailySnapshot(t, 58, 79, 10, "8 AM", nil, false)
changes, err := CompareDaily(previous, current, testThresholds())
if err != nil {
@@ -35,8 +34,8 @@ func TestCompareDailyTemperatureThreshold(t *testing.T) {
}
func TestCompareDailyPrecipTimingShift(t *testing.T) {
previous := dailyBriefing(60, 70, 60, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
current := dailyBriefing(60, 70, 60, at("2026-05-29T11:00:00Z"), nil, forecast.Indicators{})
previous := dailySnapshot(t, 60, 70, 60, "8 AM", nil, false)
current := dailySnapshot(t, 60, 70, 60, "11 AM", nil, false)
changes, err := CompareDaily(previous, current, testThresholds())
if err != nil {
@@ -48,8 +47,8 @@ func TestCompareDailyPrecipTimingShift(t *testing.T) {
}
func TestCompareDailyAlertAddedAndRemoved(t *testing.T) {
previous := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), []string{"Wind Advisory"}, forecast.Indicators{})
current := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), []string{"Flood Watch"}, forecast.Indicators{})
previous := dailySnapshot(t, 60, 70, 10, "8 AM", []string{"Wind Advisory"}, false)
current := dailySnapshot(t, 60, 70, 10, "8 AM", []string{"Flood Watch"}, false)
changes, err := CompareDaily(previous, current, testThresholds())
if err != nil {
@@ -61,38 +60,56 @@ func TestCompareDailyAlertAddedAndRemoved(t *testing.T) {
}
func TestCompareDailyIndicatorChange(t *testing.T) {
previous := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
current := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{Thunder: true})
previous := dailySnapshot(t, 60, 70, 10, "8 AM", nil, false)
current := dailySnapshot(t, 60, 70, 10, "8 AM", nil, true)
changes, err := CompareDaily(previous, current, testThresholds())
if err != nil {
t.Fatalf("CompareDaily() error = %v", err)
}
if countType(changes, "thunder_risk_change") != 1 {
t.Fatalf("changes = %#v, want thunder risk change", changes)
if countType(changes, "snow_risk_change") != 1 {
t.Fatalf("changes = %#v, want snow risk change", changes)
}
}
func dailyBriefing(low float64, high float64, precip float64, precipTime time.Time, alerts []string, indicators forecast.Indicators) briefing.Package {
alertOverlaps := make([]forecast.AlertOverlap, 0, len(alerts))
func TestCompareDailyRequiresComparisonStanzas(t *testing.T) {
_, 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 {
alertOverlaps = append(alertOverlaps, forecast.AlertOverlap{Event: alert})
relevant = append(relevant, alertSummaryStanza{Event: alert})
}
return briefing.Package{
Daily: &briefing.Daily{
BottomLine: briefing.BottomLine{
Temperature: forecast.Range{Min: &low, Max: &high},
MaxPrecipProbability: &forecast.TimedValue{
Value: precip,
Time: precipTime,
},
},
RelevantAlerts: alertOverlaps,
Dayparts: []forecast.DaypartSummary{
{Name: "morning", Indicators: indicators},
},
},
return snapshot(t,
module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: dailySummaryStanza{
Date: "2026-05-29",
HighTempF: &high,
LowTempF: &low,
DailyPrecipitationProbability: &precip,
}},
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
"morning": {Date: "2026-05-29", Period: "2026-05-29 at 6:00 AM to 2026-05-29 at 10:00 AM", TempRangeF: "60-70", Snow: snow},
}},
module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: precipTimingStanza{MaxPopPercent: &precip, MaxPopTime: precipTime}},
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: alertDigestStanza{Relevant: relevant}},
)
}
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 {
@@ -113,11 +130,3 @@ func countType(changes []Change, changeType string) int {
}
return count
}
func at(value string) time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
panic(err)
}
return parsed
}

View File

@@ -2,77 +2,143 @@ package changes
import (
"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) {
if previous.ThreeDay == nil {
return nil, fmt.Errorf("previous 3-day briefing is required")
func CompareThreeDay(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) {
previousDayparts, err := requiredStanza[map[string]daypartSummaryStanza](previous, "derived_daypart_summaries")
if err != nil {
return nil, fmt.Errorf("previous daypart summaries: %w", err)
}
if current.ThreeDay == nil {
return nil, fmt.Errorf("current 3-day briefing is required")
currentDayparts, err := requiredStanza[map[string]daypartSummaryStanza](current, "derived_daypart_summaries")
if err != nil {
return nil, fmt.Errorf("current daypart summaries: %w", err)
}
previousDays := outlookDaysByDate(previous.ThreeDay.Days)
currentDays := outlookDaysByDate(current.ThreeDay.Days)
previousDays := outlookDaysFromDayparts(previousDayparts)
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
for date, currentDay := range currentDays {
previousDay, ok := previousDays[date]
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
}
changes = append(changes, compareOutlookDay(date, previousDay, currentDay, thresholds)...)
changes = append(changes, compareOutlookDay(date, previousDay, currentDay, thresholds, prefix)...)
}
for date := range previousDays {
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)
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
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
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
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.Type = "outlook_" + change.Type
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.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)
}
return changes
}
func outlookDaysByDate(days []briefing.OutlookDay) map[string]briefing.OutlookDay {
out := map[string]briefing.OutlookDay{}
for _, day := range days {
date := day.Date
if date == "" && !day.Period.Start.IsZero() {
date = day.Period.Start.Format(time.DateOnly)
func outlookDaysFromDayparts(dayparts map[string]daypartSummaryStanza) map[string]outlookDay {
out := map[string]outlookDay{}
var keys []string
for key := range dayparts {
keys = append(keys, key)
}
if date != "" {
sort.Strings(keys)
for _, key := range keys {
daypart := dayparts[key]
date := daypartDate(daypart)
if date == "" {
continue
}
day := out[date]
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
}
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
}

View File

@@ -2,41 +2,13 @@ package changes
import (
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
)
func TestCompareThreeDayDetectsDayChanges(t *testing.T) {
previousTemp := 70.0
currentTemp := 78.0
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: &currentTemp},
MaxPrecipitationProbability: &forecast.TimedValue{
Value: currentPrecip,
Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC),
},
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Thunder: true}}},
}}},
}
previous := outlookSnapshot(t, "2026-05-29", "70", 20, "9 AM", false)
current := outlookSnapshot(t, "2026-05-29", "78", 70, "12 PM", true)
changes, err := CompareThreeDay(previous, current, Thresholds{
TemperatureDegrees: 5,
@@ -50,17 +22,21 @@ func TestCompareThreeDayDetectsDayChanges(t *testing.T) {
if len(changes) == 0 {
t.Fatal("changes length = 0, want detected 3-day changes")
}
var foundPrecip bool
var foundThunder bool
for _, change := range changes {
if change.Type == "outlook_precip_probability_change" {
foundPrecip = true
}
if change.Type == "outlook_thunder_risk_change" {
foundThunder = true
}
}
if !foundPrecip || !foundThunder {
t.Fatalf("changes = %#v, want precipitation and thunder changes", changes)
if countType(changes, "outlook_precip_probability_change") == 0 || countType(changes, "outlook_snow_risk_change") == 0 {
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,
Period: date + " at 6:00 AM to " + date + " at 10:00 AM",
TempRangeF: tempRange,
MaxPopPercent: &precip,
MaxPopTime: precipTime,
Snow: snow,
},
}})
}

View File

@@ -3,24 +3,17 @@ package changes
import (
"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) {
if previous.Weekend == nil {
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)
func CompareWeekend(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) {
previousDayparts, err := requiredStanza[map[string]daypartSummaryStanza](previous, "derived_daypart_summaries")
if err != nil {
return nil, err
return nil, fmt.Errorf("previous weekend daypart summaries: %w", err)
}
for i := range changes {
changes[i].Type = "weekend_" + changes[i].Type
currentDayparts, err := requiredStanza[map[string]daypartSummaryStanza](current, "derived_daypart_summaries")
if err != nil {
return nil, fmt.Errorf("current weekend daypart summaries: %w", err)
}
return changes, nil
return compareOutlookDays(outlookDaysFromDayparts(previousDayparts), outlookDaysFromDayparts(currentDayparts), thresholds, "weekend_")
}

View File

@@ -1,31 +1,10 @@
package changes
import (
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
import "testing"
func TestCompareWeekendDetectsOutlookChanges(t *testing.T) {
previousTemp := 70.0
currentTemp := 78.0
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: &currentTemp},
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Thunder: true}}},
}}},
}
previous := outlookSnapshot(t, "2026-05-30", "70", 10, "9 AM", false)
current := outlookSnapshot(t, "2026-05-30", "78", 10, "9 AM", true)
changes, err := CompareWeekend(previous, current, Thresholds{TemperatureDegrees: 5})
if err != nil {
@@ -34,10 +13,7 @@ func TestCompareWeekendDetectsOutlookChanges(t *testing.T) {
if len(changes) == 0 {
t.Fatal("changes length = 0, want weekend changes")
}
for _, change := range changes {
if change.Type == "weekend_outlook_thunder_risk_change" {
return
if countType(changes, "weekend_outlook_snow_risk_change") == 0 {
t.Fatalf("changes = %#v, want snow risk change", changes)
}
}
t.Fatalf("changes = %#v, want thunder risk change", changes)
}

View File

@@ -9,6 +9,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
@@ -25,7 +26,7 @@ Usage:
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
weatherreporter inspect reports [--config PATH] [--limit N]
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 prior [--config PATH] RUN_ID
weatherreporter inspect sources [--config PATH] RUN_ID
@@ -72,7 +73,7 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr
result, err := app.RunBatchDetailed(ctx, req)
if result != nil {
writeRunLogs(stderr, result)
if encodeErr := writeRunSummary(stdout, result); encodeErr != nil {
if encodeErr := writeJSON(stdout, result); encodeErr != nil {
return encodeErr
}
if result.Failed > 0 {
@@ -108,6 +109,29 @@ type inspectOptions struct {
RunID string
}
type inspectRunCommand struct {
Name string
Inspect func(context.Context, app.InspectRunRequest) (any, error)
}
var inspectRunCommands = []inspectRunCommand{
{Name: "metadata", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
return app.InspectMetadata(ctx, req)
}},
{Name: "modules", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
return app.InspectModules(ctx, req)
}},
{Name: "data-package", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
return app.InspectDataPackage(ctx, req)
}},
{Name: "prior", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
return app.InspectPriorSnapshot(ctx, req)
}},
{Name: "sources", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
return app.InspectSources(ctx, req)
}},
}
func (r Runner) runInspect(ctx context.Context, args []string, stdout io.Writer) error {
if len(args) == 0 {
return fmt.Errorf("inspect requires a command")
@@ -128,81 +152,32 @@ func (r Runner) runInspect(ctx context.Context, args []string, stdout io.Writer)
return err
}
return writeJSON(stdout, records)
case "metadata":
opts, err := parseInspectRunFlags(command, args[1:])
if err != nil {
return err
}
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
if err != nil {
return err
}
metadata, err := app.InspectMetadata(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
if err != nil {
return err
}
return writeJSON(stdout, metadata)
case "briefing":
opts, err := parseInspectRunFlags(command, args[1:])
if err != nil {
return err
}
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
if err != nil {
return err
}
pkg, err := app.InspectBriefing(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
if err != nil {
return err
}
return writeJSON(stdout, pkg)
case "data-package":
opts, err := parseInspectRunFlags(command, args[1:])
if err != nil {
return err
}
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
if err != nil {
return err
}
pkg, err := app.InspectDataPackage(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
if err != nil {
return err
}
return writeJSON(stdout, pkg)
case "prior":
opts, err := parseInspectRunFlags(command, args[1:])
if err != nil {
return err
}
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
if err != nil {
return err
}
prior, err := app.InspectPriorSnapshot(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
if err != nil {
return err
}
return writeJSON(stdout, prior)
case "sources":
opts, err := parseInspectRunFlags(command, args[1:])
if err != nil {
return err
}
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
if err != nil {
return err
}
sources, err := app.InspectSources(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
if err != nil {
return err
}
return writeJSON(stdout, sources)
default:
for _, candidate := range inspectRunCommands {
if candidate.Name == command {
return runInspectRunCommand(ctx, stdout, candidate, args[1:])
}
}
return fmt.Errorf("unknown inspect command %q", command)
}
}
func runInspectRunCommand(ctx context.Context, stdout io.Writer, command inspectRunCommand, args []string) error {
opts, err := parseInspectRunFlags(command.Name, args)
if err != nil {
return err
}
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
if err != nil {
return err
}
value, err := command.Inspect(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
if err != nil {
return err
}
return writeJSON(stdout, value)
}
func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
if r.Clock == nil {
r.Clock = timeutil.SystemClock{}
@@ -210,12 +185,12 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
if len(args) == 0 {
return app.GenerateRequest{}, fmt.Errorf("generate requires a report name")
}
report, ok := reportKind(args[0])
reportKind, ok := reportKind(args[0])
if !ok {
return app.GenerateRequest{}, fmt.Errorf("unknown generate report %q", args[0])
}
opts, err := parseGenerateFlags(report, args[1:])
opts, err := parseGenerateFlags(reportKind, args[1:])
if err != nil {
return app.GenerateRequest{}, err
}
@@ -223,7 +198,6 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
Path: opts.ConfigPath,
Units: opts.Units,
Timezone: opts.Timezone,
Output: opts.Output,
})
if err != nil {
return app.GenerateRequest{}, err
@@ -235,12 +209,12 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
req := app.GenerateRequest{
Config: cfg,
Report: report,
Report: reportKind,
OutputPath: opts.Output,
Now: r.Clock.Now(),
}
switch report {
switch reportKind {
case app.ReportDaily:
if opts.Date == "" {
req.Date = timeutil.LocalDate(r.Clock.Now(), location)
@@ -257,17 +231,12 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
if opts.End == "" {
return app.GenerateRequest{}, fmt.Errorf("generate storm requires --end")
}
req.StormStart, err = timeutil.ParseStormTime(opts.Start, location)
period, err := report.ParseStormPeriod(opts.Start, opts.End, location)
if err != nil {
return app.GenerateRequest{}, err
}
req.StormEnd, err = timeutil.ParseStormTime(opts.End, location)
if err != nil {
return app.GenerateRequest{}, err
}
if !req.StormEnd.After(req.StormStart) {
return app.GenerateRequest{}, fmt.Errorf("generate storm requires --end after --start")
}
req.StormStart = period.Start
req.StormEnd = period.End
}
return req, nil
@@ -372,12 +341,6 @@ func parseInspectRunFlags(command string, args []string) (inspectOptions, error)
return opts, nil
}
func writeRunSummary(stdout io.Writer, result *app.BatchResult) error {
encoder := json.NewEncoder(stdout)
encoder.SetIndent("", " ")
return encoder.Encode(result)
}
func writeJSON(stdout io.Writer, value any) error {
encoder := json.NewEncoder(stdout)
encoder.SetIndent("", " ")
@@ -389,11 +352,21 @@ func writeRunLogs(stderr io.Writer, result *app.BatchResult) {
return
}
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" {
_, _ = 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
}
_, _ = 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)
}

View File

@@ -28,6 +28,10 @@ func TestRunHelpLongFlag(t *testing.T) {
if !strings.Contains(stdout.String(), "generate daily") {
t.Fatalf("help output missing 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) {
@@ -62,12 +66,8 @@ func TestRunGenerateStormWritesMarkdownReport(t *testing.T) {
server := dailyServer(t)
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 + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
outPath := filepath.Join(tempDir, "storm.md")
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -90,18 +90,12 @@ func TestRunGenerateStormWritesMarkdownReport(t *testing.T) {
if !strings.Contains(string(report), "# Daily Report") {
t.Fatalf("report output missing markdown:\n%s", string(report))
}
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.json"))
if err != nil {
t.Fatalf("glob data package: %v", err)
}
if len(dataPackageMatches) != 1 {
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
}
data, err := os.ReadFile(dataPackageMatches[0])
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.yaml")
data, err := os.ReadFile(dataPackagePath)
if err != nil {
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))
}
}
@@ -110,12 +104,8 @@ func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
server := dailyServer(t)
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 + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
outPath := filepath.Join(tempDir, "tomorrow.md")
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -136,18 +126,12 @@ func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
if !strings.Contains(string(report), "# Daily Report") {
t.Fatalf("report output missing markdown:\n%s", string(report))
}
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.json"))
if err != nil {
t.Fatalf("glob data package: %v", err)
}
if len(dataPackageMatches) != 1 {
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
}
data, err := os.ReadFile(dataPackageMatches[0])
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.yaml")
data, err := os.ReadFile(dataPackagePath)
if err != nil {
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: daily_tomorrow") || !strings.Contains(string(data), "tomorrow_planning:") {
t.Fatalf("data package output missing tomorrow content:\n%s", string(data))
}
reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "daily", "*.md"))
@@ -180,7 +164,7 @@ func TestRunEveningGeneratesTomorrowReport(t *testing.T) {
if err != nil {
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", "daily", "2026-05-30", "*.data_package.yaml"))
if err != nil {
t.Fatalf("glob data package: %v", err)
}
@@ -226,7 +210,7 @@ func TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) {
if !strings.Contains(string(report), "# Daily 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 {
t.Fatalf("glob data package: %v", err)
}
@@ -237,7 +221,7 @@ func TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) {
if err != nil {
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))
}
}
@@ -272,7 +256,7 @@ func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) {
if !strings.Contains(string(report), "# Daily 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 {
t.Fatalf("glob data package: %v", err)
}
@@ -283,7 +267,7 @@ func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) {
if err != nil {
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))
}
}
@@ -309,7 +293,7 @@ func TestRunMorningIncludesWeekendExceptSunday(t *testing.T) {
if err != nil {
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 {
t.Fatalf("glob weekend packages: %v", err)
}
@@ -353,11 +337,11 @@ func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
if !strings.Contains(stderr.String(), "status=failed") || !strings.Contains(stderr.String(), "status=succeeded") {
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 {
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 {
t.Fatalf("glob weekend packages: %v", err)
}
@@ -366,6 +350,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) {
server := dailyServer(t)
tempDir := t.TempDir()
@@ -404,6 +470,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.daily","status":"succeeded","report":{"actions":[{"action":"replace_older"}]}}`))
return
}
if r.URL.Path != "/v1/pipelines/weatherreporter.daily/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.daily" {
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) {
server := dailyServer(t)
tempDir := t.TempDir()
@@ -425,11 +593,11 @@ func TestRunMorningGeneratesDailyAndThreeDayOnSunday(t *testing.T) {
if err != nil {
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 {
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 {
t.Fatalf("glob 3-day packages: %v", err)
}
@@ -457,6 +625,7 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
"generate", "daily",
"--config", configPath,
"--date", "2026-05-29",
"--tz", "UTC",
"--out", outPath,
}, &stdout, &stderr)
if err != nil {
@@ -469,7 +638,7 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
if !strings.Contains(string(report), "# Daily 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 {
t.Fatalf("glob data package: %v", err)
}
@@ -480,9 +649,16 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
if err != nil {
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))
}
if !strings.Contains(string(data), "location:") ||
!strings.Contains(string(data), "id: home") ||
!strings.Contains(string(data), "name: Brentwood") ||
!strings.Contains(string(data), "region: St. Louis Metro") ||
!strings.Contains(string(data), "timezone: UTC") {
t.Fatalf("data package missing configured location with overridden timezone:\n%s", string(data))
}
preflightMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "preflight", "daily", "2026-05-29", "*.render.json"))
if err != nil {
t.Fatalf("glob preflight: %v", err)
@@ -528,14 +704,14 @@ func TestRunInspectGeneratedArtifacts(t *testing.T) {
if err != nil {
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 {
t.Fatalf("glob data package: %v", err)
}
if len(dataPackageMatches) != 1 {
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()
err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", configPath, "--limit", "1"}, &stdout, &stderr)
@@ -546,7 +722,8 @@ func TestRunInspectGeneratedArtifacts(t *testing.T) {
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()
err = runner.Run(context.Background(), []string{"inspect", command, "--config", configPath, runID}, &stdout, &stderr)
if err != nil {
@@ -555,9 +732,12 @@ func TestRunInspectGeneratedArtifacts(t *testing.T) {
if !strings.Contains(stdout.String(), runID) {
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"`) {
t.Fatalf("inspect sources output missing warnings:\n%s", stdout.String())
}
if !strings.Contains(sourcesOutput, `"name": "narrative"`) || strings.Contains(sourcesOutput, `"name": "daily"`) {
t.Fatalf("inspect sources output missing narrative source or has unexpected daily source:\n%s", sourcesOutput)
}
}
@@ -581,6 +761,45 @@ func TestRunInspectMissingMetadata(t *testing.T) {
}
}
func TestRunInspectRunCommandsParseRunIDAndConfig(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yml")
configBody := "workspace:\n root: " + filepath.Join(tempDir, "workspace") + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
runner := Runner{Clock: fixedClock()}
commands := []string{"metadata", "modules", "data-package", "prior", "sources"}
for _, command := range commands {
t.Run(command+" requires run id", func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
err := runner.Run(context.Background(), []string{"inspect", command, "--config", configPath}, &stdout, &stderr)
if err == nil {
t.Fatal("Run() error = nil, want missing run id error")
}
if !strings.Contains(err.Error(), "requires a run id") {
t.Fatalf("error = %q, want missing run id context", err.Error())
}
})
t.Run(command+" accepts config", func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
err := runner.Run(context.Background(), []string{"inspect", command, "--config", configPath, "missing"}, &stdout, &stderr)
if err == nil {
t.Fatal("Run() error = nil, want missing metadata error")
}
if !strings.Contains(err.Error(), "metadata for run id") {
t.Fatalf("error = %q, want missing metadata context", err.Error())
}
})
}
}
func TestResolveGenerateCommands(t *testing.T) {
runner := Runner{Clock: fixedClock()}
tests := []struct {
@@ -643,7 +862,15 @@ func TestResolveGenerateAppliesSharedFlags(t *testing.T) {
func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) {
runner := Runner{Clock: fixedClock()}
_, err := runner.resolveGenerate([]string{"storm", "--start", "2026-05-29T18:00"})
_, err := runner.resolveGenerate([]string{"storm", "--end", "2026-05-29T18:00"})
if err == nil {
t.Fatal("resolveGenerate() error = nil, want missing start error")
}
if !strings.Contains(err.Error(), "requires --start") {
t.Fatalf("error = %q, want missing start", err.Error())
}
_, err = runner.resolveGenerate([]string{"storm", "--start", "2026-05-29T18:00"})
if err == nil {
t.Fatal("resolveGenerate() error = nil, want missing end error")
}
@@ -652,6 +879,26 @@ func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) {
}
}
func TestResolveGenerateStormParsesLocalTimestamps(t *testing.T) {
runner := Runner{Clock: fixedClock()}
req, err := runner.resolveGenerate([]string{
"storm",
"--tz", "America/Chicago",
"--start", "2026-05-29T18:00",
"--end", "2026-05-30T06:00",
})
if err != nil {
t.Fatalf("resolveGenerate() error = %v", err)
}
if got := req.StormStart.Format(time.RFC3339); got != "2026-05-29T18:00:00-05:00" {
t.Fatalf("StormStart = %q, want local Chicago time", got)
}
if got := req.StormEnd.Format(time.RFC3339); got != "2026-05-30T06:00:00-05:00" {
t.Fatalf("StormEnd = %q, want local Chicago time", got)
}
}
func TestResolveGenerateStormParsesRFC3339(t *testing.T) {
runner := Runner{Clock: fixedClock()}
@@ -668,6 +915,22 @@ func TestResolveGenerateStormParsesRFC3339(t *testing.T) {
}
}
func TestResolveGenerateStormRejectsInvalidBounds(t *testing.T) {
runner := Runner{Clock: fixedClock()}
_, err := runner.resolveGenerate([]string{
"storm",
"--start", "2026-05-30T06:00",
"--end", "2026-05-29T18:00",
})
if err == nil {
t.Fatal("resolveGenerate() error = nil, want invalid bounds error")
}
if !strings.Contains(err.Error(), "end time after start time") {
t.Fatalf("error = %q, want invalid bounds context", err.Error())
}
}
func TestResolveRunCommands(t *testing.T) {
tests := []struct {
name string
@@ -731,6 +994,8 @@ func dailyServer(t *testing.T) *httptest.Server {
_, _ = w.Write([]byte(`{"data":{"alerts":[]}}`))
case "/discussion":
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}}`))
case "/weatherstories/latest":
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
default:
http.NotFound(w, r)
}
@@ -739,6 +1004,28 @@ func dailyServer(t *testing.T) *httptest.Server {
return server
}
func writeTestConfig(t *testing.T, server *httptest.Server, scriptoriumPath string, workspaceRoot string) string {
t.Helper()
configPath := filepath.Join(t.TempDir(), "config.yml")
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
return configPath
}
func oneArtifact(t *testing.T, root string, parts ...string) string {
t.Helper()
matches, err := filepath.Glob(filepath.Join(append([]string{root}, parts...)...))
if err != nil {
t.Fatalf("glob artifact: %v", err)
}
if len(matches) != 1 {
t.Fatalf("artifact matches = %#v, want one", matches)
}
return matches[0]
}
func writeFakeScriptorium(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, "scriptorium")

View File

@@ -2,24 +2,36 @@
// precedence, and validation.
package config
import "time"
import (
"fmt"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gopkg.in/yaml.v3"
)
type MissingSourcePolicy string
type NotifyFailurePolicy string
const (
MissingSourceError MissingSourcePolicy = "error"
MissingSourceWarn MissingSourcePolicy = "warn"
MissingSourceNone MissingSourcePolicy = "none"
NotifyFailureError NotifyFailurePolicy = "error"
)
type Config struct {
WeatherAPI WeatherAPIConfig `yaml:"weather_api"`
Location LocationConfig `yaml:"location"`
Secrets SecretsConfig `yaml:"secrets"`
Notify NotifyConfig `yaml:"notify"`
MissingSource MissingSourceConfig `yaml:"missing_source"`
Scriptorium ScriptoriumConfig `yaml:"scriptorium"`
Workspace WorkspaceConfig `yaml:"workspace"`
Reports ReportOutputConfig `yaml:"reports"`
Dayparts []DaypartConfig `yaml:"dayparts"`
RecentChange RecentChangeConfig `yaml:"recent_change"`
Reports map[string]ReportConfig `yaml:"reports"`
}
type WeatherAPIConfig struct {
@@ -31,6 +43,32 @@ type WeatherAPIConfig struct {
Format string `yaml:"format"`
}
type LocationConfig struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
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 {
Default MissingSourcePolicy `yaml:"default"`
Sources map[string]MissingSourcePolicy `yaml:"sources"`
@@ -50,11 +88,7 @@ type WorkspaceConfig struct {
ReportsDir string `yaml:"reports_dir"`
DataPackagesDir string `yaml:"data_packages_dir"`
PreflightDir string `yaml:"preflight_dir"`
}
type ReportOutputConfig struct {
OutputDir string `yaml:"output_dir"`
Paths map[string]string `yaml:"paths"`
NotificationsDir string `yaml:"notifications_dir"`
}
type DaypartConfig struct {
@@ -69,3 +103,71 @@ type RecentChangeConfig struct {
WindGustMilesPerHour int `yaml:"wind_gust_miles_per_hour"`
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")
}
}

View File

@@ -6,6 +6,9 @@ import (
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
func TestDefaults(t *testing.T) {
@@ -17,12 +20,48 @@ func TestDefaults(t *testing.T) {
if cfg.WeatherAPI.Units != "us" {
t.Fatalf("Units = %q, want us", cfg.WeatherAPI.Units)
}
if cfg.WeatherAPI.Timezone != "Chicago" {
t.Fatalf("Timezone = %q, want Chicago", cfg.WeatherAPI.Timezone)
if cfg.WeatherAPI.Timezone != "America/Chicago" {
t.Fatalf("Timezone = %q, want America/Chicago", cfg.WeatherAPI.Timezone)
}
if cfg.WeatherAPI.Format != "json" {
t.Fatalf("Format = %q, want json", cfg.WeatherAPI.Format)
}
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)
}
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 {
t.Fatalf("MissingSource.Default = %q, want warn", cfg.MissingSource.Default)
}
@@ -34,8 +73,8 @@ func TestLoadExampleConfig(t *testing.T) {
t.Fatalf("LoadFile() error = %v", err)
}
if cfg.WeatherAPI.BaseURL != "https://weather.api.example.com/" {
t.Fatalf("BaseURL = %q, want example URL", cfg.WeatherAPI.BaseURL)
if cfg.WeatherAPI.BaseURL != "https://weather.api.rakestrawhome.com/" {
t.Fatalf("BaseURL = %q, want configured example URL", cfg.WeatherAPI.BaseURL)
}
if cfg.WeatherAPI.Timeout != 15*time.Second {
t.Fatalf("Timeout = %s, want 15s", cfg.WeatherAPI.Timeout)
@@ -43,6 +82,183 @@ func TestLoadExampleConfig(t *testing.T) {
if cfg.MissingSource.Sources["alerts"] != MissingSourceNone {
t.Fatalf("alerts policy = %q, want none", cfg.MissingSource.Sources["alerts"])
}
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
t.Fatalf("Location = %#v, want example location", cfg.Location)
}
if cfg.Notify.Distributor.PipelineIDTemplate != "weatherreporter.{artifact_group}" {
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)
}
}
func TestLoadMinimalExampleConfig(t *testing.T) {
cfg, err := LoadFile(filepath.Join("..", "..", "examples", "minimal-config.yml"))
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
if cfg.WeatherAPI.BaseURL != "https://weather.api.example.com/" {
t.Fatalf("BaseURL = %q, want example URL", cfg.WeatherAPI.BaseURL)
}
if cfg.WeatherAPI.Units != "us" {
t.Fatalf("Units = %q, want default us", cfg.WeatherAPI.Units)
}
if cfg.Scriptorium.Binary != "scriptorium" {
t.Fatalf("Scriptorium.Binary = %q, want default scriptorium", cfg.Scriptorium.Binary)
}
if cfg.Workspace.Root != "workspace" {
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" {
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
- id: area_forecast_discussion
options:
sections:
- short_term
`)
cfg, err := LoadFile(path)
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
overrides := cfg.ReportModuleOverrides()
items := overrides[report.DailyToday]
if len(items) != 3 {
t.Fatalf("daily override length = %d, want 3", len(items))
}
if items[0].ID != module.Metadata || items[1].ID != module.CurrentConditions || items[2].ID != module.AreaForecastDiscussion {
t.Fatalf("daily override = %#v, want configured module order", items)
}
options, ok := items[2].Options.(module.AreaForecastDiscussionOptions)
if !ok {
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", items[2].Options)
}
if strings.Join(options.Sections, ",") != "short_term" {
t.Fatalf("AFD sections = %#v, want short_term", options.Sections)
}
}
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: "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: "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 TestExplicitMissingConfigReturnsError(t *testing.T) {
@@ -55,6 +271,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) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yml")
@@ -72,7 +297,7 @@ func TestInvalidConfigProducesActionableError(t *testing.T) {
}
func TestLoadAppliesOverrides(t *testing.T) {
cfg, err := Load(LoadOptions{Units: "metric", Timezone: "+09:30", Output: "./out"})
cfg, err := Load(LoadOptions{Units: "metric", Timezone: "+09:30"})
if err != nil {
t.Fatalf("Load() error = %v", err)
}
@@ -82,7 +307,476 @@ func TestLoadAppliesOverrides(t *testing.T) {
if cfg.WeatherAPI.Timezone != "+09:30" {
t.Fatalf("Timezone = %q, want +09:30", cfg.WeatherAPI.Timezone)
}
if cfg.Reports.OutputDir != "./out" {
t.Fatalf("OutputDir = %q, want ./out", cfg.Reports.OutputDir)
}
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.{artifact_group}\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())
}
}

View File

@@ -10,9 +10,32 @@ func Defaults() Config {
Timeout: 10 * time.Second,
Precision: 1,
Units: "us",
Timezone: "Chicago",
Timezone: "America/Chicago",
Format: "json",
},
Location: LocationConfig{
ID: "home",
Name: "Brentwood",
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{
Default: MissingSourceWarn,
Sources: map[string]MissingSourcePolicy{},
@@ -27,16 +50,14 @@ func Defaults() Config {
ReportsDir: "reports",
DataPackagesDir: "data-packages",
PreflightDir: "preflight",
},
Reports: ReportOutputConfig{
OutputDir: "reports",
Paths: map[string]string{},
NotificationsDir: "notifications",
},
Dayparts: []DaypartConfig{
{Name: "overnight", Start: "00:00", End: "06:00"},
{Name: "morning", Start: "06:00", End: "12:00"},
{Name: "afternoon", Start: "12:00", End: "18:00"},
{Name: "evening", Start: "18:00", End: "24: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"},
},
RecentChange: RecentChangeConfig{
TemperatureDegrees: 5,
@@ -44,5 +65,6 @@ func Defaults() Config {
WindGustMilesPerHour: 10,
PrecipTimingShiftMinutes: 120,
},
Reports: map[string]ReportConfig{},
}
}

View File

@@ -12,7 +12,6 @@ type LoadOptions struct {
Path string
Units string
Timezone string
Output string
}
func Load(opts LoadOptions) (Config, error) {
@@ -35,8 +34,13 @@ func Load(opts LoadOptions) (Config, error) {
if opts.Timezone != "" {
cfg.WeatherAPI.Timezone = opts.Timezone
}
if opts.Output != "" {
cfg.Reports.OutputDir = opts.Output
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 {
@@ -61,8 +65,8 @@ func mergeFile(cfg *Config, path string) error {
if cfg.MissingSource.Sources == nil {
cfg.MissingSource.Sources = map[string]MissingSourcePolicy{}
}
if cfg.Reports.Paths == nil {
cfg.Reports.Paths = map[string]string{}
if cfg.Reports == nil {
cfg.Reports = map[string]ReportConfig{}
}
return nil
}

View 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')
}

125
internal/config/reports.go Normal file
View File

@@ -0,0 +1,125 @@
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", "daily_tomorrow":
return report.DailyTomorrow, nil
case "three_day", "three_day_outlook":
return report.ThreeDay, nil
case "weekend", "weekend_outlook":
return report.Weekend, nil
case "storm", "storm_report":
return report.Storm, nil
default:
return "", fmt.Errorf("reports.%s is not a known report", key)
}
}

View 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
}

View File

@@ -9,6 +9,9 @@ import (
)
func Validate(cfg Config) error {
if err := normalizeReportModules(&cfg); err != nil {
return err
}
if cfg.WeatherAPI.BaseURL != "" {
parsed, err := url.Parse(cfg.WeatherAPI.BaseURL)
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 == "" {
return fmt.Errorf("scriptorium.binary is required")
}
@@ -58,9 +65,6 @@ func Validate(cfg Config) error {
if cfg.Workspace.Root == "" {
return fmt.Errorf("workspace.root is required")
}
if cfg.Reports.OutputDir == "" {
return fmt.Errorf("reports.output_dir is required")
}
if len(cfg.Dayparts) == 0 {
return fmt.Errorf("dayparts must contain at least one entry")
}
@@ -78,6 +82,76 @@ func Validate(cfg Config) error {
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 {
switch policy {
case MissingSourceError, MissingSourceWarn, MissingSourceNone:

141
internal/facts/facts.go Normal file
View File

@@ -0,0 +1,141 @@
// Package facts defines collected and derived report facts.
package facts
import (
"fmt"
"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
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,
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,
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
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
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),
}
derived.PrecipTiming = forecast.BuildPrecipTiming(derived.ValidPeriodHourlyPeriods)
switch req.Resolved.Definition.ID {
case report.DailyToday, report.DailyTomorrow:
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
}

View File

@@ -0,0 +1,253 @@
package facts
import (
"encoding/json"
"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"},
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 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)
}
}
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.DailyTomorrow, 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 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 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 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
}

View File

@@ -0,0 +1,48 @@
// Package fileutil provides narrow filesystem helpers for durable artifacts.
package fileutil
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
func WriteFileAtomic(path string, data []byte) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create directory %q: %w", filepath.Dir(path), err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary file for %q: %w", path, err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary file for %q: %w", path, err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary file for %q: %w", path, err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("save %q: %w", path, err)
}
return nil
}
func WriteJSONAtomic(path string, value any) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return fmt.Errorf("marshal %q: %w", path, err)
}
return WriteFileAtomic(path, data)
}
func CopyFileAtomic(source string, target string) error {
data, err := os.ReadFile(source)
if err != nil {
return fmt.Errorf("read %q: %w", source, err)
}
return WriteFileAtomic(target, data)
}

View File

@@ -0,0 +1,103 @@
package fileutil
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestWriteFileAtomicCreatesParentDirectory(t *testing.T) {
path := filepath.Join(t.TempDir(), "nested", "artifact.txt")
if err := WriteFileAtomic(path, []byte("artifact")); err != nil {
t.Fatalf("WriteFileAtomic() error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if string(data) != "artifact" {
t.Fatalf("data = %q, want artifact", data)
}
}
func TestWriteFileAtomicOverwritesTarget(t *testing.T) {
path := filepath.Join(t.TempDir(), "artifact.txt")
if err := WriteFileAtomic(path, []byte("old")); err != nil {
t.Fatalf("WriteFileAtomic() initial error = %v", err)
}
if err := WriteFileAtomic(path, []byte("new")); err != nil {
t.Fatalf("WriteFileAtomic() overwrite error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if string(data) != "new" {
t.Fatalf("data = %q, want new", data)
}
}
func TestWriteFileAtomicCleansTemporaryFileAfterRenameError(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "target")
if err := os.Mkdir(target, 0o755); err != nil {
t.Fatalf("Mkdir() error = %v", err)
}
err := WriteFileAtomic(target, []byte("data"))
if err == nil {
t.Fatal("WriteFileAtomic() error = nil, want rename error")
}
if !strings.Contains(err.Error(), "save") {
t.Fatalf("error = %q, want save context", err.Error())
}
matches, err := filepath.Glob(filepath.Join(dir, ".target.*.tmp"))
if err != nil {
t.Fatalf("Glob() error = %v", err)
}
if len(matches) != 0 {
t.Fatalf("temporary files = %v, want none", matches)
}
}
func TestWriteJSONAtomic(t *testing.T) {
path := filepath.Join(t.TempDir(), "artifact.json")
if err := WriteJSONAtomic(path, map[string]string{"status": "ok"}); err != nil {
t.Fatalf("WriteJSONAtomic() error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if string(data) != "{\n \"status\": \"ok\"\n}" {
t.Fatalf("json = %q, want indented object", data)
}
}
func TestCopyFileAtomic(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source.txt")
target := filepath.Join(dir, "nested", "target.txt")
if err := os.WriteFile(source, []byte("copied"), 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if err := CopyFileAtomic(source, target); err != nil {
t.Fatalf("CopyFileAtomic() error = %v", err)
}
data, err := os.ReadFile(target)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if string(data) != "copied" {
t.Fatalf("data = %q, want copied", data)
}
}

View File

@@ -5,6 +5,7 @@ import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type DaypartDefinition struct {
@@ -48,6 +49,6 @@ func ResolveDayparts(date time.Time, location *time.Location, definitions []Dayp
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}
}

View File

@@ -8,23 +8,24 @@ import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type DailySummary struct {
Date string `json:"date"`
Period timeutil.Period `json:"period"`
Dayparts []DaypartSummary `json:"dayparts"`
NarrativePeriods []ForecastPeriod `json:"narrativePeriods,omitempty"`
NarrativePeriods []weatherdata.ForecastPeriod `json:"narrativePeriods,omitempty"`
AlertOverlaps []AlertOverlap `json:"alertOverlaps,omitempty"`
Discussion *Discussion `json:"discussion,omitempty"`
SourceWarnings []SourceWarning `json:"sourceWarnings,omitempty"`
SourceProvenance []Source `json:"sourceProvenance,omitempty"`
Discussion *weatherdata.Discussion `json:"discussion,omitempty"`
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
SourceProvenance []weatherdata.Source `json:"sourceProvenance,omitempty"`
}
type DaypartSummary struct {
Name string `json:"name"`
Period timeutil.Period `json:"period"`
HourlyPeriods []ForecastPeriod `json:"hourlyPeriods"`
HourlyPeriods []weatherdata.ForecastPeriod `json:"hourlyPeriods"`
Temperature Range `json:"temperature,omitempty"`
ApparentTemperature Range `json:"apparentTemperature,omitempty"`
MaxPrecipitationProbability *TimedValue `json:"maxPrecipitationProbability,omitempty"`
@@ -46,8 +47,9 @@ type TimedValue struct {
Time time.Time `json:"time"`
}
const DefaultPrecipWindowProbabilityThreshold = 40
type Indicators struct {
Thunder bool `json:"thunder,omitempty"`
Snow bool `json:"snow,omitempty"`
Ice bool `json:"ice,omitempty"`
Fog bool `json:"fog,omitempty"`
@@ -65,7 +67,106 @@ type AlertOverlap struct {
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 {
return nil, fmt.Errorf("forecast bundle is required")
}
@@ -100,7 +201,7 @@ func BuildDailySummary(bundle *Bundle, date time.Time, location *time.Location,
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() {
return nil, fmt.Errorf("valid forecast period is required")
}
@@ -122,7 +223,7 @@ func BuildPeriodDailySummaries(bundle *Bundle, period timeutil.Period, location
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 {
return nil, fmt.Errorf("forecast bundle is required")
}
@@ -156,11 +257,11 @@ func buildDailySummaryForPeriod(bundle *Bundle, period timeutil.Period, location
return summary, nil
}
func SelectHourlyPeriods(run *ForecastRun, period timeutil.Period) []ForecastPeriod {
func SelectHourlyPeriods(run *weatherdata.ForecastRun, period timeutil.Period) []weatherdata.ForecastPeriod {
if run == nil {
return nil
}
var selected []ForecastPeriod
var selected []weatherdata.ForecastPeriod
for _, forecastPeriod := range run.Periods {
if PeriodForForecastPeriod(forecastPeriod).Overlaps(period) {
selected = append(selected, forecastPeriod)
@@ -172,21 +273,21 @@ func SelectHourlyPeriods(run *ForecastRun, period timeutil.Period) []ForecastPer
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 {
return nil
}
return SelectHourlyPeriods(bundle.Narrative, period)
}
func SelectDiscussion(bundle *Bundle) *Discussion {
func SelectDiscussion(bundle *weatherdata.Bundle) *weatherdata.Discussion {
if bundle == nil {
return nil
}
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{
Name: name,
Period: period,
@@ -216,7 +317,7 @@ func SummarizeDaypart(name string, period timeutil.Period, periods []ForecastPer
return summary
}
func periodTemperatureValues(period ForecastPeriod) []*float64 {
func periodTemperatureValues(period weatherdata.ForecastPeriod) []*float64 {
values := []*float64{}
values = append(values, valueFromPointers(period.TemperatureF, period.TemperatureC)...)
values = append(values, valueFromPointers(period.TemperatureFMin, period.TemperatureCMin)...)
@@ -291,7 +392,6 @@ func sortedKeys(values map[string]struct{}) []string {
func indicatorsForText(text string) Indicators {
lower := strings.ToLower(text)
return Indicators{
Thunder: strings.Contains(lower, "thunder") || strings.Contains(lower, "storm"),
Snow: strings.Contains(lower, "snow"),
Ice: strings.Contains(lower, "ice") || strings.Contains(lower, "freezing") || strings.Contains(lower, "sleet"),
Fog: strings.Contains(lower, "fog"),
@@ -299,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)
windSpeed := firstValue(period.WindSpeedMph, period.WindSpeedKmh)
indicators := Indicators{}
@@ -318,7 +422,6 @@ func numericIndicators(period ForecastPeriod) Indicators {
func mergeIndicators(left Indicators, right Indicators) Indicators {
return Indicators{
Thunder: left.Thunder || right.Thunder,
Snow: left.Snow || right.Snow,
Ice: left.Ice || right.Ice,
Fog: left.Fog || right.Fog,
@@ -328,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 {
return nil
}

View File

@@ -8,6 +8,7 @@ import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
func TestBuildDailySummaryGroupsDaypartsAndComputesMetrics(t *testing.T) {
@@ -41,10 +42,10 @@ func TestBuildDailySummaryGroupsDaypartsAndComputesMetrics(t *testing.T) {
t.Fatalf("morning peak gust = %#v, want 40", morning.PeakWindGust)
}
if morning.DominantCondition != "Thunderstorms and gusty wind" {
t.Fatalf("morning dominant = %q, want thunderstorm condition", morning.DominantCondition)
t.Fatalf("morning dominant = %q, want raw forecast condition", morning.DominantCondition)
}
if !morning.Indicators.Thunder || !morning.Indicators.Wind {
t.Fatalf("morning indicators = %#v, want thunder and wind", morning.Indicators)
if !morning.Indicators.Wind {
t.Fatalf("morning indicators = %#v, want wind", morning.Indicators)
}
afternoon := summary.Dayparts[2]
@@ -72,7 +73,7 @@ func TestBuildDailySummaryFromFixtureBundle(t *testing.T) {
if err != nil {
t.Fatalf("read fixture bundle: %v", err)
}
var bundle Bundle
var bundle weatherdata.Bundle
if err := json.Unmarshal(data, &bundle); err != nil {
t.Fatalf("decode fixture bundle: %v", err)
}
@@ -87,8 +88,8 @@ func TestBuildDailySummaryFromFixtureBundle(t *testing.T) {
if len(summary.Dayparts) != 2 {
t.Fatalf("Dayparts length = %d, want 2", len(summary.Dayparts))
}
if !summary.Dayparts[0].Indicators.Thunder {
t.Fatalf("morning indicators = %#v, want thunder", summary.Dayparts[0].Indicators)
if summary.Dayparts[0].DominantCondition != "Showers and thunderstorms" {
t.Fatalf("morning dominant = %q, want raw forecast condition", summary.Dayparts[0].DominantCondition)
}
if len(summary.AlertOverlaps) != 1 {
t.Fatalf("AlertOverlaps length = %d, want 1", len(summary.AlertOverlaps))
@@ -98,7 +99,7 @@ func TestBuildDailySummaryFromFixtureBundle(t *testing.T) {
func TestOvernightGroupingAcrossMidnight(t *testing.T) {
location := time.FixedZone("Test", -5*60*60)
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-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),
@@ -122,7 +123,7 @@ func TestOvernightGroupingAcrossMidnight(t *testing.T) {
func TestBoundaryTimestampsAtDaypartEdges(t *testing.T) {
location := time.FixedZone("Test", -5*60*60)
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-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),
@@ -145,7 +146,7 @@ func TestBoundaryTimestampsAtDaypartEdges(t *testing.T) {
func TestBuildDailySummaryRequiresHourlyData(t *testing.T) {
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"},
})
if err == nil {
@@ -155,7 +156,7 @@ func TestBuildDailySummaryRequiresHourlyData(t *testing.T) {
func TestBuildPeriodDailySummariesClipsPartialDays(t *testing.T) {
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-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),
@@ -191,7 +192,7 @@ func TestBuildPeriodDailySummariesClipsPartialDays(t *testing.T) {
func TestAlertOverlap(t *testing.T) {
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"}`)
alertRun := &AlertRun{Alerts: []json.RawMessage{raw}}
alertRun := &weatherdata.AlertRun{Alerts: []json.RawMessage{raw}}
period := timeutil.Period{
Start: mustParse("2026-05-29T06: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) {
if !DifferenceAtLeast(50, 56, 5) {
t.Fatal("DifferenceAtLeast = false, want true")
@@ -221,31 +329,31 @@ func TestThresholdHelpers(t *testing.T) {
}
}
func testBundle(location *time.Location) *Bundle {
return &Bundle{
Hourly: &ForecastRun{Periods: []ForecastPeriod{
func testBundle(location *time.Location) *weatherdata.Bundle {
return &weatherdata.Bundle{
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-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-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),
}},
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"}`),
}},
Discussion: &Discussion{Product: "discussion", KeyMessages: []string{"Storms possible."}},
Sources: []Source{{Name: "hourly"}},
Warnings: []SourceWarning{{Source: "daily", Code: "missing_source"}},
Discussion: &weatherdata.Discussion{Product: "discussion", KeyMessages: []string{"Storms possible."}},
Sources: []weatherdata.Source{{Name: "hourly"}},
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)
endTime := mustParse(end).In(location)
temp := temperature
return ForecastPeriod{
return weatherdata.ForecastPeriod{
StartTime: startTime,
EndTime: endTime,
TextDescription: text,

View File

@@ -54,7 +54,15 @@
"issuedAt": "2026-05-29T09:25:00-05:00",
"keyMessages": [
"Storms are most likely during the morning."
]
],
"shortTerm": {
"qualifier": "(Through This Evening)",
"text": "Morning showers taper as a weak boundary shifts east."
},
"longTerm": {
"qualifier": "(This Weekend)",
"text": "Warmer and more humid conditions return with periodic rain chances."
}
},
"sources": [
{

145
internal/module/module.go Normal file
View File

@@ -0,0 +1,145 @@
// Package module defines stable module contracts for prompt-facing stanzas.
package module
import (
"encoding/json"
"fmt"
)
const SnapshotSchemaVersion = "weatherreporter.modules.v1"
type ID string
const (
Metadata ID = "metadata"
CurrentConditions ID = "current_conditions"
NarrativeForecast ID = "narrative_forecast"
HourlyForecast ID = "hourly_forecast"
DerivedDailySummary ID = "derived_daily_summary"
DerivedDaypartSummaries ID = "derived_daypart_summaries"
PrecipTiming ID = "precip_timing"
AlertDigest ID = "alert_digest"
AreaForecastDiscussion ID = "area_forecast_discussion"
WeatherStory ID = "weather_story"
OutdoorWindows ID = "outdoor_windows"
TomorrowPlanning ID = "tomorrow_planning"
)
type ConfigItem struct {
ID ID `json:"id"`
Options any `json:"options,omitempty"`
}
type Output struct {
ID ID `json:"id"`
StanzaName string `json:"stanzaName"`
Value any `json:"value"`
}
type Snapshot struct {
SchemaVersion string `json:"schemaVersion"`
Outputs []Output `json:"outputs"`
}
func NewSnapshot(outputs []Output) (Snapshot, error) {
snapshot := Snapshot{
SchemaVersion: SnapshotSchemaVersion,
Outputs: append([]Output(nil), outputs...),
}
if err := snapshot.Validate(); err != nil {
return Snapshot{}, err
}
return snapshot, nil
}
func (s Snapshot) Validate() error {
if s.SchemaVersion == "" {
return fmt.Errorf("schemaVersion is required")
}
seenModules := map[ID]struct{}{}
seenStanzas := map[string]struct{}{}
for i, output := range s.Outputs {
if output.ID == "" {
return fmt.Errorf("outputs[%d].id is required", i)
}
if output.StanzaName == "" {
return fmt.Errorf("outputs[%d].stanzaName is required", i)
}
if _, ok := seenModules[output.ID]; ok {
return fmt.Errorf("duplicate module output %q", output.ID)
}
seenModules[output.ID] = struct{}{}
if _, ok := seenStanzas[output.StanzaName]; ok {
return fmt.Errorf("duplicate stanza name %q", output.StanzaName)
}
seenStanzas[output.StanzaName] = struct{}{}
}
return nil
}
func (s Snapshot) LookupStanza(name string) (Output, bool) {
for _, output := range s.Outputs {
if output.StanzaName == name {
return output, true
}
}
return Output{}, false
}
func StanzaValue[T any](s Snapshot, name string) (T, bool, error) {
var zero T
output, ok := s.LookupStanza(name)
if !ok {
return zero, false, nil
}
data, err := json.Marshal(output.Value)
if err != nil {
return zero, true, fmt.Errorf("marshal stanza %q: %w", name, err)
}
if err := json.Unmarshal(data, &zero); err != nil {
return zero, true, fmt.Errorf("decode stanza %q: %w", name, err)
}
return zero, true, nil
}
type FactRequirement string
const (
CollectedCurrentConditions FactRequirement = "collected.current_conditions"
CollectedNarrativeForecast FactRequirement = "collected.narrative_forecast"
CollectedHourlyForecast FactRequirement = "collected.hourly_forecast"
CollectedAlerts FactRequirement = "collected.alerts"
CollectedDiscussion FactRequirement = "collected.discussion"
CollectedWeatherStory FactRequirement = "collected.weather_story"
CollectedSourceMetadata FactRequirement = "collected.source_metadata"
RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods"
RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods"
RequiresDerivedAlertOverlaps FactRequirement = "derived.alert_overlaps"
RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries"
RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries"
RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing"
)
type MissingDataBehavior string
const (
MissingDataOmit MissingDataBehavior = "omit"
MissingDataEmpty MissingDataBehavior = "empty"
MissingDataError MissingDataBehavior = "error"
MissingDataWarn MissingDataBehavior = "warn"
)
type MetadataOptions struct{}
type CurrentConditionsOptions struct{}
type NarrativeForecastOptions struct{}
type HourlyForecastOptions struct{}
type DerivedDailySummaryOptions struct{}
type DerivedDaypartSummariesOptions struct{}
type PrecipTimingOptions struct{}
type AlertDigestOptions struct{}
type AreaForecastDiscussionOptions struct {
Sections []string `json:"sections,omitempty" yaml:"sections,omitempty"`
}
type WeatherStoryOptions struct{}
type OutdoorWindowsOptions struct{}
type TomorrowPlanningOptions struct{}

View File

@@ -0,0 +1,84 @@
package module
import (
"encoding/json"
"strings"
"testing"
)
type testStanza struct {
Message string `json:"message"`
Count int `json:"count"`
}
func TestSnapshotPreservesOutputOrderAndJSON(t *testing.T) {
snapshot, err := NewSnapshot([]Output{
{ID: Metadata, StanzaName: "metadata", Value: testStanza{Message: "first", Count: 1}},
{ID: AlertDigest, StanzaName: "alert_digest", Value: testStanza{Message: "second", Count: 2}},
})
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
if snapshot.SchemaVersion != SnapshotSchemaVersion {
t.Fatalf("SchemaVersion = %q, want %q", snapshot.SchemaVersion, SnapshotSchemaVersion)
}
if snapshot.Outputs[0].ID != Metadata || snapshot.Outputs[1].ID != AlertDigest {
t.Fatalf("Outputs order = %#v, want input order", snapshot.Outputs)
}
data, err := json.Marshal(snapshot)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
got := string(data)
want := `{"schemaVersion":"weatherreporter.modules.v1","outputs":[{"id":"metadata","stanzaName":"metadata","value":{"message":"first","count":1}},{"id":"alert_digest","stanzaName":"alert_digest","value":{"message":"second","count":2}}]}`
if got != want {
t.Fatalf("json = %s, want %s", got, want)
}
}
func TestSnapshotRejectsDuplicateOutputs(t *testing.T) {
_, err := NewSnapshot([]Output{
{ID: Metadata, StanzaName: "metadata", Value: struct{}{}},
{ID: Metadata, StanzaName: "other_metadata", Value: struct{}{}},
})
if err == nil || !strings.Contains(err.Error(), `duplicate module output "metadata"`) {
t.Fatalf("duplicate module error = %v, want duplicate module output", err)
}
_, err = NewSnapshot([]Output{
{ID: Metadata, StanzaName: "metadata", Value: struct{}{}},
{ID: CurrentConditions, StanzaName: "metadata", Value: struct{}{}},
})
if err == nil || !strings.Contains(err.Error(), `duplicate stanza name "metadata"`) {
t.Fatalf("duplicate stanza error = %v, want duplicate stanza name", err)
}
}
func TestStanzaValueDecodesTypedOutput(t *testing.T) {
snapshot, err := NewSnapshot([]Output{
{ID: Metadata, StanzaName: "metadata", Value: testStanza{Message: "available", Count: 3}},
})
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
value, found, err := StanzaValue[testStanza](snapshot, "metadata")
if err != nil {
t.Fatalf("StanzaValue() error = %v", err)
}
if !found {
t.Fatal("StanzaValue() found = false, want true")
}
if value.Message != "available" || value.Count != 3 {
t.Fatalf("StanzaValue() = %#v, want decoded stanza", value)
}
_, found, err = StanzaValue[testStanza](snapshot, "missing")
if err != nil {
t.Fatalf("StanzaValue(missing) error = %v", err)
}
if found {
t.Fatal("StanzaValue(missing) found = true, want false")
}
}

View File

@@ -1,68 +1,122 @@
// Package promptinput builds prompt data packages from briefing packages.
// Package promptinput builds prompt data packages from module snapshots.
package promptinput
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"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"
"gopkg.in/yaml.v3"
)
const SchemaVersion = "weatherreporter.data_package.v1"
const SchemaVersion = "weatherreporter.data_package.v2"
const (
metadataStanza = "metadata"
categoryApplicableRiskProducts = "applicable_risk_products"
categoryDerivedSummaries = "derived_summaries"
categoryNarrativeProducts = "narrative_products"
categoryRawData = "raw_data"
)
var briefingCategoryOrder = []string{
categoryApplicableRiskProducts,
categoryDerivedSummaries,
categoryNarrativeProducts,
categoryRawData,
}
var briefingStanzaCategories = map[string]string{
string(module.AlertDigest): categoryApplicableRiskProducts,
string(module.DerivedDailySummary): categoryDerivedSummaries,
string(module.DerivedDaypartSummaries): categoryDerivedSummaries,
string(module.PrecipTiming): categoryDerivedSummaries,
string(module.OutdoorWindows): categoryDerivedSummaries,
string(module.TomorrowPlanning): categoryDerivedSummaries,
string(module.NarrativeForecast): categoryNarrativeProducts,
string(module.AreaForecastDiscussion): categoryNarrativeProducts,
string(module.WeatherStory): categoryNarrativeProducts,
string(module.CurrentConditions): categoryRawData,
string(module.HourlyForecast): categoryRawData,
}
type BuildRequest struct {
Metadata Metadata
Modules module.Snapshot
RecentChanges []changes.Change
}
type Metadata struct {
RunID string
ReportID report.ID
Variant string
PromptID string
GeneratedAt time.Time
Timezone string
ValidPeriod timeutil.Period
SourceWarnings []weatherdata.SourceWarning
}
type Package struct {
SchemaVersion string `json:"schemaVersion"`
RunID string `json:"runId"`
Report Report `json:"report"`
Briefing briefing.Package `json:"briefing"`
RecentChanges RecentChanges `json:"recentChanges"`
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"`
SchemaVersion string `json:"schemaVersion" yaml:"schema_version"`
RunID string `json:"runId" yaml:"run_id"`
Report Report `json:"report" yaml:"report"`
Briefing BriefingStanzas `json:"briefing" yaml:"briefing"`
RecentChanges RecentChanges `json:"recentChanges" yaml:"recent_changes"`
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty" yaml:"source_warnings,omitempty"`
}
type Report struct {
ID report.ID `json:"id"`
Variant string `json:"variant,omitempty"`
PromptID string `json:"promptId"`
GeneratedAt time.Time `json:"generatedAt"`
Timezone string `json:"timezone"`
ValidPeriod timeutil.Period `json:"validPeriod"`
ID report.ID `json:"id" yaml:"id"`
Variant string `json:"variant,omitempty" yaml:"variant,omitempty"`
PromptID string `json:"promptId" yaml:"prompt_id"`
GeneratedAt time.Time `json:"generatedAt" yaml:"generated_at"`
Timezone string `json:"timezone" yaml:"timezone"`
CurrentLocalDate string `json:"currentLocalDate" yaml:"current_local_date"`
ValidPeriod timeutil.Period `json:"validPeriod" yaml:"valid_period"`
}
type BriefingStanzas struct {
Order []string `json:"-" yaml:"-"`
Values map[string]any `json:"-" yaml:"-"`
}
type RecentChanges struct {
Items []changes.Change `json:"items"`
Items []changes.Change `json:"items" yaml:"items"`
}
func Build(briefingPackage briefing.Package) (Package, error) {
return BuildWithRecentChanges(briefingPackage, nil)
}
func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []changes.Change) (Package, error) {
items := make([]changes.Change, len(recentChanges))
copy(items, recentChanges)
func Build(req BuildRequest) (Package, error) {
localDate, err := currentLocalDate(req.Metadata.GeneratedAt, req.Metadata.Timezone)
if err != nil {
return Package{}, err
}
items := make([]changes.Change, len(req.RecentChanges))
copy(items, req.RecentChanges)
if items == nil {
items = []changes.Change{}
}
pkg := Package{
SchemaVersion: SchemaVersion,
RunID: briefingPackage.Metadata.RunID,
RunID: req.Metadata.RunID,
Report: Report{
ID: briefingPackage.Metadata.ReportID,
Variant: briefingPackage.Metadata.Variant,
PromptID: briefingPackage.Metadata.PromptID,
GeneratedAt: briefingPackage.Metadata.GeneratedAt,
Timezone: briefingPackage.Metadata.Timezone,
ValidPeriod: briefingPackage.Metadata.ValidPeriod,
ID: req.Metadata.ReportID,
Variant: req.Metadata.Variant,
PromptID: req.Metadata.PromptID,
GeneratedAt: req.Metadata.GeneratedAt,
Timezone: req.Metadata.Timezone,
CurrentLocalDate: localDate,
ValidPeriod: req.Metadata.ValidPeriod,
},
Briefing: briefingPackage,
Briefing: stanzasFromSnapshot(req.Modules),
RecentChanges: RecentChanges{Items: items},
SourceWarnings: briefingPackage.Metadata.SourceWarnings,
SourceWarnings: append([]weatherdata.SourceWarning(nil), req.Metadata.SourceWarnings...),
}
if err := Validate(pkg); err != nil {
return Package{}, err
@@ -70,10 +124,31 @@ func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []ch
return pkg, nil
}
func stanzasFromSnapshot(snapshot module.Snapshot) BriefingStanzas {
values := map[string]any{}
order := make([]string, 0, len(snapshot.Outputs))
for _, output := range snapshot.Outputs {
order = append(order, output.StanzaName)
values[output.StanzaName] = output.Value
}
return BriefingStanzas{Order: order, Values: values}
}
func currentLocalDate(generatedAt time.Time, timezone string) (string, error) {
location, err := timeutil.LoadLocation(timezone)
if err != nil {
return "", fmt.Errorf("load report timezone %q: %w", timezone, err)
}
return generatedAt.In(location).Format(timeutil.DateLayout), nil
}
func Validate(pkg Package) error {
if pkg.SchemaVersion == "" {
return fmt.Errorf("schemaVersion is required")
}
if pkg.SchemaVersion != SchemaVersion {
return fmt.Errorf("schemaVersion must be %s", SchemaVersion)
}
if pkg.RunID == "" {
return fmt.Errorf("runId is required")
}
@@ -89,57 +164,239 @@ func Validate(pkg Package) error {
if pkg.Report.Timezone == "" {
return fmt.Errorf("report.timezone is required")
}
if pkg.Report.CurrentLocalDate == "" {
return fmt.Errorf("report.currentLocalDate is required")
}
if !pkg.Report.ValidPeriod.IsValid() {
return fmt.Errorf("report.validPeriod must be valid")
}
if pkg.Briefing.Metadata.RunID == "" {
return fmt.Errorf("briefing.metadata.runId is required")
if len(pkg.Briefing.Order) == 0 {
return fmt.Errorf("briefing stanzas are required")
}
if pkg.Briefing.Metadata.RunID != pkg.RunID {
return fmt.Errorf("briefing.metadata.runId must match runId")
seen := map[string]struct{}{}
for _, name := range pkg.Briefing.Order {
if name == "" {
return fmt.Errorf("briefing stanza name is required")
}
if pkg.Briefing.Metadata.SchemaVersion == "" {
return fmt.Errorf("briefing.metadata.schemaVersion is required")
if _, ok := seen[name]; ok {
return fmt.Errorf("duplicate briefing stanza %q", name)
}
if pkg.Briefing.Metadata.PromptID != pkg.Report.PromptID {
return fmt.Errorf("briefing.metadata.promptId must match report.promptId")
seen[name] = struct{}{}
if _, ok := pkg.Briefing.Values[name]; !ok {
return fmt.Errorf("briefing stanza %q is missing", name)
}
if pkg.Briefing.Metadata.ReportID != pkg.Report.ID {
return fmt.Errorf("briefing.metadata.reportId must match report.id")
if name == metadataStanza {
continue
}
if _, ok := briefingStanzaCategories[name]; !ok {
return fmt.Errorf("briefing stanza %q has no prompt-input category", name)
}
if pkg.Briefing.Daily == nil && pkg.Briefing.ThreeDay == nil && pkg.Briefing.Weekend == nil && pkg.Briefing.Storm == nil {
return fmt.Errorf("briefing report content is required")
}
return nil
}
func Save(path string, pkg Package) error {
if err := Validate(pkg); err != nil {
data, err := MarshalYAML(pkg)
if err != nil {
return err
}
data, err := json.MarshalIndent(pkg, "", " ")
if err != nil {
return fmt.Errorf("marshal data package: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create data package directory %q: %w", filepath.Dir(path), err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary data package file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary data package file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary data package file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("save data package %q: %w", path, err)
if err := fileutil.WriteFileAtomic(path, data); err != nil {
return fmt.Errorf("save data package: %w", err)
}
return nil
}
func MarshalYAML(pkg Package) ([]byte, error) {
if err := Validate(pkg); err != nil {
return nil, err
}
data, err := yaml.Marshal(pkg)
if err != nil {
return nil, fmt.Errorf("marshal data package: %w", err)
}
return data, nil
}
func LoadYAML(data []byte) (Package, error) {
var pkg Package
decoder := yaml.NewDecoder(bytes.NewReader(data))
if err := decoder.Decode(&pkg); err != nil {
return Package{}, fmt.Errorf("decode data package: %w", err)
}
if err := Validate(pkg); err != nil {
return Package{}, err
}
return pkg, nil
}
func (b BriefingStanzas) MarshalYAML() (any, error) {
node := &yaml.Node{Kind: yaml.MappingNode}
categoryNames := map[string][]string{}
for _, name := range b.Order {
value, ok := b.Values[name]
if !ok {
continue
}
if name == metadataStanza {
if err := appendYAMLMappingValue(node, name, value); err != nil {
return nil, err
}
continue
}
category, ok := briefingStanzaCategories[name]
if !ok {
return nil, fmt.Errorf("briefing stanza %q has no prompt-input category", name)
}
categoryNames[category] = append(categoryNames[category], name)
}
for _, category := range briefingCategoryOrder {
names := categoryNames[category]
if len(names) == 0 {
continue
}
categoryNode := &yaml.Node{Kind: yaml.MappingNode}
for _, name := range names {
value := b.Values[name]
if err := appendYAMLMappingValue(categoryNode, name, value); err != nil {
return nil, err
}
}
node.Content = append(node.Content,
&yaml.Node{Kind: yaml.ScalarNode, Value: category},
categoryNode,
)
}
return node, nil
}
func (b *BriefingStanzas) UnmarshalYAML(value *yaml.Node) error {
if value.Kind != yaml.MappingNode {
return fmt.Errorf("briefing must be a mapping")
}
values := map[string]any{}
order := make([]string, 0, len(value.Content)/2)
seen := map[string]struct{}{}
seenCategories := map[string]struct{}{}
categoryOrder := map[string][]string{}
for i := 0; i < len(value.Content); i += 2 {
name := value.Content[i].Value
if name == metadataStanza {
if err := decodeBriefingStanza(value.Content[i+1], name, values, &order, seen); err != nil {
return err
}
continue
}
if !knownBriefingCategory(name) {
return fmt.Errorf("unknown briefing category %q", name)
}
if _, ok := seenCategories[name]; ok {
return fmt.Errorf("duplicate briefing category %q", name)
}
seenCategories[name] = struct{}{}
categoryNode := value.Content[i+1]
if categoryNode.Kind != yaml.MappingNode {
return fmt.Errorf("briefing category %q must be a mapping", name)
}
var names []string
for j := 0; j < len(categoryNode.Content); j += 2 {
stanzaName := categoryNode.Content[j].Value
category, ok := briefingStanzaCategories[stanzaName]
if !ok {
return fmt.Errorf("briefing stanza %q has no prompt-input category", stanzaName)
}
if category != name {
return fmt.Errorf("briefing stanza %q belongs under category %q, not %q", stanzaName, category, name)
}
if err := decodeBriefingStanza(categoryNode.Content[j+1], stanzaName, values, &names, seen); err != nil {
return err
}
}
categoryOrder[name] = names
}
for _, category := range briefingCategoryOrder {
order = append(order, categoryOrder[category]...)
}
b.Order = order
b.Values = values
return nil
}
func (b BriefingStanzas) MarshalJSON() ([]byte, error) {
out := map[string]any{}
for _, name := range b.Order {
if value, ok := b.Values[name]; ok {
out[name] = value
}
}
return json.Marshal(out)
}
func (b *BriefingStanzas) UnmarshalJSON(data []byte) error {
var values map[string]any
if err := json.Unmarshal(data, &values); err != nil {
return err
}
order := make([]string, 0, len(values))
for name := range values {
order = append(order, name)
}
b.Order = order
b.Values = values
return nil
}
func appendYAMLMappingValue(node *yaml.Node, name string, value any) error {
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: name}
valueNode, err := yamlNode(value)
if err != nil {
return fmt.Errorf("marshal briefing stanza %q: %w", name, err)
}
node.Content = append(node.Content, keyNode, valueNode)
return nil
}
func decodeBriefingStanza(node *yaml.Node, name string, values map[string]any, order *[]string, seen map[string]struct{}) error {
if _, ok := seen[name]; ok {
return fmt.Errorf("duplicate briefing stanza %q", name)
}
var stanza any
if err := node.Decode(&stanza); err != nil {
return err
}
seen[name] = struct{}{}
*order = append(*order, name)
values[name] = stanza
return nil
}
func knownBriefingCategory(name string) bool {
for _, category := range briefingCategoryOrder {
if name == category {
return true
}
}
return false
}
func yamlNode(value any) (*yaml.Node, error) {
data, err := json.Marshal(value)
if err != nil {
return nil, err
}
var normalized any
if err := json.Unmarshal(data, &normalized); err != nil {
return nil, err
}
data, err = yaml.Marshal(normalized)
if err != nil {
return nil, err
}
var node yaml.Node
if err := yaml.Unmarshal(data, &node); err != nil {
return nil, err
}
if len(node.Content) == 0 {
return &yaml.Node{Kind: yaml.MappingNode}, nil
}
return node.Content[0], nil
}

View File

@@ -1,20 +1,17 @@
package promptinput
import (
"encoding/json"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func TestBuildDailyDataPackage(t *testing.T) {
briefingPackage := validBriefingPackage()
pkg, err := Build(briefingPackage)
pkg, err := Build(validBuildRequest(t))
if err != nil {
t.Fatalf("Build() error = %v", err)
}
@@ -23,21 +20,55 @@ func TestBuildDailyDataPackage(t *testing.T) {
t.Fatalf("SchemaVersion = %q, want %q", pkg.SchemaVersion, SchemaVersion)
}
if pkg.RunID != "20260529T100000Z_daily_today" {
t.Fatalf("RunID = %q, want briefing run id", pkg.RunID)
t.Fatalf("RunID = %q, want metadata run id", pkg.RunID)
}
if pkg.Report.PromptID != "weather.daily_report" {
t.Fatalf("PromptID = %q, want weather.daily_report", pkg.Report.PromptID)
}
if pkg.Briefing.Daily == nil {
t.Fatal("Briefing.Daily = nil")
if pkg.Report.CurrentLocalDate != "2026-05-29" {
t.Fatalf("CurrentLocalDate = %q, want 2026-05-29", pkg.Report.CurrentLocalDate)
}
if pkg.Briefing.Order[0] != "metadata" || pkg.Briefing.Order[1] != "current_conditions" || pkg.Briefing.Order[2] != "derived_daily_summary" {
t.Fatalf("Briefing.Order = %#v, want snapshot stanza order", pkg.Briefing.Order)
}
if got := pkg.Briefing.Values["current_conditions"].(map[string]string)["condition_text"]; got != "Partly cloudy" {
t.Fatalf("current_conditions.condition_text = %q, want Partly cloudy", got)
}
if pkg.RecentChanges.Items == nil || len(pkg.RecentChanges.Items) != 0 {
t.Fatalf("RecentChanges.Items = %#v, want empty slice", pkg.RecentChanges.Items)
}
}
func TestBuildCurrentLocalDateUsesReportTimezone(t *testing.T) {
req := validBuildRequest(t)
req.Metadata.GeneratedAt = time.Date(2026, 5, 30, 2, 30, 0, 0, time.UTC)
req.Metadata.Timezone = "America/Chicago"
pkg, err := Build(req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if pkg.Report.CurrentLocalDate != "2026-05-29" {
t.Fatalf("CurrentLocalDate = %q, want local Chicago date 2026-05-29", pkg.Report.CurrentLocalDate)
}
}
func TestBuildRejectsInvalidReportTimezone(t *testing.T) {
req := validBuildRequest(t)
req.Metadata.Timezone = "Not/AZone"
_, err := Build(req)
if err == nil {
t.Fatal("Build() error = nil, want invalid timezone error")
}
if !strings.Contains(err.Error(), "report timezone") {
t.Fatalf("error = %q, want report timezone context", err.Error())
}
}
func TestValidateRequiresFields(t *testing.T) {
pkg, err := Build(validBriefingPackage())
pkg, err := Build(validBuildRequest(t))
if err != nil {
t.Fatalf("Build() error = %v", err)
}
@@ -52,17 +83,33 @@ func TestValidateRequiresFields(t *testing.T) {
}
}
func TestBuildThreeDayDataPackage(t *testing.T) {
briefingPackage := validBriefingPackage()
briefingPackage.Metadata.RunID = "20260529T100000Z_three_day"
briefingPackage.Metadata.ReportID = report.ThreeDay
briefingPackage.Metadata.PromptID = "weather.three_day_outlook"
briefingPackage.Daily = nil
briefingPackage.ThreeDay = &briefing.ThreeDay{
Days: []briefing.OutlookDay{{Date: "2026-05-29"}},
func TestValidateRequiresCurrentLocalDate(t *testing.T) {
pkg, err := Build(validBuildRequest(t))
if err != nil {
t.Fatalf("Build() error = %v", err)
}
pkg.Report.CurrentLocalDate = ""
pkg, err := Build(briefingPackage)
err = Validate(pkg)
if err == nil {
t.Fatal("Validate() error = nil, want required field error")
}
if !strings.Contains(err.Error(), "currentLocalDate") {
t.Fatalf("error = %q, want currentLocalDate context", err.Error())
}
}
func TestBuildUsesNamedSnapshotStanzas(t *testing.T) {
req := validBuildRequest(t)
req.Metadata.RunID = "20260529T100000Z_three_day"
req.Metadata.ReportID = report.ThreeDay
req.Metadata.PromptID = "weather.three_day_outlook"
req.Modules = snapshotWithOutputs(t,
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": req.Metadata.RunID}},
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{"days": []string{"2026-05-29"}}},
)
pkg, err := Build(req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
@@ -70,98 +117,150 @@ func TestBuildThreeDayDataPackage(t *testing.T) {
if pkg.Report.ID != report.ThreeDay {
t.Fatalf("Report.ID = %q, want three_day", pkg.Report.ID)
}
if pkg.Briefing.ThreeDay == nil {
t.Fatal("Briefing.ThreeDay = nil")
if _, ok := pkg.Briefing.Values["derived_daypart_summaries"]; !ok {
t.Fatal("Briefing.Values[derived_daypart_summaries] missing")
}
}
func TestBuildWeekendDataPackage(t *testing.T) {
briefingPackage := validBriefingPackage()
briefingPackage.Metadata.RunID = "20260529T100000Z_weekend"
briefingPackage.Metadata.ReportID = report.Weekend
briefingPackage.Metadata.PromptID = "weather.weekend_outlook"
briefingPackage.Daily = nil
briefingPackage.Weekend = &briefing.Weekend{
Days: []briefing.OutlookDay{{Date: "2026-05-30"}},
}
pkg, err := Build(briefingPackage)
func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) {
pkg, err := Build(validBuildRequest(t))
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if pkg.Report.ID != report.Weekend {
t.Fatalf("Report.ID = %q, want weekend", pkg.Report.ID)
}
if pkg.Briefing.Weekend == nil {
t.Fatal("Briefing.Weekend = nil")
}
}
func TestBuildStormDataPackage(t *testing.T) {
briefingPackage := validBriefingPackage()
briefingPackage.Metadata.RunID = "20260529T100000Z_storm"
briefingPackage.Metadata.ReportID = report.Storm
briefingPackage.Metadata.PromptID = "weather.storm_report"
briefingPackage.Daily = nil
briefingPackage.Storm = &briefing.Storm{
TimingWindow: briefingPackage.Metadata.ValidPeriod,
Hazards: []string{"Thunderstorms"},
}
pkg, err := Build(briefingPackage)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if pkg.Report.ID != report.Storm {
t.Fatalf("Report.ID = %q, want storm", pkg.Report.ID)
}
if pkg.Report.PromptID != "weather.storm_report" {
t.Fatalf("PromptID = %q, want weather.storm_report", pkg.Report.PromptID)
}
if pkg.Briefing.Storm == nil {
t.Fatal("Briefing.Storm = nil")
}
}
func TestMarshalDeterministic(t *testing.T) {
pkg, err := Build(validBriefingPackage())
if err != nil {
t.Fatalf("Build() error = %v", err)
}
first, err := json.MarshalIndent(pkg, "", " ")
first, err := MarshalYAML(pkg)
if err != nil {
t.Fatalf("first marshal: %v", err)
}
second, err := json.MarshalIndent(pkg, "", " ")
second, err := MarshalYAML(pkg)
if err != nil {
t.Fatalf("second marshal: %v", err)
}
if string(first) != string(second) {
t.Fatalf("JSON output changed between marshals:\n%s\n---\n%s", string(first), string(second))
t.Fatalf("YAML output changed between marshals:\n%s\n---\n%s", string(first), string(second))
}
if !strings.Contains(string(first), "schema_version: weatherreporter.data_package.v2") ||
!strings.Contains(string(first), "briefing:\n") ||
!strings.Contains(string(first), " applicable_risk_products:\n") ||
!strings.Contains(string(first), " derived_summaries:\n") ||
!strings.Contains(string(first), " narrative_products:\n") ||
!strings.Contains(string(first), " raw_data:\n") ||
!strings.Contains(string(first), " current_conditions:\n") ||
!strings.Contains(string(first), " condition_text: Partly cloudy") {
t.Fatalf("YAML output missing expected grouped stanzas:\n%s", string(first))
}
for _, pair := range []struct {
before string
after string
}{
{before: " metadata:\n", after: " applicable_risk_products:\n"},
{before: " applicable_risk_products:\n", after: " derived_summaries:\n"},
{before: " derived_summaries:\n", after: " narrative_products:\n"},
{before: " narrative_products:\n", after: " raw_data:\n"},
} {
if strings.Index(string(first), pair.before) < 0 || strings.Index(string(first), pair.after) < 0 || strings.Index(string(first), pair.before) > strings.Index(string(first), pair.after) {
t.Fatalf("YAML category order is wrong, want %q before %q:\n%s", pair.before, pair.after, string(first))
}
}
}
func validBriefingPackage() briefing.Package {
func TestLoadYAMLRoundTrip(t *testing.T) {
pkg, err := Build(validBuildRequest(t))
if err != nil {
t.Fatalf("Build() error = %v", err)
}
data, err := MarshalYAML(pkg)
if err != nil {
t.Fatalf("MarshalYAML() error = %v", err)
}
loaded, err := LoadYAML(data)
if err != nil {
t.Fatalf("LoadYAML() error = %v", err)
}
if loaded.SchemaVersion != SchemaVersion || loaded.RunID != pkg.RunID {
t.Fatalf("loaded package = %#v, want schema and run id", loaded)
}
wantOrder := []string{"metadata", "alert_digest", "derived_daily_summary", "narrative_forecast", "current_conditions"}
if strings.Join(loaded.Briefing.Order, ",") != strings.Join(wantOrder, ",") {
t.Fatalf("loaded package order = %#v, want grouped category order %#v", loaded.Briefing.Order, wantOrder)
}
if got := loaded.Briefing.Values["current_conditions"].(map[string]any)["condition_text"]; got != "Partly cloudy" {
t.Fatalf("loaded current_conditions.condition_text = %#v, want Partly cloudy", got)
}
}
func TestMarshalYAMLRejectsUncategorizedStanza(t *testing.T) {
req := validBuildRequest(t)
req.Modules = snapshotWithOutputs(t, module.Output{ID: module.ID("custom"), StanzaName: "custom", Value: map[string]string{"value": "x"}})
_, err := Build(req)
if err == nil || !strings.Contains(err.Error(), `briefing stanza "custom" has no prompt-input category`) {
t.Fatalf("Build() error = %v, want uncategorized stanza error", err)
}
}
func TestLoadYAMLRejectsMisplacedStanza(t *testing.T) {
data := []byte(`
schema_version: weatherreporter.data_package.v2
run_id: 20260529T100000Z_daily_today
report:
id: daily_today
prompt_id: weather.daily_report
generated_at: 2026-05-29T10:00:00Z
timezone: America/Chicago
current_local_date: "2026-05-29"
valid_period:
start: 2026-05-29T05:00:00Z
end: 2026-05-30T05:00:00Z
briefing:
metadata:
run_id: 20260529T100000Z_daily_today
raw_data:
alert_digest:
checked: true
recent_changes:
items: []
`)
_, err := LoadYAML(data)
if err == nil || !strings.Contains(err.Error(), `briefing stanza "alert_digest" belongs under category "applicable_risk_products"`) {
t.Fatalf("LoadYAML() error = %v, want misplaced stanza error", err)
}
}
func validBuildRequest(t *testing.T) BuildRequest {
t.Helper()
generatedAt := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC)
return briefing.Package{
Metadata: briefing.Metadata{
SchemaVersion: briefing.SchemaVersion,
return BuildRequest{
Metadata: Metadata{
RunID: "20260529T100000Z_daily_today",
ReportID: report.DailyToday,
Variant: "today",
PromptID: "weather.daily_report",
GeneratedAt: generatedAt,
Units: "us",
Timezone: "America/Chicago",
ValidPeriod: timeutil.Period{
Start: time.Date(2026, 5, 29, 5, 0, 0, 0, time.UTC),
End: time.Date(2026, 5, 30, 5, 0, 0, 0, time.UTC),
},
},
Daily: &briefing.Daily{
ForecastSummaryDate: "2026-05-29",
},
Modules: snapshotWithOutputs(t,
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": "20260529T100000Z_daily_today"}},
module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: map[string]string{"condition_text": "Partly cloudy"}},
module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]string{"date": "2026-05-29"}},
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: map[string]bool{"checked": true}},
module.Output{ID: module.NarrativeForecast, StanzaName: "narrative_forecast", Value: map[string]string{"product": "narrative"}},
),
}
}
func snapshotWithOutputs(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
}

View File

@@ -0,0 +1,82 @@
package report
import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func dailyTodayDefinition() Definition {
return Definition{
ID: DailyToday,
Name: "Daily Report",
PromptID: "weather.daily_report",
ComparisonStrategy: CompareSameValidDate,
ArtifactGroup: "daily",
BatchOutputName: "daily.md",
Generated: true,
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
Modules: dailyTodayModules(),
Morning: true,
resolve: resolveDailyToday,
}
}
func dailyTomorrowDefinition() Definition {
return Definition{
ID: DailyTomorrow,
Name: "Tomorrow Planning Brief",
PromptID: "weather.daily_report",
ComparisonStrategy: CompareSameValidDate,
ArtifactGroup: "daily",
BatchOutputName: "tomorrow.md",
Generated: true,
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
Modules: dailyTomorrowModules(),
Evening: true,
resolve: resolveDailyTomorrow,
}
}
func dailyTodayModules() []module.ConfigItem {
return moduleItems(
module.Metadata,
module.CurrentConditions,
module.NarrativeForecast,
module.DerivedDailySummary,
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.AreaForecastDiscussion,
module.WeatherStory,
module.OutdoorWindows,
module.HourlyForecast,
)
}
func dailyTomorrowModules() []module.ConfigItem {
return moduleItems(
module.Metadata,
module.CurrentConditions,
module.NarrativeForecast,
module.DerivedDailySummary,
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.AreaForecastDiscussion,
module.WeatherStory,
module.OutdoorWindows,
module.TomorrowPlanning,
module.HourlyForecast,
)
}
func resolveDailyToday(req ResolveRequest) (timeutil.Period, error) {
if !req.Date.IsZero() {
return timeutil.CivilDay(req.Date, req.Location), nil
}
return timeutil.CivilDay(req.Now, req.Location), nil
}
func resolveDailyTomorrow(req ResolveRequest) (timeutil.Period, error) {
return timeutil.CivilDay(req.Now.In(req.Location).AddDate(0, 0, 1), req.Location), nil
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
@@ -38,7 +39,11 @@ type Definition struct {
Name string
PromptID string
ComparisonStrategy ComparisonStrategy
DefaultOutputName string
ArtifactGroup string
BatchOutputName string
Generated bool
CompatiblePriorIDs []ID
Modules []module.ConfigItem
Morning bool
Evening bool
resolve func(ResolveRequest) (timeutil.Period, error)
@@ -51,6 +56,23 @@ func (d Definition) ResolvePeriod(req ResolveRequest) (timeutil.Period, error) {
return d.resolve(req)
}
func (d Definition) CompatibleWithPrior(id ID) bool {
for _, compatibleID := range d.CompatiblePriorIDs {
if id == compatibleID {
return true
}
}
return false
}
func (d Definition) ModuleIDs() []module.ID {
ids := make([]module.ID, 0, len(d.Modules))
for _, item := range d.Modules {
ids = append(ids, item.ID)
}
return ids
}
type ResolveRequest struct {
Now time.Time
Location *time.Location

Some files were not shown because too many files have changed in this diff Show More