29 Commits

Author SHA1 Message Date
63749a9572 Implement support for NWS weather stories 2026-05-30 07:47:49 -05:00
9ff90d33fc Add Woodpecker CI support 2026-05-30 07:47:13 -05:00
942e8ff591 Added a field with the current local date to the data package 2026-05-29 23:29:04 -05:00
0b050256f9 Add a current conditions block to the briefing/data-package 2026-05-29 20:04:27 -05:00
8a762bf34f Added a location block to the data package 2026-05-29 19:57:08 -05:00
42defcf4b9 Implemented the AFD Short Term / Long Term coverage 2026-05-29 19:42:07 -05:00
3e93a97d10 Improve handling of alerts when no active alerts are present 2026-05-29 19:34:23 -05:00
26e6f33cde Update default timezone, dayparts, and related tests 2026-05-29 19:22:02 -05:00
1bc0739d31 Removed detection for "thunder" as a special weather indicator 2026-05-29 19:15:32 -05:00
8476dab844 Consolidate future roadmap items 2026-05-29 18:27:34 -05:00
745992886c Remove the redundant DefaultOutputName report definition field 2026-05-29 18:25:38 -05:00
8089f62806 Refresh cleanup-related documentation 2026-05-29 20:49:19 +00:00
a34aec1dd2 Align cleanup documentation 2026-05-29 20:46:05 +00:00
448bd1e510 Remove legacy daily report wrappers 2026-05-29 20:44:25 +00:00
4e23e1e11f Deduplicate adapter execution and storm parsing 2026-05-29 20:41:47 +00:00
5d3b850e46 Simplify inspect command handling 2026-05-29 20:39:17 +00:00
4f45dee332 Remove unused report output config 2026-05-29 20:36:29 +00:00
1355605e70 Centralize atomic artifact writes 2026-05-29 20:33:35 +00:00
7dc2ac9253 Centralize report path policy 2026-05-29 20:28:06 +00:00
6915bf1ba2 Removed the documentation roadmap, and added a new cleanup roadmap based upon the code quality audit 2026-05-29 15:23:27 -05:00
ac6ede8f9c Audit code quality and deduplication opportunities 2026-05-29 15:17:24 -05:00
bcb4a64c68 Complete documentation validation pass 2026-05-29 20:05:20 +00:00
7a970148f3 Validate examples and clean up roadmap 2026-05-29 20:02:56 +00:00
0759e1598f Align policy documentation with contributor workflow 2026-05-29 19:59:13 +00:00
25ad8959a6 Document implemented integration contracts 2026-05-29 19:56:33 +00:00
4f530b2b6a Document internal component boundaries 2026-05-29 19:54:22 +00:00
f23af43013 Add operational troubleshooting documentation 2026-05-29 19:49:47 +00:00
62827cf56d Align baseline user documentation 2026-05-29 19:47:01 +00:00
5c9333feec Add a documentation update roadmap 2026-05-29 14:42:32 -05:00
67 changed files with 2924 additions and 3186 deletions

3
.gitignore vendored
View File

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

50
.woodpecker/release.yml Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -5,63 +5,66 @@ This document describes the implemented briefing package boundary.
## Purpose ## Purpose
`internal/briefing` builds structured report-specific briefing packages from `internal/briefing` builds structured report-specific briefing packages from
forecast summaries and report metadata. The package currently implements Daily resolved report metadata, forecast bundles, and derived forecast summaries.
Today, Daily Tomorrow, 3-Day Outlook, Weekend Outlook, and Storm Report Briefings are curated inputs for prompt data packages, not rendered report
briefing content. prose.
## Inputs and Outputs ## Inputs And Outputs
Inputs: Inputs:
- resolved report definition and valid period - resolved report definition, generation time, timezone, and valid period
- forecast bundle - forecast bundle with source provenance and warnings
- derived forecast summary or summaries - derived daily or period summaries where required
- configured units and timezone - configured units, timezone, and descriptive location context
Output: Outputs:
- `briefing.Package` JSON containing common metadata and report-specific - `briefing.Package` with common metadata and one report-specific content
briefing content object for Daily, 3-Day, Weekend, or Storm Report
- optional `currentConditions` prompt context from normalized
`/conditions/current` data when available
- optional structured `weatherStory` context on report-specific briefing
objects when `/weatherstories/latest` is available
- optional JSON file written by `briefing.Save`
## Boundaries ## Boundaries
- Briefings are structured weather facts and context for later prompt input. - This package selects and shapes weather facts for prompts.
- This package does not fetch weather data, compare prior snapshots, build - It does not fetch weather data, compare prior snapshots, build
`scriptorium` data packages, or render final report prose. `data_package` files, invoke Scriptorium, or write workflow metadata.
## Behavior ## Config Fields Used
- Common metadata includes schema version, RunID, report ID, variant, prompt ID, The package receives configured units and timezone from the app layer. Daypart
generation time, units, timezone, valid period, source location, source configuration is consumed by `internal/forecast` before briefing builders run.
provenance, hashes, and source warnings. Configured `location` values are prompt context only; Weather API
- Daily content includes bottom-line inputs, daypart summaries, relevant alerts, `sourceLocationId` and `sourceLocation` remain source provenance.
outdoor window inputs, narrative periods, discussion context, and weather Current conditions are copied from the normalized `/conditions/current` bundle
story context when available. source only; observation station and timestamp fields remain provenance.
- Daily Tomorrow also includes planning inputs for morning readiness,
commute/school/workday concerns, and what may change overnight. ## External Adapters Used
- 3-Day content includes one summary per local day or partial day, with overall
character, temperature range, precipitation, wind, risk, outdoor-window, and None directly.
alert inputs, plus broader discussion and weather-story context when
available. ## State Or Manifest Behavior
- Weekend content uses the same daily outlook summaries and adds planning
inputs for best outdoor windows, worst weather windows, rain/storm timing, `briefing.Save` writes briefing JSON atomically. Managed workspace placement is
comfort concerns, and confidence or uncertainty context. owned by `internal/state`.
- Storm content uses the explicit event window and includes event headline
inputs, hazards, most-likely scenario inputs, reasonable worst-case inputs, ## Skip And Resume Behavior
confidence and uncertainty inputs, watch items, active alerts, relevant
hourly and narrative forecast periods, and available discussion or weather None. Builders either return a complete briefing package or an error.
story context.
- Briefing JSON is written atomically by `briefing.Save`.
## Failure Behavior ## Failure Behavior
- Daily briefing construction requires a Daily report definition and a derived - Daily briefing construction requires a Daily report definition and derived
daily forecast summary. daily summary.
- 3-Day briefing construction requires a 3-Day report definition and at least - 3-Day briefing construction requires a 3-Day report definition and at least
one derived daily summary in the outlook period. one derived summary.
- Weekend briefing construction requires a Weekend report definition and at - Weekend briefing construction requires a Weekend report definition and at
least one derived daily summary in the weekend period. least one derived summary.
- Storm briefing construction requires a Storm Report definition and a forecast - Storm briefing construction requires a Storm Report definition and forecast
bundle. bundle.
- Save failures include path and operation context. - Save failures include path and operation context.
@@ -74,11 +77,11 @@ Inspect:
- `internal/briefing/weekend_test.go` - `internal/briefing/weekend_test.go`
- `internal/briefing/storm_test.go` - `internal/briefing/storm_test.go`
- `internal/app/app_test.go` - `internal/app/app_test.go`
- `internal/cli/root_test.go`
## Invariants ## Invariants
- Weather facts come from normalized and derived source data. - Briefings contain structured weather facts and source context.
- Briefing output remains JSON-inspectable. - Common metadata includes RunID, report ID, prompt ID, valid period, source
- LLM prompt input packaging and `scriptorium` execution remain outside this provenance, source hashes, source warnings, and configured prompt location.
- LLM prompt input packaging and Scriptorium execution remain outside this
boundary. boundary.

View File

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

View File

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

View File

@@ -1,54 +1,69 @@
# Prompt Input Internals # Prompt Input Internals
This document describes the implemented prompt input package boundary. This document describes prompt input data package construction.
## Purpose ## Purpose
`internal/promptinput` converts a structured briefing package into the `internal/promptinput` converts a structured briefing package and optional
`data_package` JSON file passed to `scriptorium` prompts. Recent Changes into the `data_package` JSON passed to Scriptorium prompts.
## Inputs and Outputs ## Inputs And Outputs
Input: Inputs:
- `briefing.Package` containing Daily-family, 3-Day Outlook, Weekend Outlook, - `briefing.Package`
or Storm Report content - optional `[]changes.Change`
Output: Outputs:
- `promptinput.Package` JSON with report metadata, briefing content, source - `promptinput.Package` containing schema version, RunID, report metadata,
warnings, RunID, and a Recent Changes section. briefing content, Recent Changes, and source warnings. Briefing content
includes configured location context, current conditions when available,
structured weather story context when available, discussion key messages, and
short/long-term AFD narratives when the Weather API provides them.
- report metadata includes `currentLocalDate`, the generation date formatted as
`YYYY-MM-DD` in the effective report timezone.
- optional JSON file written by `promptinput.Save`
## Boundaries ## Boundaries
- This package owns the prompt input schema and required-field validation. - This package owns the prompt input schema and validation.
- It does not fetch weather data, compute forecast summaries, compare prior - It does not fetch weather data, derive forecast summaries, find prior
snapshots, or invoke `scriptorium`. snapshots, compare changes, or invoke Scriptorium.
## Behavior ## Config Fields Used
- `promptinput.Build` copies report metadata from the briefing package. None directly. Config-derived values, including timezone and prompt location
- `promptinput.Validate` rejects missing or inconsistent required fields before context, are already present in briefing metadata before this package runs.
render preflight.
- `promptinput.Save` writes JSON atomically where practical. ## External Adapters Used
- Recent Changes is present as an `items` list. It is empty when no prior
comparable snapshot exists or no meaningful changes are detected. None.
## State Or Manifest Behavior
`promptinput.Save` writes JSON atomically. Managed workspace paths are owned by
`internal/state`.
## Skip And Resume Behavior
None. Recent Changes is always present as an `items` list and may be empty.
## Failure Behavior ## Failure Behavior
Validation errors name the missing or inconsistent field. Save failures include Validation fails before render preflight when required top-level or briefing
the filesystem operation and path context. metadata fields are missing or inconsistent, or when no report content is
present. Save failures include filesystem operation and path context.
## Tests ## Tests
Inspect: Inspect:
- `internal/promptinput/package_test.go` - `internal/promptinput/package_test.go`
- `internal/changes/daily_test.go`
- `internal/app/app_test.go` - `internal/app/app_test.go`
## Invariants ## Invariants
- Prompt input data remains structured JSON. - Scriptorium receives structured `data_package` JSON.
- Briefing metadata and top-level report metadata must agree. - Briefing metadata and top-level report metadata must agree.
- Recent Changes is not inferred from rendered report text. - Recent Changes are not inferred from rendered report text.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

File diff suppressed because it is too large Load Diff

235
docs/troubleshooting.md Normal file
View 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).

View File

@@ -1,11 +1,16 @@
weather_api: weather_api:
base_url: https://weather.api.example.com/ base_url: https://weather.api.rakestrawhome.com/
timeout: 15s timeout: 15s
precision: 1 precision: 1
units: us units: us
timezone: Chicago timezone: "America/Chicago"
format: json format: json
location:
id: home
name: Brentwood
region: St. Louis Metro
missing_source: missing_source:
default: warn default: warn
sources: sources:
@@ -22,21 +27,21 @@ workspace:
data_packages_dir: data-packages data_packages_dir: data-packages
preflight_dir: preflight preflight_dir: preflight
reports:
output_dir: reports
dayparts: dayparts:
- name: overnight - name: overnight
start: "00:00" start: "00:00"
end: "06:00" end: "06:00"
- name: morning - name: morning
start: "06:00" start: "06:00"
end: "12:00" end: "10:00"
- name: midday
start: "10:00"
end: "15:00"
- name: afternoon - name: afternoon
start: "12:00" start: "15:00"
end: "18:00" end: "17:00"
- name: evening - name: evening
start: "18:00" start: "17:00"
end: "24:00" end: "24:00"
recent_change: recent_change:

View File

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

View File

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

View File

