Compare commits
29 Commits
88aaae3661
...
v0.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 63749a9572 | |||
| 9ff90d33fc | |||
| 942e8ff591 | |||
| 0b050256f9 | |||
| 8a762bf34f | |||
| 42defcf4b9 | |||
| 3e93a97d10 | |||
| 26e6f33cde | |||
| 1bc0739d31 | |||
| 8476dab844 | |||
| 745992886c | |||
| 8089f62806 | |||
| a34aec1dd2 | |||
| 448bd1e510 | |||
| 4e23e1e11f | |||
| 5d3b850e46 | |||
| 4f45dee332 | |||
| 1355605e70 | |||
| 7dc2ac9253 | |||
| 6915bf1ba2 | |||
| ac6ede8f9c | |||
| bcb4a64c68 | |||
| 7a970148f3 | |||
| 0759e1598f | |||
| 25ad8959a6 | |||
| 4f530b2b6a | |||
| f23af43013 | |||
| 62827cf56d | |||
| 5c9333feec |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
# Compiled application binary
|
# Compiled application binary and testing workspace
|
||||||
/weatherreporter
|
/weatherreporter
|
||||||
|
/workspace
|
||||||
|
|
||||||
# ---> Go
|
# ---> Go
|
||||||
# If you prefer the allow list template instead of the deny list, see community template:
|
# If you prefer the allow list template instead of the deny list, see community template:
|
||||||
|
|||||||
50
.woodpecker/release.yml
Normal file
50
.woodpecker/release.yml
Normal 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
|
||||||
15
README.md
15
README.md
@@ -1,21 +1,14 @@
|
|||||||
# weatherreporter
|
# weatherreporter
|
||||||
|
|
||||||
`weatherreporter` is a Go application for preparing human-facing weather
|
`weatherreporter` is a Go application for preparing human-facing weather
|
||||||
reports from normalized forecast data.
|
reports from normalized forecast data. It builds structured briefing packages,
|
||||||
|
runs them through `scriptorium`, and keeps inspectable artifacts under a local
|
||||||
The application can currently generate Daily Today, Daily Tomorrow, 3-Day
|
workspace.
|
||||||
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.
|
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
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
|
## Documentation
|
||||||
@@ -23,6 +16,6 @@ weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 -
|
|||||||
- [CLI reference](docs/cli.md)
|
- [CLI reference](docs/cli.md)
|
||||||
- [Configuration reference](docs/config.md)
|
- [Configuration reference](docs/config.md)
|
||||||
- [Operations guide](docs/operations.md)
|
- [Operations guide](docs/operations.md)
|
||||||
|
- [Troubleshooting](docs/troubleshooting.md)
|
||||||
- [Architecture policy](docs/policy/architecture.md)
|
- [Architecture policy](docs/policy/architecture.md)
|
||||||
- [Development policy](docs/policy/development.md)
|
- [Development policy](docs/policy/development.md)
|
||||||
- [Implementation roadmap](docs/roadmap/initial.md)
|
|
||||||
|
|||||||
119
docs/cli.md
119
docs/cli.md
@@ -1,11 +1,7 @@
|
|||||||
# Weatherreporter CLI
|
# Weatherreporter CLI
|
||||||
|
|
||||||
`weatherreporter generate daily`, `weatherreporter generate tomorrow`,
|
`weatherreporter` generates Markdown weather reports, runs scheduled report
|
||||||
`weatherreporter generate three-day`, `weatherreporter generate weekend`,
|
batches, and inspects previously generated artifacts.
|
||||||
`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.
|
|
||||||
|
|
||||||
## Shortest Useful Command
|
## Shortest Useful Command
|
||||||
|
|
||||||
@@ -13,82 +9,69 @@ artifacts and running `scriptorium render` as a preflight check.
|
|||||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
||||||
```
|
```
|
||||||
|
|
||||||
The command parses flags, loads configuration, fetches weather data, builds a
|
This loads configuration, fetches weather data, writes managed workspace
|
||||||
Daily briefing, writes workspace artifacts, invokes
|
artifacts, runs `scriptorium render` as a preflight check, runs
|
||||||
`scriptorium render --input data_package=<managed_path> --format json`, then
|
`scriptorium run`, and writes an extra Markdown copy to `./daily.md`.
|
||||||
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.
|
|
||||||
|
|
||||||
For tomorrow planning:
|
## Commands
|
||||||
|
|
||||||
```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
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
weatherreporter generate daily
|
weatherreporter --help
|
||||||
weatherreporter generate tomorrow
|
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
||||||
weatherreporter generate three-day
|
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
weatherreporter generate weekend
|
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
|
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
weatherreporter run morning
|
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
|
||||||
weatherreporter run evening
|
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
|
||||||
weatherreporter inspect reports
|
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
|
||||||
weatherreporter inspect metadata RUN_ID
|
weatherreporter inspect reports [--config PATH] [--limit N]
|
||||||
weatherreporter inspect briefing RUN_ID
|
weatherreporter inspect metadata [--config PATH] RUN_ID
|
||||||
weatherreporter inspect data-package RUN_ID
|
weatherreporter inspect briefing [--config PATH] RUN_ID
|
||||||
weatherreporter inspect prior RUN_ID
|
weatherreporter inspect data-package [--config PATH] RUN_ID
|
||||||
weatherreporter inspect sources RUN_ID
|
weatherreporter inspect prior [--config PATH] RUN_ID
|
||||||
|
weatherreporter inspect sources [--config PATH] RUN_ID
|
||||||
```
|
```
|
||||||
|
|
||||||
`generate daily`, `generate tomorrow`, `generate three-day`,
|
`generate` commands write briefing, data package, preflight, report, and
|
||||||
`generate weekend`, and `generate storm` write a briefing snapshot, prompt
|
metadata artifacts under the configured workspace. `generate storm` requires
|
||||||
input data package, render preflight output, Markdown report, and metadata file
|
explicit event-window bounds with `--start` and `--end`.
|
||||||
under the configured workspace. `generate storm` requires explicit `--start`
|
|
||||||
and `--end` bounds for the event window. `run evening` generates the Tomorrow
|
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
||||||
Planning Brief. `run morning` generates Daily Today and the 3-Day Outlook, plus
|
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch
|
||||||
Weekend Outlook except on Sunday. Run commands continue remaining reports after
|
runs continue independent reports after a failure, print a JSON summary to
|
||||||
an independent report failure, print a JSON aggregate summary to stdout, write
|
stdout, write compact status lines to stderr, and return nonzero when any report
|
||||||
compact report status logs to stderr, and return nonzero when any report
|
|
||||||
failed.
|
failed.
|
||||||
|
|
||||||
`inspect` commands read the configured workspace and emit JSON to stdout. They
|
`inspect` commands read existing workspace artifacts and emit JSON to stdout.
|
||||||
do not fetch weather data or invoke `scriptorium`.
|
They do not fetch weather data or invoke `scriptorium`.
|
||||||
|
|
||||||
## Flags
|
## Flags
|
||||||
|
|
||||||
- `-h`, `--help`: show help.
|
- `-h`, `--help`: show help.
|
||||||
- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`.
|
- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`.
|
||||||
- `--units VALUE`: override configured Weather API units.
|
- `--units VALUE`: override configured Weather API units for `generate` and `run`.
|
||||||
- `--tz NAME`: override configured Weather API timezone.
|
- `--tz NAME`: override configured Weather API timezone for `generate` and `run`.
|
||||||
- `--out PATH`: optional Markdown report copy for `generate daily`, `generate tomorrow`, `generate three-day`, `generate weekend`, and `generate storm`.
|
- `--out PATH`: write an extra Markdown report copy for `generate` commands.
|
||||||
- `--out-dir PATH`: optional directory for extra Markdown report copies from `run morning` and `run evening`.
|
- `--out-dir PATH`: write extra Markdown report copies for `run morning` and `run evening`.
|
||||||
- `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone.
|
- `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone.
|
||||||
- `--start TIME`: required start time for `generate storm`.
|
- `--start TIME`: required start time for `generate storm`.
|
||||||
- `--end TIME`: required end time for `generate storm`.
|
- `--end TIME`: required end time for `generate storm`.
|
||||||
- `--limit N`: maximum report records for `inspect reports`; defaults to 20,
|
- `--limit N`: maximum records for `inspect reports`; defaults to `20`, and `0` means no limit.
|
||||||
and `0` means no limit.
|
|
||||||
|
|
||||||
Storm times accept `YYYY-MM-DDTHH:MM` in the configured timezone or RFC3339
|
Storm times accept `YYYY-MM-DDTHH:MM` in the configured timezone or RFC3339
|
||||||
timestamps with explicit offsets.
|
timestamps with explicit offsets.
|
||||||
|
|
||||||
|
## 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
|
## Inspection
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -100,8 +83,8 @@ weatherreporter inspect prior 20260529T100000.000000000Z_daily_today
|
|||||||
weatherreporter inspect sources 20260529T100000.000000000Z_daily_today
|
weatherreporter inspect sources 20260529T100000.000000000Z_daily_today
|
||||||
```
|
```
|
||||||
|
|
||||||
`inspect reports` lists recent generated runs with artifact paths and warning
|
`inspect reports` lists recent generated runs with artifact paths and source
|
||||||
counts. The other commands require a RunID. `inspect prior` returns the prior
|
warning counts. The other inspect commands require a RunID. `inspect prior`
|
||||||
comparable snapshot metadata selected from stored metadata, or `null` when no
|
returns the prior comparable snapshot metadata selected from stored metadata, or
|
||||||
prior comparable snapshot exists. `inspect sources` shows source provenance and
|
`null` when none exists. `inspect sources` shows source provenance and source
|
||||||
source warnings without dumping full weather payloads.
|
warnings without dumping full weather payloads.
|
||||||
|
|||||||
144
docs/config.md
144
docs/config.md
@@ -1,46 +1,128 @@
|
|||||||
# Weatherreporter Configuration
|
# Weatherreporter Configuration
|
||||||
|
|
||||||
Configuration is loaded from `/usr/local/etc/weatherreporter/config.yml` by
|
Configuration is YAML. By default, `weatherreporter` reads:
|
||||||
default. Use `--config PATH` to load a different file. CLI flags override file
|
|
||||||
values.
|
|
||||||
|
|
||||||
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 implemented configuration overrides are `--units` and `--tz`. Output flags
|
||||||
|
control report copies for the current command but do not change configuration
|
||||||
|
files. Environment-variable configuration is not implemented.
|
||||||
|
|
||||||
## Minimal Config
|
## Minimal Config
|
||||||
|
|
||||||
|
See [examples/minimal-config.yml](../examples/minimal-config.yml).
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
weather_api:
|
weather_api:
|
||||||
base_url: https://weather.api.example.com/
|
base_url: https://weather.api.example.com/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`weather_api.base_url` is required for commands that fetch weather data. Other
|
||||||
|
fields fall back to defaults.
|
||||||
|
|
||||||
## Production-Oriented Config
|
## 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`
|
||||||
- `weather_api.timeout`: HTTP timeout duration. Default: `10s`.
|
|
||||||
- `weather_api.precision`: numeric precision hint. Default: `1`.
|
- `base_url`: absolute base URL for the Weather API. Required for generation and fetch workflows.
|
||||||
- `weather_api.units`: Weather API units. Default: `us`.
|
- `timeout`: HTTP timeout duration. Default: `10s`.
|
||||||
- `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`.
|
- `precision`: numeric precision query value. Default: `1`.
|
||||||
- `weather_api.format`: Weather API response format. Default: `json`.
|
- `units`: Weather API units query value. Default: `us`.
|
||||||
- `missing_source.default`: one of `error`, `warn`, or `none`. Default: `warn`.
|
- `timezone`: report timezone and Weather API timezone query value where supported. Default: `America/Chicago`.
|
||||||
- `missing_source.sources`: optional per-source missing-source policy overrides.
|
- `format`: Weather API response format. Must be `json`. Default: `json`.
|
||||||
- `scriptorium.binary`: `scriptorium` executable name. Default: `scriptorium`.
|
|
||||||
- `scriptorium.config_path`: optional `scriptorium` config path.
|
Timezone values may be IANA names, configured aliases such as `Chicago` and
|
||||||
- `scriptorium.profile`: optional `scriptorium` profile.
|
`Stl`, US timezone abbreviations, or UTC offsets such as `-5` and `+09:30`.
|
||||||
- `scriptorium.timeout`: subprocess timeout. Default: `2m`.
|
|
||||||
- `scriptorium.extra_args`: optional extra arguments reserved for the adapter.
|
### `location`
|
||||||
- `workspace.root`: workspace root. Default: `workspace`.
|
|
||||||
- `workspace.snapshots_dir`: snapshot directory under the workspace.
|
`location` is descriptive prompt context included in briefing metadata and
|
||||||
- `workspace.reports_dir`: managed report directory under the workspace.
|
Scriptorium data packages. It does not select a Weather API endpoint or enable
|
||||||
- `workspace.data_packages_dir`: prompt input package directory under the workspace.
|
multiple configured forecast locations.
|
||||||
- `workspace.preflight_dir`: preflight output directory under the workspace.
|
|
||||||
- `reports.output_dir`: report output directory. Default: `reports`.
|
- `id`: short local identifier. Default: `home`.
|
||||||
- `reports.paths`: optional report-specific output paths.
|
- `name`: human-readable location name. Default: `Brentwood`.
|
||||||
- `dayparts`: named daypart definitions with `start` and `end` `HH:MM` values.
|
- `region`: broader forecast area context. Default: `St. Louis Metro`.
|
||||||
- `recent_change.temperature_degrees`: temperature change threshold.
|
|
||||||
- `recent_change.precip_probability_points`: precipitation probability threshold.
|
The prompt-facing location object also includes `timezone`, derived from the
|
||||||
- `recent_change.wind_gust_miles_per_hour`: wind gust change threshold.
|
effective `weather_api.timezone` after CLI overrides such as `--tz`.
|
||||||
- `recent_change.precip_timing_shift_minutes`: precipitation timing shift threshold.
|
|
||||||
|
### `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 and
|
||||||
|
stub source slots 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`: briefing 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`.
|
||||||
|
|
||||||
|
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 briefing
|
||||||
|
snapshot exists and a threshold is crossed.
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
Configuration files should not contain secrets. The current Weather API and
|
||||||
|
Scriptorium integration settings do not require secret fields.
|
||||||
|
|
||||||
|
## 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 implemented fields.
|
||||||
|
|
||||||
|
Both example files are loaded by the config test suite.
|
||||||
|
|||||||
@@ -1,34 +1,17 @@
|
|||||||
# weatherreporter Subprocess Integration
|
# Scriptorium Integration
|
||||||
|
|
||||||
|
This document describes the external Scriptorium CLI contract used by
|
||||||
|
`weatherreporter`.
|
||||||
|
|
||||||
## Purpose
|
## 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
|
Render preflight:
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
scriptorium render \
|
scriptorium render \
|
||||||
@@ -37,78 +20,78 @@ scriptorium render \
|
|||||||
--format json
|
--format json
|
||||||
```
|
```
|
||||||
|
|
||||||
weatherreporter may add:
|
Report generation:
|
||||||
|
|
||||||
- `--config <path>`
|
```bash
|
||||||
- `--profile <profile_id>`
|
scriptorium run \
|
||||||
- repeatable `--input name=path`
|
--prompt <prompt_id> \
|
||||||
- repeatable `--var name=value`
|
--input data_package=<path> \
|
||||||
- runtime overrides when explicitly needed (`--model`, `--llm-base-url`, `--timeout`, etc.)
|
--out <artifact_path>
|
||||||
|
```
|
||||||
|
|
||||||
## Config And Directory Behavior
|
`weatherreporter` always passes prompt input as
|
||||||
|
`--input data_package=<path>`. The data package is structured JSON created by
|
||||||
|
`internal/promptinput`.
|
||||||
|
|
||||||
weatherreporter can rely on resolved app config or pass explicit paths.
|
## Configured Arguments
|
||||||
|
|
||||||
- default config search order:
|
The adapter can prepend configured flags before prompt-specific arguments:
|
||||||
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
|
|
||||||
|
|
||||||
## 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`
|
`scriptorium.binary` selects the executable name or path. If unset inside the
|
||||||
2. prompt `default_profile`
|
adapter, it falls back to `scriptorium`.
|
||||||
3. error if neither is available
|
|
||||||
|
|
||||||
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`.
|
`scriptorium.timeout` limits each subprocess call when configured. Context
|
||||||
- Input names must match prompt definition input names.
|
cancellation or timeout returns an execution error.
|
||||||
- Variables use repeated `--var name=value` for small metadata values.
|
|
||||||
- Prefer file inputs for large content.
|
|
||||||
|
|
||||||
## 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`.
|
## Results
|
||||||
- Never pass raw API keys via CLI arguments.
|
|
||||||
- Keep subprocess environment scoped to required variables.
|
|
||||||
|
|
||||||
## 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
|
Run results include the same fields plus the requested output path.
|
||||||
- `--out`: writes artifact to file
|
|
||||||
- stderr: success summary and errors
|
|
||||||
|
|
||||||
`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
|
## Failure Behavior
|
||||||
- stderr: errors
|
|
||||||
|
|
||||||
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
|
Nonzero exits return both the captured result and an error containing the exit
|
||||||
- `1`: parse/config/load/render/generation/IO/runtime error
|
code and stderr. A `run` exit code such as `2` is still treated as an error by
|
||||||
- `2`: run completed but validation failed
|
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
|
## Security Notes
|
||||||
|
|
||||||
- Treat generated artifacts and stderr logs as potentially sensitive.
|
- The adapter does not invoke a shell.
|
||||||
- Avoid logging full rendered prompts by default in production contexts.
|
- Generated artifacts, rendered prompt context, stdout, and stderr can contain
|
||||||
- Use controlled output paths and access controls for persisted artifacts.
|
operationally sensitive data.
|
||||||
|
- API keys should be provided through the Scriptorium environment or
|
||||||
## Canonical References
|
Scriptorium configuration, not through `weatherreporter` CLI arguments.
|
||||||
|
|
||||||
- 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)
|
|
||||||
|
|||||||
@@ -1,354 +1,137 @@
|
|||||||
# 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 `forecast.Bundle`. This is an integration
|
||||||
|
contract for the project adapter, not a complete public API reference for the
|
||||||
|
upstream service.
|
||||||
|
|
||||||
## Base URL
|
## 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
|
Every response used by the adapter must be JSON with a top-level `data` field:
|
||||||
|
|
||||||
### Response envelope
|
|
||||||
|
|
||||||
All endpoints return a top-level envelope:
|
|
||||||
|
|
||||||
- `data`: endpoint payload or `null` when no current/latest resource is available.
|
|
||||||
|
|
||||||
JSON example:
|
|
||||||
|
|
||||||
```json
|
```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)
|
Malformed JSON envelopes, non-2xx statuses, and response read failures include
|
||||||
- `xml`
|
endpoint context in returned errors. Decode errors include source context when
|
||||||
- `text`
|
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)
|
- `format`: from `weather_api.format`; the implemented configuration requires
|
||||||
- `us`
|
`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`
|
- `/observations`
|
||||||
- controls decimal rounding of numeric output fields
|
- `/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:
|
Hourly forecast is required:
|
||||||
- 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
|
|
||||||
|
|
||||||
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`.
|
- `observations` for `/observations`
|
||||||
- Invalid parameter values are rejected with `400 Bad Request`.
|
- `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.
|
The adapter also creates a missing stub source record for `daily` because that
|
||||||
|
source slot exists in the internal bundle but is not fetched from the Weather
|
||||||
|
API.
|
||||||
|
|
||||||
## Endpoints
|
Policy behavior:
|
||||||
|
|
||||||
## `GET /observations`
|
- `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
|
||||||
|
|
||||||
Returns the latest weather observation.
|
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.
|
||||||
|
|
||||||
Query parameters:
|
## Source Identity
|
||||||
|
|
||||||
- `units`: `metric` | `us`
|
For source payloads accepted into the bundle, including the explicit `null`
|
||||||
- `format`: `json` | `xml` | `text`
|
alerts payload, the adapter records:
|
||||||
- `precision`: `0..2`
|
|
||||||
|
|
||||||
Response `data` fields:
|
- 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
|
||||||
|
|
||||||
- `stationId` (string, optional)
|
Warnings are recorded both on the affected source and on the bundle-level
|
||||||
- `stationName` (string, optional)
|
warnings list.
|
||||||
- `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)
|
|
||||||
|
|
||||||
## `GET /alerts/active`
|
## Compatibility Assumptions
|
||||||
|
|
||||||
Returns the latest active alert run.
|
The adapter expects payload fields compatible with the internal forecast bundle
|
||||||
|
types in `internal/forecast/bundle.go`, including:
|
||||||
|
|
||||||
Query parameters:
|
- 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
|
||||||
|
|
||||||
- `units`: `metric` | `us` (accepted; does not materially alter alert payload)
|
The adapter intentionally keeps upstream transport and envelope details inside
|
||||||
- `format`: `json` | `xml` | `text`
|
`internal/adapters/weatherapi`; downstream packages consume the normalized
|
||||||
|
bundle.
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|||||||
123
docs/internal/app-orchestration.md
Normal file
123
docs/internal/app-orchestration.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# App Orchestration Internals
|
||||||
|
|
||||||
|
This document describes the implemented 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 briefing and prompt-input artifacts, invokes Scriptorium through the
|
||||||
|
adapter 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
|
||||||
|
- `BriefingRequest` and `ReportRequest` for package-level orchestration tests
|
||||||
|
and internal composition
|
||||||
|
- resolved report definitions from `internal/report`
|
||||||
|
- forecast bundles from `internal/adapters/weatherapi`
|
||||||
|
- prior snapshots loaded from `internal/state`
|
||||||
|
- optional renderer and state-store fakes for tests
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
|
||||||
|
- generated report results with briefing, data package, preflight, report,
|
||||||
|
metadata, prior snapshot, Recent Changes, and Scriptorium result details
|
||||||
|
- batch summaries with per-report status, artifact paths, and error text
|
||||||
|
- saved Weather API bundle JSON for fetch workflows
|
||||||
|
- inspection JSON values for reports, metadata, briefings, 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, derive forecast
|
||||||
|
facts, define report periods, compare rendered Markdown, or construct
|
||||||
|
Scriptorium argv.
|
||||||
|
|
||||||
|
Report selection and report identity policy come from `internal/report`.
|
||||||
|
Weather API transport stays in `internal/adapters/weatherapi`. Scriptorium
|
||||||
|
subprocess behavior stays in `internal/adapters/scriptorium`. Filesystem layout
|
||||||
|
and persisted metadata stay in `internal/state`.
|
||||||
|
|
||||||
|
## Config Fields Used
|
||||||
|
|
||||||
|
- `weather_api.*` for Weather API client construction and briefing metadata
|
||||||
|
- `scriptorium.*` for renderer construction
|
||||||
|
- `workspace.*` for filesystem state
|
||||||
|
- `dayparts` for daily and outlook summarization
|
||||||
|
- `recent_change.*` for structured Recent Changes thresholds
|
||||||
|
|
||||||
|
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 a report-specific briefing package.
|
||||||
|
6. Save the briefing snapshot.
|
||||||
|
7. Compute Recent Changes from structured prior and current briefings.
|
||||||
|
8. Build and save the 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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 briefing 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.
|
||||||
|
- 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 briefing snapshots.
|
||||||
|
- Metadata links artifacts produced for a run.
|
||||||
@@ -5,63 +5,66 @@ This document describes the implemented briefing package boundary.
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/briefing` builds structured report-specific briefing packages from
|
`internal/briefing` builds structured report-specific briefing packages from
|
||||||
forecast summaries and report metadata. The package currently implements Daily
|
resolved report metadata, forecast bundles, and derived forecast summaries.
|
||||||
Today, Daily Tomorrow, 3-Day Outlook, Weekend Outlook, and Storm Report
|
Briefings are curated inputs for prompt data packages, not rendered report
|
||||||
briefing content.
|
prose.
|
||||||
|
|
||||||
## Inputs and Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
Inputs:
|
Inputs:
|
||||||
|
|
||||||
- resolved report definition and valid period
|
- resolved report definition, generation time, timezone, and valid period
|
||||||
- forecast bundle
|
- forecast bundle with source provenance and warnings
|
||||||
- derived forecast summary or summaries
|
- derived daily or period summaries where required
|
||||||
- configured units and timezone
|
- configured units, timezone, and descriptive location context
|
||||||
|
|
||||||
Output:
|
Outputs:
|
||||||
|
|
||||||
- `briefing.Package` JSON containing common metadata and report-specific
|
- `briefing.Package` with common metadata and one report-specific content
|
||||||
briefing content
|
object for Daily, 3-Day, Weekend, or Storm Report
|
||||||
|
- optional `currentConditions` prompt context from normalized
|
||||||
|
`/conditions/current` data when available
|
||||||
|
- optional structured `weatherStory` context on report-specific briefing
|
||||||
|
objects when `/weatherstories/latest` is available
|
||||||
|
- optional JSON file written by `briefing.Save`
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
- Briefings are structured weather facts and context for later prompt input.
|
- This package selects and shapes weather facts for prompts.
|
||||||
- This package does not fetch weather data, compare prior snapshots, build
|
- It does not fetch weather data, compare prior snapshots, build
|
||||||
`scriptorium` data packages, or render final report prose.
|
`data_package` files, invoke Scriptorium, or write workflow metadata.
|
||||||
|
|
||||||
## Behavior
|
## Config Fields Used
|
||||||
|
|
||||||
- Common metadata includes schema version, RunID, report ID, variant, prompt ID,
|
The package receives configured units and timezone from the app layer. Daypart
|
||||||
generation time, units, timezone, valid period, source location, source
|
configuration is consumed by `internal/forecast` before briefing builders run.
|
||||||
provenance, hashes, and source warnings.
|
Configured `location` values are prompt context only; Weather API
|
||||||
- Daily content includes bottom-line inputs, daypart summaries, relevant alerts,
|
`sourceLocationId` and `sourceLocation` remain source provenance.
|
||||||
outdoor window inputs, narrative periods, discussion context, and weather
|
Current conditions are copied from the normalized `/conditions/current` bundle
|
||||||
story context when available.
|
source only; observation station and timestamp fields remain provenance.
|
||||||
- Daily Tomorrow also includes planning inputs for morning readiness,
|
|
||||||
commute/school/workday concerns, and what may change overnight.
|
## External Adapters Used
|
||||||
- 3-Day content includes one summary per local day or partial day, with overall
|
|
||||||
character, temperature range, precipitation, wind, risk, outdoor-window, and
|
None directly.
|
||||||
alert inputs, plus broader discussion and weather-story context when
|
|
||||||
available.
|
## State Or Manifest Behavior
|
||||||
- Weekend content uses the same daily outlook summaries and adds planning
|
|
||||||
inputs for best outdoor windows, worst weather windows, rain/storm timing,
|
`briefing.Save` writes briefing JSON atomically. Managed workspace placement is
|
||||||
comfort concerns, and confidence or uncertainty context.
|
owned by `internal/state`.
|
||||||
- Storm content uses the explicit event window and includes event headline
|
|
||||||
inputs, hazards, most-likely scenario inputs, reasonable worst-case inputs,
|
## Skip And Resume Behavior
|
||||||
confidence and uncertainty inputs, watch items, active alerts, relevant
|
|
||||||
hourly and narrative forecast periods, and available discussion or weather
|
None. Builders either return a complete briefing package or an error.
|
||||||
story context.
|
|
||||||
- Briefing JSON is written atomically by `briefing.Save`.
|
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
- Daily briefing construction requires a Daily report definition and a derived
|
- Daily briefing construction requires a Daily report definition and derived
|
||||||
daily forecast summary.
|
daily summary.
|
||||||
- 3-Day briefing construction requires a 3-Day report definition and at least
|
- 3-Day briefing construction requires a 3-Day report definition and at least
|
||||||
one derived daily summary in the outlook period.
|
one derived summary.
|
||||||
- Weekend briefing construction requires a Weekend report definition and at
|
- Weekend briefing construction requires a Weekend report definition and at
|
||||||
least one derived daily summary in the weekend period.
|
least one derived summary.
|
||||||
- Storm briefing construction requires a Storm Report definition and a forecast
|
- Storm briefing construction requires a Storm Report definition and forecast
|
||||||
bundle.
|
bundle.
|
||||||
- Save failures include path and operation context.
|
- Save failures include path and operation context.
|
||||||
|
|
||||||
@@ -74,11 +77,11 @@ Inspect:
|
|||||||
- `internal/briefing/weekend_test.go`
|
- `internal/briefing/weekend_test.go`
|
||||||
- `internal/briefing/storm_test.go`
|
- `internal/briefing/storm_test.go`
|
||||||
- `internal/app/app_test.go`
|
- `internal/app/app_test.go`
|
||||||
- `internal/cli/root_test.go`
|
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
- Weather facts come from normalized and derived source data.
|
- Briefings contain structured weather facts and source context.
|
||||||
- Briefing output remains JSON-inspectable.
|
- Common metadata includes RunID, report ID, prompt ID, valid period, source
|
||||||
- LLM prompt input packaging and `scriptorium` execution remain outside this
|
provenance, source hashes, source warnings, and configured prompt location.
|
||||||
|
- LLM prompt input packaging and Scriptorium execution remain outside this
|
||||||
boundary.
|
boundary.
|
||||||
|
|||||||
@@ -1,62 +1,61 @@
|
|||||||
# Changes Internals
|
# Changes Internals
|
||||||
|
|
||||||
This document describes the implemented structured change comparison boundary.
|
This document describes structured Recent Changes comparison.
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/changes` compares current and prior structured briefing snapshots and
|
`internal/changes` compares current and prior briefing packages and emits
|
||||||
produces compact change records for prompt input data packages.
|
compact change records for prompt input data packages.
|
||||||
|
|
||||||
## Inputs and Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
Inputs:
|
Inputs:
|
||||||
|
|
||||||
- prior briefing package
|
- prior briefing package
|
||||||
- current briefing package
|
- current briefing package
|
||||||
- configured Recent Changes thresholds
|
- comparison thresholds from configuration
|
||||||
|
|
||||||
Output:
|
Outputs:
|
||||||
|
|
||||||
- ordered `changes.Change` records with type, message, previous value, and
|
- ordered `changes.Change` items with type, message, previous value, and current
|
||||||
current value where useful
|
value where useful
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
- This package compares structured briefing data only.
|
- This package compares structured briefing data only.
|
||||||
- It does not read state directly, render Markdown, invoke `scriptorium`, or
|
- It does not read filesystem state, find prior snapshots, render Markdown,
|
||||||
compare generated report text.
|
invoke Scriptorium, or compare generated report text.
|
||||||
|
|
||||||
## Config Fields Used
|
## 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.temperature_degrees`
|
||||||
- `recent_change.precip_probability_points`
|
- `recent_change.precip_probability_points`
|
||||||
- `recent_change.wind_gust_miles_per_hour`
|
- `recent_change.wind_gust_miles_per_hour`
|
||||||
- `recent_change.precip_timing_shift_minutes`
|
- `recent_change.precip_timing_shift_minutes`
|
||||||
|
|
||||||
## Behavior
|
## External Adapters Used
|
||||||
|
|
||||||
Daily, 3-Day, and Weekend comparison currently detect:
|
None.
|
||||||
|
|
||||||
- temperature changes crossing configured thresholds
|
## State Or Manifest Behavior
|
||||||
- precipitation probability and timing changes
|
|
||||||
- alert additions and removals
|
|
||||||
- peak wind gust changes
|
|
||||||
- snow, ice, and thunder risk changes
|
|
||||||
|
|
||||||
When no prior comparable snapshot exists, the app sends an empty Recent Changes
|
None directly. The app loads prior briefing snapshots through `internal/state`
|
||||||
section in the data package. Daily Today and Daily Tomorrow are compatible for
|
before calling comparison functions.
|
||||||
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
|
## Skip And Resume Behavior
|
||||||
Outlook compares with prior Weekend Outlook snapshots for the same weekend
|
|
||||||
window.
|
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
|
## Failure Behavior
|
||||||
|
|
||||||
Daily comparison requires both inputs to contain Daily briefing content. 3-Day
|
- Daily comparison requires both inputs to contain Daily briefing content.
|
||||||
comparison requires both inputs to contain 3-Day briefing content. Weekend
|
- 3-Day comparison requires both inputs to contain 3-Day briefing content.
|
||||||
comparison requires both inputs to contain Weekend briefing content.
|
- Weekend comparison requires both inputs to contain Weekend briefing content.
|
||||||
|
- Storm Report currently has no comparison implementation, so the app leaves
|
||||||
|
Recent Changes empty for Storm reports.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@@ -70,5 +69,6 @@ Inspect:
|
|||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
- Recent Changes are based on structured snapshots, not Markdown report text.
|
- Recent Changes are based on structured snapshots, not Markdown report text.
|
||||||
- Comparison thresholds come from configuration.
|
- Report compatibility is determined outside this package by report definitions
|
||||||
- The comparison output remains compact enough for prompt input.
|
and state lookup.
|
||||||
|
- Output stays compact enough for prompt input.
|
||||||
|
|||||||
@@ -1,57 +1,66 @@
|
|||||||
# Forecast Derivation Internals
|
# Forecast Derivation Internals
|
||||||
|
|
||||||
This document describes the implemented deterministic forecast summarization
|
This document describes deterministic forecast summarization in
|
||||||
boundary.
|
`internal/forecast`.
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/forecast` converts a normalized forecast bundle into inspectable
|
`internal/forecast` converts normalized bundle data into daily and period
|
||||||
daily and multi-day daypart summaries. These summaries are structured data for
|
summaries used by briefing builders.
|
||||||
later briefing builders; they are not rendered report text.
|
|
||||||
|
|
||||||
## Inputs and Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
Inputs:
|
Inputs:
|
||||||
|
|
||||||
- `forecast.Bundle`
|
- `forecast.Bundle`
|
||||||
- local date and timezone
|
- local date or resolved report period
|
||||||
- report period, for multi-day summaries
|
- timezone
|
||||||
- configured daypart definitions with `HH:MM` start and end values
|
- configured daypart definitions
|
||||||
|
|
||||||
Output:
|
Outputs:
|
||||||
|
|
||||||
- `forecast.DailySummary` with a civil-day period, daypart summaries, selected
|
- `forecast.DailySummary` for one local civil day
|
||||||
narrative periods, alert overlaps, discussion context, source warnings, and
|
- one clipped daily summary per local day or partial day from
|
||||||
source provenance.
|
`BuildPeriodDailySummaries`
|
||||||
- `forecast.BuildPeriodDailySummaries` output with one clipped daily summary
|
- daypart summaries with selected hourly periods, ranges, timed maximums,
|
||||||
for each local day or partial day in a report period.
|
conditions, indicators, and alert overlaps
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
- This package groups and summarizes already-normalized forecast data.
|
- This package groups, selects, and summarizes already-normalized forecast
|
||||||
- It does not fetch weather data, resolve report definitions, compare prior
|
data.
|
||||||
snapshots, build prompt input packages, or call `scriptorium`.
|
- 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.
|
- `dayparts[].name`
|
||||||
- Overnight dayparts are supported when the end clock is not after the start
|
- `dayparts[].start`
|
||||||
clock.
|
- `dayparts[].end`
|
||||||
- Hourly forecast periods are selected by overlap with the daypart window.
|
|
||||||
- Each daypart computes temperature range, apparent-temperature range, maximum
|
Threshold constants for basic indicators live in forecast code rather than
|
||||||
precipitation probability, peak wind speed, peak wind gust, dominant
|
configuration.
|
||||||
condition, notable conditions, and basic weather indicators.
|
|
||||||
- Alerts are selected by overlap with the daily period and each daypart.
|
## External Adapters Used
|
||||||
- Narrative periods and discussion context are selected as broader source
|
|
||||||
context for later briefing builders.
|
None directly. Forecast data arrives through `forecast.Bundle`.
|
||||||
- Multi-day period summaries clip the first and last local days to the resolved
|
|
||||||
report period before selecting hourly periods and alerts.
|
## State Or Manifest Behavior
|
||||||
|
|
||||||
|
None. Source warnings and provenance from the bundle are carried into summaries
|
||||||
|
for later metadata and briefing output.
|
||||||
|
|
||||||
|
## Skip And Resume Behavior
|
||||||
|
|
||||||
|
None. Missing optional source context can produce empty selections, but missing
|
||||||
|
required hourly data fails summarization.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
- Missing hourly forecast data returns an error.
|
- A nil bundle or missing hourly forecast data returns an error.
|
||||||
- Invalid daypart definitions return actionable parse errors.
|
- Invalid daypart definitions return parse errors with context.
|
||||||
- Alert records without parseable RFC3339 start/end fields are skipped.
|
- Alert records without parseable RFC3339 timing are skipped.
|
||||||
|
- Empty selected periods produce empty summaries rather than generated prose.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@@ -62,7 +71,6 @@ Inspect:
|
|||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
- Weather facts come from normalized source data, not generated prose.
|
- Go owns report-period selection and meteorological summarization.
|
||||||
- Outputs remain JSON-inspectable.
|
- Weather facts come from normalized source data.
|
||||||
- Forecast derivation remains independent of CLI, HTTP adapters, and report
|
- Outputs remain JSON-inspectable and independent of CLI, state, and adapters.
|
||||||
registry behavior.
|
|
||||||
|
|||||||
@@ -1,54 +1,69 @@
|
|||||||
# Prompt Input Internals
|
# Prompt Input Internals
|
||||||
|
|
||||||
This document describes the implemented prompt input package boundary.
|
This document describes prompt input data package construction.
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/promptinput` converts a structured briefing package into the
|
`internal/promptinput` converts a structured briefing package and optional
|
||||||
`data_package` JSON file passed to `scriptorium` prompts.
|
Recent Changes into the `data_package` JSON passed to Scriptorium prompts.
|
||||||
|
|
||||||
## Inputs and Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
Input:
|
Inputs:
|
||||||
|
|
||||||
- `briefing.Package` containing Daily-family, 3-Day Outlook, Weekend Outlook,
|
- `briefing.Package`
|
||||||
or Storm Report content
|
- optional `[]changes.Change`
|
||||||
|
|
||||||
Output:
|
Outputs:
|
||||||
|
|
||||||
- `promptinput.Package` JSON with report metadata, briefing content, source
|
- `promptinput.Package` containing schema version, RunID, report metadata,
|
||||||
warnings, RunID, and a Recent Changes section.
|
briefing content, Recent Changes, and source warnings. Briefing content
|
||||||
|
includes configured location context, current conditions when available,
|
||||||
|
structured weather story context when available, discussion key messages, and
|
||||||
|
short/long-term AFD narratives when the Weather API provides them.
|
||||||
|
- report metadata includes `currentLocalDate`, the generation date formatted as
|
||||||
|
`YYYY-MM-DD` in the effective report timezone.
|
||||||
|
- optional JSON file written by `promptinput.Save`
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
- This package owns the prompt input schema and required-field validation.
|
- This package owns the prompt input schema and validation.
|
||||||
- It does not fetch weather data, compute forecast summaries, compare prior
|
- It does not fetch weather data, derive forecast summaries, find prior
|
||||||
snapshots, or invoke `scriptorium`.
|
snapshots, compare changes, or invoke Scriptorium.
|
||||||
|
|
||||||
## Behavior
|
## Config Fields Used
|
||||||
|
|
||||||
- `promptinput.Build` copies report metadata from the briefing package.
|
None directly. Config-derived values, including timezone and prompt location
|
||||||
- `promptinput.Validate` rejects missing or inconsistent required fields before
|
context, are already present in briefing metadata before this package runs.
|
||||||
render preflight.
|
|
||||||
- `promptinput.Save` writes JSON atomically where practical.
|
## External Adapters Used
|
||||||
- Recent Changes is present as an `items` list. It is empty when no prior
|
|
||||||
comparable snapshot exists or no meaningful changes are detected.
|
None.
|
||||||
|
|
||||||
|
## State Or Manifest Behavior
|
||||||
|
|
||||||
|
`promptinput.Save` writes JSON 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
|
## Failure Behavior
|
||||||
|
|
||||||
Validation errors name the missing or inconsistent field. Save failures include
|
Validation fails before render preflight when required top-level or briefing
|
||||||
the filesystem operation and path context.
|
metadata fields are missing or inconsistent, or when no report content is
|
||||||
|
present. Save failures include filesystem operation and path context.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
Inspect:
|
Inspect:
|
||||||
|
|
||||||
- `internal/promptinput/package_test.go`
|
- `internal/promptinput/package_test.go`
|
||||||
- `internal/changes/daily_test.go`
|
|
||||||
- `internal/app/app_test.go`
|
- `internal/app/app_test.go`
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
- Prompt input data remains structured JSON.
|
- Scriptorium receives structured `data_package` JSON.
|
||||||
- Briefing metadata and top-level report metadata must agree.
|
- Briefing metadata and top-level report metadata must agree.
|
||||||
- Recent Changes is not inferred from rendered report text.
|
- Recent Changes are not inferred from rendered report text.
|
||||||
|
|||||||
@@ -1,53 +1,83 @@
|
|||||||
# Report Registry Internals
|
# Report Registry Internals
|
||||||
|
|
||||||
This document describes the implemented report identity and valid-period
|
This document describes report identity, valid-period resolution, batch
|
||||||
boundary.
|
membership, output naming, artifact grouping, and comparison declarations in
|
||||||
|
`internal/report`.
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/report` centralizes report IDs, prompt IDs, comparison strategies,
|
`internal/report` is the canonical source for report definitions. App, state,
|
||||||
valid-period resolution, report metadata, and scheduled batch membership.
|
briefing, 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
|
- report ID and display name
|
||||||
- generation time
|
- Scriptorium prompt ID
|
||||||
- configured timezone
|
- valid-period resolver
|
||||||
- optional Daily date override
|
- comparison strategy
|
||||||
- optional manual storm start and end times
|
- managed artifact group
|
||||||
|
- batch output copy filename
|
||||||
|
- generated-report eligibility
|
||||||
|
- prior-report compatibility list
|
||||||
|
- morning or evening batch membership
|
||||||
|
|
||||||
Outputs:
|
## Implemented Reports
|
||||||
|
|
||||||
- `report.Resolved` values with definition metadata and half-open valid periods
|
| Report | ID | Prompt | Artifact group | Batch copy | Prior compatibility |
|
||||||
- `report.Metadata` values suitable for later persisted run metadata
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| Daily Today | `daily_today` | `weather.daily_report` | `daily` | `daily.md` | Daily Today, Daily Tomorrow |
|
||||||
|
| Daily Tomorrow | `daily_tomorrow` | `weather.daily_report` | `daily` | `tomorrow.md` | Daily Today, Daily Tomorrow |
|
||||||
|
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `three-day` | `three-day.md` | 3-Day Outlook |
|
||||||
|
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `weekend` | `weekend.md` | Weekend Outlook |
|
||||||
|
| Storm Report | `storm` | `weather.storm_report` | `storm` | `storm.md` | Storm Report |
|
||||||
|
|
||||||
|
All implemented report definitions are eligible for generation.
|
||||||
|
|
||||||
|
## 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
|
## Boundaries
|
||||||
|
|
||||||
- This package defines report identity and time coverage only.
|
`internal/report` defines report metadata and time coverage. It does not fetch
|
||||||
- It does not fetch weather data, build briefings, compare snapshots, write
|
weather data, build briefings, compare briefing contents, write state, parse CLI
|
||||||
state, or call `scriptorium`.
|
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.
|
## Config Fields Used
|
||||||
- Daily Tomorrow covers the next configured local civil day.
|
|
||||||
- 3-Day Outlook covers generation time through local midnight after the second
|
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
|
||||||
following local civil day.
|
output path copying uses batch output names from report definitions.
|
||||||
- Weekend Outlook covers Saturday 00:00 to Monday 00:00 Monday through
|
|
||||||
Thursday; Friday and Saturday cover the remaining weekend from Friday 18:00
|
## State And App Usage
|
||||||
or generation time, whichever is later.
|
|
||||||
- Manual Storm Report uses explicit start and end times.
|
- State paths use `ArtifactGroup`.
|
||||||
- Morning batch resolves Daily Today and 3-Day Outlook, plus Weekend Outlook
|
- Batch output copies use `BatchOutputName`.
|
||||||
except on Sunday.
|
- Generation checks `Generated`.
|
||||||
- Evening batch resolves Daily Tomorrow.
|
- Prior lookup checks `CompatiblePriorIDs` and the comparison strategy.
|
||||||
|
- RunIDs include the resolved report ID.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
- Unknown report and batch names return actionable errors.
|
- Unknown report IDs and batch names return actionable errors.
|
||||||
- Sunday Weekend Outlook resolution returns an error.
|
- Weekend Outlook resolution returns an error when resolved directly on Sunday.
|
||||||
- Storm windows require start and end, with end after start.
|
- Storm Report resolution requires start and end, with end after start.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@@ -55,9 +85,13 @@ Inspect:
|
|||||||
|
|
||||||
- `internal/report/period_test.go`
|
- `internal/report/period_test.go`
|
||||||
- `internal/app/app_test.go`
|
- `internal/app/app_test.go`
|
||||||
|
- `internal/cli/root_test.go`
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
- Report selection goes through the registry.
|
- Report selection goes through the registry.
|
||||||
- Valid periods are independent of rendered report text.
|
- Daily Today and Daily Tomorrow both use `weather.daily_report`.
|
||||||
- Prompt IDs and comparison strategies are declared with report definitions.
|
- Valid periods are half-open intervals independent of rendered report text.
|
||||||
|
- Artifact grouping, batch output filenames, generated-report eligibility,
|
||||||
|
comparison compatibility, and comparison strategy are declared by report
|
||||||
|
definition.
|
||||||
|
|||||||
@@ -1,59 +1,88 @@
|
|||||||
# Scriptorium Adapter Internals
|
# Scriptorium Adapter Internals
|
||||||
|
|
||||||
This document describes the implemented `scriptorium` subprocess adapter.
|
This document describes the subprocess adapter in
|
||||||
|
`internal/adapters/scriptorium`.
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/adapters/scriptorium` runs `scriptorium render` to preflight prompt
|
The adapter runs `scriptorium render` for prompt preflight and `scriptorium run`
|
||||||
wiring and `scriptorium run` to generate report artifacts.
|
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 ID
|
||||||
- prompt input data package path
|
- prompt input data package path
|
||||||
- report output path for `run`
|
- report output path for `run`
|
||||||
- configured binary, config path, profile, timeout, and extra arguments
|
- configured binary, config path, profile, timeout, and extra arguments
|
||||||
|
- context for cancellation
|
||||||
|
|
||||||
Output:
|
Outputs:
|
||||||
|
|
||||||
- captured stdout, with truncation tracking
|
- argv used for execution
|
||||||
- captured stderr, with truncation tracking
|
- captured stdout and stderr
|
||||||
|
- truncation flags for captured output
|
||||||
- exit code
|
- exit code
|
||||||
- full argv used for inspection
|
- report output path for `run`
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
- This adapter owns `scriptorium` CLI flag construction and subprocess
|
`internal/adapters/scriptorium` owns Scriptorium command construction and
|
||||||
execution.
|
subprocess execution. It does not choose report types, build prompt input,
|
||||||
- It does not choose report types, build prompt input, fetch weather data, or
|
fetch weather data, decide workflow order, or persist workflow metadata.
|
||||||
decide workflow order.
|
|
||||||
|
|
||||||
## 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
|
```text
|
||||||
scriptorium render --prompt <prompt_id> --input data_package=<path> --format json
|
scriptorium render --prompt <prompt_id> --input data_package=<path> --format json
|
||||||
```
|
```
|
||||||
|
|
||||||
The run invocation shape is:
|
Report generation argv starts with:
|
||||||
|
|
||||||
```text
|
```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
|
Configured `--config` and `--profile` flags are inserted after the subcommand
|
||||||
are passed directly as argv, not through a shell. Stdout and stderr are captured
|
and before prompt-specific arguments. Extra arguments are appended after the
|
||||||
separately. `SaveRenderResult` writes the captured result as JSON for inspection.
|
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
|
## Failure Behavior
|
||||||
|
|
||||||
Nonzero render and run exits return both the captured result and an error
|
- Missing prompt ID or data package path returns an error before subprocess
|
||||||
containing the exit code and stderr. Run exit code `2` is treated as an error
|
execution.
|
||||||
but may still produce a report artifact. Command execution respects context
|
- Missing run output path returns an error before subprocess execution.
|
||||||
cancellation and the configured timeout.
|
- 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
|
## Tests
|
||||||
|
|
||||||
@@ -65,7 +94,7 @@ Inspect:
|
|||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
- `scriptorium` details stay inside the adapter package.
|
- No shell interpolation is used.
|
||||||
- The input name for prompt packages is always `data_package`.
|
- The Scriptorium input name is `data_package`.
|
||||||
- Render preflight remains orchestration behavior; this adapter only exposes the
|
- Render and run preserve command-specific result structs.
|
||||||
subprocess operations.
|
- Scriptorium-specific flags stay inside adapter and config boundaries.
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
# State Internals
|
# State Internals
|
||||||
|
|
||||||
This document describes the implemented filesystem state boundary.
|
This document describes filesystem state in `internal/state`.
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/state` owns durable artifact paths, atomic JSON writes, metadata, and
|
`internal/state` owns managed workspace paths, atomic JSON writes, persisted
|
||||||
prior comparable snapshot lookup.
|
metadata, prior snapshot lookup, and read-only artifact inspection helpers.
|
||||||
|
|
||||||
## Inputs and Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
Inputs:
|
Inputs:
|
||||||
|
|
||||||
@@ -15,26 +15,30 @@ Inputs:
|
|||||||
- resolved report definition and valid period
|
- resolved report definition and valid period
|
||||||
- briefing package
|
- briefing package
|
||||||
- prompt input data package
|
- prompt input data package
|
||||||
- `scriptorium render` result
|
- preflight artifact
|
||||||
- rendered report path preparation
|
- rendered report path preparation request
|
||||||
|
- RunID for inspection lookups
|
||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
|
|
||||||
- briefing snapshot JSON
|
- briefing snapshot JSON path
|
||||||
- prompt input data package JSON
|
- prompt input data package JSON path
|
||||||
- render preflight JSON
|
- render preflight JSON path
|
||||||
- Markdown report path
|
- managed Markdown report path
|
||||||
- metadata JSON
|
- metadata JSON path
|
||||||
- prior comparable snapshot metadata when available
|
- prior comparable snapshot metadata
|
||||||
- prior briefing package when loaded by path
|
- loaded briefing or data package
|
||||||
- recent report records for inspection
|
- recent report records for inspection
|
||||||
- metadata and data package lookup by RunID
|
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
- This package owns managed workspace layout and narrow path validation.
|
`internal/state` owns local filesystem layout, path validation, durable writes,
|
||||||
- It does not fetch weather data, derive forecasts, build prompt inputs, invoke
|
metadata reads, prior lookup, and report listing. It does not fetch weather
|
||||||
`scriptorium`, or compare briefing contents.
|
data, derive forecasts, build prompt input content, compare briefing 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
|
## Config Fields Used
|
||||||
|
|
||||||
@@ -47,25 +51,56 @@ Outputs:
|
|||||||
Workspace subdirectories must be relative paths that stay under
|
Workspace subdirectories must be relative paths that stay under
|
||||||
`workspace.root`.
|
`workspace.root`.
|
||||||
|
|
||||||
## State Behavior
|
## Managed Layout
|
||||||
|
|
||||||
Managed artifact names use RunID, which is generated from report generation time
|
Paths are derived from the resolved report definition's artifact group, the
|
||||||
and report ID. Metadata is stored beside briefing snapshots by report group and
|
valid-period start date for JSON artifacts, and the RunID.
|
||||||
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
|
```text
|
||||||
Tomorrow are compatible with each other; 3-Day Outlook is compatible with prior
|
<workspace.root>/
|
||||||
3-Day Outlook snapshots; Weekend Outlook is compatible with prior Weekend
|
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.briefing.json
|
||||||
Outlook snapshots for the same weekend window. The store can load a briefing
|
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.metadata.json
|
||||||
snapshot by path for structured comparison. The store can list metadata-backed
|
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.json
|
||||||
report records and load metadata or data packages by RunID for inspection. The
|
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json
|
||||||
store prepares the managed Markdown report path before `scriptorium run` writes
|
reports/<artifact_group>/<run_id>.md
|
||||||
it.
|
```
|
||||||
|
|
||||||
|
Metadata is stored beside briefing snapshots and links the briefing, data
|
||||||
|
package, preflight, report paths, 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 currently 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.
|
||||||
|
|
||||||
|
Inspection helpers read existing metadata, briefing, and data package files.
|
||||||
|
Missing metadata directories return no inspection records or no prior snapshot
|
||||||
|
rather than creating state.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
Writes are atomic where practical: JSON is written to a temporary file in the
|
- Invalid workspace paths return validation errors.
|
||||||
target directory and then renamed into place. Invalid workspace paths and
|
- Missing required metadata fields prevent metadata writes.
|
||||||
missing required metadata fields produce actionable errors.
|
- 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
|
## Tests
|
||||||
|
|
||||||
@@ -77,5 +112,6 @@ Inspect:
|
|||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
- Managed paths stay under the configured workspace root.
|
- 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.
|
- Prior lookup is based on structured metadata, not rendered report text.
|
||||||
|
|||||||
@@ -1,59 +1,78 @@
|
|||||||
# Weather Data Internals
|
# Weather Data Internals
|
||||||
|
|
||||||
This document describes the implemented weather data ingestion boundary.
|
This document describes Weather API ingestion into `forecast.Bundle`.
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/adapters/weatherapi` fetches normalized weather data from one
|
`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 briefing builders. Briefing 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`,
|
- `config.Config` with Weather API URL, timeout, format, units, timezone,
|
||||||
`precision`, timeout, and missing-source policy.
|
precision, and missing-source policy
|
||||||
|
- HTTP responses using the Weather API `data` envelope
|
||||||
|
|
||||||
Output:
|
Outputs:
|
||||||
|
|
||||||
- `forecast.Bundle` containing observation, current conditions, hourly forecast,
|
- `forecast.Bundle` with observation, current conditions, hourly forecast,
|
||||||
narrative forecast, alerts, discussion, stub source slots, provenance, and
|
narrative forecast, active alerts, discussion, latest weather story, source
|
||||||
source warnings.
|
records, and source warnings
|
||||||
|
- stub source record for the daily forecast source slot
|
||||||
|
- optional saved bundle JSON through app fetch helpers
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
- The adapter performs HTTP calls and decoding only.
|
- The adapter owns HTTP calls, response-envelope handling, source hashing, and
|
||||||
- Forecast derivation, daypart grouping, report periods, report rendering, and
|
decoding into internal bundle types.
|
||||||
`scriptorium` execution are outside this boundary.
|
- It does not derive dayparts, resolve report periods, build briefings, compare
|
||||||
- Hourly forecast data is required. Other missing or malformed source sections
|
snapshots, write report state, or invoke Scriptorium.
|
||||||
use the configured missing-source policy.
|
|
||||||
|
|
||||||
## 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`
|
## External Adapters Used
|
||||||
- `/conditions/current`
|
|
||||||
- `/forecast/hourly`
|
|
||||||
- `/forecast/narrative`
|
|
||||||
- `/alerts/active`
|
|
||||||
- `/discussion`
|
|
||||||
|
|
||||||
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
|
## State Or Manifest Behavior
|
||||||
atomic rename. No report state, snapshots, or prompt input packages are written
|
|
||||||
yet.
|
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
|
## Failure Behavior
|
||||||
|
|
||||||
- HTTP and envelope decode failures return actionable errors with endpoint
|
- Missing or invalid `weather_api.base_url` prevents client construction.
|
||||||
context.
|
- HTTP errors, response read failures, and envelope decode failures include
|
||||||
- Missing hourly data fails the fetch.
|
endpoint context.
|
||||||
- Missing or malformed optional sources follow `error`, `warn`, or `none`.
|
- Missing hourly data or hourly forecasts with no periods fail bundle fetch.
|
||||||
- Source identity uses SHA-256 over compacted raw `data` JSON.
|
- Optional and stub sources follow missing-source policy.
|
||||||
|
- Explicit `data: null` from `/alerts/active` produces an empty, non-missing
|
||||||
|
alert run.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@@ -65,5 +84,6 @@ Inspect:
|
|||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
- Weather facts come from normalized source data.
|
- Weather facts come from normalized source data.
|
||||||
- External API details stay inside `internal/adapters/weatherapi`.
|
- Full hourly and narrative products are fetched; Go owns report-period
|
||||||
- Source provenance and warnings remain inspectable for later briefing builders.
|
selection.
|
||||||
|
- Source provenance and warnings remain inspectable downstream.
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
# Weatherreporter Operations
|
# Weatherreporter Operations
|
||||||
|
|
||||||
|
This guide covers normal operation, generated artifacts, inspection, recovery,
|
||||||
|
and current operational caveats. For symptom-specific diagnosis, see
|
||||||
|
[Troubleshooting](troubleshooting.md).
|
||||||
|
|
||||||
## Normal Workflow
|
## Normal Workflow
|
||||||
|
|
||||||
The implemented generation workflows are:
|
Implemented generation commands:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
weatherreporter generate daily --date 2026-05-29
|
weatherreporter generate daily --date 2026-05-29
|
||||||
@@ -10,22 +14,27 @@ weatherreporter generate tomorrow
|
|||||||
weatherreporter generate three-day
|
weatherreporter generate three-day
|
||||||
weatherreporter generate weekend
|
weatherreporter generate weekend
|
||||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
|
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
|
||||||
|
```
|
||||||
|
|
||||||
|
Each command resolves a report period, fetches a Weather API bundle, builds a
|
||||||
|
briefing, builds a prompt input data package, runs `scriptorium render`, runs
|
||||||
|
`scriptorium run`, and writes managed artifacts under the configured workspace.
|
||||||
|
`--out PATH` writes an extra Markdown copy for the current generated report.
|
||||||
|
|
||||||
|
Implemented batch commands:
|
||||||
|
|
||||||
|
```text
|
||||||
weatherreporter run morning
|
weatherreporter run morning
|
||||||
weatherreporter run evening
|
weatherreporter run evening
|
||||||
```
|
```
|
||||||
|
|
||||||
These commands fetch weather data, build a briefing for the resolved valid
|
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
||||||
period, build the prompt input data package, run `scriptorium render`, run
|
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch
|
||||||
`scriptorium run`, and write inspectable artifacts under the configured
|
commands print a JSON summary to stdout, write compact per-report status lines
|
||||||
workspace. The evening run resolves only the Tomorrow Planning Brief. The
|
to stderr, continue independent reports after one report fails, and return
|
||||||
morning run generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
nonzero when any report failed. `--out-dir PATH` writes extra Markdown copies
|
||||||
except on Sunday. Storm Report generation is manual and uses the explicit
|
using report default filenames such as `daily.md`, `three-day.md`,
|
||||||
`--start` and `--end` bounds as its valid period.
|
`weekend.md`, and `tomorrow.md`.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Filesystem Layout
|
## Filesystem Layout
|
||||||
|
|
||||||
@@ -87,51 +96,37 @@ workspace/
|
|||||||
<run_id>.md
|
<run_id>.md
|
||||||
```
|
```
|
||||||
|
|
||||||
The Markdown report is written to a RunID-managed report path. When `--out` is
|
Managed artifact filenames use the RunID, so repeated runs for the same valid
|
||||||
provided to `generate daily`, `generate tomorrow`, `generate three-day`,
|
period do not overwrite each other.
|
||||||
`generate weekend`, or `generate storm`, the managed report is also copied to
|
|
||||||
that path.
|
|
||||||
|
|
||||||
For `run morning` and `run evening`, `--out-dir PATH` writes extra Markdown
|
## RunID And Metadata
|
||||||
copies using each report definition's default filename, such as `daily.md`,
|
|
||||||
`three-day.md`, `weekend.md`, or `tomorrow.md`.
|
|
||||||
|
|
||||||
## Run Identifiers
|
RunIDs are based on generation time plus report ID:
|
||||||
|
|
||||||
Run IDs are based on generation time plus report ID, such as:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
20260529T100000.123456789Z_daily_today
|
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:
|
Each generated report writes metadata that links:
|
||||||
|
|
||||||
- RunID
|
- RunID, report ID, variant, and prompt ID
|
||||||
- report ID and prompt ID
|
- generation time, timezone, and valid period
|
||||||
- generation time and valid period
|
|
||||||
- source location, source hashes, and source warnings
|
- source location, source hashes, and source warnings
|
||||||
- briefing snapshot path
|
- briefing snapshot path
|
||||||
- prompt input data package path
|
- prompt input data package path
|
||||||
- preflight output path
|
- preflight output path
|
||||||
- rendered report path
|
- managed Markdown report path
|
||||||
|
|
||||||
Run summaries include each report ID, prompt ID, RunID, status, error text when
|
Batch summaries include report status, error text when applicable, valid
|
||||||
applicable, valid period, and artifact paths known to the application.
|
period, and known artifact paths for each attempted report.
|
||||||
|
|
||||||
## Inspection
|
## Inspection
|
||||||
|
|
||||||
Use `weatherreporter inspect reports` to list recent generated runs from the
|
Inspection commands read existing workspace artifacts and emit JSON to stdout.
|
||||||
configured workspace. The output includes RunID, report ID, valid period,
|
They do not fetch weather data or run `scriptorium`.
|
||||||
metadata path, briefing path, report path, and source warning count.
|
|
||||||
|
|
||||||
Run-specific inspection commands emit JSON for a single RunID:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
weatherreporter inspect reports --limit 10
|
||||||
weatherreporter inspect metadata RUN_ID
|
weatherreporter inspect metadata RUN_ID
|
||||||
weatherreporter inspect briefing RUN_ID
|
weatherreporter inspect briefing RUN_ID
|
||||||
weatherreporter inspect data-package RUN_ID
|
weatherreporter inspect data-package RUN_ID
|
||||||
@@ -139,44 +134,54 @@ weatherreporter inspect prior RUN_ID
|
|||||||
weatherreporter inspect sources RUN_ID
|
weatherreporter inspect sources RUN_ID
|
||||||
```
|
```
|
||||||
|
|
||||||
`inspect prior` shows the prior comparable snapshot selected from stored
|
Use `inspect reports` to find recent RunIDs and artifact paths. Use
|
||||||
metadata, or `null` when none exists. `inspect sources` shows source provenance
|
`inspect metadata` to see the artifact links recorded for a run. Use
|
||||||
and source warnings without dumping full weather payloads.
|
`inspect briefing` and `inspect data-package` to review the exact structured
|
||||||
|
inputs 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
|
## Recent Changes
|
||||||
|
|
||||||
When a prior comparable Daily briefing snapshot exists for the same valid local
|
Recent Changes are computed from structured briefing snapshots, not rendered
|
||||||
date, the app compares structured briefing data before writing the prompt input
|
Markdown text.
|
||||||
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`.
|
|
||||||
|
|
||||||
3-Day Outlook generation compares against a prior compatible 3-Day briefing
|
Daily Today and Daily Tomorrow can compare with each other when they cover the
|
||||||
snapshot for the same valid local date when one exists.
|
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
|
||||||
Weekend Outlook generation compares against a prior compatible Weekend briefing
|
compatible Weekend snapshots for the same weekend window. Storm Report currently
|
||||||
snapshot for the same weekend window when one exists. Friday evening and
|
leaves Recent Changes empty.
|
||||||
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.
|
|
||||||
|
|
||||||
When no prior comparable snapshot exists, or no configured threshold is crossed,
|
When no prior comparable snapshot exists, or no configured threshold is crossed,
|
||||||
the Recent Changes list is empty.
|
`recentChanges.items` is empty.
|
||||||
|
|
||||||
## Recovery
|
## Recovery
|
||||||
|
|
||||||
If render preflight exits nonzero after producing a result, the captured stdout,
|
A failed generation run may still leave useful artifacts:
|
||||||
stderr, exit code, and command are still written to the preflight artifact, and
|
|
||||||
metadata is still written for inspection.
|
|
||||||
|
|
||||||
If `scriptorium run` exits nonzero after writing a report, the generated report
|
- If `scriptorium render` returns a result with a nonzero exit code, the
|
||||||
and metadata remain available for inspection. Exit code `2` is still returned as
|
preflight JSON and metadata are written for inspection.
|
||||||
an error because it indicates validation failed, even if report output exists.
|
- If `scriptorium run` exits nonzero after writing a report, the managed report
|
||||||
|
and metadata remain available.
|
||||||
|
- 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
|
For a bad report, start with:
|
||||||
use the per-report artifact paths in that summary to inspect briefing,
|
|
||||||
data-package, preflight, metadata, and rendered report files.
|
|
||||||
|
|
||||||
The application does not currently implement resume, cleanup, archive, or
|
```text
|
||||||
remote storage behavior.
|
weatherreporter inspect metadata RUN_ID
|
||||||
|
weatherreporter inspect sources RUN_ID
|
||||||
|
weatherreporter inspect briefing 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.
|
||||||
|
|||||||
@@ -46,12 +46,11 @@ 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`.
|
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
|
1. CLI flags
|
||||||
2. environment variables
|
2. configuration file
|
||||||
3. configuration file
|
3. built-in defaults
|
||||||
4. 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`.
|
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`.
|
||||||
|
|
||||||
@@ -65,13 +64,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.
|
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
|
## Embedded Assets
|
||||||
|
|
||||||
@@ -87,11 +90,15 @@ Use structured logging where practical. Logs should describe operations, paths,
|
|||||||
|
|
||||||
## Context, Timeouts, and Cancellation
|
## 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
|
## 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.
|
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 +106,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.
|
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
|
||||||
|
|
||||||
Documentation should follow the project documentation policy. Keep user docs focused on implemented behavior. Put future, planned, or aspirational work only under `docs/roadmap/`.
|
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.
|
||||||
|
|||||||
@@ -1,691 +1,193 @@
|
|||||||
# 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/weatherapi`: Weather API HTTP adapter.
|
||||||
|
- `internal/adapters/scriptorium`: Scriptorium subprocess adapter.
|
||||||
|
- `internal/forecast`: normalized bundle types and deterministic forecast
|
||||||
|
derivation.
|
||||||
|
- `internal/report`: report definitions, valid periods, batches, output names,
|
||||||
|
and comparison declarations.
|
||||||
|
- `internal/briefing`: report-specific briefing package builders.
|
||||||
|
- `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`.
|
Use focused checks while editing and broader checks before committing:
|
||||||
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.
|
|
||||||
|
|
||||||
The preferred data flow is:
|
```bash
|
||||||
|
go test ./...
|
||||||
```text
|
go run ./cmd/weatherreporter --help
|
||||||
weatherfeeder-backed internal API
|
git diff --check
|
||||||
-> weather API adapter
|
|
||||||
-> forecast bundle
|
|
||||||
-> report-specific briefing builder
|
|
||||||
-> recent-change comparison
|
|
||||||
-> prompt input data package
|
|
||||||
-> scriptorium subprocess adapter
|
|
||||||
-> Markdown report + metadata + stored snapshot
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
```bash
|
||||||
|
go test ./internal/cli ./internal/config
|
||||||
```text
|
go test ./internal/app ./internal/state
|
||||||
cmd/weatherreporter/
|
go test ./internal/adapters/weatherapi ./internal/adapters/scriptorium
|
||||||
main.go
|
go test ./internal/forecast ./internal/report ./internal/briefing ./internal/changes ./internal/promptinput
|
||||||
|
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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 Weather API transport and envelope handling inside
|
||||||
|
`internal/adapters/weatherapi`.
|
||||||
|
|
||||||
```text
|
## Dependency Policy
|
||||||
cmd/weatherreporter
|
|
||||||
-> internal/cli
|
|
||||||
-> internal/app
|
|
||||||
-> internal/config
|
|
||||||
-> internal/report
|
|
||||||
-> internal/briefing
|
|
||||||
-> internal/forecast
|
|
||||||
-> internal/changes
|
|
||||||
-> internal/state
|
|
||||||
-> internal/adapters/*
|
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
Current external dependency:
|
||||||
- `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`.
|
|
||||||
|
|
||||||
## Package Responsibilities
|
- `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`.
|
Configuration is owned by `internal/config`.
|
||||||
- Execute the command.
|
|
||||||
- Handle final process exit behavior.
|
|
||||||
|
|
||||||
Non-responsibilities:
|
When adding or changing a field:
|
||||||
|
|
||||||
- No configuration loading details.
|
- update `Config` and the nested config struct in `config.go`;
|
||||||
- No forecast logic.
|
- add or adjust defaults in `defaults.go` when the field has a safe default;
|
||||||
- No direct calls to weather APIs, state stores, or `scriptorium`.
|
- 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:
|
## CLI Changes
|
||||||
- `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.
|
|
||||||
|
|
||||||
Non-responsibilities:
|
The CLI is owned by `internal/cli`.
|
||||||
|
|
||||||
- No report-building logic.
|
When adding or changing a command or flag:
|
||||||
- No direct subprocess execution.
|
|
||||||
- No direct weather API calls.
|
|
||||||
- No state comparison logic.
|
|
||||||
|
|
||||||
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
|
CLI commands should return concise actionable errors and avoid printing partial
|
||||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
JSON when command construction fails.
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
When an external contract changes, update the matching file under
|
||||||
- Provide built-in defaults in `defaults.go`.
|
`docs/integrations/`.
|
||||||
- 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.
|
|
||||||
|
|
||||||
Suggested configuration areas:
|
## Tests
|
||||||
|
|
||||||
- Weather API base URL, timeout, units, timezone, precision, and missing-source policy.
|
Core tests must not require live Weather API or Scriptorium services.
|
||||||
- `scriptorium` binary, config path, profile, timeout, and optional extra arguments.
|
|
||||||
- Workspace and output directories.
|
|
||||||
- Report enablement and output naming.
|
|
||||||
- Daypart definitions.
|
|
||||||
- Recent-change thresholds.
|
|
||||||
|
|
||||||
Initial defaults:
|
Preferred test patterns:
|
||||||
|
|
||||||
- Weather API units: `us`.
|
- fake command runners for subprocess behavior;
|
||||||
- Weather API timezone: `Chicago`.
|
- `httptest.Server` for Weather API behavior;
|
||||||
- Weather API format: `json`.
|
- filesystem temp directories for state behavior;
|
||||||
- Missing-source policy: `warn`.
|
- 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.
|
Examples under `examples/` must be real, maintained, and free of secrets.
|
||||||
- No HTTP calls.
|
|
||||||
- No report-building logic.
|
|
||||||
|
|
||||||
### `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:
|
## Documentation Checklist
|
||||||
- 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.
|
|
||||||
|
|
||||||
The core generation workflow should be approximately:
|
Documentation updates are part of behavior changes.
|
||||||
|
|
||||||
```text
|
Update:
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
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 or Scriptorium contract changes;
|
||||||
|
- `docs/roadmap/` only for unimplemented or deferred work.
|
||||||
|
|
||||||
- No detailed daypart calculations.
|
Non-roadmap docs must describe implemented behavior only.
|
||||||
- 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.
|
|
||||||
|
|||||||
85
docs/roadmap/future.md
Normal file
85
docs/roadmap/future.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Future Roadmap
|
||||||
|
|
||||||
|
This roadmap contains project work that is not implemented. Current behavior is
|
||||||
|
documented outside `docs/roadmap/`.
|
||||||
|
|
||||||
|
## Deferred: 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.
|
||||||
|
|
||||||
|
## Deferred: 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.
|
||||||
|
|
||||||
|
Each item needs its own design note before implementation. Non-roadmap docs
|
||||||
|
must not describe these as available behavior.
|
||||||
|
|
||||||
|
## Deferred: 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 briefing
|
||||||
|
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.
|
||||||
|
- Plugin architecture: defer until there is a concrete external extension
|
||||||
|
contract and at least one implemented extension point.
|
||||||
|
- Cobra migration: defer while the standard-library CLI remains small,
|
||||||
|
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, and adapter boundaries unless a separate roadmap
|
||||||
|
explicitly changes them.
|
||||||
File diff suppressed because it is too large
Load Diff
235
docs/troubleshooting.md
Normal file
235
docs/troubleshooting.md
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
# 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, briefing, and data package for that RunID.
|
||||||
|
|
||||||
|
Relevant docs: [CLI reference](cli.md), [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).
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
weather_api:
|
weather_api:
|
||||||
base_url: https://weather.api.example.com/
|
base_url: https://weather.api.rakestrawhome.com/
|
||||||
timeout: 15s
|
timeout: 15s
|
||||||
precision: 1
|
precision: 1
|
||||||
units: us
|
units: us
|
||||||
timezone: Chicago
|
timezone: "America/Chicago"
|
||||||
format: json
|
format: json
|
||||||
|
|
||||||
|
location:
|
||||||
|
id: home
|
||||||
|
name: Brentwood
|
||||||
|
region: St. Louis Metro
|
||||||
|
|
||||||
missing_source:
|
missing_source:
|
||||||
default: warn
|
default: warn
|
||||||
sources:
|
sources:
|
||||||
@@ -22,21 +27,21 @@ workspace:
|
|||||||
data_packages_dir: data-packages
|
data_packages_dir: data-packages
|
||||||
preflight_dir: preflight
|
preflight_dir: preflight
|
||||||
|
|
||||||
reports:
|
|
||||||
output_dir: reports
|
|
||||||
|
|
||||||
dayparts:
|
dayparts:
|
||||||
- name: overnight
|
- name: overnight
|
||||||
start: "00:00"
|
start: "00:00"
|
||||||
end: "06:00"
|
end: "06:00"
|
||||||
- name: morning
|
- name: morning
|
||||||
start: "06:00"
|
start: "06:00"
|
||||||
end: "12:00"
|
end: "10:00"
|
||||||
|
- name: midday
|
||||||
|
start: "10:00"
|
||||||
|
end: "15:00"
|
||||||
- name: afternoon
|
- name: afternoon
|
||||||
start: "12:00"
|
start: "15:00"
|
||||||
end: "18:00"
|
end: "17:00"
|
||||||
- name: evening
|
- name: evening
|
||||||
start: "18:00"
|
start: "17:00"
|
||||||
end: "24:00"
|
end: "24:00"
|
||||||
|
|
||||||
recent_change:
|
recent_change:
|
||||||
|
|||||||
2
examples/minimal-config.yml
Normal file
2
examples/minimal-config.yml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
weather_api:
|
||||||
|
base_url: https://weather.api.example.com/
|
||||||
@@ -3,12 +3,9 @@ package scriptorium
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -108,29 +105,20 @@ func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, e
|
|||||||
if req.DataPackagePath == "" {
|
if req.DataPackagePath == "" {
|
||||||
return nil, fmt.Errorf("data package path is required")
|
return nil, fmt.Errorf("data package path is required")
|
||||||
}
|
}
|
||||||
binary := r.Binary
|
execution, err := r.execute(ctx, r.renderArgs(req))
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("run scriptorium render: %w", err)
|
return nil, fmt.Errorf("run scriptorium render: %w", err)
|
||||||
}
|
}
|
||||||
result := &RenderResult{
|
result := &RenderResult{
|
||||||
Command: append([]string{binary}, args...),
|
Command: execution.argv(),
|
||||||
Stdout: string(commandResult.Stdout),
|
Stdout: string(execution.result.Stdout),
|
||||||
Stderr: string(commandResult.Stderr),
|
Stderr: string(execution.result.Stderr),
|
||||||
StdoutTruncated: commandResult.StdoutTruncated,
|
StdoutTruncated: execution.result.StdoutTruncated,
|
||||||
StderrTruncated: commandResult.StderrTruncated,
|
StderrTruncated: execution.result.StderrTruncated,
|
||||||
ExitCode: commandResult.ExitCode,
|
ExitCode: execution.result.ExitCode,
|
||||||
}
|
}
|
||||||
if commandResult.ExitCode != 0 {
|
if execution.result.ExitCode != 0 {
|
||||||
return result, fmt.Errorf("scriptorium render exited with code %d: %s", commandResult.ExitCode, result.Stderr)
|
return result, fmt.Errorf("scriptorium render exited with code %d: %s", execution.result.ExitCode, result.Stderr)
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
@@ -145,6 +133,32 @@ func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
|||||||
if req.OutputPath == "" {
|
if req.OutputPath == "" {
|
||||||
return nil, fmt.Errorf("output path is required")
|
return nil, fmt.Errorf("output path is required")
|
||||||
}
|
}
|
||||||
|
execution, err := r.execute(ctx, r.runArgs(req))
|
||||||
|
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
|
binary := r.Binary
|
||||||
if binary == "" {
|
if binary == "" {
|
||||||
binary = "scriptorium"
|
binary = "scriptorium"
|
||||||
@@ -153,24 +167,15 @@ func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
|||||||
if commands == nil {
|
if commands == nil {
|
||||||
commands = ExecRunner{}
|
commands = ExecRunner{}
|
||||||
}
|
}
|
||||||
args := r.runArgs(req)
|
result, err := commands.Run(ctx, binary, args, r.Timeout)
|
||||||
commandResult, err := commands.Run(ctx, binary, args, r.Timeout)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("run scriptorium: %w", err)
|
return execution{}, err
|
||||||
}
|
}
|
||||||
result := &RunResult{
|
return execution{binary: binary, args: args, result: result}, nil
|
||||||
Command: append([]string{binary}, args...),
|
}
|
||||||
Stdout: string(commandResult.Stdout),
|
|
||||||
Stderr: string(commandResult.Stderr),
|
func (e execution) argv() []string {
|
||||||
StdoutTruncated: commandResult.StdoutTruncated,
|
return append([]string{e.binary}, e.args...)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r Runner) renderArgs(req RenderRequest) []string {
|
func (r Runner) renderArgs(req RenderRequest) []string {
|
||||||
@@ -207,37 +212,6 @@ func (r Runner) runArgs(req RunRequest) []string {
|
|||||||
return args
|
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 {
|
type limitedBuffer struct {
|
||||||
data []byte
|
data []byte
|
||||||
limit int
|
limit int
|
||||||
|
|||||||
@@ -11,14 +11,13 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -110,10 +109,10 @@ func (c *Client) FetchBundle(ctx context.Context) (*forecast.Bundle, error) {
|
|||||||
if err := builder.fetchDiscussion(ctx); err != nil {
|
if err := builder.fetchDiscussion(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := builder.addStub("daily", "daily forecast data is not available from the weather API yet"); err != nil {
|
if err := builder.fetchWeatherStory(ctx); err != nil {
|
||||||
return nil, err
|
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.addStub("daily", "daily forecast data is not available from the weather API yet"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,13 +202,18 @@ func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchAlerts(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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if raw == nil {
|
if raw == nil {
|
||||||
return b.handleMissing(&source, "active alerts data is missing", false)
|
return b.handleMissing(&source, "active alerts data is missing", false)
|
||||||
}
|
}
|
||||||
|
if isJSONNull(raw) {
|
||||||
|
b.bundle.Alerts = &forecast.AlertRun{Raw: append(json.RawMessage(nil), raw...)}
|
||||||
|
b.addSource(source)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
var alerts forecast.AlertRun
|
var alerts forecast.AlertRun
|
||||||
if err := decodeSource(raw, &alerts); err != nil {
|
if err := decodeSource(raw, &alerts); err != nil {
|
||||||
return b.handleMalformed(&source, err, false)
|
return b.handleMalformed(&source, err, false)
|
||||||
@@ -242,6 +246,27 @@ func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if raw == nil {
|
||||||
|
return b.handleMissing(&source, "NWS weather story data is missing", false)
|
||||||
|
}
|
||||||
|
var story forecast.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) addStub(sourceName string, message string) error {
|
func (b *bundleBuilder) addStub(sourceName string, message string) error {
|
||||||
source := forecast.Source{
|
source := forecast.Source{
|
||||||
Name: sourceName,
|
Name: sourceName,
|
||||||
@@ -302,6 +327,8 @@ func (c *Client) policyFor(source string) config.MissingSourcePolicy {
|
|||||||
type queryOptions struct {
|
type queryOptions struct {
|
||||||
precision bool
|
precision bool
|
||||||
timezone bool
|
timezone bool
|
||||||
|
allowNull bool
|
||||||
|
omitUnits bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type envelope struct {
|
type envelope struct {
|
||||||
@@ -340,7 +367,7 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
|
|||||||
Query: queryMap(reqURL.Query()),
|
Query: queryMap(reqURL.Query()),
|
||||||
FetchedAt: c.now(),
|
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
|
source.Missing = true
|
||||||
return nil, source, nil
|
return nil, source, nil
|
||||||
}
|
}
|
||||||
@@ -352,12 +379,18 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
|
|||||||
return env.Data, source, nil
|
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 {
|
func (c *Client) endpointURL(endpoint string, opts queryOptions) *url.URL {
|
||||||
reqURL := *c.baseURL
|
reqURL := *c.baseURL
|
||||||
reqURL.Path = path.Join(c.baseURL.Path, endpoint)
|
reqURL.Path = path.Join(c.baseURL.Path, endpoint)
|
||||||
query := reqURL.Query()
|
query := reqURL.Query()
|
||||||
query.Set("format", c.format)
|
query.Set("format", c.format)
|
||||||
|
if !opts.omitUnits {
|
||||||
query.Set("units", c.units)
|
query.Set("units", c.units)
|
||||||
|
}
|
||||||
if opts.precision {
|
if opts.precision {
|
||||||
query.Set("precision", strconv.Itoa(c.precision))
|
query.Set("precision", strconv.Itoa(c.precision))
|
||||||
}
|
}
|
||||||
@@ -398,29 +431,8 @@ func sourceHash(raw json.RawMessage) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func SaveBundle(path string, bundle *forecast.Bundle) error {
|
func SaveBundle(path string, bundle *forecast.Bundle) error {
|
||||||
data, err := json.MarshalIndent(bundle, "", " ")
|
if err := fileutil.WriteJSONAtomic(path, bundle); err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf("save bundle: %w", err)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,11 +43,23 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
|||||||
if bundle.Discussion == nil || len(bundle.Discussion.KeyMessages) != 2 {
|
if bundle.Discussion == nil || len(bundle.Discussion.KeyMessages) != 2 {
|
||||||
t.Fatalf("Discussion = %#v, want key messages", bundle.Discussion)
|
t.Fatalf("Discussion = %#v, want key messages", bundle.Discussion)
|
||||||
}
|
}
|
||||||
|
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 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) != 8 {
|
if len(bundle.Sources) != 8 {
|
||||||
t.Fatalf("Sources length = %d, want 8", len(bundle.Sources))
|
t.Fatalf("Sources length = %d, want 8", len(bundle.Sources))
|
||||||
}
|
}
|
||||||
if len(bundle.Warnings) != 2 {
|
if len(bundle.Warnings) != 1 {
|
||||||
t.Fatalf("Warnings length = %d, want daily and weather story warnings", len(bundle.Warnings))
|
t.Fatalf("Warnings length = %d, want daily warning", len(bundle.Warnings))
|
||||||
}
|
}
|
||||||
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
|
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
|
||||||
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
|
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
|
||||||
@@ -55,6 +67,9 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
|||||||
if !containsPath(requested, "/forecast/narrative") || containsPath(requested, "/forecast/narrative/today") {
|
if !containsPath(requested, "/forecast/narrative") || containsPath(requested, "/forecast/narrative/today") {
|
||||||
t.Fatalf("requested paths = %v, want full narrative endpoint only", requested)
|
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) {
|
func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
||||||
@@ -68,11 +83,20 @@ func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, rawURL := range requested {
|
for _, rawURL := range requested {
|
||||||
if !strings.Contains(rawURL, "format=json") || !strings.Contains(rawURL, "units=us") {
|
if !strings.Contains(rawURL, "format=json") {
|
||||||
t.Fatalf("request %q missing format=json or units=us", rawURL)
|
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.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)
|
t.Fatalf("forecast request %q missing precision or tz", rawURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,6 +117,16 @@ func TestFetchBundleRecordsSourceHash(t *testing.T) {
|
|||||||
if observation.DataSHA256 != want {
|
if observation.DataSHA256 != want {
|
||||||
t.Fatalf("DataSHA256 = %q, want %q", 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) {
|
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) {
|
func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -133,7 +197,7 @@ func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
|
|||||||
wantWarns int
|
wantWarns int
|
||||||
wantSource bool
|
wantSource bool
|
||||||
}{
|
}{
|
||||||
{name: "warn", policy: config.MissingSourceWarn, wantWarns: 3, wantSource: true},
|
{name: "warn", policy: config.MissingSourceWarn, wantWarns: 2, wantSource: true},
|
||||||
{name: "none", policy: config.MissingSourceNone, wantWarns: 0, wantSource: true},
|
{name: "none", policy: config.MissingSourceNone, wantWarns: 0, wantSource: true},
|
||||||
{name: "error", policy: config.MissingSourceError, wantErr: true},
|
{name: "error", policy: config.MissingSourceError, wantErr: true},
|
||||||
}
|
}
|
||||||
@@ -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) {
|
func TestContextCancellation(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
<-r.Context().Done()
|
<-r.Context().Done()
|
||||||
@@ -266,6 +369,7 @@ func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested
|
|||||||
"/forecast/narrative": "narrative.json",
|
"/forecast/narrative": "narrative.json",
|
||||||
"/alerts/active": "alerts.json",
|
"/alerts/active": "alerts.json",
|
||||||
"/discussion": "discussion.json",
|
"/discussion": "discussion.json",
|
||||||
|
"/weatherstories/latest": "weather_story.json",
|
||||||
}
|
}
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if requested != nil {
|
if requested != nil {
|
||||||
|
|||||||
@@ -9,8 +9,12 @@
|
|||||||
"Warmer temperatures this weekend."
|
"Warmer temperatures this weekend."
|
||||||
],
|
],
|
||||||
"shortTerm": {
|
"shortTerm": {
|
||||||
"title": "Short Term",
|
"qualifier": "(Through This Evening)",
|
||||||
"narrative": "A weak boundary may trigger isolated showers."
|
"text": "A weak boundary may trigger isolated showers."
|
||||||
|
},
|
||||||
|
"longTerm": {
|
||||||
|
"qualifier": "(This Weekend)",
|
||||||
|
"text": "Warmer temperatures and periodic rain chances continue into the weekend."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14
internal/adapters/weatherapi/testdata/weather_story.json
vendored
Normal file
14
internal/adapters/weatherapi/testdata/weather_story.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,7 @@ package app
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
||||||
@@ -14,6 +12,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
@@ -68,8 +67,6 @@ type BriefingRequest struct {
|
|||||||
OutputPath string
|
OutputPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
type DailyBriefingRequest = BriefingRequest
|
|
||||||
|
|
||||||
type ReportRequest struct {
|
type ReportRequest struct {
|
||||||
Config config.Config
|
Config config.Config
|
||||||
Resolved report.Resolved
|
Resolved report.Resolved
|
||||||
@@ -78,15 +75,11 @@ type ReportRequest struct {
|
|||||||
Store state.Store
|
Store state.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
type DailyReportRequest = ReportRequest
|
|
||||||
|
|
||||||
type BriefingResult struct {
|
type BriefingResult struct {
|
||||||
Package briefing.Package
|
Package briefing.Package
|
||||||
OutputPath string
|
OutputPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
type DailyBriefingResult = BriefingResult
|
|
||||||
|
|
||||||
type ReportResult struct {
|
type ReportResult struct {
|
||||||
Briefing briefing.Package
|
Briefing briefing.Package
|
||||||
BriefingPath string
|
BriefingPath string
|
||||||
@@ -103,8 +96,6 @@ type ReportResult struct {
|
|||||||
RunResult *scriptorium.RunResult
|
RunResult *scriptorium.RunResult
|
||||||
}
|
}
|
||||||
|
|
||||||
type DailyReportResult = ReportResult
|
|
||||||
|
|
||||||
type BatchResult struct {
|
type BatchResult struct {
|
||||||
Batch BatchKind `json:"batch"`
|
Batch BatchKind `json:"batch"`
|
||||||
StartedAt time.Time `json:"startedAt"`
|
StartedAt time.Time `json:"startedAt"`
|
||||||
@@ -157,7 +148,7 @@ func Generate(ctx context.Context, req GenerateRequest) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if isGeneratedReport(resolved.Definition.ID) {
|
if resolved.Definition.Generated {
|
||||||
_, err := GenerateReport(ctx, ReportRequest{
|
_, err := GenerateReport(ctx, ReportRequest{
|
||||||
Config: req.Config,
|
Config: req.Config,
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
@@ -200,7 +191,7 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
|||||||
startedAt := now
|
startedAt := now
|
||||||
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
|
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
|
||||||
for _, resolved := range resolvedReports {
|
for _, resolved := range resolvedReports {
|
||||||
if !isGeneratedReport(resolved.Definition.ID) {
|
if !resolved.Definition.Generated {
|
||||||
return nil, fmt.Errorf("run is not implemented")
|
return nil, fmt.Errorf("run is not implemented")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -257,19 +248,10 @@ func batchReportResult(resolved report.Resolved) BatchReportResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func batchOutputPath(outputDir string, definition report.Definition) string {
|
func batchOutputPath(outputDir string, definition report.Definition) string {
|
||||||
if outputDir == "" || definition.DefaultOutputName == "" {
|
if outputDir == "" || definition.BatchOutputName == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
name := strings.ReplaceAll(definition.DefaultOutputName, "_", "-")
|
return filepath.Join(outputDir, definition.BatchOutputName)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) {
|
func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) {
|
||||||
@@ -359,10 +341,6 @@ func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*forecast.
|
|||||||
return bundle, nil
|
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) {
|
func GenerateBriefing(ctx context.Context, req BriefingRequest) (*BriefingResult, error) {
|
||||||
bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config})
|
bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -390,10 +368,6 @@ func GenerateBriefing(ctx context.Context, req BriefingRequest) (*BriefingResult
|
|||||||
return &BriefingResult{Package: pkg, OutputPath: outputPath}, nil
|
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) {
|
func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) {
|
||||||
store := req.Store
|
store := req.Store
|
||||||
if store == nil {
|
if store == nil {
|
||||||
@@ -460,7 +434,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
|||||||
preflightPath := paths.Preflight
|
preflightPath := paths.Preflight
|
||||||
if renderResult != nil {
|
if renderResult != nil {
|
||||||
var err error
|
var err error
|
||||||
preflightPath, err = store.SavePreflight(ctx, req.Resolved, renderResult)
|
preflightPath, err = store.SavePreflight(ctx, req.Resolved, preflightArtifact(renderResult))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -490,7 +464,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
|||||||
OutputPath: reportPath,
|
OutputPath: reportPath,
|
||||||
})
|
})
|
||||||
if runErr == nil && req.OutputPath != "" && req.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
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -524,10 +498,6 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildDailyBriefing(req DailyBriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) {
|
|
||||||
return BuildBriefing(req, bundle)
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) {
|
func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) {
|
||||||
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
|
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -552,6 +522,7 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa
|
|||||||
Bundle: bundle,
|
Bundle: bundle,
|
||||||
Units: req.Config.WeatherAPI.Units,
|
Units: req.Config.WeatherAPI.Units,
|
||||||
Timezone: req.Config.WeatherAPI.Timezone,
|
Timezone: req.Config.WeatherAPI.Timezone,
|
||||||
|
Location: briefingLocation(req.Config),
|
||||||
}, summary)
|
}, summary)
|
||||||
case report.ThreeDay, report.Weekend:
|
case report.ThreeDay, report.Weekend:
|
||||||
summaries, err := forecast.BuildPeriodDailySummaries(bundle, req.Resolved.ValidPeriod, location, dayparts)
|
summaries, err := forecast.BuildPeriodDailySummaries(bundle, req.Resolved.ValidPeriod, location, dayparts)
|
||||||
@@ -564,6 +535,7 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa
|
|||||||
Bundle: bundle,
|
Bundle: bundle,
|
||||||
Units: req.Config.WeatherAPI.Units,
|
Units: req.Config.WeatherAPI.Units,
|
||||||
Timezone: req.Config.WeatherAPI.Timezone,
|
Timezone: req.Config.WeatherAPI.Timezone,
|
||||||
|
Location: briefingLocation(req.Config),
|
||||||
}, summaries)
|
}, summaries)
|
||||||
}
|
}
|
||||||
return briefing.BuildThreeDay(briefing.BuildContext{
|
return briefing.BuildThreeDay(briefing.BuildContext{
|
||||||
@@ -571,6 +543,7 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa
|
|||||||
Bundle: bundle,
|
Bundle: bundle,
|
||||||
Units: req.Config.WeatherAPI.Units,
|
Units: req.Config.WeatherAPI.Units,
|
||||||
Timezone: req.Config.WeatherAPI.Timezone,
|
Timezone: req.Config.WeatherAPI.Timezone,
|
||||||
|
Location: briefingLocation(req.Config),
|
||||||
}, summaries)
|
}, summaries)
|
||||||
case report.Storm:
|
case report.Storm:
|
||||||
return briefing.BuildStorm(briefing.BuildContext{
|
return briefing.BuildStorm(briefing.BuildContext{
|
||||||
@@ -578,18 +551,28 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa
|
|||||||
Bundle: bundle,
|
Bundle: bundle,
|
||||||
Units: req.Config.WeatherAPI.Units,
|
Units: req.Config.WeatherAPI.Units,
|
||||||
Timezone: req.Config.WeatherAPI.Timezone,
|
Timezone: req.Config.WeatherAPI.Timezone,
|
||||||
|
Location: briefingLocation(req.Config),
|
||||||
})
|
})
|
||||||
default:
|
default:
|
||||||
return briefing.Package{}, fmt.Errorf("briefing is not implemented for report %q", req.Resolved.Definition.ID)
|
return briefing.Package{}, fmt.Errorf("briefing is not implemented for report %q", req.Resolved.Definition.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultStore(cfg config.Config) (*state.FilesystemStore, error) {
|
func briefingLocation(cfg config.Config) *briefing.LocationContext {
|
||||||
return state.NewFilesystemStore(cfg.Workspace)
|
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 dailyRecentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) {
|
func defaultStore(cfg config.Config) (*state.FilesystemStore, error) {
|
||||||
return recentChanges(ctx, store, priorSnapshot, current, cfg)
|
return state.NewFilesystemStore(cfg.Workspace)
|
||||||
}
|
}
|
||||||
|
|
||||||
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, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) {
|
||||||
@@ -618,29 +601,16 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyFileAtomic(source string, target string) error {
|
func preflightArtifact(result *scriptorium.RenderResult) state.PreflightArtifact {
|
||||||
data, err := os.ReadFile(source)
|
if result == nil {
|
||||||
if err != nil {
|
return state.PreflightArtifact{}
|
||||||
return fmt.Errorf("read rendered report %q: %w", source, err)
|
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
return state.PreflightArtifact{
|
||||||
return fmt.Errorf("create report output directory %q: %w", filepath.Dir(target), err)
|
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -33,7 +34,9 @@ func TestFetchAndSaveBundle(t *testing.T) {
|
|||||||
case "/alerts/active":
|
case "/alerts/active":
|
||||||
_, _ = w.Write([]byte(`{"data":{"alerts":[]}}`))
|
_, _ = w.Write([]byte(`{"data":{"alerts":[]}}`))
|
||||||
case "/discussion":
|
case "/discussion":
|
||||||
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":[]}}`))
|
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":[],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for saved bundle."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for saved bundle."}}}`))
|
||||||
|
case "/weatherstories/latest":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
@@ -58,6 +61,9 @@ func TestFetchAndSaveBundle(t *testing.T) {
|
|||||||
if !strings.Contains(string(data), `"product": "hourly"`) {
|
if !strings.Contains(string(data), `"product": "hourly"`) {
|
||||||
t.Fatalf("saved bundle missing hourly product:\n%s", string(data))
|
t.Fatalf("saved bundle missing hourly product:\n%s", string(data))
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(string(data), `"title": "Several Chances for Rain Through Monday"`) {
|
||||||
|
t.Fatalf("saved bundle missing weather story title:\n%s", string(data))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) {
|
func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) {
|
||||||
@@ -70,11 +76,9 @@ func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateDailyBriefingWritesArtifact(t *testing.T) {
|
func TestGenerateBriefingWritesArtifact(t *testing.T) {
|
||||||
server := dailyBundleServer(t)
|
server := dailyBundleServer(t)
|
||||||
cfg := config.Defaults()
|
cfg := dailyTestConfig(t, server)
|
||||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
|
||||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
||||||
resolved, err := ResolveGenerate(GenerateRequest{
|
resolved, err := ResolveGenerate(GenerateRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Report: ReportDaily,
|
Report: ReportDaily,
|
||||||
@@ -85,13 +89,13 @@ func TestGenerateDailyBriefingWritesArtifact(t *testing.T) {
|
|||||||
}
|
}
|
||||||
path := filepath.Join(t.TempDir(), "daily.briefing.json")
|
path := filepath.Join(t.TempDir(), "daily.briefing.json")
|
||||||
|
|
||||||
result, err := GenerateDailyBriefing(context.Background(), DailyBriefingRequest{
|
result, err := GenerateBriefing(context.Background(), BriefingRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
OutputPath: path,
|
OutputPath: path,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateDailyBriefing() error = %v", err)
|
t.Fatalf("GenerateBriefing() error = %v", err)
|
||||||
}
|
}
|
||||||
if result.OutputPath != path {
|
if result.OutputPath != path {
|
||||||
t.Fatalf("OutputPath = %q, want %q", result.OutputPath, path)
|
t.Fatalf("OutputPath = %q, want %q", result.OutputPath, path)
|
||||||
@@ -108,11 +112,9 @@ func TestGenerateDailyBriefingWritesArtifact(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateDailyBriefingDefaultPath(t *testing.T) {
|
func TestGenerateBriefingDefaultPath(t *testing.T) {
|
||||||
server := dailyBundleServer(t)
|
server := dailyBundleServer(t)
|
||||||
cfg := config.Defaults()
|
cfg := dailyTestConfig(t, server)
|
||||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
|
||||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
||||||
cfg.Workspace.Root = t.TempDir()
|
cfg.Workspace.Root = t.TempDir()
|
||||||
resolved, err := ResolveGenerate(GenerateRequest{
|
resolved, err := ResolveGenerate(GenerateRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
@@ -123,23 +125,21 @@ func TestGenerateDailyBriefingDefaultPath(t *testing.T) {
|
|||||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := GenerateDailyBriefing(context.Background(), DailyBriefingRequest{
|
result, err := GenerateBriefing(context.Background(), BriefingRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateDailyBriefing() error = %v", err)
|
t.Fatalf("GenerateBriefing() error = %v", err)
|
||||||
}
|
}
|
||||||
if !strings.HasSuffix(result.OutputPath, filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.briefing.json")) {
|
if !strings.HasSuffix(result.OutputPath, filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.briefing.json")) {
|
||||||
t.Fatalf("OutputPath = %q, want deterministic daily briefing path", result.OutputPath)
|
t.Fatalf("OutputPath = %q, want deterministic daily briefing path", result.OutputPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
|
func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
||||||
server := dailyBundleServer(t)
|
server := dailyBundleServer(t)
|
||||||
cfg := config.Defaults()
|
cfg := dailyTestConfig(t, server)
|
||||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
|
||||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
||||||
cfg.Workspace.Root = t.TempDir()
|
cfg.Workspace.Root = t.TempDir()
|
||||||
resolved, err := ResolveGenerate(GenerateRequest{
|
resolved, err := ResolveGenerate(GenerateRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
@@ -165,14 +165,14 @@ func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
}
|
}
|
||||||
outputPath := filepath.Join(t.TempDir(), "daily.md")
|
outputPath := filepath.Join(t.TempDir(), "daily.md")
|
||||||
|
|
||||||
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{
|
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
OutputPath: outputPath,
|
OutputPath: outputPath,
|
||||||
Renderer: renderer,
|
Renderer: renderer,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateDailyReport() error = %v", err)
|
t.Fatalf("GenerateReport() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if renderer.renderCalls != 1 {
|
if renderer.renderCalls != 1 {
|
||||||
@@ -193,14 +193,7 @@ func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
if renderer.runRequest.OutputPath != result.ReportPath {
|
if renderer.runRequest.OutputPath != result.ReportPath {
|
||||||
t.Fatalf("run OutputPath = %q, want managed report path %q", renderer.runRequest.OutputPath, result.ReportPath)
|
t.Fatalf("run OutputPath = %q, want managed report path %q", renderer.runRequest.OutputPath, result.ReportPath)
|
||||||
}
|
}
|
||||||
for _, path := range []string{result.BriefingPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath} {
|
assertPathsExist(t, result.BriefingPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath, outputPath)
|
||||||
if _, err := os.Stat(path); err != nil {
|
|
||||||
t.Fatalf("expected artifact %q: %v", path, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(outputPath); err != nil {
|
|
||||||
t.Fatalf("expected requested report output %q: %v", outputPath, err)
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(result.DataPackagePath)
|
data, err := os.ReadFile(result.DataPackagePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read data package: %v", err)
|
t.Fatalf("read data package: %v", err)
|
||||||
@@ -208,6 +201,32 @@ func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
if !strings.Contains(string(data), `"recentChanges"`) || !strings.Contains(string(data), `data_package.v1`) {
|
if !strings.Contains(string(data), `"recentChanges"`) || !strings.Contains(string(data), `data_package.v1`) {
|
||||||
t.Fatalf("data package missing expected content:\n%s", string(data))
|
t.Fatalf("data package missing expected content:\n%s", string(data))
|
||||||
}
|
}
|
||||||
|
var savedDataPackage struct {
|
||||||
|
Report struct {
|
||||||
|
CurrentLocalDate string `json:"currentLocalDate"`
|
||||||
|
} `json:"report"`
|
||||||
|
Briefing briefing.Package `json:"briefing"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &savedDataPackage); err != nil {
|
||||||
|
t.Fatalf("decode data package: %v", err)
|
||||||
|
}
|
||||||
|
if savedDataPackage.Report.CurrentLocalDate != "2026-05-29" {
|
||||||
|
t.Fatalf("data package currentLocalDate = %q, want 2026-05-29", savedDataPackage.Report.CurrentLocalDate)
|
||||||
|
}
|
||||||
|
location := savedDataPackage.Briefing.Metadata.Location
|
||||||
|
if location == nil || location.ID != "home" || location.Name != "Brentwood" || location.Region != "St. Louis Metro" || location.Timezone != "America/Chicago" {
|
||||||
|
t.Fatalf("data package location = %#v, want configured prompt location", location)
|
||||||
|
}
|
||||||
|
current := savedDataPackage.Briefing.CurrentConditions
|
||||||
|
if current == nil || current.ConditionText != "Clear" || current.TemperatureF == nil || *current.TemperatureF != 75 {
|
||||||
|
t.Fatalf("data package current conditions = %#v, want current conditions", current)
|
||||||
|
}
|
||||||
|
if savedDataPackage.Briefing.Daily == nil || savedDataPackage.Briefing.Daily.WeatherStory == nil || savedDataPackage.Briefing.Daily.WeatherStory.Title != "Several Chances for Rain Through Monday" {
|
||||||
|
t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Daily)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "Short-term AFD narrative for generated report.") || !strings.Contains(string(data), "Long-term AFD narrative for generated report.") {
|
||||||
|
t.Fatalf("data package missing AFD short/long-term discussion:\n%s", string(data))
|
||||||
|
}
|
||||||
preflight, err := os.ReadFile(result.PreflightPath)
|
preflight, err := os.ReadFile(result.PreflightPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read preflight: %v", err)
|
t.Fatalf("read preflight: %v", err)
|
||||||
@@ -236,7 +255,7 @@ func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateDailyReportPersistsFailedPreflight(t *testing.T) {
|
func TestGenerateReportPersistsFailedPreflight(t *testing.T) {
|
||||||
server := dailyBundleServer(t)
|
server := dailyBundleServer(t)
|
||||||
cfg := config.Defaults()
|
cfg := config.Defaults()
|
||||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||||
@@ -259,13 +278,13 @@ func TestGenerateDailyReportPersistsFailedPreflight(t *testing.T) {
|
|||||||
err: errors.New("scriptorium render exited with code 1: render failed"),
|
err: errors.New("scriptorium render exited with code 1: render failed"),
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = GenerateDailyReport(context.Background(), DailyReportRequest{
|
_, err = GenerateReport(context.Background(), ReportRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
Renderer: renderer,
|
Renderer: renderer,
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("GenerateDailyReport() error = nil, want render error")
|
t.Fatal("GenerateReport() error = nil, want render error")
|
||||||
}
|
}
|
||||||
store, err := state.NewFilesystemStore(cfg.Workspace)
|
store, err := state.NewFilesystemStore(cfg.Workspace)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -291,7 +310,7 @@ func TestGenerateDailyReportPersistsFailedPreflight(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateDailyReportReturnsRunErrorAfterPreflight(t *testing.T) {
|
func TestGenerateReportReturnsRunErrorAfterPreflight(t *testing.T) {
|
||||||
server := dailyBundleServer(t)
|
server := dailyBundleServer(t)
|
||||||
cfg := config.Defaults()
|
cfg := config.Defaults()
|
||||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||||
@@ -315,13 +334,13 @@ func TestGenerateDailyReportReturnsRunErrorAfterPreflight(t *testing.T) {
|
|||||||
runBody: "# Daily Report\n",
|
runBody: "# Daily Report\n",
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = GenerateDailyReport(context.Background(), DailyReportRequest{
|
_, err = GenerateReport(context.Background(), ReportRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
Renderer: renderer,
|
Renderer: renderer,
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("GenerateDailyReport() error = nil, want run error")
|
t.Fatal("GenerateReport() error = nil, want run error")
|
||||||
}
|
}
|
||||||
if renderer.renderCalls != 1 || renderer.runCalls != 1 {
|
if renderer.renderCalls != 1 || renderer.runCalls != 1 {
|
||||||
t.Fatalf("calls render=%d run=%d, want one of each", renderer.renderCalls, renderer.runCalls)
|
t.Fatalf("calls render=%d run=%d, want one of each", renderer.renderCalls, renderer.runCalls)
|
||||||
@@ -342,7 +361,7 @@ func TestGenerateDailyReportReturnsRunErrorAfterPreflight(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateDailyReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
|
func TestGenerateReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
|
||||||
server := dailyBundleServer(t)
|
server := dailyBundleServer(t)
|
||||||
cfg := config.Defaults()
|
cfg := config.Defaults()
|
||||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||||
@@ -371,6 +390,7 @@ func TestGenerateDailyReportIncludesRecentChangesFromPriorSnapshot(t *testing.T)
|
|||||||
}
|
}
|
||||||
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
||||||
Briefing: priorBriefingPath,
|
Briefing: priorBriefingPath,
|
||||||
|
Metadata: priorPaths.Metadata,
|
||||||
DataPackage: priorPaths.DataPackage,
|
DataPackage: priorPaths.DataPackage,
|
||||||
Preflight: priorPaths.Preflight,
|
Preflight: priorPaths.Preflight,
|
||||||
RenderedReport: priorPaths.RenderedReport,
|
RenderedReport: priorPaths.RenderedReport,
|
||||||
@@ -393,14 +413,14 @@ func TestGenerateDailyReportIncludesRecentChangesFromPriorSnapshot(t *testing.T)
|
|||||||
runBody: "# Daily Report\n",
|
runBody: "# Daily Report\n",
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{
|
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Resolved: currentResolved,
|
Resolved: currentResolved,
|
||||||
Renderer: renderer,
|
Renderer: renderer,
|
||||||
Store: store,
|
Store: store,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateDailyReport() error = %v", err)
|
t.Fatalf("GenerateReport() error = %v", err)
|
||||||
}
|
}
|
||||||
if len(result.RecentChanges) == 0 {
|
if len(result.RecentChanges) == 0 {
|
||||||
t.Fatal("RecentChanges length = 0, want changes from prior snapshot")
|
t.Fatal("RecentChanges length = 0, want changes from prior snapshot")
|
||||||
@@ -433,13 +453,13 @@ func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
|
|||||||
runBody: "# Tomorrow Planning Brief\n",
|
runBody: "# Tomorrow Planning Brief\n",
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{
|
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
Renderer: renderer,
|
Renderer: renderer,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateDailyReport() error = %v", err)
|
t.Fatalf("GenerateReport() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.Briefing.Metadata.ReportID != report.DailyTomorrow || result.Briefing.Metadata.Variant != "tomorrow" {
|
if result.Briefing.Metadata.ReportID != report.DailyTomorrow || result.Briefing.Metadata.Variant != "tomorrow" {
|
||||||
@@ -485,6 +505,7 @@ func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) {
|
|||||||
}
|
}
|
||||||
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
||||||
Briefing: priorBriefingPath,
|
Briefing: priorBriefingPath,
|
||||||
|
Metadata: priorPaths.Metadata,
|
||||||
DataPackage: priorPaths.DataPackage,
|
DataPackage: priorPaths.DataPackage,
|
||||||
Preflight: priorPaths.Preflight,
|
Preflight: priorPaths.Preflight,
|
||||||
RenderedReport: priorPaths.RenderedReport,
|
RenderedReport: priorPaths.RenderedReport,
|
||||||
@@ -506,14 +527,14 @@ func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) {
|
|||||||
runBody: "# Tomorrow Planning Brief\n",
|
runBody: "# Tomorrow Planning Brief\n",
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{
|
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Resolved: currentResolved,
|
Resolved: currentResolved,
|
||||||
Renderer: renderer,
|
Renderer: renderer,
|
||||||
Store: store,
|
Store: store,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GenerateDailyReport() error = %v", err)
|
t.Fatalf("GenerateReport() error = %v", err)
|
||||||
}
|
}
|
||||||
if result.PriorSnapshot == nil {
|
if result.PriorSnapshot == nil {
|
||||||
t.Fatal("PriorSnapshot = nil, want compatible prior daily snapshot")
|
t.Fatal("PriorSnapshot = nil, want compatible prior daily snapshot")
|
||||||
@@ -551,6 +572,7 @@ func TestGenerateThreeDayReportWritesReportAndRecentChanges(t *testing.T) {
|
|||||||
}
|
}
|
||||||
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
||||||
Briefing: priorBriefingPath,
|
Briefing: priorBriefingPath,
|
||||||
|
Metadata: priorPaths.Metadata,
|
||||||
DataPackage: priorPaths.DataPackage,
|
DataPackage: priorPaths.DataPackage,
|
||||||
Preflight: priorPaths.Preflight,
|
Preflight: priorPaths.Preflight,
|
||||||
RenderedReport: priorPaths.RenderedReport,
|
RenderedReport: priorPaths.RenderedReport,
|
||||||
@@ -626,6 +648,7 @@ func TestGenerateWeekendReportWritesReportAndRecentChanges(t *testing.T) {
|
|||||||
}
|
}
|
||||||
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
||||||
Briefing: priorBriefingPath,
|
Briefing: priorBriefingPath,
|
||||||
|
Metadata: priorPaths.Metadata,
|
||||||
DataPackage: priorPaths.DataPackage,
|
DataPackage: priorPaths.DataPackage,
|
||||||
Preflight: priorPaths.Preflight,
|
Preflight: priorPaths.Preflight,
|
||||||
RenderedReport: priorPaths.RenderedReport,
|
RenderedReport: priorPaths.RenderedReport,
|
||||||
@@ -882,7 +905,7 @@ func dailyBundleServer(t *testing.T) *httptest.Server {
|
|||||||
case "/observations":
|
case "/observations":
|
||||||
_, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`))
|
_, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`))
|
||||||
case "/conditions/current":
|
case "/conditions/current":
|
||||||
_, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`))
|
_, _ = w.Write([]byte(`{"data":{"conditionText":"Clear","temperatureF":75,"relativeHumidityPercent":56,"windSpeedMph":8}}`))
|
||||||
case "/forecast/hourly":
|
case "/forecast/hourly":
|
||||||
_, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32}]}}`))
|
_, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32}]}}`))
|
||||||
case "/forecast/narrative":
|
case "/forecast/narrative":
|
||||||
@@ -890,7 +913,9 @@ func dailyBundleServer(t *testing.T) *httptest.Server {
|
|||||||
case "/alerts/active":
|
case "/alerts/active":
|
||||||
_, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Flood Watch","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T09:00:00-05:00"}]}}`))
|
_, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Flood Watch","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T09:00:00-05:00"}]}}`))
|
||||||
case "/discussion":
|
case "/discussion":
|
||||||
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}}`))
|
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for generated report."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for generated report."}}}`))
|
||||||
|
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:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
@@ -1021,6 +1046,23 @@ func mustParse(value string) time.Time {
|
|||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func dailyTestConfig(t *testing.T, server *httptest.Server) config.Config {
|
||||||
|
t.Helper()
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||||
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertPathsExist(t *testing.T, paths ...string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, path := range paths {
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
t.Fatalf("expected artifact %q: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func priorDailyBriefing(resolved report.Resolved) briefing.Package {
|
func priorDailyBriefing(resolved report.Resolved) briefing.Package {
|
||||||
low := 50.0
|
low := 50.0
|
||||||
high := 58.0
|
high := 58.0
|
||||||
|
|||||||
@@ -40,59 +40,44 @@ func InspectReports(ctx context.Context, req InspectReportsRequest) ([]state.Rep
|
|||||||
}
|
}
|
||||||
|
|
||||||
func InspectMetadata(ctx context.Context, req InspectRunRequest) (state.Metadata, error) {
|
func InspectMetadata(ctx context.Context, req InspectRunRequest) (state.Metadata, error) {
|
||||||
store, err := defaultStore(req.Config)
|
inspection, err := inspectRun(ctx, req)
|
||||||
if err != nil {
|
return inspection.metadata, err
|
||||||
return state.Metadata{}, err
|
|
||||||
}
|
|
||||||
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
|
|
||||||
return metadata, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func InspectBriefing(ctx context.Context, req InspectRunRequest) (briefing.Package, error) {
|
func InspectBriefing(ctx context.Context, req InspectRunRequest) (briefing.Package, error) {
|
||||||
store, err := defaultStore(req.Config)
|
inspection, err := inspectRun(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return briefing.Package{}, err
|
return briefing.Package{}, err
|
||||||
}
|
}
|
||||||
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
|
return inspection.store.LoadBriefing(ctx, inspection.metadata.BriefingPath)
|
||||||
if err != nil {
|
|
||||||
return briefing.Package{}, err
|
|
||||||
}
|
|
||||||
return store.LoadBriefing(ctx, metadata.BriefingPath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func InspectDataPackage(ctx context.Context, req InspectRunRequest) (promptinput.Package, error) {
|
func InspectDataPackage(ctx context.Context, req InspectRunRequest) (promptinput.Package, error) {
|
||||||
store, err := defaultStore(req.Config)
|
inspection, err := inspectRun(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return promptinput.Package{}, err
|
return promptinput.Package{}, err
|
||||||
}
|
}
|
||||||
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
|
return inspection.store.LoadDataPackage(ctx, inspection.metadata.DataPackagePath)
|
||||||
if err != nil {
|
|
||||||
return promptinput.Package{}, err
|
|
||||||
}
|
|
||||||
return store.LoadDataPackage(ctx, metadata.DataPackagePath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func InspectPriorSnapshot(ctx context.Context, req InspectRunRequest) (*state.PriorSnapshot, error) {
|
func InspectPriorSnapshot(ctx context.Context, req InspectRunRequest) (*state.PriorSnapshot, error) {
|
||||||
store, err := defaultStore(req.Config)
|
inspection, err := inspectRun(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
|
resolved, err := resolvedFromMetadata(inspection.metadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
resolved, err := resolvedFromMetadata(metadata)
|
return inspection.store.FindPriorSnapshot(ctx, resolved)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return store.FindPriorSnapshot(ctx, resolved)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func InspectSources(ctx context.Context, req InspectRunRequest) (SourceInspection, error) {
|
func InspectSources(ctx context.Context, req InspectRunRequest) (SourceInspection, error) {
|
||||||
metadata, err := InspectMetadata(ctx, req)
|
inspection, err := inspectRun(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return SourceInspection{}, err
|
return SourceInspection{}, err
|
||||||
}
|
}
|
||||||
|
metadata := inspection.metadata
|
||||||
return SourceInspection{
|
return SourceInspection{
|
||||||
RunID: metadata.RunID,
|
RunID: metadata.RunID,
|
||||||
ReportID: metadata.ReportID,
|
ReportID: metadata.ReportID,
|
||||||
@@ -102,6 +87,23 @@ func InspectSources(ctx context.Context, req InspectRunRequest) (SourceInspectio
|
|||||||
}, nil
|
}, 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) {
|
func resolvedFromMetadata(metadata state.Metadata) (report.Resolved, error) {
|
||||||
definition, err := report.DefaultRegistry().Lookup(metadata.ReportID)
|
definition, err := report.DefaultRegistry().Lookup(metadata.ReportID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
@@ -58,7 +59,16 @@ type DiscussionContext struct {
|
|||||||
|
|
||||||
type WeatherStoryContext struct {
|
type WeatherStoryContext struct {
|
||||||
Available bool `json:"available"`
|
Available bool `json:"available"`
|
||||||
Summary string `json:"summary,omitempty"`
|
OfficeID string `json:"officeId,omitempty"`
|
||||||
|
StartTime time.Time `json:"startTime"`
|
||||||
|
EndTime time.Time `json:"endTime"`
|
||||||
|
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
AltText string `json:"altText,omitempty"`
|
||||||
|
Priority bool `json:"priority"`
|
||||||
|
Order int `json:"order"`
|
||||||
|
DownloadURL string `json:"downloadUrl,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, error) {
|
func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, error) {
|
||||||
@@ -68,9 +78,8 @@ func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, erro
|
|||||||
if summary == nil {
|
if summary == nil {
|
||||||
return Package{}, fmt.Errorf("daily forecast summary is required")
|
return Package{}, fmt.Errorf("daily forecast summary is required")
|
||||||
}
|
}
|
||||||
pkg := Package{
|
pkg := buildPackage(ctx)
|
||||||
Metadata: BuildMetadata(ctx),
|
pkg.Daily = &Daily{
|
||||||
Daily: &Daily{
|
|
||||||
BottomLine: buildBottomLine(summary),
|
BottomLine: buildBottomLine(summary),
|
||||||
Dayparts: summary.Dayparts,
|
Dayparts: summary.Dayparts,
|
||||||
RelevantAlerts: summary.AlertOverlaps,
|
RelevantAlerts: summary.AlertOverlaps,
|
||||||
@@ -79,8 +88,8 @@ func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, erro
|
|||||||
Discussion: buildDiscussion(summary.Discussion),
|
Discussion: buildDiscussion(summary.Discussion),
|
||||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||||
ForecastSummaryDate: summary.Date,
|
ForecastSummaryDate: summary.Date,
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
setRelevantAlertCount(&pkg.Metadata, len(summary.AlertOverlaps))
|
||||||
if ctx.Resolved.Definition.ID == report.DailyTomorrow {
|
if ctx.Resolved.Definition.ID == report.DailyTomorrow {
|
||||||
pkg.Daily.Planning = buildTomorrowPlanning(summary)
|
pkg.Daily.Planning = buildTomorrowPlanning(summary)
|
||||||
}
|
}
|
||||||
@@ -176,9 +185,6 @@ func readinessNotes(daypart forecast.DaypartSummary) []string {
|
|||||||
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
||||||
notes = append(notes, fmt.Sprintf("Morning gusts may reach %.0f mph.", daypart.PeakWindGust.Value))
|
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 {
|
if daypart.Indicators.Snow || daypart.Indicators.Ice {
|
||||||
notes = append(notes, "Morning wintry weather could affect surfaces and travel.")
|
notes = append(notes, "Morning wintry weather could affect surfaces and travel.")
|
||||||
}
|
}
|
||||||
@@ -203,9 +209,6 @@ func concernNotes(daypart forecast.DaypartSummary) []string {
|
|||||||
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
||||||
notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", prefix, daypart.PeakWindGust.Value))
|
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 {
|
if daypart.Indicators.Snow || daypart.Indicators.Ice {
|
||||||
notes = append(notes, prefix+" wintry weather may affect travel.")
|
notes = append(notes, prefix+" wintry weather may affect travel.")
|
||||||
}
|
}
|
||||||
@@ -229,9 +232,6 @@ func overnightWatchNotes(daypart forecast.DaypartSummary) []string {
|
|||||||
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
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))
|
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 {
|
if daypart.Indicators.Snow || daypart.Indicators.Ice {
|
||||||
notes = append(notes, "Overnight wintry weather could leave morning travel impacts.")
|
notes = append(notes, "Overnight wintry weather could leave morning travel impacts.")
|
||||||
}
|
}
|
||||||
@@ -259,19 +259,40 @@ func buildDiscussion(discussion *forecast.Discussion) DiscussionContext {
|
|||||||
KeyMessages: discussion.KeyMessages,
|
KeyMessages: discussion.KeyMessages,
|
||||||
}
|
}
|
||||||
if discussion.ShortTerm != nil {
|
if discussion.ShortTerm != nil {
|
||||||
ctx.ShortTerm = discussion.ShortTerm.Narrative
|
ctx.ShortTerm = discussion.ShortTerm.Text
|
||||||
}
|
}
|
||||||
if discussion.LongTerm != nil {
|
if discussion.LongTerm != nil {
|
||||||
ctx.LongTerm = discussion.LongTerm.Narrative
|
ctx.LongTerm = discussion.LongTerm.Text
|
||||||
}
|
}
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildWeatherStory(bundle *forecast.Bundle) *WeatherStoryContext {
|
func buildWeatherStory(bundle *forecast.Bundle) *WeatherStoryContext {
|
||||||
if bundle == nil || bundle.WeatherStory == nil || len(bundle.WeatherStory.Raw) == 0 {
|
if bundle == nil || bundle.WeatherStory == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &WeatherStoryContext{Available: true, Summary: string(bundle.WeatherStory.Raw)}
|
story := bundle.WeatherStory
|
||||||
|
return &WeatherStoryContext{
|
||||||
|
Available: true,
|
||||||
|
OfficeID: story.OfficeID,
|
||||||
|
StartTime: story.StartTime,
|
||||||
|
EndTime: story.EndTime,
|
||||||
|
UpdatedAt: copyTime(story.UpdatedAt),
|
||||||
|
Title: story.Title,
|
||||||
|
Description: story.Description,
|
||||||
|
AltText: story.AltText,
|
||||||
|
Priority: story.Priority,
|
||||||
|
Order: story.Order,
|
||||||
|
DownloadURL: story.DownloadURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyTime(value *time.Time) *time.Time {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
copied := *value
|
||||||
|
return &copied
|
||||||
}
|
}
|
||||||
|
|
||||||
func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
|
func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
|
||||||
@@ -293,10 +314,6 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
|
|||||||
score += float64(len(daypart.AlertOverlaps)) * 100
|
score += float64(len(daypart.AlertOverlaps)) * 100
|
||||||
reasons = append(reasons, "alert overlap")
|
reasons = append(reasons, "alert overlap")
|
||||||
}
|
}
|
||||||
if daypart.Indicators.Thunder {
|
|
||||||
score += 75
|
|
||||||
reasons = append(reasons, "thunder risk")
|
|
||||||
}
|
|
||||||
if daypart.Indicators.Heat || daypart.Indicators.Cold {
|
if daypart.Indicators.Heat || daypart.Indicators.Cold {
|
||||||
score += 25
|
score += 25
|
||||||
if daypart.Indicators.Heat {
|
if daypart.Indicators.Heat {
|
||||||
@@ -334,9 +351,6 @@ func bottomLineText(conditions []string, hazards []string) string {
|
|||||||
|
|
||||||
func hazardsForIndicators(indicators forecast.Indicators) []string {
|
func hazardsForIndicators(indicators forecast.Indicators) []string {
|
||||||
var hazards []string
|
var hazards []string
|
||||||
if indicators.Thunder {
|
|
||||||
hazards = append(hazards, "thunder")
|
|
||||||
}
|
|
||||||
if indicators.Snow {
|
if indicators.Snow {
|
||||||
hazards = append(hazards, "snow")
|
hazards = append(hazards, "snow")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,19 @@ import (
|
|||||||
|
|
||||||
func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
||||||
bundle := loadBundleFixture(t)
|
bundle := loadBundleFixture(t)
|
||||||
|
currentIsDay := true
|
||||||
|
currentTemp := 75.9
|
||||||
|
currentFeelsLike := 76.1
|
||||||
|
currentHumidity := 56.0
|
||||||
|
currentWind := 10.7
|
||||||
|
bundle.Current = &forecast.Current{
|
||||||
|
ConditionText: "Partly cloudy",
|
||||||
|
IsDay: ¤tIsDay,
|
||||||
|
TemperatureF: ¤tTemp,
|
||||||
|
ApparentTemperatureF: ¤tFeelsLike,
|
||||||
|
RelativeHumidityPercent: ¤tHumidity,
|
||||||
|
WindSpeedMph: ¤tWind,
|
||||||
|
}
|
||||||
bundle.Sources[0].DataSHA256 = "abc123"
|
bundle.Sources[0].DataSHA256 = "abc123"
|
||||||
bundle.Warnings = []forecast.SourceWarning{{Source: "daily", Code: "missing_source", Severity: "warning"}}
|
bundle.Warnings = []forecast.SourceWarning{{Source: "daily", Code: "missing_source", Severity: "warning"}}
|
||||||
location := mustLocation(t)
|
location := mustLocation(t)
|
||||||
@@ -29,6 +42,12 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
|||||||
Bundle: bundle,
|
Bundle: bundle,
|
||||||
Units: "us",
|
Units: "us",
|
||||||
Timezone: "America/Chicago",
|
Timezone: "America/Chicago",
|
||||||
|
Location: &LocationContext{
|
||||||
|
ID: "home",
|
||||||
|
Name: "Brentwood",
|
||||||
|
Region: "St. Louis Metro",
|
||||||
|
Timezone: "America/Chicago",
|
||||||
|
},
|
||||||
}, summary)
|
}, summary)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("BuildDaily() error = %v", err)
|
t.Fatalf("BuildDaily() error = %v", err)
|
||||||
@@ -46,6 +65,12 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
|||||||
if pkg.Metadata.Units != "us" || pkg.Metadata.Timezone != "America/Chicago" {
|
if pkg.Metadata.Units != "us" || pkg.Metadata.Timezone != "America/Chicago" {
|
||||||
t.Fatalf("metadata units/timezone = %q/%q", pkg.Metadata.Units, pkg.Metadata.Timezone)
|
t.Fatalf("metadata units/timezone = %q/%q", pkg.Metadata.Units, pkg.Metadata.Timezone)
|
||||||
}
|
}
|
||||||
|
if pkg.Metadata.Location == nil || pkg.Metadata.Location.ID != "home" || pkg.Metadata.Location.Name != "Brentwood" || pkg.Metadata.Location.Region != "St. Louis Metro" || pkg.Metadata.Location.Timezone != "America/Chicago" {
|
||||||
|
t.Fatalf("metadata location = %#v, want configured prompt location", pkg.Metadata.Location)
|
||||||
|
}
|
||||||
|
if pkg.CurrentConditions == nil || pkg.CurrentConditions.ConditionText != "Partly cloudy" || pkg.CurrentConditions.TemperatureF == nil || *pkg.CurrentConditions.TemperatureF != currentTemp || pkg.CurrentConditions.RelativeHumidityPercent == nil || *pkg.CurrentConditions.RelativeHumidityPercent != currentHumidity {
|
||||||
|
t.Fatalf("CurrentConditions = %#v, want current conditions from bundle", pkg.CurrentConditions)
|
||||||
|
}
|
||||||
if len(pkg.Metadata.Sources) != 1 || pkg.Metadata.Sources[0].DataSHA256 != "abc123" {
|
if len(pkg.Metadata.Sources) != 1 || pkg.Metadata.Sources[0].DataSHA256 != "abc123" {
|
||||||
t.Fatalf("Sources = %#v, want source hash", pkg.Metadata.Sources)
|
t.Fatalf("Sources = %#v, want source hash", pkg.Metadata.Sources)
|
||||||
}
|
}
|
||||||
@@ -55,8 +80,8 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
|||||||
if pkg.Daily == nil {
|
if pkg.Daily == nil {
|
||||||
t.Fatal("Daily = nil")
|
t.Fatal("Daily = nil")
|
||||||
}
|
}
|
||||||
if len(pkg.Daily.Dayparts) != 4 {
|
if len(pkg.Daily.Dayparts) != 5 {
|
||||||
t.Fatalf("Dayparts length = %d, want 4", len(pkg.Daily.Dayparts))
|
t.Fatalf("Dayparts length = %d, want 5", len(pkg.Daily.Dayparts))
|
||||||
}
|
}
|
||||||
if len(pkg.Daily.RelevantAlerts) != 1 {
|
if len(pkg.Daily.RelevantAlerts) != 1 {
|
||||||
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.Daily.RelevantAlerts))
|
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.Daily.RelevantAlerts))
|
||||||
@@ -67,6 +92,12 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
|||||||
if len(pkg.Daily.Discussion.KeyMessages) != 1 {
|
if len(pkg.Daily.Discussion.KeyMessages) != 1 {
|
||||||
t.Fatalf("Discussion key messages length = %d, want 1", len(pkg.Daily.Discussion.KeyMessages))
|
t.Fatalf("Discussion key messages length = %d, want 1", len(pkg.Daily.Discussion.KeyMessages))
|
||||||
}
|
}
|
||||||
|
if pkg.Daily.Discussion.ShortTerm != "Morning showers taper as a weak boundary shifts east." {
|
||||||
|
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Daily.Discussion.ShortTerm)
|
||||||
|
}
|
||||||
|
if pkg.Daily.Discussion.LongTerm != "Warmer and more humid conditions return with periodic rain chances." {
|
||||||
|
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Daily.Discussion.LongTerm)
|
||||||
|
}
|
||||||
if pkg.Daily.OutdoorWindows.Best == nil || pkg.Daily.OutdoorWindows.Worst == nil {
|
if pkg.Daily.OutdoorWindows.Best == nil || pkg.Daily.OutdoorWindows.Worst == nil {
|
||||||
t.Fatalf("OutdoorWindows = %#v, want best and worst", pkg.Daily.OutdoorWindows)
|
t.Fatalf("OutdoorWindows = %#v, want best and worst", pkg.Daily.OutdoorWindows)
|
||||||
}
|
}
|
||||||
@@ -85,7 +116,13 @@ func TestDailyBriefingQuietWeather(t *testing.T) {
|
|||||||
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{
|
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{
|
||||||
quietHour("2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", 72),
|
quietHour("2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", 72),
|
||||||
}},
|
}},
|
||||||
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
|
Alerts: &forecast.AlertRun{},
|
||||||
|
Sources: []forecast.Source{
|
||||||
|
{Name: "hourly", FetchedAt: time.Now()},
|
||||||
|
{Name: "alerts", Endpoint: "/alerts/active", FetchedAt: time.Now()},
|
||||||
|
{Name: "current", Endpoint: "/conditions/current", FetchedAt: time.Now(), Missing: true},
|
||||||
|
},
|
||||||
|
Warnings: []forecast.SourceWarning{{Source: "current", Code: "missing_source", Severity: "warning"}},
|
||||||
}
|
}
|
||||||
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
|
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -98,9 +135,28 @@ func TestDailyBriefingQuietWeather(t *testing.T) {
|
|||||||
if pkg.Daily.BottomLine.Summary != "Conditions: Clear." {
|
if pkg.Daily.BottomLine.Summary != "Conditions: Clear." {
|
||||||
t.Fatalf("BottomLine summary = %q, want clear conditions", pkg.Daily.BottomLine.Summary)
|
t.Fatalf("BottomLine summary = %q, want clear conditions", pkg.Daily.BottomLine.Summary)
|
||||||
}
|
}
|
||||||
|
if pkg.CurrentConditions != nil {
|
||||||
|
t.Fatalf("CurrentConditions = %#v, want nil when current conditions are missing", pkg.CurrentConditions)
|
||||||
|
}
|
||||||
|
if len(pkg.Metadata.SourceWarnings) != 1 || pkg.Metadata.SourceWarnings[0].Source != "current" {
|
||||||
|
t.Fatalf("SourceWarnings = %#v, want current missing-source warning", pkg.Metadata.SourceWarnings)
|
||||||
|
}
|
||||||
if len(pkg.Daily.RelevantAlerts) != 0 {
|
if len(pkg.Daily.RelevantAlerts) != 0 {
|
||||||
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts))
|
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts))
|
||||||
}
|
}
|
||||||
|
if pkg.Metadata.Alerts == nil {
|
||||||
|
t.Fatal("Metadata.Alerts = nil, want checked no-active-alerts status")
|
||||||
|
}
|
||||||
|
if !pkg.Metadata.Alerts.Checked || pkg.Metadata.Alerts.ActiveCount != 0 || pkg.Metadata.Alerts.RelevantCount != 0 || pkg.Metadata.Alerts.Missing {
|
||||||
|
t.Fatalf("Metadata.Alerts = %#v, want checked no-active-alerts status", pkg.Metadata.Alerts)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(pkg.Metadata.Alerts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal alert metadata: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), `"missing"`) {
|
||||||
|
t.Fatalf("alert metadata includes missing for checked empty alerts:\n%s", string(data))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDailyBriefingAlertExclusion(t *testing.T) {
|
func TestDailyBriefingAlertExclusion(t *testing.T) {
|
||||||
@@ -163,7 +219,7 @@ func TestTomorrowBriefingIncludesPlanningInputs(t *testing.T) {
|
|||||||
Value: wind,
|
Value: wind,
|
||||||
Time: mustParse("2026-05-30T09:00:00-05:00"),
|
Time: mustParse("2026-05-30T09:00:00-05:00"),
|
||||||
},
|
},
|
||||||
Indicators: forecast.Indicators{Thunder: true},
|
Indicators: forecast.Indicators{Snow: true},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -237,9 +293,10 @@ func mustResolveDaily(t *testing.T, location *time.Location) report.Resolved {
|
|||||||
func defaultDayparts() []forecast.DaypartDefinition {
|
func defaultDayparts() []forecast.DaypartDefinition {
|
||||||
return []forecast.DaypartDefinition{
|
return []forecast.DaypartDefinition{
|
||||||
{Name: "overnight", Start: "00:00", End: "06:00"},
|
{Name: "overnight", Start: "00:00", End: "06:00"},
|
||||||
{Name: "morning", Start: "06:00", End: "12:00"},
|
{Name: "morning", Start: "06:00", End: "10:00"},
|
||||||
{Name: "afternoon", Start: "12:00", End: "18:00"},
|
{Name: "midday", Start: "10:00", End: "15:00"},
|
||||||
{Name: "evening", Start: "18:00", End: "24:00"},
|
{Name: "afternoon", Start: "15:00", End: "17:00"},
|
||||||
|
{Name: "evening", Start: "17:00", End: "24:00"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,10 @@
|
|||||||
package briefing
|
package briefing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
@@ -17,6 +15,7 @@ const SchemaVersion = "weatherreporter.briefing.v1"
|
|||||||
|
|
||||||
type Package struct {
|
type Package struct {
|
||||||
Metadata Metadata `json:"metadata"`
|
Metadata Metadata `json:"metadata"`
|
||||||
|
CurrentConditions *CurrentConditionsContext `json:"currentConditions,omitempty"`
|
||||||
Daily *Daily `json:"daily,omitempty"`
|
Daily *Daily `json:"daily,omitempty"`
|
||||||
ThreeDay *ThreeDay `json:"threeDay,omitempty"`
|
ThreeDay *ThreeDay `json:"threeDay,omitempty"`
|
||||||
Weekend *Weekend `json:"weekend,omitempty"`
|
Weekend *Weekend `json:"weekend,omitempty"`
|
||||||
@@ -33,10 +32,34 @@ type Metadata struct {
|
|||||||
Units string `json:"units"`
|
Units string `json:"units"`
|
||||||
Timezone string `json:"timezone"`
|
Timezone string `json:"timezone"`
|
||||||
ValidPeriod timeutil.Period `json:"validPeriod"`
|
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||||
|
Location *LocationContext `json:"location,omitempty"`
|
||||||
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
||||||
SourceLocation string `json:"sourceLocation,omitempty"`
|
SourceLocation string `json:"sourceLocation,omitempty"`
|
||||||
Sources []SourceMetadata `json:"sources,omitempty"`
|
Sources []SourceMetadata `json:"sources,omitempty"`
|
||||||
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"`
|
SourceWarnings []forecast.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 CurrentConditionsContext struct {
|
||||||
|
ConditionText string `json:"conditionText,omitempty"`
|
||||||
|
IsDay *bool `json:"isDay,omitempty"`
|
||||||
|
TemperatureC *float64 `json:"temperatureC,omitempty"`
|
||||||
|
TemperatureF *float64 `json:"temperatureF,omitempty"`
|
||||||
|
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty"`
|
||||||
|
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"`
|
||||||
|
DewpointC *float64 `json:"dewpointC,omitempty"`
|
||||||
|
DewpointF *float64 `json:"dewpointF,omitempty"`
|
||||||
|
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"`
|
||||||
|
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"`
|
||||||
|
WindSpeedMph *float64 `json:"windSpeedMph,omitempty"`
|
||||||
|
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SourceMetadata struct {
|
type SourceMetadata struct {
|
||||||
@@ -50,11 +73,19 @@ type SourceMetadata struct {
|
|||||||
Warnings []forecast.SourceWarning `json:"warnings,omitempty"`
|
Warnings []forecast.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 {
|
type BuildContext struct {
|
||||||
Resolved report.Resolved
|
Resolved report.Resolved
|
||||||
Bundle *forecast.Bundle
|
Bundle *forecast.Bundle
|
||||||
Units string
|
Units string
|
||||||
Timezone string
|
Timezone string
|
||||||
|
Location *LocationContext
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildMetadata(ctx BuildContext) Metadata {
|
func BuildMetadata(ctx BuildContext) Metadata {
|
||||||
@@ -70,37 +101,85 @@ func BuildMetadata(ctx BuildContext) Metadata {
|
|||||||
Units: ctx.Units,
|
Units: ctx.Units,
|
||||||
Timezone: ctx.Timezone,
|
Timezone: ctx.Timezone,
|
||||||
ValidPeriod: metadata.ValidPeriod,
|
ValidPeriod: metadata.ValidPeriod,
|
||||||
|
Location: copyLocation(ctx.Location),
|
||||||
SourceLocationID: sourceLocationID,
|
SourceLocationID: sourceLocationID,
|
||||||
SourceLocation: sourceLocation,
|
SourceLocation: sourceLocation,
|
||||||
Sources: sourceMetadata(ctx.Bundle),
|
Sources: sourceMetadata(ctx.Bundle),
|
||||||
SourceWarnings: sourceWarnings(ctx.Bundle),
|
SourceWarnings: sourceWarnings(ctx.Bundle),
|
||||||
|
Alerts: alertStatus(ctx.Bundle),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func Save(path string, pkg Package) error {
|
func buildPackage(ctx BuildContext) Package {
|
||||||
data, err := json.MarshalIndent(pkg, "", " ")
|
return Package{
|
||||||
if err != nil {
|
Metadata: BuildMetadata(ctx),
|
||||||
return fmt.Errorf("marshal briefing package: %w", err)
|
CurrentConditions: currentConditions(ctx.Bundle),
|
||||||
}
|
}
|
||||||
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 {
|
func copyLocation(location *LocationContext) *LocationContext {
|
||||||
tmp.Close()
|
if location == nil {
|
||||||
return fmt.Errorf("write temporary briefing file: %w", err)
|
return nil
|
||||||
}
|
}
|
||||||
if err := tmp.Close(); err != nil {
|
copied := *location
|
||||||
return fmt.Errorf("close temporary briefing file: %w", err)
|
return &copied
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentConditions(bundle *forecast.Bundle) *CurrentConditionsContext {
|
||||||
|
if bundle == nil || bundle.Current == nil {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
if err := os.Rename(tmpName, path); err != nil {
|
current := bundle.Current
|
||||||
return fmt.Errorf("save briefing %q: %w", path, err)
|
context := CurrentConditionsContext{
|
||||||
|
ConditionText: current.ConditionText,
|
||||||
|
IsDay: copyBool(current.IsDay),
|
||||||
|
TemperatureC: copyFloat(current.TemperatureC),
|
||||||
|
TemperatureF: copyFloat(current.TemperatureF),
|
||||||
|
ApparentTemperatureC: copyFloat(current.ApparentTemperatureC),
|
||||||
|
ApparentTemperatureF: copyFloat(current.ApparentTemperatureF),
|
||||||
|
DewpointC: copyFloat(current.DewpointC),
|
||||||
|
DewpointF: copyFloat(current.DewpointF),
|
||||||
|
RelativeHumidityPercent: copyFloat(current.RelativeHumidityPercent),
|
||||||
|
WindSpeedKmh: copyFloat(current.WindSpeedKmh),
|
||||||
|
WindSpeedMph: copyFloat(current.WindSpeedMph),
|
||||||
|
WindDirectionDegrees: copyFloat(current.WindDirectionDegrees),
|
||||||
|
}
|
||||||
|
if context.ConditionText == "" &&
|
||||||
|
context.IsDay == nil &&
|
||||||
|
context.TemperatureC == nil &&
|
||||||
|
context.TemperatureF == nil &&
|
||||||
|
context.ApparentTemperatureC == nil &&
|
||||||
|
context.ApparentTemperatureF == nil &&
|
||||||
|
context.DewpointC == nil &&
|
||||||
|
context.DewpointF == nil &&
|
||||||
|
context.RelativeHumidityPercent == nil &&
|
||||||
|
context.WindSpeedKmh == nil &&
|
||||||
|
context.WindSpeedMph == nil &&
|
||||||
|
context.WindDirectionDegrees == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &context
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyBool(value *bool) *bool {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
copied := *value
|
||||||
|
return &copied
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFloat(value *float64) *float64 {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
copied := *value
|
||||||
|
return &copied
|
||||||
|
}
|
||||||
|
|
||||||
|
func Save(path string, pkg Package) error {
|
||||||
|
if err := fileutil.WriteJSONAtomic(path, pkg); err != nil {
|
||||||
|
return fmt.Errorf("save briefing package: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -147,6 +226,37 @@ func sourceWarnings(bundle *forecast.Bundle) []forecast.SourceWarning {
|
|||||||
return bundle.Warnings
|
return bundle.Warnings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func alertStatus(bundle *forecast.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 setRelevantAlertCount(metadata *Metadata, count int) {
|
||||||
|
if metadata.Alerts == nil {
|
||||||
|
if count == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
metadata.Alerts = &AlertStatus{}
|
||||||
|
}
|
||||||
|
metadata.Alerts.RelevantCount = count
|
||||||
|
}
|
||||||
|
|
||||||
func variantForReport(id report.ID) string {
|
func variantForReport(id report.ID) string {
|
||||||
switch id {
|
switch id {
|
||||||
case report.DailyToday:
|
case report.DailyToday:
|
||||||
|
|||||||
@@ -56,10 +56,10 @@ func BuildStorm(ctx BuildContext) (Package, error) {
|
|||||||
Discussion: buildDiscussion(ctx.Bundle.Discussion),
|
Discussion: buildDiscussion(ctx.Bundle.Discussion),
|
||||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||||
}
|
}
|
||||||
return Package{
|
pkg := buildPackage(ctx)
|
||||||
Metadata: BuildMetadata(ctx),
|
pkg.Storm = storm
|
||||||
Storm: storm,
|
setRelevantAlertCount(&pkg.Metadata, len(alerts))
|
||||||
}, nil
|
return pkg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func stormHeadlines(alerts []forecast.AlertOverlap) []string {
|
func stormHeadlines(alerts []forecast.AlertOverlap) []string {
|
||||||
@@ -139,9 +139,6 @@ func reasonableWorstCase(alerts []forecast.AlertOverlap, summary forecast.Daypar
|
|||||||
items = appendUnique(items, "Alert scenario to consider: "+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 {
|
if summary.Indicators.Wind {
|
||||||
items = appendUnique(items, "Wind impacts could be higher where stronger gusts occur.")
|
items = appendUnique(items, "Wind impacts could be higher where stronger gusts occur.")
|
||||||
}
|
}
|
||||||
@@ -161,13 +158,17 @@ func stormConfidenceInputs(bundle *forecast.Bundle) []string {
|
|||||||
}
|
}
|
||||||
if bundle.Discussion != nil {
|
if bundle.Discussion != nil {
|
||||||
items = appendUnique(items, bundle.Discussion.KeyMessages...)
|
items = appendUnique(items, bundle.Discussion.KeyMessages...)
|
||||||
if bundle.Discussion.ShortTerm != nil && bundle.Discussion.ShortTerm.Narrative != "" {
|
if bundle.Discussion.ShortTerm != nil && bundle.Discussion.ShortTerm.Text != "" {
|
||||||
items = appendUnique(items, "Short-term discussion is available for confidence context.")
|
items = appendUnique(items, "Short-term discussion is available for confidence context.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if bundle.WeatherStory != nil && len(bundle.WeatherStory.Raw) > 0 {
|
if bundle.WeatherStory != nil {
|
||||||
|
if bundle.WeatherStory.Title != "" {
|
||||||
|
items = appendUnique(items, "Weather story: "+bundle.WeatherStory.Title+".")
|
||||||
|
} else {
|
||||||
items = appendUnique(items, "Weather story source is available.")
|
items = appendUnique(items, "Weather story source is available.")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
for _, warning := range bundle.Warnings {
|
for _, warning := range bundle.Warnings {
|
||||||
if warning.Code != "" {
|
if warning.Code != "" {
|
||||||
items = appendUnique(items, "Source warning: "+warning.Code+".")
|
items = appendUnique(items, "Source warning: "+warning.Code+".")
|
||||||
|
|||||||
@@ -44,8 +44,21 @@ func TestStormBriefingWithActiveAlert(t *testing.T) {
|
|||||||
Alerts: &forecast.AlertRun{Alerts: []json.RawMessage{
|
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"}`),
|
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."}},
|
Discussion: &forecast.Discussion{
|
||||||
WeatherStory: &forecast.WeatherStory{Raw: json.RawMessage(`{"headline":"Storm risk"}`)},
|
Product: "discussion",
|
||||||
|
KeyMessages: []string{"Storms may intensify quickly."},
|
||||||
|
ShortTerm: &forecast.DiscussionSection{Text: "Short-term storm coverage peaks this morning."},
|
||||||
|
LongTerm: &forecast.DiscussionSection{Text: "Long-term pattern stays unsettled after the event."},
|
||||||
|
},
|
||||||
|
WeatherStory: &forecast.WeatherStory{
|
||||||
|
OfficeID: "LSX",
|
||||||
|
StartTime: mustParse("2026-05-29T06:00:00Z"),
|
||||||
|
EndTime: mustParse("2026-05-29T18:00:00Z"),
|
||||||
|
Title: "Storm Risk",
|
||||||
|
Description: "Strong storms are possible.",
|
||||||
|
AltText: "Weather story graphic showing storm risk.",
|
||||||
|
Order: 1,
|
||||||
|
},
|
||||||
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
|
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +88,15 @@ func TestStormBriefingWithActiveAlert(t *testing.T) {
|
|||||||
if pkg.Storm.WeatherStory == nil {
|
if pkg.Storm.WeatherStory == nil {
|
||||||
t.Fatal("WeatherStory = nil, want available story context")
|
t.Fatal("WeatherStory = nil, want available story context")
|
||||||
}
|
}
|
||||||
|
if pkg.Storm.WeatherStory.Title != "Storm Risk" || pkg.Storm.WeatherStory.Description != "Strong storms are possible." {
|
||||||
|
t.Fatalf("WeatherStory = %#v, want structured story context", pkg.Storm.WeatherStory)
|
||||||
|
}
|
||||||
|
if pkg.Storm.Discussion.ShortTerm != "Short-term storm coverage peaks this morning." {
|
||||||
|
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Storm.Discussion.ShortTerm)
|
||||||
|
}
|
||||||
|
if pkg.Storm.Discussion.LongTerm != "Long-term pattern stays unsettled after the event." {
|
||||||
|
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Storm.Discussion.LongTerm)
|
||||||
|
}
|
||||||
if len(pkg.Storm.WhatToWatchNext) == 0 {
|
if len(pkg.Storm.WhatToWatchNext) == 0 {
|
||||||
t.Fatal("WhatToWatchNext length = 0, want watch inputs")
|
t.Fatal("WhatToWatchNext length = 0, want watch inputs")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,18 +37,17 @@ func BuildThreeDay(ctx BuildContext, summaries []forecast.DailySummary) (Package
|
|||||||
if len(summaries) == 0 {
|
if len(summaries) == 0 {
|
||||||
return Package{}, fmt.Errorf("3-day forecast summaries are required")
|
return Package{}, fmt.Errorf("3-day forecast summaries are required")
|
||||||
}
|
}
|
||||||
pkg := Package{
|
pkg := buildPackage(ctx)
|
||||||
Metadata: BuildMetadata(ctx),
|
pkg.ThreeDay = &ThreeDay{
|
||||||
ThreeDay: &ThreeDay{
|
|
||||||
Discussion: buildDiscussion(summaries[0].Discussion),
|
Discussion: buildDiscussion(summaries[0].Discussion),
|
||||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||||
},
|
|
||||||
}
|
}
|
||||||
for _, summary := range summaries {
|
for _, summary := range summaries {
|
||||||
day := buildOutlookDay(summary)
|
day := buildOutlookDay(summary)
|
||||||
pkg.ThreeDay.Days = append(pkg.ThreeDay.Days, day)
|
pkg.ThreeDay.Days = append(pkg.ThreeDay.Days, day)
|
||||||
}
|
}
|
||||||
pkg.ThreeDay.RelevantAlerts = collectOutlookAlerts(pkg.ThreeDay.Days)
|
pkg.ThreeDay.RelevantAlerts = collectOutlookAlerts(pkg.ThreeDay.Days)
|
||||||
|
setRelevantAlertCount(&pkg.Metadata, len(pkg.ThreeDay.RelevantAlerts))
|
||||||
return pkg, nil
|
return pkg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,11 +39,16 @@ func TestThreeDayBriefingBuildsOutlookDays(t *testing.T) {
|
|||||||
Value: gust,
|
Value: gust,
|
||||||
Time: mustParse("2026-05-29T10:00:00-05:00"),
|
Time: mustParse("2026-05-29T10:00:00-05:00"),
|
||||||
},
|
},
|
||||||
Indicators: forecast.Indicators{Thunder: true, Wind: true},
|
Indicators: forecast.Indicators{Wind: true},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
|
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
|
||||||
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Unsettled stretch."}},
|
Discussion: &forecast.Discussion{
|
||||||
|
Product: "discussion",
|
||||||
|
KeyMessages: []string{"Unsettled stretch."},
|
||||||
|
ShortTerm: &forecast.DiscussionSection{Text: "Short-term rain chances remain focused today."},
|
||||||
|
LongTerm: &forecast.DiscussionSection{Text: "Long-term warmth builds into the weekend."},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Date: "2026-05-30",
|
Date: "2026-05-30",
|
||||||
@@ -74,10 +79,16 @@ func TestThreeDayBriefingBuildsOutlookDays(t *testing.T) {
|
|||||||
t.Fatalf("Days length = %d, want 2", len(pkg.ThreeDay.Days))
|
t.Fatalf("Days length = %d, want 2", len(pkg.ThreeDay.Days))
|
||||||
}
|
}
|
||||||
first := pkg.ThreeDay.Days[0]
|
first := pkg.ThreeDay.Days[0]
|
||||||
if !strings.Contains(first.OverallCharacter, "Showers") || !strings.Contains(strings.Join(first.Risks, ","), "thunder") {
|
if !strings.Contains(first.OverallCharacter, "Showers") || !strings.Contains(strings.Join(first.Risks, ","), "wind") {
|
||||||
t.Fatalf("first day = %#v, want conditions and risks", first)
|
t.Fatalf("first day = %#v, want conditions and risks", first)
|
||||||
}
|
}
|
||||||
if len(pkg.ThreeDay.RelevantAlerts) != 1 {
|
if len(pkg.ThreeDay.RelevantAlerts) != 1 {
|
||||||
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.ThreeDay.RelevantAlerts))
|
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.ThreeDay.RelevantAlerts))
|
||||||
}
|
}
|
||||||
|
if pkg.ThreeDay.Discussion.ShortTerm != "Short-term rain chances remain focused today." {
|
||||||
|
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.ThreeDay.Discussion.ShortTerm)
|
||||||
|
}
|
||||||
|
if pkg.ThreeDay.Discussion.LongTerm != "Long-term warmth builds into the weekend." {
|
||||||
|
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.ThreeDay.Discussion.LongTerm)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,17 +31,16 @@ func BuildWeekend(ctx BuildContext, summaries []forecast.DailySummary) (Package,
|
|||||||
if len(summaries) == 0 {
|
if len(summaries) == 0 {
|
||||||
return Package{}, fmt.Errorf("weekend forecast summaries are required")
|
return Package{}, fmt.Errorf("weekend forecast summaries are required")
|
||||||
}
|
}
|
||||||
pkg := Package{
|
pkg := buildPackage(ctx)
|
||||||
Metadata: BuildMetadata(ctx),
|
pkg.Weekend = &Weekend{
|
||||||
Weekend: &Weekend{
|
|
||||||
Discussion: buildDiscussion(summaries[0].Discussion),
|
Discussion: buildDiscussion(summaries[0].Discussion),
|
||||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||||
},
|
|
||||||
}
|
}
|
||||||
for _, summary := range summaries {
|
for _, summary := range summaries {
|
||||||
pkg.Weekend.Days = append(pkg.Weekend.Days, buildOutlookDay(summary))
|
pkg.Weekend.Days = append(pkg.Weekend.Days, buildOutlookDay(summary))
|
||||||
}
|
}
|
||||||
pkg.Weekend.RelevantAlerts = collectOutlookAlerts(pkg.Weekend.Days)
|
pkg.Weekend.RelevantAlerts = collectOutlookAlerts(pkg.Weekend.Days)
|
||||||
|
setRelevantAlertCount(&pkg.Metadata, len(pkg.Weekend.RelevantAlerts))
|
||||||
pkg.Weekend.Planning = buildWeekendPlanning(pkg.Weekend.Days, pkg.Weekend.Discussion, ctx.Bundle)
|
pkg.Weekend.Planning = buildWeekendPlanning(pkg.Weekend.Days, pkg.Weekend.Discussion, ctx.Bundle)
|
||||||
return pkg, nil
|
return pkg, nil
|
||||||
}
|
}
|
||||||
@@ -96,9 +95,6 @@ func weekendRainStormNotes(date string, daypart forecast.DaypartSummary) []strin
|
|||||||
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 {
|
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 {
|
||||||
notes = append(notes, fmt.Sprintf("%s precipitation chance peaks near %.0f%%.", label, daypart.MaxPrecipitationProbability.Value))
|
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
|
return notes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) {
|
|||||||
Value: gust,
|
Value: gust,
|
||||||
Time: mustParse("2026-05-30T16:00:00-05:00"),
|
Time: mustParse("2026-05-30T16:00:00-05:00"),
|
||||||
},
|
},
|
||||||
Indicators: forecast.Indicators{Thunder: true, Wind: true},
|
Indicators: forecast.Indicators{Wind: true},
|
||||||
HourlyPeriods: []forecast.ForecastPeriod{
|
HourlyPeriods: []forecast.ForecastPeriod{
|
||||||
{
|
{
|
||||||
StartTime: mustParse("2026-05-30T15:00:00-05:00"),
|
StartTime: mustParse("2026-05-30T15:00:00-05:00"),
|
||||||
@@ -54,7 +54,12 @@ func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
|
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
|
||||||
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Timing may shift."}},
|
Discussion: &forecast.Discussion{
|
||||||
|
Product: "discussion",
|
||||||
|
KeyMessages: []string{"Timing may shift."},
|
||||||
|
ShortTerm: &forecast.DiscussionSection{Text: "Short-term showers exit before the weekend."},
|
||||||
|
LongTerm: &forecast.DiscussionSection{Text: "Long-term weekend rain timing remains uncertain."},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,10 +84,16 @@ func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) {
|
|||||||
if len(pkg.Weekend.Planning.WorstWeatherWindows) == 0 {
|
if len(pkg.Weekend.Planning.WorstWeatherWindows) == 0 {
|
||||||
t.Fatalf("WorstWeatherWindows = %#v, want weather window", pkg.Weekend.Planning.WorstWeatherWindows)
|
t.Fatalf("WorstWeatherWindows = %#v, want weather window", pkg.Weekend.Planning.WorstWeatherWindows)
|
||||||
}
|
}
|
||||||
if !strings.Contains(strings.Join(pkg.Weekend.Planning.RainStormTiming, " "), "thunder") {
|
if !strings.Contains(strings.Join(pkg.Weekend.Planning.RainStormTiming, " "), "precipitation") {
|
||||||
t.Fatalf("RainStormTiming = %#v, want thunder timing", pkg.Weekend.Planning.RainStormTiming)
|
t.Fatalf("RainStormTiming = %#v, want precipitation timing", pkg.Weekend.Planning.RainStormTiming)
|
||||||
}
|
}
|
||||||
if len(pkg.Weekend.Planning.UncertaintyInputs) == 0 {
|
if len(pkg.Weekend.Planning.UncertaintyInputs) == 0 {
|
||||||
t.Fatal("UncertaintyInputs length = 0, want discussion context")
|
t.Fatal("UncertaintyInputs length = 0, want discussion context")
|
||||||
}
|
}
|
||||||
|
if pkg.Weekend.Discussion.ShortTerm != "Short-term showers exit before the weekend." {
|
||||||
|
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Weekend.Discussion.ShortTerm)
|
||||||
|
}
|
||||||
|
if pkg.Weekend.Discussion.LongTerm != "Long-term weekend rain timing remains uncertain." {
|
||||||
|
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Weekend.Discussion.LongTerm)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,7 +126,6 @@ func compareIndicators(previous forecast.Indicators, current forecast.Indicators
|
|||||||
previous bool
|
previous bool
|
||||||
current bool
|
current bool
|
||||||
}{
|
}{
|
||||||
{name: "thunder", previous: previous.Thunder, current: current.Thunder},
|
|
||||||
{name: "snow", previous: previous.Snow, current: current.Snow},
|
{name: "snow", previous: previous.Snow, current: current.Snow},
|
||||||
{name: "ice", previous: previous.Ice, current: current.Ice},
|
{name: "ice", previous: previous.Ice, current: current.Ice},
|
||||||
} {
|
} {
|
||||||
@@ -146,7 +145,6 @@ func compareIndicators(previous forecast.Indicators, current forecast.Indicators
|
|||||||
func aggregateIndicators(dayparts []forecast.DaypartSummary) forecast.Indicators {
|
func aggregateIndicators(dayparts []forecast.DaypartSummary) forecast.Indicators {
|
||||||
out := forecast.Indicators{}
|
out := forecast.Indicators{}
|
||||||
for _, daypart := range dayparts {
|
for _, daypart := range dayparts {
|
||||||
out.Thunder = out.Thunder || daypart.Indicators.Thunder
|
|
||||||
out.Snow = out.Snow || daypart.Indicators.Snow
|
out.Snow = out.Snow || daypart.Indicators.Snow
|
||||||
out.Ice = out.Ice || daypart.Indicators.Ice
|
out.Ice = out.Ice || daypart.Indicators.Ice
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,14 +62,14 @@ func TestCompareDailyAlertAddedAndRemoved(t *testing.T) {
|
|||||||
|
|
||||||
func TestCompareDailyIndicatorChange(t *testing.T) {
|
func TestCompareDailyIndicatorChange(t *testing.T) {
|
||||||
previous := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
previous := 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})
|
current := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{Snow: true})
|
||||||
|
|
||||||
changes, err := CompareDaily(previous, current, testThresholds())
|
changes, err := CompareDaily(previous, current, testThresholds())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CompareDaily() error = %v", err)
|
t.Fatalf("CompareDaily() error = %v", err)
|
||||||
}
|
}
|
||||||
if countType(changes, "thunder_risk_change") != 1 {
|
if countType(changes, "snow_risk_change") != 1 {
|
||||||
t.Fatalf("changes = %#v, want thunder risk change", changes)
|
t.Fatalf("changes = %#v, want snow risk change", changes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func TestCompareThreeDayDetectsDayChanges(t *testing.T) {
|
|||||||
Value: currentPrecip,
|
Value: currentPrecip,
|
||||||
Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC),
|
Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC),
|
||||||
},
|
},
|
||||||
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Thunder: true}}},
|
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Snow: true}}},
|
||||||
}}},
|
}}},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,16 +51,16 @@ func TestCompareThreeDayDetectsDayChanges(t *testing.T) {
|
|||||||
t.Fatal("changes length = 0, want detected 3-day changes")
|
t.Fatal("changes length = 0, want detected 3-day changes")
|
||||||
}
|
}
|
||||||
var foundPrecip bool
|
var foundPrecip bool
|
||||||
var foundThunder bool
|
var foundSnow bool
|
||||||
for _, change := range changes {
|
for _, change := range changes {
|
||||||
if change.Type == "outlook_precip_probability_change" {
|
if change.Type == "outlook_precip_probability_change" {
|
||||||
foundPrecip = true
|
foundPrecip = true
|
||||||
}
|
}
|
||||||
if change.Type == "outlook_thunder_risk_change" {
|
if change.Type == "outlook_snow_risk_change" {
|
||||||
foundThunder = true
|
foundSnow = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !foundPrecip || !foundThunder {
|
if !foundPrecip || !foundSnow {
|
||||||
t.Fatalf("changes = %#v, want precipitation and thunder changes", changes)
|
t.Fatalf("changes = %#v, want precipitation and snow changes", changes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func TestCompareWeekendDetectsOutlookChanges(t *testing.T) {
|
|||||||
Weekend: &briefing.Weekend{Days: []briefing.OutlookDay{{
|
Weekend: &briefing.Weekend{Days: []briefing.OutlookDay{{
|
||||||
Date: "2026-05-30",
|
Date: "2026-05-30",
|
||||||
Temperature: forecast.Range{Max: ¤tTemp},
|
Temperature: forecast.Range{Max: ¤tTemp},
|
||||||
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Thunder: true}}},
|
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Snow: true}}},
|
||||||
}}},
|
}}},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,9 +35,9 @@ func TestCompareWeekendDetectsOutlookChanges(t *testing.T) {
|
|||||||
t.Fatal("changes length = 0, want weekend changes")
|
t.Fatal("changes length = 0, want weekend changes")
|
||||||
}
|
}
|
||||||
for _, change := range changes {
|
for _, change := range changes {
|
||||||
if change.Type == "weekend_outlook_thunder_risk_change" {
|
if change.Type == "weekend_outlook_snow_risk_change" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
t.Fatalf("changes = %#v, want thunder risk change", changes)
|
t.Fatalf("changes = %#v, want snow risk change", changes)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -72,7 +73,7 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr
|
|||||||
result, err := app.RunBatchDetailed(ctx, req)
|
result, err := app.RunBatchDetailed(ctx, req)
|
||||||
if result != nil {
|
if result != nil {
|
||||||
writeRunLogs(stderr, result)
|
writeRunLogs(stderr, result)
|
||||||
if encodeErr := writeRunSummary(stdout, result); encodeErr != nil {
|
if encodeErr := writeJSON(stdout, result); encodeErr != nil {
|
||||||
return encodeErr
|
return encodeErr
|
||||||
}
|
}
|
||||||
if result.Failed > 0 {
|
if result.Failed > 0 {
|
||||||
@@ -108,6 +109,29 @@ type inspectOptions struct {
|
|||||||
RunID string
|
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: "briefing", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
|
||||||
|
return app.InspectBriefing(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 {
|
func (r Runner) runInspect(ctx context.Context, args []string, stdout io.Writer) error {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return fmt.Errorf("inspect requires a command")
|
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 err
|
||||||
}
|
}
|
||||||
return writeJSON(stdout, records)
|
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:
|
default:
|
||||||
|
for _, candidate := range inspectRunCommands {
|
||||||
|
if candidate.Name == command {
|
||||||
|
return runInspectRunCommand(ctx, stdout, candidate, args[1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
return fmt.Errorf("unknown inspect command %q", command)
|
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) {
|
func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
||||||
if r.Clock == nil {
|
if r.Clock == nil {
|
||||||
r.Clock = timeutil.SystemClock{}
|
r.Clock = timeutil.SystemClock{}
|
||||||
@@ -210,12 +185,12 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
|||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return app.GenerateRequest{}, fmt.Errorf("generate requires a report name")
|
return app.GenerateRequest{}, fmt.Errorf("generate requires a report name")
|
||||||
}
|
}
|
||||||
report, ok := reportKind(args[0])
|
reportKind, ok := reportKind(args[0])
|
||||||
if !ok {
|
if !ok {
|
||||||
return app.GenerateRequest{}, fmt.Errorf("unknown generate report %q", args[0])
|
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 {
|
if err != nil {
|
||||||
return app.GenerateRequest{}, err
|
return app.GenerateRequest{}, err
|
||||||
}
|
}
|
||||||
@@ -223,7 +198,6 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
|||||||
Path: opts.ConfigPath,
|
Path: opts.ConfigPath,
|
||||||
Units: opts.Units,
|
Units: opts.Units,
|
||||||
Timezone: opts.Timezone,
|
Timezone: opts.Timezone,
|
||||||
Output: opts.Output,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return app.GenerateRequest{}, err
|
return app.GenerateRequest{}, err
|
||||||
@@ -235,12 +209,12 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
|||||||
|
|
||||||
req := app.GenerateRequest{
|
req := app.GenerateRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Report: report,
|
Report: reportKind,
|
||||||
OutputPath: opts.Output,
|
OutputPath: opts.Output,
|
||||||
Now: r.Clock.Now(),
|
Now: r.Clock.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
switch report {
|
switch reportKind {
|
||||||
case app.ReportDaily:
|
case app.ReportDaily:
|
||||||
if opts.Date == "" {
|
if opts.Date == "" {
|
||||||
req.Date = timeutil.LocalDate(r.Clock.Now(), location)
|
req.Date = timeutil.LocalDate(r.Clock.Now(), location)
|
||||||
@@ -257,17 +231,12 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
|||||||
if opts.End == "" {
|
if opts.End == "" {
|
||||||
return app.GenerateRequest{}, fmt.Errorf("generate storm requires --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 {
|
if err != nil {
|
||||||
return app.GenerateRequest{}, err
|
return app.GenerateRequest{}, err
|
||||||
}
|
}
|
||||||
req.StormEnd, err = timeutil.ParseStormTime(opts.End, location)
|
req.StormStart = period.Start
|
||||||
if err != nil {
|
req.StormEnd = period.End
|
||||||
return app.GenerateRequest{}, err
|
|
||||||
}
|
|
||||||
if !req.StormEnd.After(req.StormStart) {
|
|
||||||
return app.GenerateRequest{}, fmt.Errorf("generate storm requires --end after --start")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return req, nil
|
return req, nil
|
||||||
@@ -372,12 +341,6 @@ func parseInspectRunFlags(command string, args []string) (inspectOptions, error)
|
|||||||
return opts, nil
|
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 {
|
func writeJSON(stdout io.Writer, value any) error {
|
||||||
encoder := json.NewEncoder(stdout)
|
encoder := json.NewEncoder(stdout)
|
||||||
encoder.SetIndent("", " ")
|
encoder.SetIndent("", " ")
|
||||||
|
|||||||
@@ -62,12 +62,8 @@ func TestRunGenerateStormWritesMarkdownReport(t *testing.T) {
|
|||||||
server := dailyServer(t)
|
server := dailyServer(t)
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
||||||
configPath := filepath.Join(tempDir, "config.yml")
|
|
||||||
workspaceRoot := filepath.Join(tempDir, "workspace")
|
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"
|
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
|
||||||
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
|
||||||
t.Fatalf("write config: %v", err)
|
|
||||||
}
|
|
||||||
outPath := filepath.Join(tempDir, "storm.md")
|
outPath := filepath.Join(tempDir, "storm.md")
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
@@ -90,14 +86,8 @@ func TestRunGenerateStormWritesMarkdownReport(t *testing.T) {
|
|||||||
if !strings.Contains(string(report), "# Daily Report") {
|
if !strings.Contains(string(report), "# Daily Report") {
|
||||||
t.Fatalf("report output missing markdown:\n%s", string(report))
|
t.Fatalf("report output missing markdown:\n%s", string(report))
|
||||||
}
|
}
|
||||||
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.json"))
|
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.json")
|
||||||
if err != nil {
|
data, err := os.ReadFile(dataPackagePath)
|
||||||
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])
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read managed data package: %v", err)
|
t.Fatalf("read managed data package: %v", err)
|
||||||
}
|
}
|
||||||
@@ -110,12 +100,8 @@ func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
|
|||||||
server := dailyServer(t)
|
server := dailyServer(t)
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
||||||
configPath := filepath.Join(tempDir, "config.yml")
|
|
||||||
workspaceRoot := filepath.Join(tempDir, "workspace")
|
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"
|
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
|
||||||
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
|
||||||
t.Fatalf("write config: %v", err)
|
|
||||||
}
|
|
||||||
outPath := filepath.Join(tempDir, "tomorrow.md")
|
outPath := filepath.Join(tempDir, "tomorrow.md")
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
@@ -136,14 +122,8 @@ func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
|
|||||||
if !strings.Contains(string(report), "# Daily Report") {
|
if !strings.Contains(string(report), "# Daily Report") {
|
||||||
t.Fatalf("report output missing markdown:\n%s", string(report))
|
t.Fatalf("report output missing markdown:\n%s", string(report))
|
||||||
}
|
}
|
||||||
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.json"))
|
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.json")
|
||||||
if err != nil {
|
data, err := os.ReadFile(dataPackagePath)
|
||||||
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])
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read managed data package: %v", err)
|
t.Fatalf("read managed data package: %v", err)
|
||||||
}
|
}
|
||||||
@@ -457,6 +437,7 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
|||||||
"generate", "daily",
|
"generate", "daily",
|
||||||
"--config", configPath,
|
"--config", configPath,
|
||||||
"--date", "2026-05-29",
|
"--date", "2026-05-29",
|
||||||
|
"--tz", "UTC",
|
||||||
"--out", outPath,
|
"--out", outPath,
|
||||||
}, &stdout, &stderr)
|
}, &stdout, &stderr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -483,6 +464,24 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
|||||||
if !strings.Contains(string(data), `data_package.v1`) || !strings.Contains(string(data), `"daily_today"`) {
|
if !strings.Contains(string(data), `data_package.v1`) || !strings.Contains(string(data), `"daily_today"`) {
|
||||||
t.Fatalf("data package output missing expected content:\n%s", string(data))
|
t.Fatalf("data package output missing expected content:\n%s", string(data))
|
||||||
}
|
}
|
||||||
|
var decoded struct {
|
||||||
|
Briefing struct {
|
||||||
|
Metadata struct {
|
||||||
|
Location struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
Timezone string `json:"timezone"`
|
||||||
|
} `json:"location"`
|
||||||
|
} `json:"metadata"`
|
||||||
|
} `json:"briefing"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||||
|
t.Fatalf("decode data package: %v", err)
|
||||||
|
}
|
||||||
|
if decoded.Briefing.Metadata.Location.ID != "home" || decoded.Briefing.Metadata.Location.Name != "Brentwood" || decoded.Briefing.Metadata.Location.Region != "St. Louis Metro" || decoded.Briefing.Metadata.Location.Timezone != "UTC" {
|
||||||
|
t.Fatalf("location = %#v, want configured location with overridden timezone", decoded.Briefing.Metadata.Location)
|
||||||
|
}
|
||||||
preflightMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "preflight", "daily", "2026-05-29", "*.render.json"))
|
preflightMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "preflight", "daily", "2026-05-29", "*.render.json"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("glob preflight: %v", err)
|
t.Fatalf("glob preflight: %v", err)
|
||||||
@@ -581,6 +580,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", "briefing", "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) {
|
func TestResolveGenerateCommands(t *testing.T) {
|
||||||
runner := Runner{Clock: fixedClock()}
|
runner := Runner{Clock: fixedClock()}
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -643,7 +681,15 @@ func TestResolveGenerateAppliesSharedFlags(t *testing.T) {
|
|||||||
func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) {
|
func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) {
|
||||||
runner := Runner{Clock: fixedClock()}
|
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 {
|
if err == nil {
|
||||||
t.Fatal("resolveGenerate() error = nil, want missing end error")
|
t.Fatal("resolveGenerate() error = nil, want missing end error")
|
||||||
}
|
}
|
||||||
@@ -652,6 +698,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) {
|
func TestResolveGenerateStormParsesRFC3339(t *testing.T) {
|
||||||
runner := Runner{Clock: fixedClock()}
|
runner := Runner{Clock: fixedClock()}
|
||||||
|
|
||||||
@@ -668,6 +734,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) {
|
func TestResolveRunCommands(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -731,6 +813,8 @@ func dailyServer(t *testing.T) *httptest.Server {
|
|||||||
_, _ = w.Write([]byte(`{"data":{"alerts":[]}}`))
|
_, _ = w.Write([]byte(`{"data":{"alerts":[]}}`))
|
||||||
case "/discussion":
|
case "/discussion":
|
||||||
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}}`))
|
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}}`))
|
||||||
|
case "/weatherstories/latest":
|
||||||
|
_, _ = 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:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
@@ -739,6 +823,28 @@ func dailyServer(t *testing.T) *httptest.Server {
|
|||||||
return 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 {
|
func writeFakeScriptorium(t *testing.T, dir string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
path := filepath.Join(dir, "scriptorium")
|
path := filepath.Join(dir, "scriptorium")
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ const (
|
|||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
WeatherAPI WeatherAPIConfig `yaml:"weather_api"`
|
WeatherAPI WeatherAPIConfig `yaml:"weather_api"`
|
||||||
|
Location LocationConfig `yaml:"location"`
|
||||||
MissingSource MissingSourceConfig `yaml:"missing_source"`
|
MissingSource MissingSourceConfig `yaml:"missing_source"`
|
||||||
Scriptorium ScriptoriumConfig `yaml:"scriptorium"`
|
Scriptorium ScriptoriumConfig `yaml:"scriptorium"`
|
||||||
Workspace WorkspaceConfig `yaml:"workspace"`
|
Workspace WorkspaceConfig `yaml:"workspace"`
|
||||||
Reports ReportOutputConfig `yaml:"reports"`
|
|
||||||
Dayparts []DaypartConfig `yaml:"dayparts"`
|
Dayparts []DaypartConfig `yaml:"dayparts"`
|
||||||
RecentChange RecentChangeConfig `yaml:"recent_change"`
|
RecentChange RecentChangeConfig `yaml:"recent_change"`
|
||||||
}
|
}
|
||||||
@@ -31,6 +31,12 @@ type WeatherAPIConfig struct {
|
|||||||
Format string `yaml:"format"`
|
Format string `yaml:"format"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LocationConfig struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Region string `yaml:"region"`
|
||||||
|
}
|
||||||
|
|
||||||
type MissingSourceConfig struct {
|
type MissingSourceConfig struct {
|
||||||
Default MissingSourcePolicy `yaml:"default"`
|
Default MissingSourcePolicy `yaml:"default"`
|
||||||
Sources map[string]MissingSourcePolicy `yaml:"sources"`
|
Sources map[string]MissingSourcePolicy `yaml:"sources"`
|
||||||
@@ -52,11 +58,6 @@ type WorkspaceConfig struct {
|
|||||||
PreflightDir string `yaml:"preflight_dir"`
|
PreflightDir string `yaml:"preflight_dir"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReportOutputConfig struct {
|
|
||||||
OutputDir string `yaml:"output_dir"`
|
|
||||||
Paths map[string]string `yaml:"paths"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DaypartConfig struct {
|
type DaypartConfig struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
Start string `yaml:"start"`
|
Start string `yaml:"start"`
|
||||||
|
|||||||
@@ -17,12 +17,15 @@ func TestDefaults(t *testing.T) {
|
|||||||
if cfg.WeatherAPI.Units != "us" {
|
if cfg.WeatherAPI.Units != "us" {
|
||||||
t.Fatalf("Units = %q, want us", cfg.WeatherAPI.Units)
|
t.Fatalf("Units = %q, want us", cfg.WeatherAPI.Units)
|
||||||
}
|
}
|
||||||
if cfg.WeatherAPI.Timezone != "Chicago" {
|
if cfg.WeatherAPI.Timezone != "America/Chicago" {
|
||||||
t.Fatalf("Timezone = %q, want Chicago", cfg.WeatherAPI.Timezone)
|
t.Fatalf("Timezone = %q, want America/Chicago", cfg.WeatherAPI.Timezone)
|
||||||
}
|
}
|
||||||
if cfg.WeatherAPI.Format != "json" {
|
if cfg.WeatherAPI.Format != "json" {
|
||||||
t.Fatalf("Format = %q, want json", cfg.WeatherAPI.Format)
|
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.MissingSource.Default != MissingSourceWarn {
|
if cfg.MissingSource.Default != MissingSourceWarn {
|
||||||
t.Fatalf("MissingSource.Default = %q, want warn", cfg.MissingSource.Default)
|
t.Fatalf("MissingSource.Default = %q, want warn", cfg.MissingSource.Default)
|
||||||
}
|
}
|
||||||
@@ -34,8 +37,8 @@ func TestLoadExampleConfig(t *testing.T) {
|
|||||||
t.Fatalf("LoadFile() error = %v", err)
|
t.Fatalf("LoadFile() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.WeatherAPI.BaseURL != "https://weather.api.example.com/" {
|
if cfg.WeatherAPI.BaseURL != "https://weather.api.rakestrawhome.com/" {
|
||||||
t.Fatalf("BaseURL = %q, want example URL", cfg.WeatherAPI.BaseURL)
|
t.Fatalf("BaseURL = %q, want configured example URL", cfg.WeatherAPI.BaseURL)
|
||||||
}
|
}
|
||||||
if cfg.WeatherAPI.Timeout != 15*time.Second {
|
if cfg.WeatherAPI.Timeout != 15*time.Second {
|
||||||
t.Fatalf("Timeout = %s, want 15s", cfg.WeatherAPI.Timeout)
|
t.Fatalf("Timeout = %s, want 15s", cfg.WeatherAPI.Timeout)
|
||||||
@@ -43,6 +46,32 @@ func TestLoadExampleConfig(t *testing.T) {
|
|||||||
if cfg.MissingSource.Sources["alerts"] != MissingSourceNone {
|
if cfg.MissingSource.Sources["alerts"] != MissingSourceNone {
|
||||||
t.Fatalf("alerts policy = %q, want none", cfg.MissingSource.Sources["alerts"])
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.Location.Name != "Brentwood" {
|
||||||
|
t.Fatalf("Location.Name = %q, want default Brentwood", cfg.Location.Name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExplicitMissingConfigReturnsError(t *testing.T) {
|
func TestExplicitMissingConfigReturnsError(t *testing.T) {
|
||||||
@@ -72,7 +101,7 @@ func TestInvalidConfigProducesActionableError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadAppliesOverrides(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 {
|
if err != nil {
|
||||||
t.Fatalf("Load() error = %v", err)
|
t.Fatalf("Load() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -82,7 +111,4 @@ func TestLoadAppliesOverrides(t *testing.T) {
|
|||||||
if cfg.WeatherAPI.Timezone != "+09:30" {
|
if cfg.WeatherAPI.Timezone != "+09:30" {
|
||||||
t.Fatalf("Timezone = %q, want +09:30", cfg.WeatherAPI.Timezone)
|
t.Fatalf("Timezone = %q, want +09:30", cfg.WeatherAPI.Timezone)
|
||||||
}
|
}
|
||||||
if cfg.Reports.OutputDir != "./out" {
|
|
||||||
t.Fatalf("OutputDir = %q, want ./out", cfg.Reports.OutputDir)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,14 @@ func Defaults() Config {
|
|||||||
Timeout: 10 * time.Second,
|
Timeout: 10 * time.Second,
|
||||||
Precision: 1,
|
Precision: 1,
|
||||||
Units: "us",
|
Units: "us",
|
||||||
Timezone: "Chicago",
|
Timezone: "America/Chicago",
|
||||||
Format: "json",
|
Format: "json",
|
||||||
},
|
},
|
||||||
|
Location: LocationConfig{
|
||||||
|
ID: "home",
|
||||||
|
Name: "Brentwood",
|
||||||
|
Region: "St. Louis Metro",
|
||||||
|
},
|
||||||
MissingSource: MissingSourceConfig{
|
MissingSource: MissingSourceConfig{
|
||||||
Default: MissingSourceWarn,
|
Default: MissingSourceWarn,
|
||||||
Sources: map[string]MissingSourcePolicy{},
|
Sources: map[string]MissingSourcePolicy{},
|
||||||
@@ -28,15 +33,12 @@ func Defaults() Config {
|
|||||||
DataPackagesDir: "data-packages",
|
DataPackagesDir: "data-packages",
|
||||||
PreflightDir: "preflight",
|
PreflightDir: "preflight",
|
||||||
},
|
},
|
||||||
Reports: ReportOutputConfig{
|
|
||||||
OutputDir: "reports",
|
|
||||||
Paths: map[string]string{},
|
|
||||||
},
|
|
||||||
Dayparts: []DaypartConfig{
|
Dayparts: []DaypartConfig{
|
||||||
{Name: "overnight", Start: "00:00", End: "06:00"},
|
{Name: "overnight", Start: "00:00", End: "06:00"},
|
||||||
{Name: "morning", Start: "06:00", End: "12:00"},
|
{Name: "morning", Start: "06:00", End: "10:00"},
|
||||||
{Name: "afternoon", Start: "12:00", End: "18:00"},
|
{Name: "midday", Start: "10:00", End: "15:00"},
|
||||||
{Name: "evening", Start: "18:00", End: "24:00"},
|
{Name: "afternoon", Start: "15:00", End: "17:00"},
|
||||||
|
{Name: "evening", Start: "17:00", End: "24:00"},
|
||||||
},
|
},
|
||||||
RecentChange: RecentChangeConfig{
|
RecentChange: RecentChangeConfig{
|
||||||
TemperatureDegrees: 5,
|
TemperatureDegrees: 5,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ type LoadOptions struct {
|
|||||||
Path string
|
Path string
|
||||||
Units string
|
Units string
|
||||||
Timezone string
|
Timezone string
|
||||||
Output string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load(opts LoadOptions) (Config, error) {
|
func Load(opts LoadOptions) (Config, error) {
|
||||||
@@ -35,9 +34,6 @@ func Load(opts LoadOptions) (Config, error) {
|
|||||||
if opts.Timezone != "" {
|
if opts.Timezone != "" {
|
||||||
cfg.WeatherAPI.Timezone = opts.Timezone
|
cfg.WeatherAPI.Timezone = opts.Timezone
|
||||||
}
|
}
|
||||||
if opts.Output != "" {
|
|
||||||
cfg.Reports.OutputDir = opts.Output
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := Validate(cfg); err != nil {
|
if err := Validate(cfg); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
@@ -61,8 +57,5 @@ func mergeFile(cfg *Config, path string) error {
|
|||||||
if cfg.MissingSource.Sources == nil {
|
if cfg.MissingSource.Sources == nil {
|
||||||
cfg.MissingSource.Sources = map[string]MissingSourcePolicy{}
|
cfg.MissingSource.Sources = map[string]MissingSourcePolicy{}
|
||||||
}
|
}
|
||||||
if cfg.Reports.Paths == nil {
|
|
||||||
cfg.Reports.Paths = map[string]string{}
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,9 +58,6 @@ func Validate(cfg Config) error {
|
|||||||
if cfg.Workspace.Root == "" {
|
if cfg.Workspace.Root == "" {
|
||||||
return fmt.Errorf("workspace.root is required")
|
return fmt.Errorf("workspace.root is required")
|
||||||
}
|
}
|
||||||
if cfg.Reports.OutputDir == "" {
|
|
||||||
return fmt.Errorf("reports.output_dir is required")
|
|
||||||
}
|
|
||||||
if len(cfg.Dayparts) == 0 {
|
if len(cfg.Dayparts) == 0 {
|
||||||
return fmt.Errorf("dayparts must contain at least one entry")
|
return fmt.Errorf("dayparts must contain at least one entry")
|
||||||
}
|
}
|
||||||
|
|||||||
48
internal/fileutil/fileutil.go
Normal file
48
internal/fileutil/fileutil.go
Normal 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)
|
||||||
|
}
|
||||||
103
internal/fileutil/fileutil_test.go
Normal file
103
internal/fileutil/fileutil_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -149,13 +149,20 @@ type Discussion struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DiscussionSection struct {
|
type DiscussionSection struct {
|
||||||
Title string `json:"title,omitempty"`
|
Qualifier string `json:"qualifier,omitempty"`
|
||||||
Narrative string `json:"narrative,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
IssuedAt *time.Time `json:"issuedAt,omitempty"`
|
IssuedAt *time.Time `json:"issuedAt,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WeatherStory struct {
|
type WeatherStory struct {
|
||||||
IssuedAt *time.Time `json:"issuedAt,omitempty"`
|
OfficeID string `json:"officeId,omitempty"`
|
||||||
|
StartTime time.Time `json:"startTime"`
|
||||||
|
EndTime time.Time `json:"endTime"`
|
||||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||||
Raw json.RawMessage `json:"raw,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
AltText string `json:"altText,omitempty"`
|
||||||
|
Priority bool `json:"priority"`
|
||||||
|
Order int `json:"order"`
|
||||||
|
DownloadURL string `json:"downloadUrl,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ type TimedValue struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Indicators struct {
|
type Indicators struct {
|
||||||
Thunder bool `json:"thunder,omitempty"`
|
|
||||||
Snow bool `json:"snow,omitempty"`
|
Snow bool `json:"snow,omitempty"`
|
||||||
Ice bool `json:"ice,omitempty"`
|
Ice bool `json:"ice,omitempty"`
|
||||||
Fog bool `json:"fog,omitempty"`
|
Fog bool `json:"fog,omitempty"`
|
||||||
@@ -291,7 +290,6 @@ func sortedKeys(values map[string]struct{}) []string {
|
|||||||
func indicatorsForText(text string) Indicators {
|
func indicatorsForText(text string) Indicators {
|
||||||
lower := strings.ToLower(text)
|
lower := strings.ToLower(text)
|
||||||
return Indicators{
|
return Indicators{
|
||||||
Thunder: strings.Contains(lower, "thunder") || strings.Contains(lower, "storm"),
|
|
||||||
Snow: strings.Contains(lower, "snow"),
|
Snow: strings.Contains(lower, "snow"),
|
||||||
Ice: strings.Contains(lower, "ice") || strings.Contains(lower, "freezing") || strings.Contains(lower, "sleet"),
|
Ice: strings.Contains(lower, "ice") || strings.Contains(lower, "freezing") || strings.Contains(lower, "sleet"),
|
||||||
Fog: strings.Contains(lower, "fog"),
|
Fog: strings.Contains(lower, "fog"),
|
||||||
@@ -318,7 +316,6 @@ func numericIndicators(period ForecastPeriod) Indicators {
|
|||||||
|
|
||||||
func mergeIndicators(left Indicators, right Indicators) Indicators {
|
func mergeIndicators(left Indicators, right Indicators) Indicators {
|
||||||
return Indicators{
|
return Indicators{
|
||||||
Thunder: left.Thunder || right.Thunder,
|
|
||||||
Snow: left.Snow || right.Snow,
|
Snow: left.Snow || right.Snow,
|
||||||
Ice: left.Ice || right.Ice,
|
Ice: left.Ice || right.Ice,
|
||||||
Fog: left.Fog || right.Fog,
|
Fog: left.Fog || right.Fog,
|
||||||
|
|||||||
@@ -41,10 +41,10 @@ func TestBuildDailySummaryGroupsDaypartsAndComputesMetrics(t *testing.T) {
|
|||||||
t.Fatalf("morning peak gust = %#v, want 40", morning.PeakWindGust)
|
t.Fatalf("morning peak gust = %#v, want 40", morning.PeakWindGust)
|
||||||
}
|
}
|
||||||
if morning.DominantCondition != "Thunderstorms and gusty wind" {
|
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 {
|
if !morning.Indicators.Wind {
|
||||||
t.Fatalf("morning indicators = %#v, want thunder and wind", morning.Indicators)
|
t.Fatalf("morning indicators = %#v, want wind", morning.Indicators)
|
||||||
}
|
}
|
||||||
|
|
||||||
afternoon := summary.Dayparts[2]
|
afternoon := summary.Dayparts[2]
|
||||||
@@ -87,8 +87,8 @@ func TestBuildDailySummaryFromFixtureBundle(t *testing.T) {
|
|||||||
if len(summary.Dayparts) != 2 {
|
if len(summary.Dayparts) != 2 {
|
||||||
t.Fatalf("Dayparts length = %d, want 2", len(summary.Dayparts))
|
t.Fatalf("Dayparts length = %d, want 2", len(summary.Dayparts))
|
||||||
}
|
}
|
||||||
if !summary.Dayparts[0].Indicators.Thunder {
|
if summary.Dayparts[0].DominantCondition != "Showers and thunderstorms" {
|
||||||
t.Fatalf("morning indicators = %#v, want thunder", summary.Dayparts[0].Indicators)
|
t.Fatalf("morning dominant = %q, want raw forecast condition", summary.Dayparts[0].DominantCondition)
|
||||||
}
|
}
|
||||||
if len(summary.AlertOverlaps) != 1 {
|
if len(summary.AlertOverlaps) != 1 {
|
||||||
t.Fatalf("AlertOverlaps length = %d, want 1", len(summary.AlertOverlaps))
|
t.Fatalf("AlertOverlaps length = %d, want 1", len(summary.AlertOverlaps))
|
||||||
|
|||||||
10
internal/forecast/testdata/daily_bundle.json
vendored
10
internal/forecast/testdata/daily_bundle.json
vendored
@@ -54,7 +54,15 @@
|
|||||||
"issuedAt": "2026-05-29T09:25:00-05:00",
|
"issuedAt": "2026-05-29T09:25:00-05:00",
|
||||||
"keyMessages": [
|
"keyMessages": [
|
||||||
"Storms are most likely during the morning."
|
"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": [
|
"sources": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,14 +2,12 @@
|
|||||||
package promptinput
|
package promptinput
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
@@ -32,6 +30,7 @@ type Report struct {
|
|||||||
PromptID string `json:"promptId"`
|
PromptID string `json:"promptId"`
|
||||||
GeneratedAt time.Time `json:"generatedAt"`
|
GeneratedAt time.Time `json:"generatedAt"`
|
||||||
Timezone string `json:"timezone"`
|
Timezone string `json:"timezone"`
|
||||||
|
CurrentLocalDate string `json:"currentLocalDate"`
|
||||||
ValidPeriod timeutil.Period `json:"validPeriod"`
|
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +43,10 @@ func Build(briefingPackage briefing.Package) (Package, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []changes.Change) (Package, error) {
|
func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []changes.Change) (Package, error) {
|
||||||
|
localDate, err := currentLocalDate(briefingPackage.Metadata.GeneratedAt, briefingPackage.Metadata.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return Package{}, err
|
||||||
|
}
|
||||||
items := make([]changes.Change, len(recentChanges))
|
items := make([]changes.Change, len(recentChanges))
|
||||||
copy(items, recentChanges)
|
copy(items, recentChanges)
|
||||||
if items == nil {
|
if items == nil {
|
||||||
@@ -58,6 +61,7 @@ func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []ch
|
|||||||
PromptID: briefingPackage.Metadata.PromptID,
|
PromptID: briefingPackage.Metadata.PromptID,
|
||||||
GeneratedAt: briefingPackage.Metadata.GeneratedAt,
|
GeneratedAt: briefingPackage.Metadata.GeneratedAt,
|
||||||
Timezone: briefingPackage.Metadata.Timezone,
|
Timezone: briefingPackage.Metadata.Timezone,
|
||||||
|
CurrentLocalDate: localDate,
|
||||||
ValidPeriod: briefingPackage.Metadata.ValidPeriod,
|
ValidPeriod: briefingPackage.Metadata.ValidPeriod,
|
||||||
},
|
},
|
||||||
Briefing: briefingPackage,
|
Briefing: briefingPackage,
|
||||||
@@ -70,6 +74,14 @@ func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []ch
|
|||||||
return pkg, nil
|
return pkg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func Validate(pkg Package) error {
|
||||||
if pkg.SchemaVersion == "" {
|
if pkg.SchemaVersion == "" {
|
||||||
return fmt.Errorf("schemaVersion is required")
|
return fmt.Errorf("schemaVersion is required")
|
||||||
@@ -89,6 +101,9 @@ func Validate(pkg Package) error {
|
|||||||
if pkg.Report.Timezone == "" {
|
if pkg.Report.Timezone == "" {
|
||||||
return fmt.Errorf("report.timezone is required")
|
return fmt.Errorf("report.timezone is required")
|
||||||
}
|
}
|
||||||
|
if pkg.Report.CurrentLocalDate == "" {
|
||||||
|
return fmt.Errorf("report.currentLocalDate is required")
|
||||||
|
}
|
||||||
if !pkg.Report.ValidPeriod.IsValid() {
|
if !pkg.Report.ValidPeriod.IsValid() {
|
||||||
return fmt.Errorf("report.validPeriod must be valid")
|
return fmt.Errorf("report.validPeriod must be valid")
|
||||||
}
|
}
|
||||||
@@ -117,29 +132,8 @@ func Save(path string, pkg Package) error {
|
|||||||
if err := Validate(pkg); err != nil {
|
if err := Validate(pkg); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
data, err := json.MarshalIndent(pkg, "", " ")
|
if err := fileutil.WriteJSONAtomic(path, pkg); err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf("save data package: %w", err)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,14 +28,51 @@ func TestBuildDailyDataPackage(t *testing.T) {
|
|||||||
if pkg.Report.PromptID != "weather.daily_report" {
|
if pkg.Report.PromptID != "weather.daily_report" {
|
||||||
t.Fatalf("PromptID = %q, want weather.daily_report", pkg.Report.PromptID)
|
t.Fatalf("PromptID = %q, want weather.daily_report", pkg.Report.PromptID)
|
||||||
}
|
}
|
||||||
|
if pkg.Report.CurrentLocalDate != "2026-05-29" {
|
||||||
|
t.Fatalf("CurrentLocalDate = %q, want 2026-05-29", pkg.Report.CurrentLocalDate)
|
||||||
|
}
|
||||||
if pkg.Briefing.Daily == nil {
|
if pkg.Briefing.Daily == nil {
|
||||||
t.Fatal("Briefing.Daily = nil")
|
t.Fatal("Briefing.Daily = nil")
|
||||||
}
|
}
|
||||||
|
if pkg.Briefing.Metadata.Location == nil || pkg.Briefing.Metadata.Location.Name != "Brentwood" {
|
||||||
|
t.Fatalf("Briefing.Metadata.Location = %#v, want configured location", pkg.Briefing.Metadata.Location)
|
||||||
|
}
|
||||||
|
if pkg.Briefing.CurrentConditions == nil || pkg.Briefing.CurrentConditions.ConditionText != "Partly cloudy" {
|
||||||
|
t.Fatalf("Briefing.CurrentConditions = %#v, want current conditions", pkg.Briefing.CurrentConditions)
|
||||||
|
}
|
||||||
if pkg.RecentChanges.Items == nil || len(pkg.RecentChanges.Items) != 0 {
|
if pkg.RecentChanges.Items == nil || len(pkg.RecentChanges.Items) != 0 {
|
||||||
t.Fatalf("RecentChanges.Items = %#v, want empty slice", pkg.RecentChanges.Items)
|
t.Fatalf("RecentChanges.Items = %#v, want empty slice", pkg.RecentChanges.Items)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildCurrentLocalDateUsesReportTimezone(t *testing.T) {
|
||||||
|
briefingPackage := validBriefingPackage()
|
||||||
|
briefingPackage.Metadata.GeneratedAt = time.Date(2026, 5, 30, 2, 30, 0, 0, time.UTC)
|
||||||
|
briefingPackage.Metadata.Timezone = "America/Chicago"
|
||||||
|
|
||||||
|
pkg, err := Build(briefingPackage)
|
||||||
|
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) {
|
||||||
|
briefingPackage := validBriefingPackage()
|
||||||
|
briefingPackage.Metadata.Timezone = "Not/AZone"
|
||||||
|
|
||||||
|
_, err := Build(briefingPackage)
|
||||||
|
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) {
|
func TestValidateRequiresFields(t *testing.T) {
|
||||||
pkg, err := Build(validBriefingPackage())
|
pkg, err := Build(validBriefingPackage())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -52,6 +89,22 @@ func TestValidateRequiresFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateRequiresCurrentLocalDate(t *testing.T) {
|
||||||
|
pkg, err := Build(validBriefingPackage())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Build() error = %v", err)
|
||||||
|
}
|
||||||
|
pkg.Report.CurrentLocalDate = ""
|
||||||
|
|
||||||
|
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 TestBuildThreeDayDataPackage(t *testing.T) {
|
func TestBuildThreeDayDataPackage(t *testing.T) {
|
||||||
briefingPackage := validBriefingPackage()
|
briefingPackage := validBriefingPackage()
|
||||||
briefingPackage.Metadata.RunID = "20260529T100000Z_three_day"
|
briefingPackage.Metadata.RunID = "20260529T100000Z_three_day"
|
||||||
@@ -155,11 +208,20 @@ func validBriefingPackage() briefing.Package {
|
|||||||
GeneratedAt: generatedAt,
|
GeneratedAt: generatedAt,
|
||||||
Units: "us",
|
Units: "us",
|
||||||
Timezone: "America/Chicago",
|
Timezone: "America/Chicago",
|
||||||
|
Location: &briefing.LocationContext{
|
||||||
|
ID: "home",
|
||||||
|
Name: "Brentwood",
|
||||||
|
Region: "St. Louis Metro",
|
||||||
|
Timezone: "America/Chicago",
|
||||||
|
},
|
||||||
ValidPeriod: timeutil.Period{
|
ValidPeriod: timeutil.Period{
|
||||||
Start: time.Date(2026, 5, 29, 5, 0, 0, 0, time.UTC),
|
Start: time.Date(2026, 5, 29, 5, 0, 0, 0, time.UTC),
|
||||||
End: time.Date(2026, 5, 30, 5, 0, 0, 0, time.UTC),
|
End: time.Date(2026, 5, 30, 5, 0, 0, 0, time.UTC),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
CurrentConditions: &briefing.CurrentConditionsContext{
|
||||||
|
ConditionText: "Partly cloudy",
|
||||||
|
},
|
||||||
Daily: &briefing.Daily{
|
Daily: &briefing.Daily{
|
||||||
ForecastSummaryDate: "2026-05-29",
|
ForecastSummaryDate: "2026-05-29",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ type Definition struct {
|
|||||||
Name string
|
Name string
|
||||||
PromptID string
|
PromptID string
|
||||||
ComparisonStrategy ComparisonStrategy
|
ComparisonStrategy ComparisonStrategy
|
||||||
DefaultOutputName string
|
ArtifactGroup string
|
||||||
|
BatchOutputName string
|
||||||
|
Generated bool
|
||||||
|
CompatiblePriorIDs []ID
|
||||||
Morning bool
|
Morning bool
|
||||||
Evening bool
|
Evening bool
|
||||||
resolve func(ResolveRequest) (timeutil.Period, error)
|
resolve func(ResolveRequest) (timeutil.Period, error)
|
||||||
@@ -51,6 +54,15 @@ func (d Definition) ResolvePeriod(req ResolveRequest) (timeutil.Period, error) {
|
|||||||
return d.resolve(req)
|
return d.resolve(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d Definition) CompatibleWithPrior(id ID) bool {
|
||||||
|
for _, compatibleID := range d.CompatiblePriorIDs {
|
||||||
|
if id == compatibleID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
type ResolveRequest struct {
|
type ResolveRequest struct {
|
||||||
Now time.Time
|
Now time.Time
|
||||||
Location *time.Location
|
Location *time.Location
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package report
|
package report
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -177,6 +178,79 @@ func TestRegistryDefinitionsHavePromptIDsAndComparisonStrategies(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
id ID
|
||||||
|
artifactGroup string
|
||||||
|
batchOutputName string
|
||||||
|
generated bool
|
||||||
|
compatiblePriorIDs []ID
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
id: DailyToday,
|
||||||
|
artifactGroup: "daily",
|
||||||
|
batchOutputName: "daily.md",
|
||||||
|
generated: true,
|
||||||
|
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: DailyTomorrow,
|
||||||
|
artifactGroup: "daily",
|
||||||
|
batchOutputName: "tomorrow.md",
|
||||||
|
generated: true,
|
||||||
|
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: ThreeDay,
|
||||||
|
artifactGroup: "three-day",
|
||||||
|
batchOutputName: "three-day.md",
|
||||||
|
generated: true,
|
||||||
|
compatiblePriorIDs: []ID{ThreeDay},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: Weekend,
|
||||||
|
artifactGroup: "weekend",
|
||||||
|
batchOutputName: "weekend.md",
|
||||||
|
generated: true,
|
||||||
|
compatiblePriorIDs: []ID{Weekend},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: Storm,
|
||||||
|
artifactGroup: "storm",
|
||||||
|
batchOutputName: "storm.md",
|
||||||
|
generated: true,
|
||||||
|
compatiblePriorIDs: []ID{Storm},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
registry := DefaultRegistry()
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(string(tt.id), func(t *testing.T) {
|
||||||
|
definition, err := registry.Lookup(tt.id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Lookup() error = %v", err)
|
||||||
|
}
|
||||||
|
if definition.ArtifactGroup != tt.artifactGroup {
|
||||||
|
t.Fatalf("ArtifactGroup = %q, want %q", definition.ArtifactGroup, tt.artifactGroup)
|
||||||
|
}
|
||||||
|
if definition.BatchOutputName != tt.batchOutputName {
|
||||||
|
t.Fatalf("BatchOutputName = %q, want %q", definition.BatchOutputName, tt.batchOutputName)
|
||||||
|
}
|
||||||
|
if definition.Generated != tt.generated {
|
||||||
|
t.Fatalf("Generated = %t, want %t", definition.Generated, tt.generated)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(definition.CompatiblePriorIDs, tt.compatiblePriorIDs) {
|
||||||
|
t.Fatalf("CompatiblePriorIDs = %#v, want %#v", definition.CompatiblePriorIDs, tt.compatiblePriorIDs)
|
||||||
|
}
|
||||||
|
for _, id := range tt.compatiblePriorIDs {
|
||||||
|
if !definition.CompatibleWithPrior(id) {
|
||||||
|
t.Fatalf("CompatibleWithPrior(%q) = false, want true", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolvedMetadata(t *testing.T) {
|
func TestResolvedMetadata(t *testing.T) {
|
||||||
location := mustLoadLocation(t)
|
location := mustLoadLocation(t)
|
||||||
resolved, err := Resolve(DailyToday, ResolveRequest{Now: mustParse("2026-05-29T05:00:00-05:00"), Location: location})
|
resolved, err := Resolve(DailyToday, ResolveRequest{Now: mustParse("2026-05-29T05:00:00-05:00"), Location: location})
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ func DefaultRegistry() Registry {
|
|||||||
Name: "Daily Report",
|
Name: "Daily Report",
|
||||||
PromptID: "weather.daily_report",
|
PromptID: "weather.daily_report",
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
DefaultOutputName: "daily.md",
|
ArtifactGroup: "daily",
|
||||||
|
BatchOutputName: "daily.md",
|
||||||
|
Generated: true,
|
||||||
|
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
||||||
Morning: true,
|
Morning: true,
|
||||||
resolve: resolveDailyToday,
|
resolve: resolveDailyToday,
|
||||||
},
|
},
|
||||||
@@ -22,7 +25,10 @@ func DefaultRegistry() Registry {
|
|||||||
Name: "Tomorrow Planning Brief",
|
Name: "Tomorrow Planning Brief",
|
||||||
PromptID: "weather.daily_report",
|
PromptID: "weather.daily_report",
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
DefaultOutputName: "tomorrow.md",
|
ArtifactGroup: "daily",
|
||||||
|
BatchOutputName: "tomorrow.md",
|
||||||
|
Generated: true,
|
||||||
|
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
||||||
Evening: true,
|
Evening: true,
|
||||||
resolve: resolveDailyTomorrow,
|
resolve: resolveDailyTomorrow,
|
||||||
},
|
},
|
||||||
@@ -31,7 +37,10 @@ func DefaultRegistry() Registry {
|
|||||||
Name: "3-Day Outlook",
|
Name: "3-Day Outlook",
|
||||||
PromptID: "weather.three_day_outlook",
|
PromptID: "weather.three_day_outlook",
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
DefaultOutputName: "three_day.md",
|
ArtifactGroup: "three-day",
|
||||||
|
BatchOutputName: "three-day.md",
|
||||||
|
Generated: true,
|
||||||
|
CompatiblePriorIDs: []ID{ThreeDay},
|
||||||
Morning: true,
|
Morning: true,
|
||||||
resolve: resolveThreeDay,
|
resolve: resolveThreeDay,
|
||||||
},
|
},
|
||||||
@@ -40,7 +49,10 @@ func DefaultRegistry() Registry {
|
|||||||
Name: "Weekend Outlook",
|
Name: "Weekend Outlook",
|
||||||
PromptID: "weather.weekend_outlook",
|
PromptID: "weather.weekend_outlook",
|
||||||
ComparisonStrategy: CompareWeekendWindow,
|
ComparisonStrategy: CompareWeekendWindow,
|
||||||
DefaultOutputName: "weekend.md",
|
ArtifactGroup: "weekend",
|
||||||
|
BatchOutputName: "weekend.md",
|
||||||
|
Generated: true,
|
||||||
|
CompatiblePriorIDs: []ID{Weekend},
|
||||||
Morning: true,
|
Morning: true,
|
||||||
resolve: resolveWeekend,
|
resolve: resolveWeekend,
|
||||||
},
|
},
|
||||||
@@ -49,7 +61,10 @@ func DefaultRegistry() Registry {
|
|||||||
Name: "Storm Report",
|
Name: "Storm Report",
|
||||||
PromptID: "weather.storm_report",
|
PromptID: "weather.storm_report",
|
||||||
ComparisonStrategy: CompareExplicitWindow,
|
ComparisonStrategy: CompareExplicitWindow,
|
||||||
DefaultOutputName: "storm.md",
|
ArtifactGroup: "storm",
|
||||||
|
BatchOutputName: "storm.md",
|
||||||
|
Generated: true,
|
||||||
|
CompatiblePriorIDs: []ID{Storm},
|
||||||
resolve: resolveStorm,
|
resolve: resolveStorm,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
)
|
)
|
||||||
@@ -79,9 +79,9 @@ func (s *FilesystemStore) Paths(resolved report.Resolved) (ArtifactPaths, error)
|
|||||||
if metadata.RunID == "" {
|
if metadata.RunID == "" {
|
||||||
return ArtifactPaths{}, fmt.Errorf("run id is required")
|
return ArtifactPaths{}, fmt.Errorf("run id is required")
|
||||||
}
|
}
|
||||||
group, err := reportGroup(resolved.Definition.ID)
|
group := resolved.Definition.ArtifactGroup
|
||||||
if err != nil {
|
if group == "" {
|
||||||
return ArtifactPaths{}, err
|
return ArtifactPaths{}, fmt.Errorf("report %q has no artifact group", resolved.Definition.ID)
|
||||||
}
|
}
|
||||||
validDate := resolved.ValidPeriod.Start.Format("2006-01-02")
|
validDate := resolved.ValidPeriod.Start.Format("2006-01-02")
|
||||||
filenameBase := metadata.RunID
|
filenameBase := metadata.RunID
|
||||||
@@ -99,7 +99,7 @@ func (s *FilesystemStore) SaveBriefing(_ context.Context, resolved report.Resolv
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if err := writeJSONAtomic(paths.Briefing, pkg); err != nil {
|
if err := fileutil.WriteJSONAtomic(paths.Briefing, pkg); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return paths.Briefing, nil
|
return paths.Briefing, nil
|
||||||
@@ -113,21 +113,18 @@ func (s *FilesystemStore) SaveDataPackage(_ context.Context, resolved report.Res
|
|||||||
if err := promptinput.Validate(pkg); err != nil {
|
if err := promptinput.Validate(pkg); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if err := writeJSONAtomic(paths.DataPackage, pkg); err != nil {
|
if err := fileutil.WriteJSONAtomic(paths.DataPackage, pkg); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return paths.DataPackage, nil
|
return paths.DataPackage, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *FilesystemStore) SavePreflight(_ context.Context, resolved report.Resolved, result *scriptorium.RenderResult) (string, error) {
|
func (s *FilesystemStore) SavePreflight(_ context.Context, resolved report.Resolved, artifact PreflightArtifact) (string, error) {
|
||||||
if result == nil {
|
|
||||||
return "", fmt.Errorf("render result is required")
|
|
||||||
}
|
|
||||||
paths, err := s.Paths(resolved)
|
paths, err := s.Paths(resolved)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if err := writeJSONAtomic(paths.Preflight, result); err != nil {
|
if err := fileutil.WriteJSONAtomic(paths.Preflight, artifact); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return paths.Preflight, nil
|
return paths.Preflight, nil
|
||||||
@@ -157,27 +154,22 @@ func (s *FilesystemStore) SaveMetadata(_ context.Context, metadata Metadata) (st
|
|||||||
if metadata.PreflightPath == "" {
|
if metadata.PreflightPath == "" {
|
||||||
return "", fmt.Errorf("metadata preflight path is required")
|
return "", fmt.Errorf("metadata preflight path is required")
|
||||||
}
|
}
|
||||||
path := metadataPathFromStored(metadata)
|
if metadata.MetadataPath == "" {
|
||||||
if path == "" {
|
return "", fmt.Errorf("metadata path is required")
|
||||||
return "", fmt.Errorf("metadata path cannot be resolved")
|
|
||||||
}
|
}
|
||||||
if err := writeJSONAtomic(path, metadata); err != nil {
|
if err := fileutil.WriteJSONAtomic(metadata.MetadataPath, metadata); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return path, nil
|
return metadata.MetadataPath, nil
|
||||||
}
|
|
||||||
|
|
||||||
func (s *FilesystemStore) FindPriorDailySnapshot(ctx context.Context, resolved report.Resolved) (*PriorSnapshot, error) {
|
|
||||||
return s.FindPriorSnapshot(ctx, resolved)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.Resolved) (*PriorSnapshot, error) {
|
func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.Resolved) (*PriorSnapshot, error) {
|
||||||
if resolved.Definition.ComparisonStrategy != report.CompareSameValidDate && resolved.Definition.ComparisonStrategy != report.CompareWeekendWindow {
|
if resolved.Definition.ComparisonStrategy != report.CompareSameValidDate && resolved.Definition.ComparisonStrategy != report.CompareWeekendWindow {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
group, err := reportGroup(resolved.Definition.ID)
|
group := resolved.Definition.ArtifactGroup
|
||||||
if err != nil {
|
if group == "" {
|
||||||
return nil, err
|
return nil, fmt.Errorf("report %q has no artifact group", resolved.Definition.ID)
|
||||||
}
|
}
|
||||||
dirs, err := s.metadataDirectories(resolved, group)
|
dirs, err := s.metadataDirectories(resolved, group)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -205,7 +197,7 @@ func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.R
|
|||||||
if metadata.RunID == resolved.Metadata().RunID {
|
if metadata.RunID == resolved.Metadata().RunID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !compatiblePriorReport(group, metadata.ReportID, resolved.Definition.ID) {
|
if !resolved.Definition.CompatibleWithPrior(metadata.ReportID) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !comparablePeriod(metadata, resolved) {
|
if !comparablePeriod(metadata, resolved) {
|
||||||
@@ -343,19 +335,6 @@ func (s *FilesystemStore) metadataDirectories(resolved report.Resolved, group st
|
|||||||
return dirs, nil
|
return dirs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func compatiblePriorReport(group string, prior report.ID, current report.ID) bool {
|
|
||||||
switch group {
|
|
||||||
case "daily":
|
|
||||||
return prior == report.DailyToday || prior == report.DailyTomorrow
|
|
||||||
case "three-day":
|
|
||||||
return prior == report.ThreeDay && current == report.ThreeDay
|
|
||||||
case "weekend":
|
|
||||||
return prior == report.Weekend && current == report.Weekend
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *FilesystemStore) join(parts ...string) string {
|
func (s *FilesystemStore) join(parts ...string) string {
|
||||||
all := append([]string{s.root}, parts...)
|
all := append([]string{s.root}, parts...)
|
||||||
return filepath.Join(all...)
|
return filepath.Join(all...)
|
||||||
@@ -375,48 +354,6 @@ func validateRelativeDir(name string, value string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func reportGroup(id report.ID) (string, error) {
|
|
||||||
switch id {
|
|
||||||
case report.DailyToday, report.DailyTomorrow:
|
|
||||||
return "daily", nil
|
|
||||||
case report.ThreeDay:
|
|
||||||
return "three-day", nil
|
|
||||||
case report.Weekend:
|
|
||||||
return "weekend", nil
|
|
||||||
case report.Storm:
|
|
||||||
return "storm", nil
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("unknown report %q", id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeJSONAtomic(path string, value any) error {
|
|
||||||
data, err := json.MarshalIndent(value, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("marshal %q: %w", path, err)
|
|
||||||
}
|
|
||||||
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 readJSON(path string, target any) error {
|
func readJSON(path string, target any) error {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -428,14 +365,6 @@ func readJSON(path string, target any) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func metadataPathFromStored(metadata Metadata) string {
|
|
||||||
if metadata.BriefingPath == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
filename := metadata.RunID + ".metadata.json"
|
|
||||||
return filepath.Join(filepath.Dir(metadata.BriefingPath), filename)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameValidDate(metadata Metadata, resolved report.Resolved) bool {
|
func sameValidDate(metadata Metadata, resolved report.Resolved) bool {
|
||||||
return metadata.ValidPeriod.Start.Format("2006-01-02") == resolved.ValidPeriod.Start.Format("2006-01-02")
|
return metadata.ValidPeriod.Start.Format("2006-01-02") == resolved.ValidPeriod.Start.Format("2006-01-02")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||||
@@ -56,7 +55,7 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SaveDataPackage() error = %v", err)
|
t.Fatalf("SaveDataPackage() error = %v", err)
|
||||||
}
|
}
|
||||||
preflightPath, err := store.SavePreflight(context.Background(), resolved, &scriptorium.RenderResult{Stdout: `{"ok":true}`})
|
preflightPath, err := store.SavePreflight(context.Background(), resolved, PreflightArtifact{Stdout: `{"ok":true}`})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SavePreflight() error = %v", err)
|
t.Fatalf("SavePreflight() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -67,6 +66,17 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
|||||||
if err := os.WriteFile(renderedReportPath, []byte("# Daily Report\n"), 0o600); err != nil {
|
if err := os.WriteFile(renderedReportPath, []byte("# Daily Report\n"), 0o600); err != nil {
|
||||||
t.Fatalf("write rendered report: %v", err)
|
t.Fatalf("write rendered report: %v", err)
|
||||||
}
|
}
|
||||||
|
var preflight PreflightArtifact
|
||||||
|
preflightData, err := os.ReadFile(preflightPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read preflight: %v", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(preflightData, &preflight); err != nil {
|
||||||
|
t.Fatalf("decode preflight: %v", err)
|
||||||
|
}
|
||||||
|
if preflight.Stdout != `{"ok":true}` {
|
||||||
|
t.Fatalf("preflight stdout = %q, want render stdout", preflight.Stdout)
|
||||||
|
}
|
||||||
paths, err := store.Paths(resolved)
|
paths, err := store.Paths(resolved)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Paths() error = %v", err)
|
t.Fatalf("Paths() error = %v", err)
|
||||||
@@ -112,9 +122,49 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
|||||||
if decoded.RenderedReportPath != renderedReportPath {
|
if decoded.RenderedReportPath != renderedReportPath {
|
||||||
t.Fatalf("RenderedReportPath = %q, want %q", decoded.RenderedReportPath, renderedReportPath)
|
t.Fatalf("RenderedReportPath = %q, want %q", decoded.RenderedReportPath, renderedReportPath)
|
||||||
}
|
}
|
||||||
|
if decoded.Location == nil || decoded.Location.Name != "Brentwood" || decoded.Location.Timezone != "America/Chicago" {
|
||||||
|
t.Fatalf("metadata location = %#v, want briefing location", decoded.Location)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), "MetadataPath") || strings.Contains(string(data), "metadataPath") {
|
||||||
|
t.Fatalf("metadata JSON includes runtime-only MetadataPath:\n%s", string(data))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFindPriorDailySnapshot(t *testing.T) {
|
func TestSaveMetadataUsesExplicitMetadataPath(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
||||||
|
briefingPackage := stateBriefingPackage(resolved)
|
||||||
|
paths, err := store.Paths(resolved)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Paths() error = %v", err)
|
||||||
|
}
|
||||||
|
otherDir := filepath.Join(t.TempDir(), "other-artifacts")
|
||||||
|
otherBriefingPath := filepath.Join(otherDir, resolved.Metadata().RunID+".briefing.json")
|
||||||
|
derivedMetadataPath := filepath.Join(otherDir, resolved.Metadata().RunID+".metadata.json")
|
||||||
|
|
||||||
|
metadata := BuildMetadata(resolved, briefingPackage, ArtifactPaths{
|
||||||
|
Briefing: otherBriefingPath,
|
||||||
|
Metadata: paths.Metadata,
|
||||||
|
DataPackage: paths.DataPackage,
|
||||||
|
Preflight: paths.Preflight,
|
||||||
|
RenderedReport: paths.RenderedReport,
|
||||||
|
})
|
||||||
|
metadataPath, err := store.SaveMetadata(context.Background(), metadata)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveMetadata() error = %v", err)
|
||||||
|
}
|
||||||
|
if metadataPath != paths.Metadata {
|
||||||
|
t.Fatalf("SaveMetadata() path = %q, want explicit metadata path %q", metadataPath, paths.Metadata)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(paths.Metadata); err != nil {
|
||||||
|
t.Fatalf("expected explicit metadata path %q: %v", paths.Metadata, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(derivedMetadataPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("derived metadata path stat error = %v, want not exist", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindPriorSnapshot(t *testing.T) {
|
||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
first := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
first := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
||||||
second := resolveDailyAt(t, "2026-05-29T08:00:00-05:00")
|
second := resolveDailyAt(t, "2026-05-29T08:00:00-05:00")
|
||||||
@@ -138,12 +188,12 @@ func TestFindPriorDailySnapshot(t *testing.T) {
|
|||||||
t.Fatalf("SaveMetadata() error = %v", err)
|
t.Fatalf("SaveMetadata() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
prior, err := store.FindPriorDailySnapshot(context.Background(), second)
|
prior, err := store.FindPriorSnapshot(context.Background(), second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("FindPriorDailySnapshot() error = %v", err)
|
t.Fatalf("FindPriorSnapshot() error = %v", err)
|
||||||
}
|
}
|
||||||
if prior == nil {
|
if prior == nil {
|
||||||
t.Fatal("FindPriorDailySnapshot() = nil, want prior snapshot")
|
t.Fatal("FindPriorSnapshot() = nil, want prior snapshot")
|
||||||
}
|
}
|
||||||
if prior.Metadata.RunID != first.Metadata().RunID {
|
if prior.Metadata.RunID != first.Metadata().RunID {
|
||||||
t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID)
|
t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID)
|
||||||
@@ -153,7 +203,7 @@ func TestFindPriorDailySnapshot(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFindPriorDailySnapshotUsesValidDate(t *testing.T) {
|
func TestFindPriorSnapshotUsesValidDate(t *testing.T) {
|
||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
previousDate := resolveDailyAt(t, "2026-05-28T05:00:00-05:00")
|
previousDate := resolveDailyAt(t, "2026-05-28T05:00:00-05:00")
|
||||||
currentDate := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
currentDate := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
||||||
@@ -177,12 +227,12 @@ func TestFindPriorDailySnapshotUsesValidDate(t *testing.T) {
|
|||||||
t.Fatalf("SaveMetadata() error = %v", err)
|
t.Fatalf("SaveMetadata() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
prior, err := store.FindPriorDailySnapshot(context.Background(), currentDate)
|
prior, err := store.FindPriorSnapshot(context.Background(), currentDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("FindPriorDailySnapshot() error = %v", err)
|
t.Fatalf("FindPriorSnapshot() error = %v", err)
|
||||||
}
|
}
|
||||||
if prior != nil {
|
if prior != nil {
|
||||||
t.Fatalf("FindPriorDailySnapshot() = %#v, want nil for different valid date", prior)
|
t.Fatalf("FindPriorSnapshot() = %#v, want nil for different valid date", prior)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,6 +440,12 @@ func stateBriefingPackage(resolved report.Resolved) briefing.Package {
|
|||||||
GeneratedAt: resolved.GeneratedAt,
|
GeneratedAt: resolved.GeneratedAt,
|
||||||
Units: "us",
|
Units: "us",
|
||||||
Timezone: resolved.Timezone,
|
Timezone: resolved.Timezone,
|
||||||
|
Location: &briefing.LocationContext{
|
||||||
|
ID: "home",
|
||||||
|
Name: "Brentwood",
|
||||||
|
Region: "St. Louis Metro",
|
||||||
|
Timezone: resolved.Timezone,
|
||||||
|
},
|
||||||
ValidPeriod: resolved.ValidPeriod,
|
ValidPeriod: resolved.ValidPeriod,
|
||||||
},
|
},
|
||||||
Daily: &briefing.Daily{ForecastSummaryDate: "2026-05-29"},
|
Daily: &briefing.Daily{ForecastSummaryDate: "2026-05-29"},
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ const MetadataSchemaVersion = "weatherreporter.metadata.v1"
|
|||||||
type Metadata struct {
|
type Metadata struct {
|
||||||
SchemaVersion string `json:"schemaVersion"`
|
SchemaVersion string `json:"schemaVersion"`
|
||||||
RunID string `json:"runId"`
|
RunID string `json:"runId"`
|
||||||
|
MetadataPath string `json:"-"`
|
||||||
ReportID report.ID `json:"reportId"`
|
ReportID report.ID `json:"reportId"`
|
||||||
Variant string `json:"variant,omitempty"`
|
Variant string `json:"variant,omitempty"`
|
||||||
PromptID string `json:"promptId"`
|
PromptID string `json:"promptId"`
|
||||||
GeneratedAt time.Time `json:"generatedAt"`
|
GeneratedAt time.Time `json:"generatedAt"`
|
||||||
Timezone string `json:"timezone"`
|
Timezone string `json:"timezone"`
|
||||||
ValidPeriod timeutil.Period `json:"validPeriod"`
|
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||||
|
Location *briefing.LocationContext `json:"location,omitempty"`
|
||||||
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
||||||
SourceLocation string `json:"sourceLocation,omitempty"`
|
SourceLocation string `json:"sourceLocation,omitempty"`
|
||||||
Sources []briefing.SourceMetadata `json:"sources,omitempty"`
|
Sources []briefing.SourceMetadata `json:"sources,omitempty"`
|
||||||
@@ -35,12 +37,14 @@ func BuildMetadata(resolved report.Resolved, briefingPackage briefing.Package, p
|
|||||||
return Metadata{
|
return Metadata{
|
||||||
SchemaVersion: MetadataSchemaVersion,
|
SchemaVersion: MetadataSchemaVersion,
|
||||||
RunID: metadata.RunID,
|
RunID: metadata.RunID,
|
||||||
|
MetadataPath: paths.Metadata,
|
||||||
ReportID: metadata.ReportID,
|
ReportID: metadata.ReportID,
|
||||||
Variant: briefingPackage.Metadata.Variant,
|
Variant: briefingPackage.Metadata.Variant,
|
||||||
PromptID: metadata.PromptID,
|
PromptID: metadata.PromptID,
|
||||||
GeneratedAt: metadata.GeneratedAt,
|
GeneratedAt: metadata.GeneratedAt,
|
||||||
Timezone: metadata.Timezone,
|
Timezone: metadata.Timezone,
|
||||||
ValidPeriod: metadata.ValidPeriod,
|
ValidPeriod: metadata.ValidPeriod,
|
||||||
|
Location: copyLocation(briefingPackage.Metadata.Location),
|
||||||
SourceLocationID: briefingPackage.Metadata.SourceLocationID,
|
SourceLocationID: briefingPackage.Metadata.SourceLocationID,
|
||||||
SourceLocation: briefingPackage.Metadata.SourceLocation,
|
SourceLocation: briefingPackage.Metadata.SourceLocation,
|
||||||
Sources: briefingPackage.Metadata.Sources,
|
Sources: briefingPackage.Metadata.Sources,
|
||||||
@@ -51,3 +55,11 @@ func BuildMetadata(resolved report.Resolved, briefingPackage briefing.Package, p
|
|||||||
RenderedReportPath: paths.RenderedReport,
|
RenderedReportPath: paths.RenderedReport,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func copyLocation(location *briefing.LocationContext) *briefing.LocationContext {
|
||||||
|
if location == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
copied := *location
|
||||||
|
return &copied
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ package state
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
@@ -14,11 +13,10 @@ type Store interface {
|
|||||||
Paths(report.Resolved) (ArtifactPaths, error)
|
Paths(report.Resolved) (ArtifactPaths, error)
|
||||||
SaveBriefing(context.Context, report.Resolved, briefing.Package) (string, error)
|
SaveBriefing(context.Context, report.Resolved, briefing.Package) (string, error)
|
||||||
SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error)
|
SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error)
|
||||||
SavePreflight(context.Context, report.Resolved, *scriptorium.RenderResult) (string, error)
|
SavePreflight(context.Context, report.Resolved, PreflightArtifact) (string, error)
|
||||||
PrepareRenderedReport(context.Context, report.Resolved) (string, error)
|
PrepareRenderedReport(context.Context, report.Resolved) (string, error)
|
||||||
SaveMetadata(context.Context, Metadata) (string, error)
|
SaveMetadata(context.Context, Metadata) (string, error)
|
||||||
FindPriorSnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
|
FindPriorSnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
|
||||||
FindPriorDailySnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
|
|
||||||
LoadBriefing(context.Context, string) (briefing.Package, error)
|
LoadBriefing(context.Context, string) (briefing.Package, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,3 +24,12 @@ type PriorSnapshot struct {
|
|||||||
Metadata Metadata
|
Metadata Metadata
|
||||||
BriefingPath string
|
BriefingPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PreflightArtifact struct {
|
||||||
|
Command []string `json:"command"`
|
||||||
|
Stdout string `json:"stdout"`
|
||||||
|
Stderr string `json:"stderr"`
|
||||||
|
StdoutTruncated bool `json:"stdoutTruncated,omitempty"`
|
||||||
|
StderrTruncated bool `json:"stderrTruncated,omitempty"`
|
||||||
|
ExitCode int `json:"exitCode"`
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user