Compare commits
27 Commits
88aaae3661
...
v0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
/workspace
|
||||
|
||||
# ---> Go
|
||||
# If you prefer the allow list template instead of the deny list, see community template:
|
||||
|
||||
15
README.md
15
README.md
@@ -1,21 +1,14 @@
|
||||
# weatherreporter
|
||||
|
||||
`weatherreporter` is a Go application for preparing human-facing weather
|
||||
reports from normalized forecast data.
|
||||
|
||||
The application can currently generate Daily Today, Daily Tomorrow, 3-Day
|
||||
Outlook, Weekend Outlook, and manual Storm Report Markdown reports through `scriptorium`, with
|
||||
inspectable briefing, prompt input, preflight, report, and metadata artifacts
|
||||
under the configured workspace.
|
||||
reports from normalized forecast data. It builds structured briefing packages,
|
||||
runs them through `scriptorium`, and keeps inspectable artifacts under a local
|
||||
workspace.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```sh
|
||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
||||
weatherreporter generate tomorrow --out ./tomorrow.md
|
||||
weatherreporter generate three-day --out ./three-day.md
|
||||
weatherreporter generate weekend --out ./weekend.md
|
||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
|
||||
```
|
||||
|
||||
## Documentation
|
||||
@@ -23,6 +16,6 @@ weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 -
|
||||
- [CLI reference](docs/cli.md)
|
||||
- [Configuration reference](docs/config.md)
|
||||
- [Operations guide](docs/operations.md)
|
||||
- [Troubleshooting](docs/troubleshooting.md)
|
||||
- [Architecture policy](docs/policy/architecture.md)
|
||||
- [Development policy](docs/policy/development.md)
|
||||
- [Implementation roadmap](docs/roadmap/initial.md)
|
||||
|
||||
119
docs/cli.md
119
docs/cli.md
@@ -1,11 +1,7 @@
|
||||
# Weatherreporter CLI
|
||||
|
||||
`weatherreporter generate daily`, `weatherreporter generate tomorrow`,
|
||||
`weatherreporter generate three-day`, `weatherreporter generate weekend`,
|
||||
`weatherreporter generate storm`,
|
||||
`weatherreporter run morning`, and `weatherreporter run evening` currently
|
||||
write Markdown reports through `scriptorium`, after writing managed preparation
|
||||
artifacts and running `scriptorium render` as a preflight check.
|
||||
`weatherreporter` generates Markdown weather reports, runs scheduled report
|
||||
batches, and inspects previously generated artifacts.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
The command parses flags, loads configuration, fetches weather data, builds a
|
||||
Daily briefing, writes workspace artifacts, invokes
|
||||
`scriptorium render --input data_package=<managed_path> --format json`, then
|
||||
invokes `scriptorium run --input data_package=<managed_path> --out <managed_report>`.
|
||||
When `--out` is supplied, it also writes a copy of the Markdown report to that
|
||||
path.
|
||||
This loads configuration, fetches weather data, writes managed workspace
|
||||
artifacts, runs `scriptorium render` as a preflight check, runs
|
||||
`scriptorium run`, and writes an extra Markdown copy to `./daily.md`.
|
||||
|
||||
For tomorrow planning:
|
||||
|
||||
```sh
|
||||
weatherreporter generate tomorrow --out ./tomorrow.md
|
||||
weatherreporter run evening
|
||||
```
|
||||
|
||||
For the 3-Day Outlook:
|
||||
|
||||
```sh
|
||||
weatherreporter generate three-day --out ./three-day.md
|
||||
weatherreporter generate weekend --out ./weekend.md
|
||||
```
|
||||
|
||||
For a focused manual Storm Report:
|
||||
|
||||
```sh
|
||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
|
||||
```
|
||||
|
||||
## Command Overview
|
||||
## Commands
|
||||
|
||||
```text
|
||||
weatherreporter generate daily
|
||||
weatherreporter generate tomorrow
|
||||
weatherreporter generate three-day
|
||||
weatherreporter generate weekend
|
||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
|
||||
weatherreporter run morning
|
||||
weatherreporter run evening
|
||||
weatherreporter inspect reports
|
||||
weatherreporter inspect metadata RUN_ID
|
||||
weatherreporter inspect briefing RUN_ID
|
||||
weatherreporter inspect data-package RUN_ID
|
||||
weatherreporter inspect prior RUN_ID
|
||||
weatherreporter inspect sources RUN_ID
|
||||
weatherreporter --help
|
||||
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
||||
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
|
||||
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
|
||||
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
|
||||
weatherreporter inspect reports [--config PATH] [--limit N]
|
||||
weatherreporter inspect metadata [--config PATH] RUN_ID
|
||||
weatherreporter inspect briefing [--config PATH] RUN_ID
|
||||
weatherreporter inspect data-package [--config PATH] RUN_ID
|
||||
weatherreporter inspect prior [--config PATH] RUN_ID
|
||||
weatherreporter inspect sources [--config PATH] RUN_ID
|
||||
```
|
||||
|
||||
`generate daily`, `generate tomorrow`, `generate three-day`,
|
||||
`generate weekend`, and `generate storm` write a briefing snapshot, prompt
|
||||
input data package, render preflight output, Markdown report, and metadata file
|
||||
under the configured workspace. `generate storm` requires explicit `--start`
|
||||
and `--end` bounds for the event window. `run evening` generates the Tomorrow
|
||||
Planning Brief. `run morning` generates Daily Today and the 3-Day Outlook, plus
|
||||
Weekend Outlook except on Sunday. Run commands continue remaining reports after
|
||||
an independent report failure, print a JSON aggregate summary to stdout, write
|
||||
compact report status logs to stderr, and return nonzero when any report
|
||||
`generate` commands write briefing, data package, preflight, report, and
|
||||
metadata artifacts under the configured workspace. `generate storm` requires
|
||||
explicit event-window bounds with `--start` and `--end`.
|
||||
|
||||
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
||||
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch
|
||||
runs continue independent reports after a failure, print a JSON summary to
|
||||
stdout, write compact status lines to stderr, and return nonzero when any report
|
||||
failed.
|
||||
|
||||
`inspect` commands read the configured workspace and emit JSON to stdout. They
|
||||
do not fetch weather data or invoke `scriptorium`.
|
||||
`inspect` commands read existing workspace artifacts and emit JSON to stdout.
|
||||
They do not fetch weather data or invoke `scriptorium`.
|
||||
|
||||
## Flags
|
||||
|
||||
- `-h`, `--help`: show help.
|
||||
- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`.
|
||||
- `--units VALUE`: override configured Weather API units.
|
||||
- `--tz NAME`: override configured Weather API timezone.
|
||||
- `--out PATH`: optional Markdown report copy for `generate daily`, `generate tomorrow`, `generate three-day`, `generate weekend`, and `generate storm`.
|
||||
- `--out-dir PATH`: optional directory for extra Markdown report copies from `run morning` and `run evening`.
|
||||
- `--units VALUE`: override configured Weather API units for `generate` and `run`.
|
||||
- `--tz NAME`: override configured Weather API timezone for `generate` and `run`.
|
||||
- `--out PATH`: write an extra Markdown report copy for `generate` commands.
|
||||
- `--out-dir PATH`: write extra Markdown report copies for `run morning` and `run evening`.
|
||||
- `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone.
|
||||
- `--start TIME`: required start time for `generate storm`.
|
||||
- `--end TIME`: required end time for `generate storm`.
|
||||
- `--limit N`: maximum report records for `inspect reports`; defaults to 20,
|
||||
and `0` means no limit.
|
||||
- `--limit N`: maximum records for `inspect reports`; defaults to `20`, and `0` means no limit.
|
||||
|
||||
Storm times accept `YYYY-MM-DDTHH:MM` in the configured timezone or RFC3339
|
||||
timestamps with explicit offsets.
|
||||
|
||||
## Common Workflows
|
||||
|
||||
```sh
|
||||
weatherreporter generate tomorrow --out ./tomorrow.md
|
||||
weatherreporter generate three-day --out ./three-day.md
|
||||
weatherreporter generate weekend --out ./weekend.md
|
||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
|
||||
weatherreporter run morning --out-dir ./reports
|
||||
weatherreporter run evening --out-dir ./reports
|
||||
```
|
||||
|
||||
## Inspection
|
||||
|
||||
```sh
|
||||
@@ -100,8 +83,8 @@ weatherreporter inspect prior 20260529T100000.000000000Z_daily_today
|
||||
weatherreporter inspect sources 20260529T100000.000000000Z_daily_today
|
||||
```
|
||||
|
||||
`inspect reports` lists recent generated runs with artifact paths and warning
|
||||
counts. The other commands require a RunID. `inspect prior` returns the prior
|
||||
comparable snapshot metadata selected from stored metadata, or `null` when no
|
||||
prior comparable snapshot exists. `inspect sources` shows source provenance and
|
||||
source warnings without dumping full weather payloads.
|
||||
`inspect reports` lists recent generated runs with artifact paths and source
|
||||
warning counts. The other inspect commands require a RunID. `inspect prior`
|
||||
returns the prior comparable snapshot metadata selected from stored metadata, or
|
||||
`null` when none exists. `inspect sources` shows source provenance and source
|
||||
warnings without dumping full weather payloads.
|
||||
|
||||
144
docs/config.md
144
docs/config.md
@@ -1,46 +1,128 @@
|
||||
# Weatherreporter Configuration
|
||||
|
||||
Configuration is loaded from `/usr/local/etc/weatherreporter/config.yml` by
|
||||
default. Use `--config PATH` to load a different file. CLI flags override file
|
||||
values.
|
||||
Configuration is YAML. By default, `weatherreporter` reads:
|
||||
|
||||
If the default file is absent, built-in defaults are used.
|
||||
```text
|
||||
/usr/local/etc/weatherreporter/config.yml
|
||||
```
|
||||
|
||||
Use `--config PATH` to load a different file. If the default file is absent,
|
||||
built-in defaults are used. If `--config PATH` points to a missing file, loading
|
||||
fails.
|
||||
|
||||
Precedence is:
|
||||
|
||||
1. CLI flags
|
||||
2. configuration file
|
||||
3. built-in defaults
|
||||
|
||||
The 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
|
||||
|
||||
See [examples/minimal-config.yml](../examples/minimal-config.yml).
|
||||
|
||||
```yaml
|
||||
weather_api:
|
||||
base_url: https://weather.api.example.com/
|
||||
```
|
||||
|
||||
`weather_api.base_url` is required for commands that fetch weather data. Other
|
||||
fields fall back to defaults.
|
||||
|
||||
## Production-Oriented Config
|
||||
|
||||
See [examples/config.yml](../examples/config.yml).
|
||||
See [examples/config.yml](../examples/config.yml). The example is loaded by the
|
||||
config test suite.
|
||||
|
||||
## Reference
|
||||
## Field Reference
|
||||
|
||||
- `weather_api.base_url`: single Weather API endpoint base URL, required when fetching weather data.
|
||||
- `weather_api.timeout`: HTTP timeout duration. Default: `10s`.
|
||||
- `weather_api.precision`: numeric precision hint. Default: `1`.
|
||||
- `weather_api.units`: Weather API units. Default: `us`.
|
||||
- `weather_api.timezone`: report timezone. Accepts IANA names, configured aliases such as `Chicago` and `Stl`, US timezone abbreviations, and UTC offsets such as `-5` or `+09:30`. Default: `Chicago`.
|
||||
- `weather_api.format`: Weather API response format. Default: `json`.
|
||||
- `missing_source.default`: one of `error`, `warn`, or `none`. Default: `warn`.
|
||||
- `missing_source.sources`: optional per-source missing-source policy overrides.
|
||||
- `scriptorium.binary`: `scriptorium` executable name. Default: `scriptorium`.
|
||||
- `scriptorium.config_path`: optional `scriptorium` config path.
|
||||
- `scriptorium.profile`: optional `scriptorium` profile.
|
||||
- `scriptorium.timeout`: subprocess timeout. Default: `2m`.
|
||||
- `scriptorium.extra_args`: optional extra arguments reserved for the adapter.
|
||||
- `workspace.root`: workspace root. Default: `workspace`.
|
||||
- `workspace.snapshots_dir`: snapshot directory under the workspace.
|
||||
- `workspace.reports_dir`: managed report directory under the workspace.
|
||||
- `workspace.data_packages_dir`: prompt input package directory under the workspace.
|
||||
- `workspace.preflight_dir`: preflight output directory under the workspace.
|
||||
- `reports.output_dir`: report output directory. Default: `reports`.
|
||||
- `reports.paths`: optional report-specific output paths.
|
||||
- `dayparts`: named daypart definitions with `start` and `end` `HH:MM` values.
|
||||
- `recent_change.temperature_degrees`: temperature change threshold.
|
||||
- `recent_change.precip_probability_points`: precipitation probability threshold.
|
||||
- `recent_change.wind_gust_miles_per_hour`: wind gust change threshold.
|
||||
- `recent_change.precip_timing_shift_minutes`: precipitation timing shift threshold.
|
||||
### `weather_api`
|
||||
|
||||
- `base_url`: absolute base URL for the Weather API. Required for generation and fetch workflows.
|
||||
- `timeout`: HTTP timeout duration. Default: `10s`.
|
||||
- `precision`: numeric precision query value. Default: `1`.
|
||||
- `units`: Weather API units query value. Default: `us`.
|
||||
- `timezone`: report timezone and Weather API timezone query value where supported. Default: `America/Chicago`.
|
||||
- `format`: Weather API response format. Must be `json`. Default: `json`.
|
||||
|
||||
Timezone values may be IANA names, configured aliases such as `Chicago` and
|
||||
`Stl`, US timezone abbreviations, or UTC offsets such as `-5` and `+09:30`.
|
||||
|
||||
### `location`
|
||||
|
||||
`location` is descriptive prompt context included in briefing metadata and
|
||||
Scriptorium data packages. It does not select a Weather API endpoint or enable
|
||||
multiple configured forecast locations.
|
||||
|
||||
- `id`: short local identifier. Default: `home`.
|
||||
- `name`: human-readable location name. Default: `Brentwood`.
|
||||
- `region`: broader forecast area context. Default: `St. Louis Metro`.
|
||||
|
||||
The prompt-facing location object also includes `timezone`, derived from the
|
||||
effective `weather_api.timezone` after CLI overrides such as `--tz`.
|
||||
|
||||
### `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
|
||||
|
||||
This document defines the supported subprocess contract for weatherreporter invoking Scriptorium through the public CLI.
|
||||
`weatherreporter` invokes Scriptorium as a subprocess to preflight prompt input
|
||||
and generate Markdown reports. This page documents the CLI surface the adapter
|
||||
uses, not the full Scriptorium product.
|
||||
|
||||
This is a CLI contract, not an internal Go package integration.
|
||||
## Commands Used
|
||||
|
||||
## Supported Commands
|
||||
|
||||
weatherreporter should invoke:
|
||||
|
||||
- `scriptorium run`
|
||||
- `scriptorium render`
|
||||
|
||||
Use `run` for generation.
|
||||
|
||||
Use `render` for preflight/debug output without LLM execution.
|
||||
|
||||
## Recommended Invocation Shapes
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
scriptorium run \
|
||||
--prompt <prompt_id> \
|
||||
--input data_package=<path> \
|
||||
--out <artifact_path>
|
||||
```
|
||||
|
||||
Render:
|
||||
Render preflight:
|
||||
|
||||
```bash
|
||||
scriptorium render \
|
||||
@@ -37,78 +20,78 @@ scriptorium render \
|
||||
--format json
|
||||
```
|
||||
|
||||
weatherreporter may add:
|
||||
Report generation:
|
||||
|
||||
- `--config <path>`
|
||||
- `--profile <profile_id>`
|
||||
- repeatable `--input name=path`
|
||||
- repeatable `--var name=value`
|
||||
- runtime overrides when explicitly needed (`--model`, `--llm-base-url`, `--timeout`, etc.)
|
||||
```bash
|
||||
scriptorium run \
|
||||
--prompt <prompt_id> \
|
||||
--input data_package=<path> \
|
||||
--out <artifact_path>
|
||||
```
|
||||
|
||||
## Config And Directory Behavior
|
||||
`weatherreporter` always passes prompt input as
|
||||
`--input data_package=<path>`. The data package is structured JSON created by
|
||||
`internal/promptinput`.
|
||||
|
||||
weatherreporter can rely on resolved app config or pass explicit paths.
|
||||
## Configured Arguments
|
||||
|
||||
- default config search order:
|
||||
1. `/usr/local/etc/scriptorium/config.yml`
|
||||
2. `/etc/scriptorium/config.yml`
|
||||
- explicit `--config` requires file existence and valid syntax
|
||||
- CLI flags override config values
|
||||
The adapter can prepend configured flags before prompt-specific arguments:
|
||||
|
||||
## Profile Selection
|
||||
- `--config <path>` from `scriptorium.config_path`
|
||||
- `--profile <profile>` from `scriptorium.profile`
|
||||
|
||||
Profile selection follows runner behavior:
|
||||
It appends `scriptorium.extra_args` after the built-in arguments. Extra
|
||||
arguments are passed directly as argv items.
|
||||
|
||||
1. explicit `--profile`
|
||||
2. prompt `default_profile`
|
||||
3. error if neither is available
|
||||
`scriptorium.binary` selects the executable name or path. If unset inside the
|
||||
adapter, it falls back to `scriptorium`.
|
||||
|
||||
weatherreporter should treat prompt/profile IDs as deployment configuration, not hardcoded logic.
|
||||
## Execution Behavior
|
||||
|
||||
## Input And Variable Contract
|
||||
The adapter runs Scriptorium without shell interpolation. Arguments are passed
|
||||
through `exec.CommandContext`.
|
||||
|
||||
- Inputs use repeated `--input name=path`.
|
||||
- Input names must match prompt definition input names.
|
||||
- Variables use repeated `--var name=value` for small metadata values.
|
||||
- Prefer file inputs for large content.
|
||||
`scriptorium.timeout` limits each subprocess call when configured. Context
|
||||
cancellation or timeout returns an execution error.
|
||||
|
||||
## Environment Contract
|
||||
Stdout and stderr are captured separately. Each stream is capped at 1 MiB and
|
||||
the result records whether truncation occurred.
|
||||
|
||||
- Pass through required API-key environment variables referenced by `api_key_env`.
|
||||
- Never pass raw API keys via CLI arguments.
|
||||
- Keep subprocess environment scoped to required variables.
|
||||
## Results
|
||||
|
||||
## Output And Error Handling
|
||||
Render results include:
|
||||
|
||||
`run`:
|
||||
- full argv recorded as `command`
|
||||
- stdout
|
||||
- stderr
|
||||
- exit code
|
||||
- truncation flags when applicable
|
||||
|
||||
- stdout: artifact body unless `--out` is used
|
||||
- `--out`: writes artifact to file
|
||||
- stderr: success summary and errors
|
||||
Run results include the same fields plus the requested output path.
|
||||
|
||||
`render`:
|
||||
`weatherreporter` persists render preflight JSON when orchestration reaches the
|
||||
preflight save point. The final Markdown artifact is written by Scriptorium to
|
||||
the `--out` path.
|
||||
|
||||
- stdout: prepared-run output unless `--out` is used
|
||||
- stderr: errors
|
||||
## Failure Behavior
|
||||
|
||||
weatherreporter should capture stdout and stderr separately.
|
||||
The adapter validates required request fields before starting Scriptorium:
|
||||
|
||||
## Exit Status Contract
|
||||
- prompt ID
|
||||
- data package path
|
||||
- output path for `run`
|
||||
|
||||
- `0`: success
|
||||
- `1`: parse/config/load/render/generation/IO/runtime error
|
||||
- `2`: run completed but validation failed
|
||||
Nonzero exits return both the captured result and an error containing the exit
|
||||
code and stderr. A `run` exit code such as `2` is still treated as an error by
|
||||
the adapter, even if Scriptorium wrote output to the requested artifact path.
|
||||
|
||||
A `run` exit code `2` can still produce output (stdout or `--out`).
|
||||
Subprocess start failures, context cancellation, and timeouts return errors
|
||||
without fabricating a successful result.
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Treat generated artifacts and stderr logs as potentially sensitive.
|
||||
- Avoid logging full rendered prompts by default in production contexts.
|
||||
- Use controlled output paths and access controls for persisted artifacts.
|
||||
|
||||
## Canonical References
|
||||
|
||||
- CLI behavior: [CLI reference](https://gitea.maximumdirect.net/eric/scriptorium/docs/cli.md)
|
||||
- Config behavior: [Configuration reference](https://gitea.maximumdirect.net/eric/scriptorium/docs/config.md)
|
||||
- Operations and failure handling: [Operations guide](https://gitea.maximumdirect.net/eric/scriptorium/docs/operations.md), [Troubleshooting](https://gitea.maximumdirect.net/eric/scriptorium/docs/troubleshooting.md)
|
||||
- The adapter does not invoke a shell.
|
||||
- Generated artifacts, rendered prompt context, stdout, and stderr can contain
|
||||
operationally sensitive data.
|
||||
- API keys should be provided through the Scriptorium environment or
|
||||
Scriptorium configuration, not through `weatherreporter` CLI arguments.
|
||||
|
||||
@@ -1,354 +1,132 @@
|
||||
# weatherapi External API
|
||||
# Weather API Integration
|
||||
|
||||
This document describes the public HTTP API exposed by `weatherapi` for external consumers.
|
||||
This document describes the external Weather API contract used by
|
||||
`weatherreporter`.
|
||||
|
||||
## Purpose
|
||||
|
||||
`weatherreporter` uses a configured Weather API base URL to fetch normalized
|
||||
weather source data and assemble a `forecast.Bundle`. This is an integration
|
||||
contract for the project adapter, not a complete public API reference for the
|
||||
upstream service.
|
||||
|
||||
## Base URL
|
||||
|
||||
The service is typically served at your deployment host, for example:
|
||||
`weather_api.base_url` must be an absolute URL. Adapter requests join this base
|
||||
URL with the endpoint paths listed below. Generation and explicit bundle fetches
|
||||
fail before any HTTP request when the base URL is empty or not absolute.
|
||||
|
||||
- `https://weather.api.rakestrawhome.com`
|
||||
The HTTP client uses `weather_api.timeout`.
|
||||
|
||||
All paths below are relative to the service root.
|
||||
## Response Envelope
|
||||
|
||||
## Common Conventions
|
||||
|
||||
### Response envelope
|
||||
|
||||
All endpoints return a top-level envelope:
|
||||
|
||||
- `data`: endpoint payload or `null` when no current/latest resource is available.
|
||||
|
||||
JSON example:
|
||||
Every response used by the adapter must be JSON with a top-level `data` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {"...": "..."}
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
### Output format
|
||||
For most sources, `data: null` is treated as a missing source. Missing optional
|
||||
sources follow the configured missing-source policy. Missing hourly forecast
|
||||
data fails bundle fetching because hourly periods are required for report
|
||||
generation.
|
||||
|
||||
Supported via `format` query parameter (case-insensitive):
|
||||
`/alerts/active` is the exception: a successful response with `data: null`
|
||||
means the endpoint was checked and there are no current active alerts. The
|
||||
adapter records a non-missing alerts source and an empty alert run.
|
||||
|
||||
- `json` (default)
|
||||
- `xml`
|
||||
- `text`
|
||||
Malformed JSON envelopes, non-2xx statuses, and response read failures include
|
||||
endpoint context in returned errors. Decode errors include source context when
|
||||
they fail the fetch; optional malformed sources follow the missing-source policy.
|
||||
|
||||
### Units
|
||||
## Query Parameters
|
||||
|
||||
Supported via `units` query parameter (case-insensitive):
|
||||
The adapter sends these query parameters:
|
||||
|
||||
- `metric` (default)
|
||||
- `us`
|
||||
- `format`: from `weather_api.format`; the implemented configuration requires
|
||||
`json`
|
||||
- `units`: from `weather_api.units`
|
||||
- `precision`: from `weather_api.precision` on observations, current
|
||||
conditions, hourly forecast, and narrative forecast requests
|
||||
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast, and
|
||||
discussion requests
|
||||
|
||||
Endpoints that include unit-based numeric fields return either metric or US field variants depending on this value.
|
||||
Alerts do not receive `precision` or `tz`.
|
||||
|
||||
### Precision
|
||||
## Endpoints Used
|
||||
|
||||
Supported where documented via `precision` query parameter:
|
||||
The adapter fetches these endpoints once per bundle:
|
||||
|
||||
- integer range: `0` to `2`
|
||||
- controls decimal rounding of numeric output fields
|
||||
- `/observations`
|
||||
- `/conditions/current`
|
||||
- `/forecast/hourly`
|
||||
- `/forecast/narrative`
|
||||
- `/alerts/active`
|
||||
- `/discussion`
|
||||
|
||||
### Timezone (`tz` / `TZ`)
|
||||
`weatherreporter` does not call day-slice forecast endpoints or discussion
|
||||
subsection endpoints. Report-period selection and daypart summarization happen
|
||||
inside Go after the full hourly and narrative products are fetched.
|
||||
|
||||
Supported where documented:
|
||||
## Required And Optional Sources
|
||||
|
||||
- accepted values include:
|
||||
- IANA timezone names (example: `America/Chicago`)
|
||||
- common US abbreviations (example: `CDT`, `EST`)
|
||||
- UTC offsets in `±H`, `±HH`, or `±HH:MM` (example: `-5`, `+09:30`)
|
||||
- aliases including `Chicago` and `Stl`
|
||||
- `tz` and `TZ` are treated equivalently
|
||||
- if both are provided, they must match exactly or the request fails
|
||||
Hourly forecast is required:
|
||||
|
||||
Timezone affects datetime rendering and day-slice filtering for `/today` and `/tomorrow` forecast routes.
|
||||
- `data: null` for `/forecast/hourly` fails the fetch.
|
||||
- an hourly forecast with no `periods` fails the fetch.
|
||||
- malformed hourly data fails the fetch.
|
||||
|
||||
### Query validation
|
||||
Other fetched sources are optional and follow `missing_source.default` or a
|
||||
source-specific `missing_source.sources` policy:
|
||||
|
||||
- Unknown query parameters are rejected with `400 Bad Request`.
|
||||
- Invalid parameter values are rejected with `400 Bad Request`.
|
||||
- `observations` for `/observations`
|
||||
- `current` for `/conditions/current`
|
||||
- `narrative` for `/forecast/narrative`
|
||||
- `alerts` for `/alerts/active`
|
||||
- `discussion` for `/discussion`
|
||||
|
||||
Error response body follows the service error envelope; exact fields may vary by error type.
|
||||
The adapter also creates missing stub source records for `daily` and
|
||||
`weather_story` because those source slots exist in the internal bundle but are
|
||||
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`
|
||||
- `format`: `json` | `xml` | `text`
|
||||
- `precision`: `0..2`
|
||||
For source payloads accepted into the bundle, including the explicit `null`
|
||||
alerts payload, the adapter records:
|
||||
|
||||
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)
|
||||
- `stationName` (string, optional)
|
||||
- `timestamp` (RFC3339 datetime, required)
|
||||
- `conditionCode` (integer WMO code, required)
|
||||
- `isDay` (boolean, optional)
|
||||
- `textDescription` (string, optional)
|
||||
- Metric mode fields:
|
||||
- `temperatureC`, `dewpointC`, `windSpeedKmh`, `windGustKmh`, `barometricPressurePa`, `visibilityMeters`, `relativeHumidityPercent`, `apparentTemperatureC` (number, optional)
|
||||
- `windDirectionDegrees` (number, optional)
|
||||
- US mode fields:
|
||||
- `temperatureF`, `dewpointF`, `windSpeedMph`, `windGustMph`, `barometricPressureInHg`, `visibilityMiles`, `relativeHumidityPercent`, `apparentTemperatureF` (number, optional)
|
||||
- `windDirectionDegrees` (number, optional)
|
||||
- `presentWeather` (array, optional)
|
||||
Warnings are recorded both on the affected source and on the bundle-level
|
||||
warnings list.
|
||||
|
||||
## `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
|
||||
|
||||
- `units`: `metric` | `us` (accepted; does not materially alter alert payload)
|
||||
- `format`: `json` | `xml` | `text`
|
||||
|
||||
Response `data` fields:
|
||||
|
||||
- Weather alert run object from canonical model (includes run metadata and active alerts list).
|
||||
|
||||
## `GET /conditions/current`
|
||||
|
||||
Returns current conditions synthesized from latest observation/forecast data.
|
||||
|
||||
Query parameters:
|
||||
|
||||
- `units`: `metric` | `us`
|
||||
- `format`: `json` | `xml` | `text`
|
||||
- `precision`: `0..2`
|
||||
|
||||
Response `data` fields:
|
||||
|
||||
- Common:
|
||||
- `conditionText` (string, optional)
|
||||
- `isDay` (boolean, optional)
|
||||
- `relativeHumidityPercent` (number, optional)
|
||||
- `windDirectionDegrees` (number, optional)
|
||||
- Metric mode:
|
||||
- `temperatureC`, `apparentTemperatureC`, `dewpointC`, `windSpeedKmh` (number, optional)
|
||||
- US mode:
|
||||
- `temperatureF`, `apparentTemperatureF`, `dewpointF`, `windSpeedMph` (number, optional)
|
||||
|
||||
## Forecast endpoints
|
||||
|
||||
- `GET /forecast/hourly`
|
||||
- `GET /forecast/hourly/today`
|
||||
- `GET /forecast/hourly/tomorrow`
|
||||
- `GET /forecast/narrative`
|
||||
- `GET /forecast/narrative/today`
|
||||
- `GET /forecast/narrative/tomorrow`
|
||||
|
||||
Query parameters:
|
||||
|
||||
- `units`: `metric` | `us`
|
||||
- `format`: `json` | `xml` | `text`
|
||||
- `precision`: `0..2`
|
||||
- `tz` or `TZ`: timezone selector
|
||||
|
||||
Day-slice routes:
|
||||
|
||||
- `/today` returns periods with `period.startTime` in the current calendar day for the resolved timezone.
|
||||
- `/tomorrow` returns periods with `period.startTime` in the next calendar day for the resolved timezone.
|
||||
|
||||
Response `data` fields:
|
||||
|
||||
- Run-level:
|
||||
- `locationId` (string, optional)
|
||||
- `locationName` (string, optional)
|
||||
- `issuedAt` (RFC3339 datetime, required)
|
||||
- `updatedAt` (RFC3339 datetime, optional)
|
||||
- `product` (string, required; e.g. `hourly`, `narrative`)
|
||||
- `latitude`, `longitude` (number, optional)
|
||||
- Metric mode: `elevationMeters` (number, optional)
|
||||
- US mode: `elevationFeet` (number, optional)
|
||||
- `periods` (array, required)
|
||||
- Period fields:
|
||||
- `startTime`, `endTime` (RFC3339 datetime, required)
|
||||
- `name` (string, optional)
|
||||
- `isDay` (boolean, optional)
|
||||
- `conditionCode` (integer WMO code, optional)
|
||||
- `textDescription` (string, optional)
|
||||
- Metric mode (optional):
|
||||
- `temperatureC`, `temperatureCMin`, `temperatureCMax`, `dewpointC`, `windSpeedKmh`, `windGustKmh`, `barometricPressurePa`, `visibilityMeters`, `apparentTemperatureC`, `cloudCoverPercent`, `probabilityOfPrecipitationPercent`, `precipitationAmountMm`, `snowfallDepthMM`, `uvIndex`, `relativeHumidityPercent`, `windDirectionDegrees`
|
||||
- US mode (optional):
|
||||
- `temperatureF`, `temperatureFMin`, `temperatureFMax`, `dewpointF`, `windSpeedMph`, `windGustMph`, `barometricPressureInHg`, `visibilityMiles`, `apparentTemperatureF`, `cloudCoverPercent`, `probabilityOfPrecipitationPercent`, `precipitationAmountIn`, `snowfallDepthIn`, `uvIndex`, `relativeHumidityPercent`, `windDirectionDegrees`
|
||||
|
||||
Notes:
|
||||
|
||||
- Narrative periods may omit `conditionCode`.
|
||||
- Text format uses forecast-specific templates (`hourly` and `narrative`).
|
||||
|
||||
## Discussion endpoints
|
||||
|
||||
- `GET /discussion`
|
||||
- `GET /discussion/key-messages`
|
||||
- `GET /discussion/short-term`
|
||||
- `GET /discussion/long-term`
|
||||
|
||||
Query parameters:
|
||||
|
||||
- `units`: `metric` | `us` (accepted; does not materially alter discussion payload)
|
||||
- `format`: `json` | `xml` | `text`
|
||||
- `tz` or `TZ`: timezone selector
|
||||
|
||||
Response `data` fields:
|
||||
|
||||
- `/discussion`:
|
||||
- `officeId` (string, optional)
|
||||
- `officeName` (string, optional)
|
||||
- `product` (string, required)
|
||||
- `issuedAt` (RFC3339 datetime, required)
|
||||
- `updatedAt` (RFC3339 datetime, optional)
|
||||
- `keyMessages` (array of string)
|
||||
- `shortTerm` (object, optional)
|
||||
- `longTerm` (object, optional)
|
||||
- `/discussion/key-messages`:
|
||||
- `officeId`, `officeName`, `product`, `issuedAt`, `updatedAt`
|
||||
- `keyMessages` (array of string)
|
||||
- `/discussion/short-term`:
|
||||
- `officeId`, `officeName`, `product`, `issuedAt`, `updatedAt`
|
||||
- `shortTerm` (object, optional)
|
||||
- `/discussion/long-term`:
|
||||
- `officeId`, `officeName`, `product`, `issuedAt`, `updatedAt`
|
||||
- `longTerm` (object, optional)
|
||||
|
||||
Discussion section object fields:
|
||||
|
||||
- `title` (string, optional)
|
||||
- `narrative` (string, optional)
|
||||
- `issuedAt` (RFC3339 datetime, optional)
|
||||
|
||||
## Examples
|
||||
|
||||
### Observation (JSON, metric)
|
||||
|
||||
```http
|
||||
GET /observations?format=json&units=metric&precision=1
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"stationId": "KSTL",
|
||||
"timestamp": "2026-05-29T14:00:00Z",
|
||||
"conditionCode": 3,
|
||||
"isDay": true,
|
||||
"textDescription": "Partly cloudy",
|
||||
"temperatureC": 24.4,
|
||||
"windSpeedKmh": 17.2,
|
||||
"relativeHumidityPercent": 56.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Alerts (JSON)
|
||||
|
||||
```http
|
||||
GET /alerts/active?format=json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"asOf": "2026-05-29T14:00:00Z",
|
||||
"alerts": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Current conditions (JSON, US)
|
||||
|
||||
```http
|
||||
GET /conditions/current?format=json&units=us&precision=1
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"conditionText": "Partly cloudy",
|
||||
"isDay": true,
|
||||
"temperatureF": 75.9,
|
||||
"apparentTemperatureF": 76.1,
|
||||
"windSpeedMph": 10.7,
|
||||
"relativeHumidityPercent": 56.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Forecast narrative (JSON, optional `conditionCode`)
|
||||
|
||||
```http
|
||||
GET /forecast/narrative?format=json&units=metric&precision=1&tz=America/Chicago
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"locationId": "nws-lsx-grid-90-74",
|
||||
"issuedAt": "2026-05-29T10:30:00-05:00",
|
||||
"product": "narrative",
|
||||
"periods": [
|
||||
{
|
||||
"startTime": "2026-05-29T13:00:00-05:00",
|
||||
"endTime": "2026-05-29T19:00:00-05:00",
|
||||
"name": "Today",
|
||||
"isDay": true,
|
||||
"textDescription": "Partly sunny, with a high near 81.",
|
||||
"temperatureC": 27.2,
|
||||
"windSpeedKmh": 18.0,
|
||||
"probabilityOfPrecipitationPercent": 10.0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Forecast hourly today (text)
|
||||
|
||||
```http
|
||||
GET /forecast/hourly/today?format=text&units=us&precision=1&tz=CDT
|
||||
```
|
||||
|
||||
```text
|
||||
<plain text forecast output>
|
||||
```
|
||||
|
||||
### Discussion key messages (JSON)
|
||||
|
||||
```http
|
||||
GET /discussion/key-messages?format=json&tz=Chicago
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"officeId": "LSX",
|
||||
"product": "discussion",
|
||||
"issuedAt": "2026-05-29T09:25:00-05:00",
|
||||
"keyMessages": [
|
||||
"Scattered showers possible this evening.",
|
||||
"Warmer temperatures this weekend."
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Invalid timezone error example
|
||||
|
||||
```http
|
||||
GET /forecast/narrative?tz=not-a-timezone
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "invalid_parameter",
|
||||
"message": "tz must be a valid timezone"
|
||||
}
|
||||
}
|
||||
```
|
||||
The adapter intentionally keeps upstream transport and envelope details inside
|
||||
`internal/adapters/weatherapi`; downstream packages consume the normalized
|
||||
bundle.
|
||||
|
||||
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,64 @@ This document describes the implemented briefing package boundary.
|
||||
## Purpose
|
||||
|
||||
`internal/briefing` builds structured report-specific briefing packages from
|
||||
forecast summaries and report metadata. The package currently implements Daily
|
||||
Today, Daily Tomorrow, 3-Day Outlook, Weekend Outlook, and Storm Report
|
||||
briefing content.
|
||||
resolved report metadata, forecast bundles, and derived forecast summaries.
|
||||
Briefings are curated inputs for prompt data packages, not rendered report
|
||||
prose.
|
||||
|
||||
## Inputs and Outputs
|
||||
## Inputs And Outputs
|
||||
|
||||
Inputs:
|
||||
|
||||
- resolved report definition and valid period
|
||||
- forecast bundle
|
||||
- derived forecast summary or summaries
|
||||
- configured units and timezone
|
||||
- resolved report definition, generation time, timezone, and valid period
|
||||
- forecast bundle with source provenance and warnings
|
||||
- derived daily or period summaries where required
|
||||
- configured units, timezone, and descriptive location context
|
||||
|
||||
Output:
|
||||
Outputs:
|
||||
|
||||
- `briefing.Package` JSON containing common metadata and report-specific
|
||||
briefing content
|
||||
- `briefing.Package` with common metadata and one report-specific content
|
||||
object for Daily, 3-Day, Weekend, or Storm Report
|
||||
- optional `currentConditions` prompt context from normalized
|
||||
`/conditions/current` data when available
|
||||
- optional JSON file written by `briefing.Save`
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Briefings are structured weather facts and context for later prompt input.
|
||||
- This package does not fetch weather data, compare prior snapshots, build
|
||||
`scriptorium` data packages, or render final report prose.
|
||||
- This package selects and shapes weather facts for prompts.
|
||||
- It does not fetch weather data, compare prior snapshots, build
|
||||
`data_package` files, invoke Scriptorium, or write workflow metadata.
|
||||
|
||||
## Behavior
|
||||
## Config Fields Used
|
||||
|
||||
- Common metadata includes schema version, RunID, report ID, variant, prompt ID,
|
||||
generation time, units, timezone, valid period, source location, source
|
||||
provenance, hashes, and source warnings.
|
||||
- Daily content includes bottom-line inputs, daypart summaries, relevant alerts,
|
||||
outdoor window inputs, narrative periods, discussion context, and weather
|
||||
story context when available.
|
||||
- Daily Tomorrow also includes planning inputs for morning readiness,
|
||||
commute/school/workday concerns, and what may change overnight.
|
||||
- 3-Day content includes one summary per local day or partial day, with overall
|
||||
character, temperature range, precipitation, wind, risk, outdoor-window, and
|
||||
alert inputs, plus broader discussion and weather-story context when
|
||||
available.
|
||||
- Weekend content uses the same daily outlook summaries and adds planning
|
||||
inputs for best outdoor windows, worst weather windows, rain/storm timing,
|
||||
comfort concerns, and confidence or uncertainty context.
|
||||
- Storm content uses the explicit event window and includes event headline
|
||||
inputs, hazards, most-likely scenario inputs, reasonable worst-case inputs,
|
||||
confidence and uncertainty inputs, watch items, active alerts, relevant
|
||||
hourly and narrative forecast periods, and available discussion or weather
|
||||
story context.
|
||||
- Briefing JSON is written atomically by `briefing.Save`.
|
||||
The package receives configured units and timezone from the app layer. Daypart
|
||||
configuration is consumed by `internal/forecast` before briefing builders run.
|
||||
Configured `location` values are prompt context only; Weather API
|
||||
`sourceLocationId` and `sourceLocation` remain source provenance.
|
||||
Current conditions are copied from the normalized `/conditions/current` bundle
|
||||
source only; observation station and timestamp fields remain provenance.
|
||||
|
||||
## External Adapters Used
|
||||
|
||||
None directly.
|
||||
|
||||
## State Or Manifest Behavior
|
||||
|
||||
`briefing.Save` writes briefing JSON atomically. Managed workspace placement is
|
||||
owned by `internal/state`.
|
||||
|
||||
## Skip And Resume Behavior
|
||||
|
||||
None. Builders either return a complete briefing package or an error.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- Daily briefing construction requires a Daily report definition and a derived
|
||||
daily forecast summary.
|
||||
- Daily briefing construction requires a Daily report definition and derived
|
||||
daily summary.
|
||||
- 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
|
||||
least one derived daily summary in the weekend period.
|
||||
- Storm briefing construction requires a Storm Report definition and a forecast
|
||||
least one derived summary.
|
||||
- Storm briefing construction requires a Storm Report definition and forecast
|
||||
bundle.
|
||||
- Save failures include path and operation context.
|
||||
|
||||
@@ -74,11 +75,11 @@ Inspect:
|
||||
- `internal/briefing/weekend_test.go`
|
||||
- `internal/briefing/storm_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
- `internal/cli/root_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Weather facts come from normalized and derived source data.
|
||||
- Briefing output remains JSON-inspectable.
|
||||
- LLM prompt input packaging and `scriptorium` execution remain outside this
|
||||
- Briefings contain structured weather facts and source context.
|
||||
- Common metadata includes RunID, report ID, prompt ID, valid period, source
|
||||
provenance, source hashes, source warnings, and configured prompt location.
|
||||
- LLM prompt input packaging and Scriptorium execution remain outside this
|
||||
boundary.
|
||||
|
||||
@@ -1,62 +1,61 @@
|
||||
# Changes Internals
|
||||
|
||||
This document describes the implemented structured change comparison boundary.
|
||||
This document describes structured Recent Changes comparison.
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/changes` compares current and prior structured briefing snapshots and
|
||||
produces compact change records for prompt input data packages.
|
||||
`internal/changes` compares current and prior briefing packages and emits
|
||||
compact change records for prompt input data packages.
|
||||
|
||||
## Inputs and Outputs
|
||||
## Inputs And Outputs
|
||||
|
||||
Inputs:
|
||||
|
||||
- prior briefing package
|
||||
- current briefing package
|
||||
- configured Recent Changes thresholds
|
||||
- comparison thresholds from configuration
|
||||
|
||||
Output:
|
||||
Outputs:
|
||||
|
||||
- ordered `changes.Change` records with type, message, previous value, and
|
||||
current value where useful
|
||||
- ordered `changes.Change` items with type, message, previous value, and current
|
||||
value where useful
|
||||
|
||||
## Boundaries
|
||||
|
||||
- This package compares structured briefing data only.
|
||||
- It does not read state directly, render Markdown, invoke `scriptorium`, or
|
||||
compare generated report text.
|
||||
- It does not read filesystem state, find prior snapshots, render Markdown,
|
||||
invoke Scriptorium, or compare generated report text.
|
||||
|
||||
## Config Fields Used
|
||||
|
||||
The app maps these config fields into comparison thresholds:
|
||||
The app maps these fields into comparison thresholds:
|
||||
|
||||
- `recent_change.temperature_degrees`
|
||||
- `recent_change.precip_probability_points`
|
||||
- `recent_change.wind_gust_miles_per_hour`
|
||||
- `recent_change.precip_timing_shift_minutes`
|
||||
|
||||
## Behavior
|
||||
## External Adapters Used
|
||||
|
||||
Daily, 3-Day, and Weekend comparison currently detect:
|
||||
None.
|
||||
|
||||
- temperature changes crossing configured thresholds
|
||||
- precipitation probability and timing changes
|
||||
- alert additions and removals
|
||||
- peak wind gust changes
|
||||
- snow, ice, and thunder risk changes
|
||||
## State Or Manifest Behavior
|
||||
|
||||
When no prior comparable snapshot exists, the app sends an empty Recent Changes
|
||||
section in the data package. Daily Today and Daily Tomorrow are compatible for
|
||||
same-valid-date comparison through the report registry. 3-Day Outlook compares
|
||||
with prior 3-Day Outlook snapshots for the same valid local date. Weekend
|
||||
Outlook compares with prior Weekend Outlook snapshots for the same weekend
|
||||
window.
|
||||
None directly. The app loads prior briefing snapshots through `internal/state`
|
||||
before calling comparison functions.
|
||||
|
||||
## Skip And Resume Behavior
|
||||
|
||||
No resume behavior. When the app has no prior comparable snapshot, it sends an
|
||||
empty Recent Changes list without calling a comparison function.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
Daily comparison requires both inputs to contain Daily briefing content. 3-Day
|
||||
comparison requires both inputs to contain 3-Day briefing content. Weekend
|
||||
comparison requires both inputs to contain Weekend briefing content.
|
||||
- Daily comparison requires both inputs to contain Daily briefing content.
|
||||
- 3-Day comparison requires both inputs to contain 3-Day briefing content.
|
||||
- Weekend comparison requires both inputs to contain Weekend briefing content.
|
||||
- Storm Report currently has no comparison implementation, so the app leaves
|
||||
Recent Changes empty for Storm reports.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -70,5 +69,6 @@ Inspect:
|
||||
## Invariants
|
||||
|
||||
- Recent Changes are based on structured snapshots, not Markdown report text.
|
||||
- Comparison thresholds come from configuration.
|
||||
- The comparison output remains compact enough for prompt input.
|
||||
- Report compatibility is determined outside this package by report definitions
|
||||
and state lookup.
|
||||
- Output stays compact enough for prompt input.
|
||||
|
||||
@@ -1,57 +1,66 @@
|
||||
# Forecast Derivation Internals
|
||||
|
||||
This document describes the implemented deterministic forecast summarization
|
||||
boundary.
|
||||
This document describes deterministic forecast summarization in
|
||||
`internal/forecast`.
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/forecast` converts a normalized forecast bundle into inspectable
|
||||
daily and multi-day daypart summaries. These summaries are structured data for
|
||||
later briefing builders; they are not rendered report text.
|
||||
`internal/forecast` converts normalized bundle data into daily and period
|
||||
summaries used by briefing builders.
|
||||
|
||||
## Inputs and Outputs
|
||||
## Inputs And Outputs
|
||||
|
||||
Inputs:
|
||||
|
||||
- `forecast.Bundle`
|
||||
- local date and timezone
|
||||
- report period, for multi-day summaries
|
||||
- configured daypart definitions with `HH:MM` start and end values
|
||||
- local date or resolved report period
|
||||
- timezone
|
||||
- configured daypart definitions
|
||||
|
||||
Output:
|
||||
Outputs:
|
||||
|
||||
- `forecast.DailySummary` with a civil-day period, daypart summaries, selected
|
||||
narrative periods, alert overlaps, discussion context, source warnings, and
|
||||
source provenance.
|
||||
- `forecast.BuildPeriodDailySummaries` output with one clipped daily summary
|
||||
for each local day or partial day in a report period.
|
||||
- `forecast.DailySummary` for one local civil day
|
||||
- one clipped daily summary per local day or partial day from
|
||||
`BuildPeriodDailySummaries`
|
||||
- daypart summaries with selected hourly periods, ranges, timed maximums,
|
||||
conditions, indicators, and alert overlaps
|
||||
|
||||
## Boundaries
|
||||
|
||||
- This package groups and summarizes already-normalized forecast data.
|
||||
- It does not fetch weather data, resolve report definitions, compare prior
|
||||
snapshots, build prompt input packages, or call `scriptorium`.
|
||||
- This package groups, selects, and summarizes already-normalized forecast
|
||||
data.
|
||||
- It does not perform HTTP calls, parse CLI flags, resolve report definitions,
|
||||
compare prior snapshots, build prompt input packages, or invoke Scriptorium.
|
||||
|
||||
## Behavior
|
||||
## Config Fields Used
|
||||
|
||||
- Daypart windows use half-open intervals.
|
||||
- Overnight dayparts are supported when the end clock is not after the start
|
||||
clock.
|
||||
- Hourly forecast periods are selected by overlap with the daypart window.
|
||||
- Each daypart computes temperature range, apparent-temperature range, maximum
|
||||
precipitation probability, peak wind speed, peak wind gust, dominant
|
||||
condition, notable conditions, and basic weather indicators.
|
||||
- Alerts are selected by overlap with the daily period and each daypart.
|
||||
- Narrative periods and discussion context are selected as broader source
|
||||
context for later briefing builders.
|
||||
- Multi-day period summaries clip the first and last local days to the resolved
|
||||
report period before selecting hourly periods and alerts.
|
||||
- `dayparts[].name`
|
||||
- `dayparts[].start`
|
||||
- `dayparts[].end`
|
||||
|
||||
Threshold constants for basic indicators live in forecast code rather than
|
||||
configuration.
|
||||
|
||||
## External Adapters Used
|
||||
|
||||
None directly. Forecast data arrives through `forecast.Bundle`.
|
||||
|
||||
## 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
|
||||
|
||||
- Missing hourly forecast data returns an error.
|
||||
- Invalid daypart definitions return actionable parse errors.
|
||||
- Alert records without parseable RFC3339 start/end fields are skipped.
|
||||
- A nil bundle or missing hourly forecast data returns an error.
|
||||
- Invalid daypart definitions return parse errors with context.
|
||||
- Alert records without parseable RFC3339 timing are skipped.
|
||||
- Empty selected periods produce empty summaries rather than generated prose.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -62,7 +71,6 @@ Inspect:
|
||||
|
||||
## Invariants
|
||||
|
||||
- Weather facts come from normalized source data, not generated prose.
|
||||
- Outputs remain JSON-inspectable.
|
||||
- Forecast derivation remains independent of CLI, HTTP adapters, and report
|
||||
registry behavior.
|
||||
- Go owns report-period selection and meteorological summarization.
|
||||
- Weather facts come from normalized source data.
|
||||
- Outputs remain JSON-inspectable and independent of CLI, state, and adapters.
|
||||
|
||||
@@ -1,54 +1,69 @@
|
||||
# Prompt Input Internals
|
||||
|
||||
This document describes the implemented prompt input package boundary.
|
||||
This document describes prompt input data package construction.
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/promptinput` converts a structured briefing package into the
|
||||
`data_package` JSON file passed to `scriptorium` prompts.
|
||||
`internal/promptinput` converts a structured briefing package and optional
|
||||
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,
|
||||
or Storm Report content
|
||||
- `briefing.Package`
|
||||
- optional `[]changes.Change`
|
||||
|
||||
Output:
|
||||
Outputs:
|
||||
|
||||
- `promptinput.Package` JSON with report metadata, briefing content, source
|
||||
warnings, RunID, and a Recent Changes section.
|
||||
- `promptinput.Package` containing schema version, RunID, report metadata,
|
||||
briefing content, Recent Changes, and source warnings. Briefing content
|
||||
includes configured location context, current conditions 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
|
||||
|
||||
- This package owns the prompt input schema and required-field validation.
|
||||
- It does not fetch weather data, compute forecast summaries, compare prior
|
||||
snapshots, or invoke `scriptorium`.
|
||||
- This package owns the prompt input schema and validation.
|
||||
- It does not fetch weather data, derive forecast summaries, find prior
|
||||
snapshots, compare changes, or invoke Scriptorium.
|
||||
|
||||
## Behavior
|
||||
## Config Fields Used
|
||||
|
||||
- `promptinput.Build` copies report metadata from the briefing package.
|
||||
- `promptinput.Validate` rejects missing or inconsistent required fields before
|
||||
render preflight.
|
||||
- `promptinput.Save` writes JSON atomically where practical.
|
||||
- Recent Changes is present as an `items` list. It is empty when no prior
|
||||
comparable snapshot exists or no meaningful changes are detected.
|
||||
None directly. Config-derived values, including timezone and prompt location
|
||||
context, are already present in briefing metadata before this package runs.
|
||||
|
||||
## External Adapters Used
|
||||
|
||||
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
|
||||
|
||||
Validation errors name the missing or inconsistent field. Save failures include
|
||||
the filesystem operation and path context.
|
||||
Validation fails before render preflight when required top-level or briefing
|
||||
metadata fields are missing or inconsistent, or when no report content is
|
||||
present. Save failures include filesystem operation and path context.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/promptinput/package_test.go`
|
||||
- `internal/changes/daily_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Prompt input data remains structured JSON.
|
||||
- Scriptorium receives structured `data_package` JSON.
|
||||
- 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
|
||||
|
||||
This document describes the implemented report identity and valid-period
|
||||
boundary.
|
||||
This document describes report identity, valid-period resolution, batch
|
||||
membership, output naming, artifact grouping, and comparison declarations in
|
||||
`internal/report`.
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/report` centralizes report IDs, prompt IDs, comparison strategies,
|
||||
valid-period resolution, report metadata, and scheduled batch membership.
|
||||
`internal/report` is the canonical source for report definitions. App, state,
|
||||
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
|
||||
- generation time
|
||||
- configured timezone
|
||||
- optional Daily date override
|
||||
- optional manual storm start and end times
|
||||
- report ID and display name
|
||||
- Scriptorium prompt ID
|
||||
- valid-period resolver
|
||||
- comparison strategy
|
||||
- managed artifact group
|
||||
- batch output copy filename
|
||||
- generated-report eligibility
|
||||
- prior-report compatibility list
|
||||
- morning or evening batch membership
|
||||
|
||||
Outputs:
|
||||
## Implemented Reports
|
||||
|
||||
- `report.Resolved` values with definition metadata and half-open valid periods
|
||||
- `report.Metadata` values suitable for later persisted run metadata
|
||||
| Report | ID | Prompt | Artifact group | Batch copy | Prior compatibility |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Daily Today | `daily_today` | `weather.daily_report` | `daily` | `daily.md` | Daily Today, Daily Tomorrow |
|
||||
| Daily Tomorrow | `daily_tomorrow` | `weather.daily_report` | `daily` | `tomorrow.md` | Daily Today, Daily Tomorrow |
|
||||
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `three-day` | `three-day.md` | 3-Day Outlook |
|
||||
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `weekend` | `weekend.md` | Weekend Outlook |
|
||||
| Storm Report | `storm` | `weather.storm_report` | `storm` | `storm.md` | Storm Report |
|
||||
|
||||
All 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
|
||||
|
||||
- This package defines report identity and time coverage only.
|
||||
- It does not fetch weather data, build briefings, compare snapshots, write
|
||||
state, or call `scriptorium`.
|
||||
`internal/report` defines report metadata and time coverage. It does not fetch
|
||||
weather data, build briefings, compare briefing contents, write state, parse CLI
|
||||
flags, or invoke Scriptorium.
|
||||
|
||||
## Behavior
|
||||
The CLI owns public command names. The app maps those command names to report
|
||||
IDs, then uses the registry for report policy.
|
||||
|
||||
- Daily Today covers one configured local civil day.
|
||||
- Daily Tomorrow covers the next configured local civil day.
|
||||
- 3-Day Outlook covers generation time through local midnight after the second
|
||||
following local civil day.
|
||||
- Weekend Outlook covers Saturday 00:00 to Monday 00:00 Monday through
|
||||
Thursday; Friday and Saturday cover the remaining weekend from Friday 18:00
|
||||
or generation time, whichever is later.
|
||||
- Manual Storm Report uses explicit start and end times.
|
||||
- Morning batch resolves Daily Today and 3-Day Outlook, plus Weekend Outlook
|
||||
except on Sunday.
|
||||
- Evening batch resolves Daily Tomorrow.
|
||||
## Config Fields Used
|
||||
|
||||
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
|
||||
output path copying uses batch output names from report definitions.
|
||||
|
||||
## State And App Usage
|
||||
|
||||
- State paths use `ArtifactGroup`.
|
||||
- Batch output copies use `BatchOutputName`.
|
||||
- Generation checks `Generated`.
|
||||
- Prior lookup checks `CompatiblePriorIDs` and the comparison strategy.
|
||||
- RunIDs include the resolved report ID.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- Unknown report and batch names return actionable errors.
|
||||
- Sunday Weekend Outlook resolution returns an error.
|
||||
- Storm windows require start and end, with end after start.
|
||||
- Unknown report IDs and batch names return actionable errors.
|
||||
- Weekend Outlook resolution returns an error when resolved directly on Sunday.
|
||||
- Storm Report resolution requires start and end, with end after start.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -55,9 +85,13 @@ Inspect:
|
||||
|
||||
- `internal/report/period_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
- `internal/cli/root_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Report selection goes through the registry.
|
||||
- Valid periods are independent of rendered report text.
|
||||
- Prompt IDs and comparison strategies are declared with report definitions.
|
||||
- Daily Today and Daily Tomorrow both use `weather.daily_report`.
|
||||
- Valid periods are half-open intervals independent of rendered report text.
|
||||
- Artifact grouping, batch output filenames, generated-report eligibility,
|
||||
comparison compatibility, and comparison strategy are declared by report
|
||||
definition.
|
||||
|
||||
@@ -1,59 +1,88 @@
|
||||
# Scriptorium Adapter Internals
|
||||
|
||||
This document describes the implemented `scriptorium` subprocess adapter.
|
||||
This document describes the subprocess adapter in
|
||||
`internal/adapters/scriptorium`.
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/adapters/scriptorium` runs `scriptorium render` to preflight prompt
|
||||
wiring and `scriptorium run` to generate report artifacts.
|
||||
The adapter runs `scriptorium render` for prompt preflight and `scriptorium run`
|
||||
for Markdown report generation. It isolates subprocess execution, argv
|
||||
construction, timeout handling, output capture, and exit-code interpretation
|
||||
from app and domain packages.
|
||||
|
||||
## Inputs and Outputs
|
||||
## Inputs And Outputs
|
||||
|
||||
Input:
|
||||
Inputs:
|
||||
|
||||
- prompt ID
|
||||
- prompt input data package path
|
||||
- report output path for `run`
|
||||
- configured binary, config path, profile, timeout, and extra arguments
|
||||
- context for cancellation
|
||||
|
||||
Output:
|
||||
Outputs:
|
||||
|
||||
- captured stdout, with truncation tracking
|
||||
- captured stderr, with truncation tracking
|
||||
- argv used for execution
|
||||
- captured stdout and stderr
|
||||
- truncation flags for captured output
|
||||
- exit code
|
||||
- full argv used for inspection
|
||||
- report output path for `run`
|
||||
|
||||
## Boundaries
|
||||
|
||||
- This adapter owns `scriptorium` CLI flag construction and subprocess
|
||||
execution.
|
||||
- It does not choose report types, build prompt input, fetch weather data, or
|
||||
decide workflow order.
|
||||
`internal/adapters/scriptorium` owns Scriptorium command construction and
|
||||
subprocess execution. It does not choose report types, build prompt input,
|
||||
fetch weather data, decide workflow order, or persist workflow metadata.
|
||||
|
||||
## Behavior
|
||||
The adapter exposes request and result structs for render and run operations.
|
||||
State persistence uses a state-owned preflight artifact shape; app
|
||||
orchestration converts render results before saving.
|
||||
|
||||
The render invocation shape is:
|
||||
## Config Fields Used
|
||||
|
||||
- `scriptorium.binary`
|
||||
- `scriptorium.config_path`
|
||||
- `scriptorium.profile`
|
||||
- `scriptorium.timeout`
|
||||
- `scriptorium.extra_args`
|
||||
|
||||
## Commands
|
||||
|
||||
Render preflight argv starts with:
|
||||
|
||||
```text
|
||||
scriptorium render --prompt <prompt_id> --input data_package=<path> --format json
|
||||
```
|
||||
|
||||
The run invocation shape is:
|
||||
Report generation argv starts with:
|
||||
|
||||
```text
|
||||
scriptorium run --prompt <prompt_id> --input data_package=<path> --out <artifact_path>
|
||||
scriptorium run --prompt <prompt_id> --input data_package=<path> --out <path>
|
||||
```
|
||||
|
||||
Configured `--config` and `--profile` values are added when present. Arguments
|
||||
are passed directly as argv, not through a shell. Stdout and stderr are captured
|
||||
separately. `SaveRenderResult` writes the captured result as JSON for inspection.
|
||||
Configured `--config` and `--profile` flags are inserted after the subcommand
|
||||
and before prompt-specific arguments. Extra arguments are appended after the
|
||||
built-in arguments.
|
||||
|
||||
## Execution Behavior
|
||||
|
||||
The adapter runs commands without shell interpolation. The same private
|
||||
execution path is used by render and run after command-specific request
|
||||
validation and argv construction.
|
||||
|
||||
When `scriptorium.timeout` is greater than zero, each subprocess call uses a
|
||||
context with that timeout. Stdout and stderr are captured separately, capped at
|
||||
1 MiB each, and marked as truncated when the cap is reached.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
Nonzero render and run exits return both the captured result and an error
|
||||
containing the exit code and stderr. Run exit code `2` is treated as an error
|
||||
but may still produce a report artifact. Command execution respects context
|
||||
cancellation and the configured timeout.
|
||||
- Missing prompt ID or data package path returns an error before subprocess
|
||||
execution.
|
||||
- Missing run output path returns an error before subprocess execution.
|
||||
- Subprocess start errors, context cancellation, and timeouts are wrapped with
|
||||
operation context by the caller-facing method.
|
||||
- Nonzero render and run exits return the captured result plus an error
|
||||
containing the exit code and stderr.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -65,7 +94,7 @@ Inspect:
|
||||
|
||||
## Invariants
|
||||
|
||||
- `scriptorium` details stay inside the adapter package.
|
||||
- The input name for prompt packages is always `data_package`.
|
||||
- Render preflight remains orchestration behavior; this adapter only exposes the
|
||||
subprocess operations.
|
||||
- No shell interpolation is used.
|
||||
- The Scriptorium input name is `data_package`.
|
||||
- Render and run preserve command-specific result structs.
|
||||
- Scriptorium-specific flags stay inside adapter and config boundaries.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# State Internals
|
||||
|
||||
This document describes the implemented filesystem state boundary.
|
||||
This document describes filesystem state in `internal/state`.
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/state` owns durable artifact paths, atomic JSON writes, metadata, and
|
||||
prior comparable snapshot lookup.
|
||||
`internal/state` owns managed workspace paths, atomic JSON writes, persisted
|
||||
metadata, prior snapshot lookup, and read-only artifact inspection helpers.
|
||||
|
||||
## Inputs and Outputs
|
||||
## Inputs And Outputs
|
||||
|
||||
Inputs:
|
||||
|
||||
@@ -15,26 +15,30 @@ Inputs:
|
||||
- resolved report definition and valid period
|
||||
- briefing package
|
||||
- prompt input data package
|
||||
- `scriptorium render` result
|
||||
- rendered report path preparation
|
||||
- preflight artifact
|
||||
- rendered report path preparation request
|
||||
- RunID for inspection lookups
|
||||
|
||||
Outputs:
|
||||
|
||||
- briefing snapshot JSON
|
||||
- prompt input data package JSON
|
||||
- render preflight JSON
|
||||
- Markdown report path
|
||||
- metadata JSON
|
||||
- prior comparable snapshot metadata when available
|
||||
- prior briefing package when loaded by path
|
||||
- briefing snapshot JSON path
|
||||
- prompt input data package JSON path
|
||||
- render preflight JSON path
|
||||
- managed Markdown report path
|
||||
- metadata JSON path
|
||||
- prior comparable snapshot metadata
|
||||
- loaded briefing or data package
|
||||
- recent report records for inspection
|
||||
- metadata and data package lookup by RunID
|
||||
|
||||
## Boundaries
|
||||
|
||||
- This package owns managed workspace layout and narrow path validation.
|
||||
- It does not fetch weather data, derive forecasts, build prompt inputs, invoke
|
||||
`scriptorium`, or compare briefing contents.
|
||||
`internal/state` owns local filesystem layout, path validation, durable writes,
|
||||
metadata reads, prior lookup, and report listing. It does not fetch weather
|
||||
data, derive forecasts, build prompt input content, compare 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
|
||||
|
||||
@@ -47,25 +51,56 @@ Outputs:
|
||||
Workspace subdirectories must be relative paths that stay under
|
||||
`workspace.root`.
|
||||
|
||||
## State Behavior
|
||||
## Managed Layout
|
||||
|
||||
Managed artifact names use RunID, which is generated from report generation time
|
||||
and report ID. Metadata is stored beside briefing snapshots by report group and
|
||||
valid local date. Prior snapshot lookup reads metadata for the same valid local
|
||||
date and returns the latest earlier compatible run. Daily Today and Daily
|
||||
Tomorrow are compatible with each other; 3-Day Outlook is compatible with prior
|
||||
3-Day Outlook snapshots; Weekend Outlook is compatible with prior Weekend
|
||||
Outlook snapshots for the same weekend window. The store can load a briefing
|
||||
snapshot by path for structured comparison. The store can list metadata-backed
|
||||
report records and load metadata or data packages by RunID for inspection. The
|
||||
store prepares the managed Markdown report path before `scriptorium run` writes
|
||||
it.
|
||||
Paths are derived from the resolved report definition's artifact group, the
|
||||
valid-period start date for JSON artifacts, and the RunID.
|
||||
|
||||
```text
|
||||
<workspace.root>/
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.briefing.json
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.metadata.json
|
||||
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.json
|
||||
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json
|
||||
reports/<artifact_group>/<run_id>.md
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
Writes are atomic where practical: JSON is written to a temporary file in the
|
||||
target directory and then renamed into place. Invalid workspace paths and
|
||||
missing required metadata fields produce actionable errors.
|
||||
- Invalid workspace paths return validation errors.
|
||||
- Missing required metadata fields prevent metadata writes.
|
||||
- JSON writes use a temporary file followed by rename where practical.
|
||||
- Read and decode failures include path context.
|
||||
- Unknown RunIDs produce an actionable lookup error.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -77,5 +112,6 @@ Inspect:
|
||||
## Invariants
|
||||
|
||||
- Managed paths stay under the configured workspace root.
|
||||
- Metadata links the artifacts produced for a run.
|
||||
- Artifact grouping comes from report definitions.
|
||||
- Metadata links artifacts produced for a run.
|
||||
- Prior lookup is based on structured metadata, not rendered report text.
|
||||
|
||||
@@ -1,59 +1,78 @@
|
||||
# Weather Data Internals
|
||||
|
||||
This document describes the implemented weather data ingestion boundary.
|
||||
This document describes Weather API ingestion into `forecast.Bundle`.
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/adapters/weatherapi` fetches normalized weather data from one
|
||||
configured weather API endpoint and assembles a `forecast.Bundle`.
|
||||
configured Weather API endpoint and assembles the bundle consumed by forecast
|
||||
derivation and briefing builders. Briefing builders expose normalized current
|
||||
conditions as prompt context when `/conditions/current` is available.
|
||||
|
||||
## Inputs and Outputs
|
||||
## Inputs And Outputs
|
||||
|
||||
Input:
|
||||
Inputs:
|
||||
|
||||
- `config.Config` with `weather_api.base_url`, `format`, `units`, `timezone`,
|
||||
`precision`, timeout, and missing-source policy.
|
||||
- `config.Config` with Weather API URL, timeout, format, units, timezone,
|
||||
precision, and missing-source policy
|
||||
- HTTP responses using the Weather API `data` envelope
|
||||
|
||||
Output:
|
||||
Outputs:
|
||||
|
||||
- `forecast.Bundle` containing observation, current conditions, hourly forecast,
|
||||
narrative forecast, alerts, discussion, stub source slots, provenance, and
|
||||
source warnings.
|
||||
- `forecast.Bundle` with observation, current conditions, hourly forecast,
|
||||
narrative forecast, active alerts, discussion, source records, and source
|
||||
warnings
|
||||
- stub source records for daily forecast and weather story source slots
|
||||
- optional saved bundle JSON through app fetch helpers
|
||||
|
||||
## Boundaries
|
||||
|
||||
- The adapter performs HTTP calls and decoding only.
|
||||
- Forecast derivation, daypart grouping, report periods, report rendering, and
|
||||
`scriptorium` execution are outside this boundary.
|
||||
- Hourly forecast data is required. Other missing or malformed source sections
|
||||
use the configured missing-source policy.
|
||||
- The adapter owns HTTP calls, response-envelope handling, source hashing, and
|
||||
decoding into internal bundle types.
|
||||
- It does not derive dayparts, resolve report periods, build briefings, compare
|
||||
snapshots, write report state, or invoke Scriptorium.
|
||||
|
||||
## External Adapter
|
||||
## Config Fields Used
|
||||
|
||||
The adapter calls:
|
||||
- `weather_api.base_url`
|
||||
- `weather_api.timeout`
|
||||
- `weather_api.format`
|
||||
- `weather_api.units`
|
||||
- `weather_api.timezone`
|
||||
- `weather_api.precision`
|
||||
- `missing_source.default`
|
||||
- `missing_source.sources`
|
||||
|
||||
- `/observations`
|
||||
- `/conditions/current`
|
||||
- `/forecast/hourly`
|
||||
- `/forecast/narrative`
|
||||
- `/alerts/active`
|
||||
- `/discussion`
|
||||
## External Adapters Used
|
||||
|
||||
Forecast routes use the full-product endpoints, not day-slice endpoints.
|
||||
- Weather API HTTP service
|
||||
|
||||
## State
|
||||
See [Weather API integration](../integrations/weatherapi.md) for the external
|
||||
contract used by this project.
|
||||
|
||||
`app.FetchAndSaveBundle` can save an inspectable bundle JSON file using an
|
||||
atomic rename. No report state, snapshots, or prompt input packages are written
|
||||
yet.
|
||||
## State Or Manifest Behavior
|
||||
|
||||
The adapter records source name, endpoint, query, fetch time, source timestamps
|
||||
when available, SHA-256 hash over compact raw `data` JSON, missing status, and
|
||||
source warnings. Successful `data: null` responses from `/alerts/active`
|
||||
represent a checked empty active-alert list, not a missing source.
|
||||
`app.FetchAndSaveBundle` can write bundle JSON atomically for inspection.
|
||||
|
||||
## Skip And Resume Behavior
|
||||
|
||||
No resume behavior. Optional missing or malformed sources may be omitted,
|
||||
warned, or treated as errors according to missing-source policy. Hourly forecast
|
||||
data is required and cannot be skipped.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- HTTP and envelope decode failures return actionable errors with endpoint
|
||||
context.
|
||||
- Missing hourly data fails the fetch.
|
||||
- Missing or malformed optional sources follow `error`, `warn`, or `none`.
|
||||
- Source identity uses SHA-256 over compacted raw `data` JSON.
|
||||
- Missing or invalid `weather_api.base_url` prevents client construction.
|
||||
- HTTP errors, response read failures, and envelope decode failures include
|
||||
endpoint context.
|
||||
- Missing hourly data or hourly forecasts with no periods fail bundle fetch.
|
||||
- Optional and stub sources follow missing-source policy.
|
||||
- Explicit `data: null` from `/alerts/active` produces an empty, non-missing
|
||||
alert run.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -65,5 +84,6 @@ Inspect:
|
||||
## Invariants
|
||||
|
||||
- Weather facts come from normalized source data.
|
||||
- External API details stay inside `internal/adapters/weatherapi`.
|
||||
- Source provenance and warnings remain inspectable for later briefing builders.
|
||||
- Full hourly and narrative products are fetched; Go owns report-period
|
||||
selection.
|
||||
- Source provenance and warnings remain inspectable downstream.
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
# 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
|
||||
|
||||
The implemented generation workflows are:
|
||||
Implemented generation commands:
|
||||
|
||||
```text
|
||||
weatherreporter generate daily --date 2026-05-29
|
||||
@@ -10,22 +14,27 @@ weatherreporter generate tomorrow
|
||||
weatherreporter generate three-day
|
||||
weatherreporter generate weekend
|
||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
|
||||
```
|
||||
|
||||
Each command resolves a report period, fetches a Weather API bundle, builds a
|
||||
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 evening
|
||||
```
|
||||
|
||||
These commands fetch weather data, build a briefing for the resolved valid
|
||||
period, build the prompt input data package, run `scriptorium render`, run
|
||||
`scriptorium run`, and write inspectable artifacts under the configured
|
||||
workspace. The evening run resolves only the Tomorrow Planning Brief. The
|
||||
morning run generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
||||
except on Sunday. Storm Report generation is manual and uses the explicit
|
||||
`--start` and `--end` bounds as its valid period.
|
||||
|
||||
Scheduled run commands print a JSON aggregate summary to stdout and compact
|
||||
per-report status lines to stderr. If one report fails, remaining independent
|
||||
reports are still attempted. The command returns nonzero after the run when any
|
||||
report failed.
|
||||
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
||||
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch
|
||||
commands print a JSON summary to stdout, write compact per-report status lines
|
||||
to stderr, continue independent reports after one report fails, and return
|
||||
nonzero when any report failed. `--out-dir PATH` writes extra Markdown copies
|
||||
using report default filenames such as `daily.md`, `three-day.md`,
|
||||
`weekend.md`, and `tomorrow.md`.
|
||||
|
||||
## Filesystem Layout
|
||||
|
||||
@@ -87,51 +96,37 @@ workspace/
|
||||
<run_id>.md
|
||||
```
|
||||
|
||||
The Markdown report is written to a RunID-managed report path. When `--out` is
|
||||
provided to `generate daily`, `generate tomorrow`, `generate three-day`,
|
||||
`generate weekend`, or `generate storm`, the managed report is also copied to
|
||||
that path.
|
||||
Managed artifact filenames use the RunID, so repeated runs for the same valid
|
||||
period do not overwrite each other.
|
||||
|
||||
For `run morning` and `run evening`, `--out-dir PATH` writes extra Markdown
|
||||
copies using each report definition's default filename, such as `daily.md`,
|
||||
`three-day.md`, `weekend.md`, or `tomorrow.md`.
|
||||
## RunID And Metadata
|
||||
|
||||
## Run Identifiers
|
||||
|
||||
Run IDs are based on generation time plus report ID, such as:
|
||||
RunIDs are based on generation time plus report ID:
|
||||
|
||||
```text
|
||||
20260529T100000.123456789Z_daily_today
|
||||
```
|
||||
|
||||
Managed artifact filenames use the RunID so repeated runs for the same valid
|
||||
date do not overwrite each other.
|
||||
|
||||
## Metadata
|
||||
|
||||
Each generated report writes metadata that links:
|
||||
|
||||
- RunID
|
||||
- report ID and prompt ID
|
||||
- generation time and valid period
|
||||
- RunID, report ID, variant, and prompt ID
|
||||
- generation time, timezone, and valid period
|
||||
- source location, source hashes, and source warnings
|
||||
- briefing snapshot path
|
||||
- prompt input data package path
|
||||
- preflight output path
|
||||
- rendered report path
|
||||
- managed Markdown report path
|
||||
|
||||
Run summaries include each report ID, prompt ID, RunID, status, error text when
|
||||
applicable, valid period, and artifact paths known to the application.
|
||||
Batch summaries include report status, error text when applicable, valid
|
||||
period, and known artifact paths for each attempted report.
|
||||
|
||||
## Inspection
|
||||
|
||||
Use `weatherreporter inspect reports` to list recent generated runs from the
|
||||
configured workspace. The output includes RunID, report ID, valid period,
|
||||
metadata path, briefing path, report path, and source warning count.
|
||||
|
||||
Run-specific inspection commands emit JSON for a single RunID:
|
||||
Inspection commands read existing workspace artifacts and emit JSON to stdout.
|
||||
They do not fetch weather data or run `scriptorium`.
|
||||
|
||||
```text
|
||||
weatherreporter inspect reports --limit 10
|
||||
weatherreporter inspect metadata RUN_ID
|
||||
weatherreporter inspect briefing RUN_ID
|
||||
weatherreporter inspect data-package RUN_ID
|
||||
@@ -139,44 +134,54 @@ weatherreporter inspect prior RUN_ID
|
||||
weatherreporter inspect sources RUN_ID
|
||||
```
|
||||
|
||||
`inspect prior` shows the prior comparable snapshot selected from stored
|
||||
metadata, or `null` when none exists. `inspect sources` shows source provenance
|
||||
and source warnings without dumping full weather payloads.
|
||||
Use `inspect reports` to find recent RunIDs and artifact paths. Use
|
||||
`inspect metadata` to see the artifact links recorded for a run. Use
|
||||
`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
|
||||
|
||||
When a prior comparable Daily briefing snapshot exists for the same valid local
|
||||
date, the app compares structured briefing data before writing the prompt input
|
||||
data package. Daily Today and Daily Tomorrow can compare with each other when
|
||||
they cover the same valid local date. Meaningful changes are included under
|
||||
`recentChanges.items`.
|
||||
Recent Changes are computed from structured briefing snapshots, not rendered
|
||||
Markdown text.
|
||||
|
||||
3-Day Outlook generation compares against a prior compatible 3-Day briefing
|
||||
snapshot for the same valid local date when one exists.
|
||||
|
||||
Weekend Outlook generation compares against a prior compatible Weekend briefing
|
||||
snapshot for the same weekend window when one exists. Friday evening and
|
||||
Saturday runs may narrow the valid start while keeping the same Monday endpoint.
|
||||
|
||||
Storm Report generation currently leaves Recent Changes empty. Its explicit
|
||||
event window is still recorded in briefing and metadata artifacts.
|
||||
Daily Today and Daily Tomorrow can compare with each other when they cover the
|
||||
same valid local date. 3-Day Outlook compares with prior compatible 3-Day
|
||||
snapshots for the same valid local date. Weekend Outlook compares with prior
|
||||
compatible Weekend snapshots for the same weekend window. Storm Report currently
|
||||
leaves Recent Changes empty.
|
||||
|
||||
When no prior comparable snapshot exists, or no configured threshold is crossed,
|
||||
the Recent Changes list is empty.
|
||||
`recentChanges.items` is empty.
|
||||
|
||||
## Recovery
|
||||
|
||||
If render preflight exits nonzero after producing a result, the captured stdout,
|
||||
stderr, exit code, and command are still written to the preflight artifact, and
|
||||
metadata is still written for inspection.
|
||||
A failed generation run may still leave useful artifacts:
|
||||
|
||||
If `scriptorium run` exits nonzero after writing a report, the generated report
|
||||
and metadata remain available for inspection. Exit code `2` is still returned as
|
||||
an error because it indicates validation failed, even if report output exists.
|
||||
- If `scriptorium render` returns a result with a nonzero exit code, the
|
||||
preflight JSON and metadata are written for inspection.
|
||||
- If `scriptorium run` exits nonzero after writing a report, the managed report
|
||||
and metadata remain available.
|
||||
- For batch commands, inspect the stdout JSON summary first, then inspect the
|
||||
artifact paths for each failed report.
|
||||
|
||||
For scheduled runs, inspect stdout first for the aggregate JSON summary, then
|
||||
use the per-report artifact paths in that summary to inspect briefing,
|
||||
data-package, preflight, metadata, and rendered report files.
|
||||
For a bad report, start with:
|
||||
|
||||
The application does not currently implement resume, cleanup, archive, or
|
||||
remote storage behavior.
|
||||
```text
|
||||
weatherreporter inspect metadata RUN_ID
|
||||
weatherreporter inspect sources RUN_ID
|
||||
weatherreporter inspect 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`.
|
||||
|
||||
Unless documented otherwise, precedence is:
|
||||
Configuration precedence is:
|
||||
|
||||
1. CLI flags
|
||||
2. environment variables
|
||||
3. configuration file
|
||||
4. built-in defaults
|
||||
2. configuration file
|
||||
3. built-in defaults
|
||||
|
||||
Prefer YAML configuration unless the project has a strong reason to use another format. Config files should be discovered at `/usr/local/etc/<app_name>/config.yml`, with a CLI override via `--config`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
## Modules, Stages, and Registries
|
||||
## Components and Registries
|
||||
|
||||
When the application has stages or modules, each major stage/module should live in its own package and have an explicit input/output contract.
|
||||
When the application has major workflow components, each component should live
|
||||
near the package that owns its contract and have explicit inputs and outputs.
|
||||
|
||||
The orchestrator should be able to compose, skip, resume, or run individual stages/modules when their prerequisites are satisfied. Ordering should be explicit: use a default sequence, dependency graph, or documented orchestration rule.
|
||||
The orchestrator should compose components in an explicit order using a default
|
||||
sequence, dependency graph, or documented orchestration rule.
|
||||
|
||||
If users can select modules, stages, validators, renderers, or adapters, selection should go through a registry or equivalent mechanism rather than scattered conditionals.
|
||||
If users can select components, validators, renderers, or adapters, selection
|
||||
should go through a registry or equivalent mechanism rather than scattered
|
||||
conditionals.
|
||||
|
||||
## Embedded Assets
|
||||
|
||||
@@ -87,11 +90,15 @@ Use structured logging where practical. Logs should describe operations, paths,
|
||||
|
||||
## Context, Timeouts, and Cancellation
|
||||
|
||||
Long-running operations should accept `context.Context`. External calls, subprocesses, HTTP requests, storage operations, and multi-stage workflows should respect cancellation and timeouts.
|
||||
Long-running operations should accept `context.Context`. External calls,
|
||||
subprocesses, HTTP requests, storage operations, and multi-step workflows should
|
||||
respect cancellation and timeouts.
|
||||
|
||||
## State, Files, and Safety
|
||||
|
||||
If the application writes durable state, writes should be atomic where practical. Multi-step workflows should preserve enough state to support inspection, retry, or resume after failure.
|
||||
If the application writes durable state, writes should be atomic where
|
||||
practical. Multi-step workflows should preserve enough state to support
|
||||
inspection and retry diagnosis after failure.
|
||||
|
||||
Code that deletes, moves, or overwrites files must use narrow, explicit paths. Avoid broad parent-directory operations. Cleanup that can cause data loss must be opt-in.
|
||||
|
||||
@@ -99,11 +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.
|
||||
|
||||
Config examples should be load-tested. Important CLI workflows should have parser or command tests. Stage/module contracts should have focused tests that do not require running the full application unless end-to-end coverage is intentional.
|
||||
Config examples should be load-tested. Important CLI workflows should have
|
||||
parser or command tests. Component contracts should have focused tests that do
|
||||
not require running the full application unless end-to-end coverage is
|
||||
intentional.
|
||||
|
||||
## Documentation
|
||||
|
||||
Documentation should follow the project documentation policy. Keep user docs focused on implemented behavior. Put future, planned, or aspirational work only under `docs/roadmap/`.
|
||||
|
||||
When changing architecture, config, CLI behavior, adapters, or stage/module contracts, update the relevant docs and examples in the same change.
|
||||
|
||||
When changing architecture, config, CLI behavior, adapters, or component
|
||||
contracts, update the relevant docs and examples in the same change.
|
||||
|
||||
@@ -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`.
|
||||
2. Derive report-specific briefing packages from the normalized forecast bundle.
|
||||
3. Compare current briefing snapshots against prior comparable snapshots to produce optional Recent Changes.
|
||||
4. Build structured prompt input data packages for a specific report type.
|
||||
5. Invoke `scriptorium` as an external prompt runner.
|
||||
6. Persist the rendered Markdown report, briefing snapshot, prompt input package, and generation metadata.
|
||||
Use focused checks while editing and broader checks before committing:
|
||||
|
||||
The preferred data flow is:
|
||||
|
||||
```text
|
||||
weatherfeeder-backed internal API
|
||||
-> weather API adapter
|
||||
-> forecast bundle
|
||||
-> report-specific briefing builder
|
||||
-> recent-change comparison
|
||||
-> prompt input data package
|
||||
-> scriptorium subprocess adapter
|
||||
-> Markdown report + metadata + stored snapshot
|
||||
```bash
|
||||
go test ./...
|
||||
go run ./cmd/weatherreporter --help
|
||||
git diff --check
|
||||
```
|
||||
|
||||
The application should not treat the LLM as the source of weather facts. The Go code should select the relevant data, compute daypart and period summaries, attach alerts and NWS context, identify meaningful changes, and send the LLM a curated briefing package. The LLM should synthesize and phrase the report for humans.
|
||||
Useful focused checks:
|
||||
|
||||
## Proposed Directory Layout
|
||||
|
||||
```text
|
||||
cmd/weatherreporter/
|
||||
main.go
|
||||
|
||||
internal/app/
|
||||
generate.go
|
||||
scheduled.go
|
||||
storm.go
|
||||
|
||||
internal/cli/
|
||||
root.go
|
||||
generate.go
|
||||
run.go
|
||||
inspect.go
|
||||
|
||||
internal/config/
|
||||
config.go
|
||||
defaults.go
|
||||
load.go
|
||||
validate.go
|
||||
|
||||
internal/adapters/weatherapi/
|
||||
client.go
|
||||
types.go
|
||||
|
||||
internal/adapters/scriptorium/
|
||||
runner.go
|
||||
types.go
|
||||
|
||||
internal/forecast/
|
||||
bundle.go
|
||||
dayparts.go
|
||||
derive.go
|
||||
select.go
|
||||
thresholds.go
|
||||
|
||||
internal/report/
|
||||
definition.go
|
||||
registry.go
|
||||
period.go
|
||||
daily.go
|
||||
tomorrow.go
|
||||
three_day.go
|
||||
weekend.go
|
||||
storm.go
|
||||
|
||||
internal/briefing/
|
||||
package.go
|
||||
daily.go
|
||||
tomorrow.go
|
||||
three_day.go
|
||||
weekend.go
|
||||
storm.go
|
||||
|
||||
internal/changes/
|
||||
compare.go
|
||||
thresholds.go
|
||||
summary.go
|
||||
|
||||
internal/state/
|
||||
store.go
|
||||
filesystem.go
|
||||
metadata.go
|
||||
|
||||
internal/promptinput/
|
||||
build.go
|
||||
schema.go
|
||||
|
||||
internal/timeutil/
|
||||
clock.go
|
||||
periods.go
|
||||
```bash
|
||||
go test ./internal/cli ./internal/config
|
||||
go test ./internal/app ./internal/state
|
||||
go test ./internal/adapters/weatherapi ./internal/adapters/scriptorium
|
||||
go test ./internal/forecast ./internal/report ./internal/briefing ./internal/changes ./internal/promptinput
|
||||
```
|
||||
|
||||
This layout can be simplified during early prototyping if a package has only one file, but the package boundaries should remain conceptually stable.
|
||||
Run `gofmt -w` on changed Go files before committing.
|
||||
|
||||
## Dependency Direction
|
||||
## Coding Conventions
|
||||
|
||||
The intended dependency direction is:
|
||||
- Keep domain logic out of `cmd`, `internal/cli`, and adapter packages.
|
||||
- Prefer small explicit structs and functions over broad framework-style
|
||||
abstractions.
|
||||
- Keep package APIs narrow and named around implemented behavior.
|
||||
- Return errors with operation, path, endpoint, report, or RunID context.
|
||||
- Do not log or expose secrets.
|
||||
- Use `context.Context` for external calls, subprocesses, and orchestrated
|
||||
workflows that may be canceled.
|
||||
- Use atomic writes for durable JSON artifacts where practical.
|
||||
- Keep report selection and prompt IDs centralized in `internal/report`.
|
||||
- Keep Scriptorium argv construction inside `internal/adapters/scriptorium`.
|
||||
- Keep Weather API transport and envelope handling inside
|
||||
`internal/adapters/weatherapi`.
|
||||
|
||||
```text
|
||||
cmd/weatherreporter
|
||||
-> internal/cli
|
||||
-> internal/app
|
||||
-> internal/config
|
||||
-> internal/report
|
||||
-> internal/briefing
|
||||
-> internal/forecast
|
||||
-> internal/changes
|
||||
-> internal/state
|
||||
-> internal/adapters/*
|
||||
```
|
||||
## Dependency Policy
|
||||
|
||||
Rules:
|
||||
Prefer the Go standard library. Add dependencies only when they materially
|
||||
improve correctness, interoperability, security, or maintainability.
|
||||
|
||||
- `cmd/weatherreporter` should only bootstrap the CLI.
|
||||
- `internal/cli` should parse commands and flags, then call `internal/app`.
|
||||
- `internal/app` should orchestrate workflows but avoid embedding detailed forecast logic.
|
||||
- `internal/adapters/*` should not contain domain policy.
|
||||
- `internal/forecast`, `internal/report`, `internal/briefing`, and `internal/changes` should be testable without real external services.
|
||||
- `internal/state` should expose a storage interface so filesystem state can later be replaced or supplemented.
|
||||
- `scriptorium` details should not leak outside `internal/adapters/scriptorium`.
|
||||
Current external dependency:
|
||||
|
||||
## 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`.
|
||||
- Execute the command.
|
||||
- Handle final process exit behavior.
|
||||
Configuration is owned by `internal/config`.
|
||||
|
||||
Non-responsibilities:
|
||||
When adding or changing a field:
|
||||
|
||||
- No configuration loading details.
|
||||
- No forecast logic.
|
||||
- No direct calls to weather APIs, state stores, or `scriptorium`.
|
||||
- update `Config` and the nested config struct in `config.go`;
|
||||
- add or adjust defaults in `defaults.go` when the field has a safe default;
|
||||
- update loading or CLI override behavior in `load.go` only when needed;
|
||||
- validate required values and accepted ranges in `validate.go`;
|
||||
- add or update config tests;
|
||||
- update `docs/config.md` and maintained examples when the field is user
|
||||
visible;
|
||||
- keep secrets out of example config files.
|
||||
|
||||
### `internal/cli`
|
||||
Configuration precedence is:
|
||||
|
||||
Defines the user-facing command tree, flags, arguments, and command wiring.
|
||||
1. CLI overrides supported by `config.LoadOptions`;
|
||||
2. configuration file values;
|
||||
3. built-in defaults.
|
||||
|
||||
Responsibilities:
|
||||
The default config path is `/usr/local/etc/weatherreporter/config.yml`.
|
||||
|
||||
- Define commands such as:
|
||||
- `weatherreporter generate daily`
|
||||
- `weatherreporter generate tomorrow`
|
||||
- `weatherreporter generate three-day`
|
||||
- `weatherreporter generate weekend`
|
||||
- `weatherreporter generate storm`
|
||||
- `weatherreporter run morning`
|
||||
- `weatherreporter run evening`
|
||||
- `weatherreporter inspect snapshot`
|
||||
- Use the Go standard library for CLI parsing unless future complexity justifies a dependency.
|
||||
- Parse flags such as `--config`, `--units`, `--tz`, `--out`, optional Daily `--date`, and storm `--start`/`--end`, then convert them into app-layer request structs.
|
||||
- Load configuration through `internal/config`.
|
||||
- Present concise user-facing errors.
|
||||
## CLI Changes
|
||||
|
||||
Non-responsibilities:
|
||||
The CLI is owned by `internal/cli`.
|
||||
|
||||
- No report-building logic.
|
||||
- No direct subprocess execution.
|
||||
- No direct weather API calls.
|
||||
- No state comparison logic.
|
||||
When adding or changing a command or flag:
|
||||
|
||||
Suggested command shape:
|
||||
- update help text and parser behavior together;
|
||||
- convert parsed values into app-layer request structs;
|
||||
- keep domain decisions in `internal/app` or domain packages;
|
||||
- add parser or command tests in `internal/cli`;
|
||||
- update `docs/cli.md`;
|
||||
- update `docs/operations.md` or `docs/troubleshooting.md` when behavior affects
|
||||
operators.
|
||||
|
||||
```text
|
||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
||||
weatherreporter generate tomorrow --out ./tomorrow.md
|
||||
weatherreporter generate three-day --out ./three_day.md
|
||||
weatherreporter generate weekend --out ./weekend.md
|
||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
|
||||
weatherreporter run morning
|
||||
weatherreporter run evening
|
||||
```
|
||||
CLI commands should return concise actionable errors and avoid printing partial
|
||||
JSON when command construction fails.
|
||||
|
||||
The MVP should not expose location selection. Source `locationId` and `locationName` values returned by the weather API may be retained as provenance.
|
||||
## Components And Adapters
|
||||
|
||||
For `generate daily`, `--date` is optional. When provided, it must use `YYYY-MM-DD`; when omitted, it resolves to the current local date in the configured timezone.
|
||||
Use existing package boundaries before adding a package.
|
||||
|
||||
### `internal/config`
|
||||
Add a new internal component only when it owns a distinct implemented contract.
|
||||
Define its inputs, outputs, state behavior, failure behavior, tests, and
|
||||
invariants in `docs/internal/`.
|
||||
|
||||
Owns configuration structures, defaults, loading, precedence, and validation.
|
||||
Adapters should stay thin:
|
||||
|
||||
Responsibilities:
|
||||
- HTTP adapters own transport, request construction, envelope handling, and
|
||||
decode boundaries.
|
||||
- subprocess adapters own argv construction, timeout handling, stdout/stderr
|
||||
capture, and exit-code interpretation.
|
||||
- adapter packages should not own report selection, forecast summarization,
|
||||
Recent Changes, or prompt input schema decisions.
|
||||
|
||||
- Define application configuration structs.
|
||||
- Provide built-in defaults in `defaults.go`.
|
||||
- Load YAML configuration from `/usr/local/etc/weatherreporter/config.yml` or a CLI-supplied path.
|
||||
- Use `gopkg.in/yaml.v3` for YAML parsing.
|
||||
- Apply precedence rules.
|
||||
- Validate required settings.
|
||||
- Normalize paths, durations, report settings, weather API units/timezone, missing-source policy, and daypart definitions.
|
||||
When an external contract changes, update the matching file under
|
||||
`docs/integrations/`.
|
||||
|
||||
Suggested configuration areas:
|
||||
## Tests
|
||||
|
||||
- Weather API base URL, timeout, units, timezone, precision, and missing-source policy.
|
||||
- `scriptorium` binary, config path, profile, timeout, and optional extra arguments.
|
||||
- Workspace and output directories.
|
||||
- Report enablement and output naming.
|
||||
- Daypart definitions.
|
||||
- Recent-change thresholds.
|
||||
Core tests must not require live Weather API or Scriptorium services.
|
||||
|
||||
Initial defaults:
|
||||
Preferred test patterns:
|
||||
|
||||
- Weather API units: `us`.
|
||||
- Weather API timezone: `Chicago`.
|
||||
- Weather API format: `json`.
|
||||
- Missing-source policy: `warn`.
|
||||
- fake command runners for subprocess behavior;
|
||||
- `httptest.Server` for Weather API behavior;
|
||||
- filesystem temp directories for state behavior;
|
||||
- deterministic clocks for report periods and RunIDs;
|
||||
- table tests for config validation, CLI parsing, period resolution, and
|
||||
threshold behavior.
|
||||
|
||||
Missing-source policy should support a global default and per-source overrides. Valid policy values are `error`, `warn`, and `none`.
|
||||
Add focused tests near the package that owns the behavior. Use app-level tests
|
||||
for workflow ordering, persistence, and cross-package contracts.
|
||||
|
||||
Non-responsibilities:
|
||||
## Examples
|
||||
|
||||
- No command execution.
|
||||
- No HTTP calls.
|
||||
- No report-building logic.
|
||||
Examples under `examples/` must be real, maintained, and free of secrets.
|
||||
|
||||
### `internal/app`
|
||||
When updating examples:
|
||||
|
||||
Application orchestration and top-level use cases.
|
||||
- use implemented config fields only;
|
||||
- avoid private endpoints and credentials;
|
||||
- keep comments short and operationally useful;
|
||||
- add or update validation coverage when a new example file is introduced;
|
||||
- link maintained examples from `docs/config.md`.
|
||||
|
||||
Responsibilities:
|
||||
Do not add generated report examples unless they can be kept current without
|
||||
live external services.
|
||||
|
||||
- Implement use cases such as:
|
||||
- Generate one report.
|
||||
- Run the morning batch.
|
||||
- Run the evening batch.
|
||||
- Generate a manual storm report.
|
||||
- Coordinate config, weather API adapter, report registry, briefing builders, state store, change comparison, prompt input builder, and `scriptorium` runner.
|
||||
- Enforce workflow order.
|
||||
- Ensure each generation run persists enough artifacts for inspection and future comparison.
|
||||
## Documentation Checklist
|
||||
|
||||
The core generation workflow should be approximately:
|
||||
Documentation updates are part of behavior changes.
|
||||
|
||||
```text
|
||||
resolve report definition
|
||||
resolve valid period
|
||||
fetch current weather bundle
|
||||
build current briefing package
|
||||
load prior comparable briefing snapshot
|
||||
compute recent changes
|
||||
build prompt input data package
|
||||
write data package
|
||||
run scriptorium render preflight
|
||||
invoke scriptorium run
|
||||
persist report metadata, briefing snapshot, data package, preflight output, and rendered report
|
||||
```
|
||||
Update:
|
||||
|
||||
Non-responsibilities:
|
||||
- `README.md` for project orientation or quickstart changes;
|
||||
- `docs/cli.md` for command and flag changes;
|
||||
- `docs/config.md` for config fields, defaults, and precedence changes;
|
||||
- `docs/operations.md` for state, artifact, batch, inspection, and recovery
|
||||
behavior;
|
||||
- `docs/troubleshooting.md` for recurring operator-facing failure modes;
|
||||
- `docs/internal/` for component contracts and invariants;
|
||||
- `docs/integrations/` for external Weather API or Scriptorium contract changes;
|
||||
- `docs/roadmap/` only for unimplemented or deferred work.
|
||||
|
||||
- No detailed daypart calculations.
|
||||
- No direct parsing of NWS text unless delegated to domain packages.
|
||||
- No direct shell command construction outside the `scriptorium` adapter.
|
||||
|
||||
### `internal/adapters/weatherapi`
|
||||
|
||||
HTTP adapter for the internal weather API backed by `weatherfeeder`.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Fetch normalized weather data from the configured API base URL.
|
||||
- Fan out to multiple weather API endpoints and assemble one internal `forecast.Bundle`.
|
||||
- Decode API responses into adapter-owned DTOs or directly into stable internal types if those types are intentionally owned by `weatherreporter`.
|
||||
- Apply request timeouts and context cancellation.
|
||||
- Apply configured query defaults, including `format=json`, `units=us`, and `tz=Chicago` unless overridden.
|
||||
- Fetch full `/forecast/hourly` and `/forecast/narrative` products, not day-slice endpoints, so Go domain code owns report-period selection.
|
||||
- Record per-source provenance: endpoint, query, fetch time, issued/updated time when available, SHA-256 over canonical/minified raw `data` JSON, warnings, and missing-source status.
|
||||
- Represent source warnings as first-class records with source name, code, severity, message, endpoint, and completeness impact.
|
||||
- Require hourly forecast data for normal scheduled reports.
|
||||
- Apply missing-source policy for `data:null`, malformed non-required sections, or unavailable upstream products.
|
||||
- Return actionable errors containing endpoint and operation context.
|
||||
|
||||
Initial data categories:
|
||||
|
||||
- Latest observation.
|
||||
- Current conditions.
|
||||
- Hourly forecast data.
|
||||
- NWS narrative forecast periods.
|
||||
- NWS alerts.
|
||||
- NWS forecast discussion.
|
||||
|
||||
Stubbed source slots until upstream support exists:
|
||||
|
||||
- Daily forecast data.
|
||||
- NWS weather story.
|
||||
|
||||
Non-responsibilities:
|
||||
|
||||
- No daypart grouping.
|
||||
- No Recent Changes comparison.
|
||||
- No prompt input construction.
|
||||
- No `scriptorium` calls.
|
||||
|
||||
### `internal/adapters/scriptorium`
|
||||
|
||||
Subprocess adapter for invoking `scriptorium`.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Provide a narrow runner interface, such as:
|
||||
|
||||
```go
|
||||
type Runner interface {
|
||||
Render(ctx context.Context, req RenderRequest) (*RenderResult, error)
|
||||
Run(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
}
|
||||
```
|
||||
|
||||
- Execute `scriptorium render` for preflight/debug output without LLM generation.
|
||||
- Execute `scriptorium run` for report generation.
|
||||
- Run `scriptorium render` as an always-on preflight before `scriptorium run` for MVP generated reports.
|
||||
- Pass arguments as an argv slice, not through a shell.
|
||||
- Pass large prompt input as `--input data_package=<path>`.
|
||||
- Capture stdout/stderr with reasonable size limits.
|
||||
- Treat nonzero exits as actionable errors, including exit code `2` from `run`, which may still produce output.
|
||||
- Keep all `scriptorium`-specific flag details inside the adapter.
|
||||
|
||||
Suggested command forms:
|
||||
|
||||
```text
|
||||
scriptorium render \
|
||||
--prompt weather.daily_report \
|
||||
--input data_package=./workspace/data-packages/daily/2026-05-29T050000-0500.data_package.json \
|
||||
--format json
|
||||
|
||||
scriptorium run \
|
||||
--prompt weather.daily_report \
|
||||
--input data_package=./workspace/data-packages/daily/2026-05-29T050000-0500.data_package.json \
|
||||
--out ./workspace/reports/daily/2026-05-29T050000-0500.md
|
||||
```
|
||||
|
||||
Non-responsibilities:
|
||||
|
||||
- No weather logic.
|
||||
- No report registry logic.
|
||||
- No decision about which prompt to run.
|
||||
|
||||
Future note:
|
||||
|
||||
- A native LLM client can later replace or supplement this adapter behind a similar interface.
|
||||
|
||||
### `internal/forecast`
|
||||
|
||||
Core forecast-domain processing.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Define the normalized `Bundle` consumed by report builders.
|
||||
- Group hourly forecast data into configured dayparts.
|
||||
- Compute derived facts, including:
|
||||
- Temperature ranges.
|
||||
- Apparent-temperature ranges, if available.
|
||||
- Max precipitation probability.
|
||||
- Peak wind and wind gusts.
|
||||
- Precipitation windows.
|
||||
- Thunder mentions.
|
||||
- Snow/ice/freezing risk indicators.
|
||||
- Alert overlap with relevant periods.
|
||||
- Select forecast elements relevant to a report period.
|
||||
- Provide threshold helpers for impact detection.
|
||||
|
||||
Non-responsibilities:
|
||||
|
||||
- No CLI behavior.
|
||||
- No external API calls.
|
||||
- No rendered prose.
|
||||
- No direct `scriptorium` calls.
|
||||
|
||||
### `internal/report`
|
||||
|
||||
Report definitions, registry, period resolution, and report-level contracts.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Define report IDs and report definition contracts.
|
||||
- Register report types and variants.
|
||||
- Resolve valid periods for each report.
|
||||
- Associate report types with prompt IDs.
|
||||
- Define comparison strategies and output naming behavior.
|
||||
|
||||
Suggested report definitions:
|
||||
|
||||
```text
|
||||
daily_today -> prompt weather.daily_report
|
||||
daily_tomorrow -> prompt weather.daily_report
|
||||
three_day -> prompt weather.three_day_outlook
|
||||
weekend -> prompt weather.weekend_outlook
|
||||
storm -> prompt weather.storm_report
|
||||
```
|
||||
|
||||
`weather.daily_report` should be the standard prompt for one local civil day, regardless of whether that day is today or tomorrow.
|
||||
|
||||
A report definition should describe:
|
||||
|
||||
- Report ID.
|
||||
- Human-readable name.
|
||||
- Prompt ID.
|
||||
- Valid-period resolver.
|
||||
- Briefing builder ID or function.
|
||||
- Recent-change comparison strategy.
|
||||
- Default output naming pattern.
|
||||
- Whether the report participates in morning or evening scheduled batches.
|
||||
|
||||
Non-responsibilities:
|
||||
|
||||
- No detailed forecast computation.
|
||||
- No state storage.
|
||||
- No subprocess execution.
|
||||
|
||||
### `internal/briefing`
|
||||
|
||||
Builds report-specific briefing packages from forecast bundles and report definitions.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Convert a forecast bundle into a report-specific structured briefing package.
|
||||
- Keep each report's briefing shape explicit and testable.
|
||||
- Attach relevant NWS narrative periods, alerts, forecast discussion context, and weather story context when available.
|
||||
- Include metadata such as schema version, configured units/timezone, source warnings, and source provenance.
|
||||
- Provide inputs suitable for `scriptorium` data packages.
|
||||
|
||||
Report-specific builders should exist for:
|
||||
|
||||
- Daily Report.
|
||||
- Tomorrow Planning Brief.
|
||||
- 3-Day Outlook.
|
||||
- Weekend Outlook.
|
||||
- Storm Report.
|
||||
|
||||
Non-responsibilities:
|
||||
|
||||
- No external API fetching.
|
||||
- No final prose rendering.
|
||||
- No state persistence, except through app orchestration.
|
||||
|
||||
Design note:
|
||||
|
||||
- This package is the architectural center of the application. A clean briefing package makes `scriptorium` a renderer rather than a source of weather reasoning.
|
||||
|
||||
### `internal/changes`
|
||||
|
||||
Structured comparison of current and prior briefing snapshots.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Compare current briefing packages against prior comparable snapshots.
|
||||
- Apply meaningful-change thresholds.
|
||||
- Produce compact structured change summaries for prompt input data packages.
|
||||
- Avoid comparison of rendered Markdown report text.
|
||||
|
||||
Comparable snapshot matching should be declared by each report definition. Daily Today, Daily Tomorrow, and compatible date slices from multi-day reports may compare by same valid local date when the report registry marks them compatible. Weekend compares by same weekend window. Storm compares by explicit event window.
|
||||
|
||||
Meaningful changes may include:
|
||||
|
||||
- Temperature changes crossing configured thresholds.
|
||||
- Precipitation probability changes by category.
|
||||
- Precipitation timing shifts.
|
||||
- New, canceled, extended, upgraded, or expanded alerts.
|
||||
- Wind gust threshold crossings.
|
||||
- Snow/ice/freezing risk changes.
|
||||
- Severe-weather wording or risk changes.
|
||||
- Confidence or uncertainty changes, if represented in structured briefing data.
|
||||
|
||||
Non-responsibilities:
|
||||
|
||||
- No fetching prior state directly unless mediated through app/state contracts.
|
||||
- No final report prose.
|
||||
- No external calls.
|
||||
|
||||
### `internal/state`
|
||||
|
||||
Durable state store for reports, snapshots, data packages, preflight output, metadata, and comparison lookup.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Persist generated report metadata.
|
||||
- Persist briefing snapshots.
|
||||
- Persist prompt input data packages.
|
||||
- Persist `scriptorium render` preflight output for generated reports.
|
||||
- Locate prior comparable snapshots for Recent Changes.
|
||||
- Track RunID as generation timestamp plus report ID.
|
||||
- Use timestamped managed report names to avoid overwriting prior runs for the same valid period.
|
||||
- Use atomic writes where practical.
|
||||
- Keep filesystem layout narrow and predictable.
|
||||
|
||||
Initial backend:
|
||||
|
||||
- Filesystem state.
|
||||
|
||||
Potential future backend:
|
||||
|
||||
- SQLite or another state database, behind the same store interface.
|
||||
|
||||
Suggested state layout:
|
||||
|
||||
```text
|
||||
workspace/
|
||||
snapshots/
|
||||
daily/
|
||||
2026-05-30/
|
||||
2026-05-29T050000-0500.briefing.json
|
||||
2026-05-29T050000-0500.metadata.json
|
||||
three-day/
|
||||
weekend/
|
||||
storm/
|
||||
reports/
|
||||
daily/
|
||||
2026-05-29T050000-0500.md
|
||||
three-day/
|
||||
weekend/
|
||||
storm/
|
||||
data-packages/
|
||||
daily/
|
||||
2026-05-29T050000-0500.data_package.json
|
||||
preflight/
|
||||
daily/
|
||||
2026-05-29T050000-0500.render.json
|
||||
```
|
||||
|
||||
Non-responsibilities:
|
||||
|
||||
- No weather derivation.
|
||||
- No report prose generation.
|
||||
- No CLI formatting decisions.
|
||||
|
||||
### `internal/promptinput`
|
||||
|
||||
Builds the final data package passed to `scriptorium`.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Combine report metadata, briefing package, Recent Changes, selected source context, and source warnings into a prompt input document.
|
||||
- Validate required data package fields before invoking `scriptorium`.
|
||||
- Keep data package schemas explicit enough to test.
|
||||
- Write data package files to the workspace when requested by the app layer.
|
||||
|
||||
Non-responsibilities:
|
||||
|
||||
- No weather API calls.
|
||||
- No forecast derivation.
|
||||
- No subprocess execution.
|
||||
|
||||
### `internal/timeutil`
|
||||
|
||||
Time, clock, and period helpers.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Provide an injectable clock for deterministic tests.
|
||||
- Resolve local dates using the configured report timezone.
|
||||
- Handle daypart spans, including overnight windows.
|
||||
- Normalize valid periods.
|
||||
- Provide helpers for recurring scheduled batches.
|
||||
|
||||
Non-responsibilities:
|
||||
|
||||
- No report-specific forecast logic unless delegated by `internal/report`.
|
||||
- No external calls.
|
||||
|
||||
## Report Types and Valid-Period Identity
|
||||
|
||||
Each generated report must be associated with explicit metadata:
|
||||
|
||||
- RunID.
|
||||
- Report type.
|
||||
- Report variant, if applicable.
|
||||
- Generation time.
|
||||
- Configured report timezone.
|
||||
- Valid period start.
|
||||
- Valid period end.
|
||||
- Source location ID/name when provided by upstream.
|
||||
- Source product timestamps and/or SHA-256 hashes.
|
||||
- Source warnings.
|
||||
- Briefing snapshot path.
|
||||
- Prompt input data package path.
|
||||
- Preflight output path.
|
||||
- Rendered report path.
|
||||
|
||||
All valid periods should use the configured local timezone, default `Chicago`, and half-open `[start,end)` intervals.
|
||||
|
||||
Initial valid-period rules:
|
||||
|
||||
- Daily Today: current local civil day, `[00:00, next 00:00)`.
|
||||
- Daily Tomorrow: next local civil day.
|
||||
- 3-Day Outlook: generation time through local midnight after the second following local civil day.
|
||||
- Weekend Outlook: Monday through Thursday covers Saturday 00:00 to Monday 00:00; Friday and Saturday cover `max(generation time, Friday 18:00)` to Monday 00:00; scheduled Sunday morning skips Weekend Outlook.
|
||||
- Manual Storm Report: requires explicit `--start` and `--end`; accept `YYYY-MM-DDTHH:MM` interpreted in the configured timezone and RFC3339 timestamps with explicit offsets.
|
||||
|
||||
The valid period should identify what weather period the report covers, independent of when the report was generated.
|
||||
|
||||
Examples:
|
||||
|
||||
- A 5 PM Tomorrow Planning Brief for Saturday and a 5 AM Saturday Daily Report both cover the same valid date.
|
||||
- A Saturday Weekend Outlook covers the remaining weekend, while a Friday Weekend Outlook may cover Friday evening through Sunday night.
|
||||
- A Storm Report covers an explicit forecast event window, not a fixed calendar day.
|
||||
|
||||
This identity is required for reliable Recent Changes behavior.
|
||||
|
||||
## Scheduled Batch Semantics
|
||||
|
||||
The app should support scheduled batches but should not need to be a daemon in the initial version.
|
||||
|
||||
Suggested batches:
|
||||
|
||||
```text
|
||||
morning:
|
||||
- daily_today
|
||||
- three_day
|
||||
- weekend, except Sunday
|
||||
|
||||
evening:
|
||||
- daily_tomorrow
|
||||
```
|
||||
|
||||
Scheduled batches should continue independent reports after a report failure. The CLI should return nonzero if any report failed and should emit an aggregate run summary.
|
||||
|
||||
External scheduling should be handled by systemd timers, cron, or another orchestrator. `weatherreporter` should simply provide deterministic commands that can be scheduled.
|
||||
|
||||
## Storm Report Direction
|
||||
|
||||
The initial version should support manual Storm Report generation:
|
||||
|
||||
```text
|
||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
|
||||
```
|
||||
|
||||
Future storm monitoring should use a staged design:
|
||||
|
||||
```text
|
||||
incoming weather data
|
||||
-> deterministic candidate detector
|
||||
-> LLM event evaluator
|
||||
-> storm lifecycle state
|
||||
-> storm report generation or skip decision
|
||||
```
|
||||
|
||||
Potential storm lifecycle states:
|
||||
|
||||
```text
|
||||
none -> monitoring -> active_report -> escalated -> deescalating -> resolved
|
||||
```
|
||||
|
||||
This future behavior should not be built before the core scheduled reports are stable, but the package layout should leave room for it.
|
||||
|
||||
## Testing Expectations
|
||||
|
||||
Core tests should not require real external services.
|
||||
|
||||
Priority test areas:
|
||||
|
||||
- Configuration loading and validation, including defaults for `units=us`, `tz=Chicago`, and missing-source policy `warn`.
|
||||
- Standard-library CLI command parsing, including `--units`, `--tz`, and storm `--start`/`--end`.
|
||||
- Weather API fan-out, source provenance, `data:null`, and missing-source policy behavior.
|
||||
- Daypart grouping, especially overnight periods.
|
||||
- Valid-period resolution for each report type.
|
||||
- Briefing package construction from fixtures.
|
||||
- Recent Changes threshold behavior and compatible snapshot matching.
|
||||
- Prior snapshot lookup.
|
||||
- `scriptorium` adapter behavior using a fake executable or command runner, including both `render` and `run` with `--input data_package=<path>`.
|
||||
- Batch partial-failure behavior and aggregate exit status.
|
||||
|
||||
## Design Invariants
|
||||
|
||||
Preserve these invariants as the project evolves:
|
||||
|
||||
- Weather facts come from normalized source data, not from the LLM.
|
||||
- The LLM receives curated briefing packages, not unbounded raw weather payloads.
|
||||
- Recent Changes are based on structured snapshot comparison, not Markdown diffing.
|
||||
- Report types are registered or otherwise centrally defined.
|
||||
- External integrations are thin adapters.
|
||||
- CLI code wires workflows but does not own domain logic.
|
||||
- The first durable state backend is filesystem-based and inspectable.
|
||||
- `scriptorium` is an adapter boundary, not an application dependency that leaks across packages.
|
||||
Non-roadmap docs must describe implemented behavior only.
|
||||
|
||||
86
docs/roadmap/future.md
Normal file
86
docs/roadmap/future.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# 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 when available, 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:
|
||||
base_url: https://weather.api.example.com/
|
||||
base_url: https://weather.api.rakestrawhome.com/
|
||||
timeout: 15s
|
||||
precision: 1
|
||||
units: us
|
||||
timezone: Chicago
|
||||
timezone: "America/Chicago"
|
||||
format: json
|
||||
|
||||
location:
|
||||
id: home
|
||||
name: Brentwood
|
||||
region: St. Louis Metro
|
||||
|
||||
missing_source:
|
||||
default: warn
|
||||
sources:
|
||||
@@ -22,21 +27,21 @@ workspace:
|
||||
data_packages_dir: data-packages
|
||||
preflight_dir: preflight
|
||||
|
||||
reports:
|
||||
output_dir: reports
|
||||
|
||||
dayparts:
|
||||
- name: overnight
|
||||
start: "00:00"
|
||||
end: "06:00"
|
||||
- name: morning
|
||||
start: "06:00"
|
||||
end: "12:00"
|
||||
end: "10:00"
|
||||
- name: midday
|
||||
start: "10:00"
|
||||
end: "15:00"
|
||||
- name: afternoon
|
||||
start: "12:00"
|
||||
end: "18:00"
|
||||
start: "15:00"
|
||||
end: "17:00"
|
||||
- name: evening
|
||||
start: "18:00"
|
||||
start: "17:00"
|
||||
end: "24:00"
|
||||
|
||||
recent_change:
|
||||
|
||||
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 (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -108,29 +105,20 @@ func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, e
|
||||
if req.DataPackagePath == "" {
|
||||
return nil, fmt.Errorf("data package path is required")
|
||||
}
|
||||
binary := r.Binary
|
||||
if binary == "" {
|
||||
binary = "scriptorium"
|
||||
}
|
||||
commands := r.Commands
|
||||
if commands == nil {
|
||||
commands = ExecRunner{}
|
||||
}
|
||||
args := r.renderArgs(req)
|
||||
commandResult, err := commands.Run(ctx, binary, args, r.Timeout)
|
||||
execution, err := r.execute(ctx, r.renderArgs(req))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("run scriptorium render: %w", err)
|
||||
}
|
||||
result := &RenderResult{
|
||||
Command: append([]string{binary}, args...),
|
||||
Stdout: string(commandResult.Stdout),
|
||||
Stderr: string(commandResult.Stderr),
|
||||
StdoutTruncated: commandResult.StdoutTruncated,
|
||||
StderrTruncated: commandResult.StderrTruncated,
|
||||
ExitCode: commandResult.ExitCode,
|
||||
Command: execution.argv(),
|
||||
Stdout: string(execution.result.Stdout),
|
||||
Stderr: string(execution.result.Stderr),
|
||||
StdoutTruncated: execution.result.StdoutTruncated,
|
||||
StderrTruncated: execution.result.StderrTruncated,
|
||||
ExitCode: execution.result.ExitCode,
|
||||
}
|
||||
if commandResult.ExitCode != 0 {
|
||||
return result, fmt.Errorf("scriptorium render exited with code %d: %s", commandResult.ExitCode, result.Stderr)
|
||||
if execution.result.ExitCode != 0 {
|
||||
return result, fmt.Errorf("scriptorium render exited with code %d: %s", execution.result.ExitCode, result.Stderr)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -145,6 +133,32 @@ func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
||||
if req.OutputPath == "" {
|
||||
return nil, fmt.Errorf("output path is required")
|
||||
}
|
||||
execution, err := r.execute(ctx, r.runArgs(req))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("run scriptorium: %w", err)
|
||||
}
|
||||
result := &RunResult{
|
||||
Command: execution.argv(),
|
||||
Stdout: string(execution.result.Stdout),
|
||||
Stderr: string(execution.result.Stderr),
|
||||
StdoutTruncated: execution.result.StdoutTruncated,
|
||||
StderrTruncated: execution.result.StderrTruncated,
|
||||
ExitCode: execution.result.ExitCode,
|
||||
OutputPath: req.OutputPath,
|
||||
}
|
||||
if execution.result.ExitCode != 0 {
|
||||
return result, fmt.Errorf("scriptorium run exited with code %d: %s", execution.result.ExitCode, result.Stderr)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type execution struct {
|
||||
binary string
|
||||
args []string
|
||||
result CommandResult
|
||||
}
|
||||
|
||||
func (r Runner) execute(ctx context.Context, args []string) (execution, error) {
|
||||
binary := r.Binary
|
||||
if binary == "" {
|
||||
binary = "scriptorium"
|
||||
@@ -153,24 +167,15 @@ func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
||||
if commands == nil {
|
||||
commands = ExecRunner{}
|
||||
}
|
||||
args := r.runArgs(req)
|
||||
commandResult, err := commands.Run(ctx, binary, args, r.Timeout)
|
||||
result, err := commands.Run(ctx, binary, args, r.Timeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("run scriptorium: %w", err)
|
||||
return execution{}, err
|
||||
}
|
||||
result := &RunResult{
|
||||
Command: append([]string{binary}, args...),
|
||||
Stdout: string(commandResult.Stdout),
|
||||
Stderr: string(commandResult.Stderr),
|
||||
StdoutTruncated: commandResult.StdoutTruncated,
|
||||
StderrTruncated: commandResult.StderrTruncated,
|
||||
ExitCode: commandResult.ExitCode,
|
||||
OutputPath: req.OutputPath,
|
||||
}
|
||||
if commandResult.ExitCode != 0 {
|
||||
return result, fmt.Errorf("scriptorium run exited with code %d: %s", commandResult.ExitCode, result.Stderr)
|
||||
}
|
||||
return result, nil
|
||||
return execution{binary: binary, args: args, result: result}, nil
|
||||
}
|
||||
|
||||
func (e execution) argv() []string {
|
||||
return append([]string{e.binary}, e.args...)
|
||||
}
|
||||
|
||||
func (r Runner) renderArgs(req RenderRequest) []string {
|
||||
@@ -207,37 +212,6 @@ func (r Runner) runArgs(req RunRequest) []string {
|
||||
return args
|
||||
}
|
||||
|
||||
func SaveRenderResult(path string, result *RenderResult) error {
|
||||
if result == nil {
|
||||
return fmt.Errorf("render result is required")
|
||||
}
|
||||
data, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal render result: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create preflight directory %q: %w", filepath.Dir(path), err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary preflight file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("write temporary preflight file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temporary preflight file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("save preflight %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type limitedBuffer struct {
|
||||
data []byte
|
||||
limit int
|
||||
|
||||
@@ -11,14 +11,13 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
)
|
||||
|
||||
@@ -203,13 +202,18 @@ func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, "alerts", "/alerts/active", queryOptions{})
|
||||
raw, source, err := b.client.fetch(ctx, "alerts", "/alerts/active", queryOptions{allowNull: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "active alerts data is missing", false)
|
||||
}
|
||||
if isJSONNull(raw) {
|
||||
b.bundle.Alerts = &forecast.AlertRun{Raw: append(json.RawMessage(nil), raw...)}
|
||||
b.addSource(source)
|
||||
return nil
|
||||
}
|
||||
var alerts forecast.AlertRun
|
||||
if err := decodeSource(raw, &alerts); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
@@ -302,6 +306,7 @@ func (c *Client) policyFor(source string) config.MissingSourcePolicy {
|
||||
type queryOptions struct {
|
||||
precision bool
|
||||
timezone bool
|
||||
allowNull bool
|
||||
}
|
||||
|
||||
type envelope struct {
|
||||
@@ -340,7 +345,7 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
|
||||
Query: queryMap(reqURL.Query()),
|
||||
FetchedAt: c.now(),
|
||||
}
|
||||
if len(env.Data) == 0 || bytes.Equal(bytes.TrimSpace(env.Data), []byte("null")) {
|
||||
if len(env.Data) == 0 || (isJSONNull(env.Data) && !opts.allowNull) {
|
||||
source.Missing = true
|
||||
return nil, source, nil
|
||||
}
|
||||
@@ -352,6 +357,10 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
|
||||
return env.Data, source, nil
|
||||
}
|
||||
|
||||
func isJSONNull(raw json.RawMessage) bool {
|
||||
return bytes.Equal(bytes.TrimSpace(raw), []byte("null"))
|
||||
}
|
||||
|
||||
func (c *Client) endpointURL(endpoint string, opts queryOptions) *url.URL {
|
||||
reqURL := *c.baseURL
|
||||
reqURL.Path = path.Join(c.baseURL.Path, endpoint)
|
||||
@@ -398,29 +407,8 @@ func sourceHash(raw json.RawMessage) (string, error) {
|
||||
}
|
||||
|
||||
func SaveBundle(path string, bundle *forecast.Bundle) error {
|
||||
data, err := json.MarshalIndent(bundle, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal forecast bundle: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create bundle directory %q: %w", filepath.Dir(path), err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary bundle file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("write temporary bundle file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temporary bundle file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("save bundle %q: %w", path, err)
|
||||
if err := fileutil.WriteJSONAtomic(path, bundle); err != nil {
|
||||
return fmt.Errorf("save bundle: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -43,6 +43,12 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
||||
if bundle.Discussion == nil || len(bundle.Discussion.KeyMessages) != 2 {
|
||||
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 len(bundle.Sources) != 8 {
|
||||
t.Fatalf("Sources length = %d, want 8", len(bundle.Sources))
|
||||
}
|
||||
@@ -72,7 +78,7 @@ func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
||||
t.Fatalf("request %q missing format=json or units=us", rawURL)
|
||||
}
|
||||
if strings.HasPrefix(rawURL, "/forecast/") {
|
||||
if !strings.Contains(rawURL, "precision=1") || !strings.Contains(rawURL, "tz=Chicago") {
|
||||
if !strings.Contains(rawURL, "precision=1") || !strings.Contains(rawURL, "tz=America%2FChicago") {
|
||||
t.Fatalf("forecast request %q missing precision or tz", rawURL)
|
||||
}
|
||||
}
|
||||
@@ -125,6 +131,36 @@ func TestRequiredHourlyForecast(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNullAlertsMeansNoActiveAlerts(t *testing.T) {
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
"/alerts/active": {status: http.StatusOK, body: `{"data": null}`},
|
||||
}, nil)
|
||||
client := newTestClient(t, server.URL+"/", nil)
|
||||
|
||||
bundle, err := client.FetchBundle(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("FetchBundle() error = %v", err)
|
||||
}
|
||||
if bundle.Alerts == nil {
|
||||
t.Fatal("Alerts = nil, want checked empty alert run")
|
||||
}
|
||||
if len(bundle.Alerts.Alerts) != 0 {
|
||||
t.Fatalf("Alerts length = %d, want no active alerts", len(bundle.Alerts.Alerts))
|
||||
}
|
||||
source := sourceByName(t, bundle.Sources, "alerts")
|
||||
if source.Missing {
|
||||
t.Fatalf("alerts source Missing = true, want false")
|
||||
}
|
||||
if source.DataSHA256 == "" {
|
||||
t.Fatal("alerts DataSHA256 is empty, want hash for explicit null payload")
|
||||
}
|
||||
for _, warning := range bundle.Warnings {
|
||||
if warning.Source == "alerts" {
|
||||
t.Fatalf("warnings = %#v, want no alerts warning", bundle.Warnings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -9,8 +9,12 @@
|
||||
"Warmer temperatures this weekend."
|
||||
],
|
||||
"shortTerm": {
|
||||
"title": "Short Term",
|
||||
"narrative": "A weak boundary may trigger isolated showers."
|
||||
"qualifier": "(Through This Evening)",
|
||||
"text": "A weak boundary may trigger isolated showers."
|
||||
},
|
||||
"longTerm": {
|
||||
"qualifier": "(This Weekend)",
|
||||
"text": "Warmer temperatures and periodic rain chances continue into the weekend."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/changes"
|
||||
"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/promptinput"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
@@ -68,8 +67,6 @@ type BriefingRequest struct {
|
||||
OutputPath string
|
||||
}
|
||||
|
||||
type DailyBriefingRequest = BriefingRequest
|
||||
|
||||
type ReportRequest struct {
|
||||
Config config.Config
|
||||
Resolved report.Resolved
|
||||
@@ -78,15 +75,11 @@ type ReportRequest struct {
|
||||
Store state.Store
|
||||
}
|
||||
|
||||
type DailyReportRequest = ReportRequest
|
||||
|
||||
type BriefingResult struct {
|
||||
Package briefing.Package
|
||||
OutputPath string
|
||||
}
|
||||
|
||||
type DailyBriefingResult = BriefingResult
|
||||
|
||||
type ReportResult struct {
|
||||
Briefing briefing.Package
|
||||
BriefingPath string
|
||||
@@ -103,8 +96,6 @@ type ReportResult struct {
|
||||
RunResult *scriptorium.RunResult
|
||||
}
|
||||
|
||||
type DailyReportResult = ReportResult
|
||||
|
||||
type BatchResult struct {
|
||||
Batch BatchKind `json:"batch"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
@@ -157,7 +148,7 @@ func Generate(ctx context.Context, req GenerateRequest) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isGeneratedReport(resolved.Definition.ID) {
|
||||
if resolved.Definition.Generated {
|
||||
_, err := GenerateReport(ctx, ReportRequest{
|
||||
Config: req.Config,
|
||||
Resolved: resolved,
|
||||
@@ -200,7 +191,7 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
||||
startedAt := now
|
||||
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
|
||||
for _, resolved := range resolvedReports {
|
||||
if !isGeneratedReport(resolved.Definition.ID) {
|
||||
if !resolved.Definition.Generated {
|
||||
return nil, fmt.Errorf("run is not implemented")
|
||||
}
|
||||
}
|
||||
@@ -257,19 +248,10 @@ func batchReportResult(resolved report.Resolved) BatchReportResult {
|
||||
}
|
||||
|
||||
func batchOutputPath(outputDir string, definition report.Definition) string {
|
||||
if outputDir == "" || definition.DefaultOutputName == "" {
|
||||
if outputDir == "" || definition.BatchOutputName == "" {
|
||||
return ""
|
||||
}
|
||||
name := strings.ReplaceAll(definition.DefaultOutputName, "_", "-")
|
||||
return filepath.Join(outputDir, name)
|
||||
}
|
||||
|
||||
func isGeneratedReport(id report.ID) bool {
|
||||
return isDailyReport(id) || id == report.ThreeDay || id == report.Weekend || id == report.Storm
|
||||
}
|
||||
|
||||
func isDailyReport(id report.ID) bool {
|
||||
return id == report.DailyToday || id == report.DailyTomorrow
|
||||
return filepath.Join(outputDir, definition.BatchOutputName)
|
||||
}
|
||||
|
||||
func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) {
|
||||
@@ -359,10 +341,6 @@ func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*forecast.
|
||||
return bundle, nil
|
||||
}
|
||||
|
||||
func GenerateDailyBriefing(ctx context.Context, req DailyBriefingRequest) (*DailyBriefingResult, error) {
|
||||
return GenerateBriefing(ctx, req)
|
||||
}
|
||||
|
||||
func GenerateBriefing(ctx context.Context, req BriefingRequest) (*BriefingResult, error) {
|
||||
bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config})
|
||||
if err != nil {
|
||||
@@ -390,10 +368,6 @@ func GenerateBriefing(ctx context.Context, req BriefingRequest) (*BriefingResult
|
||||
return &BriefingResult{Package: pkg, OutputPath: outputPath}, nil
|
||||
}
|
||||
|
||||
func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyReportResult, error) {
|
||||
return GenerateReport(ctx, req)
|
||||
}
|
||||
|
||||
func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) {
|
||||
store := req.Store
|
||||
if store == nil {
|
||||
@@ -460,7 +434,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
||||
preflightPath := paths.Preflight
|
||||
if renderResult != nil {
|
||||
var err error
|
||||
preflightPath, err = store.SavePreflight(ctx, req.Resolved, renderResult)
|
||||
preflightPath, err = store.SavePreflight(ctx, req.Resolved, preflightArtifact(renderResult))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -490,7 +464,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
||||
OutputPath: reportPath,
|
||||
})
|
||||
if runErr == nil && req.OutputPath != "" && req.OutputPath != reportPath {
|
||||
if err := copyFileAtomic(reportPath, req.OutputPath); err != nil {
|
||||
if err := fileutil.CopyFileAtomic(reportPath, req.OutputPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -524,10 +498,6 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
||||
}, 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) {
|
||||
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
|
||||
if err != nil {
|
||||
@@ -552,6 +522,7 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa
|
||||
Bundle: bundle,
|
||||
Units: req.Config.WeatherAPI.Units,
|
||||
Timezone: req.Config.WeatherAPI.Timezone,
|
||||
Location: briefingLocation(req.Config),
|
||||
}, summary)
|
||||
case report.ThreeDay, report.Weekend:
|
||||
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,
|
||||
Units: req.Config.WeatherAPI.Units,
|
||||
Timezone: req.Config.WeatherAPI.Timezone,
|
||||
Location: briefingLocation(req.Config),
|
||||
}, summaries)
|
||||
}
|
||||
return briefing.BuildThreeDay(briefing.BuildContext{
|
||||
@@ -571,6 +543,7 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa
|
||||
Bundle: bundle,
|
||||
Units: req.Config.WeatherAPI.Units,
|
||||
Timezone: req.Config.WeatherAPI.Timezone,
|
||||
Location: briefingLocation(req.Config),
|
||||
}, summaries)
|
||||
case report.Storm:
|
||||
return briefing.BuildStorm(briefing.BuildContext{
|
||||
@@ -578,18 +551,28 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa
|
||||
Bundle: bundle,
|
||||
Units: req.Config.WeatherAPI.Units,
|
||||
Timezone: req.Config.WeatherAPI.Timezone,
|
||||
Location: briefingLocation(req.Config),
|
||||
})
|
||||
default:
|
||||
return briefing.Package{}, fmt.Errorf("briefing is not implemented for report %q", req.Resolved.Definition.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func defaultStore(cfg config.Config) (*state.FilesystemStore, error) {
|
||||
return state.NewFilesystemStore(cfg.Workspace)
|
||||
func briefingLocation(cfg config.Config) *briefing.LocationContext {
|
||||
location := briefing.LocationContext{
|
||||
ID: cfg.Location.ID,
|
||||
Name: cfg.Location.Name,
|
||||
Region: cfg.Location.Region,
|
||||
Timezone: cfg.WeatherAPI.Timezone,
|
||||
}
|
||||
if location.ID == "" && location.Name == "" && location.Region == "" && location.Timezone == "" {
|
||||
return nil
|
||||
}
|
||||
return &location
|
||||
}
|
||||
|
||||
func dailyRecentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) {
|
||||
return recentChanges(ctx, store, priorSnapshot, current, cfg)
|
||||
func defaultStore(cfg config.Config) (*state.FilesystemStore, error) {
|
||||
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) {
|
||||
@@ -618,29 +601,16 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.
|
||||
}
|
||||
}
|
||||
|
||||
func copyFileAtomic(source string, target string) error {
|
||||
data, err := os.ReadFile(source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read rendered report %q: %w", source, err)
|
||||
func preflightArtifact(result *scriptorium.RenderResult) state.PreflightArtifact {
|
||||
if result == nil {
|
||||
return state.PreflightArtifact{}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return fmt.Errorf("create report output directory %q: %w", filepath.Dir(target), err)
|
||||
return state.PreflightArtifact{
|
||||
Command: append([]string(nil), result.Command...),
|
||||
Stdout: result.Stdout,
|
||||
Stderr: result.Stderr,
|
||||
StdoutTruncated: result.StdoutTruncated,
|
||||
StderrTruncated: result.StderrTruncated,
|
||||
ExitCode: result.ExitCode,
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(target), "."+filepath.Base(target)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary report output file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("write temporary report output file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temporary report output file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, target); err != nil {
|
||||
return fmt.Errorf("save report output %q: %w", target, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -33,7 +34,7 @@ func TestFetchAndSaveBundle(t *testing.T) {
|
||||
case "/alerts/active":
|
||||
_, _ = w.Write([]byte(`{"data":{"alerts":[]}}`))
|
||||
case "/discussion":
|
||||
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":[]}}`))
|
||||
_, _ = 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."}}}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
@@ -70,11 +71,9 @@ func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDailyBriefingWritesArtifact(t *testing.T) {
|
||||
func TestGenerateBriefingWritesArtifact(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||
cfg := dailyTestConfig(t, server)
|
||||
resolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: ReportDaily,
|
||||
@@ -85,13 +84,13 @@ func TestGenerateDailyBriefingWritesArtifact(t *testing.T) {
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "daily.briefing.json")
|
||||
|
||||
result, err := GenerateDailyBriefing(context.Background(), DailyBriefingRequest{
|
||||
result, err := GenerateBriefing(context.Background(), BriefingRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
OutputPath: path,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateDailyBriefing() error = %v", err)
|
||||
t.Fatalf("GenerateBriefing() error = %v", err)
|
||||
}
|
||||
if result.OutputPath != path {
|
||||
t.Fatalf("OutputPath = %q, want %q", result.OutputPath, path)
|
||||
@@ -108,11 +107,9 @@ func TestGenerateDailyBriefingWritesArtifact(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDailyBriefingDefaultPath(t *testing.T) {
|
||||
func TestGenerateBriefingDefaultPath(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||
cfg := dailyTestConfig(t, server)
|
||||
cfg.Workspace.Root = t.TempDir()
|
||||
resolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg,
|
||||
@@ -123,23 +120,21 @@ func TestGenerateDailyBriefingDefaultPath(t *testing.T) {
|
||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||
}
|
||||
|
||||
result, err := GenerateDailyBriefing(context.Background(), DailyBriefingRequest{
|
||||
result, err := GenerateBriefing(context.Background(), BriefingRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
})
|
||||
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")) {
|
||||
t.Fatalf("OutputPath = %q, want deterministic daily briefing path", result.OutputPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
|
||||
func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||
cfg := dailyTestConfig(t, server)
|
||||
cfg.Workspace.Root = t.TempDir()
|
||||
resolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg,
|
||||
@@ -165,14 +160,14 @@ func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
|
||||
}
|
||||
outputPath := filepath.Join(t.TempDir(), "daily.md")
|
||||
|
||||
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
OutputPath: outputPath,
|
||||
Renderer: renderer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateDailyReport() error = %v", err)
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
}
|
||||
|
||||
if renderer.renderCalls != 1 {
|
||||
@@ -193,14 +188,7 @@ func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
|
||||
if 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} {
|
||||
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)
|
||||
}
|
||||
assertPathsExist(t, result.BriefingPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath, outputPath)
|
||||
data, err := os.ReadFile(result.DataPackagePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read data package: %v", err)
|
||||
@@ -208,6 +196,29 @@ func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
|
||||
if !strings.Contains(string(data), `"recentChanges"`) || !strings.Contains(string(data), `data_package.v1`) {
|
||||
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 !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)
|
||||
if err != nil {
|
||||
t.Fatalf("read preflight: %v", err)
|
||||
@@ -236,7 +247,7 @@ func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDailyReportPersistsFailedPreflight(t *testing.T) {
|
||||
func TestGenerateReportPersistsFailedPreflight(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||
@@ -259,13 +270,13 @@ func TestGenerateDailyReportPersistsFailedPreflight(t *testing.T) {
|
||||
err: errors.New("scriptorium render exited with code 1: render failed"),
|
||||
}
|
||||
|
||||
_, err = GenerateDailyReport(context.Background(), DailyReportRequest{
|
||||
_, err = GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
Renderer: renderer,
|
||||
})
|
||||
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)
|
||||
if err != nil {
|
||||
@@ -291,7 +302,7 @@ func TestGenerateDailyReportPersistsFailedPreflight(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDailyReportReturnsRunErrorAfterPreflight(t *testing.T) {
|
||||
func TestGenerateReportReturnsRunErrorAfterPreflight(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||
@@ -315,13 +326,13 @@ func TestGenerateDailyReportReturnsRunErrorAfterPreflight(t *testing.T) {
|
||||
runBody: "# Daily Report\n",
|
||||
}
|
||||
|
||||
_, err = GenerateDailyReport(context.Background(), DailyReportRequest{
|
||||
_, err = GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
Renderer: renderer,
|
||||
})
|
||||
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 {
|
||||
t.Fatalf("calls render=%d run=%d, want one of each", renderer.renderCalls, renderer.runCalls)
|
||||
@@ -342,7 +353,7 @@ func TestGenerateDailyReportReturnsRunErrorAfterPreflight(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDailyReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
|
||||
func TestGenerateReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||
@@ -371,6 +382,7 @@ func TestGenerateDailyReportIncludesRecentChangesFromPriorSnapshot(t *testing.T)
|
||||
}
|
||||
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
||||
Briefing: priorBriefingPath,
|
||||
Metadata: priorPaths.Metadata,
|
||||
DataPackage: priorPaths.DataPackage,
|
||||
Preflight: priorPaths.Preflight,
|
||||
RenderedReport: priorPaths.RenderedReport,
|
||||
@@ -393,14 +405,14 @@ func TestGenerateDailyReportIncludesRecentChangesFromPriorSnapshot(t *testing.T)
|
||||
runBody: "# Daily Report\n",
|
||||
}
|
||||
|
||||
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: currentResolved,
|
||||
Renderer: renderer,
|
||||
Store: store,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateDailyReport() error = %v", err)
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
}
|
||||
if len(result.RecentChanges) == 0 {
|
||||
t.Fatal("RecentChanges length = 0, want changes from prior snapshot")
|
||||
@@ -433,13 +445,13 @@ func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
|
||||
runBody: "# Tomorrow Planning Brief\n",
|
||||
}
|
||||
|
||||
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
Renderer: renderer,
|
||||
})
|
||||
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" {
|
||||
@@ -485,6 +497,7 @@ func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) {
|
||||
}
|
||||
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
||||
Briefing: priorBriefingPath,
|
||||
Metadata: priorPaths.Metadata,
|
||||
DataPackage: priorPaths.DataPackage,
|
||||
Preflight: priorPaths.Preflight,
|
||||
RenderedReport: priorPaths.RenderedReport,
|
||||
@@ -506,14 +519,14 @@ func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) {
|
||||
runBody: "# Tomorrow Planning Brief\n",
|
||||
}
|
||||
|
||||
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: currentResolved,
|
||||
Renderer: renderer,
|
||||
Store: store,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateDailyReport() error = %v", err)
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
}
|
||||
if result.PriorSnapshot == nil {
|
||||
t.Fatal("PriorSnapshot = nil, want compatible prior daily snapshot")
|
||||
@@ -551,6 +564,7 @@ func TestGenerateThreeDayReportWritesReportAndRecentChanges(t *testing.T) {
|
||||
}
|
||||
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
||||
Briefing: priorBriefingPath,
|
||||
Metadata: priorPaths.Metadata,
|
||||
DataPackage: priorPaths.DataPackage,
|
||||
Preflight: priorPaths.Preflight,
|
||||
RenderedReport: priorPaths.RenderedReport,
|
||||
@@ -626,6 +640,7 @@ func TestGenerateWeekendReportWritesReportAndRecentChanges(t *testing.T) {
|
||||
}
|
||||
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
||||
Briefing: priorBriefingPath,
|
||||
Metadata: priorPaths.Metadata,
|
||||
DataPackage: priorPaths.DataPackage,
|
||||
Preflight: priorPaths.Preflight,
|
||||
RenderedReport: priorPaths.RenderedReport,
|
||||
@@ -882,7 +897,7 @@ func dailyBundleServer(t *testing.T) *httptest.Server {
|
||||
case "/observations":
|
||||
_, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`))
|
||||
case "/conditions/current":
|
||||
_, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`))
|
||||
_, _ = w.Write([]byte(`{"data":{"conditionText":"Clear","temperatureF":75,"relativeHumidityPercent":56,"windSpeedMph":8}}`))
|
||||
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}]}}`))
|
||||
case "/forecast/narrative":
|
||||
@@ -890,7 +905,7 @@ func dailyBundleServer(t *testing.T) *httptest.Server {
|
||||
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"}]}}`))
|
||||
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."}}}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
@@ -1021,6 +1036,23 @@ func mustParse(value string) time.Time {
|
||||
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 {
|
||||
low := 50.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) {
|
||||
store, err := defaultStore(req.Config)
|
||||
if err != nil {
|
||||
return state.Metadata{}, err
|
||||
}
|
||||
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
|
||||
return metadata, err
|
||||
inspection, err := inspectRun(ctx, req)
|
||||
return inspection.metadata, err
|
||||
}
|
||||
|
||||
func InspectBriefing(ctx context.Context, req InspectRunRequest) (briefing.Package, error) {
|
||||
store, err := defaultStore(req.Config)
|
||||
inspection, err := inspectRun(ctx, req)
|
||||
if err != nil {
|
||||
return briefing.Package{}, err
|
||||
}
|
||||
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
|
||||
if err != nil {
|
||||
return briefing.Package{}, err
|
||||
}
|
||||
return store.LoadBriefing(ctx, metadata.BriefingPath)
|
||||
return inspection.store.LoadBriefing(ctx, inspection.metadata.BriefingPath)
|
||||
}
|
||||
|
||||
func InspectDataPackage(ctx context.Context, req InspectRunRequest) (promptinput.Package, error) {
|
||||
store, err := defaultStore(req.Config)
|
||||
inspection, err := inspectRun(ctx, req)
|
||||
if err != nil {
|
||||
return promptinput.Package{}, err
|
||||
}
|
||||
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
|
||||
if err != nil {
|
||||
return promptinput.Package{}, err
|
||||
}
|
||||
return store.LoadDataPackage(ctx, metadata.DataPackagePath)
|
||||
return inspection.store.LoadDataPackage(ctx, inspection.metadata.DataPackagePath)
|
||||
}
|
||||
|
||||
func InspectPriorSnapshot(ctx context.Context, req InspectRunRequest) (*state.PriorSnapshot, error) {
|
||||
store, err := defaultStore(req.Config)
|
||||
inspection, err := inspectRun(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
|
||||
resolved, err := resolvedFromMetadata(inspection.metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolved, err := resolvedFromMetadata(metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store.FindPriorSnapshot(ctx, resolved)
|
||||
return inspection.store.FindPriorSnapshot(ctx, resolved)
|
||||
}
|
||||
|
||||
func InspectSources(ctx context.Context, req InspectRunRequest) (SourceInspection, error) {
|
||||
metadata, err := InspectMetadata(ctx, req)
|
||||
inspection, err := inspectRun(ctx, req)
|
||||
if err != nil {
|
||||
return SourceInspection{}, err
|
||||
}
|
||||
metadata := inspection.metadata
|
||||
return SourceInspection{
|
||||
RunID: metadata.RunID,
|
||||
ReportID: metadata.ReportID,
|
||||
@@ -102,6 +87,23 @@ func InspectSources(ctx context.Context, req InspectRunRequest) (SourceInspectio
|
||||
}, nil
|
||||
}
|
||||
|
||||
type runInspection struct {
|
||||
store *state.FilesystemStore
|
||||
metadata state.Metadata
|
||||
}
|
||||
|
||||
func inspectRun(ctx context.Context, req InspectRunRequest) (runInspection, error) {
|
||||
store, err := defaultStore(req.Config)
|
||||
if err != nil {
|
||||
return runInspection{}, err
|
||||
}
|
||||
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
|
||||
if err != nil {
|
||||
return runInspection{}, err
|
||||
}
|
||||
return runInspection{store: store, metadata: metadata}, nil
|
||||
}
|
||||
|
||||
func resolvedFromMetadata(metadata state.Metadata) (report.Resolved, error) {
|
||||
definition, err := report.DefaultRegistry().Lookup(metadata.ReportID)
|
||||
if err != nil {
|
||||
|
||||
@@ -68,9 +68,8 @@ func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, erro
|
||||
if summary == nil {
|
||||
return Package{}, fmt.Errorf("daily forecast summary is required")
|
||||
}
|
||||
pkg := Package{
|
||||
Metadata: BuildMetadata(ctx),
|
||||
Daily: &Daily{
|
||||
pkg := buildPackage(ctx)
|
||||
pkg.Daily = &Daily{
|
||||
BottomLine: buildBottomLine(summary),
|
||||
Dayparts: summary.Dayparts,
|
||||
RelevantAlerts: summary.AlertOverlaps,
|
||||
@@ -79,8 +78,8 @@ func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, erro
|
||||
Discussion: buildDiscussion(summary.Discussion),
|
||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||
ForecastSummaryDate: summary.Date,
|
||||
},
|
||||
}
|
||||
setRelevantAlertCount(&pkg.Metadata, len(summary.AlertOverlaps))
|
||||
if ctx.Resolved.Definition.ID == report.DailyTomorrow {
|
||||
pkg.Daily.Planning = buildTomorrowPlanning(summary)
|
||||
}
|
||||
@@ -176,9 +175,6 @@ func readinessNotes(daypart forecast.DaypartSummary) []string {
|
||||
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
||||
notes = append(notes, fmt.Sprintf("Morning gusts may reach %.0f mph.", daypart.PeakWindGust.Value))
|
||||
}
|
||||
if daypart.Indicators.Thunder {
|
||||
notes = append(notes, "Morning thunder could affect departure timing.")
|
||||
}
|
||||
if daypart.Indicators.Snow || daypart.Indicators.Ice {
|
||||
notes = append(notes, "Morning wintry weather could affect surfaces and travel.")
|
||||
}
|
||||
@@ -203,9 +199,6 @@ func concernNotes(daypart forecast.DaypartSummary) []string {
|
||||
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
||||
notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", prefix, daypart.PeakWindGust.Value))
|
||||
}
|
||||
if daypart.Indicators.Thunder {
|
||||
notes = append(notes, prefix+" thunder may disrupt outdoor plans.")
|
||||
}
|
||||
if daypart.Indicators.Snow || daypart.Indicators.Ice {
|
||||
notes = append(notes, prefix+" wintry weather may affect travel.")
|
||||
}
|
||||
@@ -229,9 +222,6 @@ func overnightWatchNotes(daypart forecast.DaypartSummary) []string {
|
||||
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
||||
notes = append(notes, fmt.Sprintf("Overnight gusts may reach %.0f mph before morning plans begin.", daypart.PeakWindGust.Value))
|
||||
}
|
||||
if daypart.Indicators.Thunder {
|
||||
notes = append(notes, "Overnight storms could change morning impacts.")
|
||||
}
|
||||
if daypart.Indicators.Snow || daypart.Indicators.Ice {
|
||||
notes = append(notes, "Overnight wintry weather could leave morning travel impacts.")
|
||||
}
|
||||
@@ -259,10 +249,10 @@ func buildDiscussion(discussion *forecast.Discussion) DiscussionContext {
|
||||
KeyMessages: discussion.KeyMessages,
|
||||
}
|
||||
if discussion.ShortTerm != nil {
|
||||
ctx.ShortTerm = discussion.ShortTerm.Narrative
|
||||
ctx.ShortTerm = discussion.ShortTerm.Text
|
||||
}
|
||||
if discussion.LongTerm != nil {
|
||||
ctx.LongTerm = discussion.LongTerm.Narrative
|
||||
ctx.LongTerm = discussion.LongTerm.Text
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@@ -293,10 +283,6 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
|
||||
score += float64(len(daypart.AlertOverlaps)) * 100
|
||||
reasons = append(reasons, "alert overlap")
|
||||
}
|
||||
if daypart.Indicators.Thunder {
|
||||
score += 75
|
||||
reasons = append(reasons, "thunder risk")
|
||||
}
|
||||
if daypart.Indicators.Heat || daypart.Indicators.Cold {
|
||||
score += 25
|
||||
if daypart.Indicators.Heat {
|
||||
@@ -334,9 +320,6 @@ func bottomLineText(conditions []string, hazards []string) string {
|
||||
|
||||
func hazardsForIndicators(indicators forecast.Indicators) []string {
|
||||
var hazards []string
|
||||
if indicators.Thunder {
|
||||
hazards = append(hazards, "thunder")
|
||||
}
|
||||
if indicators.Snow {
|
||||
hazards = append(hazards, "snow")
|
||||
}
|
||||
|
||||
@@ -15,6 +15,19 @@ import (
|
||||
|
||||
func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
||||
bundle := loadBundleFixture(t)
|
||||
currentIsDay := true
|
||||
currentTemp := 75.9
|
||||
currentFeelsLike := 76.1
|
||||
currentHumidity := 56.0
|
||||
currentWind := 10.7
|
||||
bundle.Current = &forecast.Current{
|
||||
ConditionText: "Partly cloudy",
|
||||
IsDay: ¤tIsDay,
|
||||
TemperatureF: ¤tTemp,
|
||||
ApparentTemperatureF: ¤tFeelsLike,
|
||||
RelativeHumidityPercent: ¤tHumidity,
|
||||
WindSpeedMph: ¤tWind,
|
||||
}
|
||||
bundle.Sources[0].DataSHA256 = "abc123"
|
||||
bundle.Warnings = []forecast.SourceWarning{{Source: "daily", Code: "missing_source", Severity: "warning"}}
|
||||
location := mustLocation(t)
|
||||
@@ -29,6 +42,12 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
||||
Bundle: bundle,
|
||||
Units: "us",
|
||||
Timezone: "America/Chicago",
|
||||
Location: &LocationContext{
|
||||
ID: "home",
|
||||
Name: "Brentwood",
|
||||
Region: "St. Louis Metro",
|
||||
Timezone: "America/Chicago",
|
||||
},
|
||||
}, summary)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDaily() error = %v", err)
|
||||
@@ -46,6 +65,12 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
||||
if pkg.Metadata.Units != "us" || pkg.Metadata.Timezone != "America/Chicago" {
|
||||
t.Fatalf("metadata units/timezone = %q/%q", pkg.Metadata.Units, pkg.Metadata.Timezone)
|
||||
}
|
||||
if pkg.Metadata.Location == nil || pkg.Metadata.Location.ID != "home" || pkg.Metadata.Location.Name != "Brentwood" || pkg.Metadata.Location.Region != "St. Louis Metro" || pkg.Metadata.Location.Timezone != "America/Chicago" {
|
||||
t.Fatalf("metadata location = %#v, want configured prompt location", pkg.Metadata.Location)
|
||||
}
|
||||
if pkg.CurrentConditions == nil || pkg.CurrentConditions.ConditionText != "Partly cloudy" || pkg.CurrentConditions.TemperatureF == nil || *pkg.CurrentConditions.TemperatureF != currentTemp || pkg.CurrentConditions.RelativeHumidityPercent == nil || *pkg.CurrentConditions.RelativeHumidityPercent != currentHumidity {
|
||||
t.Fatalf("CurrentConditions = %#v, want current conditions from bundle", pkg.CurrentConditions)
|
||||
}
|
||||
if len(pkg.Metadata.Sources) != 1 || pkg.Metadata.Sources[0].DataSHA256 != "abc123" {
|
||||
t.Fatalf("Sources = %#v, want source hash", pkg.Metadata.Sources)
|
||||
}
|
||||
@@ -55,8 +80,8 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
||||
if pkg.Daily == nil {
|
||||
t.Fatal("Daily = nil")
|
||||
}
|
||||
if len(pkg.Daily.Dayparts) != 4 {
|
||||
t.Fatalf("Dayparts length = %d, want 4", len(pkg.Daily.Dayparts))
|
||||
if len(pkg.Daily.Dayparts) != 5 {
|
||||
t.Fatalf("Dayparts length = %d, want 5", len(pkg.Daily.Dayparts))
|
||||
}
|
||||
if len(pkg.Daily.RelevantAlerts) != 1 {
|
||||
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.Daily.RelevantAlerts))
|
||||
@@ -67,6 +92,12 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
||||
if len(pkg.Daily.Discussion.KeyMessages) != 1 {
|
||||
t.Fatalf("Discussion key messages length = %d, want 1", len(pkg.Daily.Discussion.KeyMessages))
|
||||
}
|
||||
if pkg.Daily.Discussion.ShortTerm != "Morning showers taper as a weak boundary shifts east." {
|
||||
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Daily.Discussion.ShortTerm)
|
||||
}
|
||||
if pkg.Daily.Discussion.LongTerm != "Warmer and more humid conditions return with periodic rain chances." {
|
||||
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Daily.Discussion.LongTerm)
|
||||
}
|
||||
if pkg.Daily.OutdoorWindows.Best == nil || pkg.Daily.OutdoorWindows.Worst == nil {
|
||||
t.Fatalf("OutdoorWindows = %#v, want best and worst", pkg.Daily.OutdoorWindows)
|
||||
}
|
||||
@@ -85,7 +116,13 @@ func TestDailyBriefingQuietWeather(t *testing.T) {
|
||||
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{
|
||||
quietHour("2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", 72),
|
||||
}},
|
||||
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
|
||||
Alerts: &forecast.AlertRun{},
|
||||
Sources: []forecast.Source{
|
||||
{Name: "hourly", FetchedAt: time.Now()},
|
||||
{Name: "alerts", Endpoint: "/alerts/active", FetchedAt: time.Now()},
|
||||
{Name: "current", Endpoint: "/conditions/current", FetchedAt: time.Now(), Missing: true},
|
||||
},
|
||||
Warnings: []forecast.SourceWarning{{Source: "current", Code: "missing_source", Severity: "warning"}},
|
||||
}
|
||||
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
|
||||
if err != nil {
|
||||
@@ -98,9 +135,28 @@ func TestDailyBriefingQuietWeather(t *testing.T) {
|
||||
if pkg.Daily.BottomLine.Summary != "Conditions: Clear." {
|
||||
t.Fatalf("BottomLine summary = %q, want clear conditions", pkg.Daily.BottomLine.Summary)
|
||||
}
|
||||
if pkg.CurrentConditions != nil {
|
||||
t.Fatalf("CurrentConditions = %#v, want nil when current conditions are missing", pkg.CurrentConditions)
|
||||
}
|
||||
if len(pkg.Metadata.SourceWarnings) != 1 || pkg.Metadata.SourceWarnings[0].Source != "current" {
|
||||
t.Fatalf("SourceWarnings = %#v, want current missing-source warning", pkg.Metadata.SourceWarnings)
|
||||
}
|
||||
if len(pkg.Daily.RelevantAlerts) != 0 {
|
||||
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts))
|
||||
}
|
||||
if pkg.Metadata.Alerts == nil {
|
||||
t.Fatal("Metadata.Alerts = nil, want checked no-active-alerts status")
|
||||
}
|
||||
if !pkg.Metadata.Alerts.Checked || pkg.Metadata.Alerts.ActiveCount != 0 || pkg.Metadata.Alerts.RelevantCount != 0 || pkg.Metadata.Alerts.Missing {
|
||||
t.Fatalf("Metadata.Alerts = %#v, want checked no-active-alerts status", pkg.Metadata.Alerts)
|
||||
}
|
||||
data, err := json.Marshal(pkg.Metadata.Alerts)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal alert metadata: %v", err)
|
||||
}
|
||||
if strings.Contains(string(data), `"missing"`) {
|
||||
t.Fatalf("alert metadata includes missing for checked empty alerts:\n%s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyBriefingAlertExclusion(t *testing.T) {
|
||||
@@ -163,7 +219,7 @@ func TestTomorrowBriefingIncludesPlanningInputs(t *testing.T) {
|
||||
Value: wind,
|
||||
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 {
|
||||
return []forecast.DaypartDefinition{
|
||||
{Name: "overnight", Start: "00:00", End: "06:00"},
|
||||
{Name: "morning", Start: "06:00", End: "12:00"},
|
||||
{Name: "afternoon", Start: "12:00", End: "18:00"},
|
||||
{Name: "evening", Start: "18:00", End: "24:00"},
|
||||
{Name: "morning", Start: "06:00", End: "10:00"},
|
||||
{Name: "midday", Start: "10:00", End: "15:00"},
|
||||
{Name: "afternoon", Start: "15:00", End: "17:00"},
|
||||
{Name: "evening", Start: "17:00", End: "24:00"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
@@ -17,6 +15,7 @@ const SchemaVersion = "weatherreporter.briefing.v1"
|
||||
|
||||
type Package struct {
|
||||
Metadata Metadata `json:"metadata"`
|
||||
CurrentConditions *CurrentConditionsContext `json:"currentConditions,omitempty"`
|
||||
Daily *Daily `json:"daily,omitempty"`
|
||||
ThreeDay *ThreeDay `json:"threeDay,omitempty"`
|
||||
Weekend *Weekend `json:"weekend,omitempty"`
|
||||
@@ -33,10 +32,34 @@ type Metadata struct {
|
||||
Units string `json:"units"`
|
||||
Timezone string `json:"timezone"`
|
||||
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||
Location *LocationContext `json:"location,omitempty"`
|
||||
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
||||
SourceLocation string `json:"sourceLocation,omitempty"`
|
||||
Sources []SourceMetadata `json:"sources,omitempty"`
|
||||
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"`
|
||||
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 {
|
||||
@@ -50,11 +73,19 @@ type SourceMetadata struct {
|
||||
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 {
|
||||
Resolved report.Resolved
|
||||
Bundle *forecast.Bundle
|
||||
Units string
|
||||
Timezone string
|
||||
Location *LocationContext
|
||||
}
|
||||
|
||||
func BuildMetadata(ctx BuildContext) Metadata {
|
||||
@@ -70,37 +101,85 @@ func BuildMetadata(ctx BuildContext) Metadata {
|
||||
Units: ctx.Units,
|
||||
Timezone: ctx.Timezone,
|
||||
ValidPeriod: metadata.ValidPeriod,
|
||||
Location: copyLocation(ctx.Location),
|
||||
SourceLocationID: sourceLocationID,
|
||||
SourceLocation: sourceLocation,
|
||||
Sources: sourceMetadata(ctx.Bundle),
|
||||
SourceWarnings: sourceWarnings(ctx.Bundle),
|
||||
Alerts: alertStatus(ctx.Bundle),
|
||||
}
|
||||
}
|
||||
|
||||
func Save(path string, pkg Package) error {
|
||||
data, err := json.MarshalIndent(pkg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal briefing package: %w", err)
|
||||
func buildPackage(ctx BuildContext) Package {
|
||||
return Package{
|
||||
Metadata: BuildMetadata(ctx),
|
||||
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 {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("write temporary briefing file: %w", err)
|
||||
func copyLocation(location *LocationContext) *LocationContext {
|
||||
if location == nil {
|
||||
return nil
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temporary briefing file: %w", err)
|
||||
copied := *location
|
||||
return &copied
|
||||
}
|
||||
|
||||
func currentConditions(bundle *forecast.Bundle) *CurrentConditionsContext {
|
||||
if bundle == nil || bundle.Current == nil {
|
||||
return nil
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("save briefing %q: %w", path, err)
|
||||
current := bundle.Current
|
||||
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
|
||||
}
|
||||
@@ -147,6 +226,37 @@ func sourceWarnings(bundle *forecast.Bundle) []forecast.SourceWarning {
|
||||
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 {
|
||||
switch id {
|
||||
case report.DailyToday:
|
||||
|
||||
@@ -56,10 +56,10 @@ func BuildStorm(ctx BuildContext) (Package, error) {
|
||||
Discussion: buildDiscussion(ctx.Bundle.Discussion),
|
||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||
}
|
||||
return Package{
|
||||
Metadata: BuildMetadata(ctx),
|
||||
Storm: storm,
|
||||
}, nil
|
||||
pkg := buildPackage(ctx)
|
||||
pkg.Storm = storm
|
||||
setRelevantAlertCount(&pkg.Metadata, len(alerts))
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
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+".")
|
||||
}
|
||||
}
|
||||
if summary.Indicators.Thunder {
|
||||
items = appendUnique(items, "Thunderstorm timing or intensity could be more disruptive than the baseline forecast.")
|
||||
}
|
||||
if summary.Indicators.Wind {
|
||||
items = appendUnique(items, "Wind impacts could be higher where stronger gusts occur.")
|
||||
}
|
||||
@@ -161,7 +158,7 @@ func stormConfidenceInputs(bundle *forecast.Bundle) []string {
|
||||
}
|
||||
if bundle.Discussion != nil {
|
||||
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.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,12 @@ func TestStormBriefingWithActiveAlert(t *testing.T) {
|
||||
Alerts: &forecast.AlertRun{Alerts: []json.RawMessage{
|
||||
json.RawMessage(`{"event":"Severe Thunderstorm Warning","headline":"Severe storms near Testville","severity":"Severe","effective":"2026-05-29T06:30:00-05:00","expires":"2026-05-29T08:30:00-05:00"}`),
|
||||
}},
|
||||
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Storms may intensify quickly."}},
|
||||
Discussion: &forecast.Discussion{
|
||||
Product: "discussion",
|
||||
KeyMessages: []string{"Storms may intensify quickly."},
|
||||
ShortTerm: &forecast.DiscussionSection{Text: "Short-term storm coverage peaks this morning."},
|
||||
LongTerm: &forecast.DiscussionSection{Text: "Long-term pattern stays unsettled after the event."},
|
||||
},
|
||||
WeatherStory: &forecast.WeatherStory{Raw: json.RawMessage(`{"headline":"Storm risk"}`)},
|
||||
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
|
||||
}
|
||||
@@ -75,6 +80,12 @@ func TestStormBriefingWithActiveAlert(t *testing.T) {
|
||||
if pkg.Storm.WeatherStory == nil {
|
||||
t.Fatal("WeatherStory = nil, want available story context")
|
||||
}
|
||||
if pkg.Storm.Discussion.ShortTerm != "Short-term storm coverage peaks this morning." {
|
||||
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Storm.Discussion.ShortTerm)
|
||||
}
|
||||
if pkg.Storm.Discussion.LongTerm != "Long-term pattern stays unsettled after the event." {
|
||||
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Storm.Discussion.LongTerm)
|
||||
}
|
||||
if len(pkg.Storm.WhatToWatchNext) == 0 {
|
||||
t.Fatal("WhatToWatchNext length = 0, want watch inputs")
|
||||
}
|
||||
|
||||
@@ -37,18 +37,17 @@ func BuildThreeDay(ctx BuildContext, summaries []forecast.DailySummary) (Package
|
||||
if len(summaries) == 0 {
|
||||
return Package{}, fmt.Errorf("3-day forecast summaries are required")
|
||||
}
|
||||
pkg := Package{
|
||||
Metadata: BuildMetadata(ctx),
|
||||
ThreeDay: &ThreeDay{
|
||||
pkg := buildPackage(ctx)
|
||||
pkg.ThreeDay = &ThreeDay{
|
||||
Discussion: buildDiscussion(summaries[0].Discussion),
|
||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||
},
|
||||
}
|
||||
for _, summary := range summaries {
|
||||
day := buildOutlookDay(summary)
|
||||
pkg.ThreeDay.Days = append(pkg.ThreeDay.Days, day)
|
||||
}
|
||||
pkg.ThreeDay.RelevantAlerts = collectOutlookAlerts(pkg.ThreeDay.Days)
|
||||
setRelevantAlertCount(&pkg.Metadata, len(pkg.ThreeDay.RelevantAlerts))
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -39,11 +39,16 @@ func TestThreeDayBriefingBuildsOutlookDays(t *testing.T) {
|
||||
Value: gust,
|
||||
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"}},
|
||||
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",
|
||||
@@ -74,10 +79,16 @@ func TestThreeDayBriefingBuildsOutlookDays(t *testing.T) {
|
||||
t.Fatalf("Days length = %d, want 2", len(pkg.ThreeDay.Days))
|
||||
}
|
||||
first := pkg.ThreeDay.Days[0]
|
||||
if !strings.Contains(first.OverallCharacter, "Showers") || !strings.Contains(strings.Join(first.Risks, ","), "thunder") {
|
||||
if !strings.Contains(first.OverallCharacter, "Showers") || !strings.Contains(strings.Join(first.Risks, ","), "wind") {
|
||||
t.Fatalf("first day = %#v, want conditions and risks", first)
|
||||
}
|
||||
if len(pkg.ThreeDay.RelevantAlerts) != 1 {
|
||||
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.ThreeDay.RelevantAlerts))
|
||||
}
|
||||
if pkg.ThreeDay.Discussion.ShortTerm != "Short-term rain chances remain focused today." {
|
||||
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.ThreeDay.Discussion.ShortTerm)
|
||||
}
|
||||
if pkg.ThreeDay.Discussion.LongTerm != "Long-term warmth builds into the weekend." {
|
||||
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.ThreeDay.Discussion.LongTerm)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,17 +31,16 @@ func BuildWeekend(ctx BuildContext, summaries []forecast.DailySummary) (Package,
|
||||
if len(summaries) == 0 {
|
||||
return Package{}, fmt.Errorf("weekend forecast summaries are required")
|
||||
}
|
||||
pkg := Package{
|
||||
Metadata: BuildMetadata(ctx),
|
||||
Weekend: &Weekend{
|
||||
pkg := buildPackage(ctx)
|
||||
pkg.Weekend = &Weekend{
|
||||
Discussion: buildDiscussion(summaries[0].Discussion),
|
||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||
},
|
||||
}
|
||||
for _, summary := range summaries {
|
||||
pkg.Weekend.Days = append(pkg.Weekend.Days, buildOutlookDay(summary))
|
||||
}
|
||||
pkg.Weekend.RelevantAlerts = collectOutlookAlerts(pkg.Weekend.Days)
|
||||
setRelevantAlertCount(&pkg.Metadata, len(pkg.Weekend.RelevantAlerts))
|
||||
pkg.Weekend.Planning = buildWeekendPlanning(pkg.Weekend.Days, pkg.Weekend.Discussion, ctx.Bundle)
|
||||
return pkg, nil
|
||||
}
|
||||
@@ -96,9 +95,6 @@ func weekendRainStormNotes(date string, daypart forecast.DaypartSummary) []strin
|
||||
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 {
|
||||
notes = append(notes, fmt.Sprintf("%s precipitation chance peaks near %.0f%%.", label, daypart.MaxPrecipitationProbability.Value))
|
||||
}
|
||||
if daypart.Indicators.Thunder {
|
||||
notes = append(notes, label+" thunder risk is present.")
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) {
|
||||
Value: gust,
|
||||
Time: mustParse("2026-05-30T16:00:00-05:00"),
|
||||
},
|
||||
Indicators: forecast.Indicators{Thunder: true, Wind: true},
|
||||
Indicators: forecast.Indicators{Wind: true},
|
||||
HourlyPeriods: []forecast.ForecastPeriod{
|
||||
{
|
||||
StartTime: mustParse("2026-05-30T15:00:00-05:00"),
|
||||
@@ -54,7 +54,12 @@ func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) {
|
||||
},
|
||||
},
|
||||
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 {
|
||||
t.Fatalf("WorstWeatherWindows = %#v, want weather window", pkg.Weekend.Planning.WorstWeatherWindows)
|
||||
}
|
||||
if !strings.Contains(strings.Join(pkg.Weekend.Planning.RainStormTiming, " "), "thunder") {
|
||||
t.Fatalf("RainStormTiming = %#v, want thunder timing", pkg.Weekend.Planning.RainStormTiming)
|
||||
if !strings.Contains(strings.Join(pkg.Weekend.Planning.RainStormTiming, " "), "precipitation") {
|
||||
t.Fatalf("RainStormTiming = %#v, want precipitation timing", pkg.Weekend.Planning.RainStormTiming)
|
||||
}
|
||||
if len(pkg.Weekend.Planning.UncertaintyInputs) == 0 {
|
||||
t.Fatal("UncertaintyInputs length = 0, want discussion context")
|
||||
}
|
||||
if pkg.Weekend.Discussion.ShortTerm != "Short-term showers exit before the weekend." {
|
||||
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Weekend.Discussion.ShortTerm)
|
||||
}
|
||||
if pkg.Weekend.Discussion.LongTerm != "Long-term weekend rain timing remains uncertain." {
|
||||
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Weekend.Discussion.LongTerm)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,6 @@ func compareIndicators(previous forecast.Indicators, current forecast.Indicators
|
||||
previous bool
|
||||
current bool
|
||||
}{
|
||||
{name: "thunder", previous: previous.Thunder, current: current.Thunder},
|
||||
{name: "snow", previous: previous.Snow, current: current.Snow},
|
||||
{name: "ice", previous: previous.Ice, current: current.Ice},
|
||||
} {
|
||||
@@ -146,7 +145,6 @@ func compareIndicators(previous forecast.Indicators, current forecast.Indicators
|
||||
func aggregateIndicators(dayparts []forecast.DaypartSummary) forecast.Indicators {
|
||||
out := forecast.Indicators{}
|
||||
for _, daypart := range dayparts {
|
||||
out.Thunder = out.Thunder || daypart.Indicators.Thunder
|
||||
out.Snow = out.Snow || daypart.Indicators.Snow
|
||||
out.Ice = out.Ice || daypart.Indicators.Ice
|
||||
}
|
||||
|
||||
@@ -62,14 +62,14 @@ func TestCompareDailyAlertAddedAndRemoved(t *testing.T) {
|
||||
|
||||
func TestCompareDailyIndicatorChange(t *testing.T) {
|
||||
previous := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
||||
current := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{Thunder: true})
|
||||
current := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{Snow: true})
|
||||
|
||||
changes, err := CompareDaily(previous, current, testThresholds())
|
||||
if err != nil {
|
||||
t.Fatalf("CompareDaily() error = %v", err)
|
||||
}
|
||||
if countType(changes, "thunder_risk_change") != 1 {
|
||||
t.Fatalf("changes = %#v, want thunder risk change", changes)
|
||||
if countType(changes, "snow_risk_change") != 1 {
|
||||
t.Fatalf("changes = %#v, want snow risk change", changes)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestCompareThreeDayDetectsDayChanges(t *testing.T) {
|
||||
Value: currentPrecip,
|
||||
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")
|
||||
}
|
||||
var foundPrecip bool
|
||||
var foundThunder bool
|
||||
var foundSnow bool
|
||||
for _, change := range changes {
|
||||
if change.Type == "outlook_precip_probability_change" {
|
||||
foundPrecip = true
|
||||
}
|
||||
if change.Type == "outlook_thunder_risk_change" {
|
||||
foundThunder = true
|
||||
if change.Type == "outlook_snow_risk_change" {
|
||||
foundSnow = true
|
||||
}
|
||||
}
|
||||
if !foundPrecip || !foundThunder {
|
||||
t.Fatalf("changes = %#v, want precipitation and thunder changes", changes)
|
||||
if !foundPrecip || !foundSnow {
|
||||
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{{
|
||||
Date: "2026-05-30",
|
||||
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")
|
||||
}
|
||||
for _, change := range changes {
|
||||
if change.Type == "weekend_outlook_thunder_risk_change" {
|
||||
if change.Type == "weekend_outlook_snow_risk_change" {
|
||||
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/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"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)
|
||||
if result != nil {
|
||||
writeRunLogs(stderr, result)
|
||||
if encodeErr := writeRunSummary(stdout, result); encodeErr != nil {
|
||||
if encodeErr := writeJSON(stdout, result); encodeErr != nil {
|
||||
return encodeErr
|
||||
}
|
||||
if result.Failed > 0 {
|
||||
@@ -108,6 +109,29 @@ type inspectOptions struct {
|
||||
RunID string
|
||||
}
|
||||
|
||||
type inspectRunCommand struct {
|
||||
Name string
|
||||
Inspect func(context.Context, app.InspectRunRequest) (any, error)
|
||||
}
|
||||
|
||||
var inspectRunCommands = []inspectRunCommand{
|
||||
{Name: "metadata", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
|
||||
return app.InspectMetadata(ctx, req)
|
||||
}},
|
||||
{Name: "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 {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("inspect requires a command")
|
||||
@@ -128,81 +152,32 @@ func (r Runner) runInspect(ctx context.Context, args []string, stdout io.Writer)
|
||||
return err
|
||||
}
|
||||
return writeJSON(stdout, records)
|
||||
case "metadata":
|
||||
opts, err := parseInspectRunFlags(command, args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
metadata, err := app.InspectMetadata(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(stdout, metadata)
|
||||
case "briefing":
|
||||
opts, err := parseInspectRunFlags(command, args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pkg, err := app.InspectBriefing(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(stdout, pkg)
|
||||
case "data-package":
|
||||
opts, err := parseInspectRunFlags(command, args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pkg, err := app.InspectDataPackage(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(stdout, pkg)
|
||||
case "prior":
|
||||
opts, err := parseInspectRunFlags(command, args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
prior, err := app.InspectPriorSnapshot(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(stdout, prior)
|
||||
case "sources":
|
||||
opts, err := parseInspectRunFlags(command, args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sources, err := app.InspectSources(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(stdout, sources)
|
||||
default:
|
||||
for _, candidate := range inspectRunCommands {
|
||||
if candidate.Name == command {
|
||||
return runInspectRunCommand(ctx, stdout, candidate, args[1:])
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("unknown inspect command %q", command)
|
||||
}
|
||||
}
|
||||
|
||||
func runInspectRunCommand(ctx context.Context, stdout io.Writer, command inspectRunCommand, args []string) error {
|
||||
opts, err := parseInspectRunFlags(command.Name, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
value, err := command.Inspect(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(stdout, value)
|
||||
}
|
||||
|
||||
func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
||||
if r.Clock == nil {
|
||||
r.Clock = timeutil.SystemClock{}
|
||||
@@ -210,12 +185,12 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
||||
if len(args) == 0 {
|
||||
return app.GenerateRequest{}, fmt.Errorf("generate requires a report name")
|
||||
}
|
||||
report, ok := reportKind(args[0])
|
||||
reportKind, ok := reportKind(args[0])
|
||||
if !ok {
|
||||
return app.GenerateRequest{}, fmt.Errorf("unknown generate report %q", args[0])
|
||||
}
|
||||
|
||||
opts, err := parseGenerateFlags(report, args[1:])
|
||||
opts, err := parseGenerateFlags(reportKind, args[1:])
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, err
|
||||
}
|
||||
@@ -223,7 +198,6 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
||||
Path: opts.ConfigPath,
|
||||
Units: opts.Units,
|
||||
Timezone: opts.Timezone,
|
||||
Output: opts.Output,
|
||||
})
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, err
|
||||
@@ -235,12 +209,12 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
||||
|
||||
req := app.GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: report,
|
||||
Report: reportKind,
|
||||
OutputPath: opts.Output,
|
||||
Now: r.Clock.Now(),
|
||||
}
|
||||
|
||||
switch report {
|
||||
switch reportKind {
|
||||
case app.ReportDaily:
|
||||
if opts.Date == "" {
|
||||
req.Date = timeutil.LocalDate(r.Clock.Now(), location)
|
||||
@@ -257,17 +231,12 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
||||
if opts.End == "" {
|
||||
return app.GenerateRequest{}, fmt.Errorf("generate storm requires --end")
|
||||
}
|
||||
req.StormStart, err = timeutil.ParseStormTime(opts.Start, location)
|
||||
period, err := report.ParseStormPeriod(opts.Start, opts.End, location)
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, err
|
||||
}
|
||||
req.StormEnd, err = timeutil.ParseStormTime(opts.End, location)
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, err
|
||||
}
|
||||
if !req.StormEnd.After(req.StormStart) {
|
||||
return app.GenerateRequest{}, fmt.Errorf("generate storm requires --end after --start")
|
||||
}
|
||||
req.StormStart = period.Start
|
||||
req.StormEnd = period.End
|
||||
}
|
||||
|
||||
return req, nil
|
||||
@@ -372,12 +341,6 @@ func parseInspectRunFlags(command string, args []string) (inspectOptions, error)
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func writeRunSummary(stdout io.Writer, result *app.BatchResult) error {
|
||||
encoder := json.NewEncoder(stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(result)
|
||||
}
|
||||
|
||||
func writeJSON(stdout io.Writer, value any) error {
|
||||
encoder := json.NewEncoder(stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
|
||||
@@ -62,12 +62,8 @@ func TestRunGenerateStormWritesMarkdownReport(t *testing.T) {
|
||||
server := dailyServer(t)
|
||||
tempDir := t.TempDir()
|
||||
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
||||
configPath := filepath.Join(tempDir, "config.yml")
|
||||
workspaceRoot := filepath.Join(tempDir, "workspace")
|
||||
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
|
||||
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
|
||||
outPath := filepath.Join(tempDir, "storm.md")
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
@@ -90,14 +86,8 @@ func TestRunGenerateStormWritesMarkdownReport(t *testing.T) {
|
||||
if !strings.Contains(string(report), "# Daily Report") {
|
||||
t.Fatalf("report output missing markdown:\n%s", string(report))
|
||||
}
|
||||
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob data package: %v", err)
|
||||
}
|
||||
if len(dataPackageMatches) != 1 {
|
||||
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
|
||||
}
|
||||
data, err := os.ReadFile(dataPackageMatches[0])
|
||||
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.json")
|
||||
data, err := os.ReadFile(dataPackagePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read managed data package: %v", err)
|
||||
}
|
||||
@@ -110,12 +100,8 @@ func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
|
||||
server := dailyServer(t)
|
||||
tempDir := t.TempDir()
|
||||
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
||||
configPath := filepath.Join(tempDir, "config.yml")
|
||||
workspaceRoot := filepath.Join(tempDir, "workspace")
|
||||
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
|
||||
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
|
||||
outPath := filepath.Join(tempDir, "tomorrow.md")
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
@@ -136,14 +122,8 @@ func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
|
||||
if !strings.Contains(string(report), "# Daily Report") {
|
||||
t.Fatalf("report output missing markdown:\n%s", string(report))
|
||||
}
|
||||
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob data package: %v", err)
|
||||
}
|
||||
if len(dataPackageMatches) != 1 {
|
||||
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
|
||||
}
|
||||
data, err := os.ReadFile(dataPackageMatches[0])
|
||||
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.json")
|
||||
data, err := os.ReadFile(dataPackagePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read managed data package: %v", err)
|
||||
}
|
||||
@@ -457,6 +437,7 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
||||
"generate", "daily",
|
||||
"--config", configPath,
|
||||
"--date", "2026-05-29",
|
||||
"--tz", "UTC",
|
||||
"--out", outPath,
|
||||
}, &stdout, &stderr)
|
||||
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"`) {
|
||||
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"))
|
||||
if err != nil {
|
||||
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) {
|
||||
runner := Runner{Clock: fixedClock()}
|
||||
tests := []struct {
|
||||
@@ -643,7 +681,15 @@ func TestResolveGenerateAppliesSharedFlags(t *testing.T) {
|
||||
func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) {
|
||||
runner := Runner{Clock: fixedClock()}
|
||||
|
||||
_, err := runner.resolveGenerate([]string{"storm", "--start", "2026-05-29T18:00"})
|
||||
_, err := runner.resolveGenerate([]string{"storm", "--end", "2026-05-29T18:00"})
|
||||
if err == nil {
|
||||
t.Fatal("resolveGenerate() error = nil, want missing start error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "requires --start") {
|
||||
t.Fatalf("error = %q, want missing start", err.Error())
|
||||
}
|
||||
|
||||
_, err = runner.resolveGenerate([]string{"storm", "--start", "2026-05-29T18:00"})
|
||||
if err == nil {
|
||||
t.Fatal("resolveGenerate() error = nil, want missing end error")
|
||||
}
|
||||
@@ -652,6 +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) {
|
||||
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) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -739,6 +821,28 @@ func dailyServer(t *testing.T) *httptest.Server {
|
||||
return server
|
||||
}
|
||||
|
||||
func writeTestConfig(t *testing.T, server *httptest.Server, scriptoriumPath string, workspaceRoot string) string {
|
||||
t.Helper()
|
||||
configPath := filepath.Join(t.TempDir(), "config.yml")
|
||||
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
|
||||
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
return configPath
|
||||
}
|
||||
|
||||
func oneArtifact(t *testing.T, root string, parts ...string) string {
|
||||
t.Helper()
|
||||
matches, err := filepath.Glob(filepath.Join(append([]string{root}, parts...)...))
|
||||
if err != nil {
|
||||
t.Fatalf("glob artifact: %v", err)
|
||||
}
|
||||
if len(matches) != 1 {
|
||||
t.Fatalf("artifact matches = %#v, want one", matches)
|
||||
}
|
||||
return matches[0]
|
||||
}
|
||||
|
||||
func writeFakeScriptorium(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, "scriptorium")
|
||||
|
||||
@@ -14,10 +14,10 @@ const (
|
||||
|
||||
type Config struct {
|
||||
WeatherAPI WeatherAPIConfig `yaml:"weather_api"`
|
||||
Location LocationConfig `yaml:"location"`
|
||||
MissingSource MissingSourceConfig `yaml:"missing_source"`
|
||||
Scriptorium ScriptoriumConfig `yaml:"scriptorium"`
|
||||
Workspace WorkspaceConfig `yaml:"workspace"`
|
||||
Reports ReportOutputConfig `yaml:"reports"`
|
||||
Dayparts []DaypartConfig `yaml:"dayparts"`
|
||||
RecentChange RecentChangeConfig `yaml:"recent_change"`
|
||||
}
|
||||
@@ -31,6 +31,12 @@ type WeatherAPIConfig struct {
|
||||
Format string `yaml:"format"`
|
||||
}
|
||||
|
||||
type LocationConfig struct {
|
||||
ID string `yaml:"id"`
|
||||
Name string `yaml:"name"`
|
||||
Region string `yaml:"region"`
|
||||
}
|
||||
|
||||
type MissingSourceConfig struct {
|
||||
Default MissingSourcePolicy `yaml:"default"`
|
||||
Sources map[string]MissingSourcePolicy `yaml:"sources"`
|
||||
@@ -52,11 +58,6 @@ type WorkspaceConfig struct {
|
||||
PreflightDir string `yaml:"preflight_dir"`
|
||||
}
|
||||
|
||||
type ReportOutputConfig struct {
|
||||
OutputDir string `yaml:"output_dir"`
|
||||
Paths map[string]string `yaml:"paths"`
|
||||
}
|
||||
|
||||
type DaypartConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
Start string `yaml:"start"`
|
||||
|
||||
@@ -17,12 +17,15 @@ func TestDefaults(t *testing.T) {
|
||||
if cfg.WeatherAPI.Units != "us" {
|
||||
t.Fatalf("Units = %q, want us", cfg.WeatherAPI.Units)
|
||||
}
|
||||
if cfg.WeatherAPI.Timezone != "Chicago" {
|
||||
t.Fatalf("Timezone = %q, want Chicago", cfg.WeatherAPI.Timezone)
|
||||
if cfg.WeatherAPI.Timezone != "America/Chicago" {
|
||||
t.Fatalf("Timezone = %q, want America/Chicago", cfg.WeatherAPI.Timezone)
|
||||
}
|
||||
if cfg.WeatherAPI.Format != "json" {
|
||||
t.Fatalf("Format = %q, want json", cfg.WeatherAPI.Format)
|
||||
}
|
||||
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
|
||||
t.Fatalf("Location = %#v, want home/Brentwood/St. Louis Metro", cfg.Location)
|
||||
}
|
||||
if cfg.MissingSource.Default != MissingSourceWarn {
|
||||
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)
|
||||
}
|
||||
|
||||
if cfg.WeatherAPI.BaseURL != "https://weather.api.example.com/" {
|
||||
t.Fatalf("BaseURL = %q, want example URL", cfg.WeatherAPI.BaseURL)
|
||||
if cfg.WeatherAPI.BaseURL != "https://weather.api.rakestrawhome.com/" {
|
||||
t.Fatalf("BaseURL = %q, want configured example URL", cfg.WeatherAPI.BaseURL)
|
||||
}
|
||||
if cfg.WeatherAPI.Timeout != 15*time.Second {
|
||||
t.Fatalf("Timeout = %s, want 15s", cfg.WeatherAPI.Timeout)
|
||||
@@ -43,6 +46,32 @@ func TestLoadExampleConfig(t *testing.T) {
|
||||
if cfg.MissingSource.Sources["alerts"] != MissingSourceNone {
|
||||
t.Fatalf("alerts policy = %q, want none", cfg.MissingSource.Sources["alerts"])
|
||||
}
|
||||
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
|
||||
t.Fatalf("Location = %#v, want example location", cfg.Location)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -72,7 +101,7 @@ func TestInvalidConfigProducesActionableError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoadAppliesOverrides(t *testing.T) {
|
||||
cfg, err := Load(LoadOptions{Units: "metric", Timezone: "+09:30", Output: "./out"})
|
||||
cfg, err := Load(LoadOptions{Units: "metric", Timezone: "+09:30"})
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
@@ -82,7 +111,4 @@ func TestLoadAppliesOverrides(t *testing.T) {
|
||||
if cfg.WeatherAPI.Timezone != "+09:30" {
|
||||
t.Fatalf("Timezone = %q, want +09:30", cfg.WeatherAPI.Timezone)
|
||||
}
|
||||
if cfg.Reports.OutputDir != "./out" {
|
||||
t.Fatalf("OutputDir = %q, want ./out", cfg.Reports.OutputDir)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,14 @@ func Defaults() Config {
|
||||
Timeout: 10 * time.Second,
|
||||
Precision: 1,
|
||||
Units: "us",
|
||||
Timezone: "Chicago",
|
||||
Timezone: "America/Chicago",
|
||||
Format: "json",
|
||||
},
|
||||
Location: LocationConfig{
|
||||
ID: "home",
|
||||
Name: "Brentwood",
|
||||
Region: "St. Louis Metro",
|
||||
},
|
||||
MissingSource: MissingSourceConfig{
|
||||
Default: MissingSourceWarn,
|
||||
Sources: map[string]MissingSourcePolicy{},
|
||||
@@ -28,15 +33,12 @@ func Defaults() Config {
|
||||
DataPackagesDir: "data-packages",
|
||||
PreflightDir: "preflight",
|
||||
},
|
||||
Reports: ReportOutputConfig{
|
||||
OutputDir: "reports",
|
||||
Paths: map[string]string{},
|
||||
},
|
||||
Dayparts: []DaypartConfig{
|
||||
{Name: "overnight", Start: "00:00", End: "06:00"},
|
||||
{Name: "morning", Start: "06:00", End: "12:00"},
|
||||
{Name: "afternoon", Start: "12:00", End: "18:00"},
|
||||
{Name: "evening", Start: "18:00", End: "24:00"},
|
||||
{Name: "morning", Start: "06:00", End: "10:00"},
|
||||
{Name: "midday", Start: "10:00", End: "15:00"},
|
||||
{Name: "afternoon", Start: "15:00", End: "17:00"},
|
||||
{Name: "evening", Start: "17:00", End: "24:00"},
|
||||
},
|
||||
RecentChange: RecentChangeConfig{
|
||||
TemperatureDegrees: 5,
|
||||
|
||||
@@ -12,7 +12,6 @@ type LoadOptions struct {
|
||||
Path string
|
||||
Units string
|
||||
Timezone string
|
||||
Output string
|
||||
}
|
||||
|
||||
func Load(opts LoadOptions) (Config, error) {
|
||||
@@ -35,9 +34,6 @@ func Load(opts LoadOptions) (Config, error) {
|
||||
if opts.Timezone != "" {
|
||||
cfg.WeatherAPI.Timezone = opts.Timezone
|
||||
}
|
||||
if opts.Output != "" {
|
||||
cfg.Reports.OutputDir = opts.Output
|
||||
}
|
||||
|
||||
if err := Validate(cfg); err != nil {
|
||||
return Config{}, err
|
||||
@@ -61,8 +57,5 @@ func mergeFile(cfg *Config, path string) error {
|
||||
if cfg.MissingSource.Sources == nil {
|
||||
cfg.MissingSource.Sources = map[string]MissingSourcePolicy{}
|
||||
}
|
||||
if cfg.Reports.Paths == nil {
|
||||
cfg.Reports.Paths = map[string]string{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -58,9 +58,6 @@ func Validate(cfg Config) error {
|
||||
if cfg.Workspace.Root == "" {
|
||||
return fmt.Errorf("workspace.root is required")
|
||||
}
|
||||
if cfg.Reports.OutputDir == "" {
|
||||
return fmt.Errorf("reports.output_dir is required")
|
||||
}
|
||||
if len(cfg.Dayparts) == 0 {
|
||||
return fmt.Errorf("dayparts must contain at least one entry")
|
||||
}
|
||||
|
||||
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,8 +149,8 @@ type Discussion struct {
|
||||
}
|
||||
|
||||
type DiscussionSection struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Narrative string `json:"narrative,omitempty"`
|
||||
Qualifier string `json:"qualifier,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
IssuedAt *time.Time `json:"issuedAt,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,6 @@ type TimedValue struct {
|
||||
}
|
||||
|
||||
type Indicators struct {
|
||||
Thunder bool `json:"thunder,omitempty"`
|
||||
Snow bool `json:"snow,omitempty"`
|
||||
Ice bool `json:"ice,omitempty"`
|
||||
Fog bool `json:"fog,omitempty"`
|
||||
@@ -291,7 +290,6 @@ func sortedKeys(values map[string]struct{}) []string {
|
||||
func indicatorsForText(text string) Indicators {
|
||||
lower := strings.ToLower(text)
|
||||
return Indicators{
|
||||
Thunder: strings.Contains(lower, "thunder") || strings.Contains(lower, "storm"),
|
||||
Snow: strings.Contains(lower, "snow"),
|
||||
Ice: strings.Contains(lower, "ice") || strings.Contains(lower, "freezing") || strings.Contains(lower, "sleet"),
|
||||
Fog: strings.Contains(lower, "fog"),
|
||||
@@ -318,7 +316,6 @@ func numericIndicators(period ForecastPeriod) Indicators {
|
||||
|
||||
func mergeIndicators(left Indicators, right Indicators) Indicators {
|
||||
return Indicators{
|
||||
Thunder: left.Thunder || right.Thunder,
|
||||
Snow: left.Snow || right.Snow,
|
||||
Ice: left.Ice || right.Ice,
|
||||
Fog: left.Fog || right.Fog,
|
||||
|
||||
@@ -41,10 +41,10 @@ func TestBuildDailySummaryGroupsDaypartsAndComputesMetrics(t *testing.T) {
|
||||
t.Fatalf("morning peak gust = %#v, want 40", morning.PeakWindGust)
|
||||
}
|
||||
if morning.DominantCondition != "Thunderstorms and gusty wind" {
|
||||
t.Fatalf("morning dominant = %q, want thunderstorm condition", morning.DominantCondition)
|
||||
t.Fatalf("morning dominant = %q, want raw forecast condition", morning.DominantCondition)
|
||||
}
|
||||
if !morning.Indicators.Thunder || !morning.Indicators.Wind {
|
||||
t.Fatalf("morning indicators = %#v, want thunder and wind", morning.Indicators)
|
||||
if !morning.Indicators.Wind {
|
||||
t.Fatalf("morning indicators = %#v, want wind", morning.Indicators)
|
||||
}
|
||||
|
||||
afternoon := summary.Dayparts[2]
|
||||
@@ -87,8 +87,8 @@ func TestBuildDailySummaryFromFixtureBundle(t *testing.T) {
|
||||
if len(summary.Dayparts) != 2 {
|
||||
t.Fatalf("Dayparts length = %d, want 2", len(summary.Dayparts))
|
||||
}
|
||||
if !summary.Dayparts[0].Indicators.Thunder {
|
||||
t.Fatalf("morning indicators = %#v, want thunder", summary.Dayparts[0].Indicators)
|
||||
if summary.Dayparts[0].DominantCondition != "Showers and thunderstorms" {
|
||||
t.Fatalf("morning dominant = %q, want raw forecast condition", summary.Dayparts[0].DominantCondition)
|
||||
}
|
||||
if len(summary.AlertOverlaps) != 1 {
|
||||
t.Fatalf("AlertOverlaps length = %d, want 1", len(summary.AlertOverlaps))
|
||||
|
||||
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",
|
||||
"keyMessages": [
|
||||
"Storms are most likely during the morning."
|
||||
]
|
||||
],
|
||||
"shortTerm": {
|
||||
"qualifier": "(Through This Evening)",
|
||||
"text": "Morning showers taper as a weak boundary shifts east."
|
||||
},
|
||||
"longTerm": {
|
||||
"qualifier": "(This Weekend)",
|
||||
"text": "Warmer and more humid conditions return with periodic rain chances."
|
||||
}
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
|
||||
@@ -2,14 +2,12 @@
|
||||
package promptinput
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
@@ -32,6 +30,7 @@ type Report struct {
|
||||
PromptID string `json:"promptId"`
|
||||
GeneratedAt time.Time `json:"generatedAt"`
|
||||
Timezone string `json:"timezone"`
|
||||
CurrentLocalDate string `json:"currentLocalDate"`
|
||||
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) {
|
||||
localDate, err := currentLocalDate(briefingPackage.Metadata.GeneratedAt, briefingPackage.Metadata.Timezone)
|
||||
if err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
items := make([]changes.Change, len(recentChanges))
|
||||
copy(items, recentChanges)
|
||||
if items == nil {
|
||||
@@ -58,6 +61,7 @@ func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []ch
|
||||
PromptID: briefingPackage.Metadata.PromptID,
|
||||
GeneratedAt: briefingPackage.Metadata.GeneratedAt,
|
||||
Timezone: briefingPackage.Metadata.Timezone,
|
||||
CurrentLocalDate: localDate,
|
||||
ValidPeriod: briefingPackage.Metadata.ValidPeriod,
|
||||
},
|
||||
Briefing: briefingPackage,
|
||||
@@ -70,6 +74,14 @@ func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []ch
|
||||
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 {
|
||||
if pkg.SchemaVersion == "" {
|
||||
return fmt.Errorf("schemaVersion is required")
|
||||
@@ -89,6 +101,9 @@ func Validate(pkg Package) error {
|
||||
if pkg.Report.Timezone == "" {
|
||||
return fmt.Errorf("report.timezone is required")
|
||||
}
|
||||
if pkg.Report.CurrentLocalDate == "" {
|
||||
return fmt.Errorf("report.currentLocalDate is required")
|
||||
}
|
||||
if !pkg.Report.ValidPeriod.IsValid() {
|
||||
return fmt.Errorf("report.validPeriod must be valid")
|
||||
}
|
||||
@@ -117,29 +132,8 @@ func Save(path string, pkg Package) error {
|
||||
if err := Validate(pkg); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(pkg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal data package: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create data package directory %q: %w", filepath.Dir(path), err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary data package file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("write temporary data package file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temporary data package file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("save data package %q: %w", path, err)
|
||||
if err := fileutil.WriteJSONAtomic(path, pkg); err != nil {
|
||||
return fmt.Errorf("save data package: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -28,14 +28,51 @@ func TestBuildDailyDataPackage(t *testing.T) {
|
||||
if pkg.Report.PromptID != "weather.daily_report" {
|
||||
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 {
|
||||
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 {
|
||||
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) {
|
||||
pkg, err := Build(validBriefingPackage())
|
||||
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) {
|
||||
briefingPackage := validBriefingPackage()
|
||||
briefingPackage.Metadata.RunID = "20260529T100000Z_three_day"
|
||||
@@ -155,11 +208,20 @@ func validBriefingPackage() briefing.Package {
|
||||
GeneratedAt: generatedAt,
|
||||
Units: "us",
|
||||
Timezone: "America/Chicago",
|
||||
Location: &briefing.LocationContext{
|
||||
ID: "home",
|
||||
Name: "Brentwood",
|
||||
Region: "St. Louis Metro",
|
||||
Timezone: "America/Chicago",
|
||||
},
|
||||
ValidPeriod: timeutil.Period{
|
||||
Start: time.Date(2026, 5, 29, 5, 0, 0, 0, time.UTC),
|
||||
End: time.Date(2026, 5, 30, 5, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
CurrentConditions: &briefing.CurrentConditionsContext{
|
||||
ConditionText: "Partly cloudy",
|
||||
},
|
||||
Daily: &briefing.Daily{
|
||||
ForecastSummaryDate: "2026-05-29",
|
||||
},
|
||||
|
||||
@@ -38,7 +38,10 @@ type Definition struct {
|
||||
Name string
|
||||
PromptID string
|
||||
ComparisonStrategy ComparisonStrategy
|
||||
DefaultOutputName string
|
||||
ArtifactGroup string
|
||||
BatchOutputName string
|
||||
Generated bool
|
||||
CompatiblePriorIDs []ID
|
||||
Morning bool
|
||||
Evening bool
|
||||
resolve func(ResolveRequest) (timeutil.Period, error)
|
||||
@@ -51,6 +54,15 @@ func (d Definition) ResolvePeriod(req ResolveRequest) (timeutil.Period, error) {
|
||||
return d.resolve(req)
|
||||
}
|
||||
|
||||
func (d Definition) CompatibleWithPrior(id ID) bool {
|
||||
for _, compatibleID := range d.CompatiblePriorIDs {
|
||||
if id == compatibleID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type ResolveRequest struct {
|
||||
Now time.Time
|
||||
Location *time.Location
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"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) {
|
||||
location := mustLoadLocation(t)
|
||||
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",
|
||||
PromptID: "weather.daily_report",
|
||||
ComparisonStrategy: CompareSameValidDate,
|
||||
DefaultOutputName: "daily.md",
|
||||
ArtifactGroup: "daily",
|
||||
BatchOutputName: "daily.md",
|
||||
Generated: true,
|
||||
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
||||
Morning: true,
|
||||
resolve: resolveDailyToday,
|
||||
},
|
||||
@@ -22,7 +25,10 @@ func DefaultRegistry() Registry {
|
||||
Name: "Tomorrow Planning Brief",
|
||||
PromptID: "weather.daily_report",
|
||||
ComparisonStrategy: CompareSameValidDate,
|
||||
DefaultOutputName: "tomorrow.md",
|
||||
ArtifactGroup: "daily",
|
||||
BatchOutputName: "tomorrow.md",
|
||||
Generated: true,
|
||||
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
||||
Evening: true,
|
||||
resolve: resolveDailyTomorrow,
|
||||
},
|
||||
@@ -31,7 +37,10 @@ func DefaultRegistry() Registry {
|
||||
Name: "3-Day Outlook",
|
||||
PromptID: "weather.three_day_outlook",
|
||||
ComparisonStrategy: CompareSameValidDate,
|
||||
DefaultOutputName: "three_day.md",
|
||||
ArtifactGroup: "three-day",
|
||||
BatchOutputName: "three-day.md",
|
||||
Generated: true,
|
||||
CompatiblePriorIDs: []ID{ThreeDay},
|
||||
Morning: true,
|
||||
resolve: resolveThreeDay,
|
||||
},
|
||||
@@ -40,7 +49,10 @@ func DefaultRegistry() Registry {
|
||||
Name: "Weekend Outlook",
|
||||
PromptID: "weather.weekend_outlook",
|
||||
ComparisonStrategy: CompareWeekendWindow,
|
||||
DefaultOutputName: "weekend.md",
|
||||
ArtifactGroup: "weekend",
|
||||
BatchOutputName: "weekend.md",
|
||||
Generated: true,
|
||||
CompatiblePriorIDs: []ID{Weekend},
|
||||
Morning: true,
|
||||
resolve: resolveWeekend,
|
||||
},
|
||||
@@ -49,7 +61,10 @@ func DefaultRegistry() Registry {
|
||||
Name: "Storm Report",
|
||||
PromptID: "weather.storm_report",
|
||||
ComparisonStrategy: CompareExplicitWindow,
|
||||
DefaultOutputName: "storm.md",
|
||||
ArtifactGroup: "storm",
|
||||
BatchOutputName: "storm.md",
|
||||
Generated: true,
|
||||
CompatiblePriorIDs: []ID{Storm},
|
||||
resolve: resolveStorm,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"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/report"
|
||||
)
|
||||
@@ -79,9 +79,9 @@ func (s *FilesystemStore) Paths(resolved report.Resolved) (ArtifactPaths, error)
|
||||
if metadata.RunID == "" {
|
||||
return ArtifactPaths{}, fmt.Errorf("run id is required")
|
||||
}
|
||||
group, err := reportGroup(resolved.Definition.ID)
|
||||
if err != nil {
|
||||
return ArtifactPaths{}, err
|
||||
group := resolved.Definition.ArtifactGroup
|
||||
if group == "" {
|
||||
return ArtifactPaths{}, fmt.Errorf("report %q has no artifact group", resolved.Definition.ID)
|
||||
}
|
||||
validDate := resolved.ValidPeriod.Start.Format("2006-01-02")
|
||||
filenameBase := metadata.RunID
|
||||
@@ -99,7 +99,7 @@ func (s *FilesystemStore) SaveBriefing(_ context.Context, resolved report.Resolv
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := writeJSONAtomic(paths.Briefing, pkg); err != nil {
|
||||
if err := fileutil.WriteJSONAtomic(paths.Briefing, pkg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return paths.Briefing, nil
|
||||
@@ -113,21 +113,18 @@ func (s *FilesystemStore) SaveDataPackage(_ context.Context, resolved report.Res
|
||||
if err := promptinput.Validate(pkg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := writeJSONAtomic(paths.DataPackage, pkg); err != nil {
|
||||
if err := fileutil.WriteJSONAtomic(paths.DataPackage, pkg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return paths.DataPackage, nil
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) SavePreflight(_ context.Context, resolved report.Resolved, result *scriptorium.RenderResult) (string, error) {
|
||||
if result == nil {
|
||||
return "", fmt.Errorf("render result is required")
|
||||
}
|
||||
func (s *FilesystemStore) SavePreflight(_ context.Context, resolved report.Resolved, artifact PreflightArtifact) (string, error) {
|
||||
paths, err := s.Paths(resolved)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := writeJSONAtomic(paths.Preflight, result); err != nil {
|
||||
if err := fileutil.WriteJSONAtomic(paths.Preflight, artifact); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return paths.Preflight, nil
|
||||
@@ -157,27 +154,22 @@ func (s *FilesystemStore) SaveMetadata(_ context.Context, metadata Metadata) (st
|
||||
if metadata.PreflightPath == "" {
|
||||
return "", fmt.Errorf("metadata preflight path is required")
|
||||
}
|
||||
path := metadataPathFromStored(metadata)
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("metadata path cannot be resolved")
|
||||
if metadata.MetadataPath == "" {
|
||||
return "", fmt.Errorf("metadata path is required")
|
||||
}
|
||||
if err := writeJSONAtomic(path, metadata); err != nil {
|
||||
if err := fileutil.WriteJSONAtomic(metadata.MetadataPath, metadata); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) FindPriorDailySnapshot(ctx context.Context, resolved report.Resolved) (*PriorSnapshot, error) {
|
||||
return s.FindPriorSnapshot(ctx, resolved)
|
||||
return metadata.MetadataPath, nil
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.Resolved) (*PriorSnapshot, error) {
|
||||
if resolved.Definition.ComparisonStrategy != report.CompareSameValidDate && resolved.Definition.ComparisonStrategy != report.CompareWeekendWindow {
|
||||
return nil, nil
|
||||
}
|
||||
group, err := reportGroup(resolved.Definition.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
group := resolved.Definition.ArtifactGroup
|
||||
if group == "" {
|
||||
return nil, fmt.Errorf("report %q has no artifact group", resolved.Definition.ID)
|
||||
}
|
||||
dirs, err := s.metadataDirectories(resolved, group)
|
||||
if err != nil {
|
||||
@@ -205,7 +197,7 @@ func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.R
|
||||
if metadata.RunID == resolved.Metadata().RunID {
|
||||
continue
|
||||
}
|
||||
if !compatiblePriorReport(group, metadata.ReportID, resolved.Definition.ID) {
|
||||
if !resolved.Definition.CompatibleWithPrior(metadata.ReportID) {
|
||||
continue
|
||||
}
|
||||
if !comparablePeriod(metadata, resolved) {
|
||||
@@ -343,19 +335,6 @@ func (s *FilesystemStore) metadataDirectories(resolved report.Resolved, group st
|
||||
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 {
|
||||
all := append([]string{s.root}, parts...)
|
||||
return filepath.Join(all...)
|
||||
@@ -375,48 +354,6 @@ func validateRelativeDir(name string, value string) error {
|
||||
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 {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -428,14 +365,6 @@ func readJSON(path string, target any) error {
|
||||
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 {
|
||||
return metadata.ValidPeriod.Start.Format("2006-01-02") == resolved.ValidPeriod.Start.Format("2006-01-02")
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||
@@ -56,7 +55,7 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
||||
if err != nil {
|
||||
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 {
|
||||
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 {
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatalf("Paths() error = %v", err)
|
||||
@@ -112,9 +122,49 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
||||
if 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)
|
||||
first := resolveDailyAt(t, "2026-05-29T05: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)
|
||||
}
|
||||
|
||||
prior, err := store.FindPriorDailySnapshot(context.Background(), second)
|
||||
prior, err := store.FindPriorSnapshot(context.Background(), second)
|
||||
if err != nil {
|
||||
t.Fatalf("FindPriorDailySnapshot() error = %v", err)
|
||||
t.Fatalf("FindPriorSnapshot() error = %v", err)
|
||||
}
|
||||
if prior == nil {
|
||||
t.Fatal("FindPriorDailySnapshot() = nil, want prior snapshot")
|
||||
t.Fatal("FindPriorSnapshot() = nil, want prior snapshot")
|
||||
}
|
||||
if 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)
|
||||
previousDate := resolveDailyAt(t, "2026-05-28T05: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)
|
||||
}
|
||||
|
||||
prior, err := store.FindPriorDailySnapshot(context.Background(), currentDate)
|
||||
prior, err := store.FindPriorSnapshot(context.Background(), currentDate)
|
||||
if err != nil {
|
||||
t.Fatalf("FindPriorDailySnapshot() error = %v", err)
|
||||
t.Fatalf("FindPriorSnapshot() error = %v", err)
|
||||
}
|
||||
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,
|
||||
Units: "us",
|
||||
Timezone: resolved.Timezone,
|
||||
Location: &briefing.LocationContext{
|
||||
ID: "home",
|
||||
Name: "Brentwood",
|
||||
Region: "St. Louis Metro",
|
||||
Timezone: resolved.Timezone,
|
||||
},
|
||||
ValidPeriod: resolved.ValidPeriod,
|
||||
},
|
||||
Daily: &briefing.Daily{ForecastSummaryDate: "2026-05-29"},
|
||||
|
||||
@@ -14,12 +14,14 @@ const MetadataSchemaVersion = "weatherreporter.metadata.v1"
|
||||
type Metadata struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
RunID string `json:"runId"`
|
||||
MetadataPath string `json:"-"`
|
||||
ReportID report.ID `json:"reportId"`
|
||||
Variant string `json:"variant,omitempty"`
|
||||
PromptID string `json:"promptId"`
|
||||
GeneratedAt time.Time `json:"generatedAt"`
|
||||
Timezone string `json:"timezone"`
|
||||
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||
Location *briefing.LocationContext `json:"location,omitempty"`
|
||||
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
||||
SourceLocation string `json:"sourceLocation,omitempty"`
|
||||
Sources []briefing.SourceMetadata `json:"sources,omitempty"`
|
||||
@@ -35,12 +37,14 @@ func BuildMetadata(resolved report.Resolved, briefingPackage briefing.Package, p
|
||||
return Metadata{
|
||||
SchemaVersion: MetadataSchemaVersion,
|
||||
RunID: metadata.RunID,
|
||||
MetadataPath: paths.Metadata,
|
||||
ReportID: metadata.ReportID,
|
||||
Variant: briefingPackage.Metadata.Variant,
|
||||
PromptID: metadata.PromptID,
|
||||
GeneratedAt: metadata.GeneratedAt,
|
||||
Timezone: metadata.Timezone,
|
||||
ValidPeriod: metadata.ValidPeriod,
|
||||
Location: copyLocation(briefingPackage.Metadata.Location),
|
||||
SourceLocationID: briefingPackage.Metadata.SourceLocationID,
|
||||
SourceLocation: briefingPackage.Metadata.SourceLocation,
|
||||
Sources: briefingPackage.Metadata.Sources,
|
||||
@@ -51,3 +55,11 @@ func BuildMetadata(resolved report.Resolved, briefingPackage briefing.Package, p
|
||||
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 (
|
||||
"context"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
@@ -14,11 +13,10 @@ type Store interface {
|
||||
Paths(report.Resolved) (ArtifactPaths, error)
|
||||
SaveBriefing(context.Context, report.Resolved, briefing.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)
|
||||
SaveMetadata(context.Context, Metadata) (string, error)
|
||||
FindPriorSnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
|
||||
FindPriorDailySnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
|
||||
LoadBriefing(context.Context, string) (briefing.Package, error)
|
||||
}
|
||||
|
||||
@@ -26,3 +24,12 @@ type PriorSnapshot struct {
|
||||
Metadata Metadata
|
||||
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