@@ -11,14 +11,13 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"os"
"path" "path"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
) )
@@ -110,10 +109,10 @@ func (c *Client) FetchBundle(ctx context.Context) (*forecast.Bundle, error) {
if err := builder.fetchDiscussion(ctx); err != nil { if err := builder.fetchDiscussion(ctx); err != nil {
return nil, err return nil, err
} }
if err := builder.addStub("daily", "daily forecast data is not available from the weather API yet"); err != nil { if err := builder.fetchWeatherStory(ctx); err != nil {
return nil, err return nil, err
} }
if err := builder.addStub("weather_story", "NWS weather story is not available from the weather API yet"); err != nil { if err := builder.addStub("daily", "daily forecast data is not available from the weather API yet"); err != nil {
return nil, err return nil, err
} }
@@ -203,13 +202,18 @@ func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
} }
func (b *bundleBuilder) fetchAlerts(ctx context.Context) error { func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "alerts", "/alerts/active", queryOptions{}) raw, source, err := b.client.fetch(ctx, "alerts", "/alerts/active", queryOptions{allowNull: true})
if err != nil { if err != nil {
return err return err
} }
if raw == nil { if raw == nil {
return b.handleMissing(&source, "active alerts data is missing", false) return b.handleMissing(&source, "active alerts data is missing", false)
} }
if isJSONNull(raw) {
b.bundle.Alerts = &forecast.AlertRun{Raw: append(json.RawMessage(nil), raw...)}
b.addSource(source)
return nil
}
var alerts forecast.AlertRun var alerts forecast.AlertRun
if err := decodeSource(raw, &alerts); err != nil { if err := decodeSource(raw, &alerts); err != nil {
return b.handleMalformed(&source, err, false) return b.handleMalformed(&source, err, false)
@@ -242,6 +246,27 @@ func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
return nil return nil
} }
func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "weather_story", "/weatherstories/latest", queryOptions{omitUnits: true})
if err != nil {
return err
}
if raw == nil {
return b.handleMissing(&source, "NWS weather story data is missing", false)
}
var story forecast.WeatherStory
if err := decodeSource(raw, &story); err != nil {
return b.handleMalformed(&source, err, false)
}
if !story.StartTime.IsZero() {
source.IssuedAt = &story.StartTime
}
source.UpdatedAt = story.UpdatedAt
b.bundle.WeatherStory = &story
b.addSource(source)
return nil
}
func (b *bundleBuilder) addStub(sourceName string, message string) error { func (b *bundleBuilder) addStub(sourceName string, message string) error {
source := forecast.Source{ source := forecast.Source{
Name: sourceName, Name: sourceName,
@@ -302,6 +327,8 @@ func (c *Client) policyFor(source string) config.MissingSourcePolicy {
type queryOptions struct { type queryOptions struct {
precision bool precision bool
timezone bool timezone bool
allowNull bool
omitUnits bool
} }
type envelope struct { type envelope struct {
@@ -340,7 +367,7 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
Query: queryMap(reqURL.Query()), Query: queryMap(reqURL.Query()),
FetchedAt: c.now(), FetchedAt: c.now(),
} }
if len(env.Data) == 0 || bytes.Equal(bytes.TrimSpace(env.Data), []byte("null")) { if len(env.Data) == 0 || (isJSONNull(env.Data) && !opts.allowNull) {
source.Missing = true source.Missing = true
return nil, source, nil return nil, source, nil
} }
@@ -352,12 +379,18 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
return env.Data, source, nil return env.Data, source, nil
} }
func isJSONNull(raw json.RawMessage) bool {
return bytes.Equal(bytes.TrimSpace(raw), []byte("null"))
}
func (c *Client) endpointURL(endpoint string, opts queryOptions) *url.URL { func (c *Client) endpointURL(endpoint string, opts queryOptions) *url.URL {
reqURL := *c.baseURL reqURL := *c.baseURL
reqURL.Path = path.Join(c.baseURL.Path, endpoint) reqURL.Path = path.Join(c.baseURL.Path, endpoint)
query := reqURL.Query() query := reqURL.Query()
query.Set("format", c.format) query.Set("format", c.format)
query.Set("units", c.units) if !opts.omitUnits {
query.Set("units", c.units)
}
if opts.precision { if opts.precision {
query.Set("precision", strconv.Itoa(c.precision)) query.Set("precision", strconv.Itoa(c.precision))
} }
@@ -398,29 +431,8 @@ func sourceHash(raw json.RawMessage) (string, error) {
} }
func SaveBundle(path string, bundle *forecast.Bundle) error { func SaveBundle(path string, bundle *forecast.Bundle) error {
data, err := json.MarshalIndent(bundle, "", " ") if err := fileutil.WriteJSONAtomic(path, bundle); err != nil {
if err != nil { return fmt.Errorf("save bundle: %w", err)
return fmt.Errorf("marshal forecast bundle: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create bundle directory %q: %w", filepath.Dir(path), err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary bundle file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary bundle file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary bundle file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("save bundle %q: %w", path, err)
} }
return nil return nil
} }

View File

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

View File

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

View File

@@ -0,0 +1,14 @@
{
"data": {
"officeId": "LSX",
"startTime": "2026-05-30T08:46:00Z",
"endTime": "2026-05-31T11:00:00Z",
"updatedAt": "2026-05-30T09:00:34Z",
"title": "Several Chances for Rain Through Monday",
"description": "A stagnant weather pattern with low pressure over the Great Plains and high pressure over the Great Lakes will continue to produce scattered showers and thunderstorms, for areas mainly along and west of the Mississippi River today and Sunday.",
"altText": "This slide shows the forecast for today through Tuesday with icons for showers and thunderstorms and a picture of a cumulonimbus cloud on the right side.",
"priority": false,
"order": 1,
"downloadUrl": "https://api.weather.gov/offices/LSX/weatherstories/download/3228e499-2aae-45a8-9ff9-1c060311026f"
}
}

View File

@@ -4,9 +4,7 @@ package app
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium" "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
@@ -14,6 +12,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes" "gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
@@ -68,8 +67,6 @@ type BriefingRequest struct {
OutputPath string OutputPath string
} }
type DailyBriefingRequest = BriefingRequest
type ReportRequest struct { type ReportRequest struct {
Config config.Config Config config.Config
Resolved report.Resolved Resolved report.Resolved
@@ -78,15 +75,11 @@ type ReportRequest struct {
Store state.Store Store state.Store
} }
type DailyReportRequest = ReportRequest
type BriefingResult struct { type BriefingResult struct {
Package briefing.Package Package briefing.Package
OutputPath string OutputPath string
} }
type DailyBriefingResult = BriefingResult
type ReportResult struct { type ReportResult struct {
Briefing briefing.Package Briefing briefing.Package
BriefingPath string BriefingPath string
@@ -103,8 +96,6 @@ type ReportResult struct {
RunResult *scriptorium.RunResult RunResult *scriptorium.RunResult
} }
type DailyReportResult = ReportResult
type BatchResult struct { type BatchResult struct {
Batch BatchKind `json:"batch"` Batch BatchKind `json:"batch"`
StartedAt time.Time `json:"startedAt"` StartedAt time.Time `json:"startedAt"`
@@ -157,7 +148,7 @@ func Generate(ctx context.Context, req GenerateRequest) error {
if err != nil { if err != nil {
return err return err
} }
if isGeneratedReport(resolved.Definition.ID) { if resolved.Definition.Generated {
_, err := GenerateReport(ctx, ReportRequest{ _, err := GenerateReport(ctx, ReportRequest{
Config: req.Config, Config: req.Config,
Resolved: resolved, Resolved: resolved,
@@ -200,7 +191,7 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
startedAt := now startedAt := now
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt} result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
for _, resolved := range resolvedReports { for _, resolved := range resolvedReports {
if !isGeneratedReport(resolved.Definition.ID) { if !resolved.Definition.Generated {
return nil, fmt.Errorf("run is not implemented") return nil, fmt.Errorf("run is not implemented")
} }
} }
@@ -257,19 +248,10 @@ func batchReportResult(resolved report.Resolved) BatchReportResult {
} }
func batchOutputPath(outputDir string, definition report.Definition) string { func batchOutputPath(outputDir string, definition report.Definition) string {
if outputDir == "" || definition.DefaultOutputName == "" { if outputDir == "" || definition.BatchOutputName == "" {
return "" return ""
} }
name := strings.ReplaceAll(definition.DefaultOutputName, "_", "-") return filepath.Join(outputDir, definition.BatchOutputName)
return filepath.Join(outputDir, name)
}
func isGeneratedReport(id report.ID) bool {
return isDailyReport(id) || id == report.ThreeDay || id == report.Weekend || id == report.Storm
}
func isDailyReport(id report.ID) bool {
return id == report.DailyToday || id == report.DailyTomorrow
} }
func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) { func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) {
@@ -359,10 +341,6 @@ func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*forecast.
return bundle, nil return bundle, nil
} }
func GenerateDailyBriefing(ctx context.Context, req DailyBriefingRequest) (*DailyBriefingResult, error) {
return GenerateBriefing(ctx, req)
}
func GenerateBriefing(ctx context.Context, req BriefingRequest) (*BriefingResult, error) { func GenerateBriefing(ctx context.Context, req BriefingRequest) (*BriefingResult, error) {
bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config}) bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config})
if err != nil { if err != nil {
@@ -390,10 +368,6 @@ func GenerateBriefing(ctx context.Context, req BriefingRequest) (*BriefingResult
return &BriefingResult{Package: pkg, OutputPath: outputPath}, nil return &BriefingResult{Package: pkg, OutputPath: outputPath}, nil
} }
func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyReportResult, error) {
return GenerateReport(ctx, req)
}
func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) { func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) {
store := req.Store store := req.Store
if store == nil { if store == nil {
@@ -460,7 +434,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
preflightPath := paths.Preflight preflightPath := paths.Preflight
if renderResult != nil { if renderResult != nil {
var err error var err error
preflightPath, err = store.SavePreflight(ctx, req.Resolved, renderResult) preflightPath, err = store.SavePreflight(ctx, req.Resolved, preflightArtifact(renderResult))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -490,7 +464,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
OutputPath: reportPath, OutputPath: reportPath,
}) })
if runErr == nil && req.OutputPath != "" && req.OutputPath != reportPath { if runErr == nil && req.OutputPath != "" && req.OutputPath != reportPath {
if err := copyFileAtomic(reportPath, req.OutputPath); err != nil { if err := fileutil.CopyFileAtomic(reportPath, req.OutputPath); err != nil {
return nil, err return nil, err
} }
} }
@@ -524,10 +498,6 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
}, nil }, nil
} }
func BuildDailyBriefing(req DailyBriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) {
return BuildBriefing(req, bundle)
}
func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) { func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) {
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone) location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
if err != nil { if err != nil {
@@ -552,6 +522,7 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa
Bundle: bundle, Bundle: bundle,
Units: req.Config.WeatherAPI.Units, Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone, Timezone: req.Config.WeatherAPI.Timezone,
Location: briefingLocation(req.Config),
}, summary) }, summary)
case report.ThreeDay, report.Weekend: case report.ThreeDay, report.Weekend:
summaries, err := forecast.BuildPeriodDailySummaries(bundle, req.Resolved.ValidPeriod, location, dayparts) summaries, err := forecast.BuildPeriodDailySummaries(bundle, req.Resolved.ValidPeriod, location, dayparts)
@@ -564,6 +535,7 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa
Bundle: bundle, Bundle: bundle,
Units: req.Config.WeatherAPI.Units, Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone, Timezone: req.Config.WeatherAPI.Timezone,
Location: briefingLocation(req.Config),
}, summaries) }, summaries)
} }
return briefing.BuildThreeDay(briefing.BuildContext{ return briefing.BuildThreeDay(briefing.BuildContext{
@@ -571,6 +543,7 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa
Bundle: bundle, Bundle: bundle,
Units: req.Config.WeatherAPI.Units, Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone, Timezone: req.Config.WeatherAPI.Timezone,
Location: briefingLocation(req.Config),
}, summaries) }, summaries)
case report.Storm: case report.Storm:
return briefing.BuildStorm(briefing.BuildContext{ return briefing.BuildStorm(briefing.BuildContext{
@@ -578,18 +551,28 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa
Bundle: bundle, Bundle: bundle,
Units: req.Config.WeatherAPI.Units, Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone, Timezone: req.Config.WeatherAPI.Timezone,
Location: briefingLocation(req.Config),
}) })
default: default:
return briefing.Package{}, fmt.Errorf("briefing is not implemented for report %q", req.Resolved.Definition.ID) return briefing.Package{}, fmt.Errorf("briefing is not implemented for report %q", req.Resolved.Definition.ID)
} }
} }
func defaultStore(cfg config.Config) (*state.FilesystemStore, error) { func briefingLocation(cfg config.Config) *briefing.LocationContext {
return state.NewFilesystemStore(cfg.Workspace) location := briefing.LocationContext{
ID: cfg.Location.ID,
Name: cfg.Location.Name,
Region: cfg.Location.Region,
Timezone: cfg.WeatherAPI.Timezone,
}
if location.ID == "" && location.Name == "" && location.Region == "" && location.Timezone == "" {
return nil
}
return &location
} }
func dailyRecentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) { func defaultStore(cfg config.Config) (*state.FilesystemStore, error) {
return recentChanges(ctx, store, priorSnapshot, current, cfg) return state.NewFilesystemStore(cfg.Workspace)
} }
func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) { func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) {
@@ -618,29 +601,16 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.
} }
} }
func copyFileAtomic(source string, target string) error { func preflightArtifact(result *scriptorium.RenderResult) state.PreflightArtifact {
data, err := os.ReadFile(source) if result == nil {
if err != nil { return state.PreflightArtifact{}
return fmt.Errorf("read rendered report %q: %w", source, err)
} }
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { return state.PreflightArtifact{
return fmt.Errorf("create report output directory %q: %w", filepath.Dir(target), err) Command: append([]string(nil), result.Command...),
Stdout: result.Stdout,
Stderr: result.Stderr,
StdoutTruncated: result.StdoutTruncated,
StderrTruncated: result.StderrTruncated,
ExitCode: result.ExitCode,
} }
tmp, err := os.CreateTemp(filepath.Dir(target), "."+filepath.Base(target)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary report output file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary report output file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary report output file: %w", err)
}
if err := os.Rename(tmpName, target); err != nil {
return fmt.Errorf("save report output %q: %w", target, err)
}
return nil
} }

View File

@@ -2,6 +2,7 @@ package app
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -33,7 +34,9 @@ func TestFetchAndSaveBundle(t *testing.T) {
case "/alerts/active": case "/alerts/active":
_, _ = w.Write([]byte(`{"data":{"alerts":[]}}`)) _, _ = w.Write([]byte(`{"data":{"alerts":[]}}`))
case "/discussion": case "/discussion":
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":[]}}`)) _, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":[],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for saved bundle."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for saved bundle."}}}`))
case "/weatherstories/latest":
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@@ -58,6 +61,9 @@ func TestFetchAndSaveBundle(t *testing.T) {
if !strings.Contains(string(data), `"product": "hourly"`) { if !strings.Contains(string(data), `"product": "hourly"`) {
t.Fatalf("saved bundle missing hourly product:\n%s", string(data)) t.Fatalf("saved bundle missing hourly product:\n%s", string(data))
} }
if !strings.Contains(string(data), `"title": "Several Chances for Rain Through Monday"`) {
t.Fatalf("saved bundle missing weather story title:\n%s", string(data))
}
} }
func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) { func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) {
@@ -70,11 +76,9 @@ func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) {
} }
} }
func TestGenerateDailyBriefingWritesArtifact(t *testing.T) { func TestGenerateBriefingWritesArtifact(t *testing.T) {
server := dailyBundleServer(t) server := dailyBundleServer(t)
cfg := config.Defaults() cfg := dailyTestConfig(t, server)
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
resolved, err := ResolveGenerate(GenerateRequest{ resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg, Config: cfg,
Report: ReportDaily, Report: ReportDaily,
@@ -85,13 +89,13 @@ func TestGenerateDailyBriefingWritesArtifact(t *testing.T) {
} }
path := filepath.Join(t.TempDir(), "daily.briefing.json") path := filepath.Join(t.TempDir(), "daily.briefing.json")
result, err := GenerateDailyBriefing(context.Background(), DailyBriefingRequest{ result, err := GenerateBriefing(context.Background(), BriefingRequest{
Config: cfg, Config: cfg,
Resolved: resolved, Resolved: resolved,
OutputPath: path, OutputPath: path,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateDailyBriefing() error = %v", err) t.Fatalf("GenerateBriefing() error = %v", err)
} }
if result.OutputPath != path { if result.OutputPath != path {
t.Fatalf("OutputPath = %q, want %q", result.OutputPath, path) t.Fatalf("OutputPath = %q, want %q", result.OutputPath, path)
@@ -108,11 +112,9 @@ func TestGenerateDailyBriefingWritesArtifact(t *testing.T) {
} }
} }
func TestGenerateDailyBriefingDefaultPath(t *testing.T) { func TestGenerateBriefingDefaultPath(t *testing.T) {
server := dailyBundleServer(t) server := dailyBundleServer(t)
cfg := config.Defaults() cfg := dailyTestConfig(t, server)
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir() cfg.Workspace.Root = t.TempDir()
resolved, err := ResolveGenerate(GenerateRequest{ resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg, Config: cfg,
@@ -123,23 +125,21 @@ func TestGenerateDailyBriefingDefaultPath(t *testing.T) {
t.Fatalf("ResolveGenerate() error = %v", err) t.Fatalf("ResolveGenerate() error = %v", err)
} }
result, err := GenerateDailyBriefing(context.Background(), DailyBriefingRequest{ result, err := GenerateBriefing(context.Background(), BriefingRequest{
Config: cfg, Config: cfg,
Resolved: resolved, Resolved: resolved,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateDailyBriefing() error = %v", err) t.Fatalf("GenerateBriefing() error = %v", err)
} }
if !strings.HasSuffix(result.OutputPath, filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.briefing.json")) { if !strings.HasSuffix(result.OutputPath, filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.briefing.json")) {
t.Fatalf("OutputPath = %q, want deterministic daily briefing path", result.OutputPath) t.Fatalf("OutputPath = %q, want deterministic daily briefing path", result.OutputPath)
} }
} }
func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) { func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
server := dailyBundleServer(t) server := dailyBundleServer(t)
cfg := config.Defaults() cfg := dailyTestConfig(t, server)
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir() cfg.Workspace.Root = t.TempDir()
resolved, err := ResolveGenerate(GenerateRequest{ resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg, Config: cfg,
@@ -165,14 +165,14 @@ func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
} }
outputPath := filepath.Join(t.TempDir(), "daily.md") outputPath := filepath.Join(t.TempDir(), "daily.md")
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: resolved, Resolved: resolved,
OutputPath: outputPath, OutputPath: outputPath,
Renderer: renderer, Renderer: renderer,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateDailyReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
} }
if renderer.renderCalls != 1 { if renderer.renderCalls != 1 {
@@ -193,14 +193,7 @@ func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
if renderer.runRequest.OutputPath != result.ReportPath { if renderer.runRequest.OutputPath != result.ReportPath {
t.Fatalf("run OutputPath = %q, want managed report path %q", renderer.runRequest.OutputPath, result.ReportPath) t.Fatalf("run OutputPath = %q, want managed report path %q", renderer.runRequest.OutputPath, result.ReportPath)
} }
for _, path := range []string{result.BriefingPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath} { assertPathsExist(t, result.BriefingPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath, outputPath)
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected artifact %q: %v", path, err)
}
}
if _, err := os.Stat(outputPath); err != nil {
t.Fatalf("expected requested report output %q: %v", outputPath, err)
}
data, err := os.ReadFile(result.DataPackagePath) data, err := os.ReadFile(result.DataPackagePath)
if err != nil { if err != nil {
t.Fatalf("read data package: %v", err) t.Fatalf("read data package: %v", err)
@@ -208,6 +201,32 @@ func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
if !strings.Contains(string(data), `"recentChanges"`) || !strings.Contains(string(data), `data_package.v1`) { if !strings.Contains(string(data), `"recentChanges"`) || !strings.Contains(string(data), `data_package.v1`) {
t.Fatalf("data package missing expected content:\n%s", string(data)) t.Fatalf("data package missing expected content:\n%s", string(data))
} }
var savedDataPackage struct {
Report struct {
CurrentLocalDate string `json:"currentLocalDate"`
} `json:"report"`
Briefing briefing.Package `json:"briefing"`
}
if err := json.Unmarshal(data, &savedDataPackage); err != nil {
t.Fatalf("decode data package: %v", err)
}
if savedDataPackage.Report.CurrentLocalDate != "2026-05-29" {
t.Fatalf("data package currentLocalDate = %q, want 2026-05-29", savedDataPackage.Report.CurrentLocalDate)
}
location := savedDataPackage.Briefing.Metadata.Location
if location == nil || location.ID != "home" || location.Name != "Brentwood" || location.Region != "St. Louis Metro" || location.Timezone != "America/Chicago" {
t.Fatalf("data package location = %#v, want configured prompt location", location)
}
current := savedDataPackage.Briefing.CurrentConditions
if current == nil || current.ConditionText != "Clear" || current.TemperatureF == nil || *current.TemperatureF != 75 {
t.Fatalf("data package current conditions = %#v, want current conditions", current)
}
if savedDataPackage.Briefing.Daily == nil || savedDataPackage.Briefing.Daily.WeatherStory == nil || savedDataPackage.Briefing.Daily.WeatherStory.Title != "Several Chances for Rain Through Monday" {
t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Daily)
}
if !strings.Contains(string(data), "Short-term AFD narrative for generated report.") || !strings.Contains(string(data), "Long-term AFD narrative for generated report.") {
t.Fatalf("data package missing AFD short/long-term discussion:\n%s", string(data))
}
preflight, err := os.ReadFile(result.PreflightPath) preflight, err := os.ReadFile(result.PreflightPath)
if err != nil { if err != nil {
t.Fatalf("read preflight: %v", err) t.Fatalf("read preflight: %v", err)
@@ -236,7 +255,7 @@ func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) {
} }
} }
func TestGenerateDailyReportPersistsFailedPreflight(t *testing.T) { func TestGenerateReportPersistsFailedPreflight(t *testing.T) {
server := dailyBundleServer(t) server := dailyBundleServer(t)
cfg := config.Defaults() cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/" cfg.WeatherAPI.BaseURL = server.URL + "/"
@@ -259,13 +278,13 @@ func TestGenerateDailyReportPersistsFailedPreflight(t *testing.T) {
err: errors.New("scriptorium render exited with code 1: render failed"), err: errors.New("scriptorium render exited with code 1: render failed"),
} }
_, err = GenerateDailyReport(context.Background(), DailyReportRequest{ _, err = GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: resolved, Resolved: resolved,
Renderer: renderer, Renderer: renderer,
}) })
if err == nil { if err == nil {
t.Fatal("GenerateDailyReport() error = nil, want render error") t.Fatal("GenerateReport() error = nil, want render error")
} }
store, err := state.NewFilesystemStore(cfg.Workspace) store, err := state.NewFilesystemStore(cfg.Workspace)
if err != nil { if err != nil {
@@ -291,7 +310,7 @@ func TestGenerateDailyReportPersistsFailedPreflight(t *testing.T) {
} }
} }
func TestGenerateDailyReportReturnsRunErrorAfterPreflight(t *testing.T) { func TestGenerateReportReturnsRunErrorAfterPreflight(t *testing.T) {
server := dailyBundleServer(t) server := dailyBundleServer(t)
cfg := config.Defaults() cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/" cfg.WeatherAPI.BaseURL = server.URL + "/"
@@ -315,13 +334,13 @@ func TestGenerateDailyReportReturnsRunErrorAfterPreflight(t *testing.T) {
runBody: "# Daily Report\n", runBody: "# Daily Report\n",
} }
_, err = GenerateDailyReport(context.Background(), DailyReportRequest{ _, err = GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: resolved, Resolved: resolved,
Renderer: renderer, Renderer: renderer,
}) })
if err == nil { if err == nil {
t.Fatal("GenerateDailyReport() error = nil, want run error") t.Fatal("GenerateReport() error = nil, want run error")
} }
if renderer.renderCalls != 1 || renderer.runCalls != 1 { if renderer.renderCalls != 1 || renderer.runCalls != 1 {
t.Fatalf("calls render=%d run=%d, want one of each", renderer.renderCalls, renderer.runCalls) t.Fatalf("calls render=%d run=%d, want one of each", renderer.renderCalls, renderer.runCalls)
@@ -342,7 +361,7 @@ func TestGenerateDailyReportReturnsRunErrorAfterPreflight(t *testing.T) {
} }
} }
func TestGenerateDailyReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) { func TestGenerateReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
server := dailyBundleServer(t) server := dailyBundleServer(t)
cfg := config.Defaults() cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/" cfg.WeatherAPI.BaseURL = server.URL + "/"
@@ -371,6 +390,7 @@ func TestGenerateDailyReportIncludesRecentChangesFromPriorSnapshot(t *testing.T)
} }
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{ _, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
Briefing: priorBriefingPath, Briefing: priorBriefingPath,
Metadata: priorPaths.Metadata,
DataPackage: priorPaths.DataPackage, DataPackage: priorPaths.DataPackage,
Preflight: priorPaths.Preflight, Preflight: priorPaths.Preflight,
RenderedReport: priorPaths.RenderedReport, RenderedReport: priorPaths.RenderedReport,
@@ -393,14 +413,14 @@ func TestGenerateDailyReportIncludesRecentChangesFromPriorSnapshot(t *testing.T)
runBody: "# Daily Report\n", runBody: "# Daily Report\n",
} }
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: currentResolved, Resolved: currentResolved,
Renderer: renderer, Renderer: renderer,
Store: store, Store: store,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateDailyReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
} }
if len(result.RecentChanges) == 0 { if len(result.RecentChanges) == 0 {
t.Fatal("RecentChanges length = 0, want changes from prior snapshot") t.Fatal("RecentChanges length = 0, want changes from prior snapshot")
@@ -433,13 +453,13 @@ func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
runBody: "# Tomorrow Planning Brief\n", runBody: "# Tomorrow Planning Brief\n",
} }
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: resolved, Resolved: resolved,
Renderer: renderer, Renderer: renderer,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateDailyReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
} }
if result.Briefing.Metadata.ReportID != report.DailyTomorrow || result.Briefing.Metadata.Variant != "tomorrow" { if result.Briefing.Metadata.ReportID != report.DailyTomorrow || result.Briefing.Metadata.Variant != "tomorrow" {
@@ -485,6 +505,7 @@ func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) {
} }
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{ _, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
Briefing: priorBriefingPath, Briefing: priorBriefingPath,
Metadata: priorPaths.Metadata,
DataPackage: priorPaths.DataPackage, DataPackage: priorPaths.DataPackage,
Preflight: priorPaths.Preflight, Preflight: priorPaths.Preflight,
RenderedReport: priorPaths.RenderedReport, RenderedReport: priorPaths.RenderedReport,
@@ -506,14 +527,14 @@ func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) {
runBody: "# Tomorrow Planning Brief\n", runBody: "# Tomorrow Planning Brief\n",
} }
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: currentResolved, Resolved: currentResolved,
Renderer: renderer, Renderer: renderer,
Store: store, Store: store,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateDailyReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
} }
if result.PriorSnapshot == nil { if result.PriorSnapshot == nil {
t.Fatal("PriorSnapshot = nil, want compatible prior daily snapshot") t.Fatal("PriorSnapshot = nil, want compatible prior daily snapshot")
@@ -551,6 +572,7 @@ func TestGenerateThreeDayReportWritesReportAndRecentChanges(t *testing.T) {
} }
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{ _, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
Briefing: priorBriefingPath, Briefing: priorBriefingPath,
Metadata: priorPaths.Metadata,
DataPackage: priorPaths.DataPackage, DataPackage: priorPaths.DataPackage,
Preflight: priorPaths.Preflight, Preflight: priorPaths.Preflight,
RenderedReport: priorPaths.RenderedReport, RenderedReport: priorPaths.RenderedReport,
@@ -626,6 +648,7 @@ func TestGenerateWeekendReportWritesReportAndRecentChanges(t *testing.T) {
} }
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{ _, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
Briefing: priorBriefingPath, Briefing: priorBriefingPath,
Metadata: priorPaths.Metadata,
DataPackage: priorPaths.DataPackage, DataPackage: priorPaths.DataPackage,
Preflight: priorPaths.Preflight, Preflight: priorPaths.Preflight,
RenderedReport: priorPaths.RenderedReport, RenderedReport: priorPaths.RenderedReport,
@@ -882,7 +905,7 @@ func dailyBundleServer(t *testing.T) *httptest.Server {
case "/observations": case "/observations":
_, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`)) _, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`))
case "/conditions/current": case "/conditions/current":
_, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`)) _, _ = w.Write([]byte(`{"data":{"conditionText":"Clear","temperatureF":75,"relativeHumidityPercent":56,"windSpeedMph":8}}`))
case "/forecast/hourly": case "/forecast/hourly":
_, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32}]}}`)) _, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32}]}}`))
case "/forecast/narrative": case "/forecast/narrative":
@@ -890,7 +913,9 @@ func dailyBundleServer(t *testing.T) *httptest.Server {
case "/alerts/active": case "/alerts/active":
_, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Flood Watch","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T09:00:00-05:00"}]}}`)) _, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Flood Watch","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T09:00:00-05:00"}]}}`))
case "/discussion": case "/discussion":
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}}`)) _, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for generated report."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for generated report."}}}`))
case "/weatherstories/latest":
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@@ -1021,6 +1046,23 @@ func mustParse(value string) time.Time {
return parsed return parsed
} }
func dailyTestConfig(t *testing.T, server *httptest.Server) config.Config {
t.Helper()
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
return cfg
}
func assertPathsExist(t *testing.T, paths ...string) {
t.Helper()
for _, path := range paths {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected artifact %q: %v", path, err)
}
}
}
func priorDailyBriefing(resolved report.Resolved) briefing.Package { func priorDailyBriefing(resolved report.Resolved) briefing.Package {
low := 50.0 low := 50.0
high := 58.0 high := 58.0

View File

@@ -40,59 +40,44 @@ func InspectReports(ctx context.Context, req InspectReportsRequest) ([]state.Rep
} }
func InspectMetadata(ctx context.Context, req InspectRunRequest) (state.Metadata, error) { func InspectMetadata(ctx context.Context, req InspectRunRequest) (state.Metadata, error) {
store, err := defaultStore(req.Config) inspection, err := inspectRun(ctx, req)
if err != nil { return inspection.metadata, err
return state.Metadata{}, err
}
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
return metadata, err
} }
func InspectBriefing(ctx context.Context, req InspectRunRequest) (briefing.Package, error) { func InspectBriefing(ctx context.Context, req InspectRunRequest) (briefing.Package, error) {
store, err := defaultStore(req.Config) inspection, err := inspectRun(ctx, req)
if err != nil { if err != nil {
return briefing.Package{}, err return briefing.Package{}, err
} }
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID) return inspection.store.LoadBriefing(ctx, inspection.metadata.BriefingPath)
if err != nil {
return briefing.Package{}, err
}
return store.LoadBriefing(ctx, metadata.BriefingPath)
} }
func InspectDataPackage(ctx context.Context, req InspectRunRequest) (promptinput.Package, error) { func InspectDataPackage(ctx context.Context, req InspectRunRequest) (promptinput.Package, error) {
store, err := defaultStore(req.Config) inspection, err := inspectRun(ctx, req)
if err != nil { if err != nil {
return promptinput.Package{}, err return promptinput.Package{}, err
} }
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID) return inspection.store.LoadDataPackage(ctx, inspection.metadata.DataPackagePath)
if err != nil {
return promptinput.Package{}, err
}
return store.LoadDataPackage(ctx, metadata.DataPackagePath)
} }
func InspectPriorSnapshot(ctx context.Context, req InspectRunRequest) (*state.PriorSnapshot, error) { func InspectPriorSnapshot(ctx context.Context, req InspectRunRequest) (*state.PriorSnapshot, error) {
store, err := defaultStore(req.Config) inspection, err := inspectRun(ctx, req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID) resolved, err := resolvedFromMetadata(inspection.metadata)
if err != nil { if err != nil {
return nil, err return nil, err
} }
resolved, err := resolvedFromMetadata(metadata) return inspection.store.FindPriorSnapshot(ctx, resolved)
if err != nil {
return nil, err
}
return store.FindPriorSnapshot(ctx, resolved)
} }
func InspectSources(ctx context.Context, req InspectRunRequest) (SourceInspection, error) { func InspectSources(ctx context.Context, req InspectRunRequest) (SourceInspection, error) {
metadata, err := InspectMetadata(ctx, req) inspection, err := inspectRun(ctx, req)
if err != nil { if err != nil {
return SourceInspection{}, err return SourceInspection{}, err
} }
metadata := inspection.metadata
return SourceInspection{ return SourceInspection{
RunID: metadata.RunID, RunID: metadata.RunID,
ReportID: metadata.ReportID, ReportID: metadata.ReportID,
@@ -102,6 +87,23 @@ func InspectSources(ctx context.Context, req InspectRunRequest) (SourceInspectio
}, nil }, nil
} }
type runInspection struct {
store *state.FilesystemStore
metadata state.Metadata
}
func inspectRun(ctx context.Context, req InspectRunRequest) (runInspection, error) {
store, err := defaultStore(req.Config)
if err != nil {
return runInspection{}, err
}
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
if err != nil {
return runInspection{}, err
}
return runInspection{store: store, metadata: metadata}, nil
}
func resolvedFromMetadata(metadata state.Metadata) (report.Resolved, error) { func resolvedFromMetadata(metadata state.Metadata) (report.Resolved, error) {
definition, err := report.DefaultRegistry().Lookup(metadata.ReportID) definition, err := report.DefaultRegistry().Lookup(metadata.ReportID)
if err != nil { if err != nil {

View File

@@ -5,6 +5,7 @@ import (
"math" "math"
"sort" "sort"
"strings" "strings"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
@@ -57,8 +58,17 @@ type DiscussionContext struct {
} }
type WeatherStoryContext struct { type WeatherStoryContext struct {
Available bool `json:"available"` Available bool `json:"available"`
Summary string `json:"summary,omitempty"` OfficeID string `json:"officeId,omitempty"`
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
AltText string `json:"altText,omitempty"`
Priority bool `json:"priority"`
Order int `json:"order"`
DownloadURL string `json:"downloadUrl,omitempty"`
} }
func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, error) { func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, error) {
@@ -68,19 +78,18 @@ func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, erro
if summary == nil { if summary == nil {
return Package{}, fmt.Errorf("daily forecast summary is required") return Package{}, fmt.Errorf("daily forecast summary is required")
} }
pkg := Package{ pkg := buildPackage(ctx)
Metadata: BuildMetadata(ctx), pkg.Daily = &Daily{
Daily: &Daily{ BottomLine: buildBottomLine(summary),
BottomLine: buildBottomLine(summary), Dayparts: summary.Dayparts,
Dayparts: summary.Dayparts, RelevantAlerts: summary.AlertOverlaps,
RelevantAlerts: summary.AlertOverlaps, OutdoorWindows: buildOutdoorWindows(summary.Dayparts),
OutdoorWindows: buildOutdoorWindows(summary.Dayparts), NarrativePeriods: summary.NarrativePeriods,
NarrativePeriods: summary.NarrativePeriods, Discussion: buildDiscussion(summary.Discussion),
Discussion: buildDiscussion(summary.Discussion), WeatherStory: buildWeatherStory(ctx.Bundle),
WeatherStory: buildWeatherStory(ctx.Bundle), ForecastSummaryDate: summary.Date,
ForecastSummaryDate: summary.Date,
},
} }
setRelevantAlertCount(&pkg.Metadata, len(summary.AlertOverlaps))
if ctx.Resolved.Definition.ID == report.DailyTomorrow { if ctx.Resolved.Definition.ID == report.DailyTomorrow {
pkg.Daily.Planning = buildTomorrowPlanning(summary) pkg.Daily.Planning = buildTomorrowPlanning(summary)
} }
@@ -176,9 +185,6 @@ func readinessNotes(daypart forecast.DaypartSummary) []string {
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 { if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
notes = append(notes, fmt.Sprintf("Morning gusts may reach %.0f mph.", daypart.PeakWindGust.Value)) notes = append(notes, fmt.Sprintf("Morning gusts may reach %.0f mph.", daypart.PeakWindGust.Value))
} }
if daypart.Indicators.Thunder {
notes = append(notes, "Morning thunder could affect departure timing.")
}
if daypart.Indicators.Snow || daypart.Indicators.Ice { if daypart.Indicators.Snow || daypart.Indicators.Ice {
notes = append(notes, "Morning wintry weather could affect surfaces and travel.") notes = append(notes, "Morning wintry weather could affect surfaces and travel.")
} }
@@ -203,9 +209,6 @@ func concernNotes(daypart forecast.DaypartSummary) []string {
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 { if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", prefix, daypart.PeakWindGust.Value)) notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", prefix, daypart.PeakWindGust.Value))
} }
if daypart.Indicators.Thunder {
notes = append(notes, prefix+" thunder may disrupt outdoor plans.")
}
if daypart.Indicators.Snow || daypart.Indicators.Ice { if daypart.Indicators.Snow || daypart.Indicators.Ice {
notes = append(notes, prefix+" wintry weather may affect travel.") notes = append(notes, prefix+" wintry weather may affect travel.")
} }
@@ -229,9 +232,6 @@ func overnightWatchNotes(daypart forecast.DaypartSummary) []string {
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 { if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
notes = append(notes, fmt.Sprintf("Overnight gusts may reach %.0f mph before morning plans begin.", daypart.PeakWindGust.Value)) notes = append(notes, fmt.Sprintf("Overnight gusts may reach %.0f mph before morning plans begin.", daypart.PeakWindGust.Value))
} }
if daypart.Indicators.Thunder {
notes = append(notes, "Overnight storms could change morning impacts.")
}
if daypart.Indicators.Snow || daypart.Indicators.Ice { if daypart.Indicators.Snow || daypart.Indicators.Ice {
notes = append(notes, "Overnight wintry weather could leave morning travel impacts.") notes = append(notes, "Overnight wintry weather could leave morning travel impacts.")
} }
@@ -259,19 +259,40 @@ func buildDiscussion(discussion *forecast.Discussion) DiscussionContext {
KeyMessages: discussion.KeyMessages, KeyMessages: discussion.KeyMessages,
} }
if discussion.ShortTerm != nil { if discussion.ShortTerm != nil {
ctx.ShortTerm = discussion.ShortTerm.Narrative ctx.ShortTerm = discussion.ShortTerm.Text
} }
if discussion.LongTerm != nil { if discussion.LongTerm != nil {
ctx.LongTerm = discussion.LongTerm.Narrative ctx.LongTerm = discussion.LongTerm.Text
} }
return ctx return ctx
} }
func buildWeatherStory(bundle *forecast.Bundle) *WeatherStoryContext { func buildWeatherStory(bundle *forecast.Bundle) *WeatherStoryContext {
if bundle == nil || bundle.WeatherStory == nil || len(bundle.WeatherStory.Raw) == 0 { if bundle == nil || bundle.WeatherStory == nil {
return nil return nil
} }
return &WeatherStoryContext{Available: true, Summary: string(bundle.WeatherStory.Raw)} story := bundle.WeatherStory
return &WeatherStoryContext{
Available: true,
OfficeID: story.OfficeID,
StartTime: story.StartTime,
EndTime: story.EndTime,
UpdatedAt: copyTime(story.UpdatedAt),
Title: story.Title,
Description: story.Description,
AltText: story.AltText,
Priority: story.Priority,
Order: story.Order,
DownloadURL: story.DownloadURL,
}
}
func copyTime(value *time.Time) *time.Time {
if value == nil {
return nil
}
copied := *value
return &copied
} }
func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow { func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
@@ -293,10 +314,6 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
score += float64(len(daypart.AlertOverlaps)) * 100 score += float64(len(daypart.AlertOverlaps)) * 100
reasons = append(reasons, "alert overlap") reasons = append(reasons, "alert overlap")
} }
if daypart.Indicators.Thunder {
score += 75
reasons = append(reasons, "thunder risk")
}
if daypart.Indicators.Heat || daypart.Indicators.Cold { if daypart.Indicators.Heat || daypart.Indicators.Cold {
score += 25 score += 25
if daypart.Indicators.Heat { if daypart.Indicators.Heat {
@@ -334,9 +351,6 @@ func bottomLineText(conditions []string, hazards []string) string {
func hazardsForIndicators(indicators forecast.Indicators) []string { func hazardsForIndicators(indicators forecast.Indicators) []string {
var hazards []string var hazards []string
if indicators.Thunder {
hazards = append(hazards, "thunder")
}
if indicators.Snow { if indicators.Snow {
hazards = append(hazards, "snow") hazards = append(hazards, "snow")
} }

View File

@@ -15,6 +15,19 @@ import (
func TestDailyBriefingFromRepresentativeFixture(t *testing.T) { func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
bundle := loadBundleFixture(t) bundle := loadBundleFixture(t)
currentIsDay := true
currentTemp := 75.9
currentFeelsLike := 76.1
currentHumidity := 56.0
currentWind := 10.7
bundle.Current = &forecast.Current{
ConditionText: "Partly cloudy",
IsDay: &currentIsDay,
TemperatureF: &currentTemp,
ApparentTemperatureF: &currentFeelsLike,
RelativeHumidityPercent: &currentHumidity,
WindSpeedMph: &currentWind,
}
bundle.Sources[0].DataSHA256 = "abc123" bundle.Sources[0].DataSHA256 = "abc123"
bundle.Warnings = []forecast.SourceWarning{{Source: "daily", Code: "missing_source", Severity: "warning"}} bundle.Warnings = []forecast.SourceWarning{{Source: "daily", Code: "missing_source", Severity: "warning"}}
location := mustLocation(t) location := mustLocation(t)
@@ -29,6 +42,12 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
Bundle: bundle, Bundle: bundle,
Units: "us", Units: "us",
Timezone: "America/Chicago", Timezone: "America/Chicago",
Location: &LocationContext{
ID: "home",
Name: "Brentwood",
Region: "St. Louis Metro",
Timezone: "America/Chicago",
},
}, summary) }, summary)
if err != nil { if err != nil {
t.Fatalf("BuildDaily() error = %v", err) t.Fatalf("BuildDaily() error = %v", err)
@@ -46,6 +65,12 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
if pkg.Metadata.Units != "us" || pkg.Metadata.Timezone != "America/Chicago" { if pkg.Metadata.Units != "us" || pkg.Metadata.Timezone != "America/Chicago" {
t.Fatalf("metadata units/timezone = %q/%q", pkg.Metadata.Units, pkg.Metadata.Timezone) t.Fatalf("metadata units/timezone = %q/%q", pkg.Metadata.Units, pkg.Metadata.Timezone)
} }
if pkg.Metadata.Location == nil || pkg.Metadata.Location.ID != "home" || pkg.Metadata.Location.Name != "Brentwood" || pkg.Metadata.Location.Region != "St. Louis Metro" || pkg.Metadata.Location.Timezone != "America/Chicago" {
t.Fatalf("metadata location = %#v, want configured prompt location", pkg.Metadata.Location)
}
if pkg.CurrentConditions == nil || pkg.CurrentConditions.ConditionText != "Partly cloudy" || pkg.CurrentConditions.TemperatureF == nil || *pkg.CurrentConditions.TemperatureF != currentTemp || pkg.CurrentConditions.RelativeHumidityPercent == nil || *pkg.CurrentConditions.RelativeHumidityPercent != currentHumidity {
t.Fatalf("CurrentConditions = %#v, want current conditions from bundle", pkg.CurrentConditions)
}
if len(pkg.Metadata.Sources) != 1 || pkg.Metadata.Sources[0].DataSHA256 != "abc123" { if len(pkg.Metadata.Sources) != 1 || pkg.Metadata.Sources[0].DataSHA256 != "abc123" {
t.Fatalf("Sources = %#v, want source hash", pkg.Metadata.Sources) t.Fatalf("Sources = %#v, want source hash", pkg.Metadata.Sources)
} }
@@ -55,8 +80,8 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
if pkg.Daily == nil { if pkg.Daily == nil {
t.Fatal("Daily = nil") t.Fatal("Daily = nil")
} }
if len(pkg.Daily.Dayparts) != 4 { if len(pkg.Daily.Dayparts) != 5 {
t.Fatalf("Dayparts length = %d, want 4", len(pkg.Daily.Dayparts)) t.Fatalf("Dayparts length = %d, want 5", len(pkg.Daily.Dayparts))
} }
if len(pkg.Daily.RelevantAlerts) != 1 { if len(pkg.Daily.RelevantAlerts) != 1 {
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.Daily.RelevantAlerts)) t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.Daily.RelevantAlerts))
@@ -67,6 +92,12 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
if len(pkg.Daily.Discussion.KeyMessages) != 1 { if len(pkg.Daily.Discussion.KeyMessages) != 1 {
t.Fatalf("Discussion key messages length = %d, want 1", len(pkg.Daily.Discussion.KeyMessages)) t.Fatalf("Discussion key messages length = %d, want 1", len(pkg.Daily.Discussion.KeyMessages))
} }
if pkg.Daily.Discussion.ShortTerm != "Morning showers taper as a weak boundary shifts east." {
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Daily.Discussion.ShortTerm)
}
if pkg.Daily.Discussion.LongTerm != "Warmer and more humid conditions return with periodic rain chances." {
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Daily.Discussion.LongTerm)
}
if pkg.Daily.OutdoorWindows.Best == nil || pkg.Daily.OutdoorWindows.Worst == nil { if pkg.Daily.OutdoorWindows.Best == nil || pkg.Daily.OutdoorWindows.Worst == nil {
t.Fatalf("OutdoorWindows = %#v, want best and worst", pkg.Daily.OutdoorWindows) t.Fatalf("OutdoorWindows = %#v, want best and worst", pkg.Daily.OutdoorWindows)
} }
@@ -85,7 +116,13 @@ func TestDailyBriefingQuietWeather(t *testing.T) {
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{ Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{
quietHour("2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", 72), quietHour("2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", 72),
}}, }},
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}}, Alerts: &forecast.AlertRun{},
Sources: []forecast.Source{
{Name: "hourly", FetchedAt: time.Now()},
{Name: "alerts", Endpoint: "/alerts/active", FetchedAt: time.Now()},
{Name: "current", Endpoint: "/conditions/current", FetchedAt: time.Now(), Missing: true},
},
Warnings: []forecast.SourceWarning{{Source: "current", Code: "missing_source", Severity: "warning"}},
} }
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts()) summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
if err != nil { if err != nil {
@@ -98,9 +135,28 @@ func TestDailyBriefingQuietWeather(t *testing.T) {
if pkg.Daily.BottomLine.Summary != "Conditions: Clear." { if pkg.Daily.BottomLine.Summary != "Conditions: Clear." {
t.Fatalf("BottomLine summary = %q, want clear conditions", pkg.Daily.BottomLine.Summary) t.Fatalf("BottomLine summary = %q, want clear conditions", pkg.Daily.BottomLine.Summary)
} }
if pkg.CurrentConditions != nil {
t.Fatalf("CurrentConditions = %#v, want nil when current conditions are missing", pkg.CurrentConditions)
}
if len(pkg.Metadata.SourceWarnings) != 1 || pkg.Metadata.SourceWarnings[0].Source != "current" {
t.Fatalf("SourceWarnings = %#v, want current missing-source warning", pkg.Metadata.SourceWarnings)
}
if len(pkg.Daily.RelevantAlerts) != 0 { if len(pkg.Daily.RelevantAlerts) != 0 {
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts)) t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts))
} }
if pkg.Metadata.Alerts == nil {
t.Fatal("Metadata.Alerts = nil, want checked no-active-alerts status")
}
if !pkg.Metadata.Alerts.Checked || pkg.Metadata.Alerts.ActiveCount != 0 || pkg.Metadata.Alerts.RelevantCount != 0 || pkg.Metadata.Alerts.Missing {
t.Fatalf("Metadata.Alerts = %#v, want checked no-active-alerts status", pkg.Metadata.Alerts)
}
data, err := json.Marshal(pkg.Metadata.Alerts)
if err != nil {
t.Fatalf("marshal alert metadata: %v", err)
}
if strings.Contains(string(data), `"missing"`) {
t.Fatalf("alert metadata includes missing for checked empty alerts:\n%s", string(data))
}
} }
func TestDailyBriefingAlertExclusion(t *testing.T) { func TestDailyBriefingAlertExclusion(t *testing.T) {
@@ -163,7 +219,7 @@ func TestTomorrowBriefingIncludesPlanningInputs(t *testing.T) {
Value: wind, Value: wind,
Time: mustParse("2026-05-30T09:00:00-05:00"), Time: mustParse("2026-05-30T09:00:00-05:00"),
}, },
Indicators: forecast.Indicators{Thunder: true}, Indicators: forecast.Indicators{Snow: true},
}, },
}, },
} }
@@ -237,9 +293,10 @@ func mustResolveDaily(t *testing.T, location *time.Location) report.Resolved {
func defaultDayparts() []forecast.DaypartDefinition { func defaultDayparts() []forecast.DaypartDefinition {
return []forecast.DaypartDefinition{ return []forecast.DaypartDefinition{
{Name: "overnight", Start: "00:00", End: "06:00"}, {Name: "overnight", Start: "00:00", End: "06:00"},
{Name: "morning", Start: "06:00", End: "12:00"}, {Name: "morning", Start: "06:00", End: "10:00"},
{Name: "afternoon", Start: "12:00", End: "18:00"}, {Name: "midday", Start: "10:00", End: "15:00"},
{Name: "evening", Start: "18:00", End: "24:00"}, {Name: "afternoon", Start: "15:00", End: "17:00"},
{Name: "evening", Start: "17:00", End: "24:00"},
} }
} }

View File

@@ -2,12 +2,10 @@
package briefing package briefing
import ( import (
"encoding/json"
"fmt" "fmt"
"os"
"path/filepath"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
@@ -16,11 +14,12 @@ import (
const SchemaVersion = "weatherreporter.briefing.v1" const SchemaVersion = "weatherreporter.briefing.v1"
type Package struct { type Package struct {
Metadata Metadata `json:"metadata"` Metadata Metadata `json:"metadata"`
Daily *Daily `json:"daily,omitempty"` CurrentConditions *CurrentConditionsContext `json:"currentConditions,omitempty"`
ThreeDay *ThreeDay `json:"threeDay,omitempty"` Daily *Daily `json:"daily,omitempty"`
Weekend *Weekend `json:"weekend,omitempty"` ThreeDay *ThreeDay `json:"threeDay,omitempty"`
Storm *Storm `json:"storm,omitempty"` Weekend *Weekend `json:"weekend,omitempty"`
Storm *Storm `json:"storm,omitempty"`
} }
type Metadata struct { type Metadata struct {
@@ -33,10 +32,34 @@ type Metadata struct {
Units string `json:"units"` Units string `json:"units"`
Timezone string `json:"timezone"` Timezone string `json:"timezone"`
ValidPeriod timeutil.Period `json:"validPeriod"` ValidPeriod timeutil.Period `json:"validPeriod"`
Location *LocationContext `json:"location,omitempty"`
SourceLocationID string `json:"sourceLocationId,omitempty"` SourceLocationID string `json:"sourceLocationId,omitempty"`
SourceLocation string `json:"sourceLocation,omitempty"` SourceLocation string `json:"sourceLocation,omitempty"`
Sources []SourceMetadata `json:"sources,omitempty"` Sources []SourceMetadata `json:"sources,omitempty"`
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"` SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"`
Alerts *AlertStatus `json:"alerts,omitempty"`
}
type LocationContext struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Region string `json:"region,omitempty"`
Timezone string `json:"timezone,omitempty"`
}
type CurrentConditionsContext struct {
ConditionText string `json:"conditionText,omitempty"`
IsDay *bool `json:"isDay,omitempty"`
TemperatureC *float64 `json:"temperatureC,omitempty"`
TemperatureF *float64 `json:"temperatureF,omitempty"`
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"`
DewpointC *float64 `json:"dewpointC,omitempty"`
DewpointF *float64 `json:"dewpointF,omitempty"`
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"`
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"`
WindSpeedMph *float64 `json:"windSpeedMph,omitempty"`
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"`
} }
type SourceMetadata struct { type SourceMetadata struct {
@@ -50,11 +73,19 @@ type SourceMetadata struct {
Warnings []forecast.SourceWarning `json:"warnings,omitempty"` Warnings []forecast.SourceWarning `json:"warnings,omitempty"`
} }
type AlertStatus struct {
Checked bool `json:"checked"`
ActiveCount int `json:"activeCount"`
RelevantCount int `json:"relevantCount"`
Missing bool `json:"missing,omitempty"`
}
type BuildContext struct { type BuildContext struct {
Resolved report.Resolved Resolved report.Resolved
Bundle *forecast.Bundle Bundle *forecast.Bundle
Units string Units string
Timezone string Timezone string
Location *LocationContext
} }
func BuildMetadata(ctx BuildContext) Metadata { func BuildMetadata(ctx BuildContext) Metadata {
@@ -70,37 +101,85 @@ func BuildMetadata(ctx BuildContext) Metadata {
Units: ctx.Units, Units: ctx.Units,
Timezone: ctx.Timezone, Timezone: ctx.Timezone,
ValidPeriod: metadata.ValidPeriod, ValidPeriod: metadata.ValidPeriod,
Location: copyLocation(ctx.Location),
SourceLocationID: sourceLocationID, SourceLocationID: sourceLocationID,
SourceLocation: sourceLocation, SourceLocation: sourceLocation,
Sources: sourceMetadata(ctx.Bundle), Sources: sourceMetadata(ctx.Bundle),
SourceWarnings: sourceWarnings(ctx.Bundle), SourceWarnings: sourceWarnings(ctx.Bundle),
Alerts: alertStatus(ctx.Bundle),
} }
} }
func Save(path string, pkg Package) error { func buildPackage(ctx BuildContext) Package {
data, err := json.MarshalIndent(pkg, "", " ") return Package{
if err != nil { Metadata: BuildMetadata(ctx),
return fmt.Errorf("marshal briefing package: %w", err) CurrentConditions: currentConditions(ctx.Bundle),
} }
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { }
return fmt.Errorf("create briefing directory %q: %w", filepath.Dir(path), err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary briefing file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil { func copyLocation(location *LocationContext) *LocationContext {
tmp.Close() if location == nil {
return fmt.Errorf("write temporary briefing file: %w", err) return nil
} }
if err := tmp.Close(); err != nil { copied := *location
return fmt.Errorf("close temporary briefing file: %w", err) return &copied
}
func currentConditions(bundle *forecast.Bundle) *CurrentConditionsContext {
if bundle == nil || bundle.Current == nil {
return nil
} }
if err := os.Rename(tmpName, path); err != nil { current := bundle.Current
return fmt.Errorf("save briefing %q: %w", path, err) context := CurrentConditionsContext{
ConditionText: current.ConditionText,
IsDay: copyBool(current.IsDay),
TemperatureC: copyFloat(current.TemperatureC),
TemperatureF: copyFloat(current.TemperatureF),
ApparentTemperatureC: copyFloat(current.ApparentTemperatureC),
ApparentTemperatureF: copyFloat(current.ApparentTemperatureF),
DewpointC: copyFloat(current.DewpointC),
DewpointF: copyFloat(current.DewpointF),
RelativeHumidityPercent: copyFloat(current.RelativeHumidityPercent),
WindSpeedKmh: copyFloat(current.WindSpeedKmh),
WindSpeedMph: copyFloat(current.WindSpeedMph),
WindDirectionDegrees: copyFloat(current.WindDirectionDegrees),
}
if context.ConditionText == "" &&
context.IsDay == nil &&
context.TemperatureC == nil &&
context.TemperatureF == nil &&
context.ApparentTemperatureC == nil &&
context.ApparentTemperatureF == nil &&
context.DewpointC == nil &&
context.DewpointF == nil &&
context.RelativeHumidityPercent == nil &&
context.WindSpeedKmh == nil &&
context.WindSpeedMph == nil &&
context.WindDirectionDegrees == nil {
return nil
}
return &context
}
func copyBool(value *bool) *bool {
if value == nil {
return nil
}
copied := *value
return &copied
}
func copyFloat(value *float64) *float64 {
if value == nil {
return nil
}
copied := *value
return &copied
}
func Save(path string, pkg Package) error {
if err := fileutil.WriteJSONAtomic(path, pkg); err != nil {
return fmt.Errorf("save briefing package: %w", err)
} }
return nil return nil
} }
@@ -147,6 +226,37 @@ func sourceWarnings(bundle *forecast.Bundle) []forecast.SourceWarning {
return bundle.Warnings return bundle.Warnings
} }
func alertStatus(bundle *forecast.Bundle) *AlertStatus {
if bundle == nil {
return nil
}
status := &AlertStatus{}
if bundle.Alerts != nil {
status.Checked = true
status.ActiveCount = len(bundle.Alerts.Alerts)
}
for _, source := range bundle.Sources {
if source.Name == "alerts" && source.Missing {
status.Missing = true
break
}
}
if !status.Checked && !status.Missing {
return nil
}
return status
}
func setRelevantAlertCount(metadata *Metadata, count int) {
if metadata.Alerts == nil {
if count == 0 {
return
}
metadata.Alerts = &AlertStatus{}
}
metadata.Alerts.RelevantCount = count
}
func variantForReport(id report.ID) string { func variantForReport(id report.ID) string {
switch id { switch id {
case report.DailyToday: case report.DailyToday:

View File

@@ -56,10 +56,10 @@ func BuildStorm(ctx BuildContext) (Package, error) {
Discussion: buildDiscussion(ctx.Bundle.Discussion), Discussion: buildDiscussion(ctx.Bundle.Discussion),
WeatherStory: buildWeatherStory(ctx.Bundle), WeatherStory: buildWeatherStory(ctx.Bundle),
} }
return Package{ pkg := buildPackage(ctx)
Metadata: BuildMetadata(ctx), pkg.Storm = storm
Storm: storm, setRelevantAlertCount(&pkg.Metadata, len(alerts))
}, nil return pkg, nil
} }
func stormHeadlines(alerts []forecast.AlertOverlap) []string { func stormHeadlines(alerts []forecast.AlertOverlap) []string {
@@ -139,9 +139,6 @@ func reasonableWorstCase(alerts []forecast.AlertOverlap, summary forecast.Daypar
items = appendUnique(items, "Alert scenario to consider: "+label+".") items = appendUnique(items, "Alert scenario to consider: "+label+".")
} }
} }
if summary.Indicators.Thunder {
items = appendUnique(items, "Thunderstorm timing or intensity could be more disruptive than the baseline forecast.")
}
if summary.Indicators.Wind { if summary.Indicators.Wind {
items = appendUnique(items, "Wind impacts could be higher where stronger gusts occur.") items = appendUnique(items, "Wind impacts could be higher where stronger gusts occur.")
} }
@@ -161,12 +158,16 @@ func stormConfidenceInputs(bundle *forecast.Bundle) []string {
} }
if bundle.Discussion != nil { if bundle.Discussion != nil {
items = appendUnique(items, bundle.Discussion.KeyMessages...) items = appendUnique(items, bundle.Discussion.KeyMessages...)
if bundle.Discussion.ShortTerm != nil && bundle.Discussion.ShortTerm.Narrative != "" { if bundle.Discussion.ShortTerm != nil && bundle.Discussion.ShortTerm.Text != "" {
items = appendUnique(items, "Short-term discussion is available for confidence context.") items = appendUnique(items, "Short-term discussion is available for confidence context.")
} }
} }
if bundle.WeatherStory != nil && len(bundle.WeatherStory.Raw) > 0 { if bundle.WeatherStory != nil {
items = appendUnique(items, "Weather story source is available.") if bundle.WeatherStory.Title != "" {
items = appendUnique(items, "Weather story: "+bundle.WeatherStory.Title+".")
} else {
items = appendUnique(items, "Weather story source is available.")
}
} }
for _, warning := range bundle.Warnings { for _, warning := range bundle.Warnings {
if warning.Code != "" { if warning.Code != "" {

View File

@@ -44,9 +44,22 @@ func TestStormBriefingWithActiveAlert(t *testing.T) {
Alerts: &forecast.AlertRun{Alerts: []json.RawMessage{ Alerts: &forecast.AlertRun{Alerts: []json.RawMessage{
json.RawMessage(`{"event":"Severe Thunderstorm Warning","headline":"Severe storms near Testville","severity":"Severe","effective":"2026-05-29T06:30:00-05:00","expires":"2026-05-29T08:30:00-05:00"}`), json.RawMessage(`{"event":"Severe Thunderstorm Warning","headline":"Severe storms near Testville","severity":"Severe","effective":"2026-05-29T06:30:00-05:00","expires":"2026-05-29T08:30:00-05:00"}`),
}}, }},
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Storms may intensify quickly."}}, Discussion: &forecast.Discussion{
WeatherStory: &forecast.WeatherStory{Raw: json.RawMessage(`{"headline":"Storm risk"}`)}, Product: "discussion",
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}}, KeyMessages: []string{"Storms may intensify quickly."},
ShortTerm: &forecast.DiscussionSection{Text: "Short-term storm coverage peaks this morning."},
LongTerm: &forecast.DiscussionSection{Text: "Long-term pattern stays unsettled after the event."},
},
WeatherStory: &forecast.WeatherStory{
OfficeID: "LSX",
StartTime: mustParse("2026-05-29T06:00:00Z"),
EndTime: mustParse("2026-05-29T18:00:00Z"),
Title: "Storm Risk",
Description: "Strong storms are possible.",
AltText: "Weather story graphic showing storm risk.",
Order: 1,
},
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
} }
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}) pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"})
@@ -75,6 +88,15 @@ func TestStormBriefingWithActiveAlert(t *testing.T) {
if pkg.Storm.WeatherStory == nil { if pkg.Storm.WeatherStory == nil {
t.Fatal("WeatherStory = nil, want available story context") t.Fatal("WeatherStory = nil, want available story context")
} }
if pkg.Storm.WeatherStory.Title != "Storm Risk" || pkg.Storm.WeatherStory.Description != "Strong storms are possible." {
t.Fatalf("WeatherStory = %#v, want structured story context", pkg.Storm.WeatherStory)
}
if pkg.Storm.Discussion.ShortTerm != "Short-term storm coverage peaks this morning." {
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Storm.Discussion.ShortTerm)
}
if pkg.Storm.Discussion.LongTerm != "Long-term pattern stays unsettled after the event." {
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Storm.Discussion.LongTerm)
}
if len(pkg.Storm.WhatToWatchNext) == 0 { if len(pkg.Storm.WhatToWatchNext) == 0 {
t.Fatal("WhatToWatchNext length = 0, want watch inputs") t.Fatal("WhatToWatchNext length = 0, want watch inputs")
} }

View File

@@ -37,18 +37,17 @@ func BuildThreeDay(ctx BuildContext, summaries []forecast.DailySummary) (Package
if len(summaries) == 0 { if len(summaries) == 0 {
return Package{}, fmt.Errorf("3-day forecast summaries are required") return Package{}, fmt.Errorf("3-day forecast summaries are required")
} }
pkg := Package{ pkg := buildPackage(ctx)
Metadata: BuildMetadata(ctx), pkg.ThreeDay = &ThreeDay{
ThreeDay: &ThreeDay{ Discussion: buildDiscussion(summaries[0].Discussion),
Discussion: buildDiscussion(summaries[0].Discussion), WeatherStory: buildWeatherStory(ctx.Bundle),
WeatherStory: buildWeatherStory(ctx.Bundle),
},
} }
for _, summary := range summaries { for _, summary := range summaries {
day := buildOutlookDay(summary) day := buildOutlookDay(summary)
pkg.ThreeDay.Days = append(pkg.ThreeDay.Days, day) pkg.ThreeDay.Days = append(pkg.ThreeDay.Days, day)
} }
pkg.ThreeDay.RelevantAlerts = collectOutlookAlerts(pkg.ThreeDay.Days) pkg.ThreeDay.RelevantAlerts = collectOutlookAlerts(pkg.ThreeDay.Days)
setRelevantAlertCount(&pkg.Metadata, len(pkg.ThreeDay.RelevantAlerts))
return pkg, nil return pkg, nil
} }

View File

@@ -39,11 +39,16 @@ func TestThreeDayBriefingBuildsOutlookDays(t *testing.T) {
Value: gust, Value: gust,
Time: mustParse("2026-05-29T10:00:00-05:00"), Time: mustParse("2026-05-29T10:00:00-05:00"),
}, },
Indicators: forecast.Indicators{Thunder: true, Wind: true}, Indicators: forecast.Indicators{Wind: true},
}, },
}, },
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}}, AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Unsettled stretch."}}, Discussion: &forecast.Discussion{
Product: "discussion",
KeyMessages: []string{"Unsettled stretch."},
ShortTerm: &forecast.DiscussionSection{Text: "Short-term rain chances remain focused today."},
LongTerm: &forecast.DiscussionSection{Text: "Long-term warmth builds into the weekend."},
},
}, },
{ {
Date: "2026-05-30", Date: "2026-05-30",
@@ -74,10 +79,16 @@ func TestThreeDayBriefingBuildsOutlookDays(t *testing.T) {
t.Fatalf("Days length = %d, want 2", len(pkg.ThreeDay.Days)) t.Fatalf("Days length = %d, want 2", len(pkg.ThreeDay.Days))
} }
first := pkg.ThreeDay.Days[0] first := pkg.ThreeDay.Days[0]
if !strings.Contains(first.OverallCharacter, "Showers") || !strings.Contains(strings.Join(first.Risks, ","), "thunder") { if !strings.Contains(first.OverallCharacter, "Showers") || !strings.Contains(strings.Join(first.Risks, ","), "wind") {
t.Fatalf("first day = %#v, want conditions and risks", first) t.Fatalf("first day = %#v, want conditions and risks", first)
} }
if len(pkg.ThreeDay.RelevantAlerts) != 1 { if len(pkg.ThreeDay.RelevantAlerts) != 1 {
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.ThreeDay.RelevantAlerts)) t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.ThreeDay.RelevantAlerts))
} }
if pkg.ThreeDay.Discussion.ShortTerm != "Short-term rain chances remain focused today." {
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.ThreeDay.Discussion.ShortTerm)
}
if pkg.ThreeDay.Discussion.LongTerm != "Long-term warmth builds into the weekend." {
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.ThreeDay.Discussion.LongTerm)
}
} }

View File

@@ -31,17 +31,16 @@ func BuildWeekend(ctx BuildContext, summaries []forecast.DailySummary) (Package,
if len(summaries) == 0 { if len(summaries) == 0 {
return Package{}, fmt.Errorf("weekend forecast summaries are required") return Package{}, fmt.Errorf("weekend forecast summaries are required")
} }
pkg := Package{ pkg := buildPackage(ctx)
Metadata: BuildMetadata(ctx), pkg.Weekend = &Weekend{
Weekend: &Weekend{ Discussion: buildDiscussion(summaries[0].Discussion),
Discussion: buildDiscussion(summaries[0].Discussion), WeatherStory: buildWeatherStory(ctx.Bundle),
WeatherStory: buildWeatherStory(ctx.Bundle),
},
} }
for _, summary := range summaries { for _, summary := range summaries {
pkg.Weekend.Days = append(pkg.Weekend.Days, buildOutlookDay(summary)) pkg.Weekend.Days = append(pkg.Weekend.Days, buildOutlookDay(summary))
} }
pkg.Weekend.RelevantAlerts = collectOutlookAlerts(pkg.Weekend.Days) pkg.Weekend.RelevantAlerts = collectOutlookAlerts(pkg.Weekend.Days)
setRelevantAlertCount(&pkg.Metadata, len(pkg.Weekend.RelevantAlerts))
pkg.Weekend.Planning = buildWeekendPlanning(pkg.Weekend.Days, pkg.Weekend.Discussion, ctx.Bundle) pkg.Weekend.Planning = buildWeekendPlanning(pkg.Weekend.Days, pkg.Weekend.Discussion, ctx.Bundle)
return pkg, nil return pkg, nil
} }
@@ -96,9 +95,6 @@ func weekendRainStormNotes(date string, daypart forecast.DaypartSummary) []strin
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 { if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 {
notes = append(notes, fmt.Sprintf("%s precipitation chance peaks near %.0f%%.", label, daypart.MaxPrecipitationProbability.Value)) notes = append(notes, fmt.Sprintf("%s precipitation chance peaks near %.0f%%.", label, daypart.MaxPrecipitationProbability.Value))
} }
if daypart.Indicators.Thunder {
notes = append(notes, label+" thunder risk is present.")
}
return notes return notes
} }

View File

@@ -43,7 +43,7 @@ func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) {
Value: gust, Value: gust,
Time: mustParse("2026-05-30T16:00:00-05:00"), Time: mustParse("2026-05-30T16:00:00-05:00"),
}, },
Indicators: forecast.Indicators{Thunder: true, Wind: true}, Indicators: forecast.Indicators{Wind: true},
HourlyPeriods: []forecast.ForecastPeriod{ HourlyPeriods: []forecast.ForecastPeriod{
{ {
StartTime: mustParse("2026-05-30T15:00:00-05:00"), StartTime: mustParse("2026-05-30T15:00:00-05:00"),
@@ -54,7 +54,12 @@ func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) {
}, },
}, },
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}}, AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Timing may shift."}}, Discussion: &forecast.Discussion{
Product: "discussion",
KeyMessages: []string{"Timing may shift."},
ShortTerm: &forecast.DiscussionSection{Text: "Short-term showers exit before the weekend."},
LongTerm: &forecast.DiscussionSection{Text: "Long-term weekend rain timing remains uncertain."},
},
}, },
} }
@@ -79,10 +84,16 @@ func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) {
if len(pkg.Weekend.Planning.WorstWeatherWindows) == 0 { if len(pkg.Weekend.Planning.WorstWeatherWindows) == 0 {
t.Fatalf("WorstWeatherWindows = %#v, want weather window", pkg.Weekend.Planning.WorstWeatherWindows) t.Fatalf("WorstWeatherWindows = %#v, want weather window", pkg.Weekend.Planning.WorstWeatherWindows)
} }
if !strings.Contains(strings.Join(pkg.Weekend.Planning.RainStormTiming, " "), "thunder") { if !strings.Contains(strings.Join(pkg.Weekend.Planning.RainStormTiming, " "), "precipitation") {
t.Fatalf("RainStormTiming = %#v, want thunder timing", pkg.Weekend.Planning.RainStormTiming) t.Fatalf("RainStormTiming = %#v, want precipitation timing", pkg.Weekend.Planning.RainStormTiming)
} }
if len(pkg.Weekend.Planning.UncertaintyInputs) == 0 { if len(pkg.Weekend.Planning.UncertaintyInputs) == 0 {
t.Fatal("UncertaintyInputs length = 0, want discussion context") t.Fatal("UncertaintyInputs length = 0, want discussion context")
} }
if pkg.Weekend.Discussion.ShortTerm != "Short-term showers exit before the weekend." {
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Weekend.Discussion.ShortTerm)
}
if pkg.Weekend.Discussion.LongTerm != "Long-term weekend rain timing remains uncertain." {
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Weekend.Discussion.LongTerm)
}
} }

View File

@@ -126,7 +126,6 @@ func compareIndicators(previous forecast.Indicators, current forecast.Indicators
previous bool previous bool
current bool current bool
}{ }{
{name: "thunder", previous: previous.Thunder, current: current.Thunder},
{name: "snow", previous: previous.Snow, current: current.Snow}, {name: "snow", previous: previous.Snow, current: current.Snow},
{name: "ice", previous: previous.Ice, current: current.Ice}, {name: "ice", previous: previous.Ice, current: current.Ice},
} { } {
@@ -146,7 +145,6 @@ func compareIndicators(previous forecast.Indicators, current forecast.Indicators
func aggregateIndicators(dayparts []forecast.DaypartSummary) forecast.Indicators { func aggregateIndicators(dayparts []forecast.DaypartSummary) forecast.Indicators {
out := forecast.Indicators{} out := forecast.Indicators{}
for _, daypart := range dayparts { for _, daypart := range dayparts {
out.Thunder = out.Thunder || daypart.Indicators.Thunder
out.Snow = out.Snow || daypart.Indicators.Snow out.Snow = out.Snow || daypart.Indicators.Snow
out.Ice = out.Ice || daypart.Indicators.Ice out.Ice = out.Ice || daypart.Indicators.Ice
} }

View File

@@ -62,14 +62,14 @@ func TestCompareDailyAlertAddedAndRemoved(t *testing.T) {
func TestCompareDailyIndicatorChange(t *testing.T) { func TestCompareDailyIndicatorChange(t *testing.T) {
previous := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{}) previous := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
current := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{Thunder: true}) current := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{Snow: true})
changes, err := CompareDaily(previous, current, testThresholds()) changes, err := CompareDaily(previous, current, testThresholds())
if err != nil { if err != nil {
t.Fatalf("CompareDaily() error = %v", err) t.Fatalf("CompareDaily() error = %v", err)
} }
if countType(changes, "thunder_risk_change") != 1 { if countType(changes, "snow_risk_change") != 1 {
t.Fatalf("changes = %#v, want thunder risk change", changes) t.Fatalf("changes = %#v, want snow risk change", changes)
} }
} }

View File

@@ -34,7 +34,7 @@ func TestCompareThreeDayDetectsDayChanges(t *testing.T) {
Value: currentPrecip, Value: currentPrecip,
Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC), Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC),
}, },
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Thunder: true}}}, Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Snow: true}}},
}}}, }}},
} }
@@ -51,16 +51,16 @@ func TestCompareThreeDayDetectsDayChanges(t *testing.T) {
t.Fatal("changes length = 0, want detected 3-day changes") t.Fatal("changes length = 0, want detected 3-day changes")
} }
var foundPrecip bool var foundPrecip bool
var foundThunder bool var foundSnow bool
for _, change := range changes { for _, change := range changes {
if change.Type == "outlook_precip_probability_change" { if change.Type == "outlook_precip_probability_change" {
foundPrecip = true foundPrecip = true
} }
if change.Type == "outlook_thunder_risk_change" { if change.Type == "outlook_snow_risk_change" {
foundThunder = true foundSnow = true
} }
} }
if !foundPrecip || !foundThunder { if !foundPrecip || !foundSnow {
t.Fatalf("changes = %#v, want precipitation and thunder changes", changes) t.Fatalf("changes = %#v, want precipitation and snow changes", changes)
} }
} }

View File

@@ -23,7 +23,7 @@ func TestCompareWeekendDetectsOutlookChanges(t *testing.T) {
Weekend: &briefing.Weekend{Days: []briefing.OutlookDay{{ Weekend: &briefing.Weekend{Days: []briefing.OutlookDay{{
Date: "2026-05-30", Date: "2026-05-30",
Temperature: forecast.Range{Max: &currentTemp}, Temperature: forecast.Range{Max: &currentTemp},
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Thunder: true}}}, Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Snow: true}}},
}}}, }}},
} }
@@ -35,9 +35,9 @@ func TestCompareWeekendDetectsOutlookChanges(t *testing.T) {
t.Fatal("changes length = 0, want weekend changes") t.Fatal("changes length = 0, want weekend changes")
} }
for _, change := range changes { for _, change := range changes {
if change.Type == "weekend_outlook_thunder_risk_change" { if change.Type == "weekend_outlook_snow_risk_change" {
return return
} }
} }
t.Fatalf("changes = %#v, want thunder risk change", changes) t.Fatalf("changes = %#v, want snow risk change", changes)
} }

View File

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

View File

@@ -62,12 +62,8 @@ func TestRunGenerateStormWritesMarkdownReport(t *testing.T) {
server := dailyServer(t) server := dailyServer(t)
tempDir := t.TempDir() tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir) scriptoriumPath := writeFakeScriptorium(t, tempDir)
configPath := filepath.Join(tempDir, "config.yml")
workspaceRoot := filepath.Join(tempDir, "workspace") workspaceRoot := filepath.Join(tempDir, "workspace")
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n" configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
outPath := filepath.Join(tempDir, "storm.md") outPath := filepath.Join(tempDir, "storm.md")
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
@@ -90,14 +86,8 @@ func TestRunGenerateStormWritesMarkdownReport(t *testing.T) {
if !strings.Contains(string(report), "# Daily Report") { if !strings.Contains(string(report), "# Daily Report") {
t.Fatalf("report output missing markdown:\n%s", string(report)) t.Fatalf("report output missing markdown:\n%s", string(report))
} }
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.json")) dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.json")
if err != nil { data, err := os.ReadFile(dataPackagePath)
t.Fatalf("glob data package: %v", err)
}
if len(dataPackageMatches) != 1 {
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
}
data, err := os.ReadFile(dataPackageMatches[0])
if err != nil { if err != nil {
t.Fatalf("read managed data package: %v", err) t.Fatalf("read managed data package: %v", err)
} }
@@ -110,12 +100,8 @@ func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
server := dailyServer(t) server := dailyServer(t)
tempDir := t.TempDir() tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir) scriptoriumPath := writeFakeScriptorium(t, tempDir)
configPath := filepath.Join(tempDir, "config.yml")
workspaceRoot := filepath.Join(tempDir, "workspace") workspaceRoot := filepath.Join(tempDir, "workspace")
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n" configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
outPath := filepath.Join(tempDir, "tomorrow.md") outPath := filepath.Join(tempDir, "tomorrow.md")
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
@@ -136,14 +122,8 @@ func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
if !strings.Contains(string(report), "# Daily Report") { if !strings.Contains(string(report), "# Daily Report") {
t.Fatalf("report output missing markdown:\n%s", string(report)) t.Fatalf("report output missing markdown:\n%s", string(report))
} }
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.json")) dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.json")
if err != nil { data, err := os.ReadFile(dataPackagePath)
t.Fatalf("glob data package: %v", err)
}
if len(dataPackageMatches) != 1 {
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
}
data, err := os.ReadFile(dataPackageMatches[0])
if err != nil { if err != nil {
t.Fatalf("read managed data package: %v", err) t.Fatalf("read managed data package: %v", err)
} }
@@ -457,6 +437,7 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
"generate", "daily", "generate", "daily",
"--config", configPath, "--config", configPath,
"--date", "2026-05-29", "--date", "2026-05-29",
"--tz", "UTC",
"--out", outPath, "--out", outPath,
}, &stdout, &stderr) }, &stdout, &stderr)
if err != nil { if err != nil {
@@ -483,6 +464,24 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
if !strings.Contains(string(data), `data_package.v1`) || !strings.Contains(string(data), `"daily_today"`) { if !strings.Contains(string(data), `data_package.v1`) || !strings.Contains(string(data), `"daily_today"`) {
t.Fatalf("data package output missing expected content:\n%s", string(data)) t.Fatalf("data package output missing expected content:\n%s", string(data))
} }
var decoded struct {
Briefing struct {
Metadata struct {
Location struct {
ID string `json:"id"`
Name string `json:"name"`
Region string `json:"region"`
Timezone string `json:"timezone"`
} `json:"location"`
} `json:"metadata"`
} `json:"briefing"`
}
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("decode data package: %v", err)
}
if decoded.Briefing.Metadata.Location.ID != "home" || decoded.Briefing.Metadata.Location.Name != "Brentwood" || decoded.Briefing.Metadata.Location.Region != "St. Louis Metro" || decoded.Briefing.Metadata.Location.Timezone != "UTC" {
t.Fatalf("location = %#v, want configured location with overridden timezone", decoded.Briefing.Metadata.Location)
}
preflightMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "preflight", "daily", "2026-05-29", "*.render.json")) preflightMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "preflight", "daily", "2026-05-29", "*.render.json"))
if err != nil { if err != nil {
t.Fatalf("glob preflight: %v", err) t.Fatalf("glob preflight: %v", err)
@@ -581,6 +580,45 @@ func TestRunInspectMissingMetadata(t *testing.T) {
} }
} }
func TestRunInspectRunCommandsParseRunIDAndConfig(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yml")
configBody := "workspace:\n root: " + filepath.Join(tempDir, "workspace") + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
runner := Runner{Clock: fixedClock()}
commands := []string{"metadata", "briefing", "data-package", "prior", "sources"}
for _, command := range commands {
t.Run(command+" requires run id", func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
err := runner.Run(context.Background(), []string{"inspect", command, "--config", configPath}, &stdout, &stderr)
if err == nil {
t.Fatal("Run() error = nil, want missing run id error")
}
if !strings.Contains(err.Error(), "requires a run id") {
t.Fatalf("error = %q, want missing run id context", err.Error())
}
})
t.Run(command+" accepts config", func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
err := runner.Run(context.Background(), []string{"inspect", command, "--config", configPath, "missing"}, &stdout, &stderr)
if err == nil {
t.Fatal("Run() error = nil, want missing metadata error")
}
if !strings.Contains(err.Error(), "metadata for run id") {
t.Fatalf("error = %q, want missing metadata context", err.Error())
}
})
}
}
func TestResolveGenerateCommands(t *testing.T) { func TestResolveGenerateCommands(t *testing.T) {
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
tests := []struct { tests := []struct {
@@ -643,7 +681,15 @@ func TestResolveGenerateAppliesSharedFlags(t *testing.T) {
func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) { func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) {
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
_, err := runner.resolveGenerate([]string{"storm", "--start", "2026-05-29T18:00"}) _, err := runner.resolveGenerate([]string{"storm", "--end", "2026-05-29T18:00"})
if err == nil {
t.Fatal("resolveGenerate() error = nil, want missing start error")
}
if !strings.Contains(err.Error(), "requires --start") {
t.Fatalf("error = %q, want missing start", err.Error())
}
_, err = runner.resolveGenerate([]string{"storm", "--start", "2026-05-29T18:00"})
if err == nil { if err == nil {
t.Fatal("resolveGenerate() error = nil, want missing end error") t.Fatal("resolveGenerate() error = nil, want missing end error")
} }
@@ -652,6 +698,26 @@ func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) {
} }
} }
func TestResolveGenerateStormParsesLocalTimestamps(t *testing.T) {
runner := Runner{Clock: fixedClock()}
req, err := runner.resolveGenerate([]string{
"storm",
"--tz", "America/Chicago",
"--start", "2026-05-29T18:00",
"--end", "2026-05-30T06:00",
})
if err != nil {
t.Fatalf("resolveGenerate() error = %v", err)
}
if got := req.StormStart.Format(time.RFC3339); got != "2026-05-29T18:00:00-05:00" {
t.Fatalf("StormStart = %q, want local Chicago time", got)
}
if got := req.StormEnd.Format(time.RFC3339); got != "2026-05-30T06:00:00-05:00" {
t.Fatalf("StormEnd = %q, want local Chicago time", got)
}
}
func TestResolveGenerateStormParsesRFC3339(t *testing.T) { func TestResolveGenerateStormParsesRFC3339(t *testing.T) {
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
@@ -668,6 +734,22 @@ func TestResolveGenerateStormParsesRFC3339(t *testing.T) {
} }
} }
func TestResolveGenerateStormRejectsInvalidBounds(t *testing.T) {
runner := Runner{Clock: fixedClock()}
_, err := runner.resolveGenerate([]string{
"storm",
"--start", "2026-05-30T06:00",
"--end", "2026-05-29T18:00",
})
if err == nil {
t.Fatal("resolveGenerate() error = nil, want invalid bounds error")
}
if !strings.Contains(err.Error(), "end time after start time") {
t.Fatalf("error = %q, want invalid bounds context", err.Error())
}
}
func TestResolveRunCommands(t *testing.T) { func TestResolveRunCommands(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -731,6 +813,8 @@ func dailyServer(t *testing.T) *httptest.Server {
_, _ = w.Write([]byte(`{"data":{"alerts":[]}}`)) _, _ = w.Write([]byte(`{"data":{"alerts":[]}}`))
case "/discussion": case "/discussion":
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}}`)) _, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}}`))
case "/weatherstories/latest":
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@@ -739,6 +823,28 @@ func dailyServer(t *testing.T) *httptest.Server {
return server return server
} }
func writeTestConfig(t *testing.T, server *httptest.Server, scriptoriumPath string, workspaceRoot string) string {
t.Helper()
configPath := filepath.Join(t.TempDir(), "config.yml")
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
return configPath
}
func oneArtifact(t *testing.T, root string, parts ...string) string {
t.Helper()
matches, err := filepath.Glob(filepath.Join(append([]string{root}, parts...)...))
if err != nil {
t.Fatalf("glob artifact: %v", err)
}
if len(matches) != 1 {
t.Fatalf("artifact matches = %#v, want one", matches)
}
return matches[0]
}
func writeFakeScriptorium(t *testing.T, dir string) string { func writeFakeScriptorium(t *testing.T, dir string) string {
t.Helper() t.Helper()
path := filepath.Join(dir, "scriptorium") path := filepath.Join(dir, "scriptorium")

View File

@@ -14,10 +14,10 @@ const (
type Config struct { type Config struct {
WeatherAPI WeatherAPIConfig `yaml:"weather_api"` WeatherAPI WeatherAPIConfig `yaml:"weather_api"`
Location LocationConfig `yaml:"location"`
MissingSource MissingSourceConfig `yaml:"missing_source"` MissingSource MissingSourceConfig `yaml:"missing_source"`
Scriptorium ScriptoriumConfig `yaml:"scriptorium"` Scriptorium ScriptoriumConfig `yaml:"scriptorium"`
Workspace WorkspaceConfig `yaml:"workspace"` Workspace WorkspaceConfig `yaml:"workspace"`
Reports ReportOutputConfig `yaml:"reports"`
Dayparts []DaypartConfig `yaml:"dayparts"` Dayparts []DaypartConfig `yaml:"dayparts"`
RecentChange RecentChangeConfig `yaml:"recent_change"` RecentChange RecentChangeConfig `yaml:"recent_change"`
} }
@@ -31,6 +31,12 @@ type WeatherAPIConfig struct {
Format string `yaml:"format"` Format string `yaml:"format"`
} }
type LocationConfig struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Region string `yaml:"region"`
}
type MissingSourceConfig struct { type MissingSourceConfig struct {
Default MissingSourcePolicy `yaml:"default"` Default MissingSourcePolicy `yaml:"default"`
Sources map[string]MissingSourcePolicy `yaml:"sources"` Sources map[string]MissingSourcePolicy `yaml:"sources"`
@@ -52,11 +58,6 @@ type WorkspaceConfig struct {
PreflightDir string `yaml:"preflight_dir"` PreflightDir string `yaml:"preflight_dir"`
} }
type ReportOutputConfig struct {
OutputDir string `yaml:"output_dir"`
Paths map[string]string `yaml:"paths"`
}
type DaypartConfig struct { type DaypartConfig struct {
Name string `yaml:"name"` Name string `yaml:"name"`
Start string `yaml:"start"` Start string `yaml:"start"`

View File

@@ -17,12 +17,15 @@ func TestDefaults(t *testing.T) {
if cfg.WeatherAPI.Units != "us" { if cfg.WeatherAPI.Units != "us" {
t.Fatalf("Units = %q, want us", cfg.WeatherAPI.Units) t.Fatalf("Units = %q, want us", cfg.WeatherAPI.Units)
} }
if cfg.WeatherAPI.Timezone != "Chicago" { if cfg.WeatherAPI.Timezone != "America/Chicago" {
t.Fatalf("Timezone = %q, want Chicago", cfg.WeatherAPI.Timezone) t.Fatalf("Timezone = %q, want America/Chicago", cfg.WeatherAPI.Timezone)
} }
if cfg.WeatherAPI.Format != "json" { if cfg.WeatherAPI.Format != "json" {
t.Fatalf("Format = %q, want json", cfg.WeatherAPI.Format) t.Fatalf("Format = %q, want json", cfg.WeatherAPI.Format)
} }
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
t.Fatalf("Location = %#v, want home/Brentwood/St. Louis Metro", cfg.Location)
}
if cfg.MissingSource.Default != MissingSourceWarn { if cfg.MissingSource.Default != MissingSourceWarn {
t.Fatalf("MissingSource.Default = %q, want warn", cfg.MissingSource.Default) t.Fatalf("MissingSource.Default = %q, want warn", cfg.MissingSource.Default)
} }
@@ -34,8 +37,8 @@ func TestLoadExampleConfig(t *testing.T) {
t.Fatalf("LoadFile() error = %v", err) t.Fatalf("LoadFile() error = %v", err)
} }
if cfg.WeatherAPI.BaseURL != "https://weather.api.example.com/" { if cfg.WeatherAPI.BaseURL != "https://weather.api.rakestrawhome.com/" {
t.Fatalf("BaseURL = %q, want example URL", cfg.WeatherAPI.BaseURL) t.Fatalf("BaseURL = %q, want configured example URL", cfg.WeatherAPI.BaseURL)
} }
if cfg.WeatherAPI.Timeout != 15*time.Second { if cfg.WeatherAPI.Timeout != 15*time.Second {
t.Fatalf("Timeout = %s, want 15s", cfg.WeatherAPI.Timeout) t.Fatalf("Timeout = %s, want 15s", cfg.WeatherAPI.Timeout)
@@ -43,6 +46,32 @@ func TestLoadExampleConfig(t *testing.T) {
if cfg.MissingSource.Sources["alerts"] != MissingSourceNone { if cfg.MissingSource.Sources["alerts"] != MissingSourceNone {
t.Fatalf("alerts policy = %q, want none", cfg.MissingSource.Sources["alerts"]) t.Fatalf("alerts policy = %q, want none", cfg.MissingSource.Sources["alerts"])
} }
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
t.Fatalf("Location = %#v, want example location", cfg.Location)
}
}
func TestLoadMinimalExampleConfig(t *testing.T) {
cfg, err := LoadFile(filepath.Join("..", "..", "examples", "minimal-config.yml"))
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
if cfg.WeatherAPI.BaseURL != "https://weather.api.example.com/" {
t.Fatalf("BaseURL = %q, want example URL", cfg.WeatherAPI.BaseURL)
}
if cfg.WeatherAPI.Units != "us" {
t.Fatalf("Units = %q, want default us", cfg.WeatherAPI.Units)
}
if cfg.Scriptorium.Binary != "scriptorium" {
t.Fatalf("Scriptorium.Binary = %q, want default scriptorium", cfg.Scriptorium.Binary)
}
if cfg.Workspace.Root != "workspace" {
t.Fatalf("Workspace.Root = %q, want default workspace", cfg.Workspace.Root)
}
if cfg.Location.Name != "Brentwood" {
t.Fatalf("Location.Name = %q, want default Brentwood", cfg.Location.Name)
}
} }
func TestExplicitMissingConfigReturnsError(t *testing.T) { func TestExplicitMissingConfigReturnsError(t *testing.T) {
@@ -72,7 +101,7 @@ func TestInvalidConfigProducesActionableError(t *testing.T) {
} }
func TestLoadAppliesOverrides(t *testing.T) { func TestLoadAppliesOverrides(t *testing.T) {
cfg, err := Load(LoadOptions{Units: "metric", Timezone: "+09:30", Output: "./out"}) cfg, err := Load(LoadOptions{Units: "metric", Timezone: "+09:30"})
if err != nil { if err != nil {
t.Fatalf("Load() error = %v", err) t.Fatalf("Load() error = %v", err)
} }
@@ -82,7 +111,4 @@ func TestLoadAppliesOverrides(t *testing.T) {
if cfg.WeatherAPI.Timezone != "+09:30" { if cfg.WeatherAPI.Timezone != "+09:30" {
t.Fatalf("Timezone = %q, want +09:30", cfg.WeatherAPI.Timezone) t.Fatalf("Timezone = %q, want +09:30", cfg.WeatherAPI.Timezone)
} }
if cfg.Reports.OutputDir != "./out" {
t.Fatalf("OutputDir = %q, want ./out", cfg.Reports.OutputDir)
}
} }

View File

@@ -10,9 +10,14 @@ func Defaults() Config {
Timeout: 10 * time.Second, Timeout: 10 * time.Second,
Precision: 1, Precision: 1,
Units: "us", Units: "us",
Timezone: "Chicago", Timezone: "America/Chicago",
Format: "json", Format: "json",
}, },
Location: LocationConfig{
ID: "home",
Name: "Brentwood",
Region: "St. Louis Metro",
},
MissingSource: MissingSourceConfig{ MissingSource: MissingSourceConfig{
Default: MissingSourceWarn, Default: MissingSourceWarn,
Sources: map[string]MissingSourcePolicy{}, Sources: map[string]MissingSourcePolicy{},
@@ -28,15 +33,12 @@ func Defaults() Config {
DataPackagesDir: "data-packages", DataPackagesDir: "data-packages",
PreflightDir: "preflight", PreflightDir: "preflight",
}, },
Reports: ReportOutputConfig{
OutputDir: "reports",
Paths: map[string]string{},
},
Dayparts: []DaypartConfig{ Dayparts: []DaypartConfig{
{Name: "overnight", Start: "00:00", End: "06:00"}, {Name: "overnight", Start: "00:00", End: "06:00"},
{Name: "morning", Start: "06:00", End: "12:00"}, {Name: "morning", Start: "06:00", End: "10:00"},
{Name: "afternoon", Start: "12:00", End: "18:00"}, {Name: "midday", Start: "10:00", End: "15:00"},
{Name: "evening", Start: "18:00", End: "24:00"}, {Name: "afternoon", Start: "15:00", End: "17:00"},
{Name: "evening", Start: "17:00", End: "24:00"},
}, },
RecentChange: RecentChangeConfig{ RecentChange: RecentChangeConfig{
TemperatureDegrees: 5, TemperatureDegrees: 5,

View File

@@ -12,7 +12,6 @@ type LoadOptions struct {
Path string Path string
Units string Units string
Timezone string Timezone string
Output string
} }
func Load(opts LoadOptions) (Config, error) { func Load(opts LoadOptions) (Config, error) {
@@ -35,9 +34,6 @@ func Load(opts LoadOptions) (Config, error) {
if opts.Timezone != "" { if opts.Timezone != "" {
cfg.WeatherAPI.Timezone = opts.Timezone cfg.WeatherAPI.Timezone = opts.Timezone
} }
if opts.Output != "" {
cfg.Reports.OutputDir = opts.Output
}
if err := Validate(cfg); err != nil { if err := Validate(cfg); err != nil {
return Config{}, err return Config{}, err
@@ -61,8 +57,5 @@ func mergeFile(cfg *Config, path string) error {
if cfg.MissingSource.Sources == nil { if cfg.MissingSource.Sources == nil {
cfg.MissingSource.Sources = map[string]MissingSourcePolicy{} cfg.MissingSource.Sources = map[string]MissingSourcePolicy{}
} }
if cfg.Reports.Paths == nil {
cfg.Reports.Paths = map[string]string{}
}
return nil return nil
} }

View File

@@ -58,9 +58,6 @@ func Validate(cfg Config) error {
if cfg.Workspace.Root == "" { if cfg.Workspace.Root == "" {
return fmt.Errorf("workspace.root is required") return fmt.Errorf("workspace.root is required")
} }
if cfg.Reports.OutputDir == "" {
return fmt.Errorf("reports.output_dir is required")
}
if len(cfg.Dayparts) == 0 { if len(cfg.Dayparts) == 0 {
return fmt.Errorf("dayparts must contain at least one entry") return fmt.Errorf("dayparts must contain at least one entry")
} }

View File

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

View File

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

View File

@@ -149,13 +149,20 @@ type Discussion struct {
} }
type DiscussionSection struct { type DiscussionSection struct {
Title string `json:"title,omitempty"` Qualifier string `json:"qualifier,omitempty"`
Narrative string `json:"narrative,omitempty"` Text string `json:"text,omitempty"`
IssuedAt *time.Time `json:"issuedAt,omitempty"` IssuedAt *time.Time `json:"issuedAt,omitempty"`
} }
type WeatherStory struct { type WeatherStory struct {
IssuedAt *time.Time `json:"issuedAt,omitempty"` OfficeID string `json:"officeId,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"` StartTime time.Time `json:"startTime"`
Raw json.RawMessage `json:"raw,omitempty"` EndTime time.Time `json:"endTime"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
AltText string `json:"altText,omitempty"`
Priority bool `json:"priority"`
Order int `json:"order"`
DownloadURL string `json:"downloadUrl,omitempty"`
} }

View File

@@ -47,13 +47,12 @@ type TimedValue struct {
} }
type Indicators struct { type Indicators struct {
Thunder bool `json:"thunder,omitempty"` Snow bool `json:"snow,omitempty"`
Snow bool `json:"snow,omitempty"` Ice bool `json:"ice,omitempty"`
Ice bool `json:"ice,omitempty"` Fog bool `json:"fog,omitempty"`
Fog bool `json:"fog,omitempty"` Heat bool `json:"heat,omitempty"`
Heat bool `json:"heat,omitempty"` Cold bool `json:"cold,omitempty"`
Cold bool `json:"cold,omitempty"` Wind bool `json:"wind,omitempty"`
Wind bool `json:"wind,omitempty"`
} }
type AlertOverlap struct { type AlertOverlap struct {
@@ -291,11 +290,10 @@ func sortedKeys(values map[string]struct{}) []string {
func indicatorsForText(text string) Indicators { func indicatorsForText(text string) Indicators {
lower := strings.ToLower(text) lower := strings.ToLower(text)
return Indicators{ return Indicators{
Thunder: strings.Contains(lower, "thunder") || strings.Contains(lower, "storm"), Snow: strings.Contains(lower, "snow"),
Snow: strings.Contains(lower, "snow"), Ice: strings.Contains(lower, "ice") || strings.Contains(lower, "freezing") || strings.Contains(lower, "sleet"),
Ice: strings.Contains(lower, "ice") || strings.Contains(lower, "freezing") || strings.Contains(lower, "sleet"), Fog: strings.Contains(lower, "fog"),
Fog: strings.Contains(lower, "fog"), Wind: strings.Contains(lower, "wind") || strings.Contains(lower, "gust"),
Wind: strings.Contains(lower, "wind") || strings.Contains(lower, "gust"),
} }
} }
@@ -318,13 +316,12 @@ func numericIndicators(period ForecastPeriod) Indicators {
func mergeIndicators(left Indicators, right Indicators) Indicators { func mergeIndicators(left Indicators, right Indicators) Indicators {
return Indicators{ return Indicators{
Thunder: left.Thunder || right.Thunder, Snow: left.Snow || right.Snow,
Snow: left.Snow || right.Snow, Ice: left.Ice || right.Ice,
Ice: left.Ice || right.Ice, Fog: left.Fog || right.Fog,
Fog: left.Fog || right.Fog, Heat: left.Heat || right.Heat,
Heat: left.Heat || right.Heat, Cold: left.Cold || right.Cold,
Cold: left.Cold || right.Cold, Wind: left.Wind || right.Wind,
Wind: left.Wind || right.Wind,
} }
} }

View File

@@ -41,10 +41,10 @@ func TestBuildDailySummaryGroupsDaypartsAndComputesMetrics(t *testing.T) {
t.Fatalf("morning peak gust = %#v, want 40", morning.PeakWindGust) t.Fatalf("morning peak gust = %#v, want 40", morning.PeakWindGust)
} }
if morning.DominantCondition != "Thunderstorms and gusty wind" { if morning.DominantCondition != "Thunderstorms and gusty wind" {
t.Fatalf("morning dominant = %q, want thunderstorm condition", morning.DominantCondition) t.Fatalf("morning dominant = %q, want raw forecast condition", morning.DominantCondition)
} }
if !morning.Indicators.Thunder || !morning.Indicators.Wind { if !morning.Indicators.Wind {
t.Fatalf("morning indicators = %#v, want thunder and wind", morning.Indicators) t.Fatalf("morning indicators = %#v, want wind", morning.Indicators)
} }
afternoon := summary.Dayparts[2] afternoon := summary.Dayparts[2]
@@ -87,8 +87,8 @@ func TestBuildDailySummaryFromFixtureBundle(t *testing.T) {
if len(summary.Dayparts) != 2 { if len(summary.Dayparts) != 2 {
t.Fatalf("Dayparts length = %d, want 2", len(summary.Dayparts)) t.Fatalf("Dayparts length = %d, want 2", len(summary.Dayparts))
} }
if !summary.Dayparts[0].Indicators.Thunder { if summary.Dayparts[0].DominantCondition != "Showers and thunderstorms" {
t.Fatalf("morning indicators = %#v, want thunder", summary.Dayparts[0].Indicators) t.Fatalf("morning dominant = %q, want raw forecast condition", summary.Dayparts[0].DominantCondition)
} }
if len(summary.AlertOverlaps) != 1 { if len(summary.AlertOverlaps) != 1 {
t.Fatalf("AlertOverlaps length = %d, want 1", len(summary.AlertOverlaps)) t.Fatalf("AlertOverlaps length = %d, want 1", len(summary.AlertOverlaps))

View File

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

View File

@@ -2,14 +2,12 @@
package promptinput package promptinput
import ( import (
"encoding/json"
"fmt" "fmt"
"os"
"path/filepath"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes" "gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
@@ -27,12 +25,13 @@ type Package struct {
} }
type Report struct { type Report struct {
ID report.ID `json:"id"` ID report.ID `json:"id"`
Variant string `json:"variant,omitempty"` Variant string `json:"variant,omitempty"`
PromptID string `json:"promptId"` PromptID string `json:"promptId"`
GeneratedAt time.Time `json:"generatedAt"` GeneratedAt time.Time `json:"generatedAt"`
Timezone string `json:"timezone"` Timezone string `json:"timezone"`
ValidPeriod timeutil.Period `json:"validPeriod"` CurrentLocalDate string `json:"currentLocalDate"`
ValidPeriod timeutil.Period `json:"validPeriod"`
} }
type RecentChanges struct { type RecentChanges struct {
@@ -44,6 +43,10 @@ func Build(briefingPackage briefing.Package) (Package, error) {
} }
func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []changes.Change) (Package, error) { func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []changes.Change) (Package, error) {
localDate, err := currentLocalDate(briefingPackage.Metadata.GeneratedAt, briefingPackage.Metadata.Timezone)
if err != nil {
return Package{}, err
}
items := make([]changes.Change, len(recentChanges)) items := make([]changes.Change, len(recentChanges))
copy(items, recentChanges) copy(items, recentChanges)
if items == nil { if items == nil {
@@ -53,12 +56,13 @@ func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []ch
SchemaVersion: SchemaVersion, SchemaVersion: SchemaVersion,
RunID: briefingPackage.Metadata.RunID, RunID: briefingPackage.Metadata.RunID,
Report: Report{ Report: Report{
ID: briefingPackage.Metadata.ReportID, ID: briefingPackage.Metadata.ReportID,
Variant: briefingPackage.Metadata.Variant, Variant: briefingPackage.Metadata.Variant,
PromptID: briefingPackage.Metadata.PromptID, PromptID: briefingPackage.Metadata.PromptID,
GeneratedAt: briefingPackage.Metadata.GeneratedAt, GeneratedAt: briefingPackage.Metadata.GeneratedAt,
Timezone: briefingPackage.Metadata.Timezone, Timezone: briefingPackage.Metadata.Timezone,
ValidPeriod: briefingPackage.Metadata.ValidPeriod, CurrentLocalDate: localDate,
ValidPeriod: briefingPackage.Metadata.ValidPeriod,
}, },
Briefing: briefingPackage, Briefing: briefingPackage,
RecentChanges: RecentChanges{Items: items}, RecentChanges: RecentChanges{Items: items},
@@ -70,6 +74,14 @@ func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []ch
return pkg, nil return pkg, nil
} }
func currentLocalDate(generatedAt time.Time, timezone string) (string, error) {
location, err := timeutil.LoadLocation(timezone)
if err != nil {
return "", fmt.Errorf("load report timezone %q: %w", timezone, err)
}
return generatedAt.In(location).Format(timeutil.DateLayout), nil
}
func Validate(pkg Package) error { func Validate(pkg Package) error {
if pkg.SchemaVersion == "" { if pkg.SchemaVersion == "" {
return fmt.Errorf("schemaVersion is required") return fmt.Errorf("schemaVersion is required")
@@ -89,6 +101,9 @@ func Validate(pkg Package) error {
if pkg.Report.Timezone == "" { if pkg.Report.Timezone == "" {
return fmt.Errorf("report.timezone is required") return fmt.Errorf("report.timezone is required")
} }
if pkg.Report.CurrentLocalDate == "" {
return fmt.Errorf("report.currentLocalDate is required")
}
if !pkg.Report.ValidPeriod.IsValid() { if !pkg.Report.ValidPeriod.IsValid() {
return fmt.Errorf("report.validPeriod must be valid") return fmt.Errorf("report.validPeriod must be valid")
} }
@@ -117,29 +132,8 @@ func Save(path string, pkg Package) error {
if err := Validate(pkg); err != nil { if err := Validate(pkg); err != nil {
return err return err
} }
data, err := json.MarshalIndent(pkg, "", " ") if err := fileutil.WriteJSONAtomic(path, pkg); err != nil {
if err != nil { return fmt.Errorf("save data package: %w", err)
return fmt.Errorf("marshal data package: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create data package directory %q: %w", filepath.Dir(path), err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary data package file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary data package file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary data package file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("save data package %q: %w", path, err)
} }
return nil return nil
} }

View File

@@ -28,14 +28,51 @@ func TestBuildDailyDataPackage(t *testing.T) {
if pkg.Report.PromptID != "weather.daily_report" { if pkg.Report.PromptID != "weather.daily_report" {
t.Fatalf("PromptID = %q, want weather.daily_report", pkg.Report.PromptID) t.Fatalf("PromptID = %q, want weather.daily_report", pkg.Report.PromptID)
} }
if pkg.Report.CurrentLocalDate != "2026-05-29" {
t.Fatalf("CurrentLocalDate = %q, want 2026-05-29", pkg.Report.CurrentLocalDate)
}
if pkg.Briefing.Daily == nil { if pkg.Briefing.Daily == nil {
t.Fatal("Briefing.Daily = nil") t.Fatal("Briefing.Daily = nil")
} }
if pkg.Briefing.Metadata.Location == nil || pkg.Briefing.Metadata.Location.Name != "Brentwood" {
t.Fatalf("Briefing.Metadata.Location = %#v, want configured location", pkg.Briefing.Metadata.Location)
}
if pkg.Briefing.CurrentConditions == nil || pkg.Briefing.CurrentConditions.ConditionText != "Partly cloudy" {
t.Fatalf("Briefing.CurrentConditions = %#v, want current conditions", pkg.Briefing.CurrentConditions)
}
if pkg.RecentChanges.Items == nil || len(pkg.RecentChanges.Items) != 0 { if pkg.RecentChanges.Items == nil || len(pkg.RecentChanges.Items) != 0 {
t.Fatalf("RecentChanges.Items = %#v, want empty slice", pkg.RecentChanges.Items) t.Fatalf("RecentChanges.Items = %#v, want empty slice", pkg.RecentChanges.Items)
} }
} }
func TestBuildCurrentLocalDateUsesReportTimezone(t *testing.T) {
briefingPackage := validBriefingPackage()
briefingPackage.Metadata.GeneratedAt = time.Date(2026, 5, 30, 2, 30, 0, 0, time.UTC)
briefingPackage.Metadata.Timezone = "America/Chicago"
pkg, err := Build(briefingPackage)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if pkg.Report.CurrentLocalDate != "2026-05-29" {
t.Fatalf("CurrentLocalDate = %q, want local Chicago date 2026-05-29", pkg.Report.CurrentLocalDate)
}
}
func TestBuildRejectsInvalidReportTimezone(t *testing.T) {
briefingPackage := validBriefingPackage()
briefingPackage.Metadata.Timezone = "Not/AZone"
_, err := Build(briefingPackage)
if err == nil {
t.Fatal("Build() error = nil, want invalid timezone error")
}
if !strings.Contains(err.Error(), "report timezone") {
t.Fatalf("error = %q, want report timezone context", err.Error())
}
}
func TestValidateRequiresFields(t *testing.T) { func TestValidateRequiresFields(t *testing.T) {
pkg, err := Build(validBriefingPackage()) pkg, err := Build(validBriefingPackage())
if err != nil { if err != nil {
@@ -52,6 +89,22 @@ func TestValidateRequiresFields(t *testing.T) {
} }
} }
func TestValidateRequiresCurrentLocalDate(t *testing.T) {
pkg, err := Build(validBriefingPackage())
if err != nil {
t.Fatalf("Build() error = %v", err)
}
pkg.Report.CurrentLocalDate = ""
err = Validate(pkg)
if err == nil {
t.Fatal("Validate() error = nil, want required field error")
}
if !strings.Contains(err.Error(), "currentLocalDate") {
t.Fatalf("error = %q, want currentLocalDate context", err.Error())
}
}
func TestBuildThreeDayDataPackage(t *testing.T) { func TestBuildThreeDayDataPackage(t *testing.T) {
briefingPackage := validBriefingPackage() briefingPackage := validBriefingPackage()
briefingPackage.Metadata.RunID = "20260529T100000Z_three_day" briefingPackage.Metadata.RunID = "20260529T100000Z_three_day"
@@ -155,11 +208,20 @@ func validBriefingPackage() briefing.Package {
GeneratedAt: generatedAt, GeneratedAt: generatedAt,
Units: "us", Units: "us",
Timezone: "America/Chicago", Timezone: "America/Chicago",
Location: &briefing.LocationContext{
ID: "home",
Name: "Brentwood",
Region: "St. Louis Metro",
Timezone: "America/Chicago",
},
ValidPeriod: timeutil.Period{ ValidPeriod: timeutil.Period{
Start: time.Date(2026, 5, 29, 5, 0, 0, 0, time.UTC), Start: time.Date(2026, 5, 29, 5, 0, 0, 0, time.UTC),
End: time.Date(2026, 5, 30, 5, 0, 0, 0, time.UTC), End: time.Date(2026, 5, 30, 5, 0, 0, 0, time.UTC),
}, },
}, },
CurrentConditions: &briefing.CurrentConditionsContext{
ConditionText: "Partly cloudy",
},
Daily: &briefing.Daily{ Daily: &briefing.Daily{
ForecastSummaryDate: "2026-05-29", ForecastSummaryDate: "2026-05-29",
}, },

View File

@@ -38,7 +38,10 @@ type Definition struct {
Name string Name string
PromptID string PromptID string
ComparisonStrategy ComparisonStrategy ComparisonStrategy ComparisonStrategy
DefaultOutputName string ArtifactGroup string
BatchOutputName string
Generated bool
CompatiblePriorIDs []ID
Morning bool Morning bool
Evening bool Evening bool
resolve func(ResolveRequest) (timeutil.Period, error) resolve func(ResolveRequest) (timeutil.Period, error)
@@ -51,6 +54,15 @@ func (d Definition) ResolvePeriod(req ResolveRequest) (timeutil.Period, error) {
return d.resolve(req) return d.resolve(req)
} }
func (d Definition) CompatibleWithPrior(id ID) bool {
for _, compatibleID := range d.CompatiblePriorIDs {
if id == compatibleID {
return true
}
}
return false
}
type ResolveRequest struct { type ResolveRequest struct {
Now time.Time Now time.Time
Location *time.Location Location *time.Location

View File

@@ -1,6 +1,7 @@
package report package report
import ( import (
"reflect"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -177,6 +178,79 @@ func TestRegistryDefinitionsHavePromptIDsAndComparisonStrategies(t *testing.T) {
} }
} }
func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
tests := []struct {
id ID
artifactGroup string
batchOutputName string
generated bool
compatiblePriorIDs []ID
}{
{
id: DailyToday,
artifactGroup: "daily",
batchOutputName: "daily.md",
generated: true,
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
},
{
id: DailyTomorrow,
artifactGroup: "daily",
batchOutputName: "tomorrow.md",
generated: true,
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
},
{
id: ThreeDay,
artifactGroup: "three-day",
batchOutputName: "three-day.md",
generated: true,
compatiblePriorIDs: []ID{ThreeDay},
},
{
id: Weekend,
artifactGroup: "weekend",
batchOutputName: "weekend.md",
generated: true,
compatiblePriorIDs: []ID{Weekend},
},
{
id: Storm,
artifactGroup: "storm",
batchOutputName: "storm.md",
generated: true,
compatiblePriorIDs: []ID{Storm},
},
}
registry := DefaultRegistry()
for _, tt := range tests {
t.Run(string(tt.id), func(t *testing.T) {
definition, err := registry.Lookup(tt.id)
if err != nil {
t.Fatalf("Lookup() error = %v", err)
}
if definition.ArtifactGroup != tt.artifactGroup {
t.Fatalf("ArtifactGroup = %q, want %q", definition.ArtifactGroup, tt.artifactGroup)
}
if definition.BatchOutputName != tt.batchOutputName {
t.Fatalf("BatchOutputName = %q, want %q", definition.BatchOutputName, tt.batchOutputName)
}
if definition.Generated != tt.generated {
t.Fatalf("Generated = %t, want %t", definition.Generated, tt.generated)
}
if !reflect.DeepEqual(definition.CompatiblePriorIDs, tt.compatiblePriorIDs) {
t.Fatalf("CompatiblePriorIDs = %#v, want %#v", definition.CompatiblePriorIDs, tt.compatiblePriorIDs)
}
for _, id := range tt.compatiblePriorIDs {
if !definition.CompatibleWithPrior(id) {
t.Fatalf("CompatibleWithPrior(%q) = false, want true", id)
}
}
})
}
}
func TestResolvedMetadata(t *testing.T) { func TestResolvedMetadata(t *testing.T) {
location := mustLoadLocation(t) location := mustLoadLocation(t)
resolved, err := Resolve(DailyToday, ResolveRequest{Now: mustParse("2026-05-29T05:00:00-05:00"), Location: location}) resolved, err := Resolve(DailyToday, ResolveRequest{Now: mustParse("2026-05-29T05:00:00-05:00"), Location: location})

View File

@@ -13,7 +13,10 @@ func DefaultRegistry() Registry {
Name: "Daily Report", Name: "Daily Report",
PromptID: "weather.daily_report", PromptID: "weather.daily_report",
ComparisonStrategy: CompareSameValidDate, ComparisonStrategy: CompareSameValidDate,
DefaultOutputName: "daily.md", ArtifactGroup: "daily",
BatchOutputName: "daily.md",
Generated: true,
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
Morning: true, Morning: true,
resolve: resolveDailyToday, resolve: resolveDailyToday,
}, },
@@ -22,7 +25,10 @@ func DefaultRegistry() Registry {
Name: "Tomorrow Planning Brief", Name: "Tomorrow Planning Brief",
PromptID: "weather.daily_report", PromptID: "weather.daily_report",
ComparisonStrategy: CompareSameValidDate, ComparisonStrategy: CompareSameValidDate,
DefaultOutputName: "tomorrow.md", ArtifactGroup: "daily",
BatchOutputName: "tomorrow.md",
Generated: true,
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
Evening: true, Evening: true,
resolve: resolveDailyTomorrow, resolve: resolveDailyTomorrow,
}, },
@@ -31,7 +37,10 @@ func DefaultRegistry() Registry {
Name: "3-Day Outlook", Name: "3-Day Outlook",
PromptID: "weather.three_day_outlook", PromptID: "weather.three_day_outlook",
ComparisonStrategy: CompareSameValidDate, ComparisonStrategy: CompareSameValidDate,
DefaultOutputName: "three_day.md", ArtifactGroup: "three-day",
BatchOutputName: "three-day.md",
Generated: true,
CompatiblePriorIDs: []ID{ThreeDay},
Morning: true, Morning: true,
resolve: resolveThreeDay, resolve: resolveThreeDay,
}, },
@@ -40,7 +49,10 @@ func DefaultRegistry() Registry {
Name: "Weekend Outlook", Name: "Weekend Outlook",
PromptID: "weather.weekend_outlook", PromptID: "weather.weekend_outlook",
ComparisonStrategy: CompareWeekendWindow, ComparisonStrategy: CompareWeekendWindow,
DefaultOutputName: "weekend.md", ArtifactGroup: "weekend",
BatchOutputName: "weekend.md",
Generated: true,
CompatiblePriorIDs: []ID{Weekend},
Morning: true, Morning: true,
resolve: resolveWeekend, resolve: resolveWeekend,
}, },
@@ -49,7 +61,10 @@ func DefaultRegistry() Registry {
Name: "Storm Report", Name: "Storm Report",
PromptID: "weather.storm_report", PromptID: "weather.storm_report",
ComparisonStrategy: CompareExplicitWindow, ComparisonStrategy: CompareExplicitWindow,
DefaultOutputName: "storm.md", ArtifactGroup: "storm",
BatchOutputName: "storm.md",
Generated: true,
CompatiblePriorIDs: []ID{Storm},
resolve: resolveStorm, resolve: resolveStorm,
}, },
} }

View File

@@ -10,9 +10,9 @@ import (
"strings" "strings"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
) )
@@ -79,9 +79,9 @@ func (s *FilesystemStore) Paths(resolved report.Resolved) (ArtifactPaths, error)
if metadata.RunID == "" { if metadata.RunID == "" {
return ArtifactPaths{}, fmt.Errorf("run id is required") return ArtifactPaths{}, fmt.Errorf("run id is required")
} }
group, err := reportGroup(resolved.Definition.ID) group := resolved.Definition.ArtifactGroup
if err != nil { if group == "" {
return ArtifactPaths{}, err return ArtifactPaths{}, fmt.Errorf("report %q has no artifact group", resolved.Definition.ID)
} }
validDate := resolved.ValidPeriod.Start.Format("2006-01-02") validDate := resolved.ValidPeriod.Start.Format("2006-01-02")
filenameBase := metadata.RunID filenameBase := metadata.RunID
@@ -99,7 +99,7 @@ func (s *FilesystemStore) SaveBriefing(_ context.Context, resolved report.Resolv
if err != nil { if err != nil {
return "", err return "", err
} }
if err := writeJSONAtomic(paths.Briefing, pkg); err != nil { if err := fileutil.WriteJSONAtomic(paths.Briefing, pkg); err != nil {
return "", err return "", err
} }
return paths.Briefing, nil return paths.Briefing, nil
@@ -113,21 +113,18 @@ func (s *FilesystemStore) SaveDataPackage(_ context.Context, resolved report.Res
if err := promptinput.Validate(pkg); err != nil { if err := promptinput.Validate(pkg); err != nil {
return "", err return "", err
} }
if err := writeJSONAtomic(paths.DataPackage, pkg); err != nil { if err := fileutil.WriteJSONAtomic(paths.DataPackage, pkg); err != nil {
return "", err return "", err
} }
return paths.DataPackage, nil return paths.DataPackage, nil
} }
func (s *FilesystemStore) SavePreflight(_ context.Context, resolved report.Resolved, result *scriptorium.RenderResult) (string, error) { func (s *FilesystemStore) SavePreflight(_ context.Context, resolved report.Resolved, artifact PreflightArtifact) (string, error) {
if result == nil {
return "", fmt.Errorf("render result is required")
}
paths, err := s.Paths(resolved) paths, err := s.Paths(resolved)
if err != nil { if err != nil {
return "", err return "", err
} }
if err := writeJSONAtomic(paths.Preflight, result); err != nil { if err := fileutil.WriteJSONAtomic(paths.Preflight, artifact); err != nil {
return "", err return "", err
} }
return paths.Preflight, nil return paths.Preflight, nil
@@ -157,27 +154,22 @@ func (s *FilesystemStore) SaveMetadata(_ context.Context, metadata Metadata) (st
if metadata.PreflightPath == "" { if metadata.PreflightPath == "" {
return "", fmt.Errorf("metadata preflight path is required") return "", fmt.Errorf("metadata preflight path is required")
} }
path := metadataPathFromStored(metadata) if metadata.MetadataPath == "" {
if path == "" { return "", fmt.Errorf("metadata path is required")
return "", fmt.Errorf("metadata path cannot be resolved")
} }
if err := writeJSONAtomic(path, metadata); err != nil { if err := fileutil.WriteJSONAtomic(metadata.MetadataPath, metadata); err != nil {
return "", err return "", err
} }
return path, nil return metadata.MetadataPath, nil
}
func (s *FilesystemStore) FindPriorDailySnapshot(ctx context.Context, resolved report.Resolved) (*PriorSnapshot, error) {
return s.FindPriorSnapshot(ctx, resolved)
} }
func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.Resolved) (*PriorSnapshot, error) { func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.Resolved) (*PriorSnapshot, error) {
if resolved.Definition.ComparisonStrategy != report.CompareSameValidDate && resolved.Definition.ComparisonStrategy != report.CompareWeekendWindow { if resolved.Definition.ComparisonStrategy != report.CompareSameValidDate && resolved.Definition.ComparisonStrategy != report.CompareWeekendWindow {
return nil, nil return nil, nil
} }
group, err := reportGroup(resolved.Definition.ID) group := resolved.Definition.ArtifactGroup
if err != nil { if group == "" {
return nil, err return nil, fmt.Errorf("report %q has no artifact group", resolved.Definition.ID)
} }
dirs, err := s.metadataDirectories(resolved, group) dirs, err := s.metadataDirectories(resolved, group)
if err != nil { if err != nil {
@@ -205,7 +197,7 @@ func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.R
if metadata.RunID == resolved.Metadata().RunID { if metadata.RunID == resolved.Metadata().RunID {
continue continue
} }
if !compatiblePriorReport(group, metadata.ReportID, resolved.Definition.ID) { if !resolved.Definition.CompatibleWithPrior(metadata.ReportID) {
continue continue
} }
if !comparablePeriod(metadata, resolved) { if !comparablePeriod(metadata, resolved) {
@@ -343,19 +335,6 @@ func (s *FilesystemStore) metadataDirectories(resolved report.Resolved, group st
return dirs, nil return dirs, nil
} }
func compatiblePriorReport(group string, prior report.ID, current report.ID) bool {
switch group {
case "daily":
return prior == report.DailyToday || prior == report.DailyTomorrow
case "three-day":
return prior == report.ThreeDay && current == report.ThreeDay
case "weekend":
return prior == report.Weekend && current == report.Weekend
default:
return false
}
}
func (s *FilesystemStore) join(parts ...string) string { func (s *FilesystemStore) join(parts ...string) string {
all := append([]string{s.root}, parts...) all := append([]string{s.root}, parts...)
return filepath.Join(all...) return filepath.Join(all...)
@@ -375,48 +354,6 @@ func validateRelativeDir(name string, value string) error {
return nil return nil
} }
func reportGroup(id report.ID) (string, error) {
switch id {
case report.DailyToday, report.DailyTomorrow:
return "daily", nil
case report.ThreeDay:
return "three-day", nil
case report.Weekend:
return "weekend", nil
case report.Storm:
return "storm", nil
default:
return "", fmt.Errorf("unknown report %q", id)
}
}
func writeJSONAtomic(path string, value any) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return fmt.Errorf("marshal %q: %w", path, err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create directory %q: %w", filepath.Dir(path), err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary file for %q: %w", path, err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary file for %q: %w", path, err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary file for %q: %w", path, err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("save %q: %w", path, err)
}
return nil
}
func readJSON(path string, target any) error { func readJSON(path string, target any) error {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
@@ -428,14 +365,6 @@ func readJSON(path string, target any) error {
return nil return nil
} }
func metadataPathFromStored(metadata Metadata) string {
if metadata.BriefingPath == "" {
return ""
}
filename := metadata.RunID + ".metadata.json"
return filepath.Join(filepath.Dir(metadata.BriefingPath), filename)
}
func sameValidDate(metadata Metadata, resolved report.Resolved) bool { func sameValidDate(metadata Metadata, resolved report.Resolved) bool {
return metadata.ValidPeriod.Start.Format("2006-01-02") == resolved.ValidPeriod.Start.Format("2006-01-02") return metadata.ValidPeriod.Start.Format("2006-01-02") == resolved.ValidPeriod.Start.Format("2006-01-02")
} }

View File

@@ -9,7 +9,6 @@ import (
"testing" "testing"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
@@ -56,7 +55,7 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("SaveDataPackage() error = %v", err) t.Fatalf("SaveDataPackage() error = %v", err)
} }
preflightPath, err := store.SavePreflight(context.Background(), resolved, &scriptorium.RenderResult{Stdout: `{"ok":true}`}) preflightPath, err := store.SavePreflight(context.Background(), resolved, PreflightArtifact{Stdout: `{"ok":true}`})
if err != nil { if err != nil {
t.Fatalf("SavePreflight() error = %v", err) t.Fatalf("SavePreflight() error = %v", err)
} }
@@ -67,6 +66,17 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
if err := os.WriteFile(renderedReportPath, []byte("# Daily Report\n"), 0o600); err != nil { if err := os.WriteFile(renderedReportPath, []byte("# Daily Report\n"), 0o600); err != nil {
t.Fatalf("write rendered report: %v", err) t.Fatalf("write rendered report: %v", err)
} }
var preflight PreflightArtifact
preflightData, err := os.ReadFile(preflightPath)
if err != nil {
t.Fatalf("read preflight: %v", err)
}
if err := json.Unmarshal(preflightData, &preflight); err != nil {
t.Fatalf("decode preflight: %v", err)
}
if preflight.Stdout != `{"ok":true}` {
t.Fatalf("preflight stdout = %q, want render stdout", preflight.Stdout)
}
paths, err := store.Paths(resolved) paths, err := store.Paths(resolved)
if err != nil { if err != nil {
t.Fatalf("Paths() error = %v", err) t.Fatalf("Paths() error = %v", err)
@@ -112,9 +122,49 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
if decoded.RenderedReportPath != renderedReportPath { if decoded.RenderedReportPath != renderedReportPath {
t.Fatalf("RenderedReportPath = %q, want %q", decoded.RenderedReportPath, renderedReportPath) t.Fatalf("RenderedReportPath = %q, want %q", decoded.RenderedReportPath, renderedReportPath)
} }
if decoded.Location == nil || decoded.Location.Name != "Brentwood" || decoded.Location.Timezone != "America/Chicago" {
t.Fatalf("metadata location = %#v, want briefing location", decoded.Location)
}
if strings.Contains(string(data), "MetadataPath") || strings.Contains(string(data), "metadataPath") {
t.Fatalf("metadata JSON includes runtime-only MetadataPath:\n%s", string(data))
}
} }
func TestFindPriorDailySnapshot(t *testing.T) { func TestSaveMetadataUsesExplicitMetadataPath(t *testing.T) {
store := newTestStore(t)
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
briefingPackage := stateBriefingPackage(resolved)
paths, err := store.Paths(resolved)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
otherDir := filepath.Join(t.TempDir(), "other-artifacts")
otherBriefingPath := filepath.Join(otherDir, resolved.Metadata().RunID+".briefing.json")
derivedMetadataPath := filepath.Join(otherDir, resolved.Metadata().RunID+".metadata.json")
metadata := BuildMetadata(resolved, briefingPackage, ArtifactPaths{
Briefing: otherBriefingPath,
Metadata: paths.Metadata,
DataPackage: paths.DataPackage,
Preflight: paths.Preflight,
RenderedReport: paths.RenderedReport,
})
metadataPath, err := store.SaveMetadata(context.Background(), metadata)
if err != nil {
t.Fatalf("SaveMetadata() error = %v", err)
}
if metadataPath != paths.Metadata {
t.Fatalf("SaveMetadata() path = %q, want explicit metadata path %q", metadataPath, paths.Metadata)
}
if _, err := os.Stat(paths.Metadata); err != nil {
t.Fatalf("expected explicit metadata path %q: %v", paths.Metadata, err)
}
if _, err := os.Stat(derivedMetadataPath); !os.IsNotExist(err) {
t.Fatalf("derived metadata path stat error = %v, want not exist", err)
}
}
func TestFindPriorSnapshot(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
first := resolveDailyAt(t, "2026-05-29T05:00:00-05:00") first := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
second := resolveDailyAt(t, "2026-05-29T08:00:00-05:00") second := resolveDailyAt(t, "2026-05-29T08:00:00-05:00")
@@ -138,12 +188,12 @@ func TestFindPriorDailySnapshot(t *testing.T) {
t.Fatalf("SaveMetadata() error = %v", err) t.Fatalf("SaveMetadata() error = %v", err)
} }
prior, err := store.FindPriorDailySnapshot(context.Background(), second) prior, err := store.FindPriorSnapshot(context.Background(), second)
if err != nil { if err != nil {
t.Fatalf("FindPriorDailySnapshot() error = %v", err) t.Fatalf("FindPriorSnapshot() error = %v", err)
} }
if prior == nil { if prior == nil {
t.Fatal("FindPriorDailySnapshot() = nil, want prior snapshot") t.Fatal("FindPriorSnapshot() = nil, want prior snapshot")
} }
if prior.Metadata.RunID != first.Metadata().RunID { if prior.Metadata.RunID != first.Metadata().RunID {
t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID) t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID)
@@ -153,7 +203,7 @@ func TestFindPriorDailySnapshot(t *testing.T) {
} }
} }
func TestFindPriorDailySnapshotUsesValidDate(t *testing.T) { func TestFindPriorSnapshotUsesValidDate(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
previousDate := resolveDailyAt(t, "2026-05-28T05:00:00-05:00") previousDate := resolveDailyAt(t, "2026-05-28T05:00:00-05:00")
currentDate := resolveDailyAt(t, "2026-05-29T05:00:00-05:00") currentDate := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
@@ -177,12 +227,12 @@ func TestFindPriorDailySnapshotUsesValidDate(t *testing.T) {
t.Fatalf("SaveMetadata() error = %v", err) t.Fatalf("SaveMetadata() error = %v", err)
} }
prior, err := store.FindPriorDailySnapshot(context.Background(), currentDate) prior, err := store.FindPriorSnapshot(context.Background(), currentDate)
if err != nil { if err != nil {
t.Fatalf("FindPriorDailySnapshot() error = %v", err) t.Fatalf("FindPriorSnapshot() error = %v", err)
} }
if prior != nil { if prior != nil {
t.Fatalf("FindPriorDailySnapshot() = %#v, want nil for different valid date", prior) t.Fatalf("FindPriorSnapshot() = %#v, want nil for different valid date", prior)
} }
} }
@@ -390,7 +440,13 @@ func stateBriefingPackage(resolved report.Resolved) briefing.Package {
GeneratedAt: resolved.GeneratedAt, GeneratedAt: resolved.GeneratedAt,
Units: "us", Units: "us",
Timezone: resolved.Timezone, Timezone: resolved.Timezone,
ValidPeriod: resolved.ValidPeriod, Location: &briefing.LocationContext{
ID: "home",
Name: "Brentwood",
Region: "St. Louis Metro",
Timezone: resolved.Timezone,
},
ValidPeriod: resolved.ValidPeriod,
}, },
Daily: &briefing.Daily{ForecastSummaryDate: "2026-05-29"}, Daily: &briefing.Daily{ForecastSummaryDate: "2026-05-29"},
} }

View File

@@ -14,12 +14,14 @@ const MetadataSchemaVersion = "weatherreporter.metadata.v1"
type Metadata struct { type Metadata struct {
SchemaVersion string `json:"schemaVersion"` SchemaVersion string `json:"schemaVersion"`
RunID string `json:"runId"` RunID string `json:"runId"`
MetadataPath string `json:"-"`
ReportID report.ID `json:"reportId"` ReportID report.ID `json:"reportId"`
Variant string `json:"variant,omitempty"` Variant string `json:"variant,omitempty"`
PromptID string `json:"promptId"` PromptID string `json:"promptId"`
GeneratedAt time.Time `json:"generatedAt"` GeneratedAt time.Time `json:"generatedAt"`
Timezone string `json:"timezone"` Timezone string `json:"timezone"`
ValidPeriod timeutil.Period `json:"validPeriod"` ValidPeriod timeutil.Period `json:"validPeriod"`
Location *briefing.LocationContext `json:"location,omitempty"`
SourceLocationID string `json:"sourceLocationId,omitempty"` SourceLocationID string `json:"sourceLocationId,omitempty"`
SourceLocation string `json:"sourceLocation,omitempty"` SourceLocation string `json:"sourceLocation,omitempty"`
Sources []briefing.SourceMetadata `json:"sources,omitempty"` Sources []briefing.SourceMetadata `json:"sources,omitempty"`
@@ -35,12 +37,14 @@ func BuildMetadata(resolved report.Resolved, briefingPackage briefing.Package, p
return Metadata{ return Metadata{
SchemaVersion: MetadataSchemaVersion, SchemaVersion: MetadataSchemaVersion,
RunID: metadata.RunID, RunID: metadata.RunID,
MetadataPath: paths.Metadata,
ReportID: metadata.ReportID, ReportID: metadata.ReportID,
Variant: briefingPackage.Metadata.Variant, Variant: briefingPackage.Metadata.Variant,
PromptID: metadata.PromptID, PromptID: metadata.PromptID,
GeneratedAt: metadata.GeneratedAt, GeneratedAt: metadata.GeneratedAt,
Timezone: metadata.Timezone, Timezone: metadata.Timezone,
ValidPeriod: metadata.ValidPeriod, ValidPeriod: metadata.ValidPeriod,
Location: copyLocation(briefingPackage.Metadata.Location),
SourceLocationID: briefingPackage.Metadata.SourceLocationID, SourceLocationID: briefingPackage.Metadata.SourceLocationID,
SourceLocation: briefingPackage.Metadata.SourceLocation, SourceLocation: briefingPackage.Metadata.SourceLocation,
Sources: briefingPackage.Metadata.Sources, Sources: briefingPackage.Metadata.Sources,
@@ -51,3 +55,11 @@ func BuildMetadata(resolved report.Resolved, briefingPackage briefing.Package, p
RenderedReportPath: paths.RenderedReport, RenderedReportPath: paths.RenderedReport,
} }
} }
func copyLocation(location *briefing.LocationContext) *briefing.LocationContext {
if location == nil {
return nil
}
copied := *location
return &copied
}

View File

@@ -4,7 +4,6 @@ package state
import ( import (
"context" "context"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
@@ -14,11 +13,10 @@ type Store interface {
Paths(report.Resolved) (ArtifactPaths, error) Paths(report.Resolved) (ArtifactPaths, error)
SaveBriefing(context.Context, report.Resolved, briefing.Package) (string, error) SaveBriefing(context.Context, report.Resolved, briefing.Package) (string, error)
SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error) SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error)
SavePreflight(context.Context, report.Resolved, *scriptorium.RenderResult) (string, error) SavePreflight(context.Context, report.Resolved, PreflightArtifact) (string, error)
PrepareRenderedReport(context.Context, report.Resolved) (string, error) PrepareRenderedReport(context.Context, report.Resolved) (string, error)
SaveMetadata(context.Context, Metadata) (string, error) SaveMetadata(context.Context, Metadata) (string, error)
FindPriorSnapshot(context.Context, report.Resolved) (*PriorSnapshot, error) FindPriorSnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
FindPriorDailySnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
LoadBriefing(context.Context, string) (briefing.Package, error) LoadBriefing(context.Context, string) (briefing.Package, error)
} }
@@ -26,3 +24,12 @@ type PriorSnapshot struct {
Metadata Metadata Metadata Metadata
BriefingPath string BriefingPath string
} }
type PreflightArtifact struct {
Command []string `json:"command"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
StdoutTruncated bool `json:"stdoutTruncated,omitempty"`
StderrTruncated bool `json:"stderrTruncated,omitempty"`
ExitCode int `json:"exitCode"`
}