Compare commits
311 Commits
88aaae3661
...
v0.11.0
| Author | SHA1 | Date | |
|---|---|---|---|
| af9cb0c0dc | |||
| 20c82776dc | |||
| f364ce773d | |||
| 0dc6a06cd3 | |||
| 2af6a5cfd2 | |||
| 0c4c575eea | |||
| 114f7f5f85 | |||
| 328c7a5693 | |||
| fe176a2abc | |||
| ab9218b124 | |||
| 8d6ab0eb56 | |||
| 76cd399c76 | |||
| bf1746a756 | |||
| 28bdc04fba | |||
| b67fae886e | |||
| 71a2eae87b | |||
| bd34ec57f8 | |||
| 97215ddb9b | |||
| dd7881acfb | |||
| ece31567b8 | |||
| 7ffc3dc603 | |||
| 4bdba6f2b7 | |||
| b184ca7cbd | |||
| 62a12dd661 | |||
| ac8d618111 | |||
| 5ddd3ee19c | |||
| 8be9b020d4 | |||
| 7f5a9c0357 | |||
| 7d591487e4 | |||
| 1250247986 | |||
| 117c5336ba | |||
| c5ec4f83b2 | |||
| 39c097a710 | |||
| 993120a9f2 | |||
| c20e285d5f | |||
| acbe22dcad | |||
| cc97ae186c | |||
| 51c35f7c22 | |||
| f014a078ee | |||
| 8c19ad763b | |||
| 2dbba36bf0 | |||
| f302581722 | |||
| cf82633ab7 | |||
| 8d8cdbf3c5 | |||
| a206979307 | |||
| a6515c0e56 | |||
| 41df5058ba | |||
| e1bc174ea9 | |||
| 34c395d7e5 | |||
| 870b54a4a0 | |||
| 25782447eb | |||
| b96f40e5ca | |||
| 2c68d0a85f | |||
| a6d11c01e8 | |||
| 06b26d5e88 | |||
| 9a17a8de93 | |||
| 6064af2295 | |||
| a52a6ed22a | |||
| b0b703eab4 | |||
| e4e824ed41 | |||
| d5fcbfd20c | |||
| 2e0fb65a8b | |||
| 5e96790d85 | |||
| 35f4f82e94 | |||
| b605596bcb | |||
| 9303502b32 | |||
| f9eef80233 | |||
| c6f8570474 | |||
| 1130d807dc | |||
| ff2e664c62 | |||
| 2f3558cf33 | |||
| 154d31c3e8 | |||
| 6b1ff862f3 | |||
| 0c27fab384 | |||
| ad3b788f8c | |||
| 82acb8dc1a | |||
| 3aaddda676 | |||
| 7f989839cd | |||
| 27506168f8 | |||
| dc11e08e22 | |||
| fdddb5f08d | |||
| f78186b020 | |||
| 8dd604afb4 | |||
| 52bb17c8fa | |||
| 7952e4fb25 | |||
| 0281327365 | |||
| bf76eae301 | |||
| 0d47662cf9 | |||
| f4f009b904 | |||
| 3c1b753952 | |||
| bdbab48d10 | |||
| 16cc4b3f63 | |||
| 0ef861ed8f | |||
| 6ae7eb44cf | |||
| 8f6aa8aa8b | |||
| b8e889ad13 | |||
| 15ee4af1a1 | |||
| 4c606eb39f | |||
| 8d2ac163ae | |||
| 8709b5f4d8 | |||
| fd48ebecb8 | |||
| 021e5dd8b1 | |||
| 7adf5e1b08 | |||
| 455cc67d4c | |||
| dd3133ee2a | |||
| b3637cddd6 | |||
| 662db5e511 | |||
| 2ef91cf1b1 | |||
| 1d2f176977 | |||
| 2b3bcdd4f1 | |||
| a82f03feb8 | |||
| f1d4e38414 | |||
| 32060bd370 | |||
| 133f83f4ce | |||
| 42f0e16b02 | |||
| a2f0a2fc36 | |||
| 9f552cff6b | |||
| b913194fb4 | |||
| 3eccafad6b | |||
| a9d87bdbaa | |||
| 6f9255105d | |||
| c04e3c5599 | |||
| f15315f1b9 | |||
| 0ef6cd567e | |||
| b308ff4d6b | |||
| 5ecbc06c85 | |||
| 21e97f5d4e | |||
| 3900b3313b | |||
| 5d416cfc4a | |||
| 6532e8824a | |||
| d321492995 | |||
| b57110e5c8 | |||
| 482e83903c | |||
| 21a7748b2c | |||
| b36e198bfe | |||
| f9d6d42b1b | |||
| d9ab1e47ec | |||
| 4f755704d9 | |||
| a13f04fce5 | |||
| 1f5b347cd2 | |||
| 3639636813 | |||
| ca27d81163 | |||
| d74ba0f259 | |||
| 121f28fd29 | |||
| e3bcecc5c1 | |||
| 0884eb0ce5 | |||
| a27e870522 | |||
| d90801cff5 | |||
| 0f63159482 | |||
| 2792933833 | |||
| 9261431329 | |||
| 1bfd865333 | |||
| e5af7477af | |||
| 92fcbfcc05 | |||
| ff6aade42c | |||
| 4fac69c9f0 | |||
| 90ab6973e6 | |||
| 90a502f50e | |||
| 273f462e09 | |||
| 5361d5647b | |||
| d2e90da148 | |||
| 047ce32ac6 | |||
| 5896168a93 | |||
| fe9c40741a | |||
| 88004a1827 | |||
| a515b7e7d9 | |||
| 696454cf34 | |||
| 8c97788682 | |||
| 5203440ba0 | |||
| 3d452a120a | |||
| 4eece7cc8a | |||
| d0d0b698f9 | |||
| 4c4b01f265 | |||
| 58fe794227 | |||
| 63dfc0b55a | |||
| 1ddc33eb17 | |||
| 4a0238909b | |||
| 3d5f71e72d | |||
| 8ff5c44324 | |||
| 4e704e4f51 | |||
| 7b4c73d1e6 | |||
| 7efd8b5855 | |||
| 7cbc59d8a7 | |||
| 67b30dbad6 | |||
| cd8d77b37c | |||
| bb79232e3e | |||
| b4e0aadbef | |||
| 4fe0f40cef | |||
| 40b42f4bf3 | |||
| e8f1aa5caf | |||
| a02af0bce0 | |||
| ff2f8c16a3 | |||
| ace5577402 | |||
| 473f252aee | |||
| 74bd69834c | |||
| 98cab70b53 | |||
| dc0172ff82 | |||
| 0b41773017 | |||
| ddda42453f | |||
| 295f06915f | |||
| 120bce3391 | |||
| 386263784c | |||
| afe6803a63 | |||
| 5344c7880a | |||
| 74e32eb18e | |||
| ceb00ad45e | |||
| 7cd07c429b | |||
| 28b8391d53 | |||
| bb8de054dc | |||
| 9d6502460e | |||
| fcf1108641 | |||
| 185605fbf0 | |||
| 662d906997 | |||
| f6e20d1412 | |||
| 9e14a9b7c7 | |||
| 422430613c | |||
| 2a4ce64d6f | |||
| 316ab8f3fc | |||
| f6a68426e1 | |||
| bed2b84100 | |||
| b39ec1e4d3 | |||
| dd92e9b061 | |||
| 8d737395dc | |||
| b3f7c9c1f2 | |||
| d425132ae7 | |||
| 925d351341 | |||
| c4107490df | |||
| b38230bc35 | |||
| 1f5f9964e0 | |||
| b0d8c6983d | |||
| 55f0180599 | |||
| 5284033fb8 | |||
| 0b1423c90d | |||
| e6a4bb2d16 | |||
| c3a051d372 | |||
| 3482551360 | |||
| d7a72f8581 | |||
| c09b7410ce | |||
| 96ce0edd46 | |||
| 7cd68ff222 | |||
| 16680e3f61 | |||
| 3389d4fa93 | |||
| 0041845935 | |||
| 3bcccb4a7b | |||
| 2aba52f552 | |||
| f149563c68 | |||
| 276e4f1189 | |||
| cb42cad6a6 | |||
| ef044327c6 | |||
| d1d0df11a8 | |||
| c3da3af2f4 | |||
| 7b760a0823 | |||
| 1e9c29aa55 | |||
| 1ddd88231a | |||
| d665049f05 | |||
| 1af6169999 | |||
| 468197f7e0 | |||
| 816cfb24aa | |||
| 479d144592 | |||
| 0b516d9762 | |||
| 2483c2362d | |||
| 40639309b1 | |||
| eefb0681dc | |||
| 9501dad1dc | |||
| 24dba3bd60 | |||
| e9508089ab | |||
| 454f47b2b5 | |||
| d8b417458b | |||
| 195a130124 | |||
| 8577fc29e4 | |||
| d71c7e4d28 | |||
| 9bc8156615 | |||
| 183b23cf5a | |||
| 138bc4e7e4 | |||
| e2529cfdf2 | |||
| b982b27f84 | |||
| c573cd5b4d | |||
| c2758d7a91 | |||
| 64cae8c4d9 | |||
| a2ba6f5382 | |||
| 7c8d9191c1 | |||
| 9677835d84 | |||
| 63749a9572 | |||
| 9ff90d33fc | |||
| 942e8ff591 | |||
| 0b050256f9 | |||
| 8a762bf34f | |||
| 42defcf4b9 | |||
| 3e93a97d10 | |||
| 26e6f33cde | |||
| 1bc0739d31 | |||
| 8476dab844 | |||
| 745992886c | |||
| 8089f62806 | |||
| a34aec1dd2 | |||
| 448bd1e510 | |||
| 4e23e1e11f | |||
| 5d3b850e46 | |||
| 4f45dee332 | |||
| 1355605e70 | |||
| 7dc2ac9253 | |||
| 6915bf1ba2 | |||
| ac6ede8f9c | |||
| bcb4a64c68 | |||
| 7a970148f3 | |||
| 0759e1598f | |||
| 25ad8959a6 | |||
| 4f530b2b6a | |||
| f23af43013 | |||
| 62827cf56d | |||
| 5c9333feec |
97
.woodpecker/release.yml
Normal file
97
.woodpecker/release.yml
Normal file
@@ -0,0 +1,97 @@
|
||||
when:
|
||||
- event: tag
|
||||
|
||||
steps:
|
||||
- name: validate-release
|
||||
image: golang:1.26.5
|
||||
commands:
|
||||
- |
|
||||
set -eu
|
||||
|
||||
version="$CI_COMMIT_TAG"
|
||||
release_note="docs/releases/$version.md"
|
||||
|
||||
if ! printf '%s\n' "$version" |
|
||||
grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
|
||||
then
|
||||
printf '%s\n' "invalid release tag: $version" >&2
|
||||
exit 1
|
||||
fi
|
||||
test -s "$release_note"
|
||||
test -z "$(git ls-files go.work go.work.sum)"
|
||||
test ! -e vendor
|
||||
if grep -Eq '^[[:space:]]*replace([[:space:]]|\()' go.mod
|
||||
then
|
||||
printf '%s\n' 'go.mod contains a replacement' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
GOWORK=off go test -count=1 ./...
|
||||
GOWORK=off go test -race -count=1 ./...
|
||||
GOWORK=off go vet ./...
|
||||
GOWORK=off go build ./...
|
||||
GOWORK=off go mod tidy -diff
|
||||
|
||||
unformatted=$(
|
||||
git ls-files '*.go' |
|
||||
while IFS= read -r go_file
|
||||
do
|
||||
gofmt -l "$go_file"
|
||||
done
|
||||
)
|
||||
test -z "$unformatted"
|
||||
git diff --check
|
||||
|
||||
- name: build-release-assets
|
||||
image: golang:1.26.5
|
||||
depends_on:
|
||||
- validate-release
|
||||
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"
|
||||
|
||||
host_binary="$dist/weatherreporter-$version-$(go env GOOS)-$(go env GOARCH)"
|
||||
test "$("$host_binary" --version)" = "weatherreporter $version"
|
||||
|
||||
- name: publish-release
|
||||
image: woodpeckerci/plugin-release:0.3.1
|
||||
depends_on:
|
||||
- build-release-assets
|
||||
settings:
|
||||
api_key:
|
||||
from_secret: GITEA_RELEASE_TOKEN
|
||||
files:
|
||||
- dist/weatherreporter-*
|
||||
title: Weatherreporter ${CI_COMMIT_TAG}
|
||||
note: docs/releases/${CI_COMMIT_TAG}.md
|
||||
checksum: sha256
|
||||
checksum-file: SHA256SUMS
|
||||
checksum-flatten: true
|
||||
file-exists: skip
|
||||
overwrite: false
|
||||
prerelease: false
|
||||
@@ -1,4 +1 @@
|
||||
Please carefully review the documents in `docs/policy` before making any changes to this repository.
|
||||
- `architecture.md` provides the canonical high-level architecture policy for this repository.
|
||||
- `development.md` provides more granular development policy for this repository.
|
||||
- `documentation.md` provides the canonical documentation policy for this repository.
|
||||
Please review `docs/development.md` for initial orientation in this repository and follow its task-specific reading guide.
|
||||
|
||||
26
README.md
26
README.md
@@ -1,28 +1,28 @@
|
||||
# weatherreporter
|
||||
|
||||
`weatherreporter` is a Go application for preparing human-facing weather
|
||||
reports from normalized forecast data.
|
||||
Weatherreporter is a Go CLI that turns normalized weather data into
|
||||
human-facing Markdown reports.
|
||||
|
||||
The application can currently generate Daily Today, Daily Tomorrow, 3-Day
|
||||
Outlook, Weekend Outlook, and manual Storm Report Markdown reports through `scriptorium`, with
|
||||
inspectable briefing, prompt input, preflight, report, and metadata artifacts
|
||||
under the configured workspace.
|
||||
It produces a Markdown report at an operator-owned destination and can upload
|
||||
the completed output through Distributor.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```sh
|
||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
||||
weatherreporter generate tomorrow --out ./tomorrow.md
|
||||
weatherreporter generate three-day --out ./three-day.md
|
||||
weatherreporter generate weekend --out ./weekend.md
|
||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
|
||||
weatherreporter generate today
|
||||
```
|
||||
|
||||
Configure a Weather API endpoint first; see the
|
||||
[configuration reference](docs/config.md). The report is written to
|
||||
`today.md` in the current directory when `output.directory` is not configured.
|
||||
Set that configuration value for an ordinary publication directory, or use
|
||||
`--out` for one command. See the [CLI reference](docs/cli.md) and [operations
|
||||
guide](docs/operations.md) for command and operating details.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [CLI reference](docs/cli.md)
|
||||
- [Configuration reference](docs/config.md)
|
||||
- [Operations guide](docs/operations.md)
|
||||
- [Development guide](docs/development.md)
|
||||
- [Architecture policy](docs/policy/architecture.md)
|
||||
- [Development policy](docs/policy/development.md)
|
||||
- [Implementation roadmap](docs/roadmap/initial.md)
|
||||
|
||||
100
docs/adr/0001-stateless-execution.md
Normal file
100
docs/adr/0001-stateless-execution.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# 0001: Make Weatherreporter Execution Stateless
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Date: 2026-08-01
|
||||
|
||||
## Context
|
||||
|
||||
Weather reports are ephemeral products. Forecasts and current conditions change
|
||||
continuously, so the useful response to an old, failed, or superseded report is
|
||||
normally a new generation rather than replaying or inspecting a prior run.
|
||||
|
||||
The existing run-addressed workspace retains module snapshots, prompt inputs,
|
||||
execution receipts, generated text, rendered reports, metadata, and
|
||||
notification receipts. That provenance store accumulates operational history
|
||||
whose recovery and compatibility obligations are disproportionate to the value
|
||||
of an ephemeral weather report. It also exists solely to support local Recent
|
||||
Changes comparison for a rarely used report section.
|
||||
|
||||
The temporary roadmap that defined the feature scope and implementation plan
|
||||
has been retired under the repository's documentation lifecycle. The
|
||||
[architecture policy](../policy/architecture.md) defines the resulting system
|
||||
invariants; this decision records their durable rationale.
|
||||
|
||||
## Decision
|
||||
|
||||
Weatherreporter will operate as a stateless transformation pipeline:
|
||||
|
||||
```text
|
||||
Weather API input
|
||||
-> deterministic facts and modules
|
||||
-> Promptkit data package and generated text
|
||||
-> repository-owned Markdown rendering
|
||||
-> operator-owned report output
|
||||
-> optional Distributor upload
|
||||
```
|
||||
|
||||
Ordinary invocations will retain intermediate values only for the active
|
||||
process and will publish one operator-owned Markdown output atomically. A
|
||||
failed or canceled generation must not truncate or partially replace an
|
||||
existing selected output. Single-report Distributor notification follows
|
||||
successful publication; batch notification follows successful publication of
|
||||
every planned report.
|
||||
|
||||
Weatherreporter will remove local Recent Changes comparison instead of
|
||||
retaining application state to support it. It will remove run-addressed
|
||||
workspace artifacts, historical inspection, and backward-compatible workspace
|
||||
decoding. RunIDs may remain active correlation and Distributor idempotency
|
||||
values, but will not identify retained application history.
|
||||
|
||||
Explicit `--llm-debug-dir` capture remains the sole diagnostic-file exception.
|
||||
The operator selects and manages that secure location; ordinary execution does
|
||||
not create an implicit debug location or a general logging store, and debug
|
||||
capture must continue to exclude credentials.
|
||||
|
||||
Any future forecast comparison must use a structured product supplied by the
|
||||
Weather API rather than local Weatherreporter history. The proposed
|
||||
[Upstream Forecast Change Product](../roadmap/future.md#upstream-forecast-change-product)
|
||||
defines the required upstream direction. A future integration must not add a
|
||||
local snapshot fallback.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Retain The Bounded Current-State Design
|
||||
|
||||
Retaining a managed workspace with current metadata, receipts, and snapshots
|
||||
would preserve inspection and local comparison, but keeps an application-owned
|
||||
history subsystem, artifact compatibility burden, and recovery surface that do
|
||||
not match the report lifecycle.
|
||||
|
||||
### Time-Based Retention
|
||||
|
||||
Expiring workspace material after a fixed period reduces accumulation but still
|
||||
requires retention policy, cleanup behavior, failure handling, and historical
|
||||
format support. It does not remove the mismatch between retained provenance and
|
||||
ephemeral report products.
|
||||
|
||||
### Bounded Run History
|
||||
|
||||
Keeping only a fixed number of prior runs limits storage volume but still makes
|
||||
Weatherreporter responsible for run selection, comparison, inspection, and
|
||||
state migration. It also creates arbitrary history gaps without establishing an
|
||||
authoritative forecast baseline.
|
||||
|
||||
## Consequences
|
||||
|
||||
The CLI, configuration, prompt-input, workspace, and inspection contracts will
|
||||
change together. Legacy workspace material will not be migrated, decoded, or
|
||||
automatically deleted; operators remain responsible for any desired cleanup.
|
||||
|
||||
Current action results will carry active identity, selected profile, safe
|
||||
effective model information, output location, notification result, and safe
|
||||
errors instead of historical artifact paths. Tests will protect atomic output,
|
||||
batch and notification ordering, explicit secure debug capture, and the
|
||||
absence of ordinary application-managed state.
|
||||
|
||||
This decision deliberately leaves the Weather API responsible for any future
|
||||
forecast-history comparison. It avoids a cache, archive, retention engine,
|
||||
manifest, resume mechanism, or replacement inspection surface in
|
||||
Weatherreporter.
|
||||
206
docs/cli.md
206
docs/cli.md
@@ -1,107 +1,141 @@
|
||||
# Weatherreporter CLI
|
||||
|
||||
`weatherreporter generate daily`, `weatherreporter generate tomorrow`,
|
||||
`weatherreporter generate three-day`, `weatherreporter generate weekend`,
|
||||
`weatherreporter generate storm`,
|
||||
`weatherreporter run morning`, and `weatherreporter run evening` currently
|
||||
write Markdown reports through `scriptorium`, after writing managed preparation
|
||||
artifacts and running `scriptorium render` as a preflight check.
|
||||
`weatherreporter` generates Markdown weather reports and runs report batches.
|
||||
It has no command for inspecting prior runs or application-owned state.
|
||||
|
||||
## Shortest Useful Command
|
||||
|
||||
```sh
|
||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
||||
weatherreporter generate today
|
||||
```
|
||||
|
||||
The command parses flags, loads configuration, fetches weather data, builds a
|
||||
Daily briefing, writes workspace artifacts, invokes
|
||||
`scriptorium render --input data_package=<managed_path> --format json`, then
|
||||
invokes `scriptorium run --input data_package=<managed_path> --out <managed_report>`.
|
||||
When `--out` is supplied, it also writes a copy of the Markdown report to that
|
||||
path.
|
||||
The command uses the configured Weather API and atomically writes `today.md`.
|
||||
With no configured output directory, it writes in the current directory. See
|
||||
the [configuration reference](config.md) to supply the required Weather API
|
||||
endpoint and choose an ordinary output directory.
|
||||
|
||||
For tomorrow planning:
|
||||
|
||||
```sh
|
||||
weatherreporter generate tomorrow --out ./tomorrow.md
|
||||
weatherreporter run evening
|
||||
```
|
||||
|
||||
For the 3-Day Outlook:
|
||||
|
||||
```sh
|
||||
weatherreporter generate three-day --out ./three-day.md
|
||||
weatherreporter generate weekend --out ./weekend.md
|
||||
```
|
||||
|
||||
For a focused manual Storm Report:
|
||||
|
||||
```sh
|
||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
|
||||
```
|
||||
|
||||
## Command Overview
|
||||
## Commands And Usage
|
||||
|
||||
```text
|
||||
weatherreporter generate daily
|
||||
weatherreporter generate tomorrow
|
||||
weatherreporter generate three-day
|
||||
weatherreporter generate weekend
|
||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
|
||||
weatherreporter run morning
|
||||
weatherreporter run evening
|
||||
weatherreporter inspect reports
|
||||
weatherreporter inspect metadata RUN_ID
|
||||
weatherreporter inspect briefing RUN_ID
|
||||
weatherreporter inspect data-package RUN_ID
|
||||
weatherreporter inspect prior RUN_ID
|
||||
weatherreporter inspect sources RUN_ID
|
||||
weatherreporter --help
|
||||
weatherreporter --version
|
||||
weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet]
|
||||
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] [--llm-debug-dir PATH] [--quiet]
|
||||
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet]
|
||||
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet]
|
||||
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--llm-debug-dir PATH] [--quiet]
|
||||
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--llm-debug-dir PATH] [--quiet]
|
||||
```
|
||||
|
||||
`generate daily`, `generate tomorrow`, `generate three-day`,
|
||||
`generate weekend`, and `generate storm` write a briefing snapshot, prompt
|
||||
input data package, render preflight output, Markdown report, and metadata file
|
||||
under the configured workspace. `generate storm` requires explicit `--start`
|
||||
and `--end` bounds for the event window. `run evening` generates the Tomorrow
|
||||
Planning Brief. `run morning` generates Daily Today and the 3-Day Outlook, plus
|
||||
Weekend Outlook except on Sunday. Run commands continue remaining reports after
|
||||
an independent report failure, print a JSON aggregate summary to stdout, write
|
||||
compact report status logs to stderr, and return nonzero when any report
|
||||
failed.
|
||||
`weatherreporter --version` prints the version embedded in the executable.
|
||||
Tagged release binaries report their semantic version tag; ordinary local
|
||||
builds report `development`.
|
||||
|
||||
`inspect` commands read the configured workspace and emit JSON to stdout. They
|
||||
do not fetch weather data or invoke `scriptorium`.
|
||||
| Command | Contract |
|
||||
| --- | --- |
|
||||
| `generate daily` | Requires `--date YYYY-MM-DD`; the date is interpreted in the effective report timezone. Its default filename is `daily-YYYY-MM-DD.md`. |
|
||||
| `generate today` | Accepts an optional `--date YYYY-MM-DD`; without it, the current local date in the effective report timezone is used. Its default filename is `today.md`. |
|
||||
| `generate tomorrow` | Uses the next local civil day and writes `tomorrow.md` by default. |
|
||||
| `generate hourly` | Covers the next six hours in the effective report timezone and writes `hourly.md` by default. It does not accept `--date`, `--hours`, or `--duration`. |
|
||||
| `run morning` and `run evening` | Run their defined report batches beneath the configured output directory, or the current directory when none is configured. `--out-dir` selects another directory. `--out` is not accepted. |
|
||||
|
||||
## Flags
|
||||
`generate` accepts the four report command names shown above. `run` accepts
|
||||
only `morning` and `evening`. Batch membership and notification ordering are
|
||||
described in the [operations guide](operations.md).
|
||||
|
||||
- `-h`, `--help`: show help.
|
||||
- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`.
|
||||
- `--units VALUE`: override configured Weather API units.
|
||||
- `--tz NAME`: override configured Weather API timezone.
|
||||
- `--out PATH`: optional Markdown report copy for `generate daily`, `generate tomorrow`, `generate three-day`, `generate weekend`, and `generate storm`.
|
||||
- `--out-dir PATH`: optional directory for extra Markdown report copies from `run morning` and `run evening`.
|
||||
- `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone.
|
||||
- `--start TIME`: required start time for `generate storm`.
|
||||
- `--end TIME`: required end time for `generate storm`.
|
||||
- `--limit N`: maximum report records for `inspect reports`; defaults to 20,
|
||||
and `0` means no limit.
|
||||
## Output, Errors, And Quiet Mode
|
||||
|
||||
Storm times accept `YYYY-MM-DDTHH:MM` in the configured timezone or RFC3339
|
||||
timestamps with explicit offsets.
|
||||
For `generate`, the report's default filename is placed beneath
|
||||
`output.directory` when configured, otherwise the current directory. `--out
|
||||
PATH` selects one complete output file instead. A relative path is resolved
|
||||
from the current directory; an absolute path is used as given. For a batch,
|
||||
the configured directory has the same role and `--out-dir PATH` selects its
|
||||
output directory instead. Successful summaries always report the resulting
|
||||
absolute `outputPath` values. See the [configuration reference](config.md) for
|
||||
the field's validation and path rules.
|
||||
|
||||
## Inspection
|
||||
Outputs are written atomically. A generation, rendering, write, or cancellation
|
||||
failure before publication leaves an existing destination unchanged. A
|
||||
notification failure occurs after publication, so the newly written output
|
||||
remains available.
|
||||
|
||||
Action commands (`generate` and `run`) write a JSON summary to stdout unless
|
||||
`--quiet` is set. `run` also writes compact per-report and batch status lines
|
||||
to stderr. A pre-run error, such as an invalid flag, missing required argument,
|
||||
or configuration-load failure, produces no partial JSON summary. When an
|
||||
action fails after it has produced a result, its summary has `"status": "failed"`
|
||||
and an `error` field.
|
||||
|
||||
`--quiet` is supported by action commands only. It suppresses action summaries
|
||||
and routine batch status output; it does not suppress command errors.
|
||||
|
||||
### Generate Summary
|
||||
|
||||
A generate summary identifies the command, report, run, generation time, valid
|
||||
period, prompt version, timezone, and status. Successful output has an absolute
|
||||
`outputPath`:
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "generate",
|
||||
"reportId": "today",
|
||||
"promptId": "weather.today_generated_text",
|
||||
"promptVersion": "2.0.0",
|
||||
"runId": "20260529T120000.000000000Z_today",
|
||||
"status": "succeeded",
|
||||
"timezone": "America/Chicago",
|
||||
"outputPath": "/srv/weather/today.md"
|
||||
}
|
||||
```
|
||||
|
||||
When available, the summary also includes the effective `profileId`,
|
||||
`backendId`, `modelName`, `sourceWarnings`, `validationStatus`, requested
|
||||
`llmDebugPath`, and compact Distributor `notification` result. It does not
|
||||
include historical or transient artifact paths such as metadata, prompt input,
|
||||
raw generated text, render context, or notification receipts.
|
||||
|
||||
### Run Summary And Stderr
|
||||
|
||||
A run summary contains `command`, `batch`, `status`, `startedAt`, `finishedAt`,
|
||||
`total`, `succeeded`, `failed`, and a `reports` array. Each report item includes
|
||||
its identity, status, effective profile and model details when available,
|
||||
source warnings, validation status, and absolute `outputPath` after publication.
|
||||
The top-level summary may also contain a batch `notification` object and
|
||||
`error`. Batch status is `failed` if any report or the batch notification fails.
|
||||
The `total`, `succeeded`, and `failed` counters describe report items only, so
|
||||
a failed batch notification can leave `failed` at `0` while the top-level
|
||||
notification and action status are `failed`.
|
||||
|
||||
Without `--quiet`, batch status lines use this form:
|
||||
|
||||
```text
|
||||
report=today status=succeeded output="/srv/weather/reports/today.md"
|
||||
batch=morning total=2 succeeded=2 failed=0
|
||||
```
|
||||
|
||||
## Flag Reference
|
||||
|
||||
| Flag | Accepted by | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `-h`, `--help` | top level | Show help. |
|
||||
| `--config PATH` | all commands | Load `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`. |
|
||||
| `--units VALUE` | `generate`, `run` | Override `weather_api.units` for this command. |
|
||||
| `--tz NAME` | `generate`, `run` | Override `weather_api.timezone` for this command. |
|
||||
| `--out PATH` | every `generate` command | Write the report to this complete file destination instead of the configured or current-directory default. |
|
||||
| `--llm-debug-dir PATH` | every `generate` and `run` command | Write requested sensitive prompt diagnostics under this absolute path. |
|
||||
| `--out-dir PATH` | `run morning`, `run evening` | Write batch reports beneath this directory instead of the configured or current-directory default. |
|
||||
| `--quiet` | `generate`, `run` | Suppress action summaries and routine batch status output. |
|
||||
| `--date YYYY-MM-DD` | `generate daily`, `generate today` | Required for Daily; optional for Today. |
|
||||
|
||||
Distributor notification is configured through `notify.distributor`; there are
|
||||
no Distributor-specific CLI flags. See the [configuration reference](config.md).
|
||||
|
||||
## Invocation Examples
|
||||
|
||||
```sh
|
||||
weatherreporter inspect reports --limit 10
|
||||
weatherreporter inspect metadata 20260529T100000.000000000Z_daily_today
|
||||
weatherreporter inspect briefing 20260529T100000.000000000Z_daily_today
|
||||
weatherreporter inspect data-package 20260529T100000.000000000Z_daily_today
|
||||
weatherreporter inspect prior 20260529T100000.000000000Z_daily_today
|
||||
weatherreporter inspect sources 20260529T100000.000000000Z_daily_today
|
||||
weatherreporter generate daily --date 2026-05-29
|
||||
weatherreporter generate today --out ./reports/today.md
|
||||
weatherreporter generate hourly --out /srv/weather/hourly.md
|
||||
weatherreporter generate today --llm-debug-dir /var/tmp/weatherreporter-debug
|
||||
weatherreporter run morning --out-dir ./reports --llm-debug-dir /var/tmp/weatherreporter-debug
|
||||
```
|
||||
|
||||
`inspect reports` lists recent generated runs with artifact paths and warning
|
||||
counts. The other commands require a RunID. `inspect prior` returns the prior
|
||||
comparable snapshot metadata selected from stored metadata, or `null` when no
|
||||
prior comparable snapshot exists. `inspect sources` shows source provenance and
|
||||
source warnings without dumping full weather payloads.
|
||||
|
||||
252
docs/config.md
252
docs/config.md
@@ -1,46 +1,232 @@
|
||||
# Weatherreporter Configuration
|
||||
|
||||
Configuration is loaded from `/usr/local/etc/weatherreporter/config.yml` by
|
||||
default. Use `--config PATH` to load a different file. CLI flags override file
|
||||
values.
|
||||
Weatherreporter reads YAML configuration. The default path is:
|
||||
|
||||
If the default file is absent, built-in defaults are used.
|
||||
```text
|
||||
/usr/local/etc/weatherreporter/config.yml
|
||||
```
|
||||
|
||||
## Minimal Config
|
||||
If the default file is absent, Weatherreporter uses built-in defaults. An
|
||||
explicit `--config PATH` must exist. Values are applied in this order:
|
||||
|
||||
1. built-in defaults;
|
||||
2. the configuration file, when present; and
|
||||
3. the `--units` and `--tz` command-line overrides.
|
||||
|
||||
Environment variables do not override configuration fields. Output flags select
|
||||
operator-owned destinations for one command and do not change configuration.
|
||||
|
||||
## Maintained Examples
|
||||
|
||||
- [minimal-config.yml](../examples/minimal-config.yml) is the smallest useful
|
||||
collection and generation configuration.
|
||||
- [config.yml](../examples/config.yml) is a representative production-oriented
|
||||
configuration using synthetic endpoints and no credentials.
|
||||
- [weather-light-local-profile.yml](../examples/weather-light-local-profile.yml)
|
||||
is a complete endpoint-only override for the embedded `weather-light`
|
||||
profile.
|
||||
|
||||
The configuration examples are loaded by the configuration test suite. The
|
||||
profile example is inspected through the Promptkit adapter test suite.
|
||||
|
||||
## Minimal Configuration
|
||||
|
||||
```yaml
|
||||
weather_api:
|
||||
base_url: https://weather.api.example.com/
|
||||
```
|
||||
|
||||
## Production-Oriented Config
|
||||
`weather_api.base_url` is required for workflows that collect weather data.
|
||||
All omitted fields use their built-in defaults.
|
||||
|
||||
See [examples/config.yml](../examples/config.yml).
|
||||
## Field Reference
|
||||
|
||||
## Reference
|
||||
### `weather_api`
|
||||
|
||||
- `weather_api.base_url`: single Weather API endpoint base URL, required when fetching weather data.
|
||||
- `weather_api.timeout`: HTTP timeout duration. Default: `10s`.
|
||||
- `weather_api.precision`: numeric precision hint. Default: `1`.
|
||||
- `weather_api.units`: Weather API units. Default: `us`.
|
||||
- `weather_api.timezone`: report timezone. Accepts IANA names, configured aliases such as `Chicago` and `Stl`, US timezone abbreviations, and UTC offsets such as `-5` or `+09:30`. Default: `Chicago`.
|
||||
- `weather_api.format`: Weather API response format. Default: `json`.
|
||||
- `missing_source.default`: one of `error`, `warn`, or `none`. Default: `warn`.
|
||||
- `missing_source.sources`: optional per-source missing-source policy overrides.
|
||||
- `scriptorium.binary`: `scriptorium` executable name. Default: `scriptorium`.
|
||||
- `scriptorium.config_path`: optional `scriptorium` config path.
|
||||
- `scriptorium.profile`: optional `scriptorium` profile.
|
||||
- `scriptorium.timeout`: subprocess timeout. Default: `2m`.
|
||||
- `scriptorium.extra_args`: optional extra arguments reserved for the adapter.
|
||||
- `workspace.root`: workspace root. Default: `workspace`.
|
||||
- `workspace.snapshots_dir`: snapshot directory under the workspace.
|
||||
- `workspace.reports_dir`: managed report directory under the workspace.
|
||||
- `workspace.data_packages_dir`: prompt input package directory under the workspace.
|
||||
- `workspace.preflight_dir`: preflight output directory under the workspace.
|
||||
- `reports.output_dir`: report output directory. Default: `reports`.
|
||||
- `reports.paths`: optional report-specific output paths.
|
||||
- `dayparts`: named daypart definitions with `start` and `end` `HH:MM` values.
|
||||
- `recent_change.temperature_degrees`: temperature change threshold.
|
||||
- `recent_change.precip_probability_points`: precipitation probability threshold.
|
||||
- `recent_change.wind_gust_miles_per_hour`: wind gust change threshold.
|
||||
- `recent_change.precip_timing_shift_minutes`: precipitation timing shift threshold.
|
||||
| Field | Default | Rules |
|
||||
| --- | --- | --- |
|
||||
| `base_url` | empty | Absolute Weather API URL. Required for collection and generation. |
|
||||
| `timeout` | `10s` | Must be greater than zero. |
|
||||
| `precision` | `0` | Must be zero or greater. Sent as the Weather API precision query value. |
|
||||
| `units` | `us` | Required Weather API units query value; `--units` overrides it for one command. |
|
||||
| `timezone` | `America/Chicago` | Required report and Weather API timezone; `--tz` overrides it for one command. |
|
||||
| `format` | `json` | Required and must be `json`. |
|
||||
|
||||
Timezone values may be IANA names, configured aliases such as `Chicago` and
|
||||
`Stl`, US timezone abbreviations, or UTC offsets such as `-5` and `+09:30`.
|
||||
|
||||
### `location`
|
||||
|
||||
`location` supplies descriptive prompt context; it does not choose a Weather
|
||||
API endpoint or configure multiple forecast locations.
|
||||
|
||||
| Field | Default |
|
||||
| --- | --- |
|
||||
| `id` | `home` |
|
||||
| `name` | `Brentwood` |
|
||||
| `region` | `St. Louis Metro` |
|
||||
|
||||
The prompt-facing location timezone is derived from the effective
|
||||
`weather_api.timezone` after command-line overrides.
|
||||
|
||||
### `secrets`
|
||||
|
||||
`secrets.directory` defaults to empty, which disables secret loading. When it
|
||||
is set, every regular file directly in that directory is loaded after the file
|
||||
and command-line overrides. A file basename must match
|
||||
`[A-Za-z_][A-Za-z0-9_]*`; it becomes an environment variable name, and the
|
||||
file contents replace any existing value. One trailing LF or CRLF is removed.
|
||||
|
||||
Missing directories, unreadable files, subdirectories, symlinks, non-regular
|
||||
files, and invalid names fail configuration loading. Put only secret values in
|
||||
this directory, never in the YAML file.
|
||||
|
||||
### `output`
|
||||
|
||||
`output.directory` selects the ordinary operator-owned publication directory
|
||||
for both individual reports and batches.
|
||||
|
||||
| Field | Default | Rules |
|
||||
| --- | --- | --- |
|
||||
| `directory` | empty | An omitted or empty value uses the invocation working directory. A nonempty value must contain at least one non-whitespace character. |
|
||||
|
||||
The configured value is preserved while configuration loads: it is not cleaned,
|
||||
made absolute, inspected, created, or expanded through environment variables or
|
||||
a home-directory shortcut. At execution, an absolute directory is used as
|
||||
given; a relative directory resolves from the invocation working directory, not
|
||||
from the configuration file's location. A missing directory is created when a
|
||||
report is successfully published. An existing non-directory or an uninspectable
|
||||
path fails output preflight before prompt inspection, weather collection, or
|
||||
publication.
|
||||
|
||||
For one `generate` command, `--out` is a complete file destination and takes
|
||||
precedence over `output.directory`. For `run`, `--out-dir` takes precedence.
|
||||
Those explicit flags do not inspect or rebase beneath the configured directory.
|
||||
See the [CLI reference](cli.md) for command selection and the [operations
|
||||
guide](operations.md) for publication and failure handling.
|
||||
|
||||
### `notify.distributor`
|
||||
|
||||
Distributor notification is disabled by default. Its fields are:
|
||||
|
||||
| Field | Default | Rules when notification is enabled |
|
||||
| --- | --- | --- |
|
||||
| `enabled` | `false` | Activates Distributor notification validation. |
|
||||
| `endpoint` | `https://distributor.example.com` | Must be an absolute URL. |
|
||||
| `token_env` | `DISTRIBUTOR_UPLOAD_TOKEN` | Must name a valid environment variable. |
|
||||
| `timeout` | `30s` | Must be greater than zero. |
|
||||
| `failure_policy` | `error` | Must be `error`. |
|
||||
| `pipeline_id_template` | empty | Required single-report pipeline ID template. |
|
||||
| `bundle_id_template` | `weatherreporter.{location_id}.{report_id}` | Required single-report bundle ID template. |
|
||||
| `idempotency_key_template` | `{bundle_id}.{run_id}` | Required single-report idempotency-key template. |
|
||||
| `batch.enabled` | `true` | Activates batch notification validation when Distributor notification is enabled. |
|
||||
| `batch.pipeline_id_template` | `weatherreporter` | Required when batch notification is enabled. |
|
||||
| `batch.bundle_id_template` | `weatherreporter.{location_id}.{batch}` | Required when batch notification is enabled. |
|
||||
| `batch.idempotency_key_template` | `{bundle_id}.{batch_run_id}` | Required when batch notification is enabled. |
|
||||
|
||||
The upload token is read from the environment variable named by `token_env`.
|
||||
Use `secrets.directory` when a file-backed secret is appropriate.
|
||||
|
||||
Single-report bundle templates accept `location_id`, `report_id`, `run_id`,
|
||||
`artifact_group`, `batch_output_name`, `valid_start_date`, `valid_end_date`,
|
||||
`valid_start_time`, `valid_end_time`, `valid_start_stamp`, `valid_end_stamp`,
|
||||
Pipeline and idempotency-key templates may also use `bundle_id`. Dates use
|
||||
`YYYY-MM-DD`; times use `HHMM`; and stamps use `YYYY-MM-DDTHHMM` in the
|
||||
effective report timezone.
|
||||
|
||||
Batch bundle and pipeline templates accept `location_id`, `batch`,
|
||||
`batch_run_id`, and `batch_started_date`; batch idempotency-key templates may
|
||||
also use `bundle_id`. `batch_started_date` is the batch start date in the
|
||||
effective report timezone.
|
||||
|
||||
`reports.<report>.distributor.path_templates` overrides the default ordered
|
||||
Distributor paths for that report. Each rendered path must be a unique relative
|
||||
path with `/` separators. Backslashes, empty segments, `.` and `..` segments,
|
||||
`manifest.json`, and the reserved Distributor sidecar basename are rejected.
|
||||
The default paths are:
|
||||
|
||||
| Report | Paths |
|
||||
| --- | --- |
|
||||
| `hourly` | `hourly/index.md` |
|
||||
| `daily` | `daily/{valid_start_date}/{run_id}.md`, `daily/{valid_start_date}/index.md` |
|
||||
| `today` | `daily/{valid_start_date}/{run_id}.md`, `daily/{valid_start_date}/index.md`, `today/index.md` |
|
||||
| `tomorrow` | `daily/{valid_start_date}/{run_id}.md`, `daily/{valid_start_date}/index.md`, `tomorrow/index.md` |
|
||||
|
||||
See the [operations guide](operations.md) for notification timing, uploaded
|
||||
output selection, and failure handling.
|
||||
|
||||
### `missing_source`
|
||||
|
||||
`missing_source.default` defaults to `warn` and accepts `error`, `warn`, or
|
||||
`none`. `missing_source.sources` optionally overrides that policy by source.
|
||||
Hourly forecast data is required for generated reports. Supported optional
|
||||
source keys are `observations`, `current`, `narrative`, `alerts`, `discussion`,
|
||||
`weather_story`, and `spc_convective_outlooks`.
|
||||
|
||||
### `promptkit`
|
||||
|
||||
Promptkit configuration selects the executor and prompt/profile checks for
|
||||
every `generate` and `run` command. A top-level `scriptorium:` configuration
|
||||
key is rejected with a migration error; it is not translated or ignored.
|
||||
|
||||
Prompt debug capture has no YAML setting. Use `--llm-debug-dir PATH` on an
|
||||
individual `generate` or `run` command when explicitly needed.
|
||||
|
||||
| Field | Default | Rules |
|
||||
| --- | --- | --- |
|
||||
| `profile` | empty | Optional global profile selection for every report in one command. When empty, each exact prompt version selects its declared default. |
|
||||
| `profile_file` | empty | Optional external Promptkit profile file. It cannot be combined with `profile_dir`. A same-ID profile completely replaces Weatherreporter's embedded definition. |
|
||||
| `profile_dir` | empty | Optional external Promptkit profile directory. It cannot be combined with `profile_file`. A same-ID profile completely replaces Weatherreporter's embedded definition. |
|
||||
| `timeout` | `2m` | Must be greater than zero. |
|
||||
| `local.endpoint` | empty | Optional absolute URL for the conventional local backend. A blank endpoint leaves it unregistered. |
|
||||
| `local.concurrency_limit` | `1` | Maximum local backend concurrency. `0` is unlimited; negative values are invalid. |
|
||||
|
||||
`profile` selects an ID; `profile_file` and `profile_dir` supply definitions.
|
||||
They are separate decisions. An explicit `profile` applies to every selected
|
||||
report. Otherwise Hourly selects `weather-light`, while Daily, Today, and
|
||||
Tomorrow select `weather-balanced` through their exact `2.0.0` prompt
|
||||
definitions.
|
||||
|
||||
Promptkit resolves a selected profile definition from a test or embedding
|
||||
consumer's explicit in-memory profile, then the configured `profile_file` or
|
||||
`profile_dir`, then Weatherreporter's embedded catalog, and finally Promptkit's
|
||||
built-in catalog. Sources provide complete definitions; fields are never
|
||||
merged. A matching malformed external profile fails rather than using the
|
||||
embedded definition. The [Promptkit integration guide](integrations/promptkit.md)
|
||||
owns the catalog and precedence details.
|
||||
|
||||
To replace the default Hourly definition with a local OpenAI-compatible
|
||||
endpoint, set `profile_file` to a copy of
|
||||
[weather-light-local-profile.yml](../examples/weather-light-local-profile.yml).
|
||||
The example has no credential and should be edited for the local endpoint and
|
||||
model before use. An alternative profile may use `backend: local`; in that
|
||||
case `promptkit.local.endpoint` supplies the conventional local backend
|
||||
endpoint.
|
||||
|
||||
### `dayparts`
|
||||
|
||||
`dayparts` is a non-empty list of named local-time windows used in forecast
|
||||
derivation. Every item needs `name`, `start`, and `end`; start and end use
|
||||
`HH:MM`. Defaults are `overnight` (`00:00`–`06:00`), `morning`
|
||||
(`06:00`–`10:00`), `midday` (`10:00`–`15:00`), `afternoon`
|
||||
(`15:00`–`17:00`), and `evening` (`17:00`–`24:00`).
|
||||
|
||||
### `reports`
|
||||
|
||||
`reports` optionally overrides a report's ordered deterministic modules and
|
||||
Distributor path templates. Omit a report entry to retain its defaults.
|
||||
|
||||
Supported report keys are `daily`, `today`, `tomorrow`, and `hourly`; hyphens
|
||||
and underscores are equivalent.
|
||||
|
||||
Each report entry can contain:
|
||||
|
||||
- `deterministic_modules`: an ordered list of module IDs, or objects with `id`
|
||||
and optional `options`.
|
||||
- `distributor.path_templates`: an optional, non-empty ordered list of
|
||||
Distributor paths for that report.
|
||||
|
||||
Unknown reports and modules, duplicate modules, incompatible report-module
|
||||
combinations, duplicate stanza names, invalid path templates, and invalid
|
||||
module options fail configuration loading. The accepted module IDs and module
|
||||
option contracts are documented in the [module contract internals](internal/module.md).
|
||||
|
||||
86
docs/development.md
Normal file
86
docs/development.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Development
|
||||
|
||||
This is the first-read guide for people and coding agents working on
|
||||
Weatherreporter. It provides a concise repository orientation and routes each
|
||||
kind of change to its canonical documentation.
|
||||
|
||||
Weatherreporter is a Go CLI that collects normalized weather data, derives
|
||||
deterministic report facts and module snapshots, executes Promptkit for
|
||||
single-report generated text, renders Markdown reports, and can upload completed
|
||||
operator-owned outputs through Distributor. Start with the [README](../README.md) for product
|
||||
context and the [architecture policy](policy/architecture.md) for system
|
||||
boundaries and invariants.
|
||||
|
||||
## What To Read
|
||||
|
||||
| When working on | Read | Why |
|
||||
| --- | --- | --- |
|
||||
| Product behavior or the shortest useful workflow | [README](../README.md), [CLI reference](cli.md), and [operations guide](operations.md) | These own product orientation, invocation, and normal operation. |
|
||||
| Application shape, package boundaries, dependency direction, safety properties, or architectural invariants | [Architecture policy](policy/architecture.md) and relevant ADRs under `docs/adr/`, when present | Architecture defines the intended system; ADRs preserve significant decision rationale. |
|
||||
| Any documentation addition, revision, move, or removal | [Documentation policy](policy/documentation.md) | It defines canonical owners, audience boundaries, current-state rules, and document lifecycle. |
|
||||
| Adding, changing, reviewing, or deleting tests | [Testing policy](policy/testing.md) and focused package tests | The policy defines risk-based sufficiency, durable test boundaries, doubles, and test-maintenance criteria. |
|
||||
| CLI commands, flags, output, quiet mode, or command wiring | [CLI reference](cli.md) and [CLI internals](internal/cli.md) | The reference owns the user contract; the internal guide owns command composition and output flow. |
|
||||
| Configuration fields, defaults, loading, overrides, validation, or secrets | [Configuration reference](config.md), [architecture policy](policy/architecture.md), and tests under `internal/config` | These separate the user-visible contract, architectural rules, and executable behavior. |
|
||||
| Top-level generation, batch, collection, output publication, or notification workflow | [App orchestration internals](internal/app-orchestration.md) | It owns workflow ordering, output publication, failure propagation, and orchestration invariants. |
|
||||
| Weather API transport, source envelopes, source warnings, or collection | [Weather API integration](integrations/weatherapi.md), [weather-data internals](internal/weather-data.md), and [collection internals](internal/collect.md) | These separate the external contract, normalized source facts, and app-facing collection behavior. |
|
||||
| Forecast periods, weather derivation, collected facts, or derived facts | [Forecast derivation internals](internal/forecast-derivation.md) and [fact contracts](internal/facts.md) | They own deterministic derivation and the fact boundaries used by reports. |
|
||||
| Report definitions, valid periods, report IDs, output naming, or batch composition | [Report registry internals](internal/report-registry.md) and [app orchestration internals](internal/app-orchestration.md) | Report definitions own selection and period rules; orchestration owns execution. |
|
||||
| Module IDs, module composition, briefing values, or prompt-facing exports | [Module contract internals](internal/module.md), [module builder internals](internal/briefing.md), and [prompt-input internals](internal/prompt-input.md) | These own module contracts, value construction, and the curated prompt-package boundary. |
|
||||
| Prompt execution, profiles, prompt inputs, or result handling | `internal/promptexec`, the Promptkit adapter, and [prompt-input internals](internal/prompt-input.md) | These separate the executor contract and input construction. |
|
||||
| Generated-text schemas, validation, render contexts, templates, or Markdown rendering | [Generated-text internals](internal/generatedtext.md), [report-template internals](internal/reporttemplate.md), and [report template guide](templates.md) | These own structured text, renderer implementation, and the maintainer-facing template surface. |
|
||||
| Output destinations, atomic publication, prompt diagnosis, or legacy cleanup | [Operations guide](operations.md) and [App orchestration internals](internal/app-orchestration.md) | Operations owns operator workflows; app internals owns the implementation boundary. |
|
||||
| Distributor bundles, uploads, notification results, or failures | [Distributor adapter internals](internal/distributor-adapter.md), [Distributor integration contracts](integrations/distributor/), and [operations guide](operations.md) | These separate adapter behavior, external contracts, and operational lifecycle. |
|
||||
| Maintained example configuration | [Configuration reference](config.md) and files under `examples/` | The reference owns field meaning; examples own complete copyable files. |
|
||||
| Release preparation, tagging, publication, or verification | [Release procedure](release.md) | It owns version selection, release-note preparation, candidate validation, tag publication, CI behavior, and post-publication checks. |
|
||||
| Proposed, deferred, or unimplemented work | Documents under `docs/roadmap/` | Future behavior and implementation status belong only in roadmaps until implemented. |
|
||||
|
||||
For an existing subsystem, inspect its focused internal document, package-local
|
||||
types, and tests before changing behavior. Use the package boundaries already
|
||||
present before introducing a new package or abstraction.
|
||||
|
||||
## Repository Map
|
||||
|
||||
| Area | Responsibility |
|
||||
| --- | --- |
|
||||
| `cmd/weatherreporter` | Binary entry point. |
|
||||
| `internal/cli` | Command parsing, flags, help, output, and command wiring. |
|
||||
| `internal/app` | Stateless generation, batches, collection coordination, output publication, and notification. |
|
||||
| `internal/config` | Configuration defaults, loading, precedence, secrets, and validation. |
|
||||
| `internal/adapters` | Weather API, Promptkit, and Distributor boundaries. |
|
||||
| `internal/weatherdata`, `internal/forecast`, `internal/facts` | Normalized source facts and deterministic derivation. |
|
||||
| `internal/report`, `internal/module`, `internal/briefing` | Report registry plus module and briefing contracts. |
|
||||
| `internal/promptinput`, `internal/generatedtext`, `internal/reporttemplate` | Prompt packages, generated-text validation, render contexts, and Markdown templates. |
|
||||
| `internal/fileutil`, `internal/timeutil` | Atomic output operations, clocks, dates, timezones, and periods. |
|
||||
| `docs` | User, operator, integration, internal, policy, and roadmap documentation. |
|
||||
| `examples` | Maintained copyable configuration. |
|
||||
|
||||
The [architecture policy](policy/architecture.md) is authoritative for
|
||||
normative boundaries. Focused documents under `docs/internal/` own detailed
|
||||
implemented subsystem behavior.
|
||||
|
||||
## Contributor Workflow
|
||||
|
||||
1. Read the documents and focused tests identified by the task guide.
|
||||
2. Use focused package checks while iterating.
|
||||
3. Run `gofmt -w` on changed Go files.
|
||||
4. Update the canonical documentation and maintained examples in the same
|
||||
change when behavior changes.
|
||||
5. Run repository-wide validation before considering the work complete.
|
||||
|
||||
Preserve actionable error context, keep secrets out of logs and fixtures, and
|
||||
avoid validation that requires live Weather API, Promptkit providers, or Distributor
|
||||
services. The architecture and testing policies own the detailed rules.
|
||||
|
||||
## Baseline Validation
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
go run ./cmd/weatherreporter --help
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Use focused package tests during development and add broader or race-enabled
|
||||
checks when required by the [testing policy](policy/testing.md) and the risks of
|
||||
the change.
|
||||
70
docs/integrations/distributor/api.md
Normal file
70
docs/integrations/distributor/api.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Distributor HTTP Upload Contract
|
||||
|
||||
Weatherreporter integrates with the HTTP upload API provided by
|
||||
`gitea.maximumdirect.net/eric/distributor v0.5.0`. It submits source bundles to
|
||||
a configured pipeline and reads the resulting run status. Configuration fields
|
||||
and notification lifecycle are documented in the [configuration reference](../../config.md)
|
||||
and [operations guide](../../operations.md).
|
||||
|
||||
## Upload Admission
|
||||
|
||||
Weatherreporter uses an absolute HTTP(S) endpoint as a base URL. The client
|
||||
posts a gzip-compressed source bundle to:
|
||||
|
||||
```text
|
||||
POST /v1/pipelines/<pipeline_id>/upload
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/gzip
|
||||
Idempotency-Key: <key>
|
||||
```
|
||||
|
||||
The authenticated token must be allowed to use the selected upload pipeline.
|
||||
A successful response is `202 Accepted` with JSON containing `run_id` and
|
||||
`status`. Acceptance means Distributor staged and validated the source bundle;
|
||||
it does not mean downstream destinations have published it.
|
||||
|
||||
The adapter requires a pipeline ID, bundle ID, idempotency key, and at least one
|
||||
source-file mapping before calling Distributor. It reads the bearer token from
|
||||
the configured environment variable and redacts that value from errors. Request
|
||||
construction and timeout handling belong to the [Distributor adapter](../../internal/distributor-adapter.md).
|
||||
|
||||
## Idempotency
|
||||
|
||||
Distributor scopes idempotency to the token, pipeline ID, and key. Keys must be
|
||||
non-empty ASCII values of at most 128 bytes using letters, digits, `.`, `_`,
|
||||
`-`, and `:`. Weatherreporter always supplies a rendered key; it does not rely
|
||||
on the client library's generated-key fallback.
|
||||
|
||||
Reusing a key for the same normalized source manifest returns the original
|
||||
accepted run. Reusing it for different content returns `409 Conflict`, which
|
||||
the adapter exposes as a Weatherreporter idempotency-conflict error. A distinct
|
||||
report or batch run therefore needs a distinct key; reuse a key only when
|
||||
retrying that same upload.
|
||||
|
||||
## Run Status And Retention
|
||||
|
||||
After acceptance, Weatherreporter reads:
|
||||
|
||||
```text
|
||||
GET /runs/<run_id>
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
The status record provides `run_id`, `pipeline_id`, status timestamps, optional
|
||||
JSON `report`, and an `error` for failures. Statuses are `accepted`, `queued`,
|
||||
`running`, `succeeded`, and `failed`. A terminal `failed` status makes the
|
||||
notification fail; the adapter preserves the returned status details for the
|
||||
application to record.
|
||||
|
||||
Run and idempotency records are in-memory. Completed records expire according
|
||||
to Distributor's `server.http.retention`, and a Distributor restart removes
|
||||
retained status and idempotency state. Status polling decisions are internal
|
||||
orchestration behavior; see the
|
||||
[Distributor adapter](../../internal/distributor-adapter.md) and
|
||||
[application orchestration](../../internal/app-orchestration.md).
|
||||
|
||||
## Compatibility Reference
|
||||
|
||||
The upstream canonical HTTP wire contract is
|
||||
`docs/integrations/http-upload.md` in the Distributor repository. This page
|
||||
documents only the portion exercised by Weatherreporter.
|
||||
36
docs/integrations/distributor/pkg-bundle.md
Normal file
36
docs/integrations/distributor/pkg-bundle.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Distributor Source Bundle Mapping
|
||||
|
||||
Weatherreporter uses the source-bundle format through Distributor's
|
||||
`pkg/upload.UploadFiles` helper. It does not create bundle directories or call
|
||||
`pkg/bundle` directly. The helper creates a temporary bundle, writes and
|
||||
validates `manifest.json`, archives it, and removes the temporary bundle when
|
||||
the upload call returns.
|
||||
|
||||
## File Mappings
|
||||
|
||||
Every mapping pairs an operator-owned Markdown output with one bundle-relative
|
||||
path. A single-report notification maps its published output to each rendered
|
||||
path configured for that report. A batch notification combines mappings for
|
||||
every included published output and rejects duplicate bundle paths.
|
||||
|
||||
The report source is the output selected for that command; the application does
|
||||
not scan local directories. It renders notification paths after publication;
|
||||
see the [operations guide](../../operations.md) and the
|
||||
[Distributor adapter](../../internal/distributor-adapter.md) for the boundary.
|
||||
|
||||
Bundle paths must be clean, relative, slash-separated paths. They cannot be
|
||||
empty or absolute, contain backslashes, empty segments, `.` or `..`, or use
|
||||
`manifest.json` or `.distributor.json` as a basename. The mapped source must be
|
||||
a regular file. File mapping order is preserved and affects the bundle digest.
|
||||
|
||||
The bundle manifest uses schema version `1`, carries the rendered bundle ID and
|
||||
creation time, and records each mapped file's path, SHA-256 digest, and size.
|
||||
Destination routing, publication, and Distributor-managed destination state are
|
||||
not source-bundle fields.
|
||||
|
||||
## Compatibility Reference
|
||||
|
||||
The upstream canonical file-format contract is
|
||||
`docs/integrations/source-bundle.md` in the Distributor repository. It defines
|
||||
the complete manifest and archive format; this page records only the mapping and
|
||||
path constraints Weatherreporter relies on.
|
||||
51
docs/integrations/distributor/pkg-upload.md
Normal file
51
docs/integrations/distributor/pkg-upload.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Distributor Upload Client Contract
|
||||
|
||||
Weatherreporter uses `gitea.maximumdirect.net/eric/distributor/pkg/upload` at
|
||||
the pinned module version `v0.5.0`. It constructs one client per notification
|
||||
attempt and calls `UploadFiles`, followed by `Status` for the accepted run.
|
||||
|
||||
## Client And Upload
|
||||
|
||||
The adapter constructs the client with the configured endpoint, bearer token,
|
||||
and an HTTP client whose timeout is the configured Distributor timeout. It
|
||||
passes no custom retry options, so the pinned client's defaults apply: three
|
||||
attempts, 100 ms base delay, and one-second maximum delay.
|
||||
|
||||
For each notification, Weatherreporter calls `UploadFiles` with:
|
||||
|
||||
- the rendered pipeline ID;
|
||||
- the rendered bundle ID as the source manifest ID;
|
||||
- the report or batch generation time as `Created`;
|
||||
- the published-output-to-bundle-path mappings described in the
|
||||
[bundle mapping contract](pkg-bundle.md); and
|
||||
- a rendered idempotency key.
|
||||
|
||||
It leaves bundle validation enabled. `UploadFiles` creates the temporary source
|
||||
bundle and sends it as a gzip-compressed tar archive; Weatherreporter does not
|
||||
call `UploadBundle` or submit prebuilt bundle roots.
|
||||
|
||||
## Retry, Conflict, And Status
|
||||
|
||||
The pinned upload client retries only `503 Service Unavailable` and retryable
|
||||
network failures. It does not retry successful `202` responses or other HTTP
|
||||
errors. Because every Weatherreporter request supplies an idempotency key, a
|
||||
retry keeps the same upload identity.
|
||||
|
||||
The client decodes the accepted upload result (`run_id`, `status`) and the run
|
||||
status record. A `409` response is an upstream idempotency conflict; the
|
||||
adapter translates it to its own conflict error without exposing the token.
|
||||
|
||||
The adapter then calls `Status` for the accepted run. A terminal `failed`
|
||||
status is a notification failure. A status lookup failure or a timeout before a
|
||||
terminal status remains attached to the otherwise accepted upload as diagnostic
|
||||
status information. Polling cadence, final failure handling, and redaction are
|
||||
internal behavior documented in the
|
||||
[Distributor adapter](../../internal/distributor-adapter.md) and
|
||||
[application orchestration](../../internal/app-orchestration.md).
|
||||
|
||||
## Compatibility Reference
|
||||
|
||||
The upstream package workflow is documented in
|
||||
`docs/consumers/pkg-upload.md` in the Distributor repository. Weatherreporter
|
||||
uses only the client construction, `UploadFiles`, retry/conflict behavior, and
|
||||
`Status` operations described here.
|
||||
34
docs/integrations/promptkit.md
Normal file
34
docs/integrations/promptkit.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Promptkit Integration
|
||||
|
||||
Weatherreporter uses Promptkit for all generated-text reports. The four logical prompts are `weather.daily_generated_text`, `weather.today_generated_text`, `weather.tomorrow_generated_text`, and `weather.hourly_generated_text`, each at version `2.0.0`. Their prompt assets, generated-text JSON Schemas, and Weatherreporter profile catalog are embedded by `internal/promptassets`.
|
||||
|
||||
## Logical Profile Catalog
|
||||
|
||||
Prompt definitions select a stable Weatherreporter profile ID. The embedded definitions currently use Promptkit's `openrouter` backend:
|
||||
|
||||
| Profile ID | Model | Reasoning effort | Timeout | Service tier | Default reports |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `weather-light` | `deepseek/deepseek-v4-flash` | Provider default | 180 seconds | `flex` | Hourly |
|
||||
| `weather-balanced` | `~google/gemini-flash-latest` | `high` | 240 seconds | `flex` | Daily, Today, Tomorrow |
|
||||
| `weather-deep` | `~anthropic/claude-sonnet-latest` | `high` | 240 seconds | `flex` | None |
|
||||
|
||||
The `~` prefix is part of each OpenRouter rolling-alias model ID. The embedded profiles intentionally omit endpoints, credentials, temperature, `top_p`, and output-token limits.
|
||||
|
||||
## Selection And Active Execution
|
||||
|
||||
Before weather collection, Weatherreporter validates the exact prompt version, output contract, and selected profile. A nonblank `promptkit.profile` selects one profile ID for every report in the command; otherwise the prompt's declared default selects it. Promptkit resolves the selected definition in this order:
|
||||
|
||||
1. explicit in-memory profiles used by an embedding consumer or test;
|
||||
2. the configured `profile_file` or `profile_dir`;
|
||||
3. Weatherreporter's embedded fallback profiles; and
|
||||
4. Promptkit's built-in catalog.
|
||||
|
||||
A source falls through only when the selected ID is absent. Each source supplies a complete definition, so profile fields are not merged. A malformed matching operator definition is an error and does not fall back.
|
||||
|
||||
Profiles that require a direct API key are unsupported; a profile that reports `APIKeyEnv` requires a nonblank value in that environment variable. Active results retain the selected logical profile ID and resolved backend and model. Ordinary errors, summaries, logs, and outputs exclude endpoints, credentials, rendered messages, schemas, request bodies, response bodies, and complete parameter maps.
|
||||
|
||||
Promptkit receives the YAML data package as an inline input and returns structured JSON that Weatherreporter validates before rendering its own Markdown template. Safe active provenance remains in memory. Content-rich diagnostics are opt-in through `--llm-debug-dir`; see [operations](../operations.md) for retention and permissions.
|
||||
|
||||
The generated-text schemas require `summary`, `forecast_discussion`, and `precipitation_timing`, and reject additional properties. Prompts return an empty string for `precipitation_timing` when the deterministic package contains no precipitation windows.
|
||||
|
||||
Prompt/profile configuration and the maintained local override example are owned by the [configuration reference](../config.md). Adapter construction and mapping are documented in the [Promptkit adapter internals](../internal/promptkit-adapter.md).
|
||||
@@ -1,114 +0,0 @@
|
||||
# weatherreporter Subprocess Integration
|
||||
|
||||
## Purpose
|
||||
|
||||
This document defines the supported subprocess contract for weatherreporter invoking Scriptorium through the public CLI.
|
||||
|
||||
This is a CLI contract, not an internal Go package integration.
|
||||
|
||||
## Supported Commands
|
||||
|
||||
weatherreporter should invoke:
|
||||
|
||||
- `scriptorium run`
|
||||
- `scriptorium render`
|
||||
|
||||
Use `run` for generation.
|
||||
|
||||
Use `render` for preflight/debug output without LLM execution.
|
||||
|
||||
## Recommended Invocation Shapes
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
scriptorium run \
|
||||
--prompt <prompt_id> \
|
||||
--input data_package=<path> \
|
||||
--out <artifact_path>
|
||||
```
|
||||
|
||||
Render:
|
||||
|
||||
```bash
|
||||
scriptorium render \
|
||||
--prompt <prompt_id> \
|
||||
--input data_package=<path> \
|
||||
--format json
|
||||
```
|
||||
|
||||
weatherreporter may add:
|
||||
|
||||
- `--config <path>`
|
||||
- `--profile <profile_id>`
|
||||
- repeatable `--input name=path`
|
||||
- repeatable `--var name=value`
|
||||
- runtime overrides when explicitly needed (`--model`, `--llm-base-url`, `--timeout`, etc.)
|
||||
|
||||
## Config And Directory Behavior
|
||||
|
||||
weatherreporter can rely on resolved app config or pass explicit paths.
|
||||
|
||||
- default config search order:
|
||||
1. `/usr/local/etc/scriptorium/config.yml`
|
||||
2. `/etc/scriptorium/config.yml`
|
||||
- explicit `--config` requires file existence and valid syntax
|
||||
- CLI flags override config values
|
||||
|
||||
## Profile Selection
|
||||
|
||||
Profile selection follows runner behavior:
|
||||
|
||||
1. explicit `--profile`
|
||||
2. prompt `default_profile`
|
||||
3. error if neither is available
|
||||
|
||||
weatherreporter should treat prompt/profile IDs as deployment configuration, not hardcoded logic.
|
||||
|
||||
## Input And Variable Contract
|
||||
|
||||
- Inputs use repeated `--input name=path`.
|
||||
- Input names must match prompt definition input names.
|
||||
- Variables use repeated `--var name=value` for small metadata values.
|
||||
- Prefer file inputs for large content.
|
||||
|
||||
## Environment Contract
|
||||
|
||||
- Pass through required API-key environment variables referenced by `api_key_env`.
|
||||
- Never pass raw API keys via CLI arguments.
|
||||
- Keep subprocess environment scoped to required variables.
|
||||
|
||||
## Output And Error Handling
|
||||
|
||||
`run`:
|
||||
|
||||
- stdout: artifact body unless `--out` is used
|
||||
- `--out`: writes artifact to file
|
||||
- stderr: success summary and errors
|
||||
|
||||
`render`:
|
||||
|
||||
- stdout: prepared-run output unless `--out` is used
|
||||
- stderr: errors
|
||||
|
||||
weatherreporter should capture stdout and stderr separately.
|
||||
|
||||
## Exit Status Contract
|
||||
|
||||
- `0`: success
|
||||
- `1`: parse/config/load/render/generation/IO/runtime error
|
||||
- `2`: run completed but validation failed
|
||||
|
||||
A `run` exit code `2` can still produce output (stdout or `--out`).
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Treat generated artifacts and stderr logs as potentially sensitive.
|
||||
- Avoid logging full rendered prompts by default in production contexts.
|
||||
- Use controlled output paths and access controls for persisted artifacts.
|
||||
|
||||
## Canonical References
|
||||
|
||||
- CLI behavior: [CLI reference](https://gitea.maximumdirect.net/eric/scriptorium/docs/cli.md)
|
||||
- Config behavior: [Configuration reference](https://gitea.maximumdirect.net/eric/scriptorium/docs/config.md)
|
||||
- Operations and failure handling: [Operations guide](https://gitea.maximumdirect.net/eric/scriptorium/docs/operations.md), [Troubleshooting](https://gitea.maximumdirect.net/eric/scriptorium/docs/troubleshooting.md)
|
||||
@@ -1,354 +1,147 @@
|
||||
# weatherapi External API
|
||||
# Weather API Integration
|
||||
|
||||
This document describes the public HTTP API exposed by `weatherapi` for external consumers.
|
||||
Weatherreporter fetches normalized weather inputs from a configured Weather API
|
||||
base URL. This guide defines the HTTP contract the service must satisfy; it is
|
||||
not a general Weather API reference. Configuration values are defined in the
|
||||
[configuration reference](../config.md). Normalization and collection behavior
|
||||
are documented in [Weather data internals](../internal/weather-data.md) and
|
||||
[Collection internals](../internal/collect.md).
|
||||
|
||||
## Base URL
|
||||
## Base URL And Requests
|
||||
|
||||
The service is typically served at your deployment host, for example:
|
||||
`weather_api.base_url` must be an absolute URL. Weatherreporter joins each
|
||||
endpoint path to the configured base URL path, so a service hosted under a path
|
||||
prefix must keep that prefix available. Requests use `GET` and carry the
|
||||
configured timeout on every HTTP attempt.
|
||||
|
||||
- `https://weather.api.rakestrawhome.com`
|
||||
Every request sends `format` and, except where noted below, `units`. The
|
||||
configured format must be `json`.
|
||||
|
||||
All paths below are relative to the service root.
|
||||
Before retrieving sources, Weatherreporter warms up
|
||||
`/conditions/current` with the same `format`, `units`, and `precision` query
|
||||
parameters used for current conditions. The warmup only requires a readable
|
||||
2xx response; its body is not decoded. Failure after its internal retry budget
|
||||
stops the fetch before source requests begin.
|
||||
|
||||
## Common Conventions
|
||||
## Endpoints And Query Parameters
|
||||
|
||||
### Response envelope
|
||||
The adapter makes one source request for each endpoint after a successful
|
||||
warmup, subject to retry on transient failures.
|
||||
|
||||
All endpoints return a top-level envelope:
|
||||
| Source | Endpoint | Query parameters | Availability |
|
||||
| --- | --- | --- | --- |
|
||||
| Observations | `/observations` | `format`, `units`, `precision` | Optional |
|
||||
| Current conditions | `/conditions/current` | `format`, `units`, `precision` | Optional |
|
||||
| Hourly forecast | `/forecast/hourly` | `format`, `units`, `precision`, `tz` | Required |
|
||||
| Narrative forecast | `/forecast/narrative` | `format`, `units`, `precision`, `tz` | Optional |
|
||||
| Active alerts | `/alerts/active` | `format`, `units` | Optional; `data: null` means checked with no active alerts |
|
||||
| Forecast discussion | `/discussion` | `format`, `units`, `tz` | Optional |
|
||||
| Weather story | `/weatherstories/latest` | `format` | Optional |
|
||||
| SPC convective outlooks | `/outlooks/convective` | `format`, `tz` | Optional; non-null empty lists are checked empty data |
|
||||
|
||||
- `data`: endpoint payload or `null` when no current/latest resource is available.
|
||||
`precision` comes from `weather_api.precision`; `tz` comes from
|
||||
`weather_api.timezone`. Weatherreporter does not call day-slice forecast or
|
||||
discussion-subsection endpoints.
|
||||
|
||||
JSON example:
|
||||
## Response Envelope
|
||||
|
||||
Each endpoint response must be JSON with a top-level `data` member:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {"...": "..."}
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
### Output format
|
||||
|
||||
Supported via `format` query parameter (case-insensitive):
|
||||
|
||||
- `json` (default)
|
||||
- `xml`
|
||||
- `text`
|
||||
|
||||
### Units
|
||||
|
||||
Supported via `units` query parameter (case-insensitive):
|
||||
|
||||
- `metric` (default)
|
||||
- `us`
|
||||
|
||||
Endpoints that include unit-based numeric fields return either metric or US field variants depending on this value.
|
||||
|
||||
### Precision
|
||||
|
||||
Supported where documented via `precision` query parameter:
|
||||
|
||||
- integer range: `0` to `2`
|
||||
- controls decimal rounding of numeric output fields
|
||||
|
||||
### Timezone (`tz` / `TZ`)
|
||||
|
||||
Supported where documented:
|
||||
|
||||
- accepted values include:
|
||||
- IANA timezone names (example: `America/Chicago`)
|
||||
- common US abbreviations (example: `CDT`, `EST`)
|
||||
- UTC offsets in `±H`, `±HH`, or `±HH:MM` (example: `-5`, `+09:30`)
|
||||
- aliases including `Chicago` and `Stl`
|
||||
- `tz` and `TZ` are treated equivalently
|
||||
- if both are provided, they must match exactly or the request fails
|
||||
|
||||
Timezone affects datetime rendering and day-slice filtering for `/today` and `/tomorrow` forecast routes.
|
||||
|
||||
### Query validation
|
||||
|
||||
- Unknown query parameters are rejected with `400 Bad Request`.
|
||||
- Invalid parameter values are rejected with `400 Bad Request`.
|
||||
|
||||
Error response body follows the service error envelope; exact fields may vary by error type.
|
||||
|
||||
## Endpoints
|
||||
|
||||
## `GET /observations`
|
||||
|
||||
Returns the latest weather observation.
|
||||
|
||||
Query parameters:
|
||||
|
||||
- `units`: `metric` | `us`
|
||||
- `format`: `json` | `xml` | `text`
|
||||
- `precision`: `0..2`
|
||||
|
||||
Response `data` fields:
|
||||
|
||||
- `stationId` (string, optional)
|
||||
- `stationName` (string, optional)
|
||||
- `timestamp` (RFC3339 datetime, required)
|
||||
- `conditionCode` (integer WMO code, required)
|
||||
- `isDay` (boolean, optional)
|
||||
- `textDescription` (string, optional)
|
||||
- Metric mode fields:
|
||||
- `temperatureC`, `dewpointC`, `windSpeedKmh`, `windGustKmh`, `barometricPressurePa`, `visibilityMeters`, `relativeHumidityPercent`, `apparentTemperatureC` (number, optional)
|
||||
- `windDirectionDegrees` (number, optional)
|
||||
- US mode fields:
|
||||
- `temperatureF`, `dewpointF`, `windSpeedMph`, `windGustMph`, `barometricPressureInHg`, `visibilityMiles`, `relativeHumidityPercent`, `apparentTemperatureF` (number, optional)
|
||||
- `windDirectionDegrees` (number, optional)
|
||||
- `presentWeather` (array, optional)
|
||||
|
||||
## `GET /alerts/active`
|
||||
|
||||
Returns the latest active alert run.
|
||||
|
||||
Query parameters:
|
||||
|
||||
- `units`: `metric` | `us` (accepted; does not materially alter alert payload)
|
||||
- `format`: `json` | `xml` | `text`
|
||||
|
||||
Response `data` fields:
|
||||
|
||||
- Weather alert run object from canonical model (includes run metadata and active alerts list).
|
||||
|
||||
## `GET /conditions/current`
|
||||
|
||||
Returns current conditions synthesized from latest observation/forecast data.
|
||||
|
||||
Query parameters:
|
||||
|
||||
- `units`: `metric` | `us`
|
||||
- `format`: `json` | `xml` | `text`
|
||||
- `precision`: `0..2`
|
||||
|
||||
Response `data` fields:
|
||||
|
||||
- Common:
|
||||
- `conditionText` (string, optional)
|
||||
- `isDay` (boolean, optional)
|
||||
- `relativeHumidityPercent` (number, optional)
|
||||
- `windDirectionDegrees` (number, optional)
|
||||
- Metric mode:
|
||||
- `temperatureC`, `apparentTemperatureC`, `dewpointC`, `windSpeedKmh` (number, optional)
|
||||
- US mode:
|
||||
- `temperatureF`, `apparentTemperatureF`, `dewpointF`, `windSpeedMph` (number, optional)
|
||||
|
||||
## Forecast endpoints
|
||||
|
||||
- `GET /forecast/hourly`
|
||||
- `GET /forecast/hourly/today`
|
||||
- `GET /forecast/hourly/tomorrow`
|
||||
- `GET /forecast/narrative`
|
||||
- `GET /forecast/narrative/today`
|
||||
- `GET /forecast/narrative/tomorrow`
|
||||
|
||||
Query parameters:
|
||||
|
||||
- `units`: `metric` | `us`
|
||||
- `format`: `json` | `xml` | `text`
|
||||
- `precision`: `0..2`
|
||||
- `tz` or `TZ`: timezone selector
|
||||
|
||||
Day-slice routes:
|
||||
|
||||
- `/today` returns periods with `period.startTime` in the current calendar day for the resolved timezone.
|
||||
- `/tomorrow` returns periods with `period.startTime` in the next calendar day for the resolved timezone.
|
||||
|
||||
Response `data` fields:
|
||||
|
||||
- Run-level:
|
||||
- `locationId` (string, optional)
|
||||
- `locationName` (string, optional)
|
||||
- `issuedAt` (RFC3339 datetime, required)
|
||||
- `updatedAt` (RFC3339 datetime, optional)
|
||||
- `product` (string, required; e.g. `hourly`, `narrative`)
|
||||
- `latitude`, `longitude` (number, optional)
|
||||
- Metric mode: `elevationMeters` (number, optional)
|
||||
- US mode: `elevationFeet` (number, optional)
|
||||
- `periods` (array, required)
|
||||
- Period fields:
|
||||
- `startTime`, `endTime` (RFC3339 datetime, required)
|
||||
- `name` (string, optional)
|
||||
- `isDay` (boolean, optional)
|
||||
- `conditionCode` (integer WMO code, optional)
|
||||
- `textDescription` (string, optional)
|
||||
- Metric mode (optional):
|
||||
- `temperatureC`, `temperatureCMin`, `temperatureCMax`, `dewpointC`, `windSpeedKmh`, `windGustKmh`, `barometricPressurePa`, `visibilityMeters`, `apparentTemperatureC`, `cloudCoverPercent`, `probabilityOfPrecipitationPercent`, `precipitationAmountMm`, `snowfallDepthMM`, `uvIndex`, `relativeHumidityPercent`, `windDirectionDegrees`
|
||||
- US mode (optional):
|
||||
- `temperatureF`, `temperatureFMin`, `temperatureFMax`, `dewpointF`, `windSpeedMph`, `windGustMph`, `barometricPressureInHg`, `visibilityMiles`, `apparentTemperatureF`, `cloudCoverPercent`, `probabilityOfPrecipitationPercent`, `precipitationAmountIn`, `snowfallDepthIn`, `uvIndex`, `relativeHumidityPercent`, `windDirectionDegrees`
|
||||
|
||||
Notes:
|
||||
|
||||
- Narrative periods may omit `conditionCode`.
|
||||
- Text format uses forecast-specific templates (`hourly` and `narrative`).
|
||||
|
||||
## Discussion endpoints
|
||||
|
||||
- `GET /discussion`
|
||||
- `GET /discussion/key-messages`
|
||||
- `GET /discussion/short-term`
|
||||
- `GET /discussion/long-term`
|
||||
|
||||
Query parameters:
|
||||
|
||||
- `units`: `metric` | `us` (accepted; does not materially alter discussion payload)
|
||||
- `format`: `json` | `xml` | `text`
|
||||
- `tz` or `TZ`: timezone selector
|
||||
|
||||
Response `data` fields:
|
||||
|
||||
- `/discussion`:
|
||||
- `officeId` (string, optional)
|
||||
- `officeName` (string, optional)
|
||||
- `product` (string, required)
|
||||
- `issuedAt` (RFC3339 datetime, required)
|
||||
- `updatedAt` (RFC3339 datetime, optional)
|
||||
- `keyMessages` (array of string)
|
||||
- `shortTerm` (object, optional)
|
||||
- `longTerm` (object, optional)
|
||||
- `/discussion/key-messages`:
|
||||
- `officeId`, `officeName`, `product`, `issuedAt`, `updatedAt`
|
||||
- `keyMessages` (array of string)
|
||||
- `/discussion/short-term`:
|
||||
- `officeId`, `officeName`, `product`, `issuedAt`, `updatedAt`
|
||||
- `shortTerm` (object, optional)
|
||||
- `/discussion/long-term`:
|
||||
- `officeId`, `officeName`, `product`, `issuedAt`, `updatedAt`
|
||||
- `longTerm` (object, optional)
|
||||
|
||||
Discussion section object fields:
|
||||
|
||||
- `title` (string, optional)
|
||||
- `narrative` (string, optional)
|
||||
- `issuedAt` (RFC3339 datetime, optional)
|
||||
|
||||
## Examples
|
||||
|
||||
### Observation (JSON, metric)
|
||||
|
||||
```http
|
||||
GET /observations?format=json&units=metric&precision=1
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"stationId": "KSTL",
|
||||
"timestamp": "2026-05-29T14:00:00Z",
|
||||
"conditionCode": 3,
|
||||
"isDay": true,
|
||||
"textDescription": "Partly cloudy",
|
||||
"temperatureC": 24.4,
|
||||
"windSpeedKmh": 17.2,
|
||||
"relativeHumidityPercent": 56.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Alerts (JSON)
|
||||
|
||||
```http
|
||||
GET /alerts/active?format=json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"asOf": "2026-05-29T14:00:00Z",
|
||||
"alerts": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Current conditions (JSON, US)
|
||||
|
||||
```http
|
||||
GET /conditions/current?format=json&units=us&precision=1
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"conditionText": "Partly cloudy",
|
||||
"isDay": true,
|
||||
"temperatureF": 75.9,
|
||||
"apparentTemperatureF": 76.1,
|
||||
"windSpeedMph": 10.7,
|
||||
"relativeHumidityPercent": 56.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Forecast narrative (JSON, optional `conditionCode`)
|
||||
|
||||
```http
|
||||
GET /forecast/narrative?format=json&units=metric&precision=1&tz=America/Chicago
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"locationId": "nws-lsx-grid-90-74",
|
||||
"issuedAt": "2026-05-29T10:30:00-05:00",
|
||||
"product": "narrative",
|
||||
"periods": [
|
||||
{
|
||||
"startTime": "2026-05-29T13:00:00-05:00",
|
||||
"endTime": "2026-05-29T19:00:00-05:00",
|
||||
"name": "Today",
|
||||
"isDay": true,
|
||||
"textDescription": "Partly sunny, with a high near 81.",
|
||||
"temperatureC": 27.2,
|
||||
"windSpeedKmh": 18.0,
|
||||
"probabilityOfPrecipitationPercent": 10.0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Forecast hourly today (text)
|
||||
|
||||
```http
|
||||
GET /forecast/hourly/today?format=text&units=us&precision=1&tz=CDT
|
||||
```
|
||||
|
||||
```text
|
||||
<plain text forecast output>
|
||||
```
|
||||
|
||||
### Discussion key messages (JSON)
|
||||
|
||||
```http
|
||||
GET /discussion/key-messages?format=json&tz=Chicago
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"officeId": "LSX",
|
||||
"product": "discussion",
|
||||
"issuedAt": "2026-05-29T09:25:00-05:00",
|
||||
"keyMessages": [
|
||||
"Scattered showers possible this evening.",
|
||||
"Warmer temperatures this weekend."
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Invalid timezone error example
|
||||
|
||||
```http
|
||||
GET /forecast/narrative?tz=not-a-timezone
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "invalid_parameter",
|
||||
"message": "tz must be a valid timezone"
|
||||
}
|
||||
}
|
||||
```
|
||||
An absent `data` member is treated as a missing source. For ordinary sources,
|
||||
`data: null` is also missing. The active-alert exception is listed above: its
|
||||
explicit `null` payload represents an empty alert result.
|
||||
|
||||
Hourly forecast data must be present and contain at least one `period`; a
|
||||
missing, malformed, or empty hourly product fails collection. The remaining
|
||||
sources follow the configured missing-source policy. Under `error`, collection
|
||||
fails; under `warn`, the source is omitted and an inspectable warning is
|
||||
recorded; under `none`, the source is omitted without a warning. A per-source
|
||||
policy overrides the default. See [Configuration](../config.md) for policy
|
||||
settings and [Weather data internals](../internal/weather-data.md) for recorded
|
||||
source metadata.
|
||||
|
||||
Malformed top-level JSON envelopes and HTTP failures are direct request errors.
|
||||
Malformed `data` for an optional source follows its missing-source policy.
|
||||
|
||||
## Payload Fields Used
|
||||
|
||||
Weatherreporter decodes only the fields below; additional upstream fields are
|
||||
ignored. Timestamps must be JSON values accepted by Go's `time.Time` decoder.
|
||||
|
||||
### Observations And Current Conditions
|
||||
|
||||
`/observations` uses `stationId`, `stationName`, `timestamp`, `conditionCode`,
|
||||
`isDay`, `textDescription`, `temperatureC`, `temperatureF`, `dewpointC`,
|
||||
`dewpointF`, `windSpeedKmh`, `windSpeedMph`, `windGustKmh`, `windGustMph`,
|
||||
`windDirectionDegrees`, `barometricPressurePa`, `barometricPressureInHg`,
|
||||
`visibilityMeters`, `visibilityMiles`, `relativeHumidityPercent`,
|
||||
`apparentTemperatureC`, `apparentTemperatureF`, and `presentWeather`.
|
||||
|
||||
`/conditions/current` uses `conditionText`, `isDay`,
|
||||
`relativeHumidityPercent`, `windDirectionDegrees`, `temperatureC`,
|
||||
`temperatureF`, `apparentTemperatureC`, `apparentTemperatureF`, `dewpointC`,
|
||||
`dewpointF`, `windSpeedKmh`, and `windSpeedMph`.
|
||||
|
||||
### Hourly And Narrative Forecasts
|
||||
|
||||
Both forecast endpoints use run-level `locationId`, `locationName`, `issuedAt`,
|
||||
`updatedAt`, `product`, `latitude`, `longitude`, `elevationMeters`,
|
||||
`elevationFeet`, and `periods`.
|
||||
|
||||
Each `periods` item uses `startTime`, `endTime`, `name`, `isDay`,
|
||||
`conditionCode`, `textDescription`, `temperatureC`, `temperatureF`,
|
||||
`temperatureCMin`, `temperatureFMin`, `temperatureCMax`, `temperatureFMax`,
|
||||
`dewpointC`, `dewpointF`, `windSpeedKmh`, `windSpeedMph`, `windGustKmh`,
|
||||
`windGustMph`, `windDirectionDegrees`, `barometricPressurePa`,
|
||||
`barometricPressureInHg`, `visibilityMeters`, `visibilityMiles`,
|
||||
`apparentTemperatureC`, `apparentTemperatureF`, `cloudCoverPercent`,
|
||||
`probabilityOfPrecipitationPercent`, `precipitationAmountMm`,
|
||||
`precipitationAmountIn`, `snowfallDepthMM`, `snowfallDepthIn`, `uvIndex`, and
|
||||
`relativeHumidityPercent`.
|
||||
|
||||
### Alerts, Discussion, And Weather Story
|
||||
|
||||
`/alerts/active` uses the `asOf` timestamp and keeps each item in `alerts` as
|
||||
an alert payload. Weatherreporter does not require a separate alert-item schema
|
||||
at this integration boundary.
|
||||
|
||||
`/discussion` uses `officeId`, `officeName`, `product`, `issuedAt`,
|
||||
`updatedAt`, `keyMessages`, and the `shortTerm` and `longTerm` sections. Each
|
||||
section uses `qualifier`, `text`, and `issuedAt`.
|
||||
|
||||
`/weatherstories/latest` uses `officeId`, `startTime`, `endTime`, `updatedAt`,
|
||||
`title`, `description`, `altText`, `priority`, `order`, and `downloadUrl`.
|
||||
|
||||
### SPC Convective Outlooks
|
||||
|
||||
`/outlooks/convective` uses run-level `locationId`, `locationName`, `asOf`,
|
||||
`issuedAt`, `updatedAt`, `product`, `outlooks`, and `discussions`.
|
||||
|
||||
Each outlook uses `id`, `provider`, `product`, `day`, `outlookType`, `label`,
|
||||
`labelText`, `forecaster`, `severityRank`, `validFrom`, `validTo`, `issuedAt`,
|
||||
`expiresAt`, `sourceUrl`, `imageUrl`, `containsLocation`, and GeoJSON
|
||||
`geometry`. Each discussion uses `day`, `headline`, `summary`, `discussion`,
|
||||
and `updatedAt`.
|
||||
|
||||
## Timeouts, Retries, And Failures
|
||||
|
||||
The configured Weather API timeout applies to each warmup and source HTTP
|
||||
attempt. Weatherreporter retries transient transport and response-read failures
|
||||
and these response statuses: `408`, `429`, `500`, `502`, `503`, and `504`.
|
||||
It does not retry other HTTP statuses, malformed envelopes, missing data, or
|
||||
payload decoding failures. A canceled context also stops an in-progress retry
|
||||
delay.
|
||||
|
||||
The adapter reads at most 10 MiB from one response body. A non-2xx response,
|
||||
request construction failure, read failure, or decode failure includes endpoint
|
||||
context in its error.
|
||||
|
||||
Retry counts and delays are adapter behavior rather than Weather API request
|
||||
parameters. Do not depend on a particular attempt count when implementing the
|
||||
service.
|
||||
|
||||
27
docs/internal/app-orchestration.md
Normal file
27
docs/internal/app-orchestration.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Application Orchestration Internals
|
||||
|
||||
`internal/app` owns stateless report generation, batch execution, atomic output publication, and notification coordination after `internal/cli` has parsed arguments and loaded configuration. The user contract is owned by the [CLI reference](../cli.md) and [operations guide](../operations.md).
|
||||
|
||||
## Single-Report Flow
|
||||
|
||||
`GenerateDetailed` resolves the requested report and output destination before initializing an optional explicit debug writer. An explicit output file wins; otherwise the configured output directory is used, falling back to the captured working directory. It validates the exact Promptkit prompt and selected profile before collecting weather data. The resolved profile, backend, and model are carried in the active result.
|
||||
|
||||
The workflow builds facts, a module snapshot, briefing metadata, and the YAML prompt package in memory. It executes Promptkit, validates the returned generated text, builds a render context, and renders Markdown. `fileutil` atomically writes the completed Markdown to the selected output path. Only after that write succeeds does single-report notification run.
|
||||
|
||||
Failures return an active partial result with safe identity, profile, warning, validation, debug, and output information when available. After rendering and immediately before publication, the workflow checks for cancellation or deadline expiry. Any failure before publication leaves an existing destination unchanged. A notification failure retains the newly published output.
|
||||
|
||||
## Batches
|
||||
|
||||
`RunBatchDetailed` selects an explicit output directory first, otherwise the configured directory and then the captured working directory. It does this before creating at most one explicit debug writer or validating prompt and profile candidates for the selected batch. It collects once, calculates the data-dependent plan, then validates and retains the final output path for every planned report before invoking the same generation core sequentially.
|
||||
|
||||
Each item has an independent result. A failed item does not stop later items; successful items retain their published output paths. Per-report notification is suppressed during a batch. Batch notification runs only after every planned report has published successfully. It is skipped when any item failed. Batch result counters count report items only; a batch notification failure is represented by the top-level notification result and still produces a failed batch outcome.
|
||||
|
||||
## Boundaries And Verification
|
||||
|
||||
The package does not parse flags, load YAML, implement transport, construct provider SDKs, or define report-period policy. Prompt, profile, weather, and Distributor implementations remain behind project-owned contracts.
|
||||
|
||||
Focused checks:
|
||||
|
||||
```sh
|
||||
go test ./internal/app ./internal/collect
|
||||
```
|
||||
@@ -1,84 +1,69 @@
|
||||
# Briefing Internals
|
||||
# Module Builder Internals
|
||||
|
||||
This document describes the implemented briefing package boundary.
|
||||
`internal/briefing` builds typed module outputs from resolved report context,
|
||||
collected facts, and derived facts. It owns the module registry, including
|
||||
module support, fact requirements, option types, missing-data policy, builders,
|
||||
and prompt-export hooks. It does not collect data, derive periods, write a
|
||||
snapshot, construct YAML, invoke Promptkit, or render a report.
|
||||
|
||||
## Purpose
|
||||
## Registry and construction
|
||||
|
||||
`internal/briefing` builds structured report-specific briefing packages from
|
||||
forecast summaries and report metadata. The package currently implements Daily
|
||||
Today, Daily Tomorrow, 3-Day Outlook, Weekend Outlook, and Storm Report
|
||||
briefing content.
|
||||
Every `ModuleDefinition` declares an ID, stanza name, default option value,
|
||||
required collected and derived facts, supported report IDs, missing-data
|
||||
behavior, duplicate policy, builder, and optional prompt exporter.
|
||||
|
||||
## Inputs and Outputs
|
||||
`BuildModule` first verifies the requested module, report compatibility, and
|
||||
option shape. It then applies the declared missing-data behavior:
|
||||
|
||||
Inputs:
|
||||
- `omit` returns no output for unavailable optional facts;
|
||||
- `error` returns the missing fact requirements; and
|
||||
- `empty` allows the builder to emit an explicit checked-empty value.
|
||||
|
||||
- resolved report definition and valid period
|
||||
- forecast bundle
|
||||
- derived forecast summary or summaries
|
||||
- configured units and timezone
|
||||
Unsupported `warn` behavior, missing builders, duplicate registry IDs or
|
||||
stanza names, output ID or stanza mismatches, and exporter failures all return
|
||||
errors with module context. A successful builder gets a pass-through prompt
|
||||
value unless its definition supplies an exporter.
|
||||
|
||||
Output:
|
||||
## Built value families
|
||||
|
||||
- `briefing.Package` JSON containing common metadata and report-specific
|
||||
briefing content
|
||||
Source-oriented builders shape report metadata, current conditions, narrative
|
||||
and hourly forecasts, alert digest, SPC outlooks and discussion, area forecast
|
||||
discussion, and weather story. Derived builders shape daily and daypart
|
||||
summaries, precipitation timing, outdoor windows, and the report-specific
|
||||
Daily, Today, and Tomorrow planning values.
|
||||
|
||||
## Boundaries
|
||||
The module registry preserves rich values for templates and snapshots while
|
||||
curating prompt exports where needed. In particular, source warnings are a
|
||||
metadata summary, checked-empty alerts and SPC outlooks remain distinct from
|
||||
missing sources, and prompt-safe SPC values omit geometry and other
|
||||
template-only or source details. The complete module composition is in
|
||||
[module internals](module.md); fact derivation is in [fact contracts](facts.md).
|
||||
|
||||
- Briefings are structured weather facts and context for later prompt input.
|
||||
- This package does not fetch weather data, compare prior snapshots, build
|
||||
`scriptorium` data packages, or render final report prose.
|
||||
`area_forecast_discussion` accepts an optional typed section filter. Planning
|
||||
modules are report-specific: `daily_planning` supports Daily,
|
||||
`today_planning` supports Today, and `tomorrow_planning` supports Tomorrow.
|
||||
|
||||
## Behavior
|
||||
## Missing data and boundaries
|
||||
|
||||
- Common metadata includes schema version, RunID, report ID, variant, prompt ID,
|
||||
generation time, units, timezone, valid period, source location, source
|
||||
provenance, hashes, and source warnings.
|
||||
- Daily content includes bottom-line inputs, daypart summaries, relevant alerts,
|
||||
outdoor window inputs, narrative periods, discussion context, and weather
|
||||
story context when available.
|
||||
- Daily Tomorrow also includes planning inputs for morning readiness,
|
||||
commute/school/workday concerns, and what may change overnight.
|
||||
- 3-Day content includes one summary per local day or partial day, with overall
|
||||
character, temperature range, precipitation, wind, risk, outdoor-window, and
|
||||
alert inputs, plus broader discussion and weather-story context when
|
||||
available.
|
||||
- Weekend content uses the same daily outlook summaries and adds planning
|
||||
inputs for best outdoor windows, worst weather windows, rain/storm timing,
|
||||
comfort concerns, and confidence or uncertainty context.
|
||||
- Storm content uses the explicit event window and includes event headline
|
||||
inputs, hazards, most-likely scenario inputs, reasonable worst-case inputs,
|
||||
confidence and uncertainty inputs, watch items, active alerts, relevant
|
||||
hourly and narrative forecast periods, and available discussion or weather
|
||||
story context.
|
||||
- Briefing JSON is written atomically by `briefing.Save`.
|
||||
Optional current conditions, narrative products, discussions, and weather
|
||||
stories may be omitted. Required derived modules fail when their declared facts
|
||||
are unavailable. Empty alert and outlook runs can still produce checked-empty
|
||||
modules. SPC discussion is omitted unless a retained categorical outlook meets
|
||||
the package's severity criterion and matching discussion text exists.
|
||||
|
||||
## Failure Behavior
|
||||
Effective units, timezone, and location context arrive in `ModuleContext` from
|
||||
configuration and resolved report metadata. Field defaults are owned by
|
||||
[configuration](../config.md), and prompt-package layout is owned by
|
||||
[prompt input](prompt-input.md).
|
||||
|
||||
- Daily briefing construction requires a Daily report definition and a derived
|
||||
daily forecast summary.
|
||||
- 3-Day briefing construction requires a 3-Day report definition and at least
|
||||
one derived daily summary in the outlook period.
|
||||
- Weekend briefing construction requires a Weekend report definition and at
|
||||
least one derived daily summary in the weekend period.
|
||||
- Storm briefing construction requires a Storm Report definition and a forecast
|
||||
bundle.
|
||||
- Save failures include path and operation context.
|
||||
## Verification and invariants
|
||||
|
||||
## Tests
|
||||
Focused tests cover source and derived values, registry validation, option
|
||||
handling, prompt exporters, support rules, and missing-data behavior:
|
||||
|
||||
Inspect:
|
||||
```sh
|
||||
go test ./internal/briefing
|
||||
```
|
||||
|
||||
- `internal/briefing/daily_test.go`
|
||||
- `internal/briefing/three_day_test.go`
|
||||
- `internal/briefing/weekend_test.go`
|
||||
- `internal/briefing/storm_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
- `internal/cli/root_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Weather facts come from normalized and derived source data.
|
||||
- Briefing output remains JSON-inspectable.
|
||||
- LLM prompt input packaging and `scriptorium` execution remain outside this
|
||||
boundary.
|
||||
Builders emit structured facts, never report prose. The app collects their
|
||||
outputs into an in-memory module snapshot for prompt input and rendering.
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
# Changes Internals
|
||||
|
||||
This document describes the implemented structured change comparison boundary.
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/changes` compares current and prior structured briefing snapshots and
|
||||
produces compact change records for prompt input data packages.
|
||||
|
||||
## Inputs and Outputs
|
||||
|
||||
Inputs:
|
||||
|
||||
- prior briefing package
|
||||
- current briefing package
|
||||
- configured Recent Changes thresholds
|
||||
|
||||
Output:
|
||||
|
||||
- ordered `changes.Change` records with type, message, previous value, and
|
||||
current value where useful
|
||||
|
||||
## Boundaries
|
||||
|
||||
- This package compares structured briefing data only.
|
||||
- It does not read state directly, render Markdown, invoke `scriptorium`, or
|
||||
compare generated report text.
|
||||
|
||||
## Config Fields Used
|
||||
|
||||
The app maps these config fields into comparison thresholds:
|
||||
|
||||
- `recent_change.temperature_degrees`
|
||||
- `recent_change.precip_probability_points`
|
||||
- `recent_change.wind_gust_miles_per_hour`
|
||||
- `recent_change.precip_timing_shift_minutes`
|
||||
|
||||
## Behavior
|
||||
|
||||
Daily, 3-Day, and Weekend comparison currently detect:
|
||||
|
||||
- temperature changes crossing configured thresholds
|
||||
- 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
|
||||
section in the data package. Daily Today and Daily Tomorrow are compatible for
|
||||
same-valid-date comparison through the report registry. 3-Day Outlook compares
|
||||
with prior 3-Day Outlook snapshots for the same valid local date. Weekend
|
||||
Outlook compares with prior Weekend Outlook snapshots for the same weekend
|
||||
window.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
Daily comparison requires both inputs to contain Daily briefing content. 3-Day
|
||||
comparison requires both inputs to contain 3-Day briefing content. Weekend
|
||||
comparison requires both inputs to contain Weekend briefing content.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/changes/daily_test.go`
|
||||
- `internal/changes/three_day_test.go`
|
||||
- `internal/changes/weekend_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Recent Changes are based on structured snapshots, not Markdown report text.
|
||||
- Comparison thresholds come from configuration.
|
||||
- The comparison output remains compact enough for prompt input.
|
||||
15
docs/internal/cli.md
Normal file
15
docs/internal/cli.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# CLI Internals
|
||||
|
||||
`internal/cli` parses terminal arguments, loads configuration, constructs app requests, and translates app results to bounded JSON summaries. The public contract belongs in the [CLI reference](../cli.md).
|
||||
|
||||
The root `--version` flag reports the build version supplied by `internal/buildinfo`. Tagged release builds replace its development default at link time.
|
||||
|
||||
For each `generate` or `run` action, `Runner` constructs one project-owned Promptkit executor after configuration loads. It captures an absolute working directory, resolves only a relative explicit output override against it, and passes the working directory, loaded configuration, resolved override, and any `--llm-debug-dir` request to the app. The raw configured fallback remains in the configuration for app-owned destination selection. `run` uses the same explicit-resolution rule for `--out-dir`.
|
||||
|
||||
The CLI dispatches only generation and batch actions. It has no persisted-run or inspection dispatch. Summaries include report identity, status, output path, effective profile/backend/model, source warnings, validation, requested debug path, and notification result when available. They intentionally exclude prompt input, raw generated text, render context, endpoints, credentials, and full Distributor payloads. A failed action with a partial result still emits its safe summary before its error is returned.
|
||||
|
||||
CLI code owns no report policy, weather collection, output publication, provider execution, or notification policy. Focused checks:
|
||||
|
||||
```sh
|
||||
go test ./internal/cli
|
||||
```
|
||||
43
docs/internal/collect.md
Normal file
43
docs/internal/collect.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Collection Internals
|
||||
|
||||
`internal/collect` is the application-facing boundary for collecting the
|
||||
normalized Weather API bundle. The external HTTP contract belongs in the
|
||||
[Weather API integration guide](../integrations/weatherapi.md); normalized data
|
||||
semantics belong in [weather-data internals](weather-data.md).
|
||||
|
||||
## Contract
|
||||
|
||||
`Run` accepts a `context.Context` and a `Request` containing effective
|
||||
`config.Config`. It constructs the Weather API adapter from that configuration,
|
||||
calls `FetchBundle`, and returns `Result{Bundle: *weatherdata.Bundle}`.
|
||||
|
||||
The package wraps adapter construction failures as weather-collection setup
|
||||
errors and fetch failures as bundle-collection errors. It does not retry,
|
||||
persist, select reports, derive facts, build modules, invoke Promptkit, or
|
||||
notify Distributor.
|
||||
|
||||
## Application Composition
|
||||
|
||||
`internal/app` owns the narrow `Collector` interface used by workflow tests;
|
||||
the production implementation delegates to `collect.Run`. Generation, batch
|
||||
execution, and explicit bundle fetching all use this boundary. Application
|
||||
orchestration rejects a nil collector result or a nil bundle before report work
|
||||
can continue.
|
||||
|
||||
Single-report generation and a batch each collect once. A batch passes the same
|
||||
normalized collection to planning and to every report it generates. Collection
|
||||
failure prevents later workflow work for that request.
|
||||
|
||||
## Boundaries And Invariants
|
||||
|
||||
Collection owns adapter creation and retrieval of one normalized bundle. It
|
||||
must not make report, period, batch, prompt, module, filesystem, or notification
|
||||
decisions.
|
||||
|
||||
- App-facing Weather API collection always passes through this package.
|
||||
- The returned value is normalized source data, not facts or prompt input.
|
||||
- Context cancellation is passed to the Weather API adapter.
|
||||
- Errors retain whether setup or fetching failed.
|
||||
|
||||
Focused tests are in `internal/collect/collect_test.go`; orchestration use is
|
||||
also covered by `internal/app/app_test.go`.
|
||||
63
docs/internal/distributor-adapter.md
Normal file
63
docs/internal/distributor-adapter.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Distributor Adapter Internals
|
||||
|
||||
`internal/adapters/distributor` translates a local delivery request into the
|
||||
Distributor Go client's upload and status calls, then returns a local delivery
|
||||
result. The external API, authentication, and idempotency contract is owned by
|
||||
the [Distributor API guide](../integrations/distributor/api.md) and
|
||||
[Distributor bundle guide](../integrations/distributor/pkg-bundle.md).
|
||||
|
||||
## Client construction
|
||||
|
||||
`Client` holds the endpoint, the name of the environment variable containing
|
||||
the token, an optional timeout, and an injectable upstream-client factory.
|
||||
`New` validates its configuration before creating the adapter. For each upload,
|
||||
the adapter reads the token from the configured environment variable and builds
|
||||
the upstream client with that endpoint, token, and an HTTP client whose timeout
|
||||
matches the local positive timeout.
|
||||
|
||||
The upstream client is an implementation dependency, not a source of
|
||||
application configuration: retry ownership, pipeline selection, path
|
||||
templates, and report rendering are defined by
|
||||
[configuration](../config.md) and [application orchestration](app-orchestration.md).
|
||||
|
||||
## Upload translation
|
||||
|
||||
Before calling the dependency, `Upload` validates the endpoint and token
|
||||
configuration plus the local pipeline ID, bundle ID, idempotency key, and every
|
||||
file's source and bundle paths. It maps the request as follows:
|
||||
|
||||
| Local request | Distributor client value |
|
||||
| --- | --- |
|
||||
| Pipeline ID | Upload pipeline identifier |
|
||||
| Bundle ID | Bundle identifier |
|
||||
| Idempotency key | Upload idempotency key |
|
||||
| File source and bundle paths | Bundle file entries |
|
||||
| Creation timestamp | Bundle creation time |
|
||||
|
||||
The call inherits the caller's context and applies the configured positive
|
||||
timeout. The adapter does not read report files, construct bundle layouts, or
|
||||
persist notification artifacts.
|
||||
|
||||
## Status and errors
|
||||
|
||||
An accepted upload is followed by one status request. When a timeout is
|
||||
configured, a nonterminal result is polled until `succeeded` or `failed`, or
|
||||
until the context ends. The translated `UploadResult` contains the run ID,
|
||||
status, and `RunStatus`, including pipeline ID, lifecycle timestamps, report,
|
||||
and remote error details.
|
||||
|
||||
Status lookup or polling errors are preserved in `UploadResult.StatusError` so
|
||||
the caller can report an accepted-but-unconfirmed delivery. A terminal failed
|
||||
run returns that result and an error. Upload failures return no result. Upstream
|
||||
idempotency conflicts become the local `IdempotencyConflictError`, which adds
|
||||
endpoint, pipeline, bundle, idempotency, and file-path context while redacting
|
||||
the token.
|
||||
|
||||
## Verification
|
||||
|
||||
Focused tests cover configuration validation, request mapping, timeouts and
|
||||
polling, status translation, conflict handling, and token redaction:
|
||||
|
||||
```sh
|
||||
go test ./internal/adapters/distributor
|
||||
```
|
||||
64
docs/internal/facts.md
Normal file
64
docs/internal/facts.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Fact Contracts Internals
|
||||
|
||||
`internal/facts` is the deterministic boundary between a collected weather
|
||||
bundle and report-scoped facts. It preserves normalized source values and then
|
||||
selects and summarizes the values needed for one resolved report. Provider
|
||||
transport and normalized bundle semantics belong to
|
||||
[weather-data internals](weather-data.md); report identity and valid-period
|
||||
selection belong to [report registry internals](report-registry.md).
|
||||
|
||||
## Collected facts
|
||||
|
||||
`BuildCollected` projects a `weatherdata.Bundle` into `CollectedFacts`. It
|
||||
retains the fetched timestamp and every normalized product: observations,
|
||||
current conditions, hourly, narrative, alerts, discussion, daily, weather
|
||||
story, and convective outlook data. Source provenance and warnings are copied
|
||||
into their own slices so downstream consumers can inspect data completeness
|
||||
without treating it as an ordinary weather fact.
|
||||
|
||||
A nil bundle produces an empty collected value. Collection itself, missing
|
||||
source policy, and source hashes are outside this package.
|
||||
|
||||
## Report-scoped derivation
|
||||
|
||||
`BuildDerived` requires a valid resolved period and a valid report timezone. It
|
||||
uses half-open period overlap to select hourly, narrative, daily, and alert
|
||||
data; it also derives precipitation timing. Convective outlooks are retained
|
||||
only when their valid interval overlaps the report period, with discussions
|
||||
kept for represented outlook days. Both collections are sorted deterministically.
|
||||
|
||||
Report identity controls the summary shape:
|
||||
|
||||
| Report family | Derived summary |
|
||||
| --- | --- |
|
||||
| Hourly | Rolling-period selections and precipitation timing; no daily or daypart summary |
|
||||
| Daily, Today, Tomorrow | One local civil-day summary and its dayparts |
|
||||
|
||||
`DaypartSummaries` is collected from the resulting daily summaries.
|
||||
The detailed grouping, daypart-window, and alert rules are owned by
|
||||
[forecast derivation](forecast-derivation.md).
|
||||
|
||||
## Missing data and failures
|
||||
|
||||
Optional normalized products remain nil or yield empty selections; the package
|
||||
does not create substitute values. A present convective-outlook run with no
|
||||
matching outlooks produces non-nil empty outlook and discussion slices, while
|
||||
a missing run produces nil slices.
|
||||
|
||||
Derivation fails for an invalid report period, invalid timezone, unsupported
|
||||
report ID, or when a requested daily summary has no hourly forecast data.
|
||||
Invalid daypart definitions surface from forecast derivation. The package does
|
||||
not access the CLI, filesystem, subprocesses, or network.
|
||||
|
||||
## Verification and invariants
|
||||
|
||||
Focused tests cover collected-fact separation, report-period selection,
|
||||
hourly behavior, daily summaries, and convective outlook selection:
|
||||
|
||||
```sh
|
||||
go test ./internal/facts
|
||||
```
|
||||
|
||||
Facts are derived once for a resolved report from already collected data.
|
||||
They remain reusable structured values for prompt input and template
|
||||
presentation, which are owned elsewhere.
|
||||
@@ -1,68 +1,61 @@
|
||||
# Forecast Derivation Internals
|
||||
|
||||
This document describes the implemented deterministic forecast summarization
|
||||
boundary.
|
||||
`internal/forecast` deterministically selects and summarizes normalized
|
||||
forecast data. It has no transport, filesystem, CLI, subprocess, or report
|
||||
registry dependency. Its summaries are consumed by
|
||||
[fact contracts](facts.md) and later module builders.
|
||||
|
||||
## Purpose
|
||||
## Period and daypart semantics
|
||||
|
||||
`internal/forecast` converts a normalized forecast bundle into inspectable
|
||||
daily and multi-day daypart summaries. These summaries are structured data for
|
||||
later briefing builders; they are not rendered report text.
|
||||
Selections use `timeutil.Period` half-open overlap: a value is selected only
|
||||
when both intervals share time. `BuildDailySummary` creates one local civil
|
||||
day; `BuildPeriodDailySummaries` intersects every local civil day with the
|
||||
requested period, preserving partial first and last days.
|
||||
|
||||
## Inputs and Outputs
|
||||
`ResolveDayparts` converts each configured name, start clock, and end clock
|
||||
into a local window. An end clock at or before its start clock wraps into the
|
||||
next civil day. The daypart and timezone defaults are defined in the
|
||||
[configuration reference](../config.md), not here.
|
||||
|
||||
Inputs:
|
||||
## Deterministic summaries
|
||||
|
||||
- `forecast.Bundle`
|
||||
- local date and timezone
|
||||
- report period, for multi-day summaries
|
||||
- configured daypart definitions with `HH:MM` start and end values
|
||||
`BuildDailySummary` requires an hourly run with at least one period. It adds
|
||||
the selected narrative periods, discussion, alert overlaps, source provenance,
|
||||
source warnings, and one `DaypartSummary` per resolved window. A daypart keeps
|
||||
its selected hourly periods and derives temperature and apparent-temperature
|
||||
ranges, timed precipitation and wind maxima, dominant and notable conditions,
|
||||
and weather indicators.
|
||||
|
||||
Output:
|
||||
Indicators are deterministic checks over normalized values and condition text:
|
||||
heat, cold, and wind use package-owned numeric cutoffs; snow, ice, fog, and
|
||||
wind text are detected from the forecast description. `BuildPrecipTiming`
|
||||
sorts periods, records the maximum and first precipitation, groups contiguous
|
||||
periods at or above its package-owned probability threshold, and records
|
||||
thunder mentions.
|
||||
|
||||
- `forecast.DailySummary` with a civil-day period, daypart summaries, selected
|
||||
narrative periods, alert overlaps, discussion context, source warnings, and
|
||||
source provenance.
|
||||
- `forecast.BuildPeriodDailySummaries` output with one clipped daily summary
|
||||
for each local day or partial day in a report period.
|
||||
Alert overlap parsing supports the normalized alert payload's available timing
|
||||
fields. Unparseable alerts and invalid intervals are ignored; valid overlaps
|
||||
are clipped to the requested period and ordered by alert start time.
|
||||
|
||||
## Boundaries
|
||||
## Missing data and failures
|
||||
|
||||
- This package groups and summarizes already-normalized forecast data.
|
||||
- It does not fetch weather data, resolve report definitions, compare prior
|
||||
snapshots, build prompt input packages, or call `scriptorium`.
|
||||
Empty selections yield empty summary fields rather than generated prose.
|
||||
Direct daily or period-summary calls fail when their required bundle, valid
|
||||
period, hourly data, or daypart definitions are invalid. A nil location uses
|
||||
UTC when these APIs are called directly. Optional narrative, discussion, and
|
||||
alerts remain absent when their normalized products are absent.
|
||||
|
||||
## Behavior
|
||||
Forecast thresholds used for brief indicators and precipitation timing are
|
||||
implementation rules.
|
||||
|
||||
- Daypart windows use half-open intervals.
|
||||
- Overnight dayparts are supported when the end clock is not after the start
|
||||
clock.
|
||||
- Hourly forecast periods are selected by overlap with the daypart window.
|
||||
- Each daypart computes temperature range, apparent-temperature range, maximum
|
||||
precipitation probability, peak wind speed, peak wind gust, dominant
|
||||
condition, notable conditions, and basic weather indicators.
|
||||
- Alerts are selected by overlap with the daily period and each daypart.
|
||||
- Narrative periods and discussion context are selected as broader source
|
||||
context for later briefing builders.
|
||||
- Multi-day period summaries clip the first and last local days to the resolved
|
||||
report period before selecting hourly periods and alerts.
|
||||
## Verification and invariants
|
||||
|
||||
## Failure Behavior
|
||||
Focused tests cover local civil days, clipped periods, daypart resolution,
|
||||
summary metrics, precipitation windows, threshold helpers, and alert overlap:
|
||||
|
||||
- Missing hourly forecast data returns an error.
|
||||
- Invalid daypart definitions return actionable parse errors.
|
||||
- Alert records without parseable RFC3339 start/end fields are skipped.
|
||||
```sh
|
||||
go test ./internal/forecast ./internal/timeutil
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/forecast/derive_test.go`
|
||||
- `internal/timeutil/periods_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Weather facts come from normalized source data, not generated prose.
|
||||
- Outputs remain JSON-inspectable.
|
||||
- Forecast derivation remains independent of CLI, HTTP adapters, and report
|
||||
registry behavior.
|
||||
The package preserves normalized inputs as inspectable structured values and
|
||||
never decides report identity, delivery, or presentation wording.
|
||||
|
||||
52
docs/internal/generatedtext.md
Normal file
52
docs/internal/generatedtext.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Generated Text Internals
|
||||
|
||||
`internal/generatedtext` validates the structured prose produced for generated-
|
||||
text reports and turns validated prose plus rich module values into typed render
|
||||
contexts. It owns the catalog that pairs a generated-text report definition
|
||||
with its validator, schema ID, template ID, and context builder. The complete
|
||||
maintainer-facing context fields belong to [report templates](../templates.md).
|
||||
|
||||
## Catalog and validation
|
||||
|
||||
The Daily, Today, Tomorrow, and Hourly report definitions each use structured
|
||||
generated text. `LookupDefinition` rejects unknown schema or template IDs and
|
||||
unsupported schema/template pairs before the run begins. A handler validates raw JSON, returns a typed
|
||||
value and canonical normalized JSON, loads its canonical schema through
|
||||
`internal/promptassets`, builds a render context, and renders through
|
||||
`internal/reporttemplate`.
|
||||
|
||||
Daily, Today, and Tomorrow use a day-style value with required trimmed summary
|
||||
and one or more nonblank discussion paragraphs. Hourly requires trimmed summary
|
||||
and a single trimmed discussion string. Every form also requires the
|
||||
`precipitation_timing` field; an empty string means there is no supported timing
|
||||
prose to render. Typed decoding rejects missing required fields and unknown JSON
|
||||
fields; no general-purpose JSON Schema engine is used at runtime.
|
||||
|
||||
## Render contexts
|
||||
|
||||
The catalog's report-specific builders receive briefing metadata, a rich module
|
||||
snapshot, collected facts, derived facts, and the matching validated generated
|
||||
text. They decode the module stanzas needed by the template and build typed
|
||||
Daily, Today, Tomorrow, or Hourly contexts. Context construction validates
|
||||
metadata and periods, preserves rich module values, and uses ordered slices for
|
||||
template iteration rather than maps.
|
||||
|
||||
Optional source stanzas become nil or fallback context fields. Missing required
|
||||
stanzas, type-decoding failures, invalid metadata, or a generated-text type
|
||||
that does not match the chosen handler fail before template execution. Prompt
|
||||
packages, raw Promptkit output handling, and template asset lookup remain
|
||||
outside this package.
|
||||
|
||||
## Verification and invariants
|
||||
|
||||
Focused tests cover the catalog, each report-specific validator, normalization,
|
||||
schema/template mismatches, context construction, optional modules, and typed
|
||||
stanza errors:
|
||||
|
||||
```sh
|
||||
go test ./internal/generatedtext
|
||||
```
|
||||
|
||||
Generated text supplies prose slots only; deterministic weather facts remain in
|
||||
module and fact values. Every report definition must resolve to exactly one
|
||||
supported catalog pair.
|
||||
66
docs/internal/module.md
Normal file
66
docs/internal/module.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Module Contract Internals
|
||||
|
||||
`internal/module` defines the stable envelope between report composition,
|
||||
module builders, in-memory snapshots, templates, and prompt packages. It
|
||||
does not define a report, execute a builder, or choose prompt-export policy;
|
||||
those responsibilities belong to [report registry](report-registry.md) and
|
||||
[briefing](briefing.md).
|
||||
|
||||
## Outputs and snapshots
|
||||
|
||||
Each `Output` has a module ID, stanza name, rich `Value`, and runtime-only
|
||||
`PromptValue`. `DataPackageValue` returns the prompt value when present and
|
||||
otherwise the rich value. This permits custom prompt exports without shrinking
|
||||
the template value.
|
||||
|
||||
`NewSnapshot` builds the ordered `weatherreporter.modules.v1` snapshot and
|
||||
validates it. Its JSON representation contains IDs, stanza names, and rich values only;
|
||||
`PromptValue` is deliberately excluded. `StanzaValue` decodes a named rich
|
||||
stanza into a caller-supplied type, reporting a missing stanza separately from
|
||||
a decoding error.
|
||||
|
||||
Snapshots reject missing schema versions, empty IDs or stanza names, and
|
||||
duplicate IDs or stanza names. Output order is caller-owned and preserved.
|
||||
|
||||
## Registered IDs and default composition
|
||||
|
||||
The registered IDs are `metadata`, `current_conditions`,
|
||||
`narrative_forecast`, `hourly_forecast`, `derived_daily_summary`,
|
||||
`derived_daypart_summaries`, `precip_timing`, `alert_digest`,
|
||||
`spc_convective_outlooks`, `area_forecast_discussion`,
|
||||
`spc_convective_discussion`, `weather_story`, `outdoor_windows`,
|
||||
`today_planning`, `tomorrow_planning`, and `daily_planning`.
|
||||
|
||||
The registry declares these ordered default compositions:
|
||||
|
||||
| Report | Ordered modules |
|
||||
| --- | --- |
|
||||
| Daily | metadata, current conditions, narrative forecast, daily summary, daypart summaries, precipitation timing, alert digest, SPC outlooks, AFD (long term), SPC discussion, weather story, outdoor windows, daily planning, hourly forecast |
|
||||
| Today | metadata, current conditions, narrative forecast, daily summary, daypart summaries, precipitation timing, alert digest, SPC outlooks, AFD, SPC discussion, weather story, outdoor windows, hourly forecast, today planning |
|
||||
| Tomorrow | metadata, current conditions, narrative forecast, daily summary, daypart summaries, precipitation timing, alert digest, SPC outlooks, AFD, SPC discussion, weather story, outdoor windows, tomorrow planning, hourly forecast |
|
||||
| Hourly | metadata, current conditions, hourly forecast, precipitation timing, alert digest, SPC outlooks, AFD (key messages and short term), SPC discussion, weather story |
|
||||
|
||||
The only non-empty default option is the AFD section selection. It accepts a
|
||||
`sections` list; omitted or empty selects all available sections. Report
|
||||
definitions may narrow it as shown above. Option shape and report compatibility
|
||||
are validated by the briefing registry.
|
||||
|
||||
## Rich and prompt-facing values
|
||||
|
||||
Rich values remain available to module snapshots and render contexts.
|
||||
Briefing attaches custom prompt exports only for current conditions, hourly
|
||||
forecast, and derived daypart summaries; all other current builders use
|
||||
pass-through values. The prompt package owns how exported stanzas are grouped
|
||||
and serialized; see [prompt input](prompt-input.md).
|
||||
|
||||
## Verification and invariants
|
||||
|
||||
Focused tests cover snapshot validation and order, typed stanza lookup, and
|
||||
prompt-value fallback:
|
||||
|
||||
```sh
|
||||
go test ./internal/module
|
||||
```
|
||||
|
||||
Module IDs and stanza names are stable, every emitted output has one of each,
|
||||
and this package never imports the report registry.
|
||||
@@ -1,54 +1,28 @@
|
||||
# Prompt Input Internals
|
||||
|
||||
This document describes the implemented prompt input package boundary.
|
||||
`internal/promptinput` converts report metadata, an ordered module snapshot, and source warnings into the YAML `data_package` supplied inline to Promptkit. It owns the package schema, grouping, serialization, loading, and validation; it does not choose an output destination, collect weather, execute a provider, or retain packages after a command ends.
|
||||
|
||||
## Purpose
|
||||
## Package Construction
|
||||
|
||||
`internal/promptinput` converts a structured briefing package into the
|
||||
`data_package` JSON file passed to `scriptorium` prompts.
|
||||
`Build` produces `weatherreporter.data_package.v4`. It copies the run ID; report ID, variant, prompt ID, generation time, timezone, local current date, and valid period; ordered briefing stanzas; and source warnings. Prompt input contains no historical comparison section.
|
||||
|
||||
## Inputs and Outputs
|
||||
Briefing is a flat ordered set of stanza values. `Build` uses each output's `DataPackageValue`, so curated prompt exports take precedence and rich values are used only as a fallback. Prompt exports are selected by the [briefing registry](briefing.md), while the rich-versus-prompt contract is in [module internals](module.md).
|
||||
|
||||
Input:
|
||||
## YAML Ordering And Validation
|
||||
|
||||
- `briefing.Package` containing Daily-family, 3-Day Outlook, Weekend Outlook,
|
||||
or Storm Report content
|
||||
Serialization keeps `metadata` directly under `briefing`. Every other known stanza is placed in one category and emitted in category order while preserving its original module order:
|
||||
|
||||
Output:
|
||||
| Category | Current stanzas |
|
||||
| --- | --- |
|
||||
| `applicable_risk_products` | alert digest, SPC convective outlooks |
|
||||
| `derived_summaries` | deterministic summaries, precipitation timing, outdoor windows, and planning values |
|
||||
| `narrative_products` | narrative forecast, discussions, and weather story |
|
||||
| `raw_data` | current conditions and hourly forecast |
|
||||
|
||||
- `promptinput.Package` JSON with report metadata, briefing content, source
|
||||
warnings, RunID, and a Recent Changes section.
|
||||
`LoadYAML` accepts this layout and reconstructs the flat order and values. It rejects misplaced, duplicate, unknown, or uncategorized stanzas. `Validate` requires the v4 schema version, report identity and period fields, and at least one ordered briefing stanza. `MarshalYAML` and `LoadYAML` validate their result. `Save` remains a reusable atomic-file helper for callers that explicitly need one; normal application execution passes marshalled YAML directly to Promptkit.
|
||||
|
||||
## Boundaries
|
||||
Focused tests cover construction, curated exports, category ordering, YAML round trips, invalid layout, validation, and atomic saves:
|
||||
|
||||
- This package owns the prompt input schema and required-field validation.
|
||||
- It does not fetch weather data, compute forecast summaries, compare prior
|
||||
snapshots, or invoke `scriptorium`.
|
||||
|
||||
## Behavior
|
||||
|
||||
- `promptinput.Build` copies report metadata from the briefing package.
|
||||
- `promptinput.Validate` rejects missing or inconsistent required fields before
|
||||
render preflight.
|
||||
- `promptinput.Save` writes JSON atomically where practical.
|
||||
- Recent Changes is present as an `items` list. It is empty when no prior
|
||||
comparable snapshot exists or no meaningful changes are detected.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
Validation errors name the missing or inconsistent field. Save failures include
|
||||
the filesystem operation and path context.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/promptinput/package_test.go`
|
||||
- `internal/changes/daily_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Prompt input data remains structured JSON.
|
||||
- Briefing metadata and top-level report metadata must agree.
|
||||
- Recent Changes is not inferred from rendered report text.
|
||||
```sh
|
||||
go test ./internal/promptinput
|
||||
```
|
||||
|
||||
17
docs/internal/promptkit-adapter.md
Normal file
17
docs/internal/promptkit-adapter.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Promptkit Adapter Internals
|
||||
|
||||
`internal/adapters/promptkit` maps Weatherreporter's project-owned executor contract to Promptkit. The CLI maps `promptkit` configuration to a `PromptExecutorConfig` and constructs one executor per action. Promptkit dependency types do not escape the adapter.
|
||||
|
||||
The adapter supplies Weatherreporter's embedded prompt, schema, and fallback profile filesystems to each engine. Promptkit resolves configured operator profile sources, the embedded fallback catalog, and its built-in catalog; the adapter does not parse profile YAML, merge sources, or probe endpoints.
|
||||
|
||||
The adapter exposes exact prompt and profile validation plus prepared execution. It maps safe prompt identity, logical profile, effective backend/model, preparation, execution, validation, and optional debug values into `promptexec`. `Execute` passes the YAML package as an inline Promptkit input; it does not construct a filesystem URI or write a package file.
|
||||
|
||||
The application uses the preparation callback to record active safe provenance in memory and optionally writes content-rich diagnostics only through an explicit debug writer. The adapter returns raw output for application validation and rendering. It does not retain application state, render Markdown, choose report definitions, or send Distributor notifications.
|
||||
|
||||
Focused tests:
|
||||
|
||||
```sh
|
||||
go test ./internal/adapters/promptkit ./internal/cli ./internal/app
|
||||
```
|
||||
|
||||
The public logical prompt/profile/schema contract is owned by the [Promptkit integration guide](../integrations/promptkit.md).
|
||||
@@ -1,63 +1,30 @@
|
||||
# Report Registry Internals
|
||||
|
||||
This document describes the implemented report identity and valid-period
|
||||
boundary.
|
||||
`internal/report` owns report identities, valid-period resolution, exact prompt identity and version, output names, default module composition, and Distributor path declarations. Public command syntax belongs in the [CLI reference](../cli.md); configuration aliases and overrides belong in the [configuration reference](../config.md).
|
||||
|
||||
## Purpose
|
||||
## Definitions And Resolution
|
||||
|
||||
`internal/report` centralizes report IDs, prompt IDs, comparison strategies,
|
||||
valid-period resolution, report metadata, and scheduled batch membership.
|
||||
Each `Definition` declares a stable ID and display name, prompt ID and version, template and generated-text schema IDs, valid-period resolver, default output name, Distributor path templates, module list, and fixed batch eligibility. `Resolved` combines a definition with one valid period and run identity.
|
||||
|
||||
## Inputs and Outputs
|
||||
| Report ID | Prompt version | Default profile | Period policy | Fixed batch flag | Default output |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `daily` | `2.0.0` | `weather-balanced` | Explicit local civil day | Dynamic Daily inclusion is app-owned | `daily-YYYY-MM-DD.md` |
|
||||
| `today` | `2.0.0` | `weather-balanced` | Selected or current local civil day | Morning | `today.md` |
|
||||
| `tomorrow` | `2.0.0` | `weather-balanced` | Next local civil day | Evening | `tomorrow.md` |
|
||||
| `hourly` | `2.0.0` | `weather-light` | Rolling six-hour interval | — | `hourly.md` |
|
||||
|
||||
Inputs:
|
||||
Daily derives its filename from the resolved valid-period start in the effective timezone, so multiple Daily items have distinct destinations. Exact template fields and schema assets belong to [report templates](../templates.md) and [generated-text internals](generatedtext.md). Prompt assets own default profile selection; the registry stores no provider setting.
|
||||
|
||||
- report ID or batch name
|
||||
- generation time
|
||||
- configured timezone
|
||||
- optional Daily date override
|
||||
- optional manual storm start and end times
|
||||
## Collaborators And Boundaries
|
||||
|
||||
Outputs:
|
||||
`DefaultRegistry`, `Lookup`, `Resolve`, and report-name helpers prevent callers from duplicating report identity rules. Registry overrides clone a recognized definition and replace its module list. `DistributorPathTemplates` are consumed by app orchestration; their rendered external bundle-path contract is documented in the [Distributor bundle guide](../integrations/distributor/pkg-bundle.md).
|
||||
|
||||
- `report.Resolved` values with definition metadata and half-open valid periods
|
||||
- `report.Metadata` values suitable for later persisted run metadata
|
||||
`morning` and `evening` are registry-owned batch names. Fixed flags declare Today and Tomorrow eligibility; app orchestration determines data-dependent Daily membership and the actual batch plan.
|
||||
|
||||
## Boundaries
|
||||
The registry never collects weather data, parses CLI flags, writes output, executes Promptkit, or delivers a report.
|
||||
|
||||
- This package defines report identity and time coverage only.
|
||||
- It does not fetch weather data, build briefings, compare snapshots, write
|
||||
state, or call `scriptorium`.
|
||||
Focused tests cover definition completeness, command and alias lookup, period resolution, run IDs, output names, composition defaults, and override validation:
|
||||
|
||||
## Behavior
|
||||
|
||||
- Daily Today covers one configured local civil day.
|
||||
- Daily Tomorrow covers the next configured local civil day.
|
||||
- 3-Day Outlook covers generation time through local midnight after the second
|
||||
following local civil day.
|
||||
- Weekend Outlook covers Saturday 00:00 to Monday 00:00 Monday through
|
||||
Thursday; Friday and Saturday cover the remaining weekend from Friday 18:00
|
||||
or generation time, whichever is later.
|
||||
- Manual Storm Report uses explicit start and end times.
|
||||
- Morning batch resolves Daily Today and 3-Day Outlook, plus Weekend Outlook
|
||||
except on Sunday.
|
||||
- Evening batch resolves Daily Tomorrow.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- Unknown report and batch names return actionable errors.
|
||||
- Sunday Weekend Outlook resolution returns an error.
|
||||
- Storm windows require start and end, with end after start.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/report/period_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Report selection goes through the registry.
|
||||
- Valid periods are independent of rendered report text.
|
||||
- Prompt IDs and comparison strategies are declared with report definitions.
|
||||
```sh
|
||||
go test ./internal/report
|
||||
```
|
||||
|
||||
51
docs/internal/reporttemplate.md
Normal file
51
docs/internal/reporttemplate.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Report Template Internals
|
||||
|
||||
`internal/reporttemplate` embeds and renders the repository's native Markdown
|
||||
templates. The current template IDs are `daily`, `today`, `tomorrow`, and
|
||||
`hourly`. The template files, partials, and complete render-context field
|
||||
reference are maintained in
|
||||
[report templates](../templates.md).
|
||||
|
||||
## Assets and lookup
|
||||
|
||||
The package embeds top-level templates and shared partials. `Template` returns
|
||||
the requested embedded template and fails with the requested ID when it is
|
||||
unknown or unreadable.
|
||||
|
||||
Generated-text schemas and Promptkit definitions are owned by
|
||||
`internal/promptassets`; report-template owns Markdown source only. Report
|
||||
definitions select IDs, while [generated-text internals](generatedtext.md)
|
||||
verifies the supported schema/template pairing.
|
||||
|
||||
## Rendering
|
||||
|
||||
`Render` loads the top-level template, creates a `text/template` with helper
|
||||
functions and `missingkey=error`, parses the template, parses every shared
|
||||
partial, and executes the result against the typed render context. This makes
|
||||
missing context fields, bad template syntax, unreadable partials, and execution
|
||||
failures actionable with template or partial context.
|
||||
|
||||
Top-level templates decide which shared partials they invoke. The current
|
||||
partials cover daypart forecast variants, alert digest, and precipitation
|
||||
timing. Template code receives curated typed contexts rather than raw data
|
||||
packages, and it must not reimplement weather selection or generated-text
|
||||
validation.
|
||||
|
||||
## Boundaries and verification
|
||||
|
||||
This package does not collect weather data, build modules, validate generated
|
||||
text, construct contexts, resolve report definitions, write state, execute
|
||||
Promptkit, or upload reports. It produces Markdown bytes for application
|
||||
orchestration to persist.
|
||||
|
||||
Focused tests cover template lookup, rendering, partial
|
||||
behavior, missing keys, and malformed context:
|
||||
|
||||
```sh
|
||||
go test ./internal/reporttemplate
|
||||
```
|
||||
|
||||
Embedded templates stay as separate files and shared fragments stay under the
|
||||
partial directory. Generated-text schemas are embedded separately by
|
||||
`internal/promptassets` and describe prose slots rather than deterministic
|
||||
weather facts.
|
||||
@@ -1,71 +0,0 @@
|
||||
# Scriptorium Adapter Internals
|
||||
|
||||
This document describes the implemented `scriptorium` subprocess adapter.
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/adapters/scriptorium` runs `scriptorium render` to preflight prompt
|
||||
wiring and `scriptorium run` to generate report artifacts.
|
||||
|
||||
## Inputs and Outputs
|
||||
|
||||
Input:
|
||||
|
||||
- prompt ID
|
||||
- prompt input data package path
|
||||
- report output path for `run`
|
||||
- configured binary, config path, profile, timeout, and extra arguments
|
||||
|
||||
Output:
|
||||
|
||||
- captured stdout, with truncation tracking
|
||||
- captured stderr, with truncation tracking
|
||||
- exit code
|
||||
- full argv used for inspection
|
||||
|
||||
## Boundaries
|
||||
|
||||
- This adapter owns `scriptorium` CLI flag construction and subprocess
|
||||
execution.
|
||||
- It does not choose report types, build prompt input, fetch weather data, or
|
||||
decide workflow order.
|
||||
|
||||
## Behavior
|
||||
|
||||
The render invocation shape is:
|
||||
|
||||
```text
|
||||
scriptorium render --prompt <prompt_id> --input data_package=<path> --format json
|
||||
```
|
||||
|
||||
The run invocation shape is:
|
||||
|
||||
```text
|
||||
scriptorium run --prompt <prompt_id> --input data_package=<path> --out <artifact_path>
|
||||
```
|
||||
|
||||
Configured `--config` and `--profile` values are added when present. Arguments
|
||||
are passed directly as argv, not through a shell. Stdout and stderr are captured
|
||||
separately. `SaveRenderResult` writes the captured result as JSON for inspection.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
Nonzero render and run exits return both the captured result and an error
|
||||
containing the exit code and stderr. Run exit code `2` is treated as an error
|
||||
but may still produce a report artifact. Command execution respects context
|
||||
cancellation and the configured timeout.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/adapters/scriptorium/runner_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
- `internal/cli/root_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- `scriptorium` details stay inside the adapter package.
|
||||
- The input name for prompt packages is always `data_package`.
|
||||
- Render preflight remains orchestration behavior; this adapter only exposes the
|
||||
subprocess operations.
|
||||
@@ -1,81 +0,0 @@
|
||||
# State Internals
|
||||
|
||||
This document describes the implemented filesystem state boundary.
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/state` owns durable artifact paths, atomic JSON writes, metadata, and
|
||||
prior comparable snapshot lookup.
|
||||
|
||||
## Inputs and Outputs
|
||||
|
||||
Inputs:
|
||||
|
||||
- workspace configuration
|
||||
- resolved report definition and valid period
|
||||
- briefing package
|
||||
- prompt input data package
|
||||
- `scriptorium render` result
|
||||
- rendered report path preparation
|
||||
|
||||
Outputs:
|
||||
|
||||
- briefing snapshot JSON
|
||||
- prompt input data package JSON
|
||||
- render preflight JSON
|
||||
- Markdown report path
|
||||
- metadata JSON
|
||||
- prior comparable snapshot metadata when available
|
||||
- prior briefing package when loaded by path
|
||||
- recent report records for inspection
|
||||
- metadata and data package lookup by RunID
|
||||
|
||||
## Boundaries
|
||||
|
||||
- This package owns managed workspace layout and narrow path validation.
|
||||
- It does not fetch weather data, derive forecasts, build prompt inputs, invoke
|
||||
`scriptorium`, or compare briefing contents.
|
||||
|
||||
## Config Fields Used
|
||||
|
||||
- `workspace.root`
|
||||
- `workspace.snapshots_dir`
|
||||
- `workspace.reports_dir`
|
||||
- `workspace.data_packages_dir`
|
||||
- `workspace.preflight_dir`
|
||||
|
||||
Workspace subdirectories must be relative paths that stay under
|
||||
`workspace.root`.
|
||||
|
||||
## State Behavior
|
||||
|
||||
Managed artifact names use RunID, which is generated from report generation time
|
||||
and report ID. Metadata is stored beside briefing snapshots by report group and
|
||||
valid local date. Prior snapshot lookup reads metadata for the same valid local
|
||||
date and returns the latest earlier compatible run. Daily Today and Daily
|
||||
Tomorrow are compatible with each other; 3-Day Outlook is compatible with prior
|
||||
3-Day Outlook snapshots; Weekend Outlook is compatible with prior Weekend
|
||||
Outlook snapshots for the same weekend window. The store can load a briefing
|
||||
snapshot by path for structured comparison. The store can list metadata-backed
|
||||
report records and load metadata or data packages by RunID for inspection. The
|
||||
store prepares the managed Markdown report path before `scriptorium run` writes
|
||||
it.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
Writes are atomic where practical: JSON is written to a temporary file in the
|
||||
target directory and then renamed into place. Invalid workspace paths and
|
||||
missing required metadata fields produce actionable errors.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/state/filesystem_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Managed paths stay under the configured workspace root.
|
||||
- Metadata links the artifacts produced for a run.
|
||||
- Prior lookup is based on structured metadata, not rendered report text.
|
||||
@@ -1,69 +1,68 @@
|
||||
# Weather Data Internals
|
||||
|
||||
This document describes the implemented weather data ingestion boundary.
|
||||
`internal/weatherdata` owns the normalized, wire-independent weather bundle
|
||||
that passes from collection through rendering. The Weather API
|
||||
adapter translates provider responses into these types; its request, response,
|
||||
and availability contract is documented in the
|
||||
[Weather API integration guide](../integrations/weatherapi.md).
|
||||
|
||||
## Purpose
|
||||
## Bundle contract
|
||||
|
||||
`internal/adapters/weatherapi` fetches normalized weather data from one
|
||||
configured weather API endpoint and assembles a `forecast.Bundle`.
|
||||
`Bundle` has a collection timestamp (`FetchedAt`), source provenance
|
||||
(`Sources`), and collection-level warnings (`Warnings`). Its product fields are
|
||||
optional so an allowed missing source can be represented without manufacturing
|
||||
weather data.
|
||||
|
||||
## Inputs and Outputs
|
||||
| Field | Normalized product |
|
||||
| --- | --- |
|
||||
| `Observation` | Station observation |
|
||||
| `Current` | Current conditions |
|
||||
| `Hourly` | Hourly forecast periods |
|
||||
| `Narrative` | Narrative forecast |
|
||||
| `Alerts` | Active-alert check, including an explicitly empty result |
|
||||
| `Discussion` | Forecast discussion and its time-range sections |
|
||||
| `Daily` | Daily forecast periods when supplied |
|
||||
| `WeatherStory` | Latest weather story |
|
||||
| `SPCConvectiveOutlooks` | Convective outlook run, discussions, and GeoJSON geometry |
|
||||
|
||||
Input:
|
||||
The bundle carries values rather than provider request details. Consumers use
|
||||
it to construct report facts and data packages; they should not infer a
|
||||
provider endpoint or retry policy from the normalized types. See
|
||||
[collection](collect.md) for assembly and
|
||||
[report templates](../templates.md) for the values exposed to authors.
|
||||
|
||||
- `config.Config` with `weather_api.base_url`, `format`, `units`, `timezone`,
|
||||
`precision`, timeout, and missing-source policy.
|
||||
## Source provenance
|
||||
|
||||
Output:
|
||||
Every checked source is represented by a `Source` entry. The record identifies
|
||||
the source (`Name`), request location and query (`Endpoint`, `Query`), fetch
|
||||
time, provider issue and update times when available, a SHA-256 digest of the
|
||||
source data, and whether the source was unavailable (`Missing`). Its warnings
|
||||
stay with that source in addition to the bundle-level warning list.
|
||||
|
||||
- `forecast.Bundle` containing observation, current conditions, hourly forecast,
|
||||
narrative forecast, alerts, discussion, stub source slots, provenance, and
|
||||
source warnings.
|
||||
An empty product can be meaningful checked data. For example, an explicit
|
||||
empty alerts result is not missing and retains its source hash. A source is
|
||||
marked missing only when the adapter's missing-source policy treats the
|
||||
response or parsing failure as unavailable. The policy itself belongs to the
|
||||
[configuration reference](../config.md).
|
||||
|
||||
## Boundaries
|
||||
## Warning semantics
|
||||
|
||||
- The adapter performs HTTP calls and decoding only.
|
||||
- Forecast derivation, daypart grouping, report periods, report rendering, and
|
||||
`scriptorium` execution are outside this boundary.
|
||||
- Hourly forecast data is required. Other missing or malformed source sections
|
||||
use the configured missing-source policy.
|
||||
`SourceWarning` has a source name, stable code, severity, explanatory message,
|
||||
endpoint, and `CompletenessImpact`. When collection proceeds with a warning,
|
||||
the same warning appears in `Source.Warnings` and `Bundle.Warnings` so both
|
||||
local provenance and whole-run consumers see it. A policy that treats a missing
|
||||
source as an error returns no partial bundle.
|
||||
|
||||
## External Adapter
|
||||
Warnings describe data completeness, not rendering or delivery failures.
|
||||
Those failures are reported by [application orchestration](app-orchestration.md).
|
||||
|
||||
The adapter calls:
|
||||
## Boundaries and verification
|
||||
|
||||
- `/observations`
|
||||
- `/conditions/current`
|
||||
- `/forecast/hourly`
|
||||
- `/forecast/narrative`
|
||||
- `/alerts/active`
|
||||
- `/discussion`
|
||||
This package defines data shapes and has no HTTP client, configuration loader,
|
||||
filesystem access, or template behavior. Focused tests cover the normalized
|
||||
types and the Weather API adapter verifies translation into them:
|
||||
|
||||
Forecast routes use the full-product endpoints, not day-slice endpoints.
|
||||
|
||||
## State
|
||||
|
||||
`app.FetchAndSaveBundle` can save an inspectable bundle JSON file using an
|
||||
atomic rename. No report state, snapshots, or prompt input packages are written
|
||||
yet.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- HTTP and envelope decode failures return actionable errors with endpoint
|
||||
context.
|
||||
- Missing hourly data fails the fetch.
|
||||
- Missing or malformed optional sources follow `error`, `warn`, or `none`.
|
||||
- Source identity uses SHA-256 over compacted raw `data` JSON.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/adapters/weatherapi/client_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Weather facts come from normalized source data.
|
||||
- External API details stay inside `internal/adapters/weatherapi`.
|
||||
- Source provenance and warnings remain inspectable for later briefing builders.
|
||||
```sh
|
||||
go test ./internal/weatherdata
|
||||
go test ./internal/adapters/weatherapi
|
||||
```
|
||||
|
||||
@@ -1,182 +1,136 @@
|
||||
# Weatherreporter Operations
|
||||
|
||||
## Normal Workflow
|
||||
This guide covers normal output handling, Distributor notification, secure
|
||||
prompt diagnostics, and cleanup of legacy application state. See the [CLI
|
||||
reference](cli.md) for command syntax and the [configuration reference](config.md)
|
||||
for fields, defaults, and notification templates.
|
||||
|
||||
The implemented generation workflows are:
|
||||
## Normal Operation
|
||||
|
||||
```text
|
||||
weatherreporter generate daily --date 2026-05-29
|
||||
weatherreporter generate tomorrow
|
||||
weatherreporter generate three-day
|
||||
weatherreporter generate weekend
|
||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
|
||||
weatherreporter run morning
|
||||
weatherreporter run evening
|
||||
After configuring a Weather API endpoint, generate one report:
|
||||
|
||||
```sh
|
||||
weatherreporter generate today
|
||||
```
|
||||
|
||||
These commands fetch weather data, build a briefing for the resolved valid
|
||||
period, build the prompt input data package, run `scriptorium render`, run
|
||||
`scriptorium run`, and write inspectable artifacts under the configured
|
||||
workspace. The evening run resolves only the Tomorrow Planning Brief. The
|
||||
morning run generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
||||
except on Sunday. Storm Report generation is manual and uses the explicit
|
||||
`--start` and `--end` bounds as its valid period.
|
||||
With no configured output directory, the command writes `today.md` in the
|
||||
current directory. Set `output.directory` to use one ordinary publication
|
||||
directory for reports, or choose a one-command operator-owned file with
|
||||
`--out`; a relative path is resolved from the current directory and an absolute
|
||||
path is used directly. The explicit flag takes precedence over the configured
|
||||
directory. Weatherreporter renders in memory and atomically replaces the
|
||||
selected destination only after generation and rendering succeed. It does not
|
||||
create a default workspace, metadata, receipts, or intermediate output files.
|
||||
|
||||
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.
|
||||
A missing configured directory is created only as part of successful report
|
||||
publication. If its existing path is not a directory or cannot be inspected,
|
||||
the command stops before prompt inspection or weather collection, leaving any
|
||||
existing report unchanged. See the [configuration reference](config.md) for the
|
||||
field definition and validation rules.
|
||||
|
||||
## Filesystem Layout
|
||||
Before a destination is published, provider, validation, rendering, write, and
|
||||
cancellation failures leave an existing report unchanged. A notification
|
||||
failure happens after publication, so retain and use the completed Markdown
|
||||
file while resolving the delivery error. The JSON result identifies the
|
||||
absolute output path and active profile, backend, model, warnings, validation,
|
||||
debug, and notification information; see the [CLI reference](cli.md) for its
|
||||
exact fields.
|
||||
|
||||
The default workspace root is `workspace`.
|
||||
## Batch Outputs And Distributor Notification
|
||||
|
||||
```text
|
||||
workspace/
|
||||
snapshots/
|
||||
daily/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.briefing.json
|
||||
<run_id>.metadata.json
|
||||
three-day/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.briefing.json
|
||||
<run_id>.metadata.json
|
||||
weekend/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.briefing.json
|
||||
<run_id>.metadata.json
|
||||
storm/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.briefing.json
|
||||
<run_id>.metadata.json
|
||||
data-packages/
|
||||
daily/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.json
|
||||
three-day/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.json
|
||||
weekend/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.json
|
||||
storm/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.json
|
||||
preflight/
|
||||
daily/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.render.json
|
||||
three-day/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.render.json
|
||||
weekend/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.render.json
|
||||
storm/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.render.json
|
||||
reports/
|
||||
daily/
|
||||
<run_id>.md
|
||||
three-day/
|
||||
<run_id>.md
|
||||
weekend/
|
||||
<run_id>.md
|
||||
storm/
|
||||
<run_id>.md
|
||||
Run a scheduled batch with an explicit output directory when appropriate:
|
||||
|
||||
```sh
|
||||
weatherreporter run morning --out-dir ./reports
|
||||
```
|
||||
|
||||
The Markdown report is written to a RunID-managed report path. When `--out` is
|
||||
provided to `generate daily`, `generate tomorrow`, `generate three-day`,
|
||||
`generate weekend`, or `generate storm`, the managed report is also copied to
|
||||
that path.
|
||||
Without `--out-dir`, batch reports are written beneath `output.directory` when
|
||||
configured, otherwise the current directory. The explicit directory applies
|
||||
only to that command and takes precedence over the configured fallback.
|
||||
Morning runs Today, Tomorrow, and every eligible dated Daily Report; evening
|
||||
runs Tomorrow and the same eligible Daily Reports. Eligible Daily dates begin
|
||||
after tomorrow and require complete hourly coverage for their local civil day.
|
||||
A batch collects once, determines the complete report set, and validates every
|
||||
final output destination before executing its first report prompt. A destination
|
||||
collision, such as a directory named `tomorrow.md`, stops the batch before any
|
||||
report output is created or replaced. After successful validation, each selected
|
||||
report processes independently and successful outputs remain available if
|
||||
another report fails.
|
||||
|
||||
For `run morning` and `run evening`, `--out-dir PATH` writes extra Markdown
|
||||
copies using each report definition's default filename, such as `daily.md`,
|
||||
`three-day.md`, `weekend.md`, or `tomorrow.md`.
|
||||
When `notify.distributor.enabled` and batch notification are enabled,
|
||||
Weatherreporter sends one Distributor upload only after every selected output
|
||||
exists. If an item fails, the batch notification is skipped and successful
|
||||
files remain at their selected destinations. A batch notification failure also
|
||||
leaves all successfully published report files in place. Distributor source
|
||||
files are those operator-owned Markdown outputs; rendered bundle paths and
|
||||
delivery status appear in the result, not in a local notification receipt.
|
||||
Report counters count report items only. A batch notification failure therefore
|
||||
returns a failed batch status even when all report counters show success; the
|
||||
top-level notification result contains the delivery diagnostic.
|
||||
|
||||
## Run Identifiers
|
||||
For a single report, Distributor notification follows the atomic output write.
|
||||
See the [configuration reference](config.md) for pipeline, bundle,
|
||||
idempotency-key, and per-report path templates.
|
||||
|
||||
Run IDs are based on generation time plus report ID, such as:
|
||||
## Local Prompt Profile Override
|
||||
|
||||
```text
|
||||
20260529T100000.123456789Z_daily_today
|
||||
Hourly normally selects the embedded `weather-light` profile. To use a local
|
||||
OpenAI-compatible model without changing prompts or application code, copy
|
||||
[weather-light-local-profile.yml](../examples/weather-light-local-profile.yml),
|
||||
set its `endpoint` and `model` for the local server, and configure the copy as
|
||||
`promptkit.profile_file`. The profile file's `weather-light` definition
|
||||
completely replaces the embedded definition; it does not affect a report that
|
||||
selects another profile ID.
|
||||
|
||||
Prompt and profile validation occurs before weather collection. A malformed
|
||||
profile file, missing required credential, or unsupported selected backend
|
||||
stops the command before collection. A reachable profile can still fail later
|
||||
if its local model endpoint is unavailable; Weatherreporter does not switch to
|
||||
a remote profile.
|
||||
|
||||
## Optional Prompt Debug Capture
|
||||
|
||||
Use `--llm-debug-dir` only when content-rich prompt diagnostics are required:
|
||||
|
||||
```sh
|
||||
weatherreporter generate today --llm-debug-dir /var/tmp/weatherreporter-debug
|
||||
```
|
||||
|
||||
Managed artifact filenames use the RunID so repeated runs for the same valid
|
||||
date do not overwrite each other.
|
||||
The directory must be absolute. Requested captures are written with restrictive
|
||||
permissions beneath the supplied directory, organized by report and run. They
|
||||
can contain rendered prompts and generated output, so limit access to trusted
|
||||
operators and remove the captures when they are no longer needed. Normal output,
|
||||
summaries, and routine logs omit that sensitive content. Debug capture is never
|
||||
created for an ordinary command without `--llm-debug-dir`.
|
||||
|
||||
## Metadata
|
||||
If capture creation or writing fails, the affected run fails rather than
|
||||
silently continuing without the requested diagnostics.
|
||||
|
||||
Each generated report writes metadata that links:
|
||||
## Diagnosing Failures
|
||||
|
||||
- RunID
|
||||
- report ID and prompt ID
|
||||
- generation time and valid period
|
||||
- source location, source hashes, and source warnings
|
||||
- briefing snapshot path
|
||||
- prompt input data package path
|
||||
- preflight output path
|
||||
- rendered report path
|
||||
Start with the command error and JSON summary. For a report generation failure,
|
||||
the selected destination was not replaced; for a notification failure, inspect
|
||||
the completed destination and the notification result. For a batch failure,
|
||||
use the per-report statuses and retain successful output files. Enable explicit
|
||||
debug capture only when content-rich Promptkit diagnostics are necessary.
|
||||
|
||||
Run summaries include each report ID, prompt ID, RunID, status, error text when
|
||||
applicable, valid period, and artifact paths known to the application.
|
||||
Weatherreporter does not retain runs for later inspection, resume failed work,
|
||||
or provide automatic cleanup, archival, remote state, daemon operation, or
|
||||
automatic storm monitoring.
|
||||
|
||||
## Inspection
|
||||
## Manual Cleanup Of Legacy Workspaces
|
||||
|
||||
Use `weatherreporter inspect reports` to list recent generated runs from the
|
||||
configured workspace. The output includes RunID, report ID, valid period,
|
||||
metadata path, briefing path, report path, and source warning count.
|
||||
Older installations may have a directory named `workspace` containing reports,
|
||||
snapshots, prompt inputs, or notification records from previous versions.
|
||||
Current commands neither read nor update it. After confirming that no separate
|
||||
retention requirement applies, remove that specific legacy directory manually;
|
||||
do not use a broad cleanup command that could remove current operator outputs.
|
||||
|
||||
Run-specific inspection commands emit JSON for a single RunID:
|
||||
For example, from the directory that contains the old directory:
|
||||
|
||||
```text
|
||||
weatherreporter inspect metadata RUN_ID
|
||||
weatherreporter inspect briefing RUN_ID
|
||||
weatherreporter inspect data-package RUN_ID
|
||||
weatherreporter inspect prior RUN_ID
|
||||
weatherreporter inspect sources RUN_ID
|
||||
```sh
|
||||
rm -rf ./workspace
|
||||
```
|
||||
|
||||
`inspect prior` shows the prior comparable snapshot selected from stored
|
||||
metadata, or `null` when none exists. `inspect sources` shows source provenance
|
||||
and source warnings without dumping full weather payloads.
|
||||
|
||||
## Recent Changes
|
||||
|
||||
When a prior comparable Daily briefing snapshot exists for the same valid local
|
||||
date, the app compares structured briefing data before writing the prompt input
|
||||
data package. Daily Today and Daily Tomorrow can compare with each other when
|
||||
they cover the same valid local date. Meaningful changes are included under
|
||||
`recentChanges.items`.
|
||||
|
||||
3-Day Outlook generation compares against a prior compatible 3-Day briefing
|
||||
snapshot for the same valid local date when one exists.
|
||||
|
||||
Weekend Outlook generation compares against a prior compatible Weekend briefing
|
||||
snapshot for the same weekend window when one exists. Friday evening and
|
||||
Saturday runs may narrow the valid start while keeping the same Monday endpoint.
|
||||
|
||||
Storm Report generation currently leaves Recent Changes empty. Its explicit
|
||||
event window is still recorded in briefing and metadata artifacts.
|
||||
|
||||
When no prior comparable snapshot exists, or no configured threshold is crossed,
|
||||
the Recent Changes list is empty.
|
||||
|
||||
## Recovery
|
||||
|
||||
If render preflight exits nonzero after producing a result, the captured stdout,
|
||||
stderr, exit code, and command are still written to the preflight artifact, and
|
||||
metadata is still written for inspection.
|
||||
|
||||
If `scriptorium run` exits nonzero after writing a report, the generated report
|
||||
and metadata remain available for inspection. Exit code `2` is still returned as
|
||||
an error because it indicates validation failed, even if report output exists.
|
||||
|
||||
For scheduled runs, inspect stdout first for the aggregate JSON summary, then
|
||||
use the per-report artifact paths in that summary to inspect briefing,
|
||||
data-package, preflight, metadata, and rendered report files.
|
||||
|
||||
The application does not currently implement resume, cleanup, archive, or
|
||||
remote storage behavior.
|
||||
This removal cannot be recovered by Weatherreporter. Keep or archive any
|
||||
historical files that are still needed before deleting them.
|
||||
|
||||
@@ -1,109 +1,79 @@
|
||||
# Architecture
|
||||
# Architecture Policy
|
||||
|
||||
This document defines the development principles for this Go project. It is inward-facing: developers and LLM coding agents should use it to preserve the project’s shape, boundaries, and invariants as the code evolves.
|
||||
## Purpose
|
||||
|
||||
## weatherreporter
|
||||
`weatherreporter` is a deterministic weather briefing and report-preparation application. It consumes normalized weather data from the internal weatherfeeder-backed API, derives report-specific briefing packages, compares those packages against prior snapshots, and invokes an external prompt runner to produce human-facing reports.
|
||||
This policy defines Weatherreporter's system shape, ownership, dependency direction,
|
||||
and safety invariants. The [development guide](../development.md) owns the
|
||||
package inventory; focused documents in `docs/internal/` own implementation detail.
|
||||
|
||||
The application should keep meteorological data selection, daypart grouping, threshold detection, forecast-period resolution, and recent-change comparison inside Go domain packages. LLM prompts should receive curated briefing packages rather than raw unbounded source payloads wherever practical.
|
||||
## System Shape
|
||||
|
||||
Report types must be defined through a registry or equivalent mechanism. Each report definition should declare its report ID, prompt ID, valid-period resolver, briefing builder, comparison strategy, and output naming behavior. Avoid scattering report-type conditionals across CLI and orchestration code.
|
||||
Weatherreporter is a deterministic weather-report CLI. It collects normalized
|
||||
weather data, derives facts and modules, builds a curated YAML data package,
|
||||
executes exact-version Promptkit prompts, validates structured generated prose,
|
||||
and renders repository-owned Markdown in memory. Completed Markdown is
|
||||
atomically published to an operator-owned output destination and may then be
|
||||
uploaded through Distributor.
|
||||
|
||||
Generated reports must be associated with explicit metadata, including report type, location, generation time, valid period, source product timestamps or hashes, briefing snapshot path, and output path. Recent Changes must be based on structured snapshot comparison rather than comparison of rendered Markdown report text.
|
||||
The supported report products are Daily, Today, Tomorrow, and Hourly. A batch
|
||||
collects once, validates its complete candidate prompt/profile set before
|
||||
collection, then determines and validates every planned output destination
|
||||
before executing reports sequentially with one executor. It continues after
|
||||
independent report failures and sends a batch notification only after every
|
||||
planned report succeeds.
|
||||
|
||||
`scriptorium` is an external adapter, not domain logic. Subprocess execution must be isolated under `internal/adapters/scriptorium`, use context-aware execution, avoid shell interpolation, capture actionable stderr, and keep scriptorium-specific flags from leaking into domain packages.
|
||||
## Ownership And Boundaries
|
||||
|
||||
## Project Shape
|
||||
- `internal/cli` owns command parsing, help, summaries, and one executor
|
||||
construction per action.
|
||||
- `internal/config` owns defaults, loading, validation, and secret loading.
|
||||
- `internal/app` owns in-memory workflow order, partial results, atomic output
|
||||
publication, and notification coordination through project-owned contracts.
|
||||
- Deterministic domain packages own weather derivation, report periods, modules,
|
||||
generated-text validation, and template contexts.
|
||||
- `internal/adapters/weatherapi`, `internal/adapters/promptkit`, and
|
||||
`internal/adapters/distributor` own their external dependency mechanics.
|
||||
|
||||
Default to a small, explicit, dependency-light Go application. Keep the design modular enough to test and change safely, but do not add abstraction unless it protects a real boundary or enables a real extension point.
|
||||
Dependency-specific Promptkit types remain inside its adapter. The application
|
||||
does not parse flags, construct provider clients, or render provider output
|
||||
directly.
|
||||
|
||||
Business/domain logic should live outside CLI, transport, and external-adapter packages.
|
||||
## Prompt Execution Invariants
|
||||
|
||||
## Dependency Policy
|
||||
- Prompts receive curated module packages, never unbounded raw weather payloads.
|
||||
- Every execution validates the exact prompt version and output contract before
|
||||
collection. The selected profile is configured explicitly or declared by the
|
||||
prompt; unsupported direct-key profiles and missing reported credentials fail
|
||||
before collection.
|
||||
- Prompt and profile validation completes before weather collection. Raw output
|
||||
is validated before template rendering.
|
||||
- Generated text fills defined prose slots only. Deterministic facts remain
|
||||
authoritative and repository-owned templates produce all Markdown output.
|
||||
- Sensitive rendered prompts, schemas, input bodies, provider endpoints, and
|
||||
credentials never enter normal summaries or logs. They are written only to
|
||||
an explicit secure debug root when requested.
|
||||
|
||||
Prefer the Go standard library where practical.
|
||||
## Output, Notification, And Testing Invariants
|
||||
|
||||
Use external dependencies only when justified by correctness, security, interoperability, or substantial complexity reduction. Good reasons include complex security-sensitive behavior, such as HTML sanitization, or widely used de facto standards, such as YAML parsing.
|
||||
- Normal execution is stateless: it keeps weather data, prompt input, generated
|
||||
text, and render context in memory and creates no application-owned durable
|
||||
state.
|
||||
- Markdown writes are atomic at an operator-selected destination. A
|
||||
pre-publication failure, including cancellation observed immediately before
|
||||
publication, does not replace an existing destination; a notification failure
|
||||
does not remove a newly published output.
|
||||
- Configuration or explicit CLI input selects that operator-owned destination;
|
||||
it does not create an application-owned state boundary.
|
||||
- Distributor uploads use only the published Markdown output, never a scan of
|
||||
local files. Single notification follows publication; batch notification
|
||||
follows publication of every selected report. Batch counters describe report
|
||||
outcomes only; a failed batch notification is represented separately at the
|
||||
batch level.
|
||||
- Default tests are deterministic, offline, and use Promptkit/provider fakes
|
||||
rather than live provider calls. See the [testing policy](testing.md).
|
||||
|
||||
Avoid dependencies for small conveniences. Do not let external dependency types leak across internal package boundaries unless the dependency is itself the explicit public contract of that package.
|
||||
|
||||
## Package Layout
|
||||
|
||||
Use this layout unless the project has a documented reason to differ:
|
||||
|
||||
- `internal/app`: application orchestration and top-level use cases.
|
||||
- `internal/cli`: CLI command definitions, flags, argument parsing, and command wiring.
|
||||
- `internal/config`: configuration structs, defaults, loading, precedence, and validation.
|
||||
- `internal/adapters/<name>`: adapters for external CLIs, APIs, databases, object stores, or libraries.
|
||||
- `internal/api`: HTTP API handlers and request/response types, when the application exposes an HTTP API.
|
||||
- `internal/transport/http`: HTTP client code, when the application calls HTTP services.
|
||||
|
||||
Package-private implementation constants may live near the package that owns them, preferably in `constants.go` when useful.
|
||||
|
||||
## Configuration
|
||||
|
||||
Centralize configuration loading, processing, precedence, defaults, and validation in `internal/config`.
|
||||
|
||||
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:
|
||||
|
||||
1. CLI flags
|
||||
2. environment variables
|
||||
3. configuration file
|
||||
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`.
|
||||
|
||||
Configuration files should not contain raw secrets unless the application is explicitly designed for that. Prefer environment variables or secret files for secrets.
|
||||
|
||||
## Adapters and External Integrations
|
||||
|
||||
Use a hexagonal architecture style for external integrations.
|
||||
|
||||
External adapters belong under `internal/adapters/<name>`. If an adapter uses an external dependency, that dependency’s interface must not leak outside the adapter package. Other packages should interact only with the adapter’s API, so the dependency can be swapped, upgraded, or removed without touching unrelated code.
|
||||
|
||||
Adapters should be thin. Domain decisions belong in application/domain packages, not inside adapter glue.
|
||||
|
||||
## Modules, Stages, 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.
|
||||
|
||||
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.
|
||||
|
||||
If users can select modules, stages, validators, renderers, or adapters, selection should go through a registry or equivalent mechanism rather than scattered conditionals.
|
||||
|
||||
## Embedded Assets
|
||||
|
||||
Store embedded JSON schemas, Markdown prompts, templates, and similar assets as separate files, not inline string literals, unless there is a strong reason otherwise.
|
||||
|
||||
## Errors and Logging
|
||||
|
||||
Errors should be actionable and preserve context. Wrap errors with operation and path/resource context. CLI code should convert internal errors into concise user-facing messages.
|
||||
|
||||
Errors and logs must not expose secrets.
|
||||
|
||||
Use structured logging where practical. Logs should describe operations, paths, external calls, retries, and failure causes, but should not include large user data by default.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
## Testing
|
||||
|
||||
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.
|
||||
|
||||
## Documentation
|
||||
|
||||
Documentation should follow the project documentation policy. Keep user docs focused on implemented behavior. Put future, planned, or aspirational work only under `docs/roadmap/`.
|
||||
|
||||
When changing architecture, config, CLI behavior, adapters, or stage/module contracts, update the relevant docs and examples in the same change.
|
||||
## Non-Goals
|
||||
|
||||
Weatherreporter is not a weather-data ingestion service, general LLM
|
||||
orchestration framework, plugin platform, HTTP service, multi-user job system,
|
||||
or a replacement for Promptkit or Distributor.
|
||||
|
||||
@@ -1,691 +0,0 @@
|
||||
# Weatherreporter Package Layout
|
||||
|
||||
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`.
|
||||
|
||||
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.
|
||||
|
||||
## Architectural Summary
|
||||
|
||||
`weatherreporter` is a deterministic weather briefing and report-preparation application. It should:
|
||||
|
||||
1. Fetch normalized weather data from a single configured internal weather API endpoint backed by `weatherfeeder`.
|
||||
2. Derive report-specific briefing packages from the normalized forecast bundle.
|
||||
3. Compare current briefing snapshots against prior comparable snapshots to produce optional Recent Changes.
|
||||
4. Build structured prompt input data packages for a specific report type.
|
||||
5. Invoke `scriptorium` as an external prompt runner.
|
||||
6. Persist the rendered Markdown report, briefing snapshot, prompt input package, and generation metadata.
|
||||
|
||||
The preferred data flow is:
|
||||
|
||||
```text
|
||||
weatherfeeder-backed internal API
|
||||
-> weather API adapter
|
||||
-> forecast bundle
|
||||
-> report-specific briefing builder
|
||||
-> recent-change comparison
|
||||
-> prompt input data package
|
||||
-> scriptorium subprocess adapter
|
||||
-> Markdown report + metadata + stored snapshot
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Proposed Directory Layout
|
||||
|
||||
```text
|
||||
cmd/weatherreporter/
|
||||
main.go
|
||||
|
||||
internal/app/
|
||||
generate.go
|
||||
scheduled.go
|
||||
storm.go
|
||||
|
||||
internal/cli/
|
||||
root.go
|
||||
generate.go
|
||||
run.go
|
||||
inspect.go
|
||||
|
||||
internal/config/
|
||||
config.go
|
||||
defaults.go
|
||||
load.go
|
||||
validate.go
|
||||
|
||||
internal/adapters/weatherapi/
|
||||
client.go
|
||||
types.go
|
||||
|
||||
internal/adapters/scriptorium/
|
||||
runner.go
|
||||
types.go
|
||||
|
||||
internal/forecast/
|
||||
bundle.go
|
||||
dayparts.go
|
||||
derive.go
|
||||
select.go
|
||||
thresholds.go
|
||||
|
||||
internal/report/
|
||||
definition.go
|
||||
registry.go
|
||||
period.go
|
||||
daily.go
|
||||
tomorrow.go
|
||||
three_day.go
|
||||
weekend.go
|
||||
storm.go
|
||||
|
||||
internal/briefing/
|
||||
package.go
|
||||
daily.go
|
||||
tomorrow.go
|
||||
three_day.go
|
||||
weekend.go
|
||||
storm.go
|
||||
|
||||
internal/changes/
|
||||
compare.go
|
||||
thresholds.go
|
||||
summary.go
|
||||
|
||||
internal/state/
|
||||
store.go
|
||||
filesystem.go
|
||||
metadata.go
|
||||
|
||||
internal/promptinput/
|
||||
build.go
|
||||
schema.go
|
||||
|
||||
internal/timeutil/
|
||||
clock.go
|
||||
periods.go
|
||||
```
|
||||
|
||||
This layout can be simplified during early prototyping if a package has only one file, but the package boundaries should remain conceptually stable.
|
||||
|
||||
## Dependency Direction
|
||||
|
||||
The intended dependency direction is:
|
||||
|
||||
```text
|
||||
cmd/weatherreporter
|
||||
-> internal/cli
|
||||
-> internal/app
|
||||
-> internal/config
|
||||
-> internal/report
|
||||
-> internal/briefing
|
||||
-> internal/forecast
|
||||
-> internal/changes
|
||||
-> internal/state
|
||||
-> internal/adapters/*
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `cmd/weatherreporter` should only bootstrap the CLI.
|
||||
- `internal/cli` should parse commands and flags, then call `internal/app`.
|
||||
- `internal/app` should orchestrate workflows but avoid embedding detailed forecast logic.
|
||||
- `internal/adapters/*` should not contain domain policy.
|
||||
- `internal/forecast`, `internal/report`, `internal/briefing`, and `internal/changes` should be testable without real external services.
|
||||
- `internal/state` should expose a storage interface so filesystem state can later be replaced or supplemented.
|
||||
- `scriptorium` details should not leak outside `internal/adapters/scriptorium`.
|
||||
|
||||
## Package Responsibilities
|
||||
|
||||
### `cmd/weatherreporter`
|
||||
|
||||
Entry point for the compiled binary.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Construct the root command from `internal/cli`.
|
||||
- Execute the command.
|
||||
- Handle final process exit behavior.
|
||||
|
||||
Non-responsibilities:
|
||||
|
||||
- No configuration loading details.
|
||||
- No forecast logic.
|
||||
- No direct calls to weather APIs, state stores, or `scriptorium`.
|
||||
|
||||
### `internal/cli`
|
||||
|
||||
Defines the user-facing command tree, flags, arguments, and command wiring.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Define commands such as:
|
||||
- `weatherreporter generate daily`
|
||||
- `weatherreporter generate tomorrow`
|
||||
- `weatherreporter generate three-day`
|
||||
- `weatherreporter generate weekend`
|
||||
- `weatherreporter generate storm`
|
||||
- `weatherreporter run morning`
|
||||
- `weatherreporter run evening`
|
||||
- `weatherreporter inspect snapshot`
|
||||
- Use the Go standard library for CLI parsing unless future complexity justifies a dependency.
|
||||
- Parse flags such as `--config`, `--units`, `--tz`, `--out`, optional Daily `--date`, and storm `--start`/`--end`, then convert them into app-layer request structs.
|
||||
- Load configuration through `internal/config`.
|
||||
- Present concise user-facing errors.
|
||||
|
||||
Non-responsibilities:
|
||||
|
||||
- No report-building logic.
|
||||
- No direct subprocess execution.
|
||||
- No direct weather API calls.
|
||||
- No state comparison logic.
|
||||
|
||||
Suggested command shape:
|
||||
|
||||
```text
|
||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
||||
weatherreporter generate tomorrow --out ./tomorrow.md
|
||||
weatherreporter generate three-day --out ./three_day.md
|
||||
weatherreporter generate weekend --out ./weekend.md
|
||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
|
||||
weatherreporter run morning
|
||||
weatherreporter run evening
|
||||
```
|
||||
|
||||
The MVP should not expose location selection. Source `locationId` and `locationName` values returned by the weather API may be retained as provenance.
|
||||
|
||||
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.
|
||||
|
||||
### `internal/config`
|
||||
|
||||
Owns configuration structures, defaults, loading, precedence, and validation.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Define application configuration structs.
|
||||
- Provide built-in defaults in `defaults.go`.
|
||||
- Load YAML configuration from `/usr/local/etc/weatherreporter/config.yml` or a CLI-supplied path.
|
||||
- Use `gopkg.in/yaml.v3` for YAML parsing.
|
||||
- Apply precedence rules.
|
||||
- Validate required settings.
|
||||
- Normalize paths, durations, report settings, weather API units/timezone, missing-source policy, and daypart definitions.
|
||||
|
||||
Suggested configuration areas:
|
||||
|
||||
- Weather API base URL, timeout, units, timezone, precision, and missing-source policy.
|
||||
- `scriptorium` binary, config path, profile, timeout, and optional extra arguments.
|
||||
- Workspace and output directories.
|
||||
- Report enablement and output naming.
|
||||
- Daypart definitions.
|
||||
- Recent-change thresholds.
|
||||
|
||||
Initial defaults:
|
||||
|
||||
- Weather API units: `us`.
|
||||
- Weather API timezone: `Chicago`.
|
||||
- Weather API format: `json`.
|
||||
- Missing-source policy: `warn`.
|
||||
|
||||
Missing-source policy should support a global default and per-source overrides. Valid policy values are `error`, `warn`, and `none`.
|
||||
|
||||
Non-responsibilities:
|
||||
|
||||
- No command execution.
|
||||
- No HTTP calls.
|
||||
- No report-building logic.
|
||||
|
||||
### `internal/app`
|
||||
|
||||
Application orchestration and top-level use cases.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Implement use cases such as:
|
||||
- Generate one report.
|
||||
- Run the morning batch.
|
||||
- Run the evening batch.
|
||||
- Generate a manual storm report.
|
||||
- Coordinate config, weather API adapter, report registry, briefing builders, state store, change comparison, prompt input builder, and `scriptorium` runner.
|
||||
- Enforce workflow order.
|
||||
- Ensure each generation run persists enough artifacts for inspection and future comparison.
|
||||
|
||||
The core generation workflow should be approximately:
|
||||
|
||||
```text
|
||||
resolve report definition
|
||||
resolve valid period
|
||||
fetch current weather bundle
|
||||
build current briefing package
|
||||
load prior comparable briefing snapshot
|
||||
compute recent changes
|
||||
build prompt input data package
|
||||
write data package
|
||||
run scriptorium render preflight
|
||||
invoke scriptorium run
|
||||
persist report metadata, briefing snapshot, data package, preflight output, and rendered report
|
||||
```
|
||||
|
||||
Non-responsibilities:
|
||||
|
||||
- No detailed daypart calculations.
|
||||
- No direct parsing of NWS text unless delegated to domain packages.
|
||||
- No direct shell command construction outside the `scriptorium` adapter.
|
||||
|
||||
### `internal/adapters/weatherapi`
|
||||
|
||||
HTTP adapter for the internal weather API backed by `weatherfeeder`.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Fetch normalized weather data from the configured API base URL.
|
||||
- Fan out to multiple weather API endpoints and assemble one internal `forecast.Bundle`.
|
||||
- Decode API responses into adapter-owned DTOs or directly into stable internal types if those types are intentionally owned by `weatherreporter`.
|
||||
- Apply request timeouts and context cancellation.
|
||||
- Apply configured query defaults, including `format=json`, `units=us`, and `tz=Chicago` unless overridden.
|
||||
- Fetch full `/forecast/hourly` and `/forecast/narrative` products, not day-slice endpoints, so Go domain code owns report-period selection.
|
||||
- Record per-source provenance: endpoint, query, fetch time, issued/updated time when available, SHA-256 over canonical/minified raw `data` JSON, warnings, and missing-source status.
|
||||
- Represent source warnings as first-class records with source name, code, severity, message, endpoint, and completeness impact.
|
||||
- Require hourly forecast data for normal scheduled reports.
|
||||
- Apply missing-source policy for `data:null`, malformed non-required sections, or unavailable upstream products.
|
||||
- Return actionable errors containing endpoint and operation context.
|
||||
|
||||
Initial data categories:
|
||||
|
||||
- Latest observation.
|
||||
- Current conditions.
|
||||
- Hourly forecast data.
|
||||
- NWS narrative forecast periods.
|
||||
- NWS alerts.
|
||||
- NWS forecast discussion.
|
||||
|
||||
Stubbed source slots until upstream support exists:
|
||||
|
||||
- Daily forecast data.
|
||||
- NWS weather story.
|
||||
|
||||
Non-responsibilities:
|
||||
|
||||
- No daypart grouping.
|
||||
- No Recent Changes comparison.
|
||||
- No prompt input construction.
|
||||
- No `scriptorium` calls.
|
||||
|
||||
### `internal/adapters/scriptorium`
|
||||
|
||||
Subprocess adapter for invoking `scriptorium`.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Provide a narrow runner interface, such as:
|
||||
|
||||
```go
|
||||
type Runner interface {
|
||||
Render(ctx context.Context, req RenderRequest) (*RenderResult, error)
|
||||
Run(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
}
|
||||
```
|
||||
|
||||
- Execute `scriptorium render` for preflight/debug output without LLM generation.
|
||||
- Execute `scriptorium run` for report generation.
|
||||
- Run `scriptorium render` as an always-on preflight before `scriptorium run` for MVP generated reports.
|
||||
- Pass arguments as an argv slice, not through a shell.
|
||||
- Pass large prompt input as `--input data_package=<path>`.
|
||||
- Capture stdout/stderr with reasonable size limits.
|
||||
- Treat nonzero exits as actionable errors, including exit code `2` from `run`, which may still produce output.
|
||||
- Keep all `scriptorium`-specific flag details inside the adapter.
|
||||
|
||||
Suggested command forms:
|
||||
|
||||
```text
|
||||
scriptorium render \
|
||||
--prompt weather.daily_report \
|
||||
--input data_package=./workspace/data-packages/daily/2026-05-29T050000-0500.data_package.json \
|
||||
--format json
|
||||
|
||||
scriptorium run \
|
||||
--prompt weather.daily_report \
|
||||
--input data_package=./workspace/data-packages/daily/2026-05-29T050000-0500.data_package.json \
|
||||
--out ./workspace/reports/daily/2026-05-29T050000-0500.md
|
||||
```
|
||||
|
||||
Non-responsibilities:
|
||||
|
||||
- No weather logic.
|
||||
- No report registry logic.
|
||||
- No decision about which prompt to run.
|
||||
|
||||
Future note:
|
||||
|
||||
- A native LLM client can later replace or supplement this adapter behind a similar interface.
|
||||
|
||||
### `internal/forecast`
|
||||
|
||||
Core forecast-domain processing.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Define the normalized `Bundle` consumed by report builders.
|
||||
- Group hourly forecast data into configured dayparts.
|
||||
- Compute derived facts, including:
|
||||
- Temperature ranges.
|
||||
- Apparent-temperature ranges, if available.
|
||||
- Max precipitation probability.
|
||||
- Peak wind and wind gusts.
|
||||
- Precipitation windows.
|
||||
- Thunder mentions.
|
||||
- Snow/ice/freezing risk indicators.
|
||||
- Alert overlap with relevant periods.
|
||||
- Select forecast elements relevant to a report period.
|
||||
- Provide threshold helpers for impact detection.
|
||||
|
||||
Non-responsibilities:
|
||||
|
||||
- No CLI behavior.
|
||||
- No external API calls.
|
||||
- No rendered prose.
|
||||
- No direct `scriptorium` calls.
|
||||
|
||||
### `internal/report`
|
||||
|
||||
Report definitions, registry, period resolution, and report-level contracts.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Define report IDs and report definition contracts.
|
||||
- Register report types and variants.
|
||||
- Resolve valid periods for each report.
|
||||
- Associate report types with prompt IDs.
|
||||
- Define comparison strategies and output naming behavior.
|
||||
|
||||
Suggested report definitions:
|
||||
|
||||
```text
|
||||
daily_today -> prompt weather.daily_report
|
||||
daily_tomorrow -> prompt weather.daily_report
|
||||
three_day -> prompt weather.three_day_outlook
|
||||
weekend -> prompt weather.weekend_outlook
|
||||
storm -> prompt weather.storm_report
|
||||
```
|
||||
|
||||
`weather.daily_report` should be the standard prompt for one local civil day, regardless of whether that day is today or tomorrow.
|
||||
|
||||
A report definition should describe:
|
||||
|
||||
- Report ID.
|
||||
- Human-readable name.
|
||||
- Prompt ID.
|
||||
- Valid-period resolver.
|
||||
- Briefing builder ID or function.
|
||||
- Recent-change comparison strategy.
|
||||
- Default output naming pattern.
|
||||
- Whether the report participates in morning or evening scheduled batches.
|
||||
|
||||
Non-responsibilities:
|
||||
|
||||
- No detailed forecast computation.
|
||||
- No state storage.
|
||||
- No subprocess execution.
|
||||
|
||||
### `internal/briefing`
|
||||
|
||||
Builds report-specific briefing packages from forecast bundles and report definitions.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Convert a forecast bundle into a report-specific structured briefing package.
|
||||
- Keep each report's briefing shape explicit and testable.
|
||||
- Attach relevant NWS narrative periods, alerts, forecast discussion context, and weather story context when available.
|
||||
- Include metadata such as schema version, configured units/timezone, source warnings, and source provenance.
|
||||
- Provide inputs suitable for `scriptorium` data packages.
|
||||
|
||||
Report-specific builders should exist for:
|
||||
|
||||
- Daily Report.
|
||||
- Tomorrow Planning Brief.
|
||||
- 3-Day Outlook.
|
||||
- Weekend Outlook.
|
||||
- Storm Report.
|
||||
|
||||
Non-responsibilities:
|
||||
|
||||
- No external API fetching.
|
||||
- No final prose rendering.
|
||||
- No state persistence, except through app orchestration.
|
||||
|
||||
Design note:
|
||||
|
||||
- This package is the architectural center of the application. A clean briefing package makes `scriptorium` a renderer rather than a source of weather reasoning.
|
||||
|
||||
### `internal/changes`
|
||||
|
||||
Structured comparison of current and prior briefing snapshots.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Compare current briefing packages against prior comparable snapshots.
|
||||
- Apply meaningful-change thresholds.
|
||||
- Produce compact structured change summaries for prompt input data packages.
|
||||
- Avoid comparison of rendered Markdown report text.
|
||||
|
||||
Comparable snapshot matching should be declared by each report definition. Daily Today, Daily Tomorrow, and compatible date slices from multi-day reports may compare by same valid local date when the report registry marks them compatible. Weekend compares by same weekend window. Storm compares by explicit event window.
|
||||
|
||||
Meaningful changes may include:
|
||||
|
||||
- Temperature changes crossing configured thresholds.
|
||||
- Precipitation probability changes by category.
|
||||
- Precipitation timing shifts.
|
||||
- New, canceled, extended, upgraded, or expanded alerts.
|
||||
- Wind gust threshold crossings.
|
||||
- Snow/ice/freezing risk changes.
|
||||
- Severe-weather wording or risk changes.
|
||||
- Confidence or uncertainty changes, if represented in structured briefing data.
|
||||
|
||||
Non-responsibilities:
|
||||
|
||||
- No fetching prior state directly unless mediated through app/state contracts.
|
||||
- No final report prose.
|
||||
- No external calls.
|
||||
|
||||
### `internal/state`
|
||||
|
||||
Durable state store for reports, snapshots, data packages, preflight output, metadata, and comparison lookup.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Persist generated report metadata.
|
||||
- Persist briefing snapshots.
|
||||
- Persist prompt input data packages.
|
||||
- Persist `scriptorium render` preflight output for generated reports.
|
||||
- Locate prior comparable snapshots for Recent Changes.
|
||||
- Track RunID as generation timestamp plus report ID.
|
||||
- Use timestamped managed report names to avoid overwriting prior runs for the same valid period.
|
||||
- Use atomic writes where practical.
|
||||
- Keep filesystem layout narrow and predictable.
|
||||
|
||||
Initial backend:
|
||||
|
||||
- Filesystem state.
|
||||
|
||||
Potential future backend:
|
||||
|
||||
- SQLite or another state database, behind the same store interface.
|
||||
|
||||
Suggested state layout:
|
||||
|
||||
```text
|
||||
workspace/
|
||||
snapshots/
|
||||
daily/
|
||||
2026-05-30/
|
||||
2026-05-29T050000-0500.briefing.json
|
||||
2026-05-29T050000-0500.metadata.json
|
||||
three-day/
|
||||
weekend/
|
||||
storm/
|
||||
reports/
|
||||
daily/
|
||||
2026-05-29T050000-0500.md
|
||||
three-day/
|
||||
weekend/
|
||||
storm/
|
||||
data-packages/
|
||||
daily/
|
||||
2026-05-29T050000-0500.data_package.json
|
||||
preflight/
|
||||
daily/
|
||||
2026-05-29T050000-0500.render.json
|
||||
```
|
||||
|
||||
Non-responsibilities:
|
||||
|
||||
- No weather derivation.
|
||||
- No report prose generation.
|
||||
- No CLI formatting decisions.
|
||||
|
||||
### `internal/promptinput`
|
||||
|
||||
Builds the final data package passed to `scriptorium`.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Combine report metadata, briefing package, Recent Changes, selected source context, and source warnings into a prompt input document.
|
||||
- Validate required data package fields before invoking `scriptorium`.
|
||||
- Keep data package schemas explicit enough to test.
|
||||
- Write data package files to the workspace when requested by the app layer.
|
||||
|
||||
Non-responsibilities:
|
||||
|
||||
- No weather API calls.
|
||||
- No forecast derivation.
|
||||
- No subprocess execution.
|
||||
|
||||
### `internal/timeutil`
|
||||
|
||||
Time, clock, and period helpers.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Provide an injectable clock for deterministic tests.
|
||||
- Resolve local dates using the configured report timezone.
|
||||
- Handle daypart spans, including overnight windows.
|
||||
- Normalize valid periods.
|
||||
- Provide helpers for recurring scheduled batches.
|
||||
|
||||
Non-responsibilities:
|
||||
|
||||
- No report-specific forecast logic unless delegated by `internal/report`.
|
||||
- No external calls.
|
||||
|
||||
## Report Types and Valid-Period Identity
|
||||
|
||||
Each generated report must be associated with explicit metadata:
|
||||
|
||||
- RunID.
|
||||
- Report type.
|
||||
- Report variant, if applicable.
|
||||
- Generation time.
|
||||
- Configured report timezone.
|
||||
- Valid period start.
|
||||
- Valid period end.
|
||||
- Source location ID/name when provided by upstream.
|
||||
- Source product timestamps and/or SHA-256 hashes.
|
||||
- Source warnings.
|
||||
- Briefing snapshot path.
|
||||
- Prompt input data package path.
|
||||
- Preflight output path.
|
||||
- Rendered report path.
|
||||
|
||||
All valid periods should use the configured local timezone, default `Chicago`, and half-open `[start,end)` intervals.
|
||||
|
||||
Initial valid-period rules:
|
||||
|
||||
- Daily Today: current local civil day, `[00:00, next 00:00)`.
|
||||
- Daily Tomorrow: next local civil day.
|
||||
- 3-Day Outlook: generation time through local midnight after the second following local civil day.
|
||||
- Weekend Outlook: Monday through Thursday covers Saturday 00:00 to Monday 00:00; Friday and Saturday cover `max(generation time, Friday 18:00)` to Monday 00:00; scheduled Sunday morning skips Weekend Outlook.
|
||||
- Manual Storm Report: requires explicit `--start` and `--end`; accept `YYYY-MM-DDTHH:MM` interpreted in the configured timezone and RFC3339 timestamps with explicit offsets.
|
||||
|
||||
The valid period should identify what weather period the report covers, independent of when the report was generated.
|
||||
|
||||
Examples:
|
||||
|
||||
- A 5 PM Tomorrow Planning Brief for Saturday and a 5 AM Saturday Daily Report both cover the same valid date.
|
||||
- A Saturday Weekend Outlook covers the remaining weekend, while a Friday Weekend Outlook may cover Friday evening through Sunday night.
|
||||
- A Storm Report covers an explicit forecast event window, not a fixed calendar day.
|
||||
|
||||
This identity is required for reliable Recent Changes behavior.
|
||||
|
||||
## Scheduled Batch Semantics
|
||||
|
||||
The app should support scheduled batches but should not need to be a daemon in the initial version.
|
||||
|
||||
Suggested batches:
|
||||
|
||||
```text
|
||||
morning:
|
||||
- daily_today
|
||||
- three_day
|
||||
- weekend, except Sunday
|
||||
|
||||
evening:
|
||||
- daily_tomorrow
|
||||
```
|
||||
|
||||
Scheduled batches should continue independent reports after a report failure. The CLI should return nonzero if any report failed and should emit an aggregate run summary.
|
||||
|
||||
External scheduling should be handled by systemd timers, cron, or another orchestrator. `weatherreporter` should simply provide deterministic commands that can be scheduled.
|
||||
|
||||
## Storm Report Direction
|
||||
|
||||
The initial version should support manual Storm Report generation:
|
||||
|
||||
```text
|
||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
|
||||
```
|
||||
|
||||
Future storm monitoring should use a staged design:
|
||||
|
||||
```text
|
||||
incoming weather data
|
||||
-> deterministic candidate detector
|
||||
-> LLM event evaluator
|
||||
-> storm lifecycle state
|
||||
-> storm report generation or skip decision
|
||||
```
|
||||
|
||||
Potential storm lifecycle states:
|
||||
|
||||
```text
|
||||
none -> monitoring -> active_report -> escalated -> deescalating -> resolved
|
||||
```
|
||||
|
||||
This future behavior should not be built before the core scheduled reports are stable, but the package layout should leave room for it.
|
||||
|
||||
## Testing Expectations
|
||||
|
||||
Core tests should not require real external services.
|
||||
|
||||
Priority test areas:
|
||||
|
||||
- Configuration loading and validation, including defaults for `units=us`, `tz=Chicago`, and missing-source policy `warn`.
|
||||
- Standard-library CLI command parsing, including `--units`, `--tz`, and storm `--start`/`--end`.
|
||||
- Weather API fan-out, source provenance, `data:null`, and missing-source policy behavior.
|
||||
- Daypart grouping, especially overnight periods.
|
||||
- Valid-period resolution for each report type.
|
||||
- Briefing package construction from fixtures.
|
||||
- Recent Changes threshold behavior and compatible snapshot matching.
|
||||
- Prior snapshot lookup.
|
||||
- `scriptorium` adapter behavior using a fake executable or command runner, including both `render` and `run` with `--input data_package=<path>`.
|
||||
- Batch partial-failure behavior and aggregate exit status.
|
||||
|
||||
## Design Invariants
|
||||
|
||||
Preserve these invariants as the project evolves:
|
||||
|
||||
- Weather facts come from normalized source data, not from the LLM.
|
||||
- The LLM receives curated briefing packages, not unbounded raw weather payloads.
|
||||
- Recent Changes are based on structured snapshot comparison, not Markdown diffing.
|
||||
- Report types are registered or otherwise centrally defined.
|
||||
- External integrations are thin adapters.
|
||||
- CLI code wires workflows but does not own domain logic.
|
||||
- The first durable state backend is filesystem-based and inspectable.
|
||||
- `scriptorium` is an adapter boundary, not an application dependency that leaks across packages.
|
||||
@@ -1,356 +1,230 @@
|
||||
# Go Project Documentation Policy
|
||||
# Documentation Policy
|
||||
|
||||
## Purpose
|
||||
|
||||
Project documentation must help four audiences:
|
||||
|
||||
1. users who need to run the application;
|
||||
2. administrators/operators who need to configure and operate it;
|
||||
3. developers who need to understand and change it safely;
|
||||
4. LLM coding agents that need clear scope, boundaries, and invariants.
|
||||
|
||||
Docs should be accurate, concise, task-oriented, and organized by audience. Prefer links to canonical docs over repetition.
|
||||
This policy assigns each Weatherreporter documentation topic to one canonical
|
||||
owner. Its goal is to keep documentation accurate, concise, discoverable, and
|
||||
resistant to drift for users, operators, developers, integrators, maintainers,
|
||||
and coding agents.
|
||||
|
||||
## Core Rules
|
||||
|
||||
### 1. Keep docs concise
|
||||
|
||||
Each document should cover a defined scope and only the essentials for that scope.
|
||||
|
||||
Avoid:
|
||||
- long background explanations;
|
||||
- repeated reference material;
|
||||
- implementation detail in user-facing docs;
|
||||
- aspirational language outside roadmap docs;
|
||||
- verbose examples where one minimal example is clearer.
|
||||
|
||||
### 2. Document only implemented behavior outside roadmap files
|
||||
|
||||
Unimplemented, planned, aspirational, experimental, or future work may be described only under:
|
||||
|
||||
- `docs/roadmap/`
|
||||
|
||||
No other documentation file, including `README.md`, should describe code, features, modules, stages, commands, config fields, or behaviors that do not currently exist.
|
||||
|
||||
If a feature is partial, non-roadmap docs may describe only the implemented portion and its current boundary.
|
||||
|
||||
### 3. Use canonical homes
|
||||
|
||||
Each type of information should have one canonical location.
|
||||
|
||||
Canonical homes:
|
||||
|
||||
- project purpose and quickstart: `README.md`
|
||||
- development principles: `docs/policy/architecture.md`
|
||||
- configuration reference: `docs/config.md`
|
||||
- CLI reference: `docs/cli.md`
|
||||
- operations and recovery: `docs/operations.md`
|
||||
- troubleshooting: `docs/troubleshooting.md`
|
||||
- implemented internals: `docs/internal/`
|
||||
- future work: `docs/roadmap/`
|
||||
- contributor workflow: `docs/policy/development.md`
|
||||
- copyable examples: `examples/`
|
||||
|
||||
Other files should summarize briefly and link to the canonical source.
|
||||
|
||||
### 4. Keep examples real
|
||||
|
||||
Examples should be valid, maintained, and free of secrets.
|
||||
|
||||
Where practical:
|
||||
- example configs should load successfully;
|
||||
- example commands should match real CLI syntax;
|
||||
- important examples should be covered by tests.
|
||||
|
||||
## Documentation Profiles
|
||||
|
||||
All projects require:
|
||||
|
||||
- `README.md`
|
||||
- `docs/policy/architecture.md`
|
||||
|
||||
Additional docs depend on the project.
|
||||
|
||||
### Small library
|
||||
|
||||
Recommended:
|
||||
- `docs/policy/development.md`, if contributor conventions are non-obvious
|
||||
|
||||
### Simple CLI
|
||||
|
||||
Required:
|
||||
- `docs/cli.md`
|
||||
|
||||
Recommended:
|
||||
- `docs/policy/development.md`
|
||||
|
||||
### Config-driven CLI
|
||||
|
||||
Required:
|
||||
- `docs/cli.md`
|
||||
- `docs/config.md`
|
||||
|
||||
Recommended:
|
||||
- `examples/`
|
||||
- `docs/policy/development.md`
|
||||
|
||||
### Stateful or operator-facing application
|
||||
|
||||
Required:
|
||||
- `docs/cli.md`, if CLI-based
|
||||
- `docs/config.md`, if config-driven
|
||||
- `docs/operations.md`
|
||||
|
||||
Recommended:
|
||||
- `docs/troubleshooting.md`
|
||||
- `examples/`
|
||||
- `docs/policy/development.md`
|
||||
|
||||
### Modular, staged, service-oriented, or orchestration application
|
||||
|
||||
Required:
|
||||
- `docs/cli.md`, if CLI-based
|
||||
- `docs/config.md`, if config-driven
|
||||
- `docs/operations.md`
|
||||
- `docs/internal/`
|
||||
- `docs/policy/development.md`
|
||||
|
||||
Recommended:
|
||||
- `docs/troubleshooting.md`
|
||||
- validated examples under `examples/`
|
||||
|
||||
## Required Documents
|
||||
|
||||
### README.md
|
||||
|
||||
**Audience:** users, administrators, operators
|
||||
|
||||
The README is the outward-facing project orientation page.
|
||||
|
||||
It should include, in order:
|
||||
|
||||
1. concise description;
|
||||
2. elevator pitch;
|
||||
3. shortest useful command or usage example;
|
||||
4. links to targeted docs.
|
||||
|
||||
The README should be short. It is not a manual.
|
||||
|
||||
The “shortest useful command” means the simplest command that performs the project’s core use case. (It does not mean `app --help`.)
|
||||
|
||||
### docs/policy/architecture.md
|
||||
|
||||
**Audience:** developers, LLM coding agents
|
||||
|
||||
`docs/policy/architecture.md` is required for every project.
|
||||
|
||||
It is an inward-facing development policy document. It should describe how the project is intended to be built and changed.
|
||||
|
||||
It should include:
|
||||
|
||||
- project shape;
|
||||
- core design principles;
|
||||
- package and boundary philosophy;
|
||||
- state/persistence philosophy, if applicable;
|
||||
- external integration philosophy, if applicable;
|
||||
- error-handling and logging principles;
|
||||
- testing expectations;
|
||||
- documentation expectations;
|
||||
- architectural invariants;
|
||||
- explicit non-goals, if useful.
|
||||
|
||||
For small projects, this file may be brief. It may simply state that the project is intentionally narrow, monolithic, and dependency-light.
|
||||
|
||||
### docs/policy/development.md
|
||||
|
||||
**Audience:** developers, LLM coding agents
|
||||
|
||||
Required for projects maintained by humans and LLM coding agents.
|
||||
|
||||
It should include:
|
||||
|
||||
- repository layout;
|
||||
- build/test commands;
|
||||
- coding conventions;
|
||||
- dependency policy;
|
||||
- how to add config fields;
|
||||
- how to add CLI flags;
|
||||
- how to add stages/modules/adapters, if applicable;
|
||||
- how to update examples;
|
||||
- documentation update expectations.
|
||||
|
||||
### docs/config.md
|
||||
|
||||
**Audience:** administrators, operators, advanced users
|
||||
|
||||
Required for applications with configuration files.
|
||||
|
||||
It should include, in order:
|
||||
|
||||
1. config file locations and discovery precedence;
|
||||
2. minimal working config;
|
||||
3. production-oriented config;
|
||||
4. full configuration reference;
|
||||
5. secrets handling, if applicable;
|
||||
6. links to maintained examples.
|
||||
|
||||
The full configuration reference should be canonical.
|
||||
|
||||
### docs/cli.md
|
||||
|
||||
**Audience:** users, administrators, operators
|
||||
|
||||
Required for CLI applications.
|
||||
|
||||
It should include, in order:
|
||||
|
||||
1. shortest useful command;
|
||||
2. command overview;
|
||||
3. complete flag reference;
|
||||
4. common workflows;
|
||||
5. diagnostic or recovery commands, if applicable.
|
||||
|
||||
Explain when commands are useful, not just their syntax.
|
||||
|
||||
### docs/operations.md
|
||||
|
||||
**Audience:** administrators, operators
|
||||
|
||||
Required for applications that maintain state, support resume behavior, run multiple stages, write durable artifacts, use remote storage, or require recovery procedures.
|
||||
|
||||
It should cover:
|
||||
|
||||
- normal workflow;
|
||||
- filesystem layout;
|
||||
- remote storage layout, if applicable;
|
||||
- logs and manifests;
|
||||
- resume/retry behavior;
|
||||
- cleanup behavior;
|
||||
- archive/backup behavior;
|
||||
- safe recovery procedures;
|
||||
- operational caveats.
|
||||
|
||||
### docs/troubleshooting.md
|
||||
|
||||
**Audience:** administrators, operators
|
||||
|
||||
Recommended once recurring failure modes exist.
|
||||
|
||||
Each entry should include:
|
||||
|
||||
- symptom;
|
||||
- likely cause;
|
||||
- diagnostic command or inspection step;
|
||||
- safe fix;
|
||||
- relevant links.
|
||||
|
||||
### docs/internal/
|
||||
|
||||
**Audience:** developers, LLM coding agents
|
||||
|
||||
Required for modular, staged, service-oriented, or orchestration projects.
|
||||
|
||||
This directory describes implemented internal components. It is not the roadmap.
|
||||
|
||||
Use one file per major component where useful.
|
||||
|
||||
Each component doc should include:
|
||||
|
||||
1. purpose;
|
||||
2. inputs and outputs;
|
||||
3. boundaries;
|
||||
4. config fields used;
|
||||
5. external adapters used;
|
||||
6. state or manifest behavior, if applicable;
|
||||
7. skip/resume behavior, if applicable;
|
||||
8. failure behavior;
|
||||
9. tests to inspect before changing;
|
||||
10. architectural invariants.
|
||||
|
||||
### docs/roadmap/
|
||||
|
||||
**Audience:** maintainers, developers, LLM coding agents
|
||||
|
||||
This is the only place for planned, future, aspirational, experimental, or unimplemented work.
|
||||
|
||||
Roadmap docs should clearly distinguish:
|
||||
|
||||
- proposed work;
|
||||
- accepted plans;
|
||||
- deferred ideas;
|
||||
- rejected ideas;
|
||||
- implementation prompts or task breakdowns, if useful.
|
||||
|
||||
Roadmap docs should not be confused with current behavior.
|
||||
|
||||
### docs/integrations/
|
||||
|
||||
**Audience:** developers, LLM coding agents
|
||||
|
||||
Required for projects that depend on external CLIs, APIs, services, protocols, or file formats where the integration contract is important to maintain.
|
||||
|
||||
This directory contains concise, versioned reference notes for external integration contracts. It should document only the parts of the external system that this project actually uses.
|
||||
|
||||
Use one file per integration where useful.
|
||||
|
||||
## Examples Directory
|
||||
|
||||
Projects with non-trivial configuration or workflows should include `examples/`.
|
||||
|
||||
Useful examples include:
|
||||
|
||||
- minimal working config;
|
||||
- production-oriented config;
|
||||
- full annotated config;
|
||||
- local development config;
|
||||
- remote/object-storage config;
|
||||
- minimal session/input file.
|
||||
|
||||
Examples should be valid, maintained, tested when practical, and linked from relevant docs.
|
||||
|
||||
## Security and Privacy
|
||||
|
||||
Docs and examples must not include:
|
||||
|
||||
- real API keys;
|
||||
- tokens;
|
||||
- passwords;
|
||||
- private keys;
|
||||
- private environment dumps;
|
||||
- sensitive user data;
|
||||
- raw private transcripts;
|
||||
- private infrastructure details unless intentionally public.
|
||||
|
||||
Document secret-handling mechanisms, not actual secret values.
|
||||
|
||||
## Maintenance Rules
|
||||
|
||||
When docs change, verify the affected behavior.
|
||||
|
||||
Where practical:
|
||||
|
||||
- load example config files in tests;
|
||||
- test CLI examples or command parser behavior;
|
||||
- validate documented flags against real flags;
|
||||
- remove stale references;
|
||||
- update links after renames;
|
||||
- keep roadmap content out of non-roadmap docs.
|
||||
|
||||
If documentation and code disagree, fix the documentation and/or open a roadmap item; do not leave aspirational behavior in current-behavior docs.
|
||||
|
||||
Documentation is complete only when it matches the current code.
|
||||
|
||||
## Documentation Change Checklist
|
||||
|
||||
Before merging documentation changes, verify:
|
||||
|
||||
- README is concise and orientation-focused.
|
||||
- `docs/policy/architecture.md` describes development principles.
|
||||
- Future work appears only under `docs/roadmap/`.
|
||||
- User-facing docs avoid unnecessary internals.
|
||||
- Developer-facing docs preserve boundaries and invariants.
|
||||
- Config examples match the schema.
|
||||
- CLI examples match real commands and flags.
|
||||
- Defaults appear in the canonical config reference.
|
||||
- No secrets or private data are included.
|
||||
- Links are accurate.
|
||||
### One Canonical Documentation Owner
|
||||
|
||||
Each authoritative fact belongs in one canonical document or documentation
|
||||
area. A non-owning document may give a short, stable summary for orientation,
|
||||
but it must link to the canonical owner instead of maintaining a second
|
||||
definition.
|
||||
|
||||
Volatile details include commands, flags, configuration fields and defaults,
|
||||
report and module IDs, schemas, file names, paths, status and exit behavior,
|
||||
retry behavior, and runtime guarantees. If readers could reasonably treat a
|
||||
statement as a contract, its exact documentation belongs with the owner named
|
||||
in this policy.
|
||||
|
||||
Executable sources of truth and documentation owners serve different purposes.
|
||||
Code, schemas, and embedded assets determine runtime behavior. The canonical
|
||||
document owns the corresponding explanation or reference for readers. Both may
|
||||
necessarily express the same contract, but other documentation should summarize
|
||||
and link rather than create another complete reference. When implementation and
|
||||
documentation disagree, verify the intended behavior and update them together.
|
||||
|
||||
### Current State, Decisions, And Future Work
|
||||
|
||||
Outside `docs/roadmap/`, documentation describes implemented behavior only.
|
||||
Partial features may be described only to their implemented boundary.
|
||||
|
||||
An accepted architecture decision may describe an approved direction before it
|
||||
is implemented, but acceptance is not evidence that the behavior exists.
|
||||
Current-state documents change when the implementation lands. Temporary
|
||||
roadmaps own future work, sequencing, and implementation status; they do not
|
||||
replace durable policies, decisions, or current contracts.
|
||||
|
||||
### Audience And Detail
|
||||
|
||||
Write for the document's stated audience and include only the detail needed for
|
||||
its owned topic. User and operator documentation should not expose incidental
|
||||
implementation detail. Developer documentation should link to user-facing and
|
||||
external contracts instead of restating them.
|
||||
|
||||
### Links
|
||||
|
||||
Use descriptive link text and repository-relative links for repository
|
||||
documents. Link to the canonical owner rather than to a duplicate summary.
|
||||
Check every added or changed link, and repair or remove links when their target
|
||||
moves or is retired.
|
||||
|
||||
### Examples And Code Fences
|
||||
|
||||
Complete copyable files belong in `examples/` when maintained examples exist.
|
||||
Documentation may use the smallest illustrative snippet needed for its owned
|
||||
topic, but should link to a maintained example instead of embedding a second
|
||||
complete copy.
|
||||
|
||||
Examples must be valid, secret-free, and tested where practical. Commands,
|
||||
flags, configuration, imports, and Go snippets must match implemented behavior.
|
||||
Use a language tag on fenced code blocks, and identify fragments that are
|
||||
illustrative rather than directly runnable.
|
||||
|
||||
### Security And Privacy
|
||||
|
||||
Documentation and examples must not contain real credentials, private keys,
|
||||
private environment dumps, sensitive source material, or private
|
||||
infrastructure details unless intentionally public. Document secret-handling
|
||||
mechanisms, not secret values.
|
||||
|
||||
## Canonical Ownership
|
||||
|
||||
| Topic | Canonical owner | Owned content | Content owned elsewhere |
|
||||
| --- | --- | --- | --- |
|
||||
| Product orientation and minimal quickstart | `README.md` | What Weatherreporter is, why it is useful, one shortest successful invocation, and links onward. | Complete command reference, configuration reference, operational procedures, architecture, and implementation detail. |
|
||||
| Contributor workflow and package inventory | `docs/development.md` | Repository layout, local workflow, validation commands, coding conventions, task-specific change guidance, dependency workflow, and repository hygiene. | Architectural invariants, user-facing contracts, detailed subsystem behavior, and future work. |
|
||||
| Current application architecture | `docs/policy/architecture.md` | System shape, normative ownership, dependency direction, package boundaries, invariants, safety properties, and non-goals. | Concrete implementation mechanics, contributor procedures, decision history, and future work. |
|
||||
| Documentation organization | `docs/policy/documentation.md` | Documentation ownership, audience boundaries, maintenance rules, and document lifecycle. | Application architecture and runtime behavior. |
|
||||
| Testing policy | `docs/policy/testing.md` | Test philosophy, risk-based sufficiency, stable test boundaries, doubles, coverage guidance, regression policy, and criteria for adding, rewriting, or deleting tests. | Subsystem behavior, application contracts, subsystem-specific test inventories, and implementation plans. |
|
||||
| Release procedure | `docs/release.md` | Version policy, release preparation, validation, tagging, automated publication, verification, failure handling, and release ordering. | General contributor workflow, product contracts, release-specific change summaries, and implementation history. |
|
||||
| Release notes | `docs/releases/` | One versioned, changelog-style summary for each release, including compatibility and operator action. The file at the tagged commit supplies the corresponding Gitea release body. | Current CLI, configuration, operations, integration, architecture, and internal contracts; release procedure; implementation plans. |
|
||||
| CLI contract | `docs/cli.md` | Commands, arguments, flags, invocation semantics, stdout and stderr behavior, summaries, and exit behavior. | Configuration field definitions, complete operating procedures, runtime filesystem layout, and command implementation. |
|
||||
| Configuration contract | `docs/config.md` | Discovery and precedence, fields, defaults, secrets, validation rules, and user-selectable values. | Complete example files, CLI syntax, output lifecycle, and loading implementation. |
|
||||
| Operations | `docs/operations.md` | Normal output handling, atomic replacement, notification behavior, diagnosis, explicit debug capture, manual legacy-workspace cleanup, permissions, and operational caveats. | Complete CLI syntax, configuration field definitions, logical external contracts, and implementation mechanics. |
|
||||
| Report template surface | `docs/templates.md` | Implemented template files and partials, render-context fields, editing rules, and maintainer-facing template examples. | Weather derivation, module implementation, generated-text validation internals, and operator procedures. |
|
||||
| External and durable integration contracts | `docs/integrations/` | Weather API, Promptkit, Distributor, external formats and protocols, durable logical paths and schemas, compatibility behavior, and upstream or downstream responsibilities. | Physical runtime placement and lifecycle, internal transformations, CLI syntax, and configuration defaults. |
|
||||
| Internal subsystem behavior | `docs/internal/` | Implementation flow, internal collaborators and state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, user-facing contracts, external schemas, operator procedures, and future package plans. |
|
||||
| Architectural decision history | `docs/adr/`, when repository-local decisions require records | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, and task sequencing. |
|
||||
| Temporary feature roadmaps | `docs/roadmap/`, while planned work needs coordination | Proposed, accepted, deferred, or rejected work; sequencing; gates; implementation status; and task breakdowns. | Implemented behavior reference and durable decision rationale. |
|
||||
| Complete copyable artifacts | `examples/` | Maintained configuration and other files intended to be copied or run. | Field-by-field reference, command reference, and prose explanation. |
|
||||
|
||||
Conditional owners do not require placeholder files or directories. If
|
||||
Weatherreporter introduces a new public API, consumer interface, release
|
||||
process, or other durable documentation responsibility, update this policy to
|
||||
assign its canonical owner when that responsibility is introduced.
|
||||
|
||||
## Boundary Rules
|
||||
|
||||
### Orientation, Architecture, And Internals
|
||||
|
||||
The README owns product orientation. The development policy routes contributors
|
||||
and owns the concise current package inventory. Architecture owns normative
|
||||
structure and invariants. Focused internal documents own implementation
|
||||
behavior. These documents may link to one another but must not maintain
|
||||
parallel package or behavior references.
|
||||
|
||||
### Commands, Configuration, And Operations
|
||||
|
||||
CLI documentation answers how to invoke Weatherreporter and what its command
|
||||
interface does. Configuration documentation answers what settings mean.
|
||||
Operations answers how to handle operator-owned outputs and runtime failures,
|
||||
including diagnosis, explicit debug capture, and safe legacy cleanup.
|
||||
|
||||
When a workflow crosses these topics, place the complete procedure with the
|
||||
document that owns the task and link to the other contracts. Do not duplicate
|
||||
complete flag, field, or path references to make a workflow self-contained.
|
||||
|
||||
### Templates, Integrations, And Implementation
|
||||
|
||||
Template documentation defines the maintainer-facing rendering surface.
|
||||
Integration documentation defines externally observable shapes, logical paths,
|
||||
protocols, and compatibility behavior. Internal documentation explains how
|
||||
Weatherreporter produces, transforms, or consumes those contracts.
|
||||
|
||||
Internal documents may name a command, field, template value, path, or protocol
|
||||
to identify a dependency, but must link to its canonical documentation for the
|
||||
complete definition.
|
||||
|
||||
### Release Procedure And Release Notes
|
||||
|
||||
The release procedure owns how a maintainer prepares, publishes, verifies, and
|
||||
recovers from a Weatherreporter release. Release notes under `docs/releases/`
|
||||
own the concise historical summary for one version and are the checked-in
|
||||
source for its generated Gitea release body.
|
||||
|
||||
Release notes are not current-state reference documents. They may summarize
|
||||
what changed and link to durable documentation, but they must not become a
|
||||
second command, configuration, operations, integration, architecture, or
|
||||
internal reference. Correct the applicable canonical owner in the same change
|
||||
when a release changes an implemented contract.
|
||||
|
||||
The release note at a published tag and the Gitea release generated from it are
|
||||
historical records. Later corrections on `main` do not rewrite that published
|
||||
record. Material release errors require the failure handling defined by the
|
||||
release procedure rather than moving a published tag or overwriting its
|
||||
release.
|
||||
|
||||
### Executable Authority
|
||||
|
||||
CLI parsing and help generation are the executable authority for accepted
|
||||
commands and flags. Configuration structs, defaults, loading, and validation
|
||||
are the executable authority for configuration behavior. Schemas and embedded
|
||||
assets are the executable authority for validated formats and template
|
||||
execution. Tests protect selected contracts and invariants but do not become a
|
||||
second documentation reference merely by asserting them.
|
||||
|
||||
Canonical documentation must be checked against these authorities whenever the
|
||||
corresponding behavior changes.
|
||||
|
||||
### Security Topics
|
||||
|
||||
This policy owns what documentation and examples may contain. Architecture owns
|
||||
application security boundaries and invariants. Configuration owns
|
||||
credential-supply mechanisms. Operations owns permissions and handling of
|
||||
sensitive runtime artifacts. Integration documents own consumer-visible
|
||||
security contracts. Internal documents own implementation mechanisms only.
|
||||
|
||||
## Architecture Decision Records
|
||||
|
||||
Use sequentially numbered ADR filenames such as
|
||||
`0001-record-architecture-decisions.md`. Follow the lightweight Nygard format:
|
||||
|
||||
1. title;
|
||||
2. status;
|
||||
3. date;
|
||||
4. context;
|
||||
5. decision;
|
||||
6. alternatives considered;
|
||||
7. consequences.
|
||||
|
||||
Use one of these statuses:
|
||||
|
||||
- **Proposed:** the decision is under consideration and may change;
|
||||
- **Accepted:** the decision is approved, whether or not implementation is
|
||||
complete;
|
||||
- **Rejected:** the proposed decision was considered and not adopted;
|
||||
- **Superseded:** a later accepted ADR replaces the accepted decision.
|
||||
|
||||
A proposed ADR transitions to Accepted or Rejected. An Accepted ADR transitions
|
||||
to Superseded only when a later Accepted ADR replaces it. An ADR may be created
|
||||
as Accepted when the decision has already been made.
|
||||
|
||||
Treat the decision content of an Accepted ADR as immutable. A changed decision
|
||||
requires a later ADR rather than a rewrite of the accepted record. A Superseded
|
||||
ADR must link to its replacement, and the replacement must link back. Rejected
|
||||
architectural alternatives belong in the ADR; rejected feature ideas belong in
|
||||
a roadmap when they need to be retained.
|
||||
|
||||
## Document Lifecycle
|
||||
|
||||
Create durable current-state documentation with the implementation it
|
||||
describes. Update its canonical owner in the same change when behavior changes.
|
||||
If ownership moves, remove the old definition and leave a link where navigation
|
||||
remains useful.
|
||||
|
||||
Roadmaps are temporary coordination documents. When their work is complete,
|
||||
record completion, move any still-useful decisions or contracts to their
|
||||
durable owners, update incoming links, and archive or remove the roadmap
|
||||
according to repository practice. Do not preserve completed roadmaps as a
|
||||
second current-state reference.
|
||||
|
||||
Release notes are durable historical summaries rather than temporary roadmaps.
|
||||
Keep them concise, retain them after publication, and keep current contracts in
|
||||
their canonical owners.
|
||||
|
||||
Before completing documentation work:
|
||||
|
||||
- verify affected behavior and examples;
|
||||
- check commands, flags, fields, defaults, schemas, paths, and identifiers
|
||||
against their implementation;
|
||||
- keep unimplemented behavior in a roadmap, subject to the ADR exception;
|
||||
- validate links and fenced examples;
|
||||
- confirm non-owning documents summarize and link rather than redefine;
|
||||
- remove stale or unsupported claims; and
|
||||
- confirm that no secrets or sensitive private data were added.
|
||||
|
||||
337
docs/policy/testing.md
Normal file
337
docs/policy/testing.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# Testing Policy
|
||||
|
||||
## Purpose
|
||||
|
||||
Our tests exist to make **incorrect changes expensive and correct changes
|
||||
cheap**.
|
||||
|
||||
We do not optimize for test count, line coverage, exhaustive isolation, or the
|
||||
fewest possible tests. We optimize for sufficient confidence in important
|
||||
behavior while imposing as little unnecessary friction as possible on future
|
||||
development.
|
||||
|
||||
## Every Test Has A Cost
|
||||
|
||||
Every test has an immediate cost and a continuing lifetime cost. It must be
|
||||
written, reviewed, executed, understood, diagnosed when it fails, updated when
|
||||
legitimate behavior changes, and maintained as fixtures and dependencies
|
||||
evolve.
|
||||
|
||||
Tests also create cognitive and architectural friction. They can constrain
|
||||
refactoring, duplicate policy, slow feedback, add noise to failures, and cause
|
||||
harmless implementation changes to require unrelated suite edits.
|
||||
|
||||
A test is warranted when the confidence it provides justifies those costs.
|
||||
Apply that judgment at two levels:
|
||||
|
||||
1. **Per test:** What realistic defect does this test detect, how consequential
|
||||
would it be, and is that protection worth the test's lifetime cost?
|
||||
2. **Across the suite:** Does this collection provide materially more
|
||||
confidence than a smaller, simpler suite would?
|
||||
|
||||
Prefer a lean suite that provides sufficient confidence in the risks that
|
||||
matter without redundant or low-value tests. Some friction is intentional:
|
||||
tests should make dangerous changes, such as corrupting state, breaking
|
||||
compatibility, violating security boundaries, or reintroducing subtle defects,
|
||||
require deliberate review. They should not make ordinary internal changes
|
||||
needlessly expensive.
|
||||
|
||||
Maintenance cost is not a reason to omit testing by default. When omitting a
|
||||
plausible test, be able to explain why the protected failure is low-risk,
|
||||
already covered, obvious, reversible, or cheaper to detect elsewhere. Favor
|
||||
testing when failure would be consequential, subtle, or difficult to observe.
|
||||
|
||||
## Default Testing Style
|
||||
|
||||
Use a classical or Detroit-style approach:
|
||||
|
||||
- Test observable behavior, resulting state, contracts, and invariants.
|
||||
- Use real internal collaborators when they are fast and deterministic.
|
||||
- Use fakes, stubs, or mocks primarily at expensive, nondeterministic,
|
||||
destructive, or external boundaries.
|
||||
- Prefer package-level behavioral tests over tests coupled to private helpers
|
||||
or internal call sequences.
|
||||
- Test exact collaborator interactions only when the interaction itself is a
|
||||
requirement.
|
||||
|
||||
Weatherreporter's important seams include clocks, Promptkit executors, HTTP
|
||||
services, Distributor uploads, filesystem roots, environment-backed secrets, and any
|
||||
future source of randomness or nondeterminism.
|
||||
|
||||
## Execution Requirements
|
||||
|
||||
The [development guide](../development.md) owns baseline repository validation.
|
||||
The default test suite is:
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Run race-enabled tests when a change affects concurrent execution, goroutine
|
||||
lifecycle, shared mutable state, or cancellation coordination. Use a focused
|
||||
package command while iterating and `go test -race ./...` when the risk crosses
|
||||
package boundaries.
|
||||
|
||||
Tests in the default suite must be deterministic, offline, and independent of
|
||||
real credentials. They must not invoke live Weather API, Promptkit providers, or
|
||||
Distributor services or depend on other mutable external infrastructure.
|
||||
Tests that require live infrastructure must be explicitly opt-in and clearly
|
||||
separated from the default suite.
|
||||
|
||||
Control clocks, environment variables, filesystem roots, and machine-specific
|
||||
state when they affect behavior. Tests must be safe to repeat and must not
|
||||
depend on execution order or state left by an earlier test. Tests that modify
|
||||
process-global state may remain serial; use `t.Parallel()` only when the test
|
||||
and its collaborators are actually safe to run concurrently.
|
||||
|
||||
## Test Types And Assets
|
||||
|
||||
Use each test type where it protects a distinct risk:
|
||||
|
||||
- Unit and package tests protect focused domain behavior and invariants through
|
||||
the narrowest stable boundary.
|
||||
- Contract tests protect CLI behavior, configuration, durable artifacts,
|
||||
schemas, templates, integration formats, compatibility, and stable error
|
||||
identity.
|
||||
- Integration tests use real deterministic collaborators when correctness
|
||||
depends on their interaction, while replacing live or nondeterministic
|
||||
external boundaries.
|
||||
- App and CLI tests protect representative assembled generation, batch, atomic
|
||||
output, and notification workflows.
|
||||
- Fixtures must be minimal, synthetic, versioned with the behavior they
|
||||
exercise, and free of credentials or private data.
|
||||
- Golden files are appropriate only when the complete output is intentionally
|
||||
stable and semantic review of updates is practical.
|
||||
- Failure-path tests should cover consequential malformed input, dependency
|
||||
failure, cancellation, partial results, and recovery behavior.
|
||||
|
||||
## What Deserves Tests
|
||||
|
||||
Prioritize tests for:
|
||||
|
||||
1. CLI, configuration, artifact, template, integration, and package contracts.
|
||||
2. Meteorological domain rules and important invariants.
|
||||
3. Boundary conditions and malformed input.
|
||||
4. Failure handling, cancellation, retries, recovery, and partial success.
|
||||
5. Serialization, schemas, compatibility, and round trips.
|
||||
6. Previously observed or plausible regressions.
|
||||
7. Representative app and CLI workflows.
|
||||
|
||||
A package-level contract is behavior relied upon by another package or major
|
||||
collaborator, not every observable implementation detail.
|
||||
|
||||
For data integrity, destructive operations, compatibility, security,
|
||||
concurrency, idempotency, or recovery, presume that durable tests are required
|
||||
unless the behavior is already credibly protected at another layer.
|
||||
|
||||
Do not add tests merely because a function, branch, or line exists. Do not add
|
||||
a test when the same meaningful risk is already adequately protected
|
||||
elsewhere.
|
||||
|
||||
## Choose The Right Boundary
|
||||
|
||||
Test through the narrowest stable boundary that expresses the behavior clearly.
|
||||
That may be:
|
||||
|
||||
- a small pure function when dense domain logic is clearest there;
|
||||
- a package operation when several internal collaborators jointly produce the
|
||||
behavior; or
|
||||
- a larger integration or app boundary when correctness emerges from
|
||||
interaction.
|
||||
|
||||
Do not force every behavior through oversized workflow tests. Do not test every
|
||||
private helper merely because it exists. Choose the boundary that provides
|
||||
durable confidence with the least incidental coupling.
|
||||
|
||||
## Test Behavior, Not Implementation
|
||||
|
||||
A test should protect a decision, contract, or invariant, not memorialize the
|
||||
current implementation. Before adding or retaining a test, ask:
|
||||
|
||||
> What realistic defect would this test catch?
|
||||
|
||||
A test is suspect when its main purpose is to detect that someone changed a
|
||||
private constant, renamed or split a helper, reordered equivalent operations,
|
||||
changed incidental formatting, replaced one correct algorithm with another, or
|
||||
refactored private structure without changing behavior.
|
||||
|
||||
Refactoring should normally require no test edits unless the changed structure
|
||||
is itself contractual. A test can be factually correct and still have negative
|
||||
value when the behavior it protects is too incidental to justify its future
|
||||
cost.
|
||||
|
||||
Use these expectations when evaluating failures:
|
||||
|
||||
| Change | Expected effect on tests |
|
||||
| --- | --- |
|
||||
| Internal refactor that preserves behavior | Existing tests should normally remain unchanged and pass. |
|
||||
| Internal default change with no contractual significance | Tests should normally derive expectations from configuration or relationships rather than duplicate the old value. |
|
||||
| Intentional change to user-visible behavior, policy, schema, or compatibility | Relevant tests should be reviewed and changed deliberately. |
|
||||
| Accidental contract or invariant violation | Tests should fail; fix production code rather than rewriting tests to accept the defect. |
|
||||
|
||||
A failing test is not necessarily a test that should be edited. Many tests may
|
||||
correctly fail because of one production defect. The maintenance smell is a
|
||||
correct internal change that requires unrelated expectation changes throughout
|
||||
the suite.
|
||||
|
||||
## Separate Mechanism From Policy
|
||||
|
||||
Do not duplicate configurable thresholds and defaults throughout the suite.
|
||||
Test mechanisms relationally: a configured valid value is accepted, a value
|
||||
outside the permitted relationship is rejected, and runtime behavior respects
|
||||
the configured value.
|
||||
|
||||
Test an exact default when its literal value is itself a documented user,
|
||||
operational, safety, protocol, or compatibility contract. The same distinction
|
||||
applies to timeouts, capacities, retry counts, ranges, thresholds, and output
|
||||
limits.
|
||||
|
||||
When concurrency limits are introduced, distinguish configuration enforcement
|
||||
from runtime enforcement. Validate accepted and rejected settings separately
|
||||
from measuring whether observed peak concurrency respects the configured
|
||||
limit.
|
||||
|
||||
## Avoid Semantic Duplication
|
||||
|
||||
Each behavior should have a clear test owner:
|
||||
|
||||
- CLI parser tests own arguments, flags, and command construction.
|
||||
- Config tests own loading, precedence, defaults, secrets, and validation.
|
||||
- Domain tests own weather transformations and invariants.
|
||||
- Adapter tests own HTTP, Promptkit/provider, and upload boundaries.
|
||||
- Orchestrator tests own workflow ordering, output publication, partial success,
|
||||
and failure propagation.
|
||||
- Filesystem tests own atomic writes and destination-preservation behavior.
|
||||
- Template and generated-text tests own schemas, render contexts, and rendered
|
||||
output contracts.
|
||||
|
||||
Higher-level tests should not repeat every lower-level case. Tests that are
|
||||
individually reasonable may still be collectively redundant; assess the
|
||||
marginal protection of each additional test.
|
||||
|
||||
## Use Test Doubles Deliberately
|
||||
|
||||
Choose the least elaborate double that provides the required control or
|
||||
observation:
|
||||
|
||||
1. Prefer real collaborators when they are fast and deterministic.
|
||||
2. Use small in-memory fakes when realistic stateful behavior helps.
|
||||
3. Use stubs when a dependency only needs controlled responses.
|
||||
4. Use mocks when the interaction itself is contractual.
|
||||
|
||||
Mocks are appropriate for requirements such as uploading exactly once,
|
||||
notifying only after output publication, propagating cancellation to Promptkit, or
|
||||
avoiding an external call after an earlier workflow failure. Do not use mocks
|
||||
merely to isolate every object or reproduce the implementation's call graph.
|
||||
|
||||
## Go-Specific Guidance
|
||||
|
||||
Use:
|
||||
|
||||
- table-driven tests for meaningful behavioral categories and boundaries;
|
||||
- `t.TempDir()` for real filesystem behavior;
|
||||
- `httptest.Server` for realistic Weather API interactions;
|
||||
- test-controlled clocks for periods and RunIDs;
|
||||
- fake Promptkit executors or provider clients for Promptkit behavior;
|
||||
- fake upload clients for Distributor behavior;
|
||||
- fuzz tests when parsers, normalization, or path handling have a broad and
|
||||
consequential input space;
|
||||
- golden files only when complete output stability is intentional; and
|
||||
- a small number of representative app and CLI workflow tests.
|
||||
|
||||
Avoid exact error-string assertions unless wording is contractual. Prefer
|
||||
`errors.Is`, `errors.As`, typed errors, structured fields, or the smallest
|
||||
stable semantic fragment that identifies the failure. At CLI boundaries,
|
||||
prefer structured summaries, exit behavior, and stable classifications over
|
||||
snapshots of complete diagnostic wording.
|
||||
|
||||
Golden-file updates must require an explicit local flag. Ordinary validation
|
||||
must never update golden files automatically, and maintainers must inspect the
|
||||
semantic diff before accepting an update.
|
||||
|
||||
Keep tests readable and direct. Helpers and fixture frameworks must earn their
|
||||
maintenance cost; do not build elaborate infrastructure for small or isolated
|
||||
needs.
|
||||
|
||||
## Coverage
|
||||
|
||||
Coverage is a diagnostic, not a target. Use it to find untested critical
|
||||
branches and unexpectedly weak packages. Do not write low-value tests solely
|
||||
to increase a percentage or infer quality from coverage alone.
|
||||
|
||||
Pure domain logic will often warrant higher coverage than CLI wiring or thin
|
||||
external adapters. Uneven coverage is acceptable when it reflects risk.
|
||||
|
||||
## Regression Tests
|
||||
|
||||
A bug fix should normally include a regression test that fails before the fix
|
||||
and passes afterward. Prefer the narrowest durable test of the violated
|
||||
contract or invariant.
|
||||
|
||||
Retain the test when the defect could realistically recur and its consequences
|
||||
justify the ongoing cost. Remove or consolidate it if the design makes
|
||||
recurrence implausible or a stronger invariant test subsumes it.
|
||||
|
||||
## Deleting Or Rewriting Tests
|
||||
|
||||
Tests are maintained code, not permanent historical artifacts. Delete or
|
||||
rewrite a test when its maintenance cost exceeds the confidence it provides.
|
||||
Candidates include tests that:
|
||||
|
||||
- require edits after harmless internal changes;
|
||||
- assert private constants without protecting a real contract;
|
||||
- duplicate the same policy across several layers;
|
||||
- verify mock choreography rather than outcomes;
|
||||
- snapshot large amounts of incidental output;
|
||||
- protect risks already covered more effectively elsewhere; or
|
||||
- are flaky, misleading, obsolete, or no longer correspond to a plausible
|
||||
failure.
|
||||
|
||||
Test removal must be deliberate and within the scope of the change. Identify
|
||||
the behavior the test protected and show that the behavior is covered more
|
||||
effectively elsewhere or that the failure is no longer plausible enough to
|
||||
justify durable coverage. Replace several brittle tests with one stronger
|
||||
behavior or invariant test when appropriate.
|
||||
|
||||
Do not delete or weaken a test merely because it fails after a production
|
||||
change. First determine whether the failure exposes an accidental regression,
|
||||
an intentional contract change, or an implementation-coupled assertion.
|
||||
|
||||
## Reviewing A Proposed Test
|
||||
|
||||
When a proposed test's value or durability is not self-evident, ask:
|
||||
|
||||
1. What realistic defect would it catch, and how consequential is that defect?
|
||||
2. Is the behavior already protected elsewhere?
|
||||
3. Which layer should own the test?
|
||||
4. Does it assert a durable contract or incidental implementation detail?
|
||||
5. What should cause it to fail, and what legitimate changes should not?
|
||||
6. Could a smaller or more direct test protect the same risk?
|
||||
7. What ongoing maintenance, execution, and diagnostic cost will it impose?
|
||||
|
||||
Written answers are not required for every routine test. Do not add a test when
|
||||
its expected lifetime cost exceeds its expected protective value.
|
||||
|
||||
## Definition Of Sufficient
|
||||
|
||||
A suite is sufficient when:
|
||||
|
||||
- important contracts and invariants are protected;
|
||||
- meaningful boundaries and failure modes are exercised;
|
||||
- consequential regressions are credibly protected against silent recurrence;
|
||||
- data integrity, destructive operations, compatibility, security,
|
||||
concurrency, idempotency, and recovery receive risk-appropriate protection;
|
||||
- external boundaries have realistic local integration coverage;
|
||||
- representative complete workflows are tested;
|
||||
- failures provide useful signal rather than redundant noise; and
|
||||
- legitimate internal changes usually do not require test edits.
|
||||
|
||||
Sufficiency is a risk judgment, not a coverage percentage or test count.
|
||||
Reassess it as Weatherreporter, its users, and the consequences of failure
|
||||
evolve.
|
||||
|
||||
The governing rule is:
|
||||
|
||||
> Test heavily where failure is consequential, subtle, or difficult to detect
|
||||
> after the fact. Test lightly where failure is obvious, reversible, and
|
||||
> inexpensive.
|
||||
269
docs/release.md
Normal file
269
docs/release.md
Normal file
@@ -0,0 +1,269 @@
|
||||
# Release Procedure
|
||||
|
||||
## Release Model
|
||||
|
||||
Weatherreporter publishes executable binaries through tagged commits on
|
||||
`main`. Releases use stable semantic-version tags in the form
|
||||
`vMAJOR.MINOR.PATCH`. The current pipeline does not publish prereleases.
|
||||
|
||||
Every release has one nonempty, version-matched note at
|
||||
`docs/releases/<tag>.md`. After the tag is pushed, the Woodpecker release
|
||||
pipeline validates the tagged source, builds six binaries, creates SHA-256
|
||||
checksums, and creates the corresponding Gitea release. The pipeline uses the
|
||||
checked-in release note as the Gitea release body and does not overwrite an
|
||||
existing release.
|
||||
|
||||
Before `v1.0.0`, a minor release may deliberately change user-facing
|
||||
interfaces when its release note explains the compatibility impact and
|
||||
required operator action. Patch releases must not intentionally break the
|
||||
documented CLI, configuration, durable artifact, or integration contracts in
|
||||
their minor line.
|
||||
|
||||
Published tags and their generated releases are immutable. Never move, reuse,
|
||||
or delete a published tag, and never manually overwrite the release produced
|
||||
from it.
|
||||
|
||||
## Select The Version And Write The Release Note
|
||||
|
||||
Choose an unpublished version and export it as `RELEASE_VERSION`. Run the
|
||||
commands in this procedure from the Weatherreporter repository root in one
|
||||
POSIX shell:
|
||||
|
||||
```sh
|
||||
export RELEASE_VERSION=vMAJOR.MINOR.PATCH
|
||||
```
|
||||
|
||||
Create `docs/releases/$RELEASE_VERSION.md` with this structure:
|
||||
|
||||
```markdown
|
||||
# Weatherreporter vMAJOR.MINOR.PATCH
|
||||
|
||||
This release ...
|
||||
|
||||
## Summary
|
||||
|
||||
Summarize the release's purpose and most important outcomes.
|
||||
|
||||
## Compatibility
|
||||
|
||||
State compatibility with the preceding release and identify any changed CLI,
|
||||
configuration, durable artifact, integration, or operating contract.
|
||||
|
||||
## Upgrade
|
||||
|
||||
State the operator actions required to upgrade, or state that no special
|
||||
action is required.
|
||||
|
||||
## Changes
|
||||
|
||||
Describe the material user-visible, operational, and maintainer-visible
|
||||
changes. Link to canonical documentation for exact current contracts.
|
||||
```
|
||||
|
||||
The note is a concise changelog and adoption aid, not a replacement for current
|
||||
documentation. Update every affected canonical document in the same candidate
|
||||
commit. Do not include credentials, private infrastructure details, or claims
|
||||
that are not true of the candidate.
|
||||
|
||||
Require the version, path, heading, and minimum sections before continuing:
|
||||
|
||||
```sh
|
||||
set -eu
|
||||
|
||||
: "${RELEASE_VERSION:?export an unpublished vMAJOR.MINOR.PATCH version}"
|
||||
if ! printf '%s\n' "$RELEASE_VERSION" |
|
||||
grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
|
||||
then
|
||||
printf '%s\n' "invalid release version: $RELEASE_VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RELEASE_NOTE="docs/releases/$RELEASE_VERSION.md"
|
||||
export RELEASE_NOTE
|
||||
|
||||
test -s "$RELEASE_NOTE"
|
||||
grep -Fx "# Weatherreporter $RELEASE_VERSION" "$RELEASE_NOTE"
|
||||
grep -Fx '## Summary' "$RELEASE_NOTE"
|
||||
grep -Fx '## Compatibility' "$RELEASE_NOTE"
|
||||
grep -Fx '## Upgrade' "$RELEASE_NOTE"
|
||||
grep -Fx '## Changes' "$RELEASE_NOTE"
|
||||
```
|
||||
|
||||
## Validate The Candidate
|
||||
|
||||
Run the same substantive checks enforced by the tag pipeline before committing
|
||||
the release note:
|
||||
|
||||
```sh
|
||||
test -z "$(git ls-files go.work go.work.sum)"
|
||||
test ! -e vendor
|
||||
if grep -Eq '^[[:space:]]*replace([[:space:]]|\()' go.mod
|
||||
then
|
||||
printf '%s\n' 'go.mod contains a replacement' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
GOWORK=off go test -count=1 ./...
|
||||
GOWORK=off go test -race -count=1 ./...
|
||||
GOWORK=off go vet ./...
|
||||
GOWORK=off go build ./...
|
||||
GOWORK=off go mod tidy -diff
|
||||
|
||||
unformatted=$(
|
||||
git ls-files '*.go' |
|
||||
while IFS= read -r go_file
|
||||
do
|
||||
gofmt -l "$go_file"
|
||||
done
|
||||
)
|
||||
test -z "$unformatted"
|
||||
git diff --check
|
||||
git diff --cached --check
|
||||
```
|
||||
|
||||
Follow every added or changed Markdown link and confirm that its local target
|
||||
exists. Review the candidate for generated binaries, test output, credentials,
|
||||
temporary files, replacements, vendored dependencies, and other files that do
|
||||
not belong in source control.
|
||||
|
||||
## Publish The Candidate Commit
|
||||
|
||||
Commit the release note and any final current-state documentation updates, then
|
||||
push `main` through the ordinary repository workflow:
|
||||
|
||||
```sh
|
||||
git add "$RELEASE_NOTE"
|
||||
git commit -m "Document Weatherreporter $RELEASE_VERSION"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
Do not tag an uncommitted or unpushed candidate. Record and export the exact
|
||||
candidate commit after the push:
|
||||
|
||||
```sh
|
||||
RELEASE_COMMIT=$(git rev-parse --verify 'HEAD^{commit}')
|
||||
export RELEASE_COMMIT
|
||||
```
|
||||
|
||||
## Guard And Tag The Candidate
|
||||
|
||||
Run this guard immediately before creating the tag. It requires a clean
|
||||
checkout on synchronized `main`, valid module hygiene, the version-matched
|
||||
release note, and an unpublished local and remote tag:
|
||||
|
||||
```sh
|
||||
check_release_candidate() {
|
||||
test "$(git branch --show-current)" = main
|
||||
test -z "$(git status --porcelain)"
|
||||
|
||||
gowork_value=$(go env GOWORK)
|
||||
case "$gowork_value" in
|
||||
''|off) ;;
|
||||
*)
|
||||
printf '%s\n' "active Go workspace: $gowork_value" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
test -z "$(git ls-files go.work go.work.sum)"
|
||||
test ! -e vendor
|
||||
if grep -Eq '^[[:space:]]*replace([[:space:]]|\()' go.mod
|
||||
then
|
||||
printf '%s\n' 'go.mod contains a replacement' >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
test -s "$RELEASE_NOTE"
|
||||
grep -Fx "# Weatherreporter $RELEASE_VERSION" "$RELEASE_NOTE"
|
||||
|
||||
git fetch origin main --tags
|
||||
test "$RELEASE_COMMIT" = \
|
||||
"$(git rev-parse --verify 'refs/remotes/origin/main^{commit}')"
|
||||
|
||||
if git show-ref --verify --quiet "refs/tags/$RELEASE_VERSION"
|
||||
then
|
||||
printf '%s\n' "local tag already exists: $RELEASE_VERSION" >&2
|
||||
return 1
|
||||
fi
|
||||
if test -n "$(
|
||||
git ls-remote --tags origin \
|
||||
"refs/tags/$RELEASE_VERSION" \
|
||||
"refs/tags/$RELEASE_VERSION^{}"
|
||||
)"
|
||||
then
|
||||
printf '%s\n' "remote tag already exists: $RELEASE_VERSION" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_release_candidate
|
||||
```
|
||||
|
||||
Create a lightweight tag, matching Weatherreporter's existing release tags,
|
||||
and bind it explicitly to the guarded commit:
|
||||
|
||||
```sh
|
||||
git tag "$RELEASE_VERSION" "$RELEASE_COMMIT"
|
||||
test "$(git cat-file -t "refs/tags/$RELEASE_VERSION")" = commit
|
||||
test "$(git rev-parse --verify "refs/tags/$RELEASE_VERSION^{commit}")" = \
|
||||
"$RELEASE_COMMIT"
|
||||
git show --no-patch --decorate "refs/tags/$RELEASE_VERSION"
|
||||
```
|
||||
|
||||
If inspection finds an error, delete the unpublished local tag, correct the
|
||||
candidate, and repeat the procedure. Once the tag is pushed, it is immutable.
|
||||
|
||||
## Publish And Verify The Release
|
||||
|
||||
Push only the selected tag ref. Do not use `git push --tags`:
|
||||
|
||||
```sh
|
||||
git push origin \
|
||||
"refs/tags/$RELEASE_VERSION:refs/tags/$RELEASE_VERSION"
|
||||
```
|
||||
|
||||
The tag event starts the release pipeline. Its validation step rejects a
|
||||
non-stable semantic tag, a missing release note, module or repository hygiene
|
||||
violations, and any failing test, race test, vet, build, module-tidiness,
|
||||
formatting, or whitespace check. Its build step also verifies that the host
|
||||
binary reports `weatherreporter $RELEASE_VERSION`.
|
||||
|
||||
Wait for the pipeline to succeed, then confirm that the Gitea release:
|
||||
|
||||
- targets `RELEASE_COMMIT` through `RELEASE_VERSION`;
|
||||
- is titled `Weatherreporter $RELEASE_VERSION`;
|
||||
- uses `RELEASE_NOTE` from the tagged commit as its body;
|
||||
- contains `SHA256SUMS`; and
|
||||
- contains Linux, macOS, and Windows binaries for both `amd64` and `arm64`,
|
||||
named `weatherreporter-$RELEASE_VERSION-<os>-<arch>` with `.exe` on Windows.
|
||||
|
||||
Compare the remote tag with the guarded commit:
|
||||
|
||||
```sh
|
||||
remote_commit=$(
|
||||
git ls-remote --tags origin "refs/tags/$RELEASE_VERSION" |
|
||||
awk 'NR == 1 { print $1 }'
|
||||
)
|
||||
test "$remote_commit" = "$RELEASE_COMMIT"
|
||||
```
|
||||
|
||||
Download `SHA256SUMS` and every release binary into a new temporary directory,
|
||||
run `sha256sum --check SHA256SUMS`, and execute the binary for the maintainer's
|
||||
host platform with `--version`. It must print exactly:
|
||||
|
||||
```text
|
||||
weatherreporter vMAJOR.MINOR.PATCH
|
||||
```
|
||||
|
||||
## Failed Publication And Corrections
|
||||
|
||||
If the tag pipeline fails after publication, preserve the tag and diagnose the
|
||||
failure from the pipeline logs. Fix the cause on `main`, select a new patch
|
||||
version, prepare a new release note, and repeat the complete procedure. Do not
|
||||
move or recreate the failed published tag.
|
||||
|
||||
Do not manually edit an automatically generated Gitea release or republish its
|
||||
assets. A wording-only correction may be committed to the historical document
|
||||
on `main`, with an explicit correction note, but it does not alter the file at
|
||||
the tag or the generated release. Publish a new patch release when the error is
|
||||
material to installation, compatibility, security, or operation.
|
||||
140
docs/releases/v0.10.0.md
Normal file
140
docs/releases/v0.10.0.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# Weatherreporter v0.10.0
|
||||
|
||||
Weatherreporter `v0.10.0` makes report execution stateless, adds stable
|
||||
weather-specific Promptkit profiles, and turns every successful generation
|
||||
into one atomic operator-owned Markdown output.
|
||||
|
||||
## Summary
|
||||
|
||||
- Ordinary generation no longer creates or depends on a managed workspace,
|
||||
historical run artifacts, metadata, receipts, or prior snapshots.
|
||||
- `generate` and `run` now publish directly to operator-selected paths, with
|
||||
useful current-directory defaults when output flags are omitted.
|
||||
- Local Recent Changes comparison and the historical `inspect` command family
|
||||
have been removed.
|
||||
- Promptkit `v0.5.0` and three embedded logical profiles provide a stable model
|
||||
ladder with complete file- or directory-based overrides.
|
||||
- Prompt input and generated-text contracts have been tightened, and output,
|
||||
cancellation, batch preflight, notification, and partial-failure behavior
|
||||
have focused offline coverage.
|
||||
|
||||
## Compatibility
|
||||
|
||||
This pre-`v1` minor release intentionally breaks CLI, configuration,
|
||||
prompt-input, action-summary, and workspace contracts from `v0.9.0`.
|
||||
|
||||
- The `workspace:` and `recent_change:` configuration sections are no longer
|
||||
supported. Strict configuration loading rejects them.
|
||||
- The `inspect reports`, `inspect metadata`, `inspect modules`,
|
||||
`inspect data-package`, `inspect prior`, and `inspect sources` commands have
|
||||
been removed. Weatherreporter no longer reads V1 or V2 run metadata or other
|
||||
historical workspace artifacts.
|
||||
- Every successful `generate` writes exactly one Markdown file. Without
|
||||
`--out`, Daily writes `daily-YYYY-MM-DD.md` and Today, Tomorrow, and Hourly
|
||||
write `today.md`, `tomorrow.md`, and `hourly.md` in the invocation's current
|
||||
directory. `--out` selects that file rather than creating an extra copy of a
|
||||
separately managed report.
|
||||
- `run` writes selected outputs beneath the current directory unless
|
||||
`--out-dir` selects another directory. Successful items remain available
|
||||
when another batch item fails.
|
||||
- Action summaries no longer expose managed report, metadata, snapshot, data
|
||||
package, prompt preparation, prompt execution, generated-text, render-context,
|
||||
or notification-receipt paths. They retain the final `outputPath`, optional
|
||||
`llmDebugPath`, safe effective profile/backend/model details, validation,
|
||||
warnings, notification status, and safe errors.
|
||||
- Batch report items no longer contain per-report notification fields. Batch
|
||||
notification is represented once at the top level. The `total`, `succeeded`,
|
||||
and `failed` counters describe reports only, so notification failure can
|
||||
produce a failed action while `failed` remains `0`.
|
||||
- The prompt data package advances from `weatherreporter.data_package.v3` to
|
||||
`weatherreporter.data_package.v4` and removes `recent_changes`. All four
|
||||
embedded prompts advance from `1.1.0` to `2.0.0`.
|
||||
- Generated-text schemas now require string-valued `precipitation_timing`; the
|
||||
model returns an empty string when there is no timing text. The unused
|
||||
`confidence` field has been removed and is rejected as an unknown field.
|
||||
|
||||
Existing operator-owned Markdown files remain valid. Existing workspace trees
|
||||
are ignored rather than migrated or deleted. Distributor continues to receive
|
||||
the completed Markdown report, but its source is now the selected operator
|
||||
output rather than a managed report copy.
|
||||
|
||||
## Upgrade
|
||||
|
||||
Before replacing `v0.9.0`:
|
||||
|
||||
1. Remove `workspace:` and `recent_change:` from configuration files.
|
||||
2. Give scheduled commands a predictable working directory or explicit
|
||||
`--out` or `--out-dir` destination. Confirm that these selected files may be
|
||||
atomically replaced on later successful runs.
|
||||
3. Remove historical `inspect` invocations and update action-summary consumers
|
||||
to use `outputPath` and the remaining active-workflow fields.
|
||||
4. Decide whether old workspace contents have any external retention value.
|
||||
Weatherreporter no longer reads them; after review, they may be removed
|
||||
manually using the narrowly scoped procedure in the operations guide.
|
||||
5. Review Promptkit profile selection and credentials. Hourly defaults to
|
||||
`weather-light`; Daily, Today, and Tomorrow default to `weather-balanced`.
|
||||
A configured `promptkit.profile` still overrides every report in one action.
|
||||
|
||||
The embedded logical profiles are:
|
||||
|
||||
| Profile | OpenRouter model | Default use |
|
||||
| --- | --- | --- |
|
||||
| `weather-light` | `deepseek/deepseek-v4-flash` | Hourly |
|
||||
| `weather-balanced` | `~google/gemini-flash-latest` | Daily, Today, Tomorrow |
|
||||
| `weather-deep` | `~anthropic/claude-sonnet-latest` | Explicit selection |
|
||||
|
||||
Override a complete same-ID definition through `promptkit.profile_file` or
|
||||
`promptkit.profile_dir` to use different models or a local OpenAI-compatible
|
||||
endpoint. Definitions are replaced rather than field-merged, and a malformed
|
||||
matching override fails instead of silently falling back.
|
||||
|
||||
See the [CLI reference](../cli.md), [configuration
|
||||
reference](../config.md), [operations guide](../operations.md), and [Promptkit
|
||||
integration](../integrations/promptkit.md) for the exact current contracts.
|
||||
|
||||
## Changes
|
||||
|
||||
### Stateless Execution And Operator-Owned Outputs
|
||||
|
||||
- Removed local forecast-change comparison, prior-snapshot selection, durable
|
||||
module and prompt artifacts, managed reports, metadata compatibility, run
|
||||
discovery, notification receipts, and the complete `internal/state`
|
||||
subsystem.
|
||||
- Added an Accepted architecture decision recording the stateless
|
||||
transformation pipeline and operator-owned output boundary.
|
||||
- Kept weather, facts, modules, prompt input, generated text, and render context
|
||||
in memory during ordinary execution.
|
||||
- Made output publication atomic and ensured cancellation or deadline expiry
|
||||
observed before publication leaves an existing destination unchanged.
|
||||
- Added complete batch-destination preflight before the first report prompt,
|
||||
so a structural collision cannot leave an unreported partial batch.
|
||||
- Preserved successful outputs after report or Distributor failure. Batch
|
||||
notification runs only after every selected report succeeds.
|
||||
|
||||
### Promptkit Profiles And Prompt Contracts
|
||||
|
||||
- Upgraded Promptkit from `v0.4.0` to `v0.5.0`.
|
||||
- Added embedded `weather-light`, `weather-balanced`, and `weather-deep`
|
||||
profiles and mapped each exact prompt to its logical default.
|
||||
- Added embedded-profile fallback after configured `profile_file` or
|
||||
`profile_dir` lookup, allowing operators to replace a logical profile without
|
||||
changing report definitions.
|
||||
- Added a maintained local-endpoint example for replacing `weather-light`.
|
||||
- Advanced the four prompt definitions to `2.0.0` and the curated data package
|
||||
to v4 after removing Recent Changes.
|
||||
- Required `precipitation_timing`, normalized whitespace-only timing to an
|
||||
empty string, and removed the unused confidence value.
|
||||
|
||||
### CLI, Reliability, Documentation, And Testing
|
||||
|
||||
- Simplified action summaries to active workflow identity, output, model,
|
||||
validation, warning, debug, notification, and safe error information.
|
||||
- Made batch counters report-only while retaining failed action status and
|
||||
non-zero exit behavior for batch notification failure.
|
||||
- Kept prompt and profile inspection ahead of weather collection and validated
|
||||
every batch candidate before collecting once.
|
||||
- Replaced state-oriented workflow fixtures with focused generation, batch,
|
||||
output, cancellation, profile-resolution, Distributor, and CLI coverage.
|
||||
- Reconciled user, operator, integration, internal, policy, and ADR
|
||||
documentation around the implemented stateless architecture and removed
|
||||
completed temporary roadmaps.
|
||||
33
docs/releases/v0.10.1.md
Normal file
33
docs/releases/v0.10.1.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Weatherreporter v0.10.1
|
||||
|
||||
This release repairs release validation after the `v0.10.0` pipeline failed in
|
||||
its privileged build container. Application behavior is unchanged from
|
||||
`v0.10.0`.
|
||||
|
||||
## Summary
|
||||
|
||||
The unreadable-secret configuration test now verifies that its process is
|
||||
actually subject to file permission bits before asserting that a mode-`000`
|
||||
file cannot be read. This keeps the test meaningful for ordinary users while
|
||||
allowing the release suite to run correctly in privileged containers.
|
||||
|
||||
## Compatibility
|
||||
|
||||
This patch release makes no changes to Weatherreporter's CLI, configuration,
|
||||
report output, integrations, prompts, profiles, or operating behavior. It is
|
||||
fully compatible with `v0.10.0`.
|
||||
|
||||
## Upgrade
|
||||
|
||||
No special operator action is required. Use `v0.10.1` in place of `v0.10.0`;
|
||||
the `v0.10.0` tag remains immutable, but its failed pipeline did not publish
|
||||
release binaries.
|
||||
|
||||
## Changes
|
||||
|
||||
- Made the unreadable-secret test capability-aware when the test process can
|
||||
bypass filesystem permission bits.
|
||||
- Preserved the production contract that genuinely unreadable secret files
|
||||
fail configuration loading.
|
||||
- Restored portable release validation in Woodpecker's privileged Go
|
||||
container.
|
||||
37
docs/releases/v0.11.0.md
Normal file
37
docs/releases/v0.11.0.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Weatherreporter v0.11.0
|
||||
|
||||
This release adds a configurable default publication directory for generated
|
||||
weather reports.
|
||||
|
||||
## Summary
|
||||
|
||||
Operators can now set `output.directory` once for both individual reports and
|
||||
scheduled batches. Explicit `--out` and `--out-dir` destinations continue to
|
||||
take precedence, while installations that omit the setting retain the existing
|
||||
current-directory behavior.
|
||||
|
||||
## Compatibility
|
||||
|
||||
This release is additive and compatible with `v0.10.1`. Existing configuration
|
||||
files, commands, report filenames, Promptkit behavior, and Distributor
|
||||
notification behavior remain valid and unchanged.
|
||||
|
||||
## Upgrade
|
||||
|
||||
No special action is required. To use the new default destination, configure
|
||||
`output.directory` as described in the [configuration
|
||||
reference](../config.md). Existing deployments may continue using the current
|
||||
working directory or explicit CLI output flags.
|
||||
|
||||
## Changes
|
||||
|
||||
- Added strict configuration loading and validation for the optional
|
||||
`output.directory` field.
|
||||
- Applied the configured directory consistently to `generate` and `run`, with
|
||||
explicit CLI destinations retaining highest precedence.
|
||||
- Preserved relative-path handling, absolute result paths, atomic publication,
|
||||
cancellation safety, and Distributor notification ordering.
|
||||
- Strengthened output preflight so existing non-directory paths, uninspectable
|
||||
paths, and dangling symlink components fail before expensive report work.
|
||||
- Updated the [CLI reference](../cli.md) and [operations
|
||||
guide](../operations.md) for the new destination-selection behavior.
|
||||
134
docs/releases/v0.9.0.md
Normal file
134
docs/releases/v0.9.0.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# Weatherreporter v0.9.0
|
||||
|
||||
Weatherreporter `v0.9.0` replaces its external Scriptorium execution path with
|
||||
an in-process Promptkit integration and makes prompt preparation, execution,
|
||||
validation, and failure artifacts first-class parts of each report run.
|
||||
|
||||
## Summary
|
||||
|
||||
- Promptkit `v0.4.0` now executes all generated text for Daily, Today,
|
||||
Tomorrow, and Hourly reports.
|
||||
- The four exact-version prompts and their JSON Schemas are embedded in the
|
||||
Weatherreporter binary.
|
||||
- Prompt preparation and execution have separate durable, redacted provenance
|
||||
records, while sensitive prompt debugging is explicit and stored outside the
|
||||
managed workspace.
|
||||
- Weather API collection now performs a warmup request and retries transient
|
||||
transport, read, and selected HTTP failures.
|
||||
- Release binaries now report their embedded version and are published with
|
||||
checksums through a guarded Woodpecker pipeline.
|
||||
|
||||
## Compatibility
|
||||
|
||||
This pre-`v1` minor release contains intentional configuration, CLI, and
|
||||
artifact changes that require review when upgrading from `v0.8.0`.
|
||||
|
||||
- The `scriptorium:` configuration section is no longer supported. A file that
|
||||
contains it fails with a migration error instead of silently ignoring it.
|
||||
Use `promptkit:` configuration instead.
|
||||
- The previously exposed but unfinished three-day, weekend, and storm report
|
||||
surfaces have been removed. Supported report IDs and `generate` commands are
|
||||
`daily`, `today`, `tomorrow`, and `hourly`. The retired `storm_id`
|
||||
Distributor template variable is also no longer accepted.
|
||||
- Generate and batch result items now expose `preparationPath` and
|
||||
`executionPath` instead of the Scriptorium-oriented `preflightPath` and
|
||||
`generatedTextResultPath`. An opt-in prompt capture may also add
|
||||
`llmDebugPath`.
|
||||
- New runs write `weatherreporter.metadata.v2`, which records Promptkit
|
||||
preparation and execution paths. Inspection and prior-run lookup continue to
|
||||
read existing `weatherreporter.metadata.v1` records.
|
||||
- The built-in `weather_api.precision` default changed from `1` to `0`.
|
||||
Configurations that explicitly set a value retain that value.
|
||||
- Report prose may differ because the embedded prompt corpus, structured
|
||||
output path, alert presentation, and SPC background context have changed.
|
||||
|
||||
The documented Go version remains 1.26. Distributor integration remains at
|
||||
`v0.5.0`. Existing managed workspaces do not require conversion.
|
||||
|
||||
## Upgrade
|
||||
|
||||
Replace the old Scriptorium block in the Weatherreporter configuration. The
|
||||
smallest equivalent Promptkit block is:
|
||||
|
||||
```yaml
|
||||
promptkit:
|
||||
timeout: 2m
|
||||
```
|
||||
|
||||
The embedded prompts default to the Promptkit `gemini-flash-latest` profile.
|
||||
Ensure that the selected profile's credential environment variable is present,
|
||||
or configure `promptkit.profile`, an external `profile_file` or `profile_dir`,
|
||||
or the optional `promptkit.local` backend. Direct per-request API keys are not
|
||||
supported by Weatherreporter.
|
||||
|
||||
Before upgrading automation or downstream processing:
|
||||
|
||||
1. remove any `three-day`, `weekend`, or `storm` command, report override, and
|
||||
`storm_id` template usage;
|
||||
2. update consumers of action-summary JSON to use the new preparation and
|
||||
execution path fields;
|
||||
3. decide whether to retain the new precision default or explicitly configure
|
||||
the previous value; and
|
||||
4. preserve the existing workspace if historical V1 runs must remain
|
||||
inspectable.
|
||||
|
||||
Scriptorium, its executable configuration, and its external prompt corpus are
|
||||
no longer needed by Weatherreporter. See the
|
||||
[configuration reference](../config.md), [CLI reference](../cli.md), and
|
||||
[Promptkit integration](../integrations/promptkit.md) for the current
|
||||
contracts.
|
||||
|
||||
## Changes
|
||||
|
||||
### Prompt Execution And Artifacts
|
||||
|
||||
- Added a project-owned Promptkit adapter with exact prompt and profile
|
||||
inspection, prepare-once execution, error classification, and bounded
|
||||
execution timeouts.
|
||||
- Embedded version `1.0.0` of the Daily, Today, Tomorrow, and Hourly prompts and
|
||||
their private generated-text schemas.
|
||||
- Added durable preparation and execution receipts with prompt, profile,
|
||||
backend, model, hashes, timings, validation status, classified failures, and
|
||||
paths to every artifact reached during the run. Credentials, endpoints,
|
||||
rendered messages, request parameters, and generated content are excluded
|
||||
from these managed records.
|
||||
- Added `--llm-debug-dir` for explicitly requested content-rich diagnostics.
|
||||
Debug output must use an absolute path outside the managed workspace and is
|
||||
written with restrictive filesystem permissions.
|
||||
- Preflight now validates each exact prompt and selected profile before weather
|
||||
collection. Batch execution validates every candidate first, collects once,
|
||||
and retains independent report progress and failure artifacts.
|
||||
|
||||
See the [operations guide](../operations.md) for artifact layout, inspection,
|
||||
debug handling, and recovery.
|
||||
|
||||
### Weather Collection And Report Content
|
||||
|
||||
- Added a `/conditions/current` warmup before source collection and automatic
|
||||
retry for transient transport and response-read failures and HTTP `408`,
|
||||
`429`, `500`, `502`, `503`, and `504` responses.
|
||||
- Changed the default upstream precision query value to `0`.
|
||||
- Added embedded background definitions for recognized SPC categorical,
|
||||
tornado, wind, and hail outlook products.
|
||||
- Made the Alert Digest more concise: alert descriptions are omitted, and an
|
||||
SPC-only digest is rendered only for Enhanced, Moderate, or High categorical
|
||||
risk.
|
||||
- Removed duplicated alert detail from the prompt-facing metadata module; the
|
||||
alert digest remains its single prompt-facing owner.
|
||||
|
||||
See the [Weather API integration](../integrations/weatherapi.md) for the request,
|
||||
retry, and response contract.
|
||||
|
||||
### CLI, Documentation, Testing, And Releases
|
||||
|
||||
- Added `weatherreporter --version`; tagged binaries report `v0.9.0`, while
|
||||
ordinary local builds report `development`.
|
||||
- Reworked CLI summaries and inspection coverage around the Promptkit artifact
|
||||
lifecycle and retained partial-result behavior.
|
||||
- Reorganized contributor, policy, user, operator, integration, template, and
|
||||
internal documentation around explicit canonical owners.
|
||||
- Added focused single-report, batch, CLI, Promptkit adapter, durable-state,
|
||||
and artifact-path coverage while simplifying orchestration internals.
|
||||
- Added guarded tag validation and reproducible release builds for Linux,
|
||||
macOS, and Windows on `amd64` and `arm64`, with SHA-256 checksums and
|
||||
changelog-backed Gitea releases.
|
||||
177
docs/roadmap/future.md
Normal file
177
docs/roadmap/future.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# Future Roadmap
|
||||
|
||||
This roadmap contains future work only. Each section identifies its planning
|
||||
status; current behavior is documented outside `docs/roadmap/`.
|
||||
|
||||
## Upstream Forecast Change Product
|
||||
|
||||
Status: Proposed upstream feature request; unimplemented.
|
||||
|
||||
Weatherreporter's local Recent Changes feature was removed by the accepted
|
||||
[stateless execution decision](../adr/0001-stateless-execution.md). Forecast
|
||||
version history and comparison are better owned by the Weather API, where the
|
||||
underlying forecast issuances can be retained and compared consistently for
|
||||
all consumers.
|
||||
|
||||
A future Weather API feature should expose a structured change product with:
|
||||
|
||||
- explicit current and baseline forecast issuance timestamps or identifiers;
|
||||
- documented baseline selection, such as a requested comparison timestamp,
|
||||
preceding issuance, or fixed rolling period;
|
||||
- location, timezone, and half-open valid-period identity;
|
||||
- typed changed values with previous and current values and units;
|
||||
- stable change categories for temperature, precipitation probability and
|
||||
timing, wind gusts, alerts, and aggregate hazards;
|
||||
- an API-owned significance classification or enough structured information
|
||||
for a stateless consumer to apply a documented presentation threshold; and
|
||||
- deterministic ordering, missing-baseline behavior, and source metadata.
|
||||
|
||||
The API should compare forecast versions, not track a Weatherreporter client's
|
||||
"previous run." It should not require consumer identity, mutable cursors, or
|
||||
Weatherreporter-managed history. A missing baseline should be a normal empty
|
||||
result rather than an error.
|
||||
|
||||
Once a stable upstream contract exists, a separate Weatherreporter roadmap may
|
||||
reintroduce change commentary by collecting that product and mapping it into a
|
||||
curated prompt-facing module. There must be no local snapshot fallback. The
|
||||
ordinary Weatherreporter process must remain stateless, and the upstream
|
||||
feature should have deterministic fixtures before adoption.
|
||||
|
||||
## Automatic Storm Monitoring
|
||||
|
||||
Status: Proposed and unimplemented.
|
||||
|
||||
Storm reporting, whether manual or automatic, is unimplemented.
|
||||
|
||||
Possible direction:
|
||||
|
||||
1. Detect candidate storm events from alerts, forecast discussion, weather
|
||||
story context, hourly thresholds, and material forecast changes.
|
||||
2. Evaluate candidates through Promptkit or another narrow evaluator adapter.
|
||||
3. Keep any required storm lifecycle state in the upstream service or another
|
||||
explicitly designed external owner rather than silently reintroducing a
|
||||
Weatherreporter workspace.
|
||||
4. Generate or update a storm report 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`
|
||||
|
||||
Before implementation, the design must preserve scheduled report behavior,
|
||||
inspectable evaluator failures, and fixture coverage for deterministic
|
||||
candidate detection.
|
||||
|
||||
## Future Report Types
|
||||
|
||||
Status: Proposed and unimplemented.
|
||||
|
||||
Possible future report types:
|
||||
|
||||
- a short-fuse planning report distinct from the implemented Hourly Report, if
|
||||
a separate product is needed
|
||||
- event-specific reports with stable event IDs
|
||||
- storm review or yesterday-style reports using historical observations
|
||||
- archive-focused report variants if generated report history becomes a
|
||||
first-class product
|
||||
|
||||
New reports should preserve the boundaries documented in the [report registry
|
||||
internals](../internal/report-registry.md).
|
||||
|
||||
## Future Modules
|
||||
|
||||
Status: Proposed and unimplemented.
|
||||
|
||||
Possible future modules:
|
||||
|
||||
- `hourly_table` for compact valid-period hourly facts
|
||||
- `forecast_delta` after an upstream forecast-change product exists
|
||||
- `weekend_planning` if weekend-specific planning guidance needs a dedicated
|
||||
deterministic stanza
|
||||
- `storm_window_summary` if manual or automatic storm reports need a dedicated
|
||||
prompt-facing storm-window module
|
||||
- separate AFD section aliases, such as `afd_key_messages`,
|
||||
`afd_short_term_text`, and `afd_long_term_text`, if separate stanzas prove
|
||||
more useful than `area_forecast_discussion.options.sections`
|
||||
- SPC, radar, QPF, snow/rain total, or historical-observation modules once
|
||||
upstream sources and report requirements exist
|
||||
|
||||
QPF fields such as `measurable_qpf_total_in` and `max_hourly_qpf_in` should
|
||||
remain omitted until a real upstream quantitative precipitation source is
|
||||
represented in `CollectedFacts`.
|
||||
|
||||
Future module work should preserve the boundaries documented in [fact
|
||||
contracts](../internal/facts.md), [module internals](../internal/module.md), and
|
||||
[briefing internals](../internal/briefing.md):
|
||||
|
||||
- keep upstream collection in app orchestration
|
||||
- keep upstream collection out of modules
|
||||
- keep broad reusable calculations in `DerivedFacts`
|
||||
- keep prompt-facing field shape inside module builders
|
||||
- use typed options for configurable module behavior
|
||||
- keep module output structured and deterministic
|
||||
|
||||
## Distributor Notification Enhancements
|
||||
|
||||
Status: Proposed and unimplemented.
|
||||
|
||||
Single-report and batch Distributor notification are implemented. Current
|
||||
behavior is documented in the [Distributor adapter guide](../internal/distributor-adapter.md),
|
||||
[Distributor integration guides](../integrations/distributor/), and
|
||||
[operations guide](../operations.md). The following enhancements remain
|
||||
unimplemented:
|
||||
|
||||
- `failure_policy: warn`
|
||||
- durable upload retry queues
|
||||
- distributor-specific CLI flags
|
||||
- destination routing, Markdown-to-HTML transformation, public URLs, or nginx
|
||||
layout inside weatherreporter
|
||||
|
||||
Any distributor enhancement should preserve the adapter boundary:
|
||||
weatherreporter selects explicit generated files and submits source bundles,
|
||||
while distributor owns destination routing and publication behavior.
|
||||
|
||||
## Alternate Runtime Integrations
|
||||
|
||||
Status: Proposed and unimplemented.
|
||||
|
||||
These ideas remain unimplemented:
|
||||
|
||||
- native LLM client inside `weatherreporter`
|
||||
- database-backed state
|
||||
- public HTTP API
|
||||
- multi-location selection
|
||||
- daemon mode
|
||||
- multi-user authorization
|
||||
- plugin system
|
||||
- dynamic module loading
|
||||
- user-defined module code
|
||||
- YAML-defined module schemas
|
||||
- module-owned Weather API fetching
|
||||
|
||||
Each item needs its own design note before implementation. Non-roadmap docs
|
||||
must not describe these as available behavior.
|
||||
|
||||
## Deferred Refactors
|
||||
|
||||
Status: Deferred.
|
||||
|
||||
These refactors should remain deferred until new requirements or recurring
|
||||
maintenance costs make the added abstraction worthwhile:
|
||||
|
||||
- broad briefing weather-signal consolidation
|
||||
- generic workflow engine
|
||||
- Cobra migration
|
||||
- manifest, resume, or progress system
|
||||
- global test helper package
|
||||
- logging subsystem
|
||||
|
||||
Any future implementation should preserve the public CLI, report-output
|
||||
contract, report identities, module boundaries, and adapter boundaries in
|
||||
effect when that work begins unless a separate roadmap explicitly changes
|
||||
them.
|
||||
File diff suppressed because it is too large
Load Diff
397
docs/roadmap/profile-comparison.md
Normal file
397
docs/roadmap/profile-comparison.md
Normal file
@@ -0,0 +1,397 @@
|
||||
# LLM Profile Comparison Roadmap
|
||||
|
||||
Status: Accepted; unimplemented.
|
||||
|
||||
## Purpose
|
||||
|
||||
Prompt development currently requires separate Weatherreporter invocations to
|
||||
compare several LLM profiles. Those invocations may collect different weather
|
||||
snapshots or rebuild inputs at different times, making model output harder to
|
||||
compare and slowing prompt iteration.
|
||||
|
||||
Weatherreporter should provide a first-class `compare` command that resolves
|
||||
one report, prepares one exact data package, executes the same prompt and data
|
||||
package concurrently through several explicitly selected Promptkit profiles,
|
||||
and publishes a self-contained local comparison bundle.
|
||||
|
||||
An illustrative invocation is:
|
||||
|
||||
```sh
|
||||
weatherreporter compare daily \
|
||||
--date 2026-08-24 \
|
||||
--profile weather-light \
|
||||
--profile weather-balanced \
|
||||
--profile weather-deep
|
||||
```
|
||||
|
||||
This is a prompt-development workflow, not an automated model evaluator. Its
|
||||
output gives a maintainer consistent evidence for human comparison without
|
||||
assigning scores or selecting a winner.
|
||||
|
||||
## Prerequisite
|
||||
|
||||
Configurable output directories are implemented. Profile comparison must reuse
|
||||
the current [configuration reference](../config.md) and [operations
|
||||
guide](../operations.md) rather than introduce a second destination policy.
|
||||
|
||||
## User Intent
|
||||
|
||||
The command is intended for deliberate evaluation of multiple profiles,
|
||||
including sets of eight to twelve candidate models. Concurrency is part of the
|
||||
feature, not a future optimization. Promptkit should retain ownership of
|
||||
backend-specific capacity, while Weatherreporter owns comparison-wide
|
||||
coordination, cancellation, deterministic results, and artifact publication.
|
||||
|
||||
Every profile must receive byte-for-byte identical prompt input. Weather data,
|
||||
derived facts, modules, prompt metadata, and serialized YAML must not be
|
||||
recollected or rebuilt separately for individual profiles.
|
||||
|
||||
Comparison bundles are explicitly requested, operator-owned development
|
||||
outputs. They are not Weatherreporter state, are never read implicitly by a
|
||||
later run, and do not weaken the ordinary stateless execution model.
|
||||
|
||||
## Command Contract
|
||||
|
||||
The command form is:
|
||||
|
||||
```text
|
||||
weatherreporter compare REPORT [options]
|
||||
```
|
||||
|
||||
`REPORT` accepts the implemented generated-text reports: `daily`, `today`,
|
||||
`tomorrow`, and `hourly`. Report-date behavior matches `generate`: `daily`
|
||||
requires `--date`, `today` may accept an explicit date or use the current local
|
||||
date, and the remaining report types retain their existing period policies.
|
||||
|
||||
The command accepts the applicable common generation options, including
|
||||
`--config`, `--units`, `--tz`, `--date`, `--llm-debug-dir`, and `--quiet`, plus:
|
||||
|
||||
- repeatable `--profile PROFILE_ID` selections;
|
||||
- `--out-dir PATH` for the exact comparison-bundle directory; and
|
||||
- `--replace` to authorize guarded replacement of a recognized existing
|
||||
comparison bundle.
|
||||
|
||||
At least two distinct, nonblank profile IDs are required. Their command-line
|
||||
order is significant and is preserved in filenames, summaries, and
|
||||
`comparison.json`. Duplicate profile IDs are rejected rather than silently
|
||||
deduplicated or executed twice.
|
||||
|
||||
Profiles are always explicit for this command. `promptkit.profile` does not add
|
||||
or replace a comparison selection, but all other effective Promptkit settings,
|
||||
profile-source precedence, local backend configuration, credential lookup, and
|
||||
profile overrides remain in force.
|
||||
|
||||
The initial feature has no Weatherreporter-specific concurrency flag or
|
||||
artificial profile-count ceiling. The explicit profile list bounds the
|
||||
comparison, and Promptkit owns capacity enforcement for each selected backend.
|
||||
|
||||
## Preparation And Execution Invariants
|
||||
|
||||
A comparison has this logical lifecycle:
|
||||
|
||||
1. Parse and validate the report, date, profile list, configuration, output
|
||||
destination, and replacement authorization.
|
||||
2. Resolve the report definition, valid period, prompt identity, and default
|
||||
output name once.
|
||||
3. Inspect the exact prompt once and preflight every selected profile,
|
||||
including its effective backend, model, and required credential
|
||||
availability, before weather collection.
|
||||
4. Collect weather data exactly once.
|
||||
5. Build collected and derived facts, the module snapshot, briefing metadata,
|
||||
and the prompt data package exactly once.
|
||||
6. Marshal the data package to one immutable YAML byte sequence exactly once.
|
||||
7. Execute the exact prompt version concurrently for every selected profile,
|
||||
passing the same immutable YAML bytes to every execution.
|
||||
8. Validate and render each profile result independently from the shared
|
||||
deterministic inputs.
|
||||
9. Assemble results in requested-profile order and publish one coherent
|
||||
comparison bundle.
|
||||
|
||||
This lifecycle describes the required end-state behavior rather than an
|
||||
implementation-stage sequence.
|
||||
|
||||
No profile execution may cause recollection, report re-resolution, module
|
||||
rebuilding, or data-package remarshalling. Prompt execution may perform
|
||||
Promptkit-owned validation or repair behavior, but Weatherreporter does not
|
||||
retry a failed comparison execution independently.
|
||||
|
||||
## Concurrency And Cancellation
|
||||
|
||||
Weatherreporter starts one execution for each preflighted profile and permits
|
||||
them to run concurrently through one shared, concurrency-safe Promptkit
|
||||
executor. Promptkit's engine-local backend pools remain authoritative for
|
||||
backend concurrency and waiting capacity. Profiles routed to a limited local
|
||||
backend therefore respect its configured limit, while profiles routed to
|
||||
other backends may proceed independently.
|
||||
|
||||
Weatherreporter must not add a second semaphore that obscures or overrides
|
||||
Promptkit's backend policy. It must safely coordinate goroutine lifecycles,
|
||||
result collection, debug callbacks, and output assembly without data races.
|
||||
|
||||
One profile failure does not cancel its peers. Provider, capacity, validation,
|
||||
and rendering failures are recorded for that profile while other executions
|
||||
continue. Cancellation or deadline expiration of the comparison command is
|
||||
propagated to every outstanding execution, prevents new publication, and is
|
||||
joined without leaking goroutines.
|
||||
|
||||
Completion order must not affect filenames, manifest order, CLI summaries, or
|
||||
error aggregation. Those outputs always follow the original `--profile`
|
||||
order.
|
||||
|
||||
## Output Destination
|
||||
|
||||
Without `--out-dir`, Weatherreporter derives a comparison directory from the
|
||||
resolved report's existing default Markdown filename by removing `.md` and
|
||||
prefixing `comparison-`:
|
||||
|
||||
| Report output | Comparison directory |
|
||||
| --- | --- |
|
||||
| `today.md` | `comparison-today/` |
|
||||
| `tomorrow.md` | `comparison-tomorrow/` |
|
||||
| `hourly.md` | `comparison-hourly/` |
|
||||
| `daily-2026-08-24.md` | `comparison-daily-2026-08-24/` |
|
||||
|
||||
The derived directory is created beneath `output.directory` when configured,
|
||||
or beneath the present working directory otherwise. An explicit `--out-dir`
|
||||
is the exact bundle directory, resolves relative to the present working
|
||||
directory when necessary, and overrides `output.directory` completely.
|
||||
|
||||
All destination selection and validation completes before weather collection.
|
||||
The resolved comparison directory is returned in the command's structured
|
||||
result.
|
||||
|
||||
## Comparison Bundle
|
||||
|
||||
A successful three-profile comparison has a flat layout:
|
||||
|
||||
```text
|
||||
comparison-daily-2026-08-24/
|
||||
├── comparison.json
|
||||
├── data-package.yml
|
||||
├── 01-weather-light.md
|
||||
├── 02-weather-balanced.md
|
||||
└── 03-weather-deep.md
|
||||
```
|
||||
|
||||
`data-package.yml` contains the exact YAML bytes passed to every Promptkit
|
||||
execution. It is written once and its SHA-256 digest is recorded in the
|
||||
manifest.
|
||||
|
||||
Each report filename begins with its one-based, zero-padded selection position
|
||||
and a filesystem-safe representation of the requested logical profile ID. The
|
||||
safe representation must not permit absolute paths, traversal, separators, or
|
||||
control characters. The manifest retains the exact case-sensitive profile ID,
|
||||
so filename normalization never becomes the authority for profile identity.
|
||||
|
||||
`comparison.json` is the authoritative index for the bundle. It uses an
|
||||
explicit schema version and records safe comparison information including:
|
||||
|
||||
- comparison identity and start and finish timestamps;
|
||||
- report ID, resolved valid period, and effective timezone;
|
||||
- prompt ID, version, and inspected prompt hash;
|
||||
- the relative data-package filename and SHA-256 digest;
|
||||
- total, succeeded, and failed profile counts; and
|
||||
- one ordered result per requested profile containing the exact profile ID,
|
||||
resolved backend and model, relative report filename when present,
|
||||
execution and validation status, and safe error information when failed.
|
||||
|
||||
The manifest and normal command summary must not contain credentials, provider
|
||||
request bodies, raw model output, rendered prompts, schemas, provider
|
||||
endpoints, or other content-rich diagnostics. The explicit data package and
|
||||
generated reports contain the development material the user requested and
|
||||
must be handled as operator-owned potentially sensitive output.
|
||||
|
||||
## Failure And Publication Policy
|
||||
|
||||
Failure before concurrent execution, including invalid profiles, missing
|
||||
credentials, collection failure, preparation failure, or unsafe destination,
|
||||
publishes no comparison bundle and performs no model calls where the failure
|
||||
is discoverable during preflight.
|
||||
|
||||
After execution begins, Weatherreporter waits for every non-cancelled profile.
|
||||
If one or more profiles fail, it still publishes a coherent partial bundle
|
||||
containing `data-package.yml`, every successfully rendered report, and a
|
||||
manifest describing all successes and failures. It then returns a non-zero
|
||||
exit status. A failed profile has no report file unless a future contract
|
||||
explicitly introduces a separately named diagnostic artifact.
|
||||
|
||||
Bundle contents are staged outside the destination and published only after
|
||||
the manifest is complete. Ordinary publication accepts only an absent or empty
|
||||
target directory. A nonempty existing directory fails without modification
|
||||
unless `--replace` is present.
|
||||
|
||||
`--replace` may replace only the exact resolved target and must reject broad or
|
||||
unsafe targets such as a filesystem root, the present working directory, a
|
||||
symlink, or an unrecognized nonempty directory. A recognized prior bundle must
|
||||
contain a valid Weatherreporter comparison manifest. Replacement publishes the
|
||||
new complete or coherent partial bundle as a unit, prevents stale reports from
|
||||
the prior comparison from surviving, and preserves or restores the prior
|
||||
bundle if the final replacement operation fails.
|
||||
|
||||
An interrupted or cancelled comparison does not replace an existing bundle.
|
||||
Temporary staging artifacts are cleaned up on ordinary failure and
|
||||
cancellation without scanning or modifying unrelated directories.
|
||||
|
||||
## Prompt Debugging
|
||||
|
||||
The existing `--llm-debug-dir` mechanism remains available. Concurrent
|
||||
comparison executions require distinct, deterministic debug identities that
|
||||
include the comparison and exact profile selection so callbacks cannot collide
|
||||
or overwrite another profile's artifacts.
|
||||
|
||||
Debug writing must be concurrency-safe and retain the existing permission,
|
||||
redaction, explicit-opt-in, and path-containment guarantees. Debug artifacts
|
||||
remain separate from the comparison bundle; the bundle does not implicitly
|
||||
enable full Promptkit diagnostics.
|
||||
|
||||
## Notification Policy
|
||||
|
||||
Profile comparisons never invoke Distributor notification, even when
|
||||
notification is enabled in the effective configuration. Comparison reports
|
||||
are local development artifacts rather than ordinary report publications.
|
||||
|
||||
Adding comparison publication or upload behavior would require a separate
|
||||
accepted feature scope and explicit operator authorization.
|
||||
|
||||
## Architectural End State
|
||||
|
||||
Application orchestration exposes a reusable prepared-report boundary that
|
||||
contains the resolved report, shared collected and derived facts, module
|
||||
snapshot, briefing metadata, generated-text handler, render inputs, and exact
|
||||
serialized data package. That boundary is immutable during concurrent profile
|
||||
execution.
|
||||
|
||||
Ordinary `generate` behavior continues to prepare once and execute once.
|
||||
`compare` prepares once and executes many without duplicating the generation
|
||||
workflow or calling `GenerateDetailed` in a loop. Shared preparation,
|
||||
profile-specific Promptkit execution, structured-output validation, rendering,
|
||||
and artifact publication remain distinct responsibilities.
|
||||
|
||||
The Promptkit adapter remains the only owner of dependency-specific types and
|
||||
engine calls. The CLI owns parsing and user-facing summaries. The configuration
|
||||
package owns configuration. Application orchestration owns comparison order,
|
||||
concurrency lifecycle, failure aggregation, and bundle publication. Domain,
|
||||
prompt-input, generated-text, and template packages retain their existing
|
||||
deterministic contracts.
|
||||
|
||||
## Scope
|
||||
|
||||
The completed feature includes:
|
||||
|
||||
- the `compare` CLI command for every implemented generated-text report;
|
||||
- repeatable explicit profile selection and validation;
|
||||
- configured and CLI output-directory integration after the prerequisite
|
||||
feature lands;
|
||||
- one-time report resolution, collection, deterministic preparation, and YAML
|
||||
serialization;
|
||||
- concurrent execution through one Promptkit executor with backend capacity
|
||||
respected;
|
||||
- independent validation and rendering with deterministic ordered results;
|
||||
- the flat, versioned comparison-bundle contract;
|
||||
- safe filename derivation and data-package hashing;
|
||||
- coherent partial-result publication and non-zero failure behavior;
|
||||
- guarded whole-bundle replacement through `--replace`;
|
||||
- comparison-aware, concurrency-safe optional prompt debugging;
|
||||
- explicit suppression of Distributor notification;
|
||||
- structured normal and quiet-mode CLI behavior consistent with existing
|
||||
commands;
|
||||
- focused race-safe tests across configuration, CLI, application,
|
||||
Promptkit-adapter, rendering, and filesystem boundaries; and
|
||||
- updates to every affected canonical user, operator, architecture,
|
||||
integration, and internal document.
|
||||
|
||||
## Compatibility
|
||||
|
||||
The feature is additive. Existing `generate` and `run` commands, report
|
||||
definitions, profile defaults, configuration, output filenames, notification
|
||||
behavior, and exit contracts remain unchanged.
|
||||
|
||||
The comparison manifest and bundle layout begin as versioned contracts. They
|
||||
do not become inputs accepted by Weatherreporter, and no backward-compatible
|
||||
replay or long-term archive guarantee is implied beyond identifying the schema
|
||||
used to interpret a produced bundle.
|
||||
|
||||
## Testing Expectations
|
||||
|
||||
Tests should provide durable coverage for:
|
||||
|
||||
- report and date parsing consistent with `generate`;
|
||||
- rejection of fewer than two profiles, blanks, and duplicates;
|
||||
- inspection of the exact prompt and every profile before collection;
|
||||
- no collection or model execution after a preflight failure;
|
||||
- exactly one weather collection and one preparation for several profiles;
|
||||
- byte-for-byte identical data-package input in every execution;
|
||||
- observable concurrent execution through a concurrency-safe fake executor;
|
||||
- respect for Promptkit-owned backend capacity in an assembled adapter test
|
||||
where that integration adds distinct confidence;
|
||||
- deterministic filenames, manifest order, summaries, and errors under varied
|
||||
completion order;
|
||||
- continuation and coherent partial publication after one profile fails;
|
||||
- cancellation propagation, goroutine completion, and preservation of an
|
||||
existing destination;
|
||||
- destination precedence and each derived default directory;
|
||||
- safe filename handling for unusual valid profile IDs;
|
||||
- absent, empty, occupied, symlinked, unsafe, recognized, and unrecognized
|
||||
replacement targets;
|
||||
- removal of stale prior report files during authorized whole-bundle
|
||||
replacement;
|
||||
- exact package digest and manifest/result consistency;
|
||||
- concurrency-safe, non-colliding opt-in debug artifacts; and
|
||||
- absence of Distributor calls for complete and partial comparisons.
|
||||
|
||||
Concurrency and replacement behavior require race-enabled and consequential
|
||||
failure-path coverage. Tests must remain deterministic, offline, credential
|
||||
free, and independent of real Promptkit providers or machine-specific paths.
|
||||
|
||||
## Documentation End State
|
||||
|
||||
Once implemented, the [CLI reference](../cli.md) owns command syntax, flags,
|
||||
summary, and exit behavior. The [operations guide](../operations.md) owns the
|
||||
bundle lifecycle, replacement procedure, sensitivity guidance, and practical
|
||||
prompt-comparison workflow. The [architecture policy](../policy/architecture.md)
|
||||
owns the statelessness, concurrency, notification, and publication invariants.
|
||||
|
||||
The [Promptkit integration guide](../integrations/promptkit.md) should describe
|
||||
the consumer-visible multi-profile execution boundary without duplicating
|
||||
Promptkit's backend-capacity reference. App orchestration, prompt input,
|
||||
generated text, prompt debugging, and any new bundle implementation details
|
||||
belong in focused documents under `docs/internal/`.
|
||||
|
||||
Current-state documentation must not describe profile comparison as available
|
||||
until the implementation lands.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
This roadmap does not introduce:
|
||||
|
||||
- automatic model scoring, ranking, recommendation, or winner selection;
|
||||
- semantic or textual diff generation between reports;
|
||||
- repeated sampling of one profile or statistical evaluation;
|
||||
- prompt or profile editing through Weatherreporter;
|
||||
- replaying a saved data package as command input;
|
||||
- comparing several report types in one command;
|
||||
- Weatherreporter-owned backend concurrency or queue configuration;
|
||||
- automatic retries beyond Promptkit's existing execution contract;
|
||||
- Distributor upload or other external publication;
|
||||
- comparison history, indexing, retention, cleanup schedules, or implicit
|
||||
discovery of prior bundles; or
|
||||
- changes to ordinary report content or normal generation behavior.
|
||||
|
||||
Any later automated evaluation, replay, sampling, or publication feature
|
||||
requires a separate accepted roadmap.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
The feature is complete when a maintainer can select several Promptkit
|
||||
profiles, have them execute concurrently against one exact prepared report
|
||||
package, and receive a safe, flat, deterministic comparison bundle whose
|
||||
manifest accurately describes every success and failure. Configured and
|
||||
explicit destinations must follow the accepted output policy, replacement must
|
||||
never mix or silently destroy unrelated contents, cancellation and partial
|
||||
failure must be race-safe, ordinary notification must remain disabled, and all
|
||||
affected canonical documentation must describe the implemented behavior.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None. The scope, prerequisites, user intent, and target behavior required for a
|
||||
future staged implementation plan are defined above.
|
||||
178
docs/templates.md
Normal file
178
docs/templates.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# Report Templates
|
||||
|
||||
This guide is for maintainers editing Weatherreporter's embedded Markdown
|
||||
templates. Templates format already validated report inputs; they do not select
|
||||
sources, derive weather facts, or validate generated prose. For those details,
|
||||
see [Generated Text internals](internal/generatedtext.md) and [Report Template
|
||||
internals](internal/reporttemplate.md).
|
||||
|
||||
## Template Assets
|
||||
|
||||
Only the generated-text reports use repository-native Markdown templates.
|
||||
Each report has one matching template ID, generated-text schema ID, and prompt
|
||||
source:
|
||||
|
||||
| Report | Template | Schema | Prompt ID and source |
|
||||
| --- | --- | --- | --- |
|
||||
| Daily | `templates/daily.md.tmpl` (`daily`) | `daily` | `weather.daily_generated_text`; `internal/promptassets/assets/prompts/daily/` |
|
||||
| Today | `templates/today.md.tmpl` (`today`) | `today` | `weather.today_generated_text`; `internal/promptassets/assets/prompts/today/` |
|
||||
| Tomorrow | `templates/tomorrow.md.tmpl` (`tomorrow`) | `tomorrow` | `weather.tomorrow_generated_text`; `internal/promptassets/assets/prompts/tomorrow/` |
|
||||
| Hourly | `templates/hourly.md.tmpl` (`hourly`) | `hourly` | `weather.hourly_generated_text`; `internal/promptassets/assets/prompts/hourly/` |
|
||||
|
||||
The matching schemas and Promptkit definitions are embedded by
|
||||
`internal/promptassets`. The generated-text catalog pairs each schema ID with
|
||||
its template ID; keep the matching prompt definition aligned with that pair.
|
||||
|
||||
Shared partials are under `internal/reporttemplate/templates/partials/`:
|
||||
|
||||
| Partial | Used by |
|
||||
| --- | --- |
|
||||
| `alert_digest.md.tmpl` | Daily, Today, Tomorrow, and Hourly |
|
||||
| `precipitation_timing.md.tmpl` | Daily, Today, Tomorrow, and Hourly |
|
||||
| `daypart_forecast.md.tmpl` | Daily and Tomorrow |
|
||||
| `today_daypart_forecast.md.tmpl` | Today |
|
||||
|
||||
All shared partials are parsed whenever any top-level template is rendered. A
|
||||
syntax error in a partial can therefore prevent every generated-text report
|
||||
from rendering.
|
||||
|
||||
## Editing Rules
|
||||
|
||||
- Use Go `text/template` syntax and keep changes to Markdown structure,
|
||||
ordering, and display conditions.
|
||||
- Templates use `missingkey=error`; reference only documented fields and guard
|
||||
optional module pointers with `with` or `if`.
|
||||
- Prefer `.Modules` for deterministic display values. Do not add weather
|
||||
calculations, source selection, or prompt-input shaping to a template.
|
||||
- Keep generated prose in `.GeneratedText`; do not restate deterministic facts
|
||||
in generated prose merely to compensate for a template change.
|
||||
- When changing the generated-prose contract, update the matching prompt,
|
||||
schema, validator, render context, and template together. The validation and
|
||||
catalog rules are owned by [Generated Text internals](internal/generatedtext.md).
|
||||
- Use `.Modules.Dayparts` for ordered daypart output. Do not range over
|
||||
`.Modules.DerivedDaypartSummaries`, which is a map.
|
||||
|
||||
Minimal optional-value pattern:
|
||||
|
||||
```gotemplate
|
||||
{{ with .Modules.CurrentConditions }}
|
||||
Currently, it is {{ with .TemperatureF }}{{ . }}°F{{ end }}.
|
||||
{{ else }}
|
||||
Current conditions are unavailable.
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
Minimal list pattern:
|
||||
|
||||
```gotemplate
|
||||
{{ range .GeneratedText.ForecastDiscussion }}
|
||||
{{ . }}
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
## Registered Functions
|
||||
|
||||
Templates have these helpers in addition to Go template built-ins:
|
||||
|
||||
| Function | Accepts | Returns true when |
|
||||
| --- | --- | --- |
|
||||
| `hasRelevantAlerts` | an alert-digest value or pointer | its `Relevant` slice is nonempty |
|
||||
| `hasEnhancedOrHigherSPCRisk` | an SPC outlook value or pointer | its `RiskDigest` contains an Enhanced, Moderate, or High Risk entry |
|
||||
| `isEnhancedOrHigherSPCRisk` | one SPC risk-digest entry | its `LabelText`, or fallback `RiskLabel`, is Enhanced, Moderate, or High Risk |
|
||||
|
||||
For example, the alert partial uses the first two functions to decide whether
|
||||
to render the section:
|
||||
|
||||
```gotemplate
|
||||
{{ if hasRelevantAlerts .Modules.AlertDigest }}
|
||||
## Alert Digest
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
## Render Context
|
||||
|
||||
Every rendered template receives one typed context with these five top-level
|
||||
fields:
|
||||
|
||||
| Field | Purpose |
|
||||
| --- | --- |
|
||||
| `.Report` | Display labels and canonical report timing metadata. |
|
||||
| `.GeneratedText` | Validated prose supplied by Promptkit. |
|
||||
| `.Modules` | Deterministic, typed values prepared for Markdown rendering. |
|
||||
| `.Collected` | Normalized upstream facts for advanced use. |
|
||||
| `.Derived` | Shared calculated facts for advanced use. |
|
||||
|
||||
`.Collected` and `.Derived` are available for an exceptional display need, but
|
||||
they are lower-level contracts. Keep reusable weather derivation in Go and use
|
||||
the module surface for normal template work.
|
||||
|
||||
### Report Metadata
|
||||
|
||||
All contexts provide `.Report.Title`, `.Report.GeneratedAt`,
|
||||
`.Report.GeneratedAtLabel`, `.Report.ValidPeriod`, and `.Report.Timezone`.
|
||||
|
||||
Hourly additionally provides `.Report.LocationName` and
|
||||
`.Report.ValidPeriodLabel`.
|
||||
|
||||
Daily, Today, and Tomorrow additionally provide `.Report.ForecastDate`,
|
||||
`.Report.ForecastDateLabel`, and `.Report.ForecastDayName`. Their valid-period
|
||||
field remains canonical timing data; use the supplied display labels instead
|
||||
of formatting timestamps in a template.
|
||||
|
||||
### Validated GeneratedText Prose
|
||||
|
||||
GeneratedText is prose returned by Promptkit and validated before rendering.
|
||||
It is not a source for deterministic weather facts.
|
||||
|
||||
| Field | Hourly type | Daily, Today, and Tomorrow type | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `.GeneratedText.Summary` | `string` | `string` | Required. |
|
||||
| `.GeneratedText.ForecastDiscussion` | `string` | `[]string` | Required; range over the day-style paragraph slice. |
|
||||
| `.GeneratedText.PrecipitationTiming` | `string` | `string` | Required field; an empty string represents no supported prose. The precipitation partial uses nonempty prose only when deterministic windows exist. |
|
||||
|
||||
The JSON schema rejects unknown properties and defines the required fields, but
|
||||
the schema body and validation behavior are documented in [Generated Text
|
||||
internals](internal/generatedtext.md).
|
||||
|
||||
### Deterministic Module Values
|
||||
|
||||
Module values are deterministic outputs built from collected and derived facts.
|
||||
Module pointers can be nil when their source or policy permits omission.
|
||||
|
||||
| Module field | Available in |
|
||||
| --- | --- |
|
||||
| `.Modules.Metadata`, `.Modules.CurrentConditions`, `.Modules.HourlyForecast`, `.Modules.PrecipTiming`, `.Modules.AlertDigest`, `.Modules.SPCConvectiveOutlooks`, `.Modules.AreaForecastDiscussion`, `.Modules.SPCConvectiveDiscussion`, `.Modules.WeatherStory` | All four contexts |
|
||||
| `.Modules.DerivedDailySummary`, `.Modules.DerivedDaypartSummaries`, `.Modules.Dayparts` | Daily, Today, Tomorrow |
|
||||
| `.Modules.OutdoorWindows`, `.Modules.DailyPlanning` | Daily |
|
||||
| `.Modules.TodayPlanning` | Today |
|
||||
| `.Modules.TomorrowPlanning` | Tomorrow |
|
||||
|
||||
The repository templates currently use the following nested display values.
|
||||
They are the preferred surface for comparable edits:
|
||||
|
||||
| Area | Values |
|
||||
| --- | --- |
|
||||
| Current conditions | `.TemperatureF`, `.ConditionText`, `.ConditionTextLower`, `.ApparentTemperatureF`, `.RelativeHumidityPercent`, `.WindDirectionText`, `.WindSpeedMph` |
|
||||
| Hourly periods | `.Periods`, `.HourLabel`, `.Name`, `.TemperatureF`, `.TextDescription`, `.TextDescriptionLower`, `.MentionPrecipitation`, `.ProbabilityOfPrecipitationPercent` |
|
||||
| Dayparts | `.Dayparts[].Key` and `.Dayparts[].Summary` fields `DisplayName`, `DominantCondition`, `DominantConditionDisplay`, `TemperatureTrend`, `TemperatureStartPhraseF`, `TemperatureEndPhraseF`, `TemperaturePeakPhraseF`, `TemperatureSteadyPhraseF`, `TemperaturePhraseF`, `MentionPrecipitation`, and `MaxPopPercent` |
|
||||
| Precipitation timing | `.PrecipitationWindows`, plus each window's `PeriodBegins`, `PeriodBeginsHourLabel`, `PeriodEnds`, `PeriodEndsHourLabel`, `ExpectationPhrase`, `MaxPopPercent`, `MaxPopTime`, and `MaxPopHourLabel` |
|
||||
| Alert digest | `.AlertDigest.Relevant` entries' `Event`, `Headline`, `PeriodBegins`, and `PeriodEnds` |
|
||||
| SPC risk digest | `.SPCConvectiveOutlooks.RiskDigest` entries' `LabelText`, `RiskLabel`, `PeriodBegins`, and `PeriodEnds` |
|
||||
|
||||
Other fields on these typed modules remain available when a template has a
|
||||
well-defined display need. Their module contracts and weather derivation belong
|
||||
to [Module contract internals](internal/module.md), [Module builder
|
||||
internals](internal/briefing.md), and [Forecast derivation
|
||||
internals](internal/forecast-derivation.md).
|
||||
|
||||
## Validate Changes
|
||||
|
||||
Run the focused checks after editing templates, partials, prompts, or schemas:
|
||||
|
||||
```sh
|
||||
go test ./internal/reporttemplate ./internal/generatedtext ./internal/app
|
||||
git diff --check
|
||||
```
|
||||
|
||||
The render-context and template tests cover Daily, Today, Tomorrow, and Hourly
|
||||
contexts. Run the repository-wide test suite before merging a broader change.
|
||||
@@ -1,29 +1,47 @@
|
||||
weather_api:
|
||||
base_url: https://weather.api.example.com/
|
||||
timeout: 15s
|
||||
precision: 1
|
||||
precision: 0
|
||||
units: us
|
||||
timezone: Chicago
|
||||
timezone: "America/Chicago"
|
||||
format: json
|
||||
|
||||
location:
|
||||
id: home
|
||||
name: Brentwood
|
||||
region: St. Louis Metro
|
||||
|
||||
secrets:
|
||||
directory: ""
|
||||
|
||||
output:
|
||||
directory: /var/lib/weatherreporter/reports
|
||||
|
||||
notify:
|
||||
distributor:
|
||||
enabled: false
|
||||
endpoint: https://distributor.example.com
|
||||
token_env: DISTRIBUTOR_UPLOAD_TOKEN
|
||||
timeout: 30s
|
||||
failure_policy: error
|
||||
pipeline_id_template: "weatherreporter.{report_id}"
|
||||
bundle_id_template: "weatherreporter.{location_id}.{report_id}"
|
||||
idempotency_key_template: "{bundle_id}.{run_id}"
|
||||
batch:
|
||||
enabled: true
|
||||
pipeline_id_template: "weatherreporter"
|
||||
bundle_id_template: "weatherreporter.{location_id}.{batch}"
|
||||
idempotency_key_template: "{bundle_id}.{batch_run_id}"
|
||||
|
||||
missing_source:
|
||||
default: warn
|
||||
sources:
|
||||
alerts: none
|
||||
|
||||
scriptorium:
|
||||
binary: scriptorium
|
||||
promptkit:
|
||||
timeout: 2m
|
||||
|
||||
workspace:
|
||||
root: workspace
|
||||
snapshots_dir: snapshots
|
||||
reports_dir: reports
|
||||
data_packages_dir: data-packages
|
||||
preflight_dir: preflight
|
||||
|
||||
reports:
|
||||
output_dir: reports
|
||||
local:
|
||||
concurrency_limit: 1
|
||||
|
||||
dayparts:
|
||||
- name: overnight
|
||||
@@ -31,16 +49,75 @@ dayparts:
|
||||
end: "06:00"
|
||||
- name: morning
|
||||
start: "06:00"
|
||||
end: "12:00"
|
||||
end: "10:00"
|
||||
- name: midday
|
||||
start: "10:00"
|
||||
end: "15:00"
|
||||
- name: afternoon
|
||||
start: "12:00"
|
||||
end: "18:00"
|
||||
start: "15:00"
|
||||
end: "17:00"
|
||||
- name: evening
|
||||
start: "18:00"
|
||||
start: "17:00"
|
||||
end: "24:00"
|
||||
|
||||
recent_change:
|
||||
temperature_degrees: 5
|
||||
precip_probability_points: 20
|
||||
wind_gust_miles_per_hour: 10
|
||||
precip_timing_shift_minutes: 120
|
||||
reports:
|
||||
daily:
|
||||
distributor:
|
||||
path_templates:
|
||||
- "daily/{valid_start_date}/{run_id}.md"
|
||||
- "daily/{valid_start_date}/index.md"
|
||||
deterministic_modules:
|
||||
- metadata
|
||||
- current_conditions
|
||||
- narrative_forecast
|
||||
- derived_daily_summary
|
||||
- derived_daypart_summaries
|
||||
- precip_timing
|
||||
- alert_digest
|
||||
- spc_convective_outlooks
|
||||
- id: area_forecast_discussion
|
||||
options:
|
||||
sections:
|
||||
- long_term
|
||||
- spc_convective_discussion
|
||||
- weather_story
|
||||
- outdoor_windows
|
||||
- daily_planning
|
||||
- hourly_forecast
|
||||
today:
|
||||
deterministic_modules:
|
||||
- metadata
|
||||
- current_conditions
|
||||
- narrative_forecast
|
||||
- derived_daily_summary
|
||||
- derived_daypart_summaries
|
||||
- precip_timing
|
||||
- alert_digest
|
||||
- spc_convective_outlooks
|
||||
- id: area_forecast_discussion
|
||||
options:
|
||||
sections:
|
||||
- product
|
||||
- key_messages
|
||||
- short_term
|
||||
- long_term
|
||||
- spc_convective_discussion
|
||||
- weather_story
|
||||
- outdoor_windows
|
||||
- hourly_forecast
|
||||
- today_planning
|
||||
hourly:
|
||||
deterministic_modules:
|
||||
- metadata
|
||||
- current_conditions
|
||||
- hourly_forecast
|
||||
- precip_timing
|
||||
- alert_digest
|
||||
- spc_convective_outlooks
|
||||
- id: area_forecast_discussion
|
||||
options:
|
||||
sections:
|
||||
- key_messages
|
||||
- short_term
|
||||
- spc_convective_discussion
|
||||
- weather_story
|
||||
|
||||
2
examples/minimal-config.yml
Normal file
2
examples/minimal-config.yml
Normal file
@@ -0,0 +1,2 @@
|
||||
weather_api:
|
||||
base_url: https://weather.api.example.com/
|
||||
4
examples/weather-light-local-profile.yml
Normal file
4
examples/weather-light-local-profile.yml
Normal file
@@ -0,0 +1,4 @@
|
||||
id: weather-light
|
||||
endpoint: http://127.0.0.1:11434/v1
|
||||
model: weather-local
|
||||
timeout_seconds: 180
|
||||
10
go.mod
10
go.mod
@@ -3,3 +3,13 @@ module gitea.maximumdirect.net/eric/weatherreporter
|
||||
go 1.26
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
|
||||
require (
|
||||
gitea.maximumdirect.net/eric/distributor v0.5.0
|
||||
gitea.maximumdirect.net/eric/promptkit v0.5.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
)
|
||||
|
||||
56
go.sum
56
go.sum
@@ -1,3 +1,59 @@
|
||||
gitea.maximumdirect.net/eric/distributor v0.5.0 h1:+al7Bw+kMv6V35a3Sm5rUtCTQhwOn5b9x3RsclPMKJk=
|
||||
gitea.maximumdirect.net/eric/distributor v0.5.0/go.mod h1:G03FCFZPHpsUKC6SeMgTdbfNRpPQBdyTtDUj04e1Tu8=
|
||||
gitea.maximumdirect.net/eric/promptkit v0.5.0 h1:jnpazLyyNhWrB2xzwwtUkNUfktkTdkENTwuSPnKiYrc=
|
||||
gitea.maximumdirect.net/eric/promptkit v0.5.0/go.mod h1:R95NM6fbMDGDC0/UomgnSBP6ui2ns+8SZb8bESNvrDQ=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11/go.mod h1:dnakxebH6UwFvcvujL0LVggYQ8nEvBGjU4G/V79Nv94=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.20/go.mod h1:PuwEpciweIXGULWeOeSTXtSbH4CW9mWdWrhdCKQI1sM=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 h1:0w6dCiO8iez+YKwRhRBlL1CH/E3GTfdkuzrwj1by8vo=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25/go.mod h1:9FDWUothyr5RCRAHc45XOiVCzUR8n/IhCYX+uVqw6vk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 h1:A1PmWU2zfkIm9EyFlJncFXL4W4phML+h8KjltUsCvNQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26/go.mod h1:dY4MRzXEizrD4hqtpKvWVGPX7QleSGGVY+EBolo1RmM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 h1:W/EyPFl9A5rXrtoilfwHYEvzHER+K4SpBPtMXi24Mos=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18/go.mod h1:UG50K+pvd/uy6xExbobg0rjqFBFZe6I3l75EPDZw4tg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 h1:dD3dhHNglpd98gs72my22Ndqi1hqQGllFFg1F+twfxg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25/go.mod h1:0yAbjPfd64gG7mj85RW+fMEYdfBgCRZw8g/oWcL1pjc=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 h1:2pQEbwf+/6EDbiit/GcBE2K4IUpMZymaA0kOz3xK978=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25/go.mod h1:KvT6NCcQ0EZ+ZkVRrlBMt04Po3ok23YELEp7WimhLhM=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 h1:ie4ElCmUKS26pzrZcIk/lmt4yWjAqLLcawstyQCh298=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2/go.mod h1:zjsomFeX5duj+4PlMB+o4JoWTIx+G0XMyzjYrUbQkN0=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 h1:1VwbP3qMNfxUDEXWki4rCE5iA+44VA1lokTz9HasGzw=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1/go.mod h1:vUtyoSj0OPji3kjIVSc/GlKuWEiL33f/WFxl6dmpy/A=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 h1:N6pIsdFOW1Kd9S4KyFKXdGRBojPPxkP32+uHFWLv4Hc=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19/go.mod h1:3gt5WJArFooNmyLONS+h/R4J+o86II8du38IgCwj9dE=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 h1:hc+lBYiiTr8Zk4MTzIsQ92MeDWCIDvWGmzKUWOaBcOg=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2/go.mod h1:hU6fqB3OJA6/ePheD47LQnxvjYk6br6PtQxs+Q9ojvk=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3SJXQNEgksORW3Js=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8=
|
||||
github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
|
||||
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
|
||||
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
371
internal/adapters/distributor/client.go
Normal file
371
internal/adapters/distributor/client.go
Normal file
@@ -0,0 +1,371 @@
|
||||
// Package distributor adapts weatherreporter report artifacts to distributor uploads.
|
||||
package distributor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
distributorbundle "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
|
||||
distributorupload "gitea.maximumdirect.net/eric/distributor/pkg/upload"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
Endpoint string
|
||||
TokenEnv string
|
||||
Timeout time.Duration
|
||||
newUploadClient uploadClientFactory
|
||||
}
|
||||
|
||||
type UploadRequest struct {
|
||||
PipelineID string
|
||||
BundleID string
|
||||
IdempotencyKey string
|
||||
Files []UploadFile
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type UploadFile struct {
|
||||
SourcePath string
|
||||
BundlePath string
|
||||
}
|
||||
|
||||
type UploadResult struct {
|
||||
RunID string
|
||||
Status string
|
||||
UploadStatus string
|
||||
StatusError string
|
||||
RunStatus *RunStatus
|
||||
}
|
||||
|
||||
type RunStatus struct {
|
||||
RunID string
|
||||
PipelineID string
|
||||
Status string
|
||||
AcceptedAt time.Time
|
||||
StartedAt *time.Time
|
||||
FinishedAt *time.Time
|
||||
Report json.RawMessage
|
||||
Error string
|
||||
}
|
||||
|
||||
type IdempotencyConflictError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *IdempotencyConflictError) Error() string {
|
||||
if e == nil || e.Err == nil {
|
||||
return "distributor idempotency conflict"
|
||||
}
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *IdempotencyConflictError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Err
|
||||
}
|
||||
|
||||
type uploadClientFactory func(endpoint, token string, timeout time.Duration) (uploadClient, error)
|
||||
|
||||
type uploadClient interface {
|
||||
UploadFiles(ctx context.Context, opts uploadFilesOptions) (uploadFilesResult, error)
|
||||
Status(ctx context.Context, runID string) (runStatus, error)
|
||||
}
|
||||
|
||||
type uploadFilesOptions struct {
|
||||
PipelineID string
|
||||
BundleID string
|
||||
IdempotencyKey string
|
||||
Files []UploadFile
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type uploadFilesResult struct {
|
||||
RunID string
|
||||
Status string
|
||||
}
|
||||
|
||||
type runStatus struct {
|
||||
RunID string
|
||||
PipelineID string
|
||||
Status string
|
||||
AcceptedAt time.Time
|
||||
StartedAt *time.Time
|
||||
FinishedAt *time.Time
|
||||
Report json.RawMessage
|
||||
Error string
|
||||
}
|
||||
|
||||
const statusPollInterval = 250 * time.Millisecond
|
||||
|
||||
func New(cfg config.DistributorNotifyConfig) *Client {
|
||||
return newClient(cfg, newDistributorUploadClient)
|
||||
}
|
||||
|
||||
func newClient(cfg config.DistributorNotifyConfig, factory uploadClientFactory) *Client {
|
||||
if factory == nil {
|
||||
factory = newDistributorUploadClient
|
||||
}
|
||||
return &Client{
|
||||
Endpoint: cfg.Endpoint,
|
||||
TokenEnv: cfg.TokenEnv,
|
||||
Timeout: cfg.Timeout,
|
||||
newUploadClient: factory,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Upload(ctx context.Context, req UploadRequest) (UploadResult, error) {
|
||||
if c == nil {
|
||||
return UploadResult{}, fmt.Errorf("distributor client is nil")
|
||||
}
|
||||
if c.Endpoint == "" {
|
||||
return UploadResult{}, fmt.Errorf("distributor endpoint is required")
|
||||
}
|
||||
if c.TokenEnv == "" {
|
||||
return UploadResult{}, fmt.Errorf("distributor token environment variable is required")
|
||||
}
|
||||
if req.PipelineID == "" {
|
||||
return UploadResult{}, fmt.Errorf("distributor pipeline id is required")
|
||||
}
|
||||
if req.BundleID == "" {
|
||||
return UploadResult{}, fmt.Errorf("distributor bundle id is required")
|
||||
}
|
||||
if req.IdempotencyKey == "" {
|
||||
return UploadResult{}, fmt.Errorf("distributor idempotency key is required for bundle %q", req.BundleID)
|
||||
}
|
||||
if len(req.Files) == 0 {
|
||||
return UploadResult{}, fmt.Errorf("distributor upload files are required for bundle %q", req.BundleID)
|
||||
}
|
||||
for i, file := range req.Files {
|
||||
if file.SourcePath == "" {
|
||||
return UploadResult{}, fmt.Errorf("distributor source path is required for bundle %q file %d", req.BundleID, i)
|
||||
}
|
||||
if file.BundlePath == "" {
|
||||
return UploadResult{}, fmt.Errorf("distributor bundle path is required for bundle %q file %d", req.BundleID, i)
|
||||
}
|
||||
}
|
||||
if c.newUploadClient == nil {
|
||||
return UploadResult{}, fmt.Errorf("distributor upload client factory is required for endpoint %q", c.Endpoint)
|
||||
}
|
||||
|
||||
token := os.Getenv(c.TokenEnv)
|
||||
if token == "" {
|
||||
return UploadResult{}, fmt.Errorf("distributor token environment variable %q is not set", c.TokenEnv)
|
||||
}
|
||||
|
||||
uploadClient, err := c.newUploadClient(c.Endpoint, token, c.Timeout)
|
||||
if err != nil {
|
||||
return UploadResult{}, fmt.Errorf("create distributor upload client for endpoint %q: %w", c.Endpoint, redactToken(err, token))
|
||||
}
|
||||
|
||||
runCtx := ctx
|
||||
if runCtx == nil {
|
||||
runCtx = context.Background()
|
||||
}
|
||||
cancel := func() {}
|
||||
if c.Timeout > 0 {
|
||||
runCtx, cancel = context.WithTimeout(runCtx, c.Timeout)
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
result, err := uploadClient.UploadFiles(runCtx, uploadFilesOptions{
|
||||
PipelineID: req.PipelineID,
|
||||
BundleID: req.BundleID,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
Files: append([]UploadFile(nil), req.Files...),
|
||||
CreatedAt: req.CreatedAt,
|
||||
})
|
||||
if err != nil {
|
||||
return UploadResult{}, wrapUploadError(err, uploadErrorContext{
|
||||
Endpoint: c.Endpoint,
|
||||
PipelineID: req.PipelineID,
|
||||
BundleID: req.BundleID,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
SourcePaths: uploadSourcePaths(req.Files),
|
||||
BundlePaths: uploadBundlePaths(req.Files),
|
||||
Token: token,
|
||||
})
|
||||
}
|
||||
|
||||
uploadResult := UploadResult{
|
||||
RunID: result.RunID,
|
||||
Status: result.Status,
|
||||
UploadStatus: result.Status,
|
||||
}
|
||||
status, statusErr := waitForRunStatus(runCtx, uploadClient, result.RunID, c.Timeout > 0)
|
||||
if status.RunID != "" || status.Status != "" {
|
||||
uploadResult.RunStatus = &RunStatus{
|
||||
RunID: status.RunID,
|
||||
PipelineID: status.PipelineID,
|
||||
Status: status.Status,
|
||||
AcceptedAt: status.AcceptedAt,
|
||||
StartedAt: status.StartedAt,
|
||||
FinishedAt: status.FinishedAt,
|
||||
Report: append(json.RawMessage(nil), status.Report...),
|
||||
Error: redactTokenString(status.Error, token),
|
||||
}
|
||||
if status.Status != "" {
|
||||
uploadResult.Status = status.Status
|
||||
}
|
||||
}
|
||||
if statusErr != nil {
|
||||
uploadResult.StatusError = redactTokenString(statusErr.Error(), token)
|
||||
return uploadResult, nil
|
||||
}
|
||||
if status.Status == "failed" {
|
||||
return uploadResult, fmt.Errorf("distributor run %q failed: %s", status.RunID, uploadResult.RunStatus.Error)
|
||||
}
|
||||
return uploadResult, nil
|
||||
}
|
||||
|
||||
func waitForRunStatus(ctx context.Context, client uploadClient, runID string, poll bool) (runStatus, error) {
|
||||
status, err := client.Status(ctx, runID)
|
||||
if err != nil || terminalRunStatus(status.Status) || !poll {
|
||||
return status, err
|
||||
}
|
||||
|
||||
for {
|
||||
timer := time.NewTimer(statusPollInterval)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return status, fmt.Errorf("distributor run %q did not reach terminal status before timeout: %w", runID, ctx.Err())
|
||||
case <-timer.C:
|
||||
}
|
||||
|
||||
next, err := client.Status(ctx, runID)
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
status = next
|
||||
if terminalRunStatus(status.Status) {
|
||||
return status, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func terminalRunStatus(status string) bool {
|
||||
return status == "succeeded" || status == "failed"
|
||||
}
|
||||
|
||||
type distributorUploadClient struct {
|
||||
client *distributorupload.Client
|
||||
}
|
||||
|
||||
func newDistributorUploadClient(endpoint, token string, timeout time.Duration) (uploadClient, error) {
|
||||
httpClient := (*http.Client)(nil)
|
||||
if timeout > 0 {
|
||||
httpClient = &http.Client{Timeout: timeout}
|
||||
}
|
||||
client, err := distributorupload.NewClient(distributorupload.ClientOptions{
|
||||
Endpoint: endpoint,
|
||||
Token: token,
|
||||
HTTPClient: httpClient,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return distributorUploadClient{client: client}, nil
|
||||
}
|
||||
|
||||
func (c distributorUploadClient) UploadFiles(ctx context.Context, opts uploadFilesOptions) (uploadFilesResult, error) {
|
||||
files := make([]distributorbundle.BundleFile, 0, len(opts.Files))
|
||||
for _, file := range opts.Files {
|
||||
files = append(files, distributorbundle.BundleFile{
|
||||
SourcePath: file.SourcePath,
|
||||
Path: file.BundlePath,
|
||||
})
|
||||
}
|
||||
result, err := c.client.UploadFiles(ctx, distributorupload.UploadFilesOptions{
|
||||
PipelineID: opts.PipelineID,
|
||||
ID: opts.BundleID,
|
||||
Created: opts.CreatedAt,
|
||||
IdempotencyKey: opts.IdempotencyKey,
|
||||
Files: files,
|
||||
})
|
||||
if err != nil {
|
||||
return uploadFilesResult{}, err
|
||||
}
|
||||
return uploadFilesResult{
|
||||
RunID: result.RunID,
|
||||
Status: result.Status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c distributorUploadClient) Status(ctx context.Context, runID string) (runStatus, error) {
|
||||
status, err := c.client.Status(ctx, runID)
|
||||
if err != nil {
|
||||
return runStatus{}, err
|
||||
}
|
||||
return runStatus{
|
||||
RunID: status.RunID,
|
||||
PipelineID: status.PipelineID,
|
||||
Status: status.Status,
|
||||
AcceptedAt: status.AcceptedAt,
|
||||
StartedAt: status.StartedAt,
|
||||
FinishedAt: status.FinishedAt,
|
||||
Report: append(json.RawMessage(nil), status.Report...),
|
||||
Error: status.Error,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type uploadErrorContext struct {
|
||||
Endpoint string
|
||||
PipelineID string
|
||||
BundleID string
|
||||
IdempotencyKey string
|
||||
SourcePaths []string
|
||||
BundlePaths []string
|
||||
Token string
|
||||
}
|
||||
|
||||
func wrapUploadError(err error, ctx uploadErrorContext) error {
|
||||
var conflict *distributorupload.IdempotencyConflictError
|
||||
isConflict := errors.As(err, &conflict)
|
||||
err = redactToken(err, ctx.Token)
|
||||
if isConflict {
|
||||
return &IdempotencyConflictError{
|
||||
Err: fmt.Errorf("upload distributor bundle %q to pipeline %q at endpoint %q with idempotency key %q from sources %q as bundle paths %q: idempotency conflict: %w", ctx.BundleID, ctx.PipelineID, ctx.Endpoint, ctx.IdempotencyKey, ctx.SourcePaths, ctx.BundlePaths, err),
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("upload distributor bundle %q to pipeline %q at endpoint %q with idempotency key %q from sources %q as bundle paths %q: %w", ctx.BundleID, ctx.PipelineID, ctx.Endpoint, ctx.IdempotencyKey, ctx.SourcePaths, ctx.BundlePaths, err)
|
||||
}
|
||||
|
||||
func uploadSourcePaths(files []UploadFile) []string {
|
||||
paths := make([]string, 0, len(files))
|
||||
for _, file := range files {
|
||||
paths = append(paths, file.SourcePath)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func uploadBundlePaths(files []UploadFile) []string {
|
||||
paths := make([]string, 0, len(files))
|
||||
for _, file := range files {
|
||||
paths = append(paths, file.BundlePath)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func redactToken(err error, token string) error {
|
||||
if err == nil || token == "" {
|
||||
return err
|
||||
}
|
||||
return errors.New(redactTokenString(err.Error(), token))
|
||||
}
|
||||
|
||||
func redactTokenString(value, token string) string {
|
||||
if token == "" {
|
||||
return value
|
||||
}
|
||||
return strings.ReplaceAll(value, token, "[redacted]")
|
||||
}
|
||||
406
internal/adapters/distributor/client_test.go
Normal file
406
internal/adapters/distributor/client_test.go
Normal file
@@ -0,0 +1,406 @@
|
||||
package distributor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
distributorupload "gitea.maximumdirect.net/eric/distributor/pkg/upload"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
)
|
||||
|
||||
func TestUploadUsesConfiguredClientAndFiles(t *testing.T) {
|
||||
cfg := config.Defaults().Notify.Distributor
|
||||
cfg.Endpoint = "https://distributor.example.test"
|
||||
cfg.TokenEnv = "DISTRIBUTOR_UPLOAD_TOKEN"
|
||||
cfg.Timeout = 15 * time.Second
|
||||
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||
|
||||
factory := &fakeUploadFactory{
|
||||
client: &fakeUploadClient{
|
||||
result: uploadFilesResult{RunID: "run-123", Status: "accepted"},
|
||||
status: runStatus{RunID: "run-123", PipelineID: "reports", Status: "succeeded", Report: json.RawMessage(`{"actions":[{"action":"replace_older"}]}`)},
|
||||
},
|
||||
}
|
||||
client := newClient(cfg, factory.newClient)
|
||||
|
||||
result, err := client.Upload(context.Background(), UploadRequest{
|
||||
PipelineID: "weatherreporter.daily",
|
||||
BundleID: "weatherreporter.home.daily.run",
|
||||
IdempotencyKey: "weatherreporter.home.daily.run",
|
||||
Files: []UploadFile{
|
||||
{SourcePath: "/tmp/report.md", BundlePath: "2026-06-07/daily/report.md"},
|
||||
{SourcePath: "/tmp/report.md", BundlePath: "2026-06-07/daily/latest.md"},
|
||||
},
|
||||
CreatedAt: time.Date(2026, 6, 7, 12, 0, 0, 123, time.UTC),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Upload() error = %v", err)
|
||||
}
|
||||
if result.RunID != "run-123" || result.Status != "succeeded" || result.UploadStatus != "accepted" {
|
||||
t.Fatalf("result = %#v, want accepted run", result)
|
||||
}
|
||||
if result.RunStatus == nil || result.RunStatus.PipelineID != "reports" || !strings.Contains(string(result.RunStatus.Report), "replace_older") {
|
||||
t.Fatalf("RunStatus = %#v, want parsed run report", result.RunStatus)
|
||||
}
|
||||
if factory.endpoint != cfg.Endpoint {
|
||||
t.Fatalf("factory endpoint = %q, want %q", factory.endpoint, cfg.Endpoint)
|
||||
}
|
||||
if factory.token != "secret-token" {
|
||||
t.Fatalf("factory token = %q, want secret-token", factory.token)
|
||||
}
|
||||
if factory.timeout != 15*time.Second {
|
||||
t.Fatalf("factory timeout = %s, want 15s", factory.timeout)
|
||||
}
|
||||
got := factory.client.opts
|
||||
if got.PipelineID != "weatherreporter.daily" {
|
||||
t.Fatalf("PipelineID = %q, want weatherreporter.daily", got.PipelineID)
|
||||
}
|
||||
if got.BundleID != "weatherreporter.home.daily.run" {
|
||||
t.Fatalf("BundleID = %q, want weatherreporter.home.daily.run", got.BundleID)
|
||||
}
|
||||
if got.IdempotencyKey != "weatherreporter.home.daily.run" {
|
||||
t.Fatalf("IdempotencyKey = %q, want weatherreporter.home.daily.run", got.IdempotencyKey)
|
||||
}
|
||||
if len(got.Files) != 2 {
|
||||
t.Fatalf("files = %#v, want two mappings", got.Files)
|
||||
}
|
||||
if got.Files[0].SourcePath != "/tmp/report.md" || got.Files[0].BundlePath != "2026-06-07/daily/report.md" {
|
||||
t.Fatalf("first file = %#v, want archive mapping", got.Files[0])
|
||||
}
|
||||
if got.Files[1].SourcePath != "/tmp/report.md" || got.Files[1].BundlePath != "2026-06-07/daily/latest.md" {
|
||||
t.Fatalf("second file = %#v, want latest mapping", got.Files[1])
|
||||
}
|
||||
if got.CreatedAt.IsZero() {
|
||||
t.Fatal("CreatedAt is zero, want generated report timestamp")
|
||||
}
|
||||
if factory.client.statusRunID != "run-123" {
|
||||
t.Fatalf("Status runID = %q, want run-123", factory.client.statusRunID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadRejectsMissingInputs(t *testing.T) {
|
||||
cfg := config.Defaults().Notify.Distributor
|
||||
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Client, *UploadRequest)
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "Token",
|
||||
mutate: func(c *Client, req *UploadRequest) {
|
||||
t.Setenv(c.TokenEnv, "")
|
||||
},
|
||||
wantErr: "token environment variable",
|
||||
},
|
||||
{
|
||||
name: "PipelineID",
|
||||
mutate: func(c *Client, req *UploadRequest) {
|
||||
req.PipelineID = ""
|
||||
},
|
||||
wantErr: "pipeline id is required",
|
||||
},
|
||||
{
|
||||
name: "Files",
|
||||
mutate: func(c *Client, req *UploadRequest) {
|
||||
req.Files = nil
|
||||
},
|
||||
wantErr: "upload files are required",
|
||||
},
|
||||
{
|
||||
name: "SourcePath",
|
||||
mutate: func(c *Client, req *UploadRequest) {
|
||||
req.Files[0].SourcePath = ""
|
||||
},
|
||||
wantErr: "source path is required",
|
||||
},
|
||||
{
|
||||
name: "BundlePath",
|
||||
mutate: func(c *Client, req *UploadRequest) {
|
||||
req.Files[0].BundlePath = ""
|
||||
},
|
||||
wantErr: "bundle path is required",
|
||||
},
|
||||
{
|
||||
name: "UploadClientFactory",
|
||||
mutate: func(c *Client, req *UploadRequest) {
|
||||
c.newUploadClient = nil
|
||||
},
|
||||
wantErr: "upload client factory is required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||
client := newClient(cfg, (&fakeUploadFactory{client: &fakeUploadClient{}}).newClient)
|
||||
req := validUploadRequest()
|
||||
tt.mutate(client, &req)
|
||||
|
||||
_, err := client.Upload(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Fatal("Upload() error = nil, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
if strings.Contains(err.Error(), "secret-token") {
|
||||
t.Fatalf("error = %q, want no token value", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadWrapsFactoryErrorWithoutToken(t *testing.T) {
|
||||
cfg := config.Defaults().Notify.Distributor
|
||||
cfg.Endpoint = "https://distributor.example.test"
|
||||
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||
factory := &fakeUploadFactory{
|
||||
err: fmt.Errorf("factory failed with secret-token"),
|
||||
}
|
||||
client := newClient(cfg, factory.newClient)
|
||||
|
||||
_, err := client.Upload(context.Background(), validUploadRequest())
|
||||
if err == nil {
|
||||
t.Fatal("Upload() error = nil, want error")
|
||||
}
|
||||
if strings.Contains(err.Error(), "secret-token") {
|
||||
t.Fatalf("error = %q, want no token value", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), cfg.Endpoint) {
|
||||
t.Fatalf("error = %q, want endpoint context", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadWrapsUploadFailureWithContextWithoutToken(t *testing.T) {
|
||||
cfg := config.Defaults().Notify.Distributor
|
||||
cfg.Endpoint = "https://distributor.example.test"
|
||||
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||
factory := &fakeUploadFactory{
|
||||
client: &fakeUploadClient{err: fmt.Errorf("server rejected secret-token")},
|
||||
}
|
||||
client := newClient(cfg, factory.newClient)
|
||||
req := validUploadRequest()
|
||||
|
||||
_, err := client.Upload(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Fatal("Upload() error = nil, want error")
|
||||
}
|
||||
for _, want := range []string{cfg.Endpoint, req.PipelineID, req.BundleID, req.IdempotencyKey, req.Files[0].SourcePath, req.Files[0].BundlePath, req.Files[1].BundlePath} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %q, want context %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(err.Error(), "secret-token") {
|
||||
t.Fatalf("error = %q, want no token value", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadReturnsAcceptedWhenStatusLookupFails(t *testing.T) {
|
||||
cfg := config.Defaults().Notify.Distributor
|
||||
cfg.Endpoint = "https://distributor.example.test"
|
||||
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||
factory := &fakeUploadFactory{
|
||||
client: &fakeUploadClient{
|
||||
result: uploadFilesResult{RunID: "run-123", Status: "accepted"},
|
||||
statusErr: fmt.Errorf("status rejected secret-token"),
|
||||
},
|
||||
}
|
||||
client := newClient(cfg, factory.newClient)
|
||||
|
||||
result, err := client.Upload(context.Background(), validUploadRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("Upload() error = %v, want accepted upload despite status lookup failure", err)
|
||||
}
|
||||
if result.Status != "accepted" || result.StatusError == "" {
|
||||
t.Fatalf("result = %#v, want accepted status with status error", result)
|
||||
}
|
||||
if strings.Contains(result.StatusError, "secret-token") {
|
||||
t.Fatalf("StatusError = %q, want token redacted", result.StatusError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadPollsUntilTerminalStatus(t *testing.T) {
|
||||
cfg := config.Defaults().Notify.Distributor
|
||||
cfg.Endpoint = "https://distributor.example.test"
|
||||
cfg.Timeout = 2 * time.Second
|
||||
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||
factory := &fakeUploadFactory{
|
||||
client: &fakeUploadClient{
|
||||
result: uploadFilesResult{RunID: "run-123", Status: "accepted"},
|
||||
statuses: []runStatus{
|
||||
{RunID: "run-123", Status: "accepted"},
|
||||
{RunID: "run-123", Status: "succeeded", Report: json.RawMessage(`{"actions":[{"action":"replace_older"}]}`)},
|
||||
},
|
||||
},
|
||||
}
|
||||
client := newClient(cfg, factory.newClient)
|
||||
|
||||
result, err := client.Upload(context.Background(), validUploadRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("Upload() error = %v", err)
|
||||
}
|
||||
if result.Status != "succeeded" || result.RunStatus == nil || !strings.Contains(string(result.RunStatus.Report), "replace_older") {
|
||||
t.Fatalf("result = %#v, want terminal succeeded status with run report", result)
|
||||
}
|
||||
if factory.client.statusCalls != 2 {
|
||||
t.Fatalf("status calls = %d, want 2", factory.client.statusCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadReturnsLatestStatusWhenPollingTimesOut(t *testing.T) {
|
||||
cfg := config.Defaults().Notify.Distributor
|
||||
cfg.Endpoint = "https://distributor.example.test"
|
||||
cfg.Timeout = time.Millisecond
|
||||
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||
factory := &fakeUploadFactory{
|
||||
client: &fakeUploadClient{
|
||||
result: uploadFilesResult{RunID: "run-123", Status: "accepted"},
|
||||
status: runStatus{RunID: "run-123", Status: "running"},
|
||||
},
|
||||
}
|
||||
client := newClient(cfg, factory.newClient)
|
||||
|
||||
result, err := client.Upload(context.Background(), validUploadRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("Upload() error = %v, want accepted upload with status timeout recorded", err)
|
||||
}
|
||||
if result.Status != "running" || result.StatusError == "" {
|
||||
t.Fatalf("result = %#v, want latest status and status timeout", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadFailsWhenDistributorRunFailed(t *testing.T) {
|
||||
cfg := config.Defaults().Notify.Distributor
|
||||
cfg.Endpoint = "https://distributor.example.test"
|
||||
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||
factory := &fakeUploadFactory{
|
||||
client: &fakeUploadClient{
|
||||
result: uploadFilesResult{RunID: "run-123", Status: "accepted"},
|
||||
status: runStatus{
|
||||
RunID: "run-123",
|
||||
Status: "failed",
|
||||
Error: "destination rejected secret-token",
|
||||
Report: json.RawMessage(`{"actions":[{"action":"failed"}]}`),
|
||||
},
|
||||
},
|
||||
}
|
||||
client := newClient(cfg, factory.newClient)
|
||||
|
||||
result, err := client.Upload(context.Background(), validUploadRequest())
|
||||
if err == nil {
|
||||
t.Fatal("Upload() error = nil, want failed distributor run error")
|
||||
}
|
||||
if result.RunStatus == nil || result.RunStatus.Status != "failed" || !strings.Contains(string(result.RunStatus.Report), "failed") {
|
||||
t.Fatalf("result = %#v, want failed run status report", result)
|
||||
}
|
||||
if strings.Contains(err.Error(), "secret-token") || strings.Contains(result.RunStatus.Error, "secret-token") {
|
||||
t.Fatalf("error/result leaked token: err=%q result=%#v", err.Error(), result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadPreservesIdempotencyConflictDiagnosis(t *testing.T) {
|
||||
cfg := config.Defaults().Notify.Distributor
|
||||
cfg.Endpoint = "https://distributor.example.test"
|
||||
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||
factory := &fakeUploadFactory{
|
||||
client: &fakeUploadClient{
|
||||
err: &distributorupload.IdempotencyConflictError{
|
||||
HTTPError: distributorupload.HTTPError{
|
||||
StatusCode: 409,
|
||||
Status: "409 Conflict",
|
||||
Message: "conflicting upload for secret-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
client := newClient(cfg, factory.newClient)
|
||||
|
||||
_, err := client.Upload(context.Background(), validUploadRequest())
|
||||
if err == nil {
|
||||
t.Fatal("Upload() error = nil, want error")
|
||||
}
|
||||
var conflict *IdempotencyConflictError
|
||||
if !errors.As(err, &conflict) {
|
||||
t.Fatalf("Upload() error = %T %v, want IdempotencyConflictError", err, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "idempotency conflict") {
|
||||
t.Fatalf("error = %q, want idempotency conflict diagnosis", err.Error())
|
||||
}
|
||||
if strings.Contains(err.Error(), "secret-token") {
|
||||
t.Fatalf("error = %q, want no token value", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func validUploadRequest() UploadRequest {
|
||||
return UploadRequest{
|
||||
PipelineID: "weatherreporter.daily",
|
||||
BundleID: "weatherreporter.home.daily.run",
|
||||
IdempotencyKey: "weatherreporter.home.daily.run",
|
||||
Files: []UploadFile{
|
||||
{SourcePath: "/tmp/report.md", BundlePath: "2026-06-07/daily/report.md"},
|
||||
{SourcePath: "/tmp/report.md", BundlePath: "2026-06-07/daily/latest.md"},
|
||||
},
|
||||
CreatedAt: time.Date(2026, 6, 7, 12, 0, 0, 123, time.UTC),
|
||||
}
|
||||
}
|
||||
|
||||
type fakeUploadFactory struct {
|
||||
endpoint string
|
||||
token string
|
||||
timeout time.Duration
|
||||
client *fakeUploadClient
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeUploadFactory) newClient(endpoint, token string, timeout time.Duration) (uploadClient, error) {
|
||||
f.endpoint = endpoint
|
||||
f.token = token
|
||||
f.timeout = timeout
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.client, nil
|
||||
}
|
||||
|
||||
type fakeUploadClient struct {
|
||||
opts uploadFilesOptions
|
||||
statusRunID string
|
||||
statusCalls int
|
||||
result uploadFilesResult
|
||||
status runStatus
|
||||
statuses []runStatus
|
||||
err error
|
||||
statusErr error
|
||||
}
|
||||
|
||||
func (c *fakeUploadClient) UploadFiles(ctx context.Context, opts uploadFilesOptions) (uploadFilesResult, error) {
|
||||
c.opts = opts
|
||||
if c.err != nil {
|
||||
return uploadFilesResult{}, c.err
|
||||
}
|
||||
return c.result, nil
|
||||
}
|
||||
|
||||
func (c *fakeUploadClient) Status(ctx context.Context, runID string) (runStatus, error) {
|
||||
c.statusRunID = runID
|
||||
c.statusCalls++
|
||||
if c.statusErr != nil {
|
||||
return runStatus{}, c.statusErr
|
||||
}
|
||||
if len(c.statuses) > 0 {
|
||||
index := c.statusCalls - 1
|
||||
if index >= len(c.statuses) {
|
||||
index = len(c.statuses) - 1
|
||||
}
|
||||
return c.statuses[index], nil
|
||||
}
|
||||
return c.status, nil
|
||||
}
|
||||
319
internal/adapters/promptkit/adapter.go
Normal file
319
internal/adapters/promptkit/adapter.go
Normal file
@@ -0,0 +1,319 @@
|
||||
// Package promptkitadapter implements promptexec with Promptkit.
|
||||
package promptkitadapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptassets"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
)
|
||||
|
||||
// Config selects the Promptkit sources and optional local backend for one engine.
|
||||
type Config struct {
|
||||
ProfileDirectory string
|
||||
ProfileFile string
|
||||
LocalEndpoint string
|
||||
LocalConcurrencyLimit int
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Adapter owns one Promptkit engine and its opaque prepared execution handles.
|
||||
type Adapter struct {
|
||||
engine *promptkit.Engine
|
||||
}
|
||||
|
||||
var _ promptexec.Executor = (*Adapter)(nil)
|
||||
|
||||
// New constructs a Promptkit-backed executor from Weatherreporter-owned settings.
|
||||
func New(config Config) (*Adapter, error) {
|
||||
return newAdapter(config)
|
||||
}
|
||||
|
||||
func newAdapter(config Config, additionalOptions ...promptkit.Option) (*Adapter, error) {
|
||||
if config.ProfileDirectory != "" && config.ProfileFile != "" {
|
||||
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "profile directory and profile file cannot both be configured", nil)
|
||||
}
|
||||
if config.LocalEndpoint == "" && config.LocalConcurrencyLimit != 0 {
|
||||
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "local concurrency requires a local endpoint", nil)
|
||||
}
|
||||
|
||||
options := []promptkit.Option{
|
||||
promptkit.WithPromptFS(promptassets.PromptFS(), "."),
|
||||
promptkit.WithSchemaFS(promptassets.SchemaFS(), "."),
|
||||
promptkit.WithFallbackProfileFS(promptassets.ProfileFS(), "."),
|
||||
}
|
||||
if config.ProfileFile != "" {
|
||||
options = append(options, promptkit.WithProfileFile(config.ProfileFile))
|
||||
}
|
||||
if config.LocalEndpoint != "" {
|
||||
options = append(options, promptkit.WithBackend(promptkit.LocalBackend(config.LocalEndpoint, config.LocalConcurrencyLimit)))
|
||||
}
|
||||
options = append(options, additionalOptions...)
|
||||
engine, err := promptkit.NewEngine(promptkit.Config{
|
||||
ProfileDir: config.ProfileDirectory,
|
||||
Timeout: config.Timeout,
|
||||
}, options...)
|
||||
if err != nil {
|
||||
return nil, classifyConfigurationError(err)
|
||||
}
|
||||
return &Adapter{engine: engine}, nil
|
||||
}
|
||||
|
||||
func newAdapterForTest(config Config, client promptkit.LLMClient) (*Adapter, error) {
|
||||
return newAdapter(config, promptkit.WithLLMClient(client))
|
||||
}
|
||||
|
||||
// InspectPrompt maps an exact Promptkit prompt inspection into project-owned values.
|
||||
func (adapter *Adapter) InspectPrompt(ctx context.Context, promptID string, promptVersion string) (promptexec.PromptInspection, error) {
|
||||
if adapter == nil || adapter.engine == nil {
|
||||
return promptexec.PromptInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is not configured", nil)
|
||||
}
|
||||
inspection, err := adapter.engine.InspectPrompt(ctx, promptID, promptVersion)
|
||||
if err != nil {
|
||||
return promptexec.PromptInspection{}, classifyError(err)
|
||||
}
|
||||
inputs := make([]promptexec.InputDefinition, len(inspection.Inputs))
|
||||
for index, input := range inspection.Inputs {
|
||||
inputs[index] = promptexec.InputDefinition{
|
||||
Name: input.Name,
|
||||
Required: input.Required,
|
||||
ContentType: input.ContentType,
|
||||
Description: input.Description,
|
||||
}
|
||||
}
|
||||
return promptexec.PromptInspection{
|
||||
PromptID: inspection.PromptID,
|
||||
PromptVersion: inspection.PromptVersion,
|
||||
PromptHash: inspection.PromptHash,
|
||||
DefaultProfileID: inspection.DefaultProfileID,
|
||||
Inputs: inputs,
|
||||
Output: outputContract(inspection.OutputContract),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// InspectProfile maps one explicit Promptkit profile inspection into safe values.
|
||||
func (adapter *Adapter) InspectProfile(ctx context.Context, profileID string) (promptexec.ProfileInspection, error) {
|
||||
if adapter == nil || adapter.engine == nil {
|
||||
return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is not configured", nil)
|
||||
}
|
||||
inspection, err := adapter.engine.InspectProfile(ctx, profileID)
|
||||
if err != nil {
|
||||
return promptexec.ProfileInspection{}, classifyError(err)
|
||||
}
|
||||
return promptexec.ProfileInspection{
|
||||
ProfileID: inspection.ProfileID,
|
||||
BackendID: inspection.EffectiveModelParams.BackendID,
|
||||
ModelName: inspection.EffectiveModelParams.Model,
|
||||
CredentialRequired: inspection.APIKeyRequired,
|
||||
APIKeyEnv: inspection.EffectiveModelParams.APIKeyEnv,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Execute prepares one exact inline data package, invokes prepared after a
|
||||
// successful preparation, and then runs the same opaque prepared handle.
|
||||
func (adapter *Adapter) Execute(ctx context.Context, request promptexec.ExecuteRequest, preparedCallback promptexec.PreparationCallback) (*promptexec.Execution, error) {
|
||||
if adapter == nil || adapter.engine == nil {
|
||||
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is not configured", nil)
|
||||
}
|
||||
prepared, err := adapter.engine.PrepareExecution(ctx, promptkit.RunRequest{
|
||||
PromptID: request.PromptID,
|
||||
PromptVersion: request.PromptVersion,
|
||||
ProfileID: request.ProfileID,
|
||||
Inputs: map[string]promptkit.ArtifactRef{"data_package": promptkit.Inline(string(append([]byte(nil), request.DataPackage...)))},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, classifyError(err)
|
||||
}
|
||||
defer prepared.Discard()
|
||||
|
||||
details := prepared.Details()
|
||||
preparation, debug := preparationValues(details, request.CaptureDebug)
|
||||
if preparedCallback != nil {
|
||||
if err := preparedCallback(preparation, debug); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
result, err := adapter.engine.RunPrepared(ctx, prepared)
|
||||
if err != nil {
|
||||
return nil, classifyError(err)
|
||||
}
|
||||
return executionValue(result, request.CaptureDebug), nil
|
||||
}
|
||||
|
||||
func outputContract(value promptkit.OutputContract) promptexec.OutputContract {
|
||||
return promptexec.OutputContract{
|
||||
Format: string(value.Format),
|
||||
ValidationMode: string(value.ValidationMode),
|
||||
SchemaPath: value.SchemaPath,
|
||||
}
|
||||
}
|
||||
|
||||
func preparationValues(value promptkit.PreparedRun, captureDebug bool) (promptexec.Preparation, *promptexec.PreparationDebug) {
|
||||
preparation := promptexec.Preparation{
|
||||
PromptID: value.PromptID,
|
||||
PromptVersion: value.PromptVersion,
|
||||
PromptHash: value.PromptHash,
|
||||
RenderedPromptHash: value.RenderedPromptHash,
|
||||
InputHashes: copyInputHashes(value.InputHashes),
|
||||
ProfileID: value.SelectedProfileID,
|
||||
BackendID: value.SelectedBackendID,
|
||||
ModelName: value.EffectiveModelParams.Model,
|
||||
Output: outputContract(value.OutputContract),
|
||||
StartedAt: value.StartTime,
|
||||
EndedAt: value.EndTime,
|
||||
Duration: time.Duration(value.DurationMS) * time.Millisecond,
|
||||
}
|
||||
if !captureDebug {
|
||||
return preparation, nil
|
||||
}
|
||||
debug := &promptexec.PreparationDebug{
|
||||
RenderedMessages: renderedMessages(value.Messages),
|
||||
Endpoint: value.EffectiveModelParams.Endpoint,
|
||||
ParametersJSON: marshalDebugParameters(value.EffectiveModelParams),
|
||||
}
|
||||
if value.StructuredOutput != nil && value.StructuredOutput.JSONSchema != nil {
|
||||
debug.StructuredSchema, _ = json.Marshal(value.StructuredOutput.JSONSchema.Schema)
|
||||
}
|
||||
return preparation, debug
|
||||
}
|
||||
|
||||
func executionValue(value *promptkit.RunResult, captureDebug bool) *promptexec.Execution {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
validation := promptexec.NewValidation(
|
||||
promptexec.ValidationStatus(value.Validation.Status),
|
||||
string(value.Validation.Mode),
|
||||
value.Validation.SchemaPath,
|
||||
value.Validation.Errors,
|
||||
)
|
||||
execution := &promptexec.Execution{
|
||||
RunID: value.RunID,
|
||||
PromptID: value.PromptID,
|
||||
PromptVersion: value.PromptVersion,
|
||||
PromptHash: value.PromptHash,
|
||||
RenderedPromptHash: value.RenderedPromptHash,
|
||||
InputHashes: copyInputHashes(value.InputHashes),
|
||||
ProfileID: value.SelectedProfileID,
|
||||
BackendID: value.SelectedBackendID,
|
||||
ModelName: value.ModelName,
|
||||
GeneratedHash: value.Artifact.Hash,
|
||||
Usage: promptexec.TokenUsage{
|
||||
PromptTokens: value.Usage.PromptTokens,
|
||||
CompletionTokens: value.Usage.CompletionTokens,
|
||||
TotalTokens: value.Usage.TotalTokens,
|
||||
CachedTokens: value.Usage.CachedTokens,
|
||||
CacheWriteTokens: value.Usage.CacheWriteTokens,
|
||||
},
|
||||
StartedAt: value.StartTime,
|
||||
EndedAt: value.EndTime,
|
||||
Duration: value.Duration,
|
||||
Validation: validation,
|
||||
RawOutput: []byte(value.RawOutput),
|
||||
}
|
||||
if captureDebug {
|
||||
execution.Debug = &promptexec.ExecutionDebug{
|
||||
RawOutput: append([]byte(nil), value.RawOutput...),
|
||||
ValidationDiagnostics: append([]string(nil), validation.Diagnostics...),
|
||||
}
|
||||
}
|
||||
return execution
|
||||
}
|
||||
|
||||
func renderedMessages(values []promptkit.RenderedMessage) []promptexec.RenderedMessage {
|
||||
messages := make([]promptexec.RenderedMessage, len(values))
|
||||
for index, value := range values {
|
||||
messages[index] = promptexec.RenderedMessage{Role: value.Role, Content: value.Content}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
func copyInputHashes(values map[string]string) map[string]string {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
copy := make(map[string]string, len(values))
|
||||
for key, value := range values {
|
||||
copy[key] = value
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
func marshalDebugParameters(value promptkit.ExecutionTarget) []byte {
|
||||
parameters := struct {
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
TopP float64 `json:"top_p"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
ServiceTier string `json:"service_tier"`
|
||||
ReasoningEffort string `json:"reasoning_effort"`
|
||||
ExtraParams map[string]any `json:"extra_params"`
|
||||
}{
|
||||
Temperature: value.Temperature,
|
||||
MaxTokens: value.MaxTokens,
|
||||
TopP: value.TopP,
|
||||
TimeoutSeconds: value.TimeoutSeconds,
|
||||
ServiceTier: value.ServiceTier,
|
||||
ReasoningEffort: value.ReasoningEffort,
|
||||
ExtraParams: value.ExtraParams,
|
||||
}
|
||||
data, _ := json.Marshal(parameters)
|
||||
return data
|
||||
}
|
||||
|
||||
func classifyConfigurationError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor configuration is invalid", err)
|
||||
}
|
||||
|
||||
func classifyError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return promptexec.NewError(promptexec.Canceled, "prompt operation was canceled", err)
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return promptexec.NewError(promptexec.DeadlineExceeded, "prompt operation exceeded its deadline", err)
|
||||
}
|
||||
var capacityError *promptkit.CapacityError
|
||||
if errors.As(err, &capacityError) {
|
||||
return promptexec.NewCapacityError(capacityError.BackendID, "prompt backend capacity is unavailable", err)
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, promptkit.ErrInvalidConfig):
|
||||
return promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor configuration is invalid", err)
|
||||
case errors.Is(err, promptkit.ErrPromptNotFound):
|
||||
return promptexec.NewError(promptexec.PromptNotFound, "prompt definition was not found", err)
|
||||
case errors.Is(err, promptkit.ErrPromptLoad):
|
||||
return promptexec.NewError(promptexec.PromptLoad, "prompt definition could not be loaded", err)
|
||||
case errors.Is(err, promptkit.ErrProfileNotFound):
|
||||
return promptexec.NewError(promptexec.ProfileNotFound, "execution profile was not found", err)
|
||||
case errors.Is(err, promptkit.ErrProfileLoad):
|
||||
return promptexec.NewError(promptexec.ProfileLoad, "execution profile could not be loaded", err)
|
||||
case errors.Is(err, promptkit.ErrAPIKeyEnvMissing):
|
||||
return promptexec.NewError(promptexec.MissingCredential, "execution credential is unavailable", err)
|
||||
case errors.Is(err, promptkit.ErrArtifactLoad):
|
||||
return promptexec.NewError(promptexec.ArtifactLoad, "prompt input could not be loaded", err)
|
||||
case errors.Is(err, promptkit.ErrPromptRender):
|
||||
return promptexec.NewError(promptexec.PromptRender, "prompt could not be rendered", err)
|
||||
case errors.Is(err, promptkit.ErrCapacityExceeded):
|
||||
return promptexec.NewCapacityError("", "prompt backend capacity is unavailable", err)
|
||||
case errors.Is(err, promptkit.ErrLLMGenerate):
|
||||
return promptexec.NewError(promptexec.Generation, "prompt generation failed", err)
|
||||
case errors.Is(err, promptkit.ErrValidation):
|
||||
return promptexec.NewError(promptexec.OperationalValidation, "prompt output validation could not be completed", err)
|
||||
case errors.Is(err, promptkit.ErrInvalidRequest), errors.Is(err, promptkit.ErrProfileRequired):
|
||||
return promptexec.NewError(promptexec.InvalidRequest, "prompt execution request is invalid", err)
|
||||
default:
|
||||
return promptexec.NewError(promptexec.Generation, "prompt operation failed", fmt.Errorf("%w", err))
|
||||
}
|
||||
}
|
||||
548
internal/adapters/promptkit/adapter_test.go
Normal file
548
internal/adapters/promptkit/adapter_test.go
Normal file
@@ -0,0 +1,548 @@
|
||||
package promptkitadapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
)
|
||||
|
||||
type fakeClient struct {
|
||||
mu sync.Mutex
|
||||
response *promptkit.GenerateResponse
|
||||
err error
|
||||
calls int
|
||||
requests []promptkit.GenerateRequest
|
||||
block bool
|
||||
}
|
||||
|
||||
type recordingReader struct {
|
||||
ref promptkit.ArtifactRef
|
||||
}
|
||||
|
||||
func (reader *recordingReader) Read(_ context.Context, ref promptkit.ArtifactRef) (*promptkit.Artifact, error) {
|
||||
reader.ref = ref
|
||||
return &promptkit.Artifact{
|
||||
Name: "data_package",
|
||||
ContentType: "application/yaml",
|
||||
Body: []byte(ref.Body),
|
||||
URI: ref.URI,
|
||||
Hash: "input-hash",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (client *fakeClient) Generate(ctx context.Context, request promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
|
||||
client.mu.Lock()
|
||||
client.calls++
|
||||
client.requests = append(client.requests, request)
|
||||
block := client.block
|
||||
response := client.response
|
||||
err := client.err
|
||||
client.mu.Unlock()
|
||||
if block {
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return response, err
|
||||
}
|
||||
|
||||
func (client *fakeClient) callCount() int {
|
||||
client.mu.Lock()
|
||||
defer client.mu.Unlock()
|
||||
return client.calls
|
||||
}
|
||||
|
||||
func (client *fakeClient) request() promptkit.GenerateRequest {
|
||||
client.mu.Lock()
|
||||
defer client.mu.Unlock()
|
||||
return client.requests[0]
|
||||
}
|
||||
|
||||
func TestInspectPromptAndProfile(t *testing.T) {
|
||||
adapter := newTestAdapter(t, &fakeClient{})
|
||||
inspection, err := adapter.InspectPrompt(context.Background(), "weather.daily_generated_text", "2.0.0")
|
||||
if err != nil {
|
||||
t.Fatalf("InspectPrompt() error = %v", err)
|
||||
}
|
||||
if inspection.PromptID != "weather.daily_generated_text" || inspection.PromptVersion != "2.0.0" || inspection.DefaultProfileID != "weather-balanced" {
|
||||
t.Fatalf("inspection = %#v", inspection)
|
||||
}
|
||||
if len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "data_package" || !inspection.Inputs[0].Required || inspection.Inputs[0].ContentType != "application/yaml" {
|
||||
t.Fatalf("inputs = %#v", inspection.Inputs)
|
||||
}
|
||||
if inspection.Output.Format != "json" || inspection.Output.ValidationMode != "json_schema" || inspection.Output.SchemaPath != "daily.generated_text.schema.json" {
|
||||
t.Fatalf("output = %#v", inspection.Output)
|
||||
}
|
||||
|
||||
profile, err := adapter.InspectProfile(context.Background(), "test-profile")
|
||||
if err != nil {
|
||||
t.Fatalf("InspectProfile() error = %v", err)
|
||||
}
|
||||
if profile.ProfileID != "test-profile" || profile.BackendID != "" || profile.ModelName != "test-model" || profile.CredentialRequired {
|
||||
t.Fatalf("profile = %#v", profile)
|
||||
}
|
||||
if strings.Contains(fmt.Sprintf("%#v", profile), "https://profile.example") {
|
||||
t.Fatalf("profile leaks endpoint: %#v", profile)
|
||||
}
|
||||
|
||||
builtin, err := adapter.InspectProfile(context.Background(), "gemini-flash-latest")
|
||||
if err != nil {
|
||||
t.Fatalf("InspectProfile(builtin) error = %v", err)
|
||||
}
|
||||
if builtin.ProfileID != "gemini-flash-latest" || builtin.ModelName == "" {
|
||||
t.Fatalf("builtin profile = %#v", builtin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedProfilesAreAvailableToProductionAndTestAdapters(t *testing.T) {
|
||||
adapter, err := New(Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
for _, want := range []struct {
|
||||
id string
|
||||
backend string
|
||||
model string
|
||||
}{
|
||||
{"weather-light", "openrouter", "deepseek/deepseek-v4-flash"},
|
||||
{"weather-balanced", "openrouter", "~google/gemini-flash-latest"},
|
||||
{"weather-deep", "openrouter", "~anthropic/claude-sonnet-latest"},
|
||||
} {
|
||||
t.Run(want.id, func(t *testing.T) {
|
||||
assertProfile(t, adapter, want.id, want.backend, want.model)
|
||||
})
|
||||
}
|
||||
|
||||
testAdapter, err := newAdapterForTest(Config{}, &fakeClient{})
|
||||
if err != nil {
|
||||
t.Fatalf("newAdapterForTest() error = %v", err)
|
||||
}
|
||||
assertProfile(t, testAdapter, "weather-light", "openrouter", "deepseek/deepseek-v4-flash")
|
||||
}
|
||||
|
||||
func TestConfiguredProfilesOverrideEmbeddedFallbacks(t *testing.T) {
|
||||
file := writeProfileFile(t, `id: weather-light
|
||||
endpoint: https://local-file.example/v1
|
||||
model: file-light
|
||||
`)
|
||||
fileAdapter, err := New(Config{ProfileFile: file})
|
||||
if err != nil {
|
||||
t.Fatalf("New(profile file) error = %v", err)
|
||||
}
|
||||
assertProfile(t, fileAdapter, "weather-light", "", "file-light")
|
||||
|
||||
directory := testProfileDirectory(t, `id: weather-light
|
||||
backend: local
|
||||
model: directory-light
|
||||
`)
|
||||
directoryAdapter, err := New(Config{ProfileDirectory: directory, LocalEndpoint: "https://local-directory.example/v1"})
|
||||
if err != nil {
|
||||
t.Fatalf("New(profile directory) error = %v", err)
|
||||
}
|
||||
assertProfile(t, directoryAdapter, "weather-light", promptkit.BackendLocal, "directory-light")
|
||||
}
|
||||
|
||||
func TestMaintainedWeatherLightLocalProfileExampleInspectsOffline(t *testing.T) {
|
||||
adapter, err := New(Config{ProfileFile: filepath.Join("..", "..", "..", "examples", "weather-light-local-profile.yml")})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
assertProfile(t, adapter, "weather-light", "", "weather-local")
|
||||
}
|
||||
|
||||
func TestProfileResolutionFallsThroughOnlyWhenTheConfiguredIDIsAbsent(t *testing.T) {
|
||||
absentAdapter, err := New(Config{ProfileDirectory: testProfileDirectory(t, `id: other-profile
|
||||
backend: openrouter
|
||||
model: other-model
|
||||
`)})
|
||||
if err != nil {
|
||||
t.Fatalf("New(absent profile) error = %v", err)
|
||||
}
|
||||
assertProfile(t, absentAdapter, "weather-light", "openrouter", "deepseek/deepseek-v4-flash")
|
||||
|
||||
malformedAdapter, err := New(Config{ProfileDirectory: testProfileDirectory(t, `id: weather-light
|
||||
backend: openrouter
|
||||
`)})
|
||||
if err != nil {
|
||||
t.Fatalf("New(malformed profile) error = %v", err)
|
||||
}
|
||||
if _, err := malformedAdapter.InspectProfile(context.Background(), "weather-light"); err == nil {
|
||||
t.Fatal("InspectProfile() error = nil, want malformed configured profile error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileResolutionPreservesBuiltInAndExplicitPrecedence(t *testing.T) {
|
||||
adapter, err := New(Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
builtin, err := adapter.InspectProfile(context.Background(), "gemini-flash-latest")
|
||||
if err != nil {
|
||||
t.Fatalf("InspectProfile(builtin) error = %v", err)
|
||||
}
|
||||
if builtin.ProfileID != "gemini-flash-latest" || builtin.BackendID != "openrouter" || builtin.ModelName == "" {
|
||||
t.Fatalf("builtin profile = %#v", builtin)
|
||||
}
|
||||
|
||||
explicit, err := newAdapter(Config{}, promptkit.WithProfiles(promptkit.Profile{
|
||||
ID: "weather-light",
|
||||
Endpoint: "https://explicit.example/v1",
|
||||
Model: "explicit-light",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("newAdapter(explicit profile) error = %v", err)
|
||||
}
|
||||
assertProfile(t, explicit, "weather-light", "", "explicit-light")
|
||||
}
|
||||
|
||||
func TestExecuteUsesPreparedInlineDataPackage(t *testing.T) {
|
||||
client := &fakeClient{response: validResponse()}
|
||||
adapter := newTestAdapter(t, client)
|
||||
request := testExecuteRequest()
|
||||
callbackCalls := 0
|
||||
result, err := adapter.Execute(context.Background(), request, func(preparation promptexec.Preparation, debug *promptexec.PreparationDebug) error {
|
||||
callbackCalls++
|
||||
if preparation.PromptID != request.PromptID || preparation.PromptVersion != request.PromptVersion || preparation.ModelName != "test-model" {
|
||||
t.Fatalf("preparation = %#v", preparation)
|
||||
}
|
||||
if debug != nil {
|
||||
t.Fatalf("debug = %#v, want nil", debug)
|
||||
}
|
||||
if client.callCount() != 0 {
|
||||
t.Fatal("provider called before preparation callback")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if callbackCalls != 1 || client.callCount() != 1 {
|
||||
t.Fatalf("callback/provider calls = %d/%d, want 1/1", callbackCalls, client.callCount())
|
||||
}
|
||||
if result == nil || result.Validation.Status != promptexec.ValidationPassed || string(result.RawOutput) != client.response.Content {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
if result.Debug != nil {
|
||||
t.Fatalf("debug = %#v, want nil", result.Debug)
|
||||
}
|
||||
providerRequest := client.request()
|
||||
if providerRequest.Target.Model != "test-model" || providerRequest.Target.Endpoint != "https://profile.example/v1" {
|
||||
t.Fatalf("provider target = %#v", providerRequest.Target)
|
||||
}
|
||||
if len(providerRequest.Prompt.Messages) == 0 || !strings.Contains(providerRequest.Prompt.Messages[2].Content, string(request.DataPackage)) {
|
||||
t.Fatalf("rendered messages do not contain exact data package: %#v", providerRequest.Prompt.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteEmbeddedHourlyProfileThroughPreparedPath(t *testing.T) {
|
||||
t.Setenv("OPENROUTER_API_KEY", "test-openrouter-key")
|
||||
client := &fakeClient{response: hourlyValidResponse()}
|
||||
adapter, err := newAdapter(Config{}, promptkit.WithLLMClient(client))
|
||||
if err != nil {
|
||||
t.Fatalf("newAdapter() error = %v", err)
|
||||
}
|
||||
request := promptexec.ExecuteRequest{
|
||||
PromptID: "weather.hourly_generated_text",
|
||||
PromptVersion: "2.0.0",
|
||||
ProfileID: "weather-light",
|
||||
DataPackage: []byte("report:\n id: hourly\nbriefing: {}\n"),
|
||||
}
|
||||
var preparation promptexec.Preparation
|
||||
prepared := false
|
||||
result, err := adapter.Execute(context.Background(), request, func(value promptexec.Preparation, _ *promptexec.PreparationDebug) error {
|
||||
if client.callCount() != 0 {
|
||||
t.Fatal("provider was called before preparation completed")
|
||||
}
|
||||
preparation = value
|
||||
prepared = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if !prepared || preparation.ProfileID != "weather-light" || preparation.BackendID != "openrouter" || preparation.ModelName != "deepseek/deepseek-v4-flash" {
|
||||
t.Fatalf("preparation = %#v", preparation)
|
||||
}
|
||||
if result == nil || result.ProfileID != "weather-light" || result.BackendID != "openrouter" || result.ModelName != "deepseek/deepseek-v4-flash" || result.Validation.Status != promptexec.ValidationPassed {
|
||||
t.Fatalf("execution = %#v", result)
|
||||
}
|
||||
if client.callCount() != 1 || client.request().Target.Model != "deepseek/deepseek-v4-flash" {
|
||||
t.Fatalf("provider calls/request = %d/%#v", client.callCount(), client.request())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteUsesExactInlineDataPackageProvenance(t *testing.T) {
|
||||
client := &fakeClient{response: validResponse()}
|
||||
reader := &recordingReader{}
|
||||
adapter := newTestAdapterWithOptions(t, client, promptkit.WithArtifactReader(reader))
|
||||
request := testExecuteRequest()
|
||||
if _, err := adapter.Execute(context.Background(), request, nil); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if reader.ref.Type != promptkit.ArtifactRefInline || reader.ref.URI != "" || reader.ref.Body != string(request.DataPackage) {
|
||||
t.Fatalf("artifact ref = %#v, want exact inline data package provenance", reader.ref)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteCapturesSensitiveDebugOnlyWhenRequested(t *testing.T) {
|
||||
client := &fakeClient{response: validResponse()}
|
||||
adapter := newTestAdapter(t, client)
|
||||
request := testExecuteRequest()
|
||||
request.CaptureDebug = true
|
||||
var preparationDebug *promptexec.PreparationDebug
|
||||
result, err := adapter.Execute(context.Background(), request, func(preparation promptexec.Preparation, debug *promptexec.PreparationDebug) error {
|
||||
preparationDebug = debug
|
||||
if strings.Contains(fmt.Sprintf("%#v", preparation), "https://profile.example") || strings.Contains(fmt.Sprintf("%#v", preparation), string(request.DataPackage)) {
|
||||
t.Fatalf("safe preparation leaks sensitive content: %#v", preparation)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if preparationDebug == nil || preparationDebug.Endpoint != "https://profile.example/v1" || len(preparationDebug.RenderedMessages) == 0 || len(preparationDebug.StructuredSchema) == 0 || len(preparationDebug.ParametersJSON) == 0 {
|
||||
t.Fatalf("preparation debug = %#v", preparationDebug)
|
||||
}
|
||||
if result.Debug == nil || string(result.Debug.RawOutput) != client.response.Content {
|
||||
t.Fatalf("execution debug = %#v", result.Debug)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteCallbackFailurePreventsGeneration(t *testing.T) {
|
||||
client := &fakeClient{response: validResponse()}
|
||||
adapter := newTestAdapter(t, client)
|
||||
callbackError := errors.New("save preparation")
|
||||
result, err := adapter.Execute(context.Background(), testExecuteRequest(), func(promptexec.Preparation, *promptexec.PreparationDebug) error {
|
||||
return callbackError
|
||||
})
|
||||
if result != nil || !errors.Is(err, callbackError) || client.callCount() != 0 {
|
||||
t.Fatalf("result/error/provider calls = %#v/%v/%d", result, err, client.callCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteReturnsCompletedValidationRejection(t *testing.T) {
|
||||
client := &fakeClient{response: &promptkit.GenerateResponse{Content: `{"summary":42}`, Usage: promptkit.TokenUsage{TotalTokens: 5}}}
|
||||
adapter := newTestAdapter(t, client)
|
||||
result, err := adapter.Execute(context.Background(), testExecuteRequest(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if result == nil || result.Validation.Status != promptexec.ValidationFailed || len(result.Validation.Diagnostics) == 0 || string(result.RawOutput) != client.response.Content {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteClassifiesOperationalFailures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
client *fakeClient
|
||||
context func() (context.Context, context.CancelFunc)
|
||||
category promptexec.ErrorCategory
|
||||
}{
|
||||
{
|
||||
name: "generation",
|
||||
client: &fakeClient{err: errors.New("provider response body")},
|
||||
context: func() (context.Context, context.CancelFunc) {
|
||||
return context.WithCancel(context.Background())
|
||||
},
|
||||
category: promptexec.Generation,
|
||||
},
|
||||
{
|
||||
name: "canceled",
|
||||
client: &fakeClient{block: true},
|
||||
context: func() (context.Context, context.CancelFunc) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
return ctx, func() {}
|
||||
},
|
||||
category: promptexec.Canceled,
|
||||
},
|
||||
{
|
||||
name: "deadline",
|
||||
client: &fakeClient{block: true},
|
||||
context: func() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), time.Nanosecond)
|
||||
},
|
||||
category: promptexec.DeadlineExceeded,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
adapter := newTestAdapter(t, test.client)
|
||||
ctx, cancel := test.context()
|
||||
defer cancel()
|
||||
result, err := adapter.Execute(ctx, testExecuteRequest(), nil)
|
||||
if result != nil || err == nil || promptexec.CategoryOf(err) != test.category {
|
||||
t.Fatalf("result/error/category = %#v/%v/%q, want %q", result, err, promptexec.CategoryOf(err), test.category)
|
||||
}
|
||||
if strings.Contains(err.Error(), "provider response body") {
|
||||
t.Fatalf("error leaks provider detail: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyPromptkitErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
err error
|
||||
category promptexec.ErrorCategory
|
||||
}{
|
||||
{promptkit.ErrInvalidConfig, promptexec.InvalidConfiguration},
|
||||
{promptkit.ErrInvalidRequest, promptexec.InvalidRequest},
|
||||
{promptkit.ErrPromptNotFound, promptexec.PromptNotFound},
|
||||
{promptkit.ErrPromptLoad, promptexec.PromptLoad},
|
||||
{promptkit.ErrProfileNotFound, promptexec.ProfileNotFound},
|
||||
{promptkit.ErrProfileLoad, promptexec.ProfileLoad},
|
||||
{promptkit.ErrAPIKeyEnvMissing, promptexec.MissingCredential},
|
||||
{promptkit.ErrArtifactLoad, promptexec.ArtifactLoad},
|
||||
{promptkit.ErrPromptRender, promptexec.PromptRender},
|
||||
{promptkit.ErrLLMGenerate, promptexec.Generation},
|
||||
{promptkit.ErrValidation, promptexec.OperationalValidation},
|
||||
{&promptkit.CapacityError{BackendID: "local"}, promptexec.Capacity},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(string(test.category), func(t *testing.T) {
|
||||
got := classifyError(test.err)
|
||||
if promptexec.CategoryOf(got) != test.category {
|
||||
t.Fatalf("category = %q, want %q", promptexec.CategoryOf(got), test.category)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewValidatesConfiguration(t *testing.T) {
|
||||
if _, err := New(Config{ProfileDirectory: "profiles", ProfileFile: "profile.yml"}); promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
|
||||
t.Fatalf("profile source error = %v", err)
|
||||
}
|
||||
if _, err := New(Config{LocalConcurrencyLimit: 1}); promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
|
||||
t.Fatalf("local concurrency error = %v", err)
|
||||
}
|
||||
if _, err := New(Config{LocalEndpoint: "not a URL"}); promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
|
||||
t.Fatalf("local endpoint error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalBackendAndMissingCredentialBehavior(t *testing.T) {
|
||||
profiles := testProfileDirectory(t, `id: local-profile
|
||||
backend: local
|
||||
model: local-model
|
||||
`)
|
||||
adapter, err := newAdapterForTest(Config{
|
||||
ProfileDirectory: profiles,
|
||||
LocalEndpoint: "https://local.example/v1",
|
||||
LocalConcurrencyLimit: 1,
|
||||
}, &fakeClient{})
|
||||
if err != nil {
|
||||
t.Fatalf("newAdapterForTest(local) error = %v", err)
|
||||
}
|
||||
profile, err := adapter.InspectProfile(context.Background(), "local-profile")
|
||||
if err != nil || profile.BackendID != promptkit.BackendLocal || profile.ModelName != "local-model" {
|
||||
t.Fatalf("local profile/error = %#v/%v", profile, err)
|
||||
}
|
||||
if got := classifyError(&promptkit.CapacityError{BackendID: promptkit.BackendLocal}); promptexec.CategoryOf(got) != promptexec.Capacity {
|
||||
t.Fatalf("capacity classification = %v", got)
|
||||
}
|
||||
|
||||
credentialProfiles := testProfileDirectory(t, `id: credential-profile
|
||||
endpoint: https://profile.example/v1
|
||||
model: test-model
|
||||
api_key_env: WEATHERREPORTER_TEST_MISSING_KEY
|
||||
`)
|
||||
client := &fakeClient{response: validResponse()}
|
||||
credentialAdapter, err := newAdapterForTest(Config{ProfileDirectory: credentialProfiles}, client)
|
||||
if err != nil {
|
||||
t.Fatalf("newAdapterForTest(credential) error = %v", err)
|
||||
}
|
||||
credentialProfile, err := credentialAdapter.InspectProfile(context.Background(), "credential-profile")
|
||||
if err != nil || credentialProfile.CredentialRequired || credentialProfile.APIKeyEnv != "WEATHERREPORTER_TEST_MISSING_KEY" {
|
||||
t.Fatalf("credential profile/error = %#v/%v", credentialProfile, err)
|
||||
}
|
||||
request := testExecuteRequest()
|
||||
request.ProfileID = "credential-profile"
|
||||
result, err := credentialAdapter.Execute(context.Background(), request, nil)
|
||||
if result != nil || promptexec.CategoryOf(err) != promptexec.MissingCredential || client.callCount() != 0 {
|
||||
t.Fatalf("credential result/category/calls = %#v/%q/%d", result, promptexec.CategoryOf(err), client.callCount())
|
||||
}
|
||||
}
|
||||
|
||||
func newTestAdapter(t *testing.T, client promptkit.LLMClient) *Adapter {
|
||||
return newTestAdapterWithOptions(t, client)
|
||||
}
|
||||
|
||||
func assertProfile(t *testing.T, adapter *Adapter, id string, backend string, model string) {
|
||||
t.Helper()
|
||||
profile, err := adapter.InspectProfile(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("InspectProfile(%q) error = %v", id, err)
|
||||
}
|
||||
if profile.ProfileID != id || profile.BackendID != backend || profile.ModelName != model {
|
||||
t.Fatalf("profile = %#v, want %q with backend/model %q/%q", profile, id, backend, model)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestAdapterWithOptions(t *testing.T, client promptkit.LLMClient, options ...promptkit.Option) *Adapter {
|
||||
t.Helper()
|
||||
profiles := testProfileDirectory(t, `id: test-profile
|
||||
endpoint: https://profile.example/v1
|
||||
model: test-model
|
||||
temperature: 0.2
|
||||
max_tokens: 300
|
||||
top_p: 1
|
||||
timeout_seconds: 30
|
||||
`)
|
||||
options = append(options, promptkit.WithLLMClient(client))
|
||||
adapter, err := newAdapter(Config{ProfileDirectory: profiles, Timeout: time.Second}, options...)
|
||||
if err != nil {
|
||||
t.Fatalf("newAdapter() error = %v", err)
|
||||
}
|
||||
return adapter
|
||||
}
|
||||
|
||||
func testProfileDirectory(t *testing.T, profile string) string {
|
||||
t.Helper()
|
||||
profiles := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(profiles, "profile.yml"), []byte(profile), 0o600); err != nil {
|
||||
t.Fatalf("write profile: %v", err)
|
||||
}
|
||||
return profiles
|
||||
}
|
||||
|
||||
func writeProfileFile(t *testing.T, profile string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "profile.yml")
|
||||
if err := os.WriteFile(path, []byte(profile), 0o600); err != nil {
|
||||
t.Fatalf("write profile: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func testExecuteRequest() promptexec.ExecuteRequest {
|
||||
return promptexec.ExecuteRequest{
|
||||
PromptID: "weather.daily_generated_text",
|
||||
PromptVersion: "2.0.0",
|
||||
ProfileID: "test-profile",
|
||||
DataPackage: []byte("report:\n id: daily\nbriefing: {}\n"),
|
||||
}
|
||||
}
|
||||
|
||||
func validResponse() *promptkit.GenerateResponse {
|
||||
return &promptkit.GenerateResponse{
|
||||
Content: `{"summary":"A quiet day is expected.","forecast_discussion":["High pressure keeps conditions settled."],"precipitation_timing":""}`,
|
||||
Usage: promptkit.TokenUsage{PromptTokens: 12, CompletionTokens: 8, TotalTokens: 20},
|
||||
}
|
||||
}
|
||||
|
||||
func hourlyValidResponse() *promptkit.GenerateResponse {
|
||||
return &promptkit.GenerateResponse{
|
||||
Content: `{"summary":"A quiet hour is expected.","forecast_discussion":"Conditions remain settled.","precipitation_timing":""}`,
|
||||
Usage: promptkit.TokenUsage{PromptTokens: 12, CompletionTokens: 8, TotalTokens: 20},
|
||||
}
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
// Package scriptorium adapts the external scriptorium CLI.
|
||||
package scriptorium
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxCapturedOutputBytes = 1024 * 1024
|
||||
|
||||
type CommandRunner interface {
|
||||
Run(ctx context.Context, name string, args []string, timeout time.Duration) (CommandResult, error)
|
||||
}
|
||||
|
||||
type CommandResult struct {
|
||||
Stdout []byte
|
||||
Stderr []byte
|
||||
StdoutTruncated bool
|
||||
StderrTruncated bool
|
||||
ExitCode int
|
||||
}
|
||||
|
||||
type ExecRunner struct{}
|
||||
|
||||
func (ExecRunner) Run(ctx context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) {
|
||||
runCtx := ctx
|
||||
cancel := func() {}
|
||||
if timeout > 0 {
|
||||
runCtx, cancel = context.WithTimeout(ctx, timeout)
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(runCtx, name, args...)
|
||||
stdout := &limitedBuffer{limit: maxCapturedOutputBytes}
|
||||
stderr := &limitedBuffer{limit: maxCapturedOutputBytes}
|
||||
cmd.Stdout = stdout
|
||||
cmd.Stderr = stderr
|
||||
err := cmd.Run()
|
||||
result := CommandResult{
|
||||
Stdout: stdout.Bytes(),
|
||||
Stderr: stderr.Bytes(),
|
||||
StdoutTruncated: stdout.Truncated(),
|
||||
StderrTruncated: stderr.Truncated(),
|
||||
ExitCode: 0,
|
||||
}
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
if runCtx.Err() != nil {
|
||||
return result, runCtx.Err()
|
||||
}
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
result.ExitCode = exitErr.ExitCode()
|
||||
return result, nil
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
type Runner struct {
|
||||
Binary string
|
||||
ConfigPath string
|
||||
Profile string
|
||||
Timeout time.Duration
|
||||
ExtraArgs []string
|
||||
Commands CommandRunner
|
||||
}
|
||||
|
||||
type RenderRequest struct {
|
||||
PromptID string
|
||||
DataPackagePath string
|
||||
}
|
||||
|
||||
type RunRequest struct {
|
||||
PromptID string
|
||||
DataPackagePath string
|
||||
OutputPath string
|
||||
}
|
||||
|
||||
type RenderResult 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"`
|
||||
}
|
||||
|
||||
type RunResult struct {
|
||||
Command []string `json:"command"`
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
StdoutTruncated bool `json:"stdoutTruncated,omitempty"`
|
||||
StderrTruncated bool `json:"stderrTruncated,omitempty"`
|
||||
ExitCode int `json:"exitCode"`
|
||||
OutputPath string `json:"outputPath"`
|
||||
}
|
||||
|
||||
func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, error) {
|
||||
if req.PromptID == "" {
|
||||
return nil, fmt.Errorf("prompt id is required")
|
||||
}
|
||||
if req.DataPackagePath == "" {
|
||||
return nil, fmt.Errorf("data package path is required")
|
||||
}
|
||||
binary := r.Binary
|
||||
if binary == "" {
|
||||
binary = "scriptorium"
|
||||
}
|
||||
commands := r.Commands
|
||||
if commands == nil {
|
||||
commands = ExecRunner{}
|
||||
}
|
||||
args := r.renderArgs(req)
|
||||
commandResult, err := commands.Run(ctx, binary, args, r.Timeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("run scriptorium render: %w", err)
|
||||
}
|
||||
result := &RenderResult{
|
||||
Command: append([]string{binary}, args...),
|
||||
Stdout: string(commandResult.Stdout),
|
||||
Stderr: string(commandResult.Stderr),
|
||||
StdoutTruncated: commandResult.StdoutTruncated,
|
||||
StderrTruncated: commandResult.StderrTruncated,
|
||||
ExitCode: commandResult.ExitCode,
|
||||
}
|
||||
if commandResult.ExitCode != 0 {
|
||||
return result, fmt.Errorf("scriptorium render exited with code %d: %s", commandResult.ExitCode, result.Stderr)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
||||
if req.PromptID == "" {
|
||||
return nil, fmt.Errorf("prompt id is required")
|
||||
}
|
||||
if req.DataPackagePath == "" {
|
||||
return nil, fmt.Errorf("data package path is required")
|
||||
}
|
||||
if req.OutputPath == "" {
|
||||
return nil, fmt.Errorf("output path is required")
|
||||
}
|
||||
binary := r.Binary
|
||||
if binary == "" {
|
||||
binary = "scriptorium"
|
||||
}
|
||||
commands := r.Commands
|
||||
if commands == nil {
|
||||
commands = ExecRunner{}
|
||||
}
|
||||
args := r.runArgs(req)
|
||||
commandResult, err := commands.Run(ctx, binary, args, r.Timeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("run scriptorium: %w", err)
|
||||
}
|
||||
result := &RunResult{
|
||||
Command: append([]string{binary}, args...),
|
||||
Stdout: string(commandResult.Stdout),
|
||||
Stderr: string(commandResult.Stderr),
|
||||
StdoutTruncated: commandResult.StdoutTruncated,
|
||||
StderrTruncated: commandResult.StderrTruncated,
|
||||
ExitCode: commandResult.ExitCode,
|
||||
OutputPath: req.OutputPath,
|
||||
}
|
||||
if commandResult.ExitCode != 0 {
|
||||
return result, fmt.Errorf("scriptorium run exited with code %d: %s", commandResult.ExitCode, result.Stderr)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r Runner) renderArgs(req RenderRequest) []string {
|
||||
args := []string{"render"}
|
||||
if r.ConfigPath != "" {
|
||||
args = append(args, "--config", r.ConfigPath)
|
||||
}
|
||||
if r.Profile != "" {
|
||||
args = append(args, "--profile", r.Profile)
|
||||
}
|
||||
args = append(args,
|
||||
"--prompt", req.PromptID,
|
||||
"--input", "data_package="+req.DataPackagePath,
|
||||
"--format", "json",
|
||||
)
|
||||
args = append(args, r.ExtraArgs...)
|
||||
return args
|
||||
}
|
||||
|
||||
func (r Runner) runArgs(req RunRequest) []string {
|
||||
args := []string{"run"}
|
||||
if r.ConfigPath != "" {
|
||||
args = append(args, "--config", r.ConfigPath)
|
||||
}
|
||||
if r.Profile != "" {
|
||||
args = append(args, "--profile", r.Profile)
|
||||
}
|
||||
args = append(args,
|
||||
"--prompt", req.PromptID,
|
||||
"--input", "data_package="+req.DataPackagePath,
|
||||
"--out", req.OutputPath,
|
||||
)
|
||||
args = append(args, r.ExtraArgs...)
|
||||
return args
|
||||
}
|
||||
|
||||
func SaveRenderResult(path string, result *RenderResult) error {
|
||||
if result == nil {
|
||||
return fmt.Errorf("render result is required")
|
||||
}
|
||||
data, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal render result: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create preflight directory %q: %w", filepath.Dir(path), err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary preflight file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("write temporary preflight file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temporary preflight file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("save preflight %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type limitedBuffer struct {
|
||||
data []byte
|
||||
limit int
|
||||
truncated bool
|
||||
}
|
||||
|
||||
func (b *limitedBuffer) Write(p []byte) (int, error) {
|
||||
if b.limit <= 0 {
|
||||
b.truncated = true
|
||||
return len(p), nil
|
||||
}
|
||||
remaining := b.limit - len(b.data)
|
||||
if remaining <= 0 {
|
||||
b.truncated = true
|
||||
return len(p), nil
|
||||
}
|
||||
if len(p) > remaining {
|
||||
b.data = append(b.data, p[:remaining]...)
|
||||
b.truncated = true
|
||||
return len(p), nil
|
||||
}
|
||||
b.data = append(b.data, p...)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (b *limitedBuffer) Bytes() []byte {
|
||||
return append([]byte{}, b.data...)
|
||||
}
|
||||
|
||||
func (b *limitedBuffer) Truncated() bool {
|
||||
return b.truncated
|
||||
}
|
||||
|
||||
var _ io.Writer = (*limitedBuffer)(nil)
|
||||
@@ -1,163 +0,0 @@
|
||||
package scriptorium
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRenderConstructsCommand(t *testing.T) {
|
||||
commands := &fakeCommands{result: CommandResult{Stdout: []byte(`{"ok":true}`)}}
|
||||
runner := Runner{
|
||||
Binary: "/usr/local/bin/scriptorium",
|
||||
ConfigPath: "/etc/scriptorium.yml",
|
||||
Profile: "weather",
|
||||
Timeout: time.Minute,
|
||||
Commands: commands,
|
||||
}
|
||||
|
||||
result, err := runner.Render(context.Background(), RenderRequest{
|
||||
PromptID: "weather.daily_report",
|
||||
DataPackagePath: "/tmp/data_package.json",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Render() error = %v", err)
|
||||
}
|
||||
|
||||
wantArgs := []string{
|
||||
"render",
|
||||
"--config", "/etc/scriptorium.yml",
|
||||
"--profile", "weather",
|
||||
"--prompt", "weather.daily_report",
|
||||
"--input", "data_package=/tmp/data_package.json",
|
||||
"--format", "json",
|
||||
}
|
||||
if commands.name != "/usr/local/bin/scriptorium" {
|
||||
t.Fatalf("command name = %q, want custom binary", commands.name)
|
||||
}
|
||||
if !reflect.DeepEqual(commands.args, wantArgs) {
|
||||
t.Fatalf("args = %#v, want %#v", commands.args, wantArgs)
|
||||
}
|
||||
if !reflect.DeepEqual(result.Command, append([]string{"/usr/local/bin/scriptorium"}, wantArgs...)) {
|
||||
t.Fatalf("result command = %#v, want full argv", result.Command)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderReturnsResultForNonzeroExit(t *testing.T) {
|
||||
runner := Runner{
|
||||
Commands: &fakeCommands{
|
||||
result: CommandResult{
|
||||
Stderr: []byte("missing input"),
|
||||
ExitCode: 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := runner.Render(context.Background(), RenderRequest{
|
||||
PromptID: "weather.daily_report",
|
||||
DataPackagePath: "/tmp/data_package.json",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Render() error = nil, want nonzero exit error")
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("Render() result = nil, want captured result")
|
||||
}
|
||||
if result.ExitCode != 1 {
|
||||
t.Fatalf("ExitCode = %d, want 1", result.ExitCode)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing input") {
|
||||
t.Fatalf("error = %q, want stderr context", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConstructsCommand(t *testing.T) {
|
||||
commands := &fakeCommands{result: CommandResult{Stderr: []byte("wrote report")}}
|
||||
runner := Runner{
|
||||
Binary: "/usr/local/bin/scriptorium",
|
||||
ConfigPath: "/etc/scriptorium.yml",
|
||||
Profile: "weather",
|
||||
Timeout: 45 * time.Second,
|
||||
Commands: commands,
|
||||
}
|
||||
|
||||
result, err := runner.Run(context.Background(), RunRequest{
|
||||
PromptID: "weather.daily_report",
|
||||
DataPackagePath: "/tmp/data_package.json",
|
||||
OutputPath: "/tmp/daily.md",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
wantArgs := []string{
|
||||
"run",
|
||||
"--config", "/etc/scriptorium.yml",
|
||||
"--profile", "weather",
|
||||
"--prompt", "weather.daily_report",
|
||||
"--input", "data_package=/tmp/data_package.json",
|
||||
"--out", "/tmp/daily.md",
|
||||
}
|
||||
if commands.name != "/usr/local/bin/scriptorium" {
|
||||
t.Fatalf("command name = %q, want custom binary", commands.name)
|
||||
}
|
||||
if !reflect.DeepEqual(commands.args, wantArgs) {
|
||||
t.Fatalf("args = %#v, want %#v", commands.args, wantArgs)
|
||||
}
|
||||
if commands.timeout != 45*time.Second {
|
||||
t.Fatalf("timeout = %s, want 45s", commands.timeout)
|
||||
}
|
||||
if !reflect.DeepEqual(result.Command, append([]string{"/usr/local/bin/scriptorium"}, wantArgs...)) {
|
||||
t.Fatalf("result command = %#v, want full argv", result.Command)
|
||||
}
|
||||
if result.OutputPath != "/tmp/daily.md" {
|
||||
t.Fatalf("OutputPath = %q, want /tmp/daily.md", result.OutputPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReturnsResultForValidationExit(t *testing.T) {
|
||||
runner := Runner{
|
||||
Commands: &fakeCommands{
|
||||
result: CommandResult{
|
||||
Stdout: []byte("# Daily Report\n"),
|
||||
Stderr: []byte("validation failed"),
|
||||
ExitCode: 2,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := runner.Run(context.Background(), RunRequest{
|
||||
PromptID: "weather.daily_report",
|
||||
DataPackagePath: "/tmp/data_package.json",
|
||||
OutputPath: "/tmp/daily.md",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Run() error = nil, want nonzero exit error")
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("Run() result = nil, want captured result")
|
||||
}
|
||||
if result.ExitCode != 2 {
|
||||
t.Fatalf("ExitCode = %d, want 2", result.ExitCode)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "validation failed") {
|
||||
t.Fatalf("error = %q, want stderr context", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
type fakeCommands struct {
|
||||
name string
|
||||
args []string
|
||||
timeout time.Duration
|
||||
result CommandResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeCommands) Run(_ context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) {
|
||||
f.name = name
|
||||
f.args = append([]string{}, args...)
|
||||
f.timeout = timeout
|
||||
return f.result, f.err
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package weatherapi adapts the internal weather API to forecast bundles.
|
||||
// Package weatherapi adapts the internal weather API to weather data bundles.
|
||||
package weatherapi
|
||||
|
||||
import (
|
||||
@@ -11,15 +11,25 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
const (
|
||||
convectiveOutlooksEndpoint = "/outlooks/convective"
|
||||
sourceSPCConvectiveOutlooks = "spc_convective_outlooks"
|
||||
|
||||
defaultWarmupEndpoint = "/conditions/current"
|
||||
defaultWarmupAttempts = 3
|
||||
defaultWarmupDelay = time.Second
|
||||
defaultFetchAttempts = 2
|
||||
defaultFetchRetryDelay = time.Second
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
@@ -31,6 +41,12 @@ type Client struct {
|
||||
precision int
|
||||
missingSource config.MissingSourceConfig
|
||||
now func() time.Time
|
||||
|
||||
warmupEndpoint string
|
||||
warmupAttempts int
|
||||
warmupDelay time.Duration
|
||||
fetchAttempts int
|
||||
fetchRetryDelay time.Duration
|
||||
}
|
||||
|
||||
type Option func(*Client)
|
||||
@@ -76,7 +92,12 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
|
||||
Default: cfg.MissingSource.Default,
|
||||
Sources: cfg.MissingSource.Sources,
|
||||
},
|
||||
now: time.Now,
|
||||
now: time.Now,
|
||||
warmupEndpoint: defaultWarmupEndpoint,
|
||||
warmupAttempts: defaultWarmupAttempts,
|
||||
warmupDelay: defaultWarmupDelay,
|
||||
fetchAttempts: defaultFetchAttempts,
|
||||
fetchRetryDelay: defaultFetchRetryDelay,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(client)
|
||||
@@ -84,11 +105,15 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *Client) FetchBundle(ctx context.Context) (*forecast.Bundle, error) {
|
||||
func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
|
||||
if err := c.warmup(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fetchedAt := c.now()
|
||||
builder := bundleBuilder{
|
||||
client: c,
|
||||
bundle: &forecast.Bundle{FetchedAt: fetchedAt},
|
||||
bundle: &weatherdata.Bundle{FetchedAt: fetchedAt},
|
||||
fetchedAt: fetchedAt,
|
||||
}
|
||||
|
||||
@@ -110,10 +135,10 @@ func (c *Client) FetchBundle(ctx context.Context) (*forecast.Bundle, error) {
|
||||
if err := builder.fetchDiscussion(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := builder.addStub("daily", "daily forecast data is not available from the weather API yet"); err != nil {
|
||||
if err := builder.fetchWeatherStory(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := builder.addStub("weather_story", "NWS weather story is not available from the weather API yet"); err != nil {
|
||||
if err := builder.fetchSPCConvectiveOutlooks(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -122,22 +147,36 @@ func (c *Client) FetchBundle(ctx context.Context) (*forecast.Bundle, error) {
|
||||
|
||||
type bundleBuilder struct {
|
||||
client *Client
|
||||
bundle *forecast.Bundle
|
||||
bundle *weatherdata.Bundle
|
||||
fetchedAt time.Time
|
||||
}
|
||||
|
||||
type sourceRequest struct {
|
||||
name string
|
||||
endpoint string
|
||||
query queryOptions
|
||||
missingMessage string
|
||||
required bool
|
||||
decodeLabel string
|
||||
}
|
||||
|
||||
type fetchedSource struct {
|
||||
raw json.RawMessage
|
||||
source weatherdata.Source
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, "observations", "/observations", queryOptions{precision: true})
|
||||
if err != nil {
|
||||
var observation weatherdata.Observation
|
||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||
name: "observations",
|
||||
endpoint: "/observations",
|
||||
query: queryOptions{precision: true},
|
||||
missingMessage: "observation data is missing",
|
||||
}, &observation)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "observation data is missing", false)
|
||||
}
|
||||
var observation forecast.Observation
|
||||
if err := decodeSource(raw, &observation); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
}
|
||||
source := fetched.source
|
||||
source.IssuedAt = &observation.Timestamp
|
||||
b.bundle.Observation = &observation
|
||||
b.addSource(source)
|
||||
@@ -145,34 +184,36 @@ func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, "current", "/conditions/current", queryOptions{precision: true})
|
||||
if err != nil {
|
||||
var current weatherdata.Current
|
||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||
name: "current",
|
||||
endpoint: "/conditions/current",
|
||||
query: queryOptions{precision: true},
|
||||
missingMessage: "current conditions data is missing",
|
||||
}, ¤t)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "current conditions data is missing", false)
|
||||
}
|
||||
var current forecast.Current
|
||||
if err := decodeSource(raw, ¤t); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
}
|
||||
source := fetched.source
|
||||
b.bundle.Current = ¤t
|
||||
b.addSource(source)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, "hourly", "/forecast/hourly", queryOptions{precision: true, timezone: true})
|
||||
if err != nil {
|
||||
var hourly weatherdata.ForecastRun
|
||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||
name: "hourly",
|
||||
endpoint: "/forecast/hourly",
|
||||
query: queryOptions{precision: true, timezone: true},
|
||||
missingMessage: "hourly forecast data is missing",
|
||||
required: true,
|
||||
decodeLabel: "hourly forecast",
|
||||
}, &hourly)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "hourly forecast data is missing", true)
|
||||
}
|
||||
var hourly forecast.ForecastRun
|
||||
if err := decodeSource(raw, &hourly); err != nil {
|
||||
return fmt.Errorf("decode hourly forecast from %s: %w", source.Endpoint, err)
|
||||
}
|
||||
source := fetched.source
|
||||
if len(hourly.Periods) == 0 {
|
||||
return fmt.Errorf("hourly forecast from %s contains no periods", source.Endpoint)
|
||||
}
|
||||
@@ -184,17 +225,17 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, "narrative", "/forecast/narrative", queryOptions{precision: true, timezone: true})
|
||||
if err != nil {
|
||||
var narrative weatherdata.ForecastRun
|
||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||
name: "narrative",
|
||||
endpoint: "/forecast/narrative",
|
||||
query: queryOptions{precision: true, timezone: true},
|
||||
missingMessage: "narrative forecast data is missing",
|
||||
}, &narrative)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "narrative forecast data is missing", false)
|
||||
}
|
||||
var narrative forecast.ForecastRun
|
||||
if err := decodeSource(raw, &narrative); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
}
|
||||
source := fetched.source
|
||||
source.IssuedAt = &narrative.IssuedAt
|
||||
source.UpdatedAt = narrative.UpdatedAt
|
||||
b.bundle.Narrative = &narrative
|
||||
@@ -203,16 +244,21 @@ func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, "alerts", "/alerts/active", queryOptions{})
|
||||
raw, source, err := b.client.fetch(ctx, "alerts", "/alerts/active", queryOptions{allowNull: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "active alerts data is missing", false)
|
||||
}
|
||||
var alerts forecast.AlertRun
|
||||
if isJSONNull(raw) {
|
||||
b.bundle.Alerts = &weatherdata.AlertRun{Raw: append(json.RawMessage(nil), raw...)}
|
||||
b.addSource(source)
|
||||
return nil
|
||||
}
|
||||
var alerts weatherdata.AlertRun
|
||||
if err := decodeSource(raw, &alerts); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
return b.handleMalformed(&source, err, sourceRequest{name: "alerts"})
|
||||
}
|
||||
alerts.Raw = append(json.RawMessage(nil), raw...)
|
||||
if alerts.AsOf != nil {
|
||||
@@ -224,17 +270,17 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, "discussion", "/discussion", queryOptions{timezone: true})
|
||||
if err != nil {
|
||||
var discussion weatherdata.Discussion
|
||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||
name: "discussion",
|
||||
endpoint: "/discussion",
|
||||
query: queryOptions{timezone: true},
|
||||
missingMessage: "forecast discussion data is missing",
|
||||
}, &discussion)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "forecast discussion data is missing", false)
|
||||
}
|
||||
var discussion forecast.Discussion
|
||||
if err := decodeSource(raw, &discussion); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
}
|
||||
source := fetched.source
|
||||
source.IssuedAt = &discussion.IssuedAt
|
||||
source.UpdatedAt = discussion.UpdatedAt
|
||||
b.bundle.Discussion = &discussion
|
||||
@@ -242,16 +288,73 @@ func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) addStub(sourceName string, message string) error {
|
||||
source := forecast.Source{
|
||||
Name: sourceName,
|
||||
FetchedAt: b.fetchedAt,
|
||||
Missing: true,
|
||||
func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
||||
var story weatherdata.WeatherStory
|
||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||
name: "weather_story",
|
||||
endpoint: "/weatherstories/latest",
|
||||
query: queryOptions{omitUnits: true},
|
||||
missingMessage: "NWS weather story data is missing",
|
||||
}, &story)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
return b.applyMissingPolicy(&source, "missing_source", message)
|
||||
source := fetched.source
|
||||
if !story.StartTime.IsZero() {
|
||||
source.IssuedAt = &story.StartTime
|
||||
}
|
||||
source.UpdatedAt = story.UpdatedAt
|
||||
b.bundle.WeatherStory = &story
|
||||
b.addSource(source)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) handleMissing(source *forecast.Source, message string, required bool) error {
|
||||
func (b *bundleBuilder) fetchSPCConvectiveOutlooks(ctx context.Context) error {
|
||||
var run weatherdata.ConvectiveOutlookRun
|
||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||
name: sourceSPCConvectiveOutlooks,
|
||||
endpoint: convectiveOutlooksEndpoint,
|
||||
query: queryOptions{timezone: true, omitUnits: true},
|
||||
missingMessage: "SPC convective outlook data is missing",
|
||||
}, &run)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
source := fetched.source
|
||||
if run.IssuedAt != nil {
|
||||
source.IssuedAt = run.IssuedAt
|
||||
} else {
|
||||
source.IssuedAt = run.AsOf
|
||||
}
|
||||
source.UpdatedAt = run.UpdatedAt
|
||||
b.bundle.SPCConvectiveOutlooks = &run
|
||||
b.addSource(source)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchDecodedSource(ctx context.Context, request sourceRequest, target any) (fetchedSource, bool, error) {
|
||||
fetched, ok, err := b.fetchSource(ctx, request)
|
||||
if err != nil || !ok {
|
||||
return fetchedSource{}, false, err
|
||||
}
|
||||
if err := decodeSource(fetched.raw, target); err != nil {
|
||||
return fetchedSource{}, false, b.handleMalformed(&fetched.source, err, request)
|
||||
}
|
||||
return fetched, true, nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchSource(ctx context.Context, request sourceRequest) (fetchedSource, bool, error) {
|
||||
raw, source, err := b.client.fetch(ctx, request.name, request.endpoint, request.query)
|
||||
if err != nil {
|
||||
return fetchedSource{}, false, err
|
||||
}
|
||||
if raw == nil {
|
||||
return fetchedSource{}, false, b.handleMissing(&source, request.missingMessage, request.required)
|
||||
}
|
||||
return fetchedSource{raw: raw, source: source}, true, nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error {
|
||||
source.Missing = true
|
||||
if required {
|
||||
return fmt.Errorf("%s from %s is required", message, source.Endpoint)
|
||||
@@ -259,21 +362,25 @@ func (b *bundleBuilder) handleMissing(source *forecast.Source, message string, r
|
||||
return b.applyMissingPolicy(source, "missing_source", message)
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) handleMalformed(source *forecast.Source, err error, required bool) error {
|
||||
if required {
|
||||
return fmt.Errorf("decode %s from %s: %w", source.Name, source.Endpoint, err)
|
||||
func (b *bundleBuilder) handleMalformed(source *weatherdata.Source, err error, request sourceRequest) error {
|
||||
if request.required {
|
||||
label := request.name
|
||||
if request.decodeLabel != "" {
|
||||
label = request.decodeLabel
|
||||
}
|
||||
return fmt.Errorf("decode %s from %s: %w", label, source.Endpoint, err)
|
||||
}
|
||||
source.Missing = true
|
||||
return b.applyMissingPolicy(source, "malformed_source", fmt.Sprintf("malformed %s data: %v", source.Name, err))
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) applyMissingPolicy(source *forecast.Source, code string, message string) error {
|
||||
func (b *bundleBuilder) applyMissingPolicy(source *weatherdata.Source, code string, message string) error {
|
||||
policy := b.client.policyFor(source.Name)
|
||||
if policy == config.MissingSourceError {
|
||||
return fmt.Errorf("%s: %s", source.Name, message)
|
||||
}
|
||||
if policy == config.MissingSourceWarn {
|
||||
warning := forecast.SourceWarning{
|
||||
warning := weatherdata.SourceWarning{
|
||||
Source: source.Name,
|
||||
Code: code,
|
||||
Severity: "warning",
|
||||
@@ -288,7 +395,7 @@ func (b *bundleBuilder) applyMissingPolicy(source *forecast.Source, code string,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) addSource(source forecast.Source) {
|
||||
func (b *bundleBuilder) addSource(source weatherdata.Source) {
|
||||
b.bundle.Sources = append(b.bundle.Sources, source)
|
||||
}
|
||||
|
||||
@@ -302,45 +409,32 @@ func (c *Client) policyFor(source string) config.MissingSourcePolicy {
|
||||
type queryOptions struct {
|
||||
precision bool
|
||||
timezone bool
|
||||
allowNull bool
|
||||
omitUnits bool
|
||||
}
|
||||
|
||||
type envelope struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string, opts queryOptions) (json.RawMessage, forecast.Source, error) {
|
||||
reqURL := c.endpointURL(endpoint, opts)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
|
||||
func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string, opts queryOptions) (json.RawMessage, weatherdata.Source, error) {
|
||||
reqURL, body, err := c.fetchHTTP(ctx, endpoint, opts)
|
||||
if err != nil {
|
||||
return nil, forecast.Source{}, fmt.Errorf("create request for %s: %w", endpoint, err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, forecast.Source{}, fmt.Errorf("fetch %s: %w", endpoint, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
||||
if err != nil {
|
||||
return nil, forecast.Source{}, fmt.Errorf("read %s response: %w", endpoint, err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, forecast.Source{}, fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
return nil, weatherdata.Source{}, err
|
||||
}
|
||||
|
||||
var env envelope
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
return nil, forecast.Source{}, fmt.Errorf("decode %s envelope: %w", endpoint, err)
|
||||
return nil, weatherdata.Source{}, fmt.Errorf("decode %s envelope: %w", endpoint, err)
|
||||
}
|
||||
|
||||
source := forecast.Source{
|
||||
source := weatherdata.Source{
|
||||
Name: sourceName,
|
||||
Endpoint: endpoint,
|
||||
Query: queryMap(reqURL.Query()),
|
||||
FetchedAt: c.now(),
|
||||
}
|
||||
if len(env.Data) == 0 || bytes.Equal(bytes.TrimSpace(env.Data), []byte("null")) {
|
||||
if len(env.Data) == 0 || (isJSONNull(env.Data) && !opts.allowNull) {
|
||||
source.Missing = true
|
||||
return nil, source, nil
|
||||
}
|
||||
@@ -352,12 +446,181 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
|
||||
return env.Data, source, nil
|
||||
}
|
||||
|
||||
func (c *Client) warmup(ctx context.Context) error {
|
||||
endpoint := c.warmupEndpoint
|
||||
if strings.TrimSpace(endpoint) == "" {
|
||||
endpoint = defaultWarmupEndpoint
|
||||
}
|
||||
attempts := positiveAttemptCount(c.warmupAttempts)
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= attempts; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fmt.Errorf("warm up weather API via %s: %w", endpoint, err)
|
||||
}
|
||||
if err := c.warmupOnce(ctx, endpoint); err != nil {
|
||||
lastErr = err
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
if attempt == attempts {
|
||||
break
|
||||
}
|
||||
if err := waitForRetry(ctx, c.warmupDelay); err != nil {
|
||||
return fmt.Errorf("warm up weather API via %s after %d attempt(s): %w", endpoint, attempt, err)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("warm up weather API via %s failed after %d attempts: %w", endpoint, attempts, lastErr)
|
||||
}
|
||||
|
||||
func (c *Client) warmupOnce(ctx context.Context, endpoint string) error {
|
||||
reqURL := c.endpointURL(endpoint, queryOptions{precision: true})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request for %s: %w", endpoint, err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch %s: %w", endpoint, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s response: %w", endpoint, err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) fetchHTTP(ctx context.Context, endpoint string, opts queryOptions) (*url.URL, []byte, error) {
|
||||
attempts := positiveAttemptCount(c.fetchAttempts)
|
||||
var lastErr error
|
||||
var lastRetryable bool
|
||||
for attempt := 1; attempt <= attempts; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, nil, fmt.Errorf("fetch %s: %w", endpoint, err)
|
||||
}
|
||||
reqURL, body, err := c.fetchHTTPOnce(ctx, endpoint, opts)
|
||||
if err == nil {
|
||||
return reqURL, body, nil
|
||||
}
|
||||
lastErr = err
|
||||
lastRetryable = isRetryableRequestError(err)
|
||||
if !lastRetryable || attempt == attempts {
|
||||
break
|
||||
}
|
||||
if err := waitForRetry(ctx, c.fetchRetryDelay); err != nil {
|
||||
return nil, nil, fmt.Errorf("fetch %s retry delay after attempt %d: %w", endpoint, attempt, err)
|
||||
}
|
||||
}
|
||||
if lastRetryable {
|
||||
return nil, nil, fmt.Errorf("fetch %s failed after %d attempts: %w", endpoint, attempts, lastErr)
|
||||
}
|
||||
return nil, nil, lastErr
|
||||
}
|
||||
|
||||
func (c *Client) fetchHTTPOnce(ctx context.Context, endpoint string, opts queryOptions) (*url.URL, []byte, error) {
|
||||
reqURL := c.endpointURL(endpoint, opts)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create request for %s: %w", endpoint, err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("fetch %s: %w", endpoint, err)
|
||||
if ctx.Err() != nil {
|
||||
return reqURL, nil, err
|
||||
}
|
||||
return reqURL, nil, retryableRequestError{err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
||||
if err != nil {
|
||||
err = fmt.Errorf("read %s response: %w", endpoint, err)
|
||||
if ctx.Err() != nil {
|
||||
return reqURL, nil, err
|
||||
}
|
||||
return reqURL, nil, retryableRequestError{err: err}
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
err := fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
if isRetryableHTTPStatus(resp.StatusCode) {
|
||||
return reqURL, nil, retryableRequestError{err: err}
|
||||
}
|
||||
return reqURL, nil, err
|
||||
}
|
||||
return reqURL, body, nil
|
||||
}
|
||||
|
||||
type retryableRequestError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e retryableRequestError) Error() string {
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
func (e retryableRequestError) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
func isRetryableRequestError(err error) bool {
|
||||
_, ok := err.(retryableRequestError)
|
||||
return ok
|
||||
}
|
||||
|
||||
func isRetryableHTTPStatus(status int) bool {
|
||||
switch status {
|
||||
case http.StatusRequestTimeout,
|
||||
http.StatusTooManyRequests,
|
||||
http.StatusInternalServerError,
|
||||
http.StatusBadGateway,
|
||||
http.StatusServiceUnavailable,
|
||||
http.StatusGatewayTimeout:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func waitForRetry(ctx context.Context, delay time.Duration) error {
|
||||
if delay <= 0 {
|
||||
return ctx.Err()
|
||||
}
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func positiveAttemptCount(attempts int) int {
|
||||
if attempts < 1 {
|
||||
return 1
|
||||
}
|
||||
return attempts
|
||||
}
|
||||
|
||||
func isJSONNull(raw json.RawMessage) bool {
|
||||
return bytes.Equal(bytes.TrimSpace(raw), []byte("null"))
|
||||
}
|
||||
|
||||
func (c *Client) endpointURL(endpoint string, opts queryOptions) *url.URL {
|
||||
reqURL := *c.baseURL
|
||||
reqURL.Path = path.Join(c.baseURL.Path, endpoint)
|
||||
query := reqURL.Query()
|
||||
query.Set("format", c.format)
|
||||
query.Set("units", c.units)
|
||||
if !opts.omitUnits {
|
||||
query.Set("units", c.units)
|
||||
}
|
||||
if opts.precision {
|
||||
query.Set("precision", strconv.Itoa(c.precision))
|
||||
}
|
||||
@@ -397,30 +660,9 @@ func sourceHash(raw json.RawMessage) (string, error) {
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func SaveBundle(path string, bundle *forecast.Bundle) error {
|
||||
data, err := json.MarshalIndent(bundle, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal forecast bundle: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create bundle directory %q: %w", filepath.Dir(path), err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary bundle file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("write temporary bundle file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temporary bundle file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("save bundle %q: %w", path, err)
|
||||
func SaveBundle(path string, bundle *weatherdata.Bundle) error {
|
||||
if err := fileutil.WriteJSONAtomic(path, bundle); err != nil {
|
||||
return fmt.Errorf("save bundle: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
func TestFetchBundleFromFixtures(t *testing.T) {
|
||||
@@ -43,11 +43,53 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
||||
if bundle.Discussion == nil || len(bundle.Discussion.KeyMessages) != 2 {
|
||||
t.Fatalf("Discussion = %#v, want key messages", bundle.Discussion)
|
||||
}
|
||||
if bundle.Discussion.ShortTerm == nil || bundle.Discussion.ShortTerm.Text != "A weak boundary may trigger isolated showers." {
|
||||
t.Fatalf("Discussion.ShortTerm = %#v, want short-term AFD text", bundle.Discussion.ShortTerm)
|
||||
}
|
||||
if bundle.Discussion.LongTerm == nil || bundle.Discussion.LongTerm.Text != "Warmer temperatures and periodic rain chances continue into the weekend." {
|
||||
t.Fatalf("Discussion.LongTerm = %#v, want long-term AFD text", bundle.Discussion.LongTerm)
|
||||
}
|
||||
if 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 bundle.SPCConvectiveOutlooks == nil || len(bundle.SPCConvectiveOutlooks.Outlooks) != 1 {
|
||||
t.Fatalf("SPCConvectiveOutlooks = %#v, want one outlook", bundle.SPCConvectiveOutlooks)
|
||||
}
|
||||
if len(bundle.SPCConvectiveOutlooks.Outlooks[0].Geometry) == 0 {
|
||||
t.Fatalf("SPCConvectiveOutlooks.Outlooks[0].Geometry is empty, want GeoJSON")
|
||||
}
|
||||
if len(bundle.SPCConvectiveOutlooks.Discussions) != 1 || bundle.SPCConvectiveOutlooks.Discussions[0].Headline != "Severe storms possible" {
|
||||
t.Fatalf("SPCConvectiveOutlooks.Discussions = %#v, want one discussion", bundle.SPCConvectiveOutlooks.Discussions)
|
||||
}
|
||||
if len(bundle.Sources) != 8 {
|
||||
t.Fatalf("Sources length = %d, want 8", len(bundle.Sources))
|
||||
}
|
||||
if len(bundle.Warnings) != 2 {
|
||||
t.Fatalf("Warnings length = %d, want daily and weather story warnings", len(bundle.Warnings))
|
||||
if len(bundle.Warnings) != 0 {
|
||||
t.Fatalf("Warnings length = %d, want no warnings", len(bundle.Warnings))
|
||||
}
|
||||
wantPaths := []string{
|
||||
"/observations",
|
||||
"/conditions/current",
|
||||
"/forecast/hourly",
|
||||
"/forecast/narrative",
|
||||
"/alerts/active",
|
||||
"/discussion",
|
||||
"/weatherstories/latest",
|
||||
convectiveOutlooksEndpoint,
|
||||
}
|
||||
if len(requested) != len(wantPaths)+1 {
|
||||
t.Fatalf("requested paths = %v, want warmup plus %d source endpoints", requested, len(wantPaths))
|
||||
}
|
||||
if !strings.HasPrefix(requested[0], defaultWarmupEndpoint+"?") && requested[0] != defaultWarmupEndpoint {
|
||||
t.Fatalf("first requested path = %q, want warmup endpoint %s", requested[0], defaultWarmupEndpoint)
|
||||
}
|
||||
for _, want := range wantPaths {
|
||||
if !containsPath(requested, want) {
|
||||
t.Fatalf("requested paths = %v, want %s", requested, want)
|
||||
}
|
||||
}
|
||||
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
|
||||
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
|
||||
@@ -55,6 +97,12 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
||||
if !containsPath(requested, "/forecast/narrative") || containsPath(requested, "/forecast/narrative/today") {
|
||||
t.Fatalf("requested paths = %v, want full narrative endpoint only", requested)
|
||||
}
|
||||
if !containsPath(requested, "/weatherstories/latest") {
|
||||
t.Fatalf("requested paths = %v, want weather story endpoint", requested)
|
||||
}
|
||||
if !containsPath(requested, convectiveOutlooksEndpoint) {
|
||||
t.Fatalf("requested paths = %v, want convective outlook endpoint", requested)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
||||
@@ -68,13 +116,34 @@ func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, rawURL := range requested {
|
||||
if !strings.Contains(rawURL, "format=json") || !strings.Contains(rawURL, "units=us") {
|
||||
t.Fatalf("request %q missing format=json or units=us", rawURL)
|
||||
if !strings.Contains(rawURL, "format=json") {
|
||||
t.Fatalf("request %q missing format=json", rawURL)
|
||||
}
|
||||
if strings.HasPrefix(rawURL, "/weatherstories/") {
|
||||
if strings.Contains(rawURL, "units=") || strings.Contains(rawURL, "precision=") || strings.Contains(rawURL, "tz=") {
|
||||
t.Fatalf("weather story request %q should use format only", rawURL)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(rawURL, convectiveOutlooksEndpoint) {
|
||||
if strings.Contains(rawURL, "units=") || strings.Contains(rawURL, "precision=") || !strings.Contains(rawURL, "tz=America%2FChicago") {
|
||||
t.Fatalf("convective outlook request %q should use format and tz only", rawURL)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(rawURL, "units=us") {
|
||||
t.Fatalf("request %q missing units=us", rawURL)
|
||||
}
|
||||
if strings.HasPrefix(rawURL, "/forecast/") {
|
||||
if !strings.Contains(rawURL, "precision=1") || !strings.Contains(rawURL, "tz=Chicago") {
|
||||
if !strings.Contains(rawURL, "precision=0") || !strings.Contains(rawURL, "tz=America%2FChicago") {
|
||||
t.Fatalf("forecast request %q missing precision or tz", rawURL)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if rawURL == defaultWarmupEndpoint || strings.HasPrefix(rawURL, defaultWarmupEndpoint+"?") || strings.HasPrefix(rawURL, "/observations?") {
|
||||
if !strings.Contains(rawURL, "precision=0") {
|
||||
t.Fatalf("request %q missing precision=0", rawURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,11 +162,40 @@ func TestFetchBundleRecordsSourceHash(t *testing.T) {
|
||||
if observation.DataSHA256 != want {
|
||||
t.Fatalf("DataSHA256 = %q, want %q", observation.DataSHA256, want)
|
||||
}
|
||||
story := sourceByName(t, bundle.Sources, "weather_story")
|
||||
if story.Endpoint != "/weatherstories/latest" {
|
||||
t.Fatalf("weather story endpoint = %q, want /weatherstories/latest", story.Endpoint)
|
||||
}
|
||||
if story.DataSHA256 != hashFixtureData(t, "weather_story.json") {
|
||||
t.Fatalf("weather story DataSHA256 = %q, want fixture hash", story.DataSHA256)
|
||||
}
|
||||
if story.IssuedAt == nil || story.UpdatedAt == nil {
|
||||
t.Fatalf("weather story source timestamps = issued %#v updated %#v, want both", story.IssuedAt, story.UpdatedAt)
|
||||
}
|
||||
outlooks := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
|
||||
if outlooks.Endpoint != convectiveOutlooksEndpoint {
|
||||
t.Fatalf("convective outlook endpoint = %q, want %s", outlooks.Endpoint, convectiveOutlooksEndpoint)
|
||||
}
|
||||
if outlooks.Query["format"] != "json" || outlooks.Query["tz"] != "America/Chicago" || outlooks.Query["units"] != "" || outlooks.Query["precision"] != "" {
|
||||
t.Fatalf("convective outlook query = %#v, want format and tz only", outlooks.Query)
|
||||
}
|
||||
if outlooks.DataSHA256 != hashFixtureData(t, "convective_outlooks.json") {
|
||||
t.Fatalf("convective outlook DataSHA256 = %q, want fixture hash", outlooks.DataSHA256)
|
||||
}
|
||||
if outlooks.Missing {
|
||||
t.Fatal("convective outlook source Missing = true, want false")
|
||||
}
|
||||
if outlooks.IssuedAt == nil || outlooks.IssuedAt.Format(time.RFC3339) != "2026-05-29T15:45:00Z" {
|
||||
t.Fatalf("convective outlook IssuedAt = %#v, want run issuedAt", outlooks.IssuedAt)
|
||||
}
|
||||
if outlooks.UpdatedAt == nil || outlooks.UpdatedAt.Format(time.RFC3339) != "2026-05-29T16:05:00Z" {
|
||||
t.Fatalf("convective outlook UpdatedAt = %#v, want run updatedAt", outlooks.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPErrorIsActionable(t *testing.T) {
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
"/conditions/current": {status: http.StatusBadGateway, body: `upstream failed`},
|
||||
"/forecast/hourly": {status: http.StatusBadGateway, body: `upstream failed`},
|
||||
}, nil)
|
||||
client := newTestClient(t, server.URL+"/", nil)
|
||||
|
||||
@@ -105,15 +203,140 @@ func TestHTTPErrorIsActionable(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("FetchBundle() error = nil, want HTTP error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "/conditions/current") || !strings.Contains(err.Error(), "502") {
|
||||
if !strings.Contains(err.Error(), "/forecast/hourly") || !strings.Contains(err.Error(), "502") {
|
||||
t.Fatalf("error = %q, want endpoint and status", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarmupRetriesBeforeFetchBundle(t *testing.T) {
|
||||
var requested []string
|
||||
var warmupCalls int
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
defaultWarmupEndpoint: {handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
warmupCalls++
|
||||
if warmupCalls == 1 {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte("vpn waking up"))
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, filepath.Join("testdata", "current.json"))
|
||||
}},
|
||||
}, &requested)
|
||||
client := newTestClient(t, server.URL+"/", nil)
|
||||
|
||||
bundle, err := client.FetchBundle(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("FetchBundle() error = %v", err)
|
||||
}
|
||||
if bundle.Current == nil {
|
||||
t.Fatal("Current = nil, want successful fetch after warmup retry")
|
||||
}
|
||||
if warmupCalls != 3 {
|
||||
t.Fatalf("conditions/current calls = %d, want failed warmup, successful warmup, and current source fetch", warmupCalls)
|
||||
}
|
||||
if len(requested) < 2 || !containsPath(requested[:2], defaultWarmupEndpoint) {
|
||||
t.Fatalf("initial requests = %v, want warmup endpoint retries", requested)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarmupFailureStopsBeforeSourceFetches(t *testing.T) {
|
||||
var requested []string
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
defaultWarmupEndpoint: {status: http.StatusBadGateway, body: `vpn unavailable`},
|
||||
}, &requested)
|
||||
client := newTestClient(t, server.URL+"/", nil)
|
||||
client.warmupAttempts = 2
|
||||
|
||||
_, err := client.FetchBundle(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("FetchBundle() error = nil, want warmup failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "warm up weather API") ||
|
||||
!strings.Contains(err.Error(), defaultWarmupEndpoint) ||
|
||||
!strings.Contains(err.Error(), "2 attempts") ||
|
||||
!strings.Contains(err.Error(), "502") {
|
||||
t.Fatalf("error = %q, want warmup endpoint, attempts, and status", err.Error())
|
||||
}
|
||||
if got := countPath(requested, defaultWarmupEndpoint); got != 2 {
|
||||
t.Fatalf("warmup requests = %d, want 2; all requests = %v", got, requested)
|
||||
}
|
||||
if containsPath(requested, "/observations") {
|
||||
t.Fatalf("requested paths = %v, want warmup failure before source fetches", requested)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRetriesRetryableStatus(t *testing.T) {
|
||||
var hourlyCalls int
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
"/forecast/hourly": {handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
hourlyCalls++
|
||||
if hourlyCalls == 1 {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte("temporary upstream failure"))
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, filepath.Join("testdata", "hourly.json"))
|
||||
}},
|
||||
}, nil)
|
||||
client := newTestClient(t, server.URL+"/", nil)
|
||||
|
||||
bundle, err := client.FetchBundle(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("FetchBundle() error = %v", err)
|
||||
}
|
||||
if bundle.Hourly == nil {
|
||||
t.Fatal("Hourly = nil, want successful fetch after retry")
|
||||
}
|
||||
if hourlyCalls != 2 {
|
||||
t.Fatalf("hourly calls = %d, want 2", hourlyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchDoesNotRetryNonRetryableStatus(t *testing.T) {
|
||||
var hourlyCalls int
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
"/forecast/hourly": {handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
hourlyCalls++
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte("not found"))
|
||||
}},
|
||||
}, nil)
|
||||
client := newTestClient(t, server.URL+"/", nil)
|
||||
|
||||
_, err := client.FetchBundle(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("FetchBundle() error = nil, want non-retryable status error")
|
||||
}
|
||||
if hourlyCalls != 1 {
|
||||
t.Fatalf("hourly calls = %d, want no retry", hourlyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchDoesNotRetryMalformedEnvelope(t *testing.T) {
|
||||
var hourlyCalls int
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
"/forecast/hourly": {handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
hourlyCalls++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`not-json`))
|
||||
}},
|
||||
}, nil)
|
||||
client := newTestClient(t, server.URL+"/", nil)
|
||||
|
||||
_, err := client.FetchBundle(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("FetchBundle() error = nil, want envelope decode error")
|
||||
}
|
||||
if hourlyCalls != 1 {
|
||||
t.Fatalf("hourly calls = %d, want no retry", hourlyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiredHourlyForecast(t *testing.T) {
|
||||
var requested []string
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
"/forecast/hourly": {status: http.StatusOK, body: `{"data": null}`},
|
||||
}, nil)
|
||||
}, &requested)
|
||||
client := newTestClient(t, server.URL+"/", nil)
|
||||
|
||||
_, err := client.FetchBundle(context.Background())
|
||||
@@ -123,6 +346,92 @@ func TestRequiredHourlyForecast(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "hourly forecast data") {
|
||||
t.Fatalf("error = %q, want hourly context", err.Error())
|
||||
}
|
||||
if got := countPath(requested, "/forecast/hourly"); got != 1 {
|
||||
t.Fatalf("hourly requests = %d, want no retry; all requests = %v", got, requested)
|
||||
}
|
||||
}
|
||||
|
||||
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 TestMissingSPCConvectiveOutlooksUsesPolicy(t *testing.T) {
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
convectiveOutlooksEndpoint: {status: http.StatusOK, body: `{"data": null}`},
|
||||
}, nil)
|
||||
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
|
||||
sourceSPCConvectiveOutlooks: config.MissingSourceWarn,
|
||||
})
|
||||
|
||||
bundle, err := client.FetchBundle(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("FetchBundle() error = %v", err)
|
||||
}
|
||||
if bundle.SPCConvectiveOutlooks != nil {
|
||||
t.Fatalf("SPCConvectiveOutlooks = %#v, want nil for missing source", bundle.SPCConvectiveOutlooks)
|
||||
}
|
||||
source := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
|
||||
if !source.Missing || len(source.Warnings) != 1 {
|
||||
t.Fatalf("convective outlook source = %#v, want missing source warning", source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptySPCConvectiveOutlooksAreCheckedData(t *testing.T) {
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
convectiveOutlooksEndpoint: {status: http.StatusOK, body: `{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`},
|
||||
}, nil)
|
||||
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
|
||||
sourceSPCConvectiveOutlooks: config.MissingSourceWarn,
|
||||
})
|
||||
|
||||
bundle, err := client.FetchBundle(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("FetchBundle() error = %v", err)
|
||||
}
|
||||
if bundle.SPCConvectiveOutlooks == nil {
|
||||
t.Fatal("SPCConvectiveOutlooks = nil, want checked empty run")
|
||||
}
|
||||
if len(bundle.SPCConvectiveOutlooks.Outlooks) != 0 || len(bundle.SPCConvectiveOutlooks.Discussions) != 0 {
|
||||
t.Fatalf("SPCConvectiveOutlooks = %#v, want empty arrays", bundle.SPCConvectiveOutlooks)
|
||||
}
|
||||
source := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
|
||||
if source.Missing || len(source.Warnings) != 0 {
|
||||
t.Fatalf("convective outlook source = %#v, want non-missing source without warnings", source)
|
||||
}
|
||||
if source.IssuedAt == nil || source.IssuedAt.Format(time.RFC3339) != "2026-05-29T16:00:00Z" {
|
||||
t.Fatalf("convective outlook IssuedAt = %#v, want fallback to asOf", source.IssuedAt)
|
||||
}
|
||||
for _, warning := range bundle.Warnings {
|
||||
if warning.Source == sourceSPCConvectiveOutlooks {
|
||||
t.Fatalf("warnings = %#v, want no convective outlook warning", bundle.Warnings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
|
||||
@@ -133,7 +442,7 @@ func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
|
||||
wantWarns int
|
||||
wantSource bool
|
||||
}{
|
||||
{name: "warn", policy: config.MissingSourceWarn, wantWarns: 3, wantSource: true},
|
||||
{name: "warn", policy: config.MissingSourceWarn, wantWarns: 1, wantSource: true},
|
||||
{name: "none", policy: config.MissingSourceNone, wantWarns: 0, wantSource: true},
|
||||
{name: "error", policy: config.MissingSourceError, wantErr: true},
|
||||
}
|
||||
@@ -194,6 +503,45 @@ func TestMalformedNonRequiredSourceUsesPolicy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingWeatherStoryUsesPolicy(t *testing.T) {
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
"/weatherstories/latest": {status: http.StatusOK, body: `{"data": null}`},
|
||||
}, nil)
|
||||
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
|
||||
"weather_story": config.MissingSourceWarn,
|
||||
})
|
||||
|
||||
bundle, err := client.FetchBundle(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("FetchBundle() error = %v", err)
|
||||
}
|
||||
if bundle.WeatherStory != nil {
|
||||
t.Fatalf("WeatherStory = %#v, want nil for missing source", bundle.WeatherStory)
|
||||
}
|
||||
source := sourceByName(t, bundle.Sources, "weather_story")
|
||||
if !source.Missing || len(source.Warnings) != 1 {
|
||||
t.Fatalf("weather_story source = %#v, want missing source warning", source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedWeatherStoryUsesPolicy(t *testing.T) {
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
"/weatherstories/latest": {status: http.StatusOK, body: `{"data": {"startTime": 123}}`},
|
||||
}, nil)
|
||||
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
|
||||
"weather_story": config.MissingSourceWarn,
|
||||
})
|
||||
|
||||
bundle, err := client.FetchBundle(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("FetchBundle() error = %v", err)
|
||||
}
|
||||
source := sourceByName(t, bundle.Sources, "weather_story")
|
||||
if !source.Missing || len(source.Warnings) != 1 || source.Warnings[0].Code != "malformed_source" {
|
||||
t.Fatalf("weather_story source = %#v, want malformed source warning", source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextCancellation(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done()
|
||||
@@ -209,6 +557,43 @@ func TestContextCancellation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryDelayRespectsContextCancellation(t *testing.T) {
|
||||
var cancel context.CancelFunc
|
||||
var hourlyCalls int
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
"/forecast/hourly": {handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
hourlyCalls++
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte("temporary upstream failure"))
|
||||
}},
|
||||
}, nil)
|
||||
client := newTestClient(t, server.URL+"/", nil)
|
||||
client.fetchRetryDelay = time.Hour
|
||||
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
cancel = cancelFunc
|
||||
defer cancelFunc()
|
||||
|
||||
start := time.Now()
|
||||
_, err := client.FetchBundle(ctx)
|
||||
elapsed := time.Since(start)
|
||||
if err == nil {
|
||||
t.Fatal("FetchBundle() error = nil, want cancellation during retry delay")
|
||||
}
|
||||
if !strings.Contains(err.Error(), context.Canceled.Error()) {
|
||||
t.Fatalf("error = %q, want context cancellation", err.Error())
|
||||
}
|
||||
if elapsed > time.Second {
|
||||
t.Fatalf("FetchBundle() elapsed = %s, want prompt cancellation", elapsed)
|
||||
}
|
||||
if hourlyCalls != 1 {
|
||||
t.Fatalf("hourly calls = %d, want retry delay cancellation before second attempt", hourlyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPTimeout(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
@@ -221,12 +606,14 @@ func TestHTTPTimeout(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
client.warmupDelay = 0
|
||||
client.fetchRetryDelay = 0
|
||||
|
||||
_, err = client.FetchBundle(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("FetchBundle() error = nil, want timeout error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "/observations") {
|
||||
if !strings.Contains(err.Error(), defaultWarmupEndpoint) {
|
||||
t.Fatalf("error = %q, want endpoint context", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -253,25 +640,32 @@ func TestSaveBundle(t *testing.T) {
|
||||
}
|
||||
|
||||
type handlerOverride struct {
|
||||
status int
|
||||
body string
|
||||
status int
|
||||
body string
|
||||
handler http.HandlerFunc
|
||||
}
|
||||
|
||||
func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server {
|
||||
t.Helper()
|
||||
fixtures := map[string]string{
|
||||
"/observations": "observations.json",
|
||||
"/conditions/current": "current.json",
|
||||
"/forecast/hourly": "hourly.json",
|
||||
"/forecast/narrative": "narrative.json",
|
||||
"/alerts/active": "alerts.json",
|
||||
"/discussion": "discussion.json",
|
||||
"/observations": "observations.json",
|
||||
"/conditions/current": "current.json",
|
||||
"/forecast/hourly": "hourly.json",
|
||||
"/forecast/narrative": "narrative.json",
|
||||
"/alerts/active": "alerts.json",
|
||||
"/discussion": "discussion.json",
|
||||
"/weatherstories/latest": "weather_story.json",
|
||||
convectiveOutlooksEndpoint: "convective_outlooks.json",
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if requested != nil {
|
||||
*requested = append(*requested, r.URL.String())
|
||||
}
|
||||
if override, ok := overrides[r.URL.Path]; ok {
|
||||
if override.handler != nil {
|
||||
override.handler(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(override.status)
|
||||
_, _ = w.Write([]byte(override.body))
|
||||
return
|
||||
@@ -297,6 +691,8 @@ func newTestClient(t *testing.T, baseURL string, sourcePolicies map[string]confi
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
client.warmupDelay = 0
|
||||
client.fetchRetryDelay = 0
|
||||
return client
|
||||
}
|
||||
|
||||
@@ -319,7 +715,17 @@ func containsPath(requested []string, path string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func sourceByName(t *testing.T, sources []forecast.Source, name string) forecast.Source {
|
||||
func countPath(requested []string, path string) int {
|
||||
var count int
|
||||
for _, rawURL := range requested {
|
||||
if strings.HasPrefix(rawURL, path+"?") || rawURL == path {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func sourceByName(t *testing.T, sources []weatherdata.Source, name string) weatherdata.Source {
|
||||
t.Helper()
|
||||
for _, source := range sources {
|
||||
if source.Name == name {
|
||||
@@ -327,7 +733,7 @@ func sourceByName(t *testing.T, sources []forecast.Source, name string) forecast
|
||||
}
|
||||
}
|
||||
t.Fatalf("source %q not found in %#v", name, sources)
|
||||
return forecast.Source{}
|
||||
return weatherdata.Source{}
|
||||
}
|
||||
|
||||
func hashFixtureData(t *testing.T, fixture string) string {
|
||||
|
||||
50
internal/adapters/weatherapi/testdata/convective_outlooks.json
vendored
Normal file
50
internal/adapters/weatherapi/testdata/convective_outlooks.json
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"data": {
|
||||
"locationId": "nws-lsx-grid-90-74",
|
||||
"locationName": "St. Louis, MO",
|
||||
"asOf": "2026-05-29T16:00:00Z",
|
||||
"issuedAt": "2026-05-29T15:45:00Z",
|
||||
"updatedAt": "2026-05-29T16:05:00Z",
|
||||
"product": "convective_outlook",
|
||||
"outlooks": [
|
||||
{
|
||||
"id": "day1-categorical-slight",
|
||||
"provider": "spc",
|
||||
"product": "convective_outlook",
|
||||
"day": 1,
|
||||
"outlookType": "categorical",
|
||||
"label": "SLGT",
|
||||
"labelText": "Slight Risk",
|
||||
"forecaster": "Smith",
|
||||
"severityRank": 3,
|
||||
"validFrom": "2026-05-29T13:00:00-05:00",
|
||||
"validTo": "2026-05-30T07:00:00-05:00",
|
||||
"issuedAt": "2026-05-29T15:45:00Z",
|
||||
"expiresAt": "2026-05-30T07:00:00-05:00",
|
||||
"sourceUrl": "https://www.spc.noaa.gov/products/outlook/day1otlk.html",
|
||||
"imageUrl": "https://www.spc.noaa.gov/products/outlook/day1probotlk_2000_torn.gif",
|
||||
"containsLocation": true,
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [
|
||||
[
|
||||
[-91.0, 38.0],
|
||||
[-90.0, 38.5],
|
||||
[-89.5, 37.8],
|
||||
[-91.0, 38.0]
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"discussions": [
|
||||
{
|
||||
"day": 1,
|
||||
"headline": "Severe storms possible",
|
||||
"summary": "Scattered severe storms are possible.",
|
||||
"discussion": "A few storms may become severe during the afternoon.",
|
||||
"updatedAt": "2026-05-29T16:05:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,12 @@
|
||||
"Warmer temperatures this weekend."
|
||||
],
|
||||
"shortTerm": {
|
||||
"title": "Short Term",
|
||||
"narrative": "A weak boundary may trigger isolated showers."
|
||||
"qualifier": "(Through This Evening)",
|
||||
"text": "A weak boundary may trigger isolated showers."
|
||||
},
|
||||
"longTerm": {
|
||||
"qualifier": "(This Weekend)",
|
||||
"text": "Warmer temperatures and periodic rain chances continue into the weekend."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
14
internal/adapters/weatherapi/testdata/weather_story.json
vendored
Normal file
14
internal/adapters/weatherapi/testdata/weather_story.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"data": {
|
||||
"officeId": "LSX",
|
||||
"startTime": "2026-05-30T08:46:00Z",
|
||||
"endTime": "2026-05-31T11:00:00Z",
|
||||
"updatedAt": "2026-05-30T09:00:34Z",
|
||||
"title": "Several Chances for Rain Through Monday",
|
||||
"description": "A stagnant weather pattern with low pressure over the Great Plains and high pressure over the Great Lakes will continue to produce scattered showers and thunderstorms, for areas mainly along and west of the Mississippi River today and Sunday.",
|
||||
"altText": "This slide shows the forecast for today through Tuesday with icons for showers and thunderstorms and a picture of a cumulonimbus cloud on the right side.",
|
||||
"priority": false,
|
||||
"order": 1,
|
||||
"downloadUrl": "https://api.weather.gov/offices/LSX/weatherstories/download/3228e499-2aae-45a8-9ff9-1c060311026f"
|
||||
}
|
||||
}
|
||||
1108
internal/app/app.go
1108
internal/app/app.go
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
236
internal/app/batch_generation_test.go
Normal file
236
internal/app/batch_generation_test.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
)
|
||||
|
||||
func TestRunBatchDetailedKeepsSuccessfulOutputAndSkipsNotificationAfterPartialFailure(t *testing.T) {
|
||||
bundle := generationBundle(t)
|
||||
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
||||
notifier := &generationNotifier{}
|
||||
executor := &generationExecutor{failedPrompt: generationDefinitionForPrompt("weather.tomorrow_generated_text").PromptID}
|
||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||
Config: generationDistributorConfig(), Batch: BatchMorning,
|
||||
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
|
||||
Collector: &generationCollector{bundle: &bundle}, Executor: executor, Notifier: notifier,
|
||||
})
|
||||
if err != nil || result == nil || result.Total != 2 || result.Succeeded != 1 || result.Failed != 1 || result.Notification == nil || result.Notification.Status != "skipped" || notifier.batchCalls != 0 {
|
||||
t.Fatalf("RunBatchDetailed() result/error/notifier = %#v/%v/%#v", result, err, notifier)
|
||||
}
|
||||
if result.Reports[0].Status != "succeeded" || result.Reports[0].OutputPath == "" || result.Reports[1].Status != "failed" || result.Reports[1].OutputPath != "" {
|
||||
t.Fatalf("report results = %#v", result.Reports)
|
||||
}
|
||||
if data, readErr := os.ReadFile(result.Reports[0].OutputPath); readErr != nil || len(data) == 0 {
|
||||
t.Fatalf("successful output = %q, error = %v", data, readErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatchDetailedNotifiesOnlyAfterAllOutputsExist(t *testing.T) {
|
||||
bundle := generationBundle(t)
|
||||
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
||||
outputDir := t.TempDir()
|
||||
notifier := &generationNotifier{}
|
||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||
Config: generationDistributorConfig(), Batch: BatchMorning,
|
||||
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: outputDir,
|
||||
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier,
|
||||
})
|
||||
if err != nil || result == nil || result.Total != 2 || result.Succeeded != 2 || result.Failed != 0 || notifier.batchCalls != 1 || result.Notification == nil || result.Notification.Status != "succeeded" {
|
||||
t.Fatalf("RunBatchDetailed() result/error/notifier = %#v/%v/%#v", result, err, notifier)
|
||||
}
|
||||
if len(notifier.batchRequest.Files) < 2 || len(notifier.batchRequest.IncludedReports) != 2 {
|
||||
t.Fatalf("batch notification = %#v", notifier.batchRequest)
|
||||
}
|
||||
if result.Reports[0].OutputPath == result.Reports[1].OutputPath {
|
||||
t.Fatalf("batch reports share output path %q", result.Reports[0].OutputPath)
|
||||
}
|
||||
for _, file := range notifier.batchRequest.Files {
|
||||
if filepath.Dir(file.SourcePath) != outputDir || file.BundlePath == "" {
|
||||
t.Fatalf("notification file = %#v", file)
|
||||
}
|
||||
if _, statErr := os.Stat(file.SourcePath); statErr != nil {
|
||||
t.Fatalf("notification source %q: %v", file.SourcePath, statErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatchDetailedUsesDefaultAndConfiguredOutputDirectories(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
directory func(t *testing.T, workingDir string) string
|
||||
wantDir func(t *testing.T, workingDir string, configuredDir string) string
|
||||
}{
|
||||
{
|
||||
name: "working directory default",
|
||||
directory: func(_ *testing.T, _ string) string {
|
||||
return ""
|
||||
},
|
||||
wantDir: func(_ *testing.T, workingDir string, _ string) string {
|
||||
return workingDir
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "absolute directory",
|
||||
directory: func(t *testing.T, _ string) string {
|
||||
return filepath.Join(t.TempDir(), "reports")
|
||||
},
|
||||
wantDir: func(_ *testing.T, _ string, configuredDir string) string {
|
||||
return configuredDir
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "relative directory",
|
||||
directory: func(_ *testing.T, _ string) string {
|
||||
return "configured/../reports"
|
||||
},
|
||||
wantDir: func(_ *testing.T, workingDir string, _ string) string {
|
||||
return filepath.Join(workingDir, "reports")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
workingDir := t.TempDir()
|
||||
configuredDir := tt.directory(t, workingDir)
|
||||
cfg := generationDistributorConfig()
|
||||
cfg.Output.Directory = configuredDir
|
||||
bundle := generationBundle(t)
|
||||
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
||||
notifier := &generationNotifier{}
|
||||
|
||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||
Config: cfg, Batch: BatchMorning,
|
||||
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: workingDir,
|
||||
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier,
|
||||
})
|
||||
wantDir := tt.wantDir(t, workingDir, configuredDir)
|
||||
if err != nil || result == nil || result.Succeeded != len(result.Reports) || notifier.batchCalls != 1 {
|
||||
t.Fatalf("RunBatchDetailed() result/error/notifier = %#v/%v/%#v", result, err, notifier)
|
||||
}
|
||||
for _, item := range result.Reports {
|
||||
if filepath.Dir(item.OutputPath) != wantDir {
|
||||
t.Fatalf("report output %q, want directory %q", item.OutputPath, wantDir)
|
||||
}
|
||||
}
|
||||
for _, file := range notifier.batchRequest.Files {
|
||||
if filepath.Dir(file.SourcePath) != wantDir {
|
||||
t.Fatalf("notification source %q, want directory %q", file.SourcePath, wantDir)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatchDetailedExplicitOutputDirectoryIgnoresConfiguredDirectory(t *testing.T) {
|
||||
configuredPath := filepath.Join(t.TempDir(), "not-a-directory")
|
||||
if err := os.WriteFile(configuredPath, []byte("not a directory"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explicitDir := t.TempDir()
|
||||
cfg := generationDistributorConfig()
|
||||
cfg.Output.Directory = configuredPath
|
||||
bundle := generationBundle(t)
|
||||
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
||||
|
||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||
Config: cfg, Batch: BatchMorning,
|
||||
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: explicitDir,
|
||||
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: &generationNotifier{},
|
||||
})
|
||||
if err != nil || result == nil || result.Succeeded != len(result.Reports) {
|
||||
t.Fatalf("RunBatchDetailed() result/error = %#v/%v", result, err)
|
||||
}
|
||||
for _, item := range result.Reports {
|
||||
if filepath.Dir(item.OutputPath) != explicitDir {
|
||||
t.Fatalf("report output %q, want directory %q", item.OutputPath, explicitDir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatchDetailedPreflightsAllOutputPaths(t *testing.T) {
|
||||
bundle := generationBundle(t)
|
||||
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
||||
outputDir := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(outputDir, "tomorrow.md"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
todayPath := filepath.Join(outputDir, "today.md")
|
||||
const previousReport = "previous report"
|
||||
if err := os.WriteFile(todayPath, []byte(previousReport), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
executor := &generationExecutor{}
|
||||
promptInspectedBeforeCollection := false
|
||||
collector := &generationCollector{
|
||||
bundle: &bundle,
|
||||
beforeRun: func() {
|
||||
promptInspectedBeforeCollection = executor.promptInspections > 0
|
||||
},
|
||||
}
|
||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||
Config: generationDistributorConfig(), Batch: BatchMorning,
|
||||
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: outputDir,
|
||||
Collector: collector, Executor: executor, Notifier: &generationNotifier{},
|
||||
})
|
||||
if err == nil || result != nil || !collector.called || !promptInspectedBeforeCollection || executor.called {
|
||||
t.Fatalf("RunBatchDetailed() result/error/collection/inspection/execution = %#v/%v/%t/%t/%t", result, err, collector.called, promptInspectedBeforeCollection, executor.called)
|
||||
}
|
||||
if data, readErr := os.ReadFile(todayPath); readErr != nil || string(data) != previousReport {
|
||||
t.Fatalf("earlier output = %q, error = %v", data, readErr)
|
||||
}
|
||||
if info, statErr := os.Stat(filepath.Join(outputDir, "tomorrow.md")); statErr != nil || !info.IsDir() {
|
||||
t.Fatalf("blocked output info/error = %#v/%v", info, statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatchDetailedRetainsReportCountsWhenNotificationFails(t *testing.T) {
|
||||
bundle := generationBundle(t)
|
||||
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
||||
outputDir := t.TempDir()
|
||||
notifier := &generationNotifier{batchErr: errors.New("distributor unavailable")}
|
||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||
Config: generationDistributorConfig(), Batch: BatchMorning,
|
||||
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: outputDir,
|
||||
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier,
|
||||
})
|
||||
if err != nil || result == nil || result.Total != len(result.Reports) || result.Succeeded != len(result.Reports) || result.Failed != 0 || result.Notification == nil || result.Notification.Status != "failed" {
|
||||
t.Fatalf("RunBatchDetailed() result/error = %#v/%v", result, err)
|
||||
}
|
||||
for _, item := range result.Reports {
|
||||
if item.Status != "succeeded" || item.OutputPath == "" {
|
||||
t.Fatalf("report result = %#v", item)
|
||||
}
|
||||
if _, statErr := os.Stat(item.OutputPath); statErr != nil {
|
||||
t.Fatalf("published output %q: %v", item.OutputPath, statErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatchReturnsNotificationFailureWithoutReportFailureWording(t *testing.T) {
|
||||
bundle := generationBundle(t)
|
||||
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
||||
err := RunBatch(context.Background(), BatchRequest{
|
||||
Config: generationDistributorConfig(), Batch: BatchMorning,
|
||||
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
|
||||
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: &generationNotifier{batchErr: errors.New("distributor unavailable")},
|
||||
})
|
||||
var batchErr BatchError
|
||||
if !errors.As(err, &batchErr) || batchErr.Result == nil || batchErr.Result.Failed != 0 || batchErr.Result.Notification == nil || batchErr.Result.Notification.Status != "failed" || !strings.Contains(err.Error(), "notification failed") || strings.Contains(err.Error(), "reports failed") {
|
||||
t.Fatalf("RunBatch() error/result = %v/%#v", err, batchErr.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func generationDistributorConfig() config.Config {
|
||||
cfg := generationConfig()
|
||||
cfg.Notify.Distributor.Enabled = true
|
||||
cfg.Notify.Distributor.PipelineIDTemplate = "weather"
|
||||
return cfg
|
||||
}
|
||||
293
internal/app/batch_notification.go
Normal file
293
internal/app/batch_notification.go
Normal file
@@ -0,0 +1,293 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
const runIDTimestampLayout = "20060102T150405.000000000Z"
|
||||
|
||||
type batchNotificationIdentity struct {
|
||||
PipelineID string
|
||||
BundleID string
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type batchNotificationRequest struct {
|
||||
Batch BatchKind
|
||||
RunID string
|
||||
PipelineID string
|
||||
BundleID string
|
||||
IdempotencyKey string
|
||||
Files []batchNotificationFile
|
||||
IncludedReports []BatchNotificationReport
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type batchNotificationFile struct {
|
||||
ReportID report.ID
|
||||
RunID string
|
||||
SourcePath string
|
||||
BundlePath string
|
||||
}
|
||||
|
||||
type batchNotifier interface {
|
||||
NotifyBatch(context.Context, batchNotificationRequest) (*NotificationResult, error)
|
||||
}
|
||||
|
||||
func batchRunID(startedAt time.Time, batch BatchKind) string {
|
||||
return startedAt.UTC().Format(runIDTimestampLayout) + "_" + string(batch)
|
||||
}
|
||||
|
||||
func notifyBatch(ctx context.Context, cfg config.Config, batch BatchKind, runID string, startedAt time.Time, result *BatchResult, planned []plannedBatchReport, notifier Notifier) *BatchNotificationResult {
|
||||
if !cfg.Notify.Distributor.Enabled {
|
||||
return nil
|
||||
}
|
||||
if !cfg.Notify.Distributor.Batch.Enabled {
|
||||
return nil
|
||||
}
|
||||
if result == nil {
|
||||
return failedBatchNotificationResult(batchNotificationRequest{}, fmt.Errorf("batch result is required"))
|
||||
}
|
||||
if result.Failed > 0 {
|
||||
return &BatchNotificationResult{
|
||||
Status: "skipped",
|
||||
Reason: "one or more reports failed",
|
||||
}
|
||||
}
|
||||
|
||||
req, err := buildBatchNotificationRequest(cfg, batch, runID, startedAt, result.Reports, planned)
|
||||
if err != nil {
|
||||
return failedBatchNotificationResult(batchNotificationRequest{}, err)
|
||||
}
|
||||
|
||||
batchNotifier, err := resolveBatchNotifier(cfg, notifier)
|
||||
if err != nil {
|
||||
return failedBatchNotificationResult(req, err)
|
||||
}
|
||||
|
||||
notification, notifyErr := batchNotifier.NotifyBatch(ctx, req)
|
||||
wrappedErr := notifyErr
|
||||
if notifyErr != nil {
|
||||
wrappedErr = fmt.Errorf("notify batch %q run %q bundle %q: %w", batch, runID, req.BundleID, notifyErr)
|
||||
}
|
||||
batchResult := batchNotificationResult(req, notification)
|
||||
if wrappedErr != nil {
|
||||
batchResult.Status = "failed"
|
||||
batchResult.Error = wrappedErr.Error()
|
||||
return batchResult
|
||||
}
|
||||
return batchResult
|
||||
}
|
||||
|
||||
func resolveBatchNotifier(cfg config.Config, notifier Notifier) (batchNotifier, error) {
|
||||
if notifier != nil {
|
||||
if batchNotifier, ok := notifier.(batchNotifier); ok {
|
||||
return batchNotifier, nil
|
||||
}
|
||||
return nil, fmt.Errorf("batch distributor notifier is required")
|
||||
}
|
||||
return distributorNotifier{
|
||||
client: distributoradapter.New(cfg.Notify.Distributor),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildBatchNotificationRequest(cfg config.Config, batch BatchKind, runID string, startedAt time.Time, reports []BatchReportResult, planned []plannedBatchReport) (batchNotificationRequest, error) {
|
||||
if len(reports) == 0 {
|
||||
return batchNotificationRequest{}, fmt.Errorf("batch notification requires at least one report")
|
||||
}
|
||||
|
||||
identity, err := renderBatchNotificationIdentity(cfg, batch, runID, startedAt)
|
||||
if err != nil {
|
||||
return batchNotificationRequest{}, err
|
||||
}
|
||||
if identity.PipelineID == "" {
|
||||
return batchNotificationRequest{}, fmt.Errorf("batch notification pipeline id is required")
|
||||
}
|
||||
if identity.BundleID == "" {
|
||||
return batchNotificationRequest{}, fmt.Errorf("batch notification bundle id is required")
|
||||
}
|
||||
if identity.IdempotencyKey == "" {
|
||||
return batchNotificationRequest{}, fmt.Errorf("batch notification idempotency key is required for bundle %q", identity.BundleID)
|
||||
}
|
||||
|
||||
plannedByRunID, err := plannedReportsByRunID(planned)
|
||||
if err != nil {
|
||||
return batchNotificationRequest{}, err
|
||||
}
|
||||
|
||||
req := batchNotificationRequest{
|
||||
Batch: batch,
|
||||
RunID: runID,
|
||||
PipelineID: identity.PipelineID,
|
||||
BundleID: identity.BundleID,
|
||||
IdempotencyKey: identity.IdempotencyKey,
|
||||
CreatedAt: startedAt,
|
||||
}
|
||||
seenBundlePaths := map[string]batchNotificationFile{}
|
||||
for _, item := range reports {
|
||||
plannedReport, ok := plannedByRunID[item.RunID]
|
||||
if !ok {
|
||||
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q has no matching planned report", item.ReportID, item.RunID)
|
||||
}
|
||||
if item.ReportID != plannedReport.Resolved.Definition.ID {
|
||||
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q does not match planned report %q", item.ReportID, item.RunID, plannedReport.Resolved.Definition.ID)
|
||||
}
|
||||
if item.OutputPath == "" {
|
||||
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q is missing output path", item.ReportID, item.RunID)
|
||||
}
|
||||
|
||||
values, err := distributorTemplateValuesForReport(cfg, plannedReport.Resolved, item.RunID, filepath.Base(item.OutputPath))
|
||||
if err != nil {
|
||||
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q source path %q: %w", item.ReportID, item.RunID, item.OutputPath, err)
|
||||
}
|
||||
bundlePaths, err := renderDistributorReportBundlePaths(cfg, plannedReport.Resolved, item.RunID, item.OutputPath, values)
|
||||
if err != nil {
|
||||
return batchNotificationRequest{}, err
|
||||
}
|
||||
|
||||
included := BatchNotificationReport{
|
||||
ReportID: item.ReportID,
|
||||
RunID: item.RunID,
|
||||
SourcePath: item.OutputPath,
|
||||
BundlePaths: append([]string(nil), bundlePaths...),
|
||||
}
|
||||
for _, bundlePath := range bundlePaths {
|
||||
file := batchNotificationFile{
|
||||
ReportID: item.ReportID,
|
||||
RunID: item.RunID,
|
||||
SourcePath: item.OutputPath,
|
||||
BundlePath: bundlePath,
|
||||
}
|
||||
if previous, ok := seenBundlePaths[bundlePath]; ok {
|
||||
return batchNotificationRequest{}, fmt.Errorf("batch notification duplicate bundle path %q for report %q run %q source path %q; already used by report %q run %q source path %q", bundlePath, item.ReportID, item.RunID, item.OutputPath, previous.ReportID, previous.RunID, previous.SourcePath)
|
||||
}
|
||||
seenBundlePaths[bundlePath] = file
|
||||
req.Files = append(req.Files, file)
|
||||
}
|
||||
req.IncludedReports = append(req.IncludedReports, included)
|
||||
}
|
||||
if len(req.Files) == 0 {
|
||||
return batchNotificationRequest{}, fmt.Errorf("batch notification requires at least one file mapping")
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func plannedReportsByRunID(planned []plannedBatchReport) (map[string]plannedBatchReport, error) {
|
||||
byRunID := make(map[string]plannedBatchReport, len(planned))
|
||||
for _, item := range planned {
|
||||
runID := item.Resolved.Metadata().RunID
|
||||
if runID == "" {
|
||||
return nil, fmt.Errorf("planned report %q has empty run id", item.Resolved.Definition.ID)
|
||||
}
|
||||
if previous, ok := byRunID[runID]; ok {
|
||||
return nil, fmt.Errorf("planned reports %q and %q share run id %q", previous.Resolved.Definition.ID, item.Resolved.Definition.ID, runID)
|
||||
}
|
||||
byRunID[runID] = item
|
||||
}
|
||||
return byRunID, nil
|
||||
}
|
||||
|
||||
func batchDistributorUploadRequest(req batchNotificationRequest) distributoradapter.UploadRequest {
|
||||
files := make([]distributoradapter.UploadFile, 0, len(req.Files))
|
||||
for _, file := range req.Files {
|
||||
files = append(files, distributoradapter.UploadFile{
|
||||
SourcePath: file.SourcePath,
|
||||
BundlePath: file.BundlePath,
|
||||
})
|
||||
}
|
||||
return distributoradapter.UploadRequest{
|
||||
PipelineID: req.PipelineID,
|
||||
BundleID: req.BundleID,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
Files: files,
|
||||
CreatedAt: req.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func batchNotificationResult(req batchNotificationRequest, result *NotificationResult) *BatchNotificationResult {
|
||||
notification := &BatchNotificationResult{
|
||||
Status: "unknown",
|
||||
PipelineID: req.PipelineID,
|
||||
BundleID: req.BundleID,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
IncludedReports: append([]BatchNotificationReport(nil), req.IncludedReports...),
|
||||
}
|
||||
if result != nil {
|
||||
notification.Status = result.Status
|
||||
notification.RunID = result.RunID
|
||||
if result.PipelineID != "" {
|
||||
notification.PipelineID = result.PipelineID
|
||||
}
|
||||
if result.BundleID != "" {
|
||||
notification.BundleID = result.BundleID
|
||||
}
|
||||
if result.IdempotencyKey != "" {
|
||||
notification.IdempotencyKey = result.IdempotencyKey
|
||||
}
|
||||
if result.Error != "" {
|
||||
notification.Error = result.Error
|
||||
}
|
||||
}
|
||||
if notification.Status == "" {
|
||||
notification.Status = "unknown"
|
||||
}
|
||||
return notification
|
||||
}
|
||||
|
||||
func failedBatchNotificationResult(req batchNotificationRequest, err error) *BatchNotificationResult {
|
||||
notification := batchNotificationResult(req, nil)
|
||||
notification.Status = "failed"
|
||||
if err != nil {
|
||||
notification.Error = err.Error()
|
||||
}
|
||||
return notification
|
||||
}
|
||||
|
||||
func renderBatchNotificationIdentity(cfg config.Config, batch BatchKind, runID string, startedAt time.Time) (batchNotificationIdentity, error) {
|
||||
values, err := batchNotificationTemplateValues(cfg, batch, runID, startedAt)
|
||||
if err != nil {
|
||||
return batchNotificationIdentity{}, err
|
||||
}
|
||||
|
||||
bundleID, err := config.RenderDistributorBatchBundleID(cfg.Notify.Distributor.Batch.BundleIDTemplate, values)
|
||||
if err != nil {
|
||||
return batchNotificationIdentity{}, err
|
||||
}
|
||||
values.BundleID = bundleID
|
||||
|
||||
pipelineID, err := config.RenderDistributorBatchPipelineID(cfg.Notify.Distributor.Batch.PipelineIDTemplate, values)
|
||||
if err != nil {
|
||||
return batchNotificationIdentity{}, err
|
||||
}
|
||||
idempotencyKey, err := config.RenderDistributorBatchIdempotencyKey(cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate, values)
|
||||
if err != nil {
|
||||
return batchNotificationIdentity{}, err
|
||||
}
|
||||
|
||||
return batchNotificationIdentity{
|
||||
PipelineID: pipelineID,
|
||||
BundleID: bundleID,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func batchNotificationTemplateValues(cfg config.Config, batch BatchKind, runID string, startedAt time.Time) (config.DistributorBatchTemplateValues, error) {
|
||||
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
|
||||
if err != nil {
|
||||
return config.DistributorBatchTemplateValues{}, fmt.Errorf("load batch notification timezone: %w", err)
|
||||
}
|
||||
return config.DistributorBatchTemplateValues{
|
||||
LocationID: cfg.Location.ID,
|
||||
Batch: string(batch),
|
||||
BatchRunID: runID,
|
||||
BatchStartedDate: startedAt.In(location).Format(timeutil.DateLayout),
|
||||
}, nil
|
||||
}
|
||||
135
internal/app/batch_plan.go
Normal file
135
internal/app/batch_plan.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type plannedBatchReport struct {
|
||||
Resolved report.Resolved
|
||||
OutputPath string
|
||||
}
|
||||
|
||||
func planBatchRun(req BatchRequest, now time.Time, collection collect.Result) ([]plannedBatchReport, error) {
|
||||
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
batch, err := report.BatchForCommandName(string(req.Batch))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
registry, err := reportRegistry(req.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resolveReq := report.ResolveRequest{
|
||||
Now: now,
|
||||
Location: location,
|
||||
}
|
||||
var planned []plannedBatchReport
|
||||
switch batch {
|
||||
case report.Morning:
|
||||
planned, err = appendPlannedReport(planned, registry, report.Today, resolveReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case report.Evening:
|
||||
planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown batch %q", batch)
|
||||
}
|
||||
|
||||
var hourly *weatherdata.ForecastRun
|
||||
if collection.Bundle != nil {
|
||||
hourly = collection.Bundle.Hourly
|
||||
}
|
||||
for _, date := range eligibleDailyDates(hourly, now, location) {
|
||||
dailyReq := resolveReq
|
||||
dailyReq.Date = date
|
||||
planned, err = appendPlannedReport(planned, registry, report.Daily, dailyReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return planned, nil
|
||||
}
|
||||
|
||||
func appendPlannedReport(planned []plannedBatchReport, registry report.Registry, id report.ID, req report.ResolveRequest) ([]plannedBatchReport, error) {
|
||||
resolved, err := registry.Resolve(id, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(planned, plannedBatchReport{Resolved: resolved}), nil
|
||||
}
|
||||
|
||||
func eligibleDailyDates(hourly *weatherdata.ForecastRun, now time.Time, location *time.Location) []time.Time {
|
||||
if hourly == nil || location == nil || hourly.Product != "hourly" || len(hourly.Periods) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
hourlyStarts := make(map[time.Time]struct{}, len(hourly.Periods))
|
||||
var maxLocalDate time.Time
|
||||
for _, period := range hourly.Periods {
|
||||
if !isHourlyPeriod(period) {
|
||||
continue
|
||||
}
|
||||
start := period.StartTime
|
||||
hourlyStarts[instantKey(start)] = struct{}{}
|
||||
localDate := localDateStart(start, location)
|
||||
if maxLocalDate.IsZero() || localDate.After(maxLocalDate) {
|
||||
maxLocalDate = localDate
|
||||
}
|
||||
}
|
||||
if len(hourlyStarts) == 0 || maxLocalDate.IsZero() {
|
||||
return nil
|
||||
}
|
||||
|
||||
startDate := localDateStart(now.In(location).AddDate(0, 0, 2), location)
|
||||
var dates []time.Time
|
||||
for candidate := startDate; !candidate.After(maxLocalDate); candidate = candidate.AddDate(0, 0, 1) {
|
||||
if hasFullHourlyCoverage(candidate, location, hourlyStarts) {
|
||||
dates = append(dates, candidate)
|
||||
}
|
||||
}
|
||||
return dates
|
||||
}
|
||||
|
||||
func isHourlyPeriod(period weatherdata.ForecastPeriod) bool {
|
||||
if period.StartTime.IsZero() || period.EndTime.IsZero() {
|
||||
return false
|
||||
}
|
||||
return period.EndTime.Equal(period.StartTime.Add(time.Hour))
|
||||
}
|
||||
|
||||
func hasFullHourlyCoverage(date time.Time, location *time.Location, hourlyStarts map[time.Time]struct{}) bool {
|
||||
day := timeutil.CivilDay(date, location)
|
||||
for required := day.Start; required.Before(day.End); required = required.Add(time.Hour) {
|
||||
if _, ok := hourlyStarts[instantKey(required)]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func instantKey(value time.Time) time.Time {
|
||||
return value.UTC()
|
||||
}
|
||||
|
||||
func localDateStart(value time.Time, location *time.Location) time.Time {
|
||||
local := value.In(location)
|
||||
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location)
|
||||
}
|
||||
347
internal/app/batch_plan_test.go
Normal file
347
internal/app/batch_plan_test.go
Normal file
@@ -0,0 +1,347 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||
"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/weatherdata"
|
||||
)
|
||||
|
||||
func TestPlanBatchRunMorningOrder(t *testing.T) {
|
||||
location := mustLoadTestLocation(t, "America/Chicago")
|
||||
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
|
||||
|
||||
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchMorning}, mustParse("2026-05-29T08:00:00-05:00"), collectionWithHourly(hourly))
|
||||
if err != nil {
|
||||
t.Fatalf("planBatchRun() error = %v", err)
|
||||
}
|
||||
|
||||
assertPlannedReportIDs(t, planned, report.Today, report.Tomorrow, report.Daily)
|
||||
}
|
||||
|
||||
func TestPlanBatchRunEveningOrder(t *testing.T) {
|
||||
location := mustLoadTestLocation(t, "America/Chicago")
|
||||
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
|
||||
|
||||
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchEvening}, mustParse("2026-05-29T18:00:00-05:00"), collectionWithHourly(hourly))
|
||||
if err != nil {
|
||||
t.Fatalf("planBatchRun() error = %v", err)
|
||||
}
|
||||
|
||||
assertPlannedReportIDs(t, planned, report.Tomorrow, report.Daily)
|
||||
}
|
||||
|
||||
func TestPlanBatchRunDynamicDailyDatesStartAfterTomorrow(t *testing.T) {
|
||||
location := mustLoadTestLocation(t, "America/Chicago")
|
||||
periods := fullDayPeriods(t, "2026-05-30", location)
|
||||
periods = append(periods, fullDayPeriods(t, "2026-05-31", location)...)
|
||||
periods = append(periods, fullDayPeriods(t, "2026-06-01", location)...)
|
||||
|
||||
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchMorning}, mustParse("2026-05-29T08:00:00-05:00"), collectionWithHourly(hourlyRun(periods...)))
|
||||
if err != nil {
|
||||
t.Fatalf("planBatchRun() error = %v", err)
|
||||
}
|
||||
|
||||
daily := plannedDailyReports(planned)
|
||||
if len(daily) != 2 {
|
||||
t.Fatalf("daily reports = %#v, want two future Daily reports", daily)
|
||||
}
|
||||
assertPlanningPeriod(t, daily[0].Resolved.ValidPeriod, "2026-05-31T00:00:00-05:00", "2026-06-01T00:00:00-05:00")
|
||||
assertPlanningPeriod(t, daily[1].Resolved.ValidPeriod, "2026-06-01T00:00:00-05:00", "2026-06-02T00:00:00-05:00")
|
||||
}
|
||||
|
||||
func TestPlanBatchRunUsesResolvedOutputNames(t *testing.T) {
|
||||
location := mustLoadTestLocation(t, "America/Chicago")
|
||||
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
|
||||
|
||||
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchEvening}, mustParse("2026-05-29T18:00:00-05:00"), collectionWithHourly(hourly))
|
||||
if err != nil {
|
||||
t.Fatalf("planBatchRun() error = %v", err)
|
||||
}
|
||||
|
||||
daily := plannedDailyReports(planned)
|
||||
if len(daily) != 1 {
|
||||
t.Fatalf("daily reports = %#v, want one Daily report", daily)
|
||||
}
|
||||
outputName, err := daily[0].Resolved.OutputName()
|
||||
if err != nil {
|
||||
t.Fatalf("OutputName() error = %v", err)
|
||||
}
|
||||
if outputName != "daily-2026-05-31.md" {
|
||||
t.Fatalf("Daily output name = %q, want date-qualified name", outputName)
|
||||
}
|
||||
outputName, err = planned[0].Resolved.OutputName()
|
||||
if err != nil {
|
||||
t.Fatalf("OutputName() error = %v", err)
|
||||
}
|
||||
if outputName != "tomorrow.md" {
|
||||
t.Fatalf("Tomorrow output name = %q, want tomorrow.md", outputName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanBatchRunRejectsUnknownBatch(t *testing.T) {
|
||||
_, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchKind("hourly")}, mustParse("2026-05-29T08:00:00-05:00"), collect.Result{Bundle: &weatherdata.Bundle{}})
|
||||
if err == nil || !strings.Contains(err.Error(), `unknown batch command "hourly"`) {
|
||||
t.Fatalf("planBatchRun() error = %v, want unknown batch command", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEligibleDailyDatesRequiresFullOrdinaryLocalDay(t *testing.T) {
|
||||
location := mustLoadTestLocation(t, "America/Chicago")
|
||||
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
|
||||
|
||||
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||
assertLocalDates(t, got, location, "2026-05-31")
|
||||
}
|
||||
|
||||
func TestEligibleDailyDatesMatchesFixedOffsetStartInstants(t *testing.T) {
|
||||
location := mustLoadTestLocation(t, "America/Chicago")
|
||||
hourly := hourlyRun(fixedOffsetPeriods(t, fullDayPeriods(t, "2026-05-31", location))...)
|
||||
|
||||
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||
assertLocalDates(t, got, location, "2026-05-31")
|
||||
}
|
||||
|
||||
func TestEligibleDailyDatesSkipsDayWithMissingRequiredHour(t *testing.T) {
|
||||
location := mustLoadTestLocation(t, "America/Chicago")
|
||||
periods := fullDayPeriods(t, "2026-05-31", location)
|
||||
periods = append(periods[:12], periods[13:]...)
|
||||
hourly := hourlyRun(periods...)
|
||||
|
||||
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||
assertLocalDates(t, got, location)
|
||||
}
|
||||
|
||||
func TestEligibleDailyDatesSkipsPartialFinalDay(t *testing.T) {
|
||||
location := mustLoadTestLocation(t, "America/Chicago")
|
||||
periods := fullDayPeriods(t, "2026-05-31", location)
|
||||
periods = append(periods, partialDayPeriods(t, "2026-06-01", location, 12)...)
|
||||
hourly := hourlyRun(periods...)
|
||||
|
||||
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||
assertLocalDates(t, got, location, "2026-05-31")
|
||||
}
|
||||
|
||||
func TestEligibleDailyDatesStartsAfterTomorrow(t *testing.T) {
|
||||
location := mustLoadTestLocation(t, "America/Chicago")
|
||||
periods := fullDayPeriods(t, "2026-05-29", location)
|
||||
periods = append(periods, fullDayPeriods(t, "2026-05-30", location)...)
|
||||
periods = append(periods, fullDayPeriods(t, "2026-05-31", location)...)
|
||||
hourly := hourlyRun(periods...)
|
||||
|
||||
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||
assertLocalDates(t, got, location, "2026-05-31")
|
||||
}
|
||||
|
||||
func TestEligibleDailyDatesReturnsMultipleFutureDatesInOrder(t *testing.T) {
|
||||
location := mustLoadTestLocation(t, "America/Chicago")
|
||||
periods := fullDayPeriods(t, "2026-05-31", location)
|
||||
periods = append(periods, fullDayPeriods(t, "2026-06-01", location)...)
|
||||
hourly := hourlyRun(periods...)
|
||||
|
||||
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||
assertLocalDates(t, got, location, "2026-05-31", "2026-06-01")
|
||||
}
|
||||
|
||||
func TestEligibleDailyDatesIgnoresNonHourlyAndInvalidPeriods(t *testing.T) {
|
||||
location := mustLoadTestLocation(t, "America/Chicago")
|
||||
day := timeutil.CivilDay(mustParseLocalDate(t, "2026-05-31", location), location)
|
||||
periods := []weatherdata.ForecastPeriod{
|
||||
{StartTime: day.Start, EndTime: day.Start.Add(2 * time.Hour)},
|
||||
{StartTime: day.Start.Add(time.Hour), EndTime: day.Start.Add(time.Hour)},
|
||||
{StartTime: time.Time{}, EndTime: day.Start.Add(3 * time.Hour)},
|
||||
}
|
||||
periods = append(periods, fullDayPeriods(t, "2026-06-01", location)...)
|
||||
hourly := hourlyRun(periods...)
|
||||
|
||||
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||
assertLocalDates(t, got, location, "2026-06-01")
|
||||
}
|
||||
|
||||
func TestEligibleDailyDatesUsesDSTCivilDayInstants(t *testing.T) {
|
||||
location := mustLoadTestLocation(t, "America/New_York")
|
||||
tests := []struct {
|
||||
name string
|
||||
now string
|
||||
date string
|
||||
}{
|
||||
{
|
||||
name: "spring forward",
|
||||
now: "2026-03-06T08:00:00-05:00",
|
||||
date: "2026-03-08",
|
||||
},
|
||||
{
|
||||
name: "fall back",
|
||||
now: "2026-10-30T08:00:00-04:00",
|
||||
date: "2026-11-01",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
hourly := hourlyRun(fullDayPeriods(t, tt.date, location)...)
|
||||
|
||||
got := eligibleDailyDates(hourly, mustParse(tt.now), location)
|
||||
assertLocalDates(t, got, location, tt.date)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEligibleDailyDatesReturnsNoneWithoutHourlyForecast(t *testing.T) {
|
||||
location := mustLoadTestLocation(t, "America/Chicago")
|
||||
fullDay := fullDayPeriods(t, "2026-05-31", location)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
hourly *weatherdata.ForecastRun
|
||||
}{
|
||||
{name: "nil run"},
|
||||
{name: "empty periods", hourly: hourlyRun()},
|
||||
{name: "non-hourly product", hourly: forecastRun("narrative", fullDay...)},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := eligibleDailyDates(tt.hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||
assertLocalDates(t, got, location)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func hourlyRun(periods ...weatherdata.ForecastPeriod) *weatherdata.ForecastRun {
|
||||
return forecastRun("hourly", periods...)
|
||||
}
|
||||
|
||||
func forecastRun(product string, periods ...weatherdata.ForecastPeriod) *weatherdata.ForecastRun {
|
||||
return &weatherdata.ForecastRun{
|
||||
Product: product,
|
||||
Periods: periods,
|
||||
}
|
||||
}
|
||||
|
||||
func collectionWithHourly(hourly *weatherdata.ForecastRun) collect.Result {
|
||||
return collect.Result{Bundle: &weatherdata.Bundle{Hourly: hourly}}
|
||||
}
|
||||
|
||||
func planningConfig() config.Config {
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||
return cfg
|
||||
}
|
||||
|
||||
func assertPlannedReportIDs(t *testing.T, got []plannedBatchReport, want ...report.ID) {
|
||||
t.Helper()
|
||||
gotIDs := make([]string, 0, len(got))
|
||||
for _, item := range got {
|
||||
gotIDs = append(gotIDs, string(item.Resolved.Definition.ID))
|
||||
}
|
||||
wantIDs := make([]string, 0, len(want))
|
||||
for _, id := range want {
|
||||
wantIDs = append(wantIDs, string(id))
|
||||
}
|
||||
if strings.Join(gotIDs, ",") != strings.Join(wantIDs, ",") {
|
||||
t.Fatalf("planned report IDs = [%s], want [%s]", strings.Join(gotIDs, ","), strings.Join(wantIDs, ","))
|
||||
}
|
||||
}
|
||||
|
||||
func plannedDailyReports(planned []plannedBatchReport) []plannedBatchReport {
|
||||
var daily []plannedBatchReport
|
||||
for _, item := range planned {
|
||||
if item.Resolved.Definition.ID == report.Daily {
|
||||
daily = append(daily, item)
|
||||
}
|
||||
}
|
||||
return daily
|
||||
}
|
||||
|
||||
func fullDayPeriods(t *testing.T, date string, location *time.Location) []weatherdata.ForecastPeriod {
|
||||
t.Helper()
|
||||
day := timeutil.CivilDay(mustParseLocalDate(t, date, location), location)
|
||||
var periods []weatherdata.ForecastPeriod
|
||||
for start := day.Start; start.Before(day.End); start = start.Add(time.Hour) {
|
||||
periods = append(periods, weatherdata.ForecastPeriod{
|
||||
StartTime: start,
|
||||
EndTime: start.Add(time.Hour),
|
||||
})
|
||||
}
|
||||
return periods
|
||||
}
|
||||
|
||||
func partialDayPeriods(t *testing.T, date string, location *time.Location, count int) []weatherdata.ForecastPeriod {
|
||||
t.Helper()
|
||||
periods := fullDayPeriods(t, date, location)
|
||||
if count > len(periods) {
|
||||
count = len(periods)
|
||||
}
|
||||
return periods[:count]
|
||||
}
|
||||
|
||||
func fixedOffsetPeriods(t *testing.T, periods []weatherdata.ForecastPeriod) []weatherdata.ForecastPeriod {
|
||||
t.Helper()
|
||||
out := make([]weatherdata.ForecastPeriod, 0, len(periods))
|
||||
for _, period := range periods {
|
||||
start, err := time.Parse(time.RFC3339, period.StartTime.Format(time.RFC3339))
|
||||
if err != nil {
|
||||
t.Fatalf("parse fixed-offset start: %v", err)
|
||||
}
|
||||
end, err := time.Parse(time.RFC3339, period.EndTime.Format(time.RFC3339))
|
||||
if err != nil {
|
||||
t.Fatalf("parse fixed-offset end: %v", err)
|
||||
}
|
||||
out = append(out, weatherdata.ForecastPeriod{StartTime: start, EndTime: end})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func assertLocalDates(t *testing.T, got []time.Time, location *time.Location, want ...string) {
|
||||
t.Helper()
|
||||
gotDates := make([]string, 0, len(got))
|
||||
for _, date := range got {
|
||||
gotDates = append(gotDates, date.In(location).Format(timeutil.DateLayout))
|
||||
}
|
||||
if strings.Join(gotDates, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("eligibleDailyDates() = [%s], want [%s]", strings.Join(gotDates, ","), strings.Join(want, ","))
|
||||
}
|
||||
for _, date := range got {
|
||||
day := timeutil.CivilDay(date, location)
|
||||
if !date.Equal(day.Start) {
|
||||
t.Fatalf("eligible date %s is not local civil day start %s", date, day.Start)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertPlanningPeriod(t *testing.T, period timeutil.Period, wantStart string, wantEnd string) {
|
||||
t.Helper()
|
||||
if !period.IsValid() {
|
||||
t.Fatalf("period = %#v, want valid", period)
|
||||
}
|
||||
if got := period.Start.Format(time.RFC3339); got != wantStart {
|
||||
t.Fatalf("Start = %s, want %s", got, wantStart)
|
||||
}
|
||||
if got := period.End.Format(time.RFC3339); got != wantEnd {
|
||||
t.Fatalf("End = %s, want %s", got, wantEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func mustLoadTestLocation(t *testing.T, name string) *time.Location {
|
||||
t.Helper()
|
||||
location, err := time.LoadLocation(name)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLocation(%q) error = %v", name, err)
|
||||
}
|
||||
return location
|
||||
}
|
||||
|
||||
func mustParseLocalDate(t *testing.T, value string, location *time.Location) time.Time {
|
||||
t.Helper()
|
||||
parsed, err := timeutil.ParseLocalDate(value, location)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseLocalDate(%q) error = %v", value, err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
395
internal/app/generation_test.go
Normal file
395
internal/app/generation_test.go
Normal file
@@ -0,0 +1,395 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type generationCollector struct {
|
||||
bundle *weatherdata.Bundle
|
||||
err error
|
||||
called bool
|
||||
beforeRun func()
|
||||
}
|
||||
|
||||
func (c *generationCollector) Run(context.Context, collect.Request) (*collect.Result, error) {
|
||||
if c.beforeRun != nil {
|
||||
c.beforeRun()
|
||||
}
|
||||
c.called = true
|
||||
return &collect.Result{Bundle: c.bundle}, c.err
|
||||
}
|
||||
|
||||
type generationExecutor struct {
|
||||
called bool
|
||||
promptInspections int
|
||||
inspectErr error
|
||||
executeErr error
|
||||
cancelBeforeReturn context.CancelFunc
|
||||
validation promptexec.ValidationStatus
|
||||
rawOutput []byte
|
||||
failedPrompt string
|
||||
}
|
||||
|
||||
func (e *generationExecutor) InspectPrompt(_ context.Context, id, version string) (promptexec.PromptInspection, error) {
|
||||
e.promptInspections++
|
||||
if e.inspectErr != nil {
|
||||
return promptexec.PromptInspection{}, e.inspectErr
|
||||
}
|
||||
definition := generationDefinitionForPrompt(id)
|
||||
return promptexec.PromptInspection{PromptID: id, PromptVersion: version, PromptHash: "prompt-hash", DefaultProfileID: "fixture", Inputs: []promptexec.InputDefinition{{Name: "data_package", Required: true, ContentType: "application/yaml"}}, Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: definition.GeneratedTextSchemaID + ".generated_text.schema.json"}}, nil
|
||||
}
|
||||
func (*generationExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) {
|
||||
return promptexec.ProfileInspection{ProfileID: id, BackendID: "fixture", ModelName: "fixture-model"}, nil
|
||||
}
|
||||
func (e *generationExecutor) Execute(_ context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {
|
||||
stamp := time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
|
||||
if err := callback(promptexec.Preparation{PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "fixture", ModelName: "fixture-model", StartedAt: stamp, EndedAt: stamp}, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.called = true
|
||||
if e.executeErr != nil {
|
||||
return nil, e.executeErr
|
||||
}
|
||||
status := e.validation
|
||||
if status == "" {
|
||||
status = promptexec.ValidationPassed
|
||||
}
|
||||
if e.failedPrompt == req.PromptID {
|
||||
status = promptexec.ValidationFailed
|
||||
}
|
||||
rawOutput := e.rawOutput
|
||||
if rawOutput == nil {
|
||||
rawOutput = []byte(`{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."],"precipitation_timing":"Rain is most likely during the afternoon."}`)
|
||||
}
|
||||
if e.cancelBeforeReturn != nil {
|
||||
e.cancelBeforeReturn()
|
||||
}
|
||||
return &promptexec.Execution{RunID: "provider-run", PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "fixture", ModelName: "fixture-model", StartedAt: stamp, EndedAt: stamp, RawOutput: rawOutput, Validation: promptexec.NewValidation(status, "json_schema", generationDefinitionForPrompt(req.PromptID).GeneratedTextSchemaID+".generated_text.schema.json", nil)}, nil
|
||||
}
|
||||
|
||||
func generationDefinitionForPrompt(promptID string) report.Definition {
|
||||
for _, definition := range report.DefaultRegistry().All() {
|
||||
if definition.PromptID == promptID {
|
||||
return definition
|
||||
}
|
||||
}
|
||||
panic("unknown fixture prompt " + promptID)
|
||||
}
|
||||
|
||||
func TestGenerateDetailedPublishesOnlySelectedOutput(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.Timezone, cfg.Location.ID = "America/Chicago", "home"
|
||||
bundle := generationBundle(t)
|
||||
executor := &generationExecutor{}
|
||||
workingDir := t.TempDir()
|
||||
result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: cfg, Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: workingDir, Collector: &generationCollector{bundle: &bundle}, Executor: executor})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateDetailed() error = %v", err)
|
||||
}
|
||||
if !executor.called || result.OutputPath != filepath.Join(workingDir, "daily-2026-05-29.md") || result.ValidationStatus != promptexec.ValidationPassed || result.ProfileID == "" || result.BackendID == "" || result.ModelName == "" {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
if result.LLMDebugPath != "" {
|
||||
t.Fatalf("unexpected debug output = %q", result.LLMDebugPath)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(workingDir, "workspace")); !os.IsNotExist(err) {
|
||||
t.Fatalf("unexpected default state directory: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(result.OutputPath)
|
||||
if err != nil || len(data) == 0 {
|
||||
t.Fatalf("output = %q, error = %v", data, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDetailedUsesConfiguredOutputDirectory(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
directory func(t *testing.T, workingDir string) string
|
||||
wantDir func(t *testing.T, workingDir string, configuredDir string) string
|
||||
}{
|
||||
{
|
||||
name: "absolute directory",
|
||||
directory: func(t *testing.T, _ string) string {
|
||||
return filepath.Join(t.TempDir(), "reports")
|
||||
},
|
||||
wantDir: func(_ *testing.T, _ string, configuredDir string) string {
|
||||
return configuredDir
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "relative directory",
|
||||
directory: func(_ *testing.T, _ string) string {
|
||||
return "configured/../reports"
|
||||
},
|
||||
wantDir: func(_ *testing.T, workingDir string, _ string) string {
|
||||
return filepath.Join(workingDir, "reports")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
workingDir := t.TempDir()
|
||||
configuredDir := tt.directory(t, workingDir)
|
||||
cfg := generationDistributorConfig()
|
||||
cfg.Output.Directory = configuredDir
|
||||
bundle := generationBundle(t)
|
||||
notifier := &generationNotifier{}
|
||||
|
||||
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||
Config: cfg, Report: ReportDaily,
|
||||
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
|
||||
WorkingDir: workingDir, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier,
|
||||
})
|
||||
wantPath := filepath.Join(tt.wantDir(t, workingDir, configuredDir), "daily-2026-05-29.md")
|
||||
if err != nil || result == nil || result.OutputPath != wantPath || notifier.request.ReportPath != wantPath {
|
||||
t.Fatalf("GenerateDetailed() result/error/notification = %#v/%v/%#v", result, err, notifier.request)
|
||||
}
|
||||
if info, statErr := os.Stat(filepath.Dir(wantPath)); statErr != nil || !info.IsDir() {
|
||||
t.Fatalf("configured output directory info/error = %#v/%v", info, statErr)
|
||||
}
|
||||
if _, statErr := os.Stat(wantPath); statErr != nil {
|
||||
t.Fatalf("output %q: %v", wantPath, statErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDetailedExplicitOutputPathIgnoresConfiguredDirectory(t *testing.T) {
|
||||
configuredPath := filepath.Join(t.TempDir(), "not-a-directory")
|
||||
if err := os.WriteFile(configuredPath, []byte("not a directory"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explicitPath := filepath.Join(t.TempDir(), "explicit.md")
|
||||
cfg := generationConfig()
|
||||
cfg.Output.Directory = configuredPath
|
||||
bundle := generationBundle(t)
|
||||
|
||||
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||
Config: cfg, Report: ReportDaily,
|
||||
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
|
||||
WorkingDir: t.TempDir(), OutputPath: explicitPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{},
|
||||
})
|
||||
if err != nil || result == nil || result.OutputPath != explicitPath {
|
||||
t.Fatalf("GenerateDetailed() result/error = %#v/%v", result, err)
|
||||
}
|
||||
if _, statErr := os.Stat(explicitPath); statErr != nil {
|
||||
t.Fatalf("explicit output %q: %v", explicitPath, statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDetailedRejectsConfiguredNonDirectoryBeforeWork(t *testing.T) {
|
||||
configuredPath := filepath.Join(t.TempDir(), "not-a-directory")
|
||||
if err := os.WriteFile(configuredPath, []byte("not a directory"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := generationDistributorConfig()
|
||||
cfg.Output.Directory = configuredPath
|
||||
bundle := generationBundle(t)
|
||||
collector := &generationCollector{bundle: &bundle}
|
||||
executor := &generationExecutor{}
|
||||
notifier := &generationNotifier{}
|
||||
|
||||
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||
Config: cfg, Report: ReportDaily,
|
||||
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
|
||||
WorkingDir: t.TempDir(), Collector: collector, Executor: executor, Notifier: notifier,
|
||||
})
|
||||
if err == nil || result == nil || collector.called || executor.promptInspections != 0 || executor.called || notifier.calls != 0 {
|
||||
t.Fatalf("GenerateDetailed() result/error/collector/executor/notifier = %#v/%v/%t/%#v/%#v", result, err, collector.called, executor, notifier)
|
||||
}
|
||||
if data, readErr := os.ReadFile(configuredPath); readErr != nil || string(data) != "not a directory" {
|
||||
t.Fatalf("configured path = %q, error = %v", data, readErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDetailedReturnsResolvedResultWhenCollectionFails(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.Timezone, cfg.Location.ID = "America/Chicago", "home"
|
||||
collectionErr := errors.New("weather source unavailable")
|
||||
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||
Config: cfg, Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
|
||||
WorkingDir: t.TempDir(), Collector: &generationCollector{err: collectionErr}, Executor: &generationExecutor{},
|
||||
})
|
||||
if !errors.Is(err, collectionErr) {
|
||||
t.Fatalf("GenerateDetailed() error = %v, want %v", err, collectionErr)
|
||||
}
|
||||
if result == nil || result.ReportID != report.Daily || result.RunID == "" || result.ProfileID != "fixture" || result.BackendID != "fixture" || result.ModelName != "fixture-model" || result.OutputPath != "" {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDetailedInspectsPromptBeforeCollectingWeather(t *testing.T) {
|
||||
cfg := generationConfig()
|
||||
inspectionErr := errors.New("profile is invalid")
|
||||
collector := &generationCollector{bundle: generationBundlePointer(t)}
|
||||
result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: cfg, Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), Collector: collector, Executor: &generationExecutor{inspectErr: inspectionErr}})
|
||||
if !errors.Is(err, inspectionErr) || collector.called || result == nil {
|
||||
t.Fatalf("GenerateDetailed() result/error/collector-called = %#v/%v/%t", result, err, collector.called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDetailedPreservesDestinationBeforePublish(t *testing.T) {
|
||||
for _, scenario := range []struct {
|
||||
name string
|
||||
executor generationExecutor
|
||||
}{
|
||||
{name: "generation", executor: generationExecutor{executeErr: errors.New("provider unavailable")}},
|
||||
{name: "render", executor: generationExecutor{rawOutput: []byte(`{"summary":""}`)}},
|
||||
} {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
outputPath := filepath.Join(t.TempDir(), "daily.md")
|
||||
if err := os.WriteFile(outputPath, []byte("previous report"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bundle := generationBundle(t)
|
||||
result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: generationConfig(), Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &scenario.executor})
|
||||
data, readErr := os.ReadFile(outputPath)
|
||||
if err == nil || result == nil || readErr != nil || string(data) != "previous report" {
|
||||
t.Fatalf("GenerateDetailed() result/error/output = %#v/%v/%q (%v)", result, err, data, readErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDetailedPreservesDestinationWhenContextCancelsBeforePublication(t *testing.T) {
|
||||
outputPath := filepath.Join(t.TempDir(), "daily.md")
|
||||
const previousReport = "previous report"
|
||||
if err := os.WriteFile(outputPath, []byte(previousReport), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
bundle := generationBundle(t)
|
||||
result, err := GenerateDetailed(ctx, GenerateRequest{
|
||||
Config: generationConfig(), Report: ReportDaily,
|
||||
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
|
||||
WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{cancelBeforeReturn: cancel},
|
||||
})
|
||||
data, readErr := os.ReadFile(outputPath)
|
||||
if !errors.Is(err, context.Canceled) || promptexec.CategoryOf(err) != promptexec.Canceled || result == nil || result.OutputPath != "" || readErr != nil || string(data) != previousReport {
|
||||
t.Fatalf("GenerateDetailed() result/error/output = %#v/%v/%q (%v)", result, err, data, readErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDetailedPreservesDestinationWhenContextDeadlineExpiresBeforePublication(t *testing.T) {
|
||||
outputPath := filepath.Join(t.TempDir(), "daily.md")
|
||||
const previousReport = "previous report"
|
||||
if err := os.WriteFile(outputPath, []byte(previousReport), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithDeadline(context.Background(), time.Unix(0, 0))
|
||||
defer cancel()
|
||||
bundle := generationBundle(t)
|
||||
result, err := GenerateDetailed(ctx, GenerateRequest{
|
||||
Config: generationConfig(), Report: ReportDaily,
|
||||
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
|
||||
WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{},
|
||||
})
|
||||
data, readErr := os.ReadFile(outputPath)
|
||||
if !errors.Is(err, context.DeadlineExceeded) || promptexec.CategoryOf(err) != promptexec.DeadlineExceeded || result == nil || result.OutputPath != "" || readErr != nil || string(data) != previousReport {
|
||||
t.Fatalf("GenerateDetailed() result/error/output = %#v/%v/%q (%v)", result, err, data, readErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDetailedRetainsPublishedOutputWhenNotificationFails(t *testing.T) {
|
||||
cfg := generationConfig()
|
||||
cfg.Notify.Distributor.Enabled = true
|
||||
cfg.Notify.Distributor.PipelineIDTemplate = "weather"
|
||||
bundle := generationBundle(t)
|
||||
outputPath := filepath.Join(t.TempDir(), "daily.md")
|
||||
notifier := &generationNotifier{err: errors.New("distributor unavailable")}
|
||||
result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: cfg, Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier})
|
||||
if err == nil || result == nil || result.OutputPath != outputPath || notifier.request.ReportPath != outputPath || len(notifier.request.BundlePaths) == 0 {
|
||||
t.Fatalf("GenerateDetailed() result/error/request = %#v/%v/%#v", result, err, notifier.request)
|
||||
}
|
||||
if data, readErr := os.ReadFile(outputPath); readErr != nil || len(data) == 0 {
|
||||
t.Fatalf("published output = %q, error = %v", data, readErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDetailedDoesNotReplaceDirectoryOutput(t *testing.T) {
|
||||
bundle := generationBundle(t)
|
||||
outputPath := filepath.Join(t.TempDir(), "daily.md")
|
||||
if err := os.Mkdir(outputPath, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: generationConfig(), Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}})
|
||||
info, statErr := os.Stat(outputPath)
|
||||
if err == nil || result == nil || statErr != nil || !info.IsDir() {
|
||||
t.Fatalf("GenerateDetailed() result/error/output-info = %#v/%v/%#v (%v)", result, err, info, statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func generationConfig() config.Config {
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.Timezone, cfg.Location.ID = "America/Chicago", "home"
|
||||
return cfg
|
||||
}
|
||||
|
||||
func generationBundlePointer(t *testing.T) *weatherdata.Bundle {
|
||||
bundle := generationBundle(t)
|
||||
return &bundle
|
||||
}
|
||||
|
||||
type generationNotifier struct {
|
||||
err error
|
||||
batchErr error
|
||||
calls int
|
||||
request NotificationRequest
|
||||
batchRequest batchNotificationRequest
|
||||
batchCalls int
|
||||
}
|
||||
|
||||
func (n *generationNotifier) Notify(_ context.Context, request NotificationRequest) (*NotificationResult, error) {
|
||||
n.calls++
|
||||
n.request = request
|
||||
if n.err != nil {
|
||||
return nil, n.err
|
||||
}
|
||||
return &NotificationResult{Status: "succeeded"}, nil
|
||||
}
|
||||
|
||||
func (n *generationNotifier) NotifyBatch(_ context.Context, request batchNotificationRequest) (*NotificationResult, error) {
|
||||
n.batchCalls++
|
||||
n.batchRequest = request
|
||||
for _, file := range request.Files {
|
||||
if _, err := os.Stat(file.SourcePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &NotificationResult{Status: "succeeded", PipelineID: request.PipelineID, BundleID: request.BundleID}, n.batchErr
|
||||
}
|
||||
|
||||
func generationBundle(t *testing.T) weatherdata.Bundle {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join("..", "forecast", "testdata", "daily_bundle.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read bundle fixture: %v", err)
|
||||
}
|
||||
var bundle weatherdata.Bundle
|
||||
if err := json.Unmarshal(data, &bundle); err != nil {
|
||||
t.Fatalf("decode bundle fixture: %v", err)
|
||||
}
|
||||
return bundle
|
||||
}
|
||||
func generationTime(value string) time.Time {
|
||||
parsed, _ := time.Parse(time.RFC3339, value)
|
||||
return parsed
|
||||
}
|
||||
|
||||
var _ promptexec.Executor = (*generationExecutor)(nil)
|
||||
var _ Collector = (*generationCollector)(nil)
|
||||
var _ Notifier = (*generationNotifier)(nil)
|
||||
var _ = report.Daily
|
||||
@@ -1,123 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
type InspectReportsRequest struct {
|
||||
Config config.Config
|
||||
Limit int
|
||||
}
|
||||
|
||||
type InspectRunRequest struct {
|
||||
Config config.Config
|
||||
RunID string
|
||||
}
|
||||
|
||||
type SourceInspection struct {
|
||||
RunID string `json:"runId"`
|
||||
ReportID report.ID `json:"reportId"`
|
||||
SourceLocation string `json:"sourceLocation,omitempty"`
|
||||
Sources []briefing.SourceMetadata `json:"sources,omitempty"`
|
||||
Warnings []forecast.SourceWarning `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
func InspectReports(ctx context.Context, req InspectReportsRequest) ([]state.ReportRecord, error) {
|
||||
store, err := defaultStore(req.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store.ListReports(ctx, req.Limit)
|
||||
}
|
||||
|
||||
func InspectMetadata(ctx context.Context, req InspectRunRequest) (state.Metadata, error) {
|
||||
store, err := defaultStore(req.Config)
|
||||
if err != nil {
|
||||
return state.Metadata{}, err
|
||||
}
|
||||
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
|
||||
return metadata, err
|
||||
}
|
||||
|
||||
func InspectBriefing(ctx context.Context, req InspectRunRequest) (briefing.Package, error) {
|
||||
store, err := defaultStore(req.Config)
|
||||
if err != nil {
|
||||
return briefing.Package{}, err
|
||||
}
|
||||
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
|
||||
if err != nil {
|
||||
return briefing.Package{}, err
|
||||
}
|
||||
return store.LoadBriefing(ctx, metadata.BriefingPath)
|
||||
}
|
||||
|
||||
func InspectDataPackage(ctx context.Context, req InspectRunRequest) (promptinput.Package, error) {
|
||||
store, err := defaultStore(req.Config)
|
||||
if err != nil {
|
||||
return promptinput.Package{}, err
|
||||
}
|
||||
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
|
||||
if err != nil {
|
||||
return promptinput.Package{}, err
|
||||
}
|
||||
return store.LoadDataPackage(ctx, metadata.DataPackagePath)
|
||||
}
|
||||
|
||||
func InspectPriorSnapshot(ctx context.Context, req InspectRunRequest) (*state.PriorSnapshot, error) {
|
||||
store, err := defaultStore(req.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolved, err := resolvedFromMetadata(metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store.FindPriorSnapshot(ctx, resolved)
|
||||
}
|
||||
|
||||
func InspectSources(ctx context.Context, req InspectRunRequest) (SourceInspection, error) {
|
||||
metadata, err := InspectMetadata(ctx, req)
|
||||
if err != nil {
|
||||
return SourceInspection{}, err
|
||||
}
|
||||
return SourceInspection{
|
||||
RunID: metadata.RunID,
|
||||
ReportID: metadata.ReportID,
|
||||
SourceLocation: metadata.SourceLocation,
|
||||
Sources: metadata.Sources,
|
||||
Warnings: metadata.SourceWarnings,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolvedFromMetadata(metadata state.Metadata) (report.Resolved, error) {
|
||||
definition, err := report.DefaultRegistry().Lookup(metadata.ReportID)
|
||||
if err != nil {
|
||||
return report.Resolved{}, err
|
||||
}
|
||||
location, err := timeutil.LoadLocation(metadata.Timezone)
|
||||
if err != nil {
|
||||
return report.Resolved{}, err
|
||||
}
|
||||
if !metadata.ValidPeriod.IsValid() {
|
||||
return report.Resolved{}, fmt.Errorf("metadata valid period for run id %q is invalid", metadata.RunID)
|
||||
}
|
||||
return report.Resolved{
|
||||
Definition: definition,
|
||||
GeneratedAt: metadata.GeneratedAt,
|
||||
Timezone: location.String(),
|
||||
ValidPeriod: metadata.ValidPeriod,
|
||||
}, nil
|
||||
}
|
||||
163
internal/app/output.go
Normal file
163
internal/app/output.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
|
||||
func plannedBatchOutputPath(outputDir string, planned plannedBatchReport) (string, error) {
|
||||
outputName, err := planned.Resolved.OutputName()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return validateOutputPath(filepath.Join(outputDir, outputName))
|
||||
}
|
||||
|
||||
func prepareBatchOutputs(outputDir string, plannedReports []plannedBatchReport) error {
|
||||
for index := range plannedReports {
|
||||
outputPath, err := plannedBatchOutputPath(outputDir, plannedReports[index])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
plannedReports[index].OutputPath = outputPath
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveReportOutputPath(workingDir, override, configuredDir string, resolved report.Resolved) (string, error) {
|
||||
outputName, err := resolved.OutputName()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if override != "" {
|
||||
return resolveOutputPath(workingDir, override, outputName)
|
||||
}
|
||||
outputDir, err := resolveOutputDir(workingDir, configuredDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return validateOutputPath(filepath.Join(outputDir, outputName))
|
||||
}
|
||||
|
||||
func resolveOutputDirWithConfigured(workingDir, override, configuredDir string) (string, error) {
|
||||
directory := configuredDir
|
||||
if override != "" {
|
||||
directory = override
|
||||
}
|
||||
return resolveOutputDir(workingDir, directory)
|
||||
}
|
||||
|
||||
func resolveOutputDir(workingDir, override string) (string, error) {
|
||||
workingDir, err := validateWorkingDir(workingDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if override == "" {
|
||||
return workingDir, nil
|
||||
}
|
||||
if strings.TrimSpace(override) == "" {
|
||||
return "", fmt.Errorf("output directory is required")
|
||||
}
|
||||
directory := override
|
||||
if !filepath.IsAbs(directory) {
|
||||
directory = filepath.Join(workingDir, directory)
|
||||
}
|
||||
directory = filepath.Clean(directory)
|
||||
if err := preflightOutputDirectory(directory); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return directory, nil
|
||||
}
|
||||
|
||||
func preflightOutputDirectory(directory string) error {
|
||||
info, err := os.Stat(directory)
|
||||
if err == nil {
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("output directory %q is not a directory", directory)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("inspect output directory %q: %w", directory, err)
|
||||
}
|
||||
|
||||
// A missing directory is valid, but os.Stat also reports ErrNotExist for a
|
||||
// dangling symlink. Walk to the first existing component so invalid links
|
||||
// fail preflight instead of being discovered only during publication.
|
||||
for component := directory; ; component = filepath.Dir(component) {
|
||||
componentInfo, componentErr := os.Lstat(component)
|
||||
if componentErr == nil {
|
||||
if componentInfo.Mode()&os.ModeSymlink != 0 {
|
||||
targetInfo, targetErr := os.Stat(component)
|
||||
if targetErr != nil {
|
||||
return fmt.Errorf("inspect output directory %q at %q: %w", directory, component, targetErr)
|
||||
}
|
||||
if !targetInfo.IsDir() {
|
||||
return fmt.Errorf("output directory %q has non-directory path component %q", directory, component)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !componentInfo.IsDir() {
|
||||
return fmt.Errorf("output directory %q has non-directory path component %q", directory, component)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !os.IsNotExist(componentErr) {
|
||||
return fmt.Errorf("inspect output directory %q at %q: %w", directory, component, componentErr)
|
||||
}
|
||||
if filepath.Dir(component) == component {
|
||||
return fmt.Errorf("inspect output directory %q: no existing directory ancestor", directory)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resolveOutputPath(workingDir, override, defaultName string) (string, error) {
|
||||
workingDir, err := validateWorkingDir(workingDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := override
|
||||
if path == "" {
|
||||
path = defaultName
|
||||
}
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return "", fmt.Errorf("final output path is required")
|
||||
}
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(workingDir, path)
|
||||
}
|
||||
return validateOutputPath(path)
|
||||
}
|
||||
|
||||
func validateWorkingDir(workingDir string) (string, error) {
|
||||
if strings.TrimSpace(workingDir) == "" {
|
||||
return "", fmt.Errorf("working directory is required")
|
||||
}
|
||||
if !filepath.IsAbs(workingDir) {
|
||||
return "", fmt.Errorf("working directory %q must be absolute", workingDir)
|
||||
}
|
||||
return filepath.Clean(workingDir), nil
|
||||
}
|
||||
|
||||
func validateOutputPath(path string) (string, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return "", fmt.Errorf("final output path is required")
|
||||
}
|
||||
path = filepath.Clean(path)
|
||||
if !filepath.IsAbs(path) {
|
||||
return "", fmt.Errorf("final output path %q must be absolute", path)
|
||||
}
|
||||
if filepath.Dir(path) == path {
|
||||
return "", fmt.Errorf("final output path %q must not be a filesystem root", path)
|
||||
}
|
||||
if info, err := os.Stat(path); err == nil && info.IsDir() {
|
||||
return "", fmt.Errorf("final output path %q is a directory", path)
|
||||
} else if err != nil && !os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("inspect final output path %q: %w", path, err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
38
internal/app/output_test.go
Normal file
38
internal/app/output_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveOutputDirRejectsDanglingSymlinkComponents(t *testing.T) {
|
||||
workingDir := t.TempDir()
|
||||
dangling := filepath.Join(workingDir, "dangling")
|
||||
if err := os.Symlink(filepath.Join(workingDir, "missing"), dangling); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, directory := range []string{dangling, filepath.Join(dangling, "reports")} {
|
||||
t.Run(filepath.Base(directory), func(t *testing.T) {
|
||||
if _, err := resolveOutputDir(workingDir, directory); err == nil {
|
||||
t.Fatalf("resolveOutputDir(%q) error = nil, want dangling symlink error", directory)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOutputDirAllowsMissingDirectoryBelowValidSymlink(t *testing.T) {
|
||||
workingDir := t.TempDir()
|
||||
target := t.TempDir()
|
||||
link := filepath.Join(workingDir, "linked")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
directory := filepath.Join(link, "reports")
|
||||
got, err := resolveOutputDir(workingDir, directory)
|
||||
if err != nil || got != directory {
|
||||
t.Fatalf("resolveOutputDir() = %q, %v, want %q, nil", got, err, directory)
|
||||
}
|
||||
}
|
||||
214
internal/app/prompt_generate.go
Normal file
214
internal/app/prompt_generate.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type promptReportRequest struct {
|
||||
GenerateRequest
|
||||
Resolved report.Resolved
|
||||
Collection collect.Result
|
||||
Inspection PromptInspectionResult
|
||||
DebugWriter *promptdebug.PromptDebugWriter
|
||||
Result *ReportResult
|
||||
noNotify bool
|
||||
}
|
||||
|
||||
type promptReportWorkflow struct {
|
||||
ctx context.Context
|
||||
req promptReportRequest
|
||||
result *ReportResult
|
||||
briefingMetadata briefing.Metadata
|
||||
reportFacts ReportFacts
|
||||
moduleSnapshot module.Snapshot
|
||||
dataPackage []byte
|
||||
handler generatedtext.Handler
|
||||
debugRef promptdebug.PromptDebugRef
|
||||
callbackFailed bool
|
||||
}
|
||||
|
||||
func generatePromptReport(ctx context.Context, req promptReportRequest) (*ReportResult, error) {
|
||||
workflow, err := newPromptReportWorkflow(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := workflow.buildInputs(); err != nil {
|
||||
return workflow.result, err
|
||||
}
|
||||
execution, err := workflow.executePrompt()
|
||||
if err != nil {
|
||||
if workflow.callbackFailed {
|
||||
return workflow.result, err
|
||||
}
|
||||
return workflow.result, workflow.reportError("execute prompt", classifiedPromptError("prompt execution failed", err))
|
||||
}
|
||||
if execution == nil {
|
||||
return workflow.result, workflow.reportError("execute prompt", promptexec.NewError(promptexec.Generation, "prompt executor returned no execution", nil))
|
||||
}
|
||||
workflow.result.ValidationStatus = execution.Validation.Status
|
||||
if err := workflow.writeExecutionDebug(*execution); err != nil {
|
||||
return workflow.result, err
|
||||
}
|
||||
if execution.Validation.Status != promptexec.ValidationPassed && execution.Validation.Status != promptexec.ValidationFailed {
|
||||
return workflow.result, workflow.reportError("validate prompt execution", promptexec.NewError(promptexec.OperationalValidation, "prompt execution did not complete validation", nil))
|
||||
}
|
||||
if execution.Validation.Status == promptexec.ValidationFailed {
|
||||
return workflow.result, workflow.reportError("validate prompt execution", promptexec.NewError(promptexec.ValidationRejected, "prompt output did not satisfy its schema", nil))
|
||||
}
|
||||
return workflow.renderAndPublish(execution.RawOutput)
|
||||
}
|
||||
|
||||
func newPromptReportWorkflow(ctx context.Context, req promptReportRequest) (*promptReportWorkflow, error) {
|
||||
if req.Collection.Bundle == nil {
|
||||
return nil, fmt.Errorf("collected weather bundle is required")
|
||||
}
|
||||
result := req.Result
|
||||
if result == nil {
|
||||
result = initialReportResult(req.GenerateRequest, req.Resolved, req.Inspection)
|
||||
}
|
||||
return &promptReportWorkflow{
|
||||
ctx: ctx, req: req,
|
||||
result: result,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func initialReportResult(req GenerateRequest, resolved report.Resolved, inspection PromptInspectionResult) *ReportResult {
|
||||
metadata := resolved.Metadata()
|
||||
return &ReportResult{
|
||||
ReportID: resolved.Definition.ID, ReportName: resolved.Definition.Name,
|
||||
PromptID: resolved.Definition.PromptID, PromptVersion: resolved.Definition.PromptVersion,
|
||||
RunID: metadata.RunID, GeneratedAt: metadata.GeneratedAt, Timezone: req.Config.WeatherAPI.Timezone,
|
||||
ValidPeriod: metadata.ValidPeriod,
|
||||
ProfileID: inspection.ProfileID, BackendID: inspection.BackendID, ModelName: inspection.ModelName,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *promptReportWorkflow) buildInputs() error {
|
||||
var err error
|
||||
w.reportFacts, err = BuildReportFacts(ModuleSnapshotRequest{Config: w.req.Config, Resolved: w.req.Resolved}, w.req.Collection.Bundle)
|
||||
if err != nil {
|
||||
return w.reportError("build report facts", err)
|
||||
}
|
||||
w.moduleSnapshot, err = BuildModuleSnapshotFromFacts(ModuleSnapshotRequest{Config: w.req.Config, Resolved: w.req.Resolved}, w.reportFacts)
|
||||
if err != nil {
|
||||
return w.reportError("build module snapshot", err)
|
||||
}
|
||||
w.briefingMetadata = briefing.BuildMetadata(briefingBuildContext(w.req.Config, w.req.Resolved, w.reportFacts.Collected))
|
||||
w.result.SourceWarnings = append([]weatherdata.SourceWarning(nil), w.briefingMetadata.SourceWarnings...)
|
||||
dataPackage, err := promptinput.Build(promptinput.BuildRequest{Metadata: promptMetadata(w.briefingMetadata), Modules: w.moduleSnapshot})
|
||||
if err != nil {
|
||||
return w.reportError("build data package", err)
|
||||
}
|
||||
w.dataPackage, err = promptinput.MarshalYAML(dataPackage)
|
||||
if err != nil {
|
||||
return w.reportError("marshal data package", err)
|
||||
}
|
||||
w.handler, err = generatedtext.LookupDefinition(w.req.Resolved.Definition)
|
||||
if err != nil {
|
||||
return w.reportError("lookup generated text catalog", err)
|
||||
}
|
||||
w.debugRef = promptdebug.PromptDebugRef{ReportID: w.result.ReportID, ValidDate: w.req.Resolved.ValidPeriod.Start.Format("2006-01-02"), RunID: w.result.RunID}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *promptReportWorkflow) executePrompt() (*promptexec.Execution, error) {
|
||||
captureDebug := w.req.DebugWriter != nil && w.req.DebugWriter.Enabled()
|
||||
return w.req.Executor.Execute(w.ctx, promptexec.ExecuteRequest{PromptID: w.req.Inspection.PromptID, PromptVersion: w.req.Inspection.PromptVersion, ProfileID: w.req.Inspection.ProfileID, DataPackage: w.dataPackage, CaptureDebug: captureDebug}, w.writePreparationDebug)
|
||||
}
|
||||
|
||||
func (w *promptReportWorkflow) writePreparationDebug(preparation promptexec.Preparation, debug *promptexec.PreparationDebug) error {
|
||||
w.result.ProfileID, w.result.BackendID, w.result.ModelName = preparation.ProfileID, preparation.BackendID, preparation.ModelName
|
||||
if w.req.DebugWriter == nil {
|
||||
return nil
|
||||
}
|
||||
path, err := w.req.DebugWriter.WritePreparation(w.debugRef, preparation, debug)
|
||||
if err != nil {
|
||||
w.callbackFailed = true
|
||||
return promptDebugWriteError(err)
|
||||
}
|
||||
w.result.LLMDebugPath = path
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *promptReportWorkflow) writeExecutionDebug(execution promptexec.Execution) error {
|
||||
if w.req.DebugWriter == nil {
|
||||
return nil
|
||||
}
|
||||
path, err := w.req.DebugWriter.WriteExecution(w.debugRef, execution)
|
||||
if err != nil {
|
||||
return w.reportError("write prompt debug", promptDebugWriteError(err))
|
||||
}
|
||||
if path != "" {
|
||||
w.result.LLMDebugPath = path
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *promptReportWorkflow) renderAndPublish(raw []byte) (*ReportResult, error) {
|
||||
generatedText, _, err := w.handler.Validate(raw)
|
||||
if err != nil {
|
||||
return w.result, w.reportError("validate generated text", err)
|
||||
}
|
||||
renderContext, err := w.handler.BuildRenderContext(w.briefingMetadata, w.moduleSnapshot, w.reportFacts.Collected, w.reportFacts.Derived, generatedText)
|
||||
if err != nil {
|
||||
return w.result, w.reportError("build render context", err)
|
||||
}
|
||||
rendered, err := w.handler.Render(renderContext)
|
||||
if err != nil {
|
||||
return w.result, w.reportError("render template", err)
|
||||
}
|
||||
if err := publicationContextError(w.ctx); err != nil {
|
||||
return w.result, w.reportError("publish report", err)
|
||||
}
|
||||
if err := fileutil.WriteFileAtomic(w.req.OutputPath, rendered); err != nil {
|
||||
return w.result, err
|
||||
}
|
||||
w.result.OutputPath = w.req.OutputPath
|
||||
if w.req.noNotify {
|
||||
return w.result, nil
|
||||
}
|
||||
notification, err := notifyReport(w.ctx, w.req.Config, w.req.Resolved, w.result.OutputPath, w.result.RunID, w.result.GeneratedAt, w.req.Notifier)
|
||||
w.result.Notification = notification
|
||||
if err != nil {
|
||||
return w.result, err
|
||||
}
|
||||
return w.result, nil
|
||||
}
|
||||
|
||||
func (w *promptReportWorkflow) reportError(operation string, err error) error {
|
||||
return generatedReportError(w.req.Resolved, w.result.RunID, operation, err)
|
||||
}
|
||||
|
||||
func classifiedPromptError(operation string, err error) error {
|
||||
if promptexec.CategoryOf(err) != "" {
|
||||
return err
|
||||
}
|
||||
return promptexec.NewError(promptexec.Generation, operation, err)
|
||||
}
|
||||
|
||||
func publicationContextError(ctx context.Context) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return promptexec.NewError(promptexec.DeadlineExceeded, "context expired before output publication", err)
|
||||
}
|
||||
return promptexec.NewError(promptexec.Canceled, "context canceled before output publication", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func promptDebugWriteError(err error) error {
|
||||
return promptexec.NewError(promptexec.InvalidConfiguration, "write requested prompt debug artifact", err)
|
||||
}
|
||||
142
internal/app/prompt_inspection.go
Normal file
142
internal/app/prompt_inspection.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
|
||||
// PromptInspectionRequest contains the non-executing inputs required to
|
||||
// validate one report's configured prompt and profile.
|
||||
type PromptInspectionRequest struct {
|
||||
Resolved report.Resolved
|
||||
Executor promptexec.Executor
|
||||
Promptkit config.PromptkitConfig
|
||||
LookupEnv func(string) (string, bool)
|
||||
}
|
||||
|
||||
// PromptInspectionResult contains only safe identity and provenance from a
|
||||
// prompt/profile inspection.
|
||||
type PromptInspectionResult struct {
|
||||
PromptID string
|
||||
PromptVersion string
|
||||
PromptHash string
|
||||
ProfileID string
|
||||
BackendID string
|
||||
ModelName string
|
||||
}
|
||||
|
||||
// PromptExecutionsInspectionRequest validates all prompt/profile combinations
|
||||
// needed by a batch before collection begins.
|
||||
type PromptExecutionsInspectionRequest struct {
|
||||
Resolved []report.Resolved
|
||||
Executor promptexec.Executor
|
||||
Promptkit config.PromptkitConfig
|
||||
LookupEnv func(string) (string, bool)
|
||||
}
|
||||
|
||||
// InspectPromptExecution validates the exact prompt and profile needed for a
|
||||
// report before collection, execution, or durable writes begin.
|
||||
func InspectPromptExecution(ctx context.Context, req PromptInspectionRequest) (PromptInspectionResult, error) {
|
||||
results, err := InspectPromptExecutions(ctx, PromptExecutionsInspectionRequest{
|
||||
Resolved: []report.Resolved{req.Resolved},
|
||||
Executor: req.Executor,
|
||||
Promptkit: req.Promptkit,
|
||||
LookupEnv: req.LookupEnv,
|
||||
})
|
||||
if err != nil {
|
||||
return PromptInspectionResult{}, err
|
||||
}
|
||||
return results[req.Resolved.Definition.ID], nil
|
||||
}
|
||||
|
||||
// InspectPromptExecutions validates exact prompt contracts and their unique
|
||||
// effective profiles. It performs no collection, execution, or durable write.
|
||||
func InspectPromptExecutions(ctx context.Context, req PromptExecutionsInspectionRequest) (map[report.ID]PromptInspectionResult, error) {
|
||||
if req.Executor == nil {
|
||||
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is required", nil)
|
||||
}
|
||||
results := make(map[report.ID]PromptInspectionResult, len(req.Resolved))
|
||||
profiles := map[string]promptexec.ProfileInspection{}
|
||||
for _, resolved := range req.Resolved {
|
||||
definition := resolved.Definition
|
||||
if strings.TrimSpace(definition.PromptID) == "" || strings.TrimSpace(definition.PromptVersion) == "" {
|
||||
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "report prompt identity is incomplete", nil)
|
||||
}
|
||||
inspection, err := req.Executor.InspectPrompt(ctx, definition.PromptID, definition.PromptVersion)
|
||||
if err != nil {
|
||||
return nil, promptInspectionError("prompt inspection failed", err)
|
||||
}
|
||||
if inspection.PromptID != definition.PromptID || inspection.PromptVersion != definition.PromptVersion {
|
||||
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt inspection did not return the requested prompt version", nil)
|
||||
}
|
||||
if !validPromptInput(inspection.Inputs) {
|
||||
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt must declare exactly one required application/yaml data_package input", nil)
|
||||
}
|
||||
if !validPromptOutput(definition, inspection.Output) {
|
||||
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt must declare the report JSON Schema output contract", nil)
|
||||
}
|
||||
profileID := req.Promptkit.Profile
|
||||
if profileID == "" {
|
||||
profileID = inspection.DefaultProfileID
|
||||
}
|
||||
if strings.TrimSpace(profileID) == "" {
|
||||
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt has no execution profile", nil)
|
||||
}
|
||||
profile, ok := profiles[profileID]
|
||||
if !ok {
|
||||
profile, err = inspectPromptProfile(ctx, req.Executor, profileID, req.LookupEnv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profiles[profileID] = profile
|
||||
}
|
||||
results[definition.ID] = PromptInspectionResult{
|
||||
PromptID: inspection.PromptID, PromptVersion: inspection.PromptVersion, PromptHash: inspection.PromptHash,
|
||||
ProfileID: profile.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName,
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func inspectPromptProfile(ctx context.Context, executor promptexec.Executor, profileID string, lookupEnv func(string) (string, bool)) (promptexec.ProfileInspection, error) {
|
||||
profile, err := executor.InspectProfile(ctx, profileID)
|
||||
if err != nil {
|
||||
return promptexec.ProfileInspection{}, promptInspectionError("profile inspection failed", err)
|
||||
}
|
||||
if profile.ProfileID != profileID {
|
||||
return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "profile inspection did not return the selected profile", nil)
|
||||
}
|
||||
if profile.CredentialRequired {
|
||||
return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.MissingCredential, "selected profile requires an unsupported direct API key", nil)
|
||||
}
|
||||
if strings.TrimSpace(profile.APIKeyEnv) != "" {
|
||||
if lookupEnv == nil {
|
||||
lookupEnv = os.LookupEnv
|
||||
}
|
||||
value, present := lookupEnv(profile.APIKeyEnv)
|
||||
if !present || strings.TrimSpace(value) == "" {
|
||||
return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.MissingCredential, "selected profile credential is unavailable", nil)
|
||||
}
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func validPromptInput(inputs []promptexec.InputDefinition) bool {
|
||||
return len(inputs) == 1 && inputs[0].Name == "data_package" && inputs[0].Required && inputs[0].ContentType == "application/yaml"
|
||||
}
|
||||
|
||||
func validPromptOutput(definition report.Definition, output promptexec.OutputContract) bool {
|
||||
return output.Format == "json" && output.ValidationMode == "json_schema" && output.SchemaPath == definition.GeneratedTextSchemaID+".generated_text.schema.json"
|
||||
}
|
||||
|
||||
func promptInspectionError(operation string, err error) error {
|
||||
if promptexec.CategoryOf(err) != "" {
|
||||
return err
|
||||
}
|
||||
return promptexec.NewError(promptexec.InvalidConfiguration, operation, err)
|
||||
}
|
||||
201
internal/app/prompt_inspection_test.go
Normal file
201
internal/app/prompt_inspection_test.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
|
||||
func TestInspectPromptExecutionSelectsDefaultAndOverrideProfiles(t *testing.T) {
|
||||
resolved := inspectionResolved(t)
|
||||
executor := &inspectionExecutor{
|
||||
prompt: validPromptInspection(resolved.Definition),
|
||||
profiles: map[string]promptexec.ProfileInspection{
|
||||
"default-profile": {ProfileID: "default-profile", BackendID: "local", ModelName: "default-model"},
|
||||
"override-profile": {ProfileID: "override-profile", BackendID: "cloud", ModelName: "override-model"},
|
||||
},
|
||||
}
|
||||
defaultResult, err := InspectPromptExecution(context.Background(), PromptInspectionRequest{Resolved: resolved, Executor: executor})
|
||||
if err != nil {
|
||||
t.Fatalf("InspectPromptExecution(default) error = %v", err)
|
||||
}
|
||||
if defaultResult.ProfileID != "default-profile" || defaultResult.ModelName != "default-model" {
|
||||
t.Fatalf("default result = %#v", defaultResult)
|
||||
}
|
||||
overrideResult, err := InspectPromptExecution(context.Background(), PromptInspectionRequest{
|
||||
Resolved: resolved, Executor: executor, Promptkit: config.PromptkitConfig{Profile: "override-profile"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InspectPromptExecution(override) error = %v", err)
|
||||
}
|
||||
if overrideResult.ProfileID != "override-profile" || overrideResult.ModelName != "override-model" {
|
||||
t.Fatalf("override result = %#v", overrideResult)
|
||||
}
|
||||
if len(executor.promptRequests) != 2 || executor.promptRequests[0].version != resolved.Definition.PromptVersion || executor.profileRequests[0] != "default-profile" || executor.profileRequests[1] != "override-profile" {
|
||||
t.Fatalf("inspection requests = prompts %#v profiles %#v", executor.promptRequests, executor.profileRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectPromptExecutionRejectsInvalidContractsAndCredentials(t *testing.T) {
|
||||
resolved := inspectionResolved(t)
|
||||
basePrompt := validPromptInspection(resolved.Definition)
|
||||
tests := []struct {
|
||||
name string
|
||||
prompt promptexec.PromptInspection
|
||||
profile promptexec.ProfileInspection
|
||||
lookupEnv func(string) (string, bool)
|
||||
wantCategory promptexec.ErrorCategory
|
||||
}{
|
||||
{
|
||||
name: "extra input",
|
||||
prompt: func() promptexec.PromptInspection {
|
||||
value := basePrompt
|
||||
value.Inputs = append(value.Inputs, promptexec.InputDefinition{Name: "unexpected"})
|
||||
return value
|
||||
}(),
|
||||
wantCategory: promptexec.InvalidConfiguration,
|
||||
},
|
||||
{
|
||||
name: "wrong schema",
|
||||
prompt: func() promptexec.PromptInspection {
|
||||
value := basePrompt
|
||||
value.Output.SchemaPath = "unexpected.schema.json"
|
||||
return value
|
||||
}(),
|
||||
wantCategory: promptexec.InvalidConfiguration,
|
||||
},
|
||||
{
|
||||
name: "direct key",
|
||||
prompt: basePrompt,
|
||||
profile: promptexec.ProfileInspection{ProfileID: "default-profile", CredentialRequired: true},
|
||||
wantCategory: promptexec.MissingCredential,
|
||||
},
|
||||
{
|
||||
name: "missing environment credential",
|
||||
prompt: basePrompt,
|
||||
profile: promptexec.ProfileInspection{ProfileID: "default-profile", APIKeyEnv: "PROMPT_API_KEY"},
|
||||
lookupEnv: func(string) (string, bool) { return "", false },
|
||||
wantCategory: promptexec.MissingCredential,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
executor := &inspectionExecutor{prompt: test.prompt, profiles: map[string]promptexec.ProfileInspection{"default-profile": test.profile}}
|
||||
_, err := InspectPromptExecution(context.Background(), PromptInspectionRequest{Resolved: resolved, Executor: executor, LookupEnv: test.lookupEnv})
|
||||
if err == nil || promptexec.CategoryOf(err) != test.wantCategory {
|
||||
t.Fatalf("error/category = %v/%q, want %q", err, promptexec.CategoryOf(err), test.wantCategory)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectPromptExecutionReturnsSafeInspectionError(t *testing.T) {
|
||||
resolved := inspectionResolved(t)
|
||||
executor := &inspectionExecutor{promptErr: errors.New("provider response contains resolved-secret-value")}
|
||||
_, err := InspectPromptExecution(context.Background(), PromptInspectionRequest{Resolved: resolved, Executor: executor})
|
||||
if err == nil || promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
|
||||
t.Fatalf("error/category = %v/%q", err, promptexec.CategoryOf(err))
|
||||
}
|
||||
if strings.Contains(err.Error(), "resolved-secret-value") {
|
||||
t.Fatalf("inspection error leaks provider value: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectPromptExecutionsReusesEffectiveProfile(t *testing.T) {
|
||||
first := inspectionResolved(t)
|
||||
second := first
|
||||
second.Definition.ID = report.Today
|
||||
second.Definition.PromptID = "weather.today"
|
||||
executor := &inspectionExecutor{
|
||||
prompt: validPromptInspection(first.Definition),
|
||||
profiles: map[string]promptexec.ProfileInspection{
|
||||
"default-profile": {ProfileID: "default-profile", BackendID: "local", ModelName: "model"},
|
||||
},
|
||||
}
|
||||
executor.prompts = map[string]promptexec.PromptInspection{
|
||||
first.Definition.PromptID: validPromptInspection(first.Definition),
|
||||
second.Definition.PromptID: validPromptInspection(second.Definition),
|
||||
}
|
||||
results, err := InspectPromptExecutions(context.Background(), PromptExecutionsInspectionRequest{Resolved: []report.Resolved{first, second}, Executor: executor})
|
||||
if err != nil {
|
||||
t.Fatalf("InspectPromptExecutions() error = %v", err)
|
||||
}
|
||||
if len(results) != 2 || len(executor.profileRequests) != 1 {
|
||||
t.Fatalf("results/profile requests = %#v/%#v, want two results and one profile inspection", results, executor.profileRequests)
|
||||
}
|
||||
}
|
||||
|
||||
type inspectionPromptRequest struct {
|
||||
id string
|
||||
version string
|
||||
}
|
||||
|
||||
type inspectionExecutor struct {
|
||||
prompt promptexec.PromptInspection
|
||||
prompts map[string]promptexec.PromptInspection
|
||||
profiles map[string]promptexec.ProfileInspection
|
||||
promptErr error
|
||||
promptRequests []inspectionPromptRequest
|
||||
profileRequests []string
|
||||
}
|
||||
|
||||
func (e *inspectionExecutor) InspectPrompt(_ context.Context, id string, version string) (promptexec.PromptInspection, error) {
|
||||
e.promptRequests = append(e.promptRequests, inspectionPromptRequest{id: id, version: version})
|
||||
if e.promptErr != nil {
|
||||
return promptexec.PromptInspection{}, e.promptErr
|
||||
}
|
||||
if prompt, ok := e.prompts[id]; ok {
|
||||
return prompt, nil
|
||||
}
|
||||
return e.prompt, nil
|
||||
}
|
||||
|
||||
func (e *inspectionExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) {
|
||||
e.profileRequests = append(e.profileRequests, id)
|
||||
value, ok := e.profiles[id]
|
||||
if !ok {
|
||||
return promptexec.ProfileInspection{}, errors.New("profile missing")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (e *inspectionExecutor) Execute(context.Context, promptexec.ExecuteRequest, promptexec.PreparationCallback) (*promptexec.Execution, error) {
|
||||
return nil, errors.New("unexpected execution")
|
||||
}
|
||||
|
||||
func inspectionResolved(t *testing.T) report.Resolved {
|
||||
t.Helper()
|
||||
resolved, err := report.DefaultRegistry().Resolve(report.Daily, report.ResolveRequest{
|
||||
Now: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC),
|
||||
Date: time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC),
|
||||
Location: time.UTC,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func validPromptInspection(definition report.Definition) promptexec.PromptInspection {
|
||||
return promptexec.PromptInspection{
|
||||
PromptID: definition.PromptID, PromptVersion: definition.PromptVersion, PromptHash: "prompt-hash", DefaultProfileID: "default-profile",
|
||||
Inputs: []promptexec.InputDefinition{{Name: "data_package", Required: true, ContentType: "application/yaml"}},
|
||||
Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: definition.GeneratedTextSchemaID + ".generated_text.schema.json"},
|
||||
}
|
||||
}
|
||||
|
||||
func logicalPromptInspection(definition report.Definition) promptexec.PromptInspection {
|
||||
inspection := validPromptInspection(definition)
|
||||
if definition.ID == report.Hourly {
|
||||
inspection.DefaultProfileID = "weather-light"
|
||||
} else {
|
||||
inspection.DefaultProfileID = "weather-balanced"
|
||||
}
|
||||
return inspection
|
||||
}
|
||||
73
internal/app/prompt_profile_integration_test.go
Normal file
73
internal/app/prompt_profile_integration_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package app_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
promptkitadapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/promptkit"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
|
||||
func TestPromptInspectionResolvesEmbeddedAndOverriddenProfilesOffline(t *testing.T) {
|
||||
lookupEnv := func(string) (string, bool) { return "test-key", true }
|
||||
inspect := func(t *testing.T, adapter *promptkitadapter.Adapter, id report.ID, profile string, wantID string, wantBackend string, wantModel string) {
|
||||
t.Helper()
|
||||
result, err := app.InspectPromptExecution(context.Background(), app.PromptInspectionRequest{
|
||||
Resolved: resolvedPromptProfile(t, id),
|
||||
Executor: adapter,
|
||||
Promptkit: config.PromptkitConfig{Profile: profile},
|
||||
LookupEnv: lookupEnv,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InspectPromptExecution() error = %v", err)
|
||||
}
|
||||
if result.ProfileID != wantID || result.BackendID != wantBackend || result.ModelName != wantModel {
|
||||
t.Fatalf("inspection = %#v, want profile/backend/model %q/%q/%q", result, wantID, wantBackend, wantModel)
|
||||
}
|
||||
}
|
||||
|
||||
embedded, err := promptkitadapter.New(promptkitadapter.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("New(embedded) error = %v", err)
|
||||
}
|
||||
inspect(t, embedded, report.Hourly, "", "weather-light", "openrouter", "deepseek/deepseek-v4-flash")
|
||||
inspect(t, embedded, report.Daily, "", "weather-balanced", "openrouter", "~google/gemini-flash-latest")
|
||||
inspect(t, embedded, report.Daily, "weather-deep", "weather-deep", "openrouter", "~anthropic/claude-sonnet-latest")
|
||||
|
||||
override, err := promptkitadapter.New(promptkitadapter.Config{ProfileFile: writeProfileFile(t, `id: weather-light
|
||||
endpoint: https://local.example/v1
|
||||
model: local-weather
|
||||
`)})
|
||||
if err != nil {
|
||||
t.Fatalf("New(override) error = %v", err)
|
||||
}
|
||||
inspect(t, override, report.Hourly, "", "weather-light", "", "local-weather")
|
||||
}
|
||||
|
||||
func resolvedPromptProfile(t *testing.T, id report.ID) report.Resolved {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)
|
||||
request := report.ResolveRequest{Now: now, Location: time.UTC}
|
||||
if id == report.Daily {
|
||||
request.Date = now
|
||||
}
|
||||
resolved, err := report.DefaultRegistry().Resolve(id, request)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve(%q) error = %v", id, err)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func writeProfileFile(t *testing.T, profile string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "profile.yml")
|
||||
if err := os.WriteFile(path, []byte(profile), 0o600); err != nil {
|
||||
t.Fatalf("write profile: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
21
internal/app/test_helpers_test.go
Normal file
21
internal/app/test_helpers_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func mustParse(value string) time.Time {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func requireNoError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
68
internal/briefing/alert_digest_module.go
Normal file
68
internal/briefing/alert_digest_module.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type AlertDigestModule struct {
|
||||
Checked bool `json:"checked"`
|
||||
ActiveCount int `json:"active_count"`
|
||||
RelevantCount int `json:"relevant_count"`
|
||||
Missing bool `json:"missing,omitempty"`
|
||||
Relevant []AlertSummary `json:"relevant,omitempty"`
|
||||
}
|
||||
|
||||
type AlertSummary struct {
|
||||
Event string `json:"event,omitempty"`
|
||||
Headline string `json:"headline,omitempty"`
|
||||
Severity string `json:"severity,omitempty"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
Instruction string `json:"instruction,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
func buildAlertDigestModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
value := alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps, ctx.Timezone)
|
||||
if value == nil {
|
||||
value = &AlertDigestModule{}
|
||||
}
|
||||
return &module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: *value}, nil
|
||||
}
|
||||
|
||||
func alertDigest(collected facts.CollectedFacts, overlaps []forecast.AlertOverlap, timezone string) *AlertDigestModule {
|
||||
missing := sourceMissing(collected.SourceProvenance, "alerts")
|
||||
if collected.Alerts == nil && !missing {
|
||||
return nil
|
||||
}
|
||||
value := &AlertDigestModule{Missing: missing}
|
||||
if collected.Alerts != nil {
|
||||
value.Checked = true
|
||||
value.ActiveCount = len(collected.Alerts.Alerts)
|
||||
}
|
||||
value.RelevantCount = len(overlaps)
|
||||
for _, overlap := range overlaps {
|
||||
value.Relevant = append(value.Relevant, AlertSummary{
|
||||
Event: overlap.Event,
|
||||
Headline: overlap.Headline,
|
||||
Severity: overlap.Severity,
|
||||
PeriodBegins: friendlyMonthDayTimeLabel(overlap.Period.Start, timezone),
|
||||
PeriodEnds: friendlyMonthDayTimeLabel(overlap.Period.End, timezone),
|
||||
Instruction: overlap.Instruction,
|
||||
Description: overlap.Description,
|
||||
})
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func sourceMissing(sources []weatherdata.Source, name string) bool {
|
||||
for _, source := range sources {
|
||||
if source.Name == name && source.Missing {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
67
internal/briefing/area_forecast_discussion_module.go
Normal file
67
internal/briefing/area_forecast_discussion_module.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
)
|
||||
|
||||
type AreaForecastDiscussionModule struct {
|
||||
Product string `json:"product,omitempty"`
|
||||
KeyMessages []string `json:"key_messages,omitempty"`
|
||||
ShortTerm string `json:"short_term,omitempty"`
|
||||
LongTerm string `json:"long_term,omitempty"`
|
||||
}
|
||||
|
||||
func buildAreaForecastDiscussionModule(ctx ModuleContext, options any) (*module.Output, error) {
|
||||
discussion := ctx.Collected.Discussion
|
||||
if discussion == nil {
|
||||
return nil, nil
|
||||
}
|
||||
opts, ok := options.(module.AreaForecastDiscussionOptions)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("area forecast discussion options have type %T", options)
|
||||
}
|
||||
sections, err := areaForecastDiscussionSections(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value := AreaForecastDiscussionModule{}
|
||||
if sections["product"] {
|
||||
value.Product = discussion.Product
|
||||
}
|
||||
if sections["key_messages"] {
|
||||
value.KeyMessages = append([]string(nil), discussion.KeyMessages...)
|
||||
}
|
||||
if sections["short_term"] && discussion.ShortTerm != nil {
|
||||
value.ShortTerm = discussion.ShortTerm.Text
|
||||
}
|
||||
if sections["long_term"] && discussion.LongTerm != nil {
|
||||
value.LongTerm = discussion.LongTerm.Text
|
||||
}
|
||||
if value.Product == "" && len(value.KeyMessages) == 0 && value.ShortTerm == "" && value.LongTerm == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return &module.Output{ID: module.AreaForecastDiscussion, StanzaName: "area_forecast_discussion", Value: value}, nil
|
||||
}
|
||||
|
||||
func areaForecastDiscussionSections(options module.AreaForecastDiscussionOptions) (map[string]bool, error) {
|
||||
if len(options.Sections) == 0 {
|
||||
return map[string]bool{
|
||||
"product": true,
|
||||
"key_messages": true,
|
||||
"short_term": true,
|
||||
"long_term": true,
|
||||
}, nil
|
||||
}
|
||||
sections := map[string]bool{}
|
||||
for _, section := range options.Sections {
|
||||
switch section {
|
||||
case "product", "key_messages", "short_term", "long_term":
|
||||
sections[section] = true
|
||||
default:
|
||||
return nil, fmt.Errorf("area forecast discussion section %q is not supported", section)
|
||||
}
|
||||
}
|
||||
return sections, nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"categorical:TSTM": {
|
||||
"plain_language": "General or non-severe thunderstorms.",
|
||||
"official_description": "No severe thunderstorms expected.",
|
||||
"relative_level": "0 of 5"
|
||||
},
|
||||
"categorical:MRGL": {
|
||||
"plain_language": "Isolated severe storms possible.",
|
||||
"official_description": "Isolated severe storms may occur within the risk area, but they are expected to be limited in duration, coverage, and intensity.",
|
||||
"relative_level": "1 of 5"
|
||||
},
|
||||
"categorical:SLGT": {
|
||||
"plain_language": "Scattered severe storms possible.",
|
||||
"official_description": "Isolated intense storms are possible within the risk area, but severe weather is generally expected to be short-lived and/or not widespread.",
|
||||
"relative_level": "2 of 5"
|
||||
},
|
||||
"categorical:ENH": {
|
||||
"plain_language": "Numerous severe storms possible.",
|
||||
"official_description": "Numerous severe storms are possible within the risk area, some of which may be intense.",
|
||||
"relative_level": "3 of 5"
|
||||
},
|
||||
"categorical:MDT": {
|
||||
"plain_language": "Widespread severe storms likely.",
|
||||
"official_description": "Widespread severe storms are likely within the risk area. Storms may be long-lived, widespread, and intense. This risk is usually reserved for days with several supercells producing intense tornadoes and/or very large hail, or an intense squall line with widespread damaging winds.",
|
||||
"relative_level": "4 of 5"
|
||||
},
|
||||
"categorical:HIGH": {
|
||||
"plain_language": "Major severe outbreak expected.",
|
||||
"official_description": "A major severe weather outbreak is expected, with long-lived, very widespread, and particularly intense severe storms. This risk is reserved for when high confidence exists in widespread coverage of severe weather with embedded instances of extreme severity (i.e., violent tornadoes or very damaging convective wind events).",
|
||||
"relative_level": "5 of 5"
|
||||
},
|
||||
"tornado:CIG1": {
|
||||
"plain_language": "Conditional potential for significant tornadoes.",
|
||||
"official_description": "Intensity Level 1: Reasonable Max EF2. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
|
||||
"relative_level": "1 of 3"
|
||||
},
|
||||
"tornado:CIG2": {
|
||||
"plain_language": "Conditional potential for strong tornadoes.",
|
||||
"official_description": "Intensity Level 2: Reasonable Max EF3. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
|
||||
"relative_level": "2 of 3"
|
||||
},
|
||||
"tornado:CIG3": {
|
||||
"plain_language": "Conditional potential for violent tornadoes.",
|
||||
"official_description": "Intensity Level 3: Reasonable Max EF4 or higher. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
|
||||
"relative_level": "3 of 3"
|
||||
},
|
||||
"wind:CIG1": {
|
||||
"plain_language": "Conditional potential for significant severe wind.",
|
||||
"official_description": "Intensity Level 1: Reasonable Max wind gusts around 65 kt / 75 mph or higher. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
|
||||
"relative_level": "1 of 3"
|
||||
},
|
||||
"wind:CIG2": {
|
||||
"plain_language": "Conditional potential for intense severe wind.",
|
||||
"official_description": "Intensity Level 2: Reasonable Max wind gusts around 75 kt / 85 mph or higher. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
|
||||
"relative_level": "2 of 3"
|
||||
},
|
||||
"wind:CIG3": {
|
||||
"plain_language": "Conditional potential for extreme severe wind.",
|
||||
"official_description": "Intensity Level 3: Reasonable Max wind gusts around 100 kt / 115 mph or higher. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
|
||||
"relative_level": "3 of 3"
|
||||
},
|
||||
"hail:CIG1": {
|
||||
"plain_language": "Conditional potential for significant hail.",
|
||||
"official_description": "Intensity Level 1: Reasonable Max hail size around 2.00 to 3.75 inches. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
|
||||
"relative_level": "1 of 2"
|
||||
},
|
||||
"hail:CIG2": {
|
||||
"plain_language": "Conditional potential for giant hail.",
|
||||
"official_description": "Intensity Level 2: Reasonable Max hail size greater than 3.75 inches. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
|
||||
"relative_level": "2 of 2"
|
||||
}
|
||||
}
|
||||
735
internal/briefing/base_modules_test.go
Normal file
735
internal/briefing/base_modules_test.go
Normal file
@@ -0,0 +1,735 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
func TestBaseModulesBuildAvailableSourceOutputs(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
|
||||
tests := []struct {
|
||||
id module.ID
|
||||
stanza string
|
||||
}{
|
||||
{id: module.Metadata, stanza: "metadata"},
|
||||
{id: module.CurrentConditions, stanza: "current_conditions"},
|
||||
{id: module.NarrativeForecast, stanza: "narrative_forecast"},
|
||||
{id: module.HourlyForecast, stanza: "hourly_forecast"},
|
||||
{id: module.AlertDigest, stanza: "alert_digest"},
|
||||
{id: module.AreaForecastDiscussion, stanza: "area_forecast_discussion"},
|
||||
{id: module.WeatherStory, stanza: "weather_story"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.id), func(t *testing.T) {
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: tt.id})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
if output == nil {
|
||||
t.Fatal("BuildModule() output = nil, want stanza")
|
||||
}
|
||||
if output.ID != tt.id || output.StanzaName != tt.stanza {
|
||||
t.Fatalf("output = %#v, want id %q stanza %q", output, tt.id, tt.stanza)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[HourlyForecastModule](t, output)
|
||||
if value.Product != "hourly" || value.SourceLocationID != "test-grid" || len(value.Periods) != 1 {
|
||||
t.Fatalf("HourlyForecast = %#v, want hourly metadata and one valid-period period", value)
|
||||
}
|
||||
period := value.Periods[0]
|
||||
if period.HourLabel != "8:00 AM" || period.TextDescription != "Showers likely." || period.TextDescriptionLower != "showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 {
|
||||
t.Fatalf("HourlyForecast period = %#v, want hourly period facts", period)
|
||||
}
|
||||
if period.PeriodBegins != "2026-05-29 at 8:00 AM" || period.PeriodEnds != "2026-05-29 at 9:00 AM" {
|
||||
t.Fatalf("HourlyForecast period times = %q/%q, want friendly local time labels", period.PeriodBegins, period.PeriodEnds)
|
||||
}
|
||||
if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
|
||||
t.Fatalf("HourlyForecast period = %#v, want compass wind and precip chance", period)
|
||||
}
|
||||
if !period.MentionPrecipitation {
|
||||
t.Fatalf("MentionPrecipitation = false, want true for default threshold")
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal hourly forecast: %v", err)
|
||||
}
|
||||
jsonText := string(data)
|
||||
for _, field := range []string{"source_location_id", "hour_label", "period_begins", "period_ends", "text_description", "text_description_lower", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "mention_precipitation", "relative_humidity_percent"} {
|
||||
if !strings.Contains(jsonText, field) {
|
||||
t.Fatalf("hourly json = %s, want field %s", jsonText, field)
|
||||
}
|
||||
}
|
||||
if strings.Contains(jsonText, "wind_direction_degrees") || strings.Contains(jsonText, "Tomorrow") {
|
||||
t.Fatalf("hourly json = %s, want valid-period prompt fields only", jsonText)
|
||||
}
|
||||
if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) {
|
||||
t.Fatalf("hourly json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHourlyForecastPromptExportOmitsTemplateHelpers(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
richText := mustMarshalModuleJSON(t, output.Value)
|
||||
for _, field := range []string{"hour_label", "text_description_lower", "mention_precipitation"} {
|
||||
if !strings.Contains(richText, field) {
|
||||
t.Fatalf("rich hourly json = %s, want helper field %s", richText, field)
|
||||
}
|
||||
}
|
||||
|
||||
prompt := moduleDataPackageValue[HourlyForecastPromptExport](t, output)
|
||||
if prompt.Product != "hourly" || prompt.SourceLocationID != "test-grid" || len(prompt.Periods) != 1 {
|
||||
t.Fatalf("hourly prompt export = %#v, want hourly metadata and one period", prompt)
|
||||
}
|
||||
period := prompt.Periods[0]
|
||||
if period.PeriodBegins != "2026-05-29 at 8:00 AM" || period.PeriodEnds != "2026-05-29 at 9:00 AM" || period.TextDescription != "Showers likely." {
|
||||
t.Fatalf("hourly prompt period = %#v, want factual period fields", period)
|
||||
}
|
||||
if period.TemperatureF == nil || *period.TemperatureF != 76 || period.WindSpeedMph == nil || *period.WindSpeedMph != 14 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
|
||||
t.Fatalf("hourly prompt period = %#v, want temperature, wind, and precip fields", period)
|
||||
}
|
||||
if period.WindDirection != "S" || period.RelativeHumidityPercent == nil || *period.RelativeHumidityPercent != 66 {
|
||||
t.Fatalf("hourly prompt period = %#v, want wind direction and humidity", period)
|
||||
}
|
||||
promptText := mustMarshalModuleJSON(t, output.DataPackageValue())
|
||||
for _, field := range []string{"period_begins", "period_ends", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} {
|
||||
if !strings.Contains(promptText, field) {
|
||||
t.Fatalf("hourly prompt json = %s, want field %s", promptText, field)
|
||||
}
|
||||
}
|
||||
for _, field := range []string{"hour_label", "text_description_lower", "mention_precipitation"} {
|
||||
if strings.Contains(promptText, field) {
|
||||
t.Fatalf("hourly prompt json = %s, want omitted helper field %s", promptText, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHourlyForecastPrecipMentionThreshold(t *testing.T) {
|
||||
periods := []weatherdata.ForecastPeriod{
|
||||
{StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"), ProbabilityOfPrecipitationPercent: floatPtr(19)},
|
||||
{StartTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"), ProbabilityOfPrecipitationPercent: floatPtr(20)},
|
||||
{StartTime: mustParseModuleTime("2026-05-29T10:00:00-05:00")},
|
||||
}
|
||||
value := hourlyForecastPeriodsWithPrecipMentionThreshold(periods, "America/Chicago", DefaultHourlyForecastPrecipMentionProbabilityThreshold)
|
||||
if len(value) != 3 {
|
||||
t.Fatalf("periods length = %d, want 3", len(value))
|
||||
}
|
||||
if value[0].MentionPrecipitation {
|
||||
t.Fatalf("19%% MentionPrecipitation = true, want false")
|
||||
}
|
||||
if !value[1].MentionPrecipitation {
|
||||
t.Fatalf("20%% MentionPrecipitation = false, want true")
|
||||
}
|
||||
if value[2].MentionPrecipitation {
|
||||
t.Fatalf("nil MentionPrecipitation = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHourlyForecastModuleRejectsUnsupportedReports(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Resolved.Definition = report.Definition{ID: report.ID("unsupported")}
|
||||
|
||||
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
|
||||
if err == nil || !strings.Contains(err.Error(), `module "hourly_forecast" is not compatible with report "unsupported"`) {
|
||||
t.Fatalf("BuildModule() error = %v, want incompatible report", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHourlyForecastModuleBuildsForHourly(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Hourly)
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[HourlyForecastModule](t, output)
|
||||
if len(value.Periods) != 1 || value.Periods[0].TextDescription != "Showers likely." {
|
||||
t.Fatalf("HourlyForecast = %#v, want hourly report period", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.NarrativeForecast})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[NarrativeForecastModule](t, output)
|
||||
if value.Product != "narrative" || value.SourceLocationID != "test-grid" || len(value.Periods) != 1 {
|
||||
t.Fatalf("NarrativeForecast = %#v, want narrative metadata and one valid-period period", value)
|
||||
}
|
||||
period := value.Periods[0]
|
||||
if period.Name != "Today" || period.TextDescription != "Morning storms, then partly sunny." {
|
||||
t.Fatalf("NarrativeForecast period = %#v, want Today narrative", period)
|
||||
}
|
||||
if period.PeriodBegins != "2026-05-29 at 6:00 AM" || period.PeriodEnds != "2026-05-29 at 6:00 PM" {
|
||||
t.Fatalf("NarrativeForecast period times = %q/%q, want friendly local time labels", period.PeriodBegins, period.PeriodEnds)
|
||||
}
|
||||
if period.IsDay == nil || !*period.IsDay || period.TemperatureF == nil || *period.TemperatureF != 81 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 60 {
|
||||
t.Fatalf("NarrativeForecast period = %#v, want day, temperature, and precip values", period)
|
||||
}
|
||||
if period.WindDirection != "NE" {
|
||||
t.Fatalf("NarrativeForecast period wind direction = %q, want NE", period.WindDirection)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal narrative forecast: %v", err)
|
||||
}
|
||||
jsonText := string(data)
|
||||
for _, field := range []string{"source_location_id", "period_begins", "period_ends", "text_description", "temperature_f", "wind_speed_mph", "wind_direction", "probability_of_precipitation_percent"} {
|
||||
if !strings.Contains(jsonText, field) {
|
||||
t.Fatalf("narrative json = %s, want field %s", jsonText, field)
|
||||
}
|
||||
}
|
||||
if strings.Contains(jsonText, "wind_direction_degrees") {
|
||||
t.Fatalf("narrative json = %s, want compass wind_direction without degrees field", jsonText)
|
||||
}
|
||||
if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) {
|
||||
t.Fatalf("narrative json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText)
|
||||
}
|
||||
if strings.Contains(jsonText, "Tomorrow night") {
|
||||
t.Fatalf("narrative json = %s, want only valid-period narrative periods", jsonText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNarrativeForecastModuleRejectsUnsupportedReports(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Resolved.Definition = report.Definition{ID: report.ID("unsupported")}
|
||||
|
||||
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.NarrativeForecast})
|
||||
if err == nil || !strings.Contains(err.Error(), `module "narrative_forecast" is not compatible with report "unsupported"`) {
|
||||
t.Fatalf("BuildModule() error = %v, want incompatible report", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.Metadata})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[MetadataModule](t, output)
|
||||
if value.RunID == "" || value.ReportID != report.Daily || value.PromptID != "weather.daily_generated_text" {
|
||||
t.Fatalf("metadata = %#v, want report identity", value)
|
||||
}
|
||||
if value.Location == nil || value.Location.Name != "Brentwood" {
|
||||
t.Fatalf("Location = %#v, want configured location", value.Location)
|
||||
}
|
||||
if len(value.SourceWarnings) != 1 || value.SourceWarnings[0].CompletenessImpact != "source omitted" {
|
||||
t.Fatalf("SourceWarnings = %#v, want warning summary", value.SourceWarnings)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal metadata: %v", err)
|
||||
}
|
||||
jsonText := string(data)
|
||||
if !strings.Contains(jsonText, "source_warnings") || strings.Contains(jsonText, "endpoint") || strings.Contains(jsonText, "dataSha256") {
|
||||
t.Fatalf("metadata json = %s, want source warning summary without transport provenance", jsonText)
|
||||
}
|
||||
if strings.Contains(jsonText, `"alerts"`) {
|
||||
t.Fatalf("metadata json = %s, want alert details only in alert_digest", jsonText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.CurrentConditions})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[CurrentConditionsModule](t, output)
|
||||
if value.ConditionText != "Partly cloudy" || value.ConditionTextLower != "partly cloudy" || value.TemperatureF == nil || *value.TemperatureF != 74 {
|
||||
t.Fatalf("CurrentConditions = %#v, want rounded current condition facts", value)
|
||||
}
|
||||
if value.ApparentTemperatureF == nil || *value.ApparentTemperatureF != 76 || value.RelativeHumidityPercent == nil || *value.RelativeHumidityPercent != 71 || value.WindSpeedMph == nil || *value.WindSpeedMph != 8 {
|
||||
t.Fatalf("CurrentConditions rounded values = %#v, want apparent 76, humidity 71, wind 8", value)
|
||||
}
|
||||
if value.WindDirection != "S" {
|
||||
t.Fatalf("WindDirection = %q, want S", value.WindDirection)
|
||||
}
|
||||
if value.WindDirectionText != "south" {
|
||||
t.Fatalf("WindDirectionText = %q, want south", value.WindDirectionText)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal current conditions: %v", err)
|
||||
}
|
||||
jsonText := string(data)
|
||||
for _, field := range []string{"condition_text", "condition_text_lower", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction", "wind_direction_text"} {
|
||||
if !strings.Contains(jsonText, field) {
|
||||
t.Fatalf("current json = %s, want field %s", jsonText, field)
|
||||
}
|
||||
}
|
||||
if strings.Contains(jsonText, "wind_direction_degrees") {
|
||||
t.Fatalf("current json = %s, want compass wind_direction without degrees field", jsonText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentConditionsPromptExportOmitsTemplateHelpers(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.CurrentConditions})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
richText := mustMarshalModuleJSON(t, output.Value)
|
||||
for _, field := range []string{"condition_text_lower", "wind_direction_text"} {
|
||||
if !strings.Contains(richText, field) {
|
||||
t.Fatalf("rich current conditions json = %s, want helper field %s", richText, field)
|
||||
}
|
||||
}
|
||||
|
||||
prompt := moduleDataPackageValue[CurrentConditionsPromptExport](t, output)
|
||||
if prompt.ConditionText != "Partly cloudy" || prompt.TemperatureF == nil || *prompt.TemperatureF != 74 {
|
||||
t.Fatalf("current prompt export = %#v, want condition text and temperature", prompt)
|
||||
}
|
||||
if prompt.ApparentTemperatureF == nil || *prompt.ApparentTemperatureF != 76 || prompt.RelativeHumidityPercent == nil || *prompt.RelativeHumidityPercent != 71 || prompt.WindSpeedMph == nil || *prompt.WindSpeedMph != 8 {
|
||||
t.Fatalf("current prompt export = %#v, want apparent temperature, humidity, and wind speed", prompt)
|
||||
}
|
||||
if prompt.WindDirection != "S" {
|
||||
t.Fatalf("current prompt wind direction = %q, want S", prompt.WindDirection)
|
||||
}
|
||||
promptText := mustMarshalModuleJSON(t, output.DataPackageValue())
|
||||
for _, field := range []string{"condition_text", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction"} {
|
||||
if !strings.Contains(promptText, field) {
|
||||
t.Fatalf("current prompt json = %s, want field %s", promptText, field)
|
||||
}
|
||||
}
|
||||
for _, field := range []string{"condition_text_lower", "wind_direction_text"} {
|
||||
if strings.Contains(promptText, field) {
|
||||
t.Fatalf("current prompt json = %s, want omitted helper field %s", promptText, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Collected.Alerts = &weatherdata.AlertRun{}
|
||||
ctx.Derived.AlertOverlaps = nil
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.AlertDigest})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(checked empty) error = %v", err)
|
||||
}
|
||||
checked := moduleValue[AlertDigestModule](t, output)
|
||||
if !checked.Checked || checked.ActiveCount != 0 || checked.RelevantCount != 0 || checked.Missing {
|
||||
t.Fatalf("checked empty alert digest = %#v, want checked/no active", checked)
|
||||
}
|
||||
|
||||
ctx.Collected.Alerts = nil
|
||||
ctx.Collected.SourceProvenance = []weatherdata.Source{{Name: "alerts", Missing: true}}
|
||||
output, err = registry.BuildModule(ctx, module.ConfigItem{ID: module.AlertDigest})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(missing) error = %v", err)
|
||||
}
|
||||
missing := moduleValue[AlertDigestModule](t, output)
|
||||
if missing.Checked || !missing.Missing {
|
||||
t.Fatalf("missing alert digest = %#v, want missing unchecked source", missing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertDigestIncludesPeriodAndGuidance(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Collected.Alerts = &weatherdata.AlertRun{Alerts: []json.RawMessage{json.RawMessage(`{"event":"Wind Advisory"}`)}}
|
||||
ctx.Derived.AlertOverlaps = []forecast.AlertOverlap{{
|
||||
Event: "Wind Advisory",
|
||||
Headline: "Wind Advisory until 8 PM",
|
||||
Severity: "Moderate",
|
||||
Period: timeutil.Period{Start: mustParseModuleTime("2026-06-17T18:00:00Z"), End: mustParseModuleTime("2026-06-18T01:00:00Z")},
|
||||
Instruction: "Secure outdoor objects.",
|
||||
Description: "Gusty winds may blow around unsecured objects.",
|
||||
}}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.AlertDigest})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(alert digest) error = %v", err)
|
||||
}
|
||||
value := moduleValue[AlertDigestModule](t, output)
|
||||
if len(value.Relevant) != 1 {
|
||||
t.Fatalf("Relevant length = %d, want 1", len(value.Relevant))
|
||||
}
|
||||
alert := value.Relevant[0]
|
||||
if alert.Event != "Wind Advisory" || alert.Headline != "Wind Advisory until 8 PM" || alert.Severity != "Moderate" {
|
||||
t.Fatalf("alert identity = %#v, want preserved event/headline/severity", alert)
|
||||
}
|
||||
if alert.PeriodBegins != "June 17 at 1:00 PM" || alert.PeriodEnds != "June 17 at 8:00 PM" {
|
||||
t.Fatalf("alert period = %q/%q, want friendly local labels", alert.PeriodBegins, alert.PeriodEnds)
|
||||
}
|
||||
if alert.Instruction != "Secure outdoor objects." || alert.Description != "Gusty winds may blow around unsecured objects." {
|
||||
t.Fatalf("alert guidance = %#v, want instruction and description preserved", alert)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseModulesOmitMissingOptionalOutputs(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Collected.Current = nil
|
||||
ctx.Collected.Narrative = nil
|
||||
ctx.Collected.Hourly = nil
|
||||
ctx.Derived.ValidPeriodNarrativePeriods = nil
|
||||
ctx.Derived.ValidPeriodHourlyPeriods = nil
|
||||
ctx.Collected.Discussion = nil
|
||||
ctx.Collected.WeatherStory = nil
|
||||
|
||||
for _, id := range []module.ID{module.CurrentConditions, module.NarrativeForecast, module.HourlyForecast, module.AreaForecastDiscussion, module.WeatherStory} {
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: id})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(%s) error = %v", id, err)
|
||||
}
|
||||
if output != nil {
|
||||
t.Fatalf("BuildModule(%s) output = %#v, want omitted", id, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAreaForecastDiscussionAndWeatherStoryModules(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
|
||||
afdOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.AreaForecastDiscussion})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(afd) error = %v", err)
|
||||
}
|
||||
afd := moduleValue[AreaForecastDiscussionModule](t, afdOutput)
|
||||
if len(afd.KeyMessages) != 1 || afd.ShortTerm != "Showers increase this afternoon." || afd.LongTerm != "Periodic rain chances continue." {
|
||||
t.Fatalf("AFD = %#v, want discussion sections", afd)
|
||||
}
|
||||
|
||||
storyOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.WeatherStory})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(weather story) error = %v", err)
|
||||
}
|
||||
story := moduleValue[WeatherStoryModule](t, storyOutput)
|
||||
if !story.Available || story.Title != "Rain Chances" || story.Description != "Scattered showers are possible." {
|
||||
t.Fatalf("WeatherStory = %#v, want structured story fields", story)
|
||||
}
|
||||
if story.PeriodBegins != "2026-05-29 at 6:00 AM" || story.PeriodEnds != "2026-05-29 at 6:00 PM" {
|
||||
t.Fatalf("WeatherStory period = %q/%q, want friendly local period labels", story.PeriodBegins, story.PeriodEnds)
|
||||
}
|
||||
data, err := json.Marshal(storyOutput.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal weather story: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "download_url") || !strings.Contains(string(data), "period_begins") || !strings.Contains(string(data), "period_ends") {
|
||||
t.Fatalf("weather story json = %s, want snake_case download_url", string(data))
|
||||
}
|
||||
if strings.Contains(string(data), "start_time") || strings.Contains(string(data), "end_time") {
|
||||
t.Fatalf("weather story json = %s, want period_begins/period_ends instead of start_time/end_time", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{
|
||||
ID: module.AreaForecastDiscussion,
|
||||
Options: module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
afd := moduleValue[AreaForecastDiscussionModule](t, output)
|
||||
if afd.ShortTerm != "Showers increase this afternoon." {
|
||||
t.Fatalf("ShortTerm = %q, want selected short term section", afd.ShortTerm)
|
||||
}
|
||||
if afd.Product != "" || len(afd.KeyMessages) != 0 || afd.LongTerm != "" {
|
||||
t.Fatalf("AFD = %#v, want only short_term section", afd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAreaForecastDiscussionModuleUsesHourlyDefaultSections(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Hourly)
|
||||
var item module.ConfigItem
|
||||
for _, candidate := range ctx.Resolved.Definition.Modules {
|
||||
if candidate.ID == module.AreaForecastDiscussion {
|
||||
item = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if item.ID == "" {
|
||||
t.Fatal("hourly default modules missing area_forecast_discussion")
|
||||
}
|
||||
|
||||
output, err := registry.BuildModule(ctx, item)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
afd := moduleValue[AreaForecastDiscussionModule](t, output)
|
||||
if len(afd.KeyMessages) != 1 || afd.ShortTerm != "Showers increase this afternoon." {
|
||||
t.Fatalf("AFD = %#v, want key messages and short term", afd)
|
||||
}
|
||||
if afd.Product != "" || afd.LongTerm != "" {
|
||||
t.Fatalf("AFD = %#v, want product and long term omitted", afd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAreaForecastDiscussionModuleUsesDailyDefaultSections(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Daily)
|
||||
var item module.ConfigItem
|
||||
for _, candidate := range ctx.Resolved.Definition.Modules {
|
||||
if candidate.ID == module.AreaForecastDiscussion {
|
||||
item = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if item.ID == "" {
|
||||
t.Fatal("daily default modules missing area_forecast_discussion")
|
||||
}
|
||||
|
||||
output, err := registry.BuildModule(ctx, item)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
afd := moduleValue[AreaForecastDiscussionModule](t, output)
|
||||
if afd.LongTerm != "Periodic rain chances continue." {
|
||||
t.Fatalf("LongTerm = %q, want selected long term section", afd.LongTerm)
|
||||
}
|
||||
if afd.Product != "" || len(afd.KeyMessages) != 0 || afd.ShortTerm != "" {
|
||||
t.Fatalf("AFD = %#v, want only long term section", afd)
|
||||
}
|
||||
}
|
||||
|
||||
func testModuleContext() ModuleContext {
|
||||
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
|
||||
definition := report.DefaultRegistry().MustLookup(report.Daily)
|
||||
resolved := report.Resolved{
|
||||
Definition: definition,
|
||||
GeneratedAt: generatedAt,
|
||||
Timezone: "America/Chicago",
|
||||
ValidPeriod: timeutil.Period{
|
||||
Start: mustParseModuleTime("2026-05-29T00:00:00-05:00"),
|
||||
End: mustParseModuleTime("2026-05-30T00:00:00-05:00"),
|
||||
},
|
||||
}
|
||||
isDay := true
|
||||
tempF := 74.4
|
||||
apparentF := 75.6
|
||||
humidity := 70.6
|
||||
windMph := 8.4
|
||||
windDirection := 190.0
|
||||
narrativeTempF := 81.0
|
||||
narrativePop := 60.0
|
||||
narrativeWind := 12.0
|
||||
narrativeWindDirection := 45.0
|
||||
hourlyTempF := 76.0
|
||||
hourlyPop := 70.0
|
||||
hourlyHumidity := 66.0
|
||||
hourlyWindMph := 14.0
|
||||
updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00")
|
||||
return ModuleContext{
|
||||
Resolved: resolved,
|
||||
Collected: facts.CollectedFacts{
|
||||
Current: &weatherdata.Current{
|
||||
ConditionText: "Partly cloudy",
|
||||
IsDay: &isDay,
|
||||
TemperatureF: &tempF,
|
||||
ApparentTemperatureF: &apparentF,
|
||||
RelativeHumidityPercent: &humidity,
|
||||
WindSpeedMph: &windMph,
|
||||
WindDirectionDegrees: &windDirection,
|
||||
},
|
||||
Narrative: &weatherdata.ForecastRun{
|
||||
LocationID: "test-grid",
|
||||
LocationName: "Testville",
|
||||
IssuedAt: mustParseModuleTime("2026-05-29T10:30:00-05:00"),
|
||||
UpdatedAt: &updatedAt,
|
||||
Product: "narrative",
|
||||
Periods: []weatherdata.ForecastPeriod{
|
||||
{
|
||||
Name: "Today",
|
||||
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
|
||||
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
|
||||
IsDay: &isDay,
|
||||
TextDescription: "Morning storms, then partly sunny.",
|
||||
TemperatureF: floatPtr(narrativeTempF),
|
||||
WindSpeedMph: &narrativeWind,
|
||||
WindDirectionDegrees: &narrativeWindDirection,
|
||||
ProbabilityOfPrecipitationPercent: &narrativePop,
|
||||
},
|
||||
},
|
||||
},
|
||||
Hourly: &weatherdata.ForecastRun{
|
||||
LocationID: "test-grid",
|
||||
LocationName: "Testville",
|
||||
IssuedAt: mustParseModuleTime("2026-05-29T10:30:00-05:00"),
|
||||
UpdatedAt: &updatedAt,
|
||||
Product: "hourly",
|
||||
Periods: []weatherdata.ForecastPeriod{
|
||||
{
|
||||
StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"),
|
||||
EndTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"),
|
||||
TextDescription: "Showers likely.",
|
||||
TemperatureF: &hourlyTempF,
|
||||
WindSpeedMph: &hourlyWindMph,
|
||||
WindDirectionDegrees: &windDirection,
|
||||
ProbabilityOfPrecipitationPercent: &hourlyPop,
|
||||
RelativeHumidityPercent: &hourlyHumidity,
|
||||
},
|
||||
{
|
||||
StartTime: mustParseModuleTime("2026-05-30T08:00:00-05:00"),
|
||||
EndTime: mustParseModuleTime("2026-05-30T09:00:00-05:00"),
|
||||
TextDescription: "Tomorrow showers.",
|
||||
},
|
||||
},
|
||||
},
|
||||
Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{
|
||||
json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate"}`),
|
||||
}},
|
||||
Discussion: &weatherdata.Discussion{
|
||||
Product: "discussion",
|
||||
KeyMessages: []string{"Scattered showers are possible."},
|
||||
ShortTerm: &weatherdata.DiscussionSection{Text: "Showers increase this afternoon."},
|
||||
LongTerm: &weatherdata.DiscussionSection{Text: "Periodic rain chances continue."},
|
||||
},
|
||||
WeatherStory: &weatherdata.WeatherStory{
|
||||
OfficeID: "LSX",
|
||||
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
|
||||
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
|
||||
UpdatedAt: &updatedAt,
|
||||
Title: "Rain Chances",
|
||||
Description: "Scattered showers are possible.",
|
||||
AltText: "Weather story graphic with rain chances.",
|
||||
Priority: true,
|
||||
Order: 1,
|
||||
DownloadURL: "https://example.invalid/story.png",
|
||||
},
|
||||
SourceProvenance: []weatherdata.Source{{Name: "alerts", FetchedAt: generatedAt}},
|
||||
SourceWarnings: []weatherdata.SourceWarning{{
|
||||
Source: "daily",
|
||||
Code: "missing_source",
|
||||
Severity: "warning",
|
||||
Message: "daily source is missing",
|
||||
Endpoint: "/forecast/daily",
|
||||
CompletenessImpact: "source omitted",
|
||||
}},
|
||||
},
|
||||
Derived: facts.DerivedFacts{
|
||||
ValidPeriodHourlyPeriods: []weatherdata.ForecastPeriod{
|
||||
{
|
||||
StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"),
|
||||
EndTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"),
|
||||
TextDescription: "Showers likely.",
|
||||
TemperatureF: &hourlyTempF,
|
||||
WindSpeedMph: &hourlyWindMph,
|
||||
WindDirectionDegrees: &windDirection,
|
||||
ProbabilityOfPrecipitationPercent: &hourlyPop,
|
||||
RelativeHumidityPercent: &hourlyHumidity,
|
||||
},
|
||||
},
|
||||
ValidPeriodNarrativePeriods: []weatherdata.ForecastPeriod{
|
||||
{
|
||||
Name: "Today",
|
||||
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
|
||||
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
|
||||
IsDay: &isDay,
|
||||
TextDescription: "Morning storms, then partly sunny.",
|
||||
TemperatureF: floatPtr(narrativeTempF),
|
||||
WindSpeedMph: &narrativeWind,
|
||||
WindDirectionDegrees: &narrativeWindDirection,
|
||||
ProbabilityOfPrecipitationPercent: &narrativePop,
|
||||
},
|
||||
},
|
||||
AlertOverlaps: []forecast.AlertOverlap{{
|
||||
Event: "Flood Watch",
|
||||
Headline: "Flooding possible",
|
||||
Severity: "Moderate",
|
||||
}},
|
||||
},
|
||||
Units: "us",
|
||||
Timezone: "America/Chicago",
|
||||
Location: &LocationContext{
|
||||
ID: "home",
|
||||
Name: "Brentwood",
|
||||
Region: "St. Louis Metro",
|
||||
Timezone: "America/Chicago",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func moduleValue[T any](t *testing.T, output *module.Output) T {
|
||||
t.Helper()
|
||||
var value T
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal module value: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
t.Fatalf("decode module value: %v", err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func moduleDataPackageValue[T any](t *testing.T, output *module.Output) T {
|
||||
t.Helper()
|
||||
var value T
|
||||
data, err := json.Marshal(output.DataPackageValue())
|
||||
if err != nil {
|
||||
t.Fatalf("marshal module data package value: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
t.Fatalf("decode module data package value: %v", err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func mustMarshalModuleJSON(t *testing.T, value any) string {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal module value: %v", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func mustParseModuleTime(value string) time.Time {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
104
internal/briefing/current_conditions_module.go
Normal file
104
internal/briefing/current_conditions_module.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
)
|
||||
|
||||
type CurrentConditionsModule struct {
|
||||
ConditionText string `json:"condition_text,omitempty"`
|
||||
ConditionTextLower string `json:"condition_text_lower,omitempty"`
|
||||
IsDay *bool `json:"is_day,omitempty"`
|
||||
TemperatureC *int `json:"temperature_c,omitempty"`
|
||||
TemperatureF *int `json:"temperature_f,omitempty"`
|
||||
ApparentTemperatureC *int `json:"apparent_temperature_c,omitempty"`
|
||||
ApparentTemperatureF *int `json:"apparent_temperature_f,omitempty"`
|
||||
DewpointC *int `json:"dewpoint_c,omitempty"`
|
||||
DewpointF *int `json:"dewpoint_f,omitempty"`
|
||||
RelativeHumidityPercent *int `json:"relative_humidity_percent,omitempty"`
|
||||
WindSpeedKmh *int `json:"wind_speed_kmh,omitempty"`
|
||||
WindSpeedMph *int `json:"wind_speed_mph,omitempty"`
|
||||
WindDirection string `json:"wind_direction,omitempty"`
|
||||
WindDirectionText string `json:"wind_direction_text,omitempty"`
|
||||
}
|
||||
|
||||
type CurrentConditionsPromptExport struct {
|
||||
ConditionText string `json:"condition_text,omitempty"`
|
||||
IsDay *bool `json:"is_day,omitempty"`
|
||||
TemperatureC *int `json:"temperature_c,omitempty"`
|
||||
TemperatureF *int `json:"temperature_f,omitempty"`
|
||||
ApparentTemperatureC *int `json:"apparent_temperature_c,omitempty"`
|
||||
ApparentTemperatureF *int `json:"apparent_temperature_f,omitempty"`
|
||||
DewpointC *int `json:"dewpoint_c,omitempty"`
|
||||
DewpointF *int `json:"dewpoint_f,omitempty"`
|
||||
RelativeHumidityPercent *int `json:"relative_humidity_percent,omitempty"`
|
||||
WindSpeedKmh *int `json:"wind_speed_kmh,omitempty"`
|
||||
WindSpeedMph *int `json:"wind_speed_mph,omitempty"`
|
||||
WindDirection string `json:"wind_direction,omitempty"`
|
||||
}
|
||||
|
||||
func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
current := ctx.Collected.Current
|
||||
if current == nil {
|
||||
return nil, nil
|
||||
}
|
||||
value := CurrentConditionsModule{
|
||||
ConditionText: current.ConditionText,
|
||||
ConditionTextLower: strings.ToLower(current.ConditionText),
|
||||
IsDay: copyBool(current.IsDay),
|
||||
TemperatureC: roundedInt(current.TemperatureC),
|
||||
TemperatureF: roundedInt(current.TemperatureF),
|
||||
ApparentTemperatureC: roundedInt(current.ApparentTemperatureC),
|
||||
ApparentTemperatureF: roundedInt(current.ApparentTemperatureF),
|
||||
DewpointC: roundedInt(current.DewpointC),
|
||||
DewpointF: roundedInt(current.DewpointF),
|
||||
RelativeHumidityPercent: roundedInt(current.RelativeHumidityPercent),
|
||||
WindSpeedKmh: roundedInt(current.WindSpeedKmh),
|
||||
WindSpeedMph: roundedInt(current.WindSpeedMph),
|
||||
WindDirection: windDirectionLabel(current.WindDirectionDegrees),
|
||||
WindDirectionText: windDirectionTextLabel(current.WindDirectionDegrees),
|
||||
}
|
||||
if value.isEmpty() {
|
||||
return nil, nil
|
||||
}
|
||||
return &module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: value}, nil
|
||||
}
|
||||
|
||||
func exportCurrentConditionsPromptValue(value any) (any, error) {
|
||||
rich, ok := value.(CurrentConditionsModule)
|
||||
if !ok {
|
||||
return nil, unexpectedPromptExportValue(value, CurrentConditionsModule{})
|
||||
}
|
||||
return CurrentConditionsPromptExport{
|
||||
ConditionText: rich.ConditionText,
|
||||
IsDay: copyBool(rich.IsDay),
|
||||
TemperatureC: copyInt(rich.TemperatureC),
|
||||
TemperatureF: copyInt(rich.TemperatureF),
|
||||
ApparentTemperatureC: copyInt(rich.ApparentTemperatureC),
|
||||
ApparentTemperatureF: copyInt(rich.ApparentTemperatureF),
|
||||
DewpointC: copyInt(rich.DewpointC),
|
||||
DewpointF: copyInt(rich.DewpointF),
|
||||
RelativeHumidityPercent: copyInt(rich.RelativeHumidityPercent),
|
||||
WindSpeedKmh: copyInt(rich.WindSpeedKmh),
|
||||
WindSpeedMph: copyInt(rich.WindSpeedMph),
|
||||
WindDirection: rich.WindDirection,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (v CurrentConditionsModule) isEmpty() bool {
|
||||
return v.ConditionText == "" &&
|
||||
v.ConditionTextLower == "" &&
|
||||
v.IsDay == nil &&
|
||||
v.TemperatureC == nil &&
|
||||
v.TemperatureF == nil &&
|
||||
v.ApparentTemperatureC == nil &&
|
||||
v.ApparentTemperatureF == nil &&
|
||||
v.DewpointC == nil &&
|
||||
v.DewpointF == nil &&
|
||||
v.RelativeHumidityPercent == nil &&
|
||||
v.WindSpeedKmh == nil &&
|
||||
v.WindSpeedMph == nil &&
|
||||
v.WindDirection == "" &&
|
||||
v.WindDirectionText == ""
|
||||
}
|
||||
24
internal/briefing/daily_planning_module.go
Normal file
24
internal/briefing/daily_planning_module.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package briefing
|
||||
|
||||
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
|
||||
type DailyPlanningModule struct {
|
||||
MorningReadiness []string `json:"morning_readiness,omitempty"`
|
||||
CommuteSchoolWorkdayConcerns []string `json:"commute_school_workday_concerns,omitempty"`
|
||||
OvernightChangeWatch []string `json:"overnight_change_watch,omitempty"`
|
||||
}
|
||||
|
||||
func buildDailyPlanningModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
summary := ctx.Derived.FirstDailySummary()
|
||||
if summary == nil {
|
||||
return &module.Output{ID: module.DailyPlanning, StanzaName: "daily_planning", Value: DailyPlanningModule{}}, nil
|
||||
}
|
||||
planning := buildMorningCommuteOvernightPlanning(summary)
|
||||
value := DailyPlanningModule{}
|
||||
if planning != nil {
|
||||
value.MorningReadiness = append([]string(nil), planning.MorningReadiness...)
|
||||
value.CommuteSchoolWorkdayConcerns = append([]string(nil), planning.CommuteSchoolWorkdayConcerns...)
|
||||
value.OvernightChangeWatch = append([]string(nil), planning.OvernightChangeWatch...)
|
||||
}
|
||||
return &module.Output{ID: module.DailyPlanning, StanzaName: "daily_planning", Value: value}, nil
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
||||
bundle := loadBundleFixture(t)
|
||||
bundle.Sources[0].DataSHA256 = "abc123"
|
||||
bundle.Warnings = []forecast.SourceWarning{{Source: "daily", Code: "missing_source", Severity: "warning"}}
|
||||
location := mustLocation(t)
|
||||
resolved := mustResolveDaily(t, location)
|
||||
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDailySummary() error = %v", err)
|
||||
}
|
||||
|
||||
pkg, err := BuildDaily(BuildContext{
|
||||
Resolved: resolved,
|
||||
Bundle: bundle,
|
||||
Units: "us",
|
||||
Timezone: "America/Chicago",
|
||||
}, summary)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDaily() error = %v", err)
|
||||
}
|
||||
|
||||
if pkg.Metadata.SchemaVersion != SchemaVersion {
|
||||
t.Fatalf("SchemaVersion = %q, want %q", pkg.Metadata.SchemaVersion, SchemaVersion)
|
||||
}
|
||||
if !strings.Contains(pkg.Metadata.RunID, "daily_today") {
|
||||
t.Fatalf("RunID = %q, want report id", pkg.Metadata.RunID)
|
||||
}
|
||||
if pkg.Metadata.ReportID != report.DailyToday {
|
||||
t.Fatalf("ReportID = %q, want daily_today", pkg.Metadata.ReportID)
|
||||
}
|
||||
if pkg.Metadata.Units != "us" || pkg.Metadata.Timezone != "America/Chicago" {
|
||||
t.Fatalf("metadata units/timezone = %q/%q", pkg.Metadata.Units, pkg.Metadata.Timezone)
|
||||
}
|
||||
if len(pkg.Metadata.Sources) != 1 || pkg.Metadata.Sources[0].DataSHA256 != "abc123" {
|
||||
t.Fatalf("Sources = %#v, want source hash", pkg.Metadata.Sources)
|
||||
}
|
||||
if len(pkg.Metadata.SourceWarnings) != 1 {
|
||||
t.Fatalf("SourceWarnings length = %d, want 1", len(pkg.Metadata.SourceWarnings))
|
||||
}
|
||||
if pkg.Daily == nil {
|
||||
t.Fatal("Daily = nil")
|
||||
}
|
||||
if len(pkg.Daily.Dayparts) != 4 {
|
||||
t.Fatalf("Dayparts length = %d, want 4", len(pkg.Daily.Dayparts))
|
||||
}
|
||||
if len(pkg.Daily.RelevantAlerts) != 1 {
|
||||
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.Daily.RelevantAlerts))
|
||||
}
|
||||
if len(pkg.Daily.NarrativePeriods) != 1 {
|
||||
t.Fatalf("NarrativePeriods length = %d, want 1", len(pkg.Daily.NarrativePeriods))
|
||||
}
|
||||
if len(pkg.Daily.Discussion.KeyMessages) != 1 {
|
||||
t.Fatalf("Discussion key messages length = %d, want 1", len(pkg.Daily.Discussion.KeyMessages))
|
||||
}
|
||||
if pkg.Daily.OutdoorWindows.Best == nil || pkg.Daily.OutdoorWindows.Worst == nil {
|
||||
t.Fatalf("OutdoorWindows = %#v, want best and worst", pkg.Daily.OutdoorWindows)
|
||||
}
|
||||
if pkg.Daily.BottomLine.Summary == "" {
|
||||
t.Fatal("BottomLine summary is empty")
|
||||
}
|
||||
if _, err := json.Marshal(pkg); err != nil {
|
||||
t.Fatalf("briefing package is not JSON inspectable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyBriefingQuietWeather(t *testing.T) {
|
||||
location := mustLocation(t)
|
||||
resolved := mustResolveDaily(t, location)
|
||||
bundle := &forecast.Bundle{
|
||||
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{
|
||||
quietHour("2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", 72),
|
||||
}},
|
||||
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
|
||||
}
|
||||
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDailySummary() error = %v", err)
|
||||
}
|
||||
pkg, err := BuildDaily(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}, summary)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDaily() error = %v", err)
|
||||
}
|
||||
if pkg.Daily.BottomLine.Summary != "Conditions: Clear." {
|
||||
t.Fatalf("BottomLine summary = %q, want clear conditions", pkg.Daily.BottomLine.Summary)
|
||||
}
|
||||
if len(pkg.Daily.RelevantAlerts) != 0 {
|
||||
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyBriefingAlertExclusion(t *testing.T) {
|
||||
location := mustLocation(t)
|
||||
resolved := mustResolveDaily(t, location)
|
||||
bundle := loadBundleFixture(t)
|
||||
bundle.Alerts = &forecast.AlertRun{Alerts: []json.RawMessage{
|
||||
json.RawMessage(`{"event":"Future Watch","effective":"2026-06-01T00:00:00-05:00","expires":"2026-06-01T06:00:00-05:00"}`),
|
||||
}}
|
||||
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDailySummary() error = %v", err)
|
||||
}
|
||||
pkg, err := BuildDaily(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}, summary)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDaily() error = %v", err)
|
||||
}
|
||||
if len(pkg.Daily.RelevantAlerts) != 0 {
|
||||
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTomorrowBriefingIncludesPlanningInputs(t *testing.T) {
|
||||
location := mustLocation(t)
|
||||
resolved, err := report.Resolve(report.DailyTomorrow, report.ResolveRequest{
|
||||
Now: mustParse("2026-05-29T18:00:00-05:00"),
|
||||
Location: location,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve tomorrow: %v", err)
|
||||
}
|
||||
precip := 70.0
|
||||
wind := 34.0
|
||||
summary := &forecast.DailySummary{
|
||||
Date: "2026-05-30",
|
||||
Period: resolved.ValidPeriod,
|
||||
Dayparts: []forecast.DaypartSummary{
|
||||
{
|
||||
Name: "overnight",
|
||||
Period: timeutil.Period{
|
||||
Start: mustParse("2026-05-30T00:00:00-05:00"),
|
||||
End: mustParse("2026-05-30T06:00:00-05:00"),
|
||||
},
|
||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
||||
Value: 40,
|
||||
Time: mustParse("2026-05-30T03:00:00-05:00"),
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "morning",
|
||||
Period: timeutil.Period{
|
||||
Start: mustParse("2026-05-30T06:00:00-05:00"),
|
||||
End: mustParse("2026-05-30T12:00:00-05:00"),
|
||||
},
|
||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
||||
Value: precip,
|
||||
Time: mustParse("2026-05-30T08:00:00-05:00"),
|
||||
},
|
||||
PeakWindGust: &forecast.TimedValue{
|
||||
Value: wind,
|
||||
Time: mustParse("2026-05-30T09:00:00-05:00"),
|
||||
},
|
||||
Indicators: forecast.Indicators{Thunder: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
pkg, err := BuildDaily(BuildContext{
|
||||
Resolved: resolved,
|
||||
Units: "us",
|
||||
Timezone: "America/Chicago",
|
||||
}, summary)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDaily() error = %v", err)
|
||||
}
|
||||
|
||||
if pkg.Metadata.ReportID != report.DailyTomorrow || pkg.Metadata.Variant != "tomorrow" {
|
||||
t.Fatalf("metadata report/variant = %q/%q, want tomorrow", pkg.Metadata.ReportID, pkg.Metadata.Variant)
|
||||
}
|
||||
if pkg.Daily.ForecastSummaryDate != "2026-05-30" {
|
||||
t.Fatalf("ForecastSummaryDate = %q, want 2026-05-30", pkg.Daily.ForecastSummaryDate)
|
||||
}
|
||||
if pkg.Daily.Planning == nil {
|
||||
t.Fatal("Planning = nil, want tomorrow planning inputs")
|
||||
}
|
||||
if len(pkg.Daily.Planning.MorningReadiness) == 0 || len(pkg.Daily.Planning.CommuteSchoolWorkdayConcerns) == 0 || len(pkg.Daily.Planning.OvernightChangeWatch) == 0 {
|
||||
t.Fatalf("Planning = %#v, want populated planning inputs", pkg.Daily.Planning)
|
||||
}
|
||||
if !strings.Contains(strings.Join(pkg.Daily.Planning.MorningReadiness, " "), "precipitation") {
|
||||
t.Fatalf("MorningReadiness = %#v, want precipitation note", pkg.Daily.Planning.MorningReadiness)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveBriefingPackage(t *testing.T) {
|
||||
pkg := Package{Metadata: Metadata{SchemaVersion: SchemaVersion}}
|
||||
path := filepath.Join(t.TempDir(), "nested", "briefing.json")
|
||||
if err := Save(path, pkg); err != nil {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read briefing: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), SchemaVersion) {
|
||||
t.Fatalf("saved briefing missing schema version:\n%s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func loadBundleFixture(t *testing.T) *forecast.Bundle {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join("..", "forecast", "testdata", "daily_bundle.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read bundle fixture: %v", err)
|
||||
}
|
||||
var bundle forecast.Bundle
|
||||
if err := json.Unmarshal(data, &bundle); err != nil {
|
||||
t.Fatalf("decode bundle fixture: %v", err)
|
||||
}
|
||||
return &bundle
|
||||
}
|
||||
|
||||
func mustResolveDaily(t *testing.T, location *time.Location) report.Resolved {
|
||||
t.Helper()
|
||||
resolved, err := report.Resolve(report.DailyToday, report.ResolveRequest{
|
||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
||||
Location: location,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve daily: %v", err)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func defaultDayparts() []forecast.DaypartDefinition {
|
||||
return []forecast.DaypartDefinition{
|
||||
{Name: "overnight", Start: "00:00", End: "06:00"},
|
||||
{Name: "morning", Start: "06:00", End: "12:00"},
|
||||
{Name: "afternoon", Start: "12:00", End: "18:00"},
|
||||
{Name: "evening", Start: "18:00", End: "24:00"},
|
||||
}
|
||||
}
|
||||
|
||||
func quietHour(start string, end string, temperature float64) forecast.ForecastPeriod {
|
||||
return forecast.ForecastPeriod{
|
||||
StartTime: mustParse(start),
|
||||
EndTime: mustParse(end),
|
||||
TextDescription: "Clear",
|
||||
TemperatureF: &temperature,
|
||||
}
|
||||
}
|
||||
|
||||
func mustLocation(t *testing.T) *time.Location {
|
||||
t.Helper()
|
||||
location, err := time.LoadLocation("America/Chicago")
|
||||
if err != nil {
|
||||
t.Fatalf("load location: %v", err)
|
||||
}
|
||||
return location
|
||||
}
|
||||
|
||||
func mustParse(value string) time.Time {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
153
internal/briefing/derived_daily_summary_module.go
Normal file
153
internal/briefing/derived_daily_summary_module.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type DerivedDailySummaryModule struct {
|
||||
Date string `json:"date,omitempty"`
|
||||
HighTempF *int `json:"high_temp_f,omitempty"`
|
||||
LowTempF *int `json:"low_temp_f,omitempty"`
|
||||
DailyPrecipitationProbability *int `json:"daily_precipitation_probability,omitempty"`
|
||||
MostLikelyPrecipitationHour string `json:"most_likely_precipitation_hour,omitempty"`
|
||||
ThunderMentioned bool `json:"thunder_mentioned"`
|
||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||
HeatIndexMaxF *int `json:"heat_index_max_f,omitempty"`
|
||||
DominantConditions []string `json:"dominant_conditions,omitempty"`
|
||||
Hazards []string `json:"hazards,omitempty"`
|
||||
}
|
||||
|
||||
func buildDerivedDailySummaryModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
summary := ctx.Derived.FirstDailySummary()
|
||||
if summary == nil {
|
||||
return nil, fmt.Errorf("daily summary facts are required")
|
||||
}
|
||||
value, err := derivedDailySummaryValue(*summary, ctx.Derived.PrecipTiming, ctx.Timezone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: value}, nil
|
||||
}
|
||||
|
||||
func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.PrecipTiming, timezone string) (DerivedDailySummaryModule, error) {
|
||||
value := DerivedDailySummaryModule{
|
||||
Date: friendlyDateLabel(summary.Date, timezone),
|
||||
ThunderMentioned: timing.ThunderMentioned,
|
||||
}
|
||||
conditions := map[string]struct{}{}
|
||||
hazards := map[string]struct{}{}
|
||||
var temperature forecast.Range
|
||||
var apparent forecast.Range
|
||||
var maxPop *forecast.TimedValue
|
||||
var maxGust *forecast.TimedValue
|
||||
for _, daypart := range summary.Dayparts {
|
||||
addRange(&temperature, daypart.Temperature)
|
||||
addRange(&apparent, daypart.ApparentTemperature)
|
||||
maxTimedValue(&maxPop, daypart.MaxPrecipitationProbability)
|
||||
maxTimedValue(&maxGust, daypart.PeakWindGust)
|
||||
if daypart.DominantCondition != "" {
|
||||
conditions[daypart.DominantCondition] = struct{}{}
|
||||
}
|
||||
for _, hazard := range hazardsForIndicators(daypart.Indicators) {
|
||||
hazards[hazard] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, alert := range summary.AlertOverlaps {
|
||||
if alert.Event != "" {
|
||||
hazards[alert.Event] = struct{}{}
|
||||
}
|
||||
}
|
||||
narrativeTemperature := narrativeTemperatureRange(summary.NarrativePeriods)
|
||||
if narrativeTemperature.Max != nil {
|
||||
value.HighTempF = roundedInt(narrativeTemperature.Max)
|
||||
} else {
|
||||
value.HighTempF = roundedInt(temperature.Max)
|
||||
}
|
||||
if narrativeTemperature.Min != nil {
|
||||
value.LowTempF = roundedInt(narrativeTemperature.Min)
|
||||
} else {
|
||||
value.LowTempF = roundedInt(temperature.Min)
|
||||
}
|
||||
value.HeatIndexMaxF = roundedInt(apparent.Max)
|
||||
narrativePrecipitation := narrativeMaxPrecipitation(summary.NarrativePeriods)
|
||||
if narrativePrecipitation != nil {
|
||||
value.DailyPrecipitationProbability = roundedInt(&narrativePrecipitation.Value)
|
||||
} else if maxPop != nil {
|
||||
value.DailyPrecipitationProbability = roundedInt(&maxPop.Value)
|
||||
}
|
||||
if maxPop != nil {
|
||||
value.MostLikelyPrecipitationHour = mostLikelyPrecipitationHour(maxPop, timezone)
|
||||
}
|
||||
if maxGust != nil {
|
||||
value.MaxWindGustMph = roundedInt(&maxGust.Value)
|
||||
}
|
||||
value.DominantConditions = sortedSet(conditions)
|
||||
value.Hazards = sortedSet(hazards)
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func narrativeTemperatureRange(periods []weatherdata.ForecastPeriod) forecast.Range {
|
||||
var out forecast.Range
|
||||
for _, period := range periods {
|
||||
addNarrativeHigh(&out, period.TemperatureFMax)
|
||||
addNarrativeLow(&out, period.TemperatureFMin)
|
||||
if period.TemperatureF != nil && period.IsDay != nil {
|
||||
if *period.IsDay {
|
||||
addNarrativeHigh(&out, period.TemperatureF)
|
||||
} else {
|
||||
addNarrativeLow(&out, period.TemperatureF)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func addNarrativeHigh(target *forecast.Range, value *float64) {
|
||||
if value == nil {
|
||||
return
|
||||
}
|
||||
if target.Max == nil || *value > *target.Max {
|
||||
copied := *value
|
||||
target.Max = &copied
|
||||
}
|
||||
}
|
||||
|
||||
func addNarrativeLow(target *forecast.Range, value *float64) {
|
||||
if value == nil {
|
||||
return
|
||||
}
|
||||
if target.Min == nil || *value < *target.Min {
|
||||
copied := *value
|
||||
target.Min = &copied
|
||||
}
|
||||
}
|
||||
|
||||
func narrativeMaxPrecipitation(periods []weatherdata.ForecastPeriod) *forecast.TimedValue {
|
||||
var maxPop *forecast.TimedValue
|
||||
for _, period := range periods {
|
||||
if period.ProbabilityOfPrecipitationPercent == nil {
|
||||
continue
|
||||
}
|
||||
value := forecast.TimedValue{
|
||||
Value: *period.ProbabilityOfPrecipitationPercent,
|
||||
Time: period.StartTime,
|
||||
}
|
||||
maxTimedValue(&maxPop, &value)
|
||||
}
|
||||
return maxPop
|
||||
}
|
||||
|
||||
func mostLikelyPrecipitationHour(maxPop *forecast.TimedValue, timezone string) string {
|
||||
if maxPop == nil || maxPop.Value <= 0 {
|
||||
return ""
|
||||
}
|
||||
percent := roundedInt(&maxPop.Value)
|
||||
if percent == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d%% at %s", *percent, clockLabel(maxPop.Time, timezone))
|
||||
}
|
||||
411
internal/briefing/derived_daypart_summaries_module.go
Normal file
411
internal/briefing/derived_daypart_summaries_module.go
Normal file
@@ -0,0 +1,411 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type DerivedDaypartSummaryModule struct {
|
||||
Date string `json:"date,omitempty"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||
TemperaturePhraseF string `json:"temperature_phrase_f,omitempty"`
|
||||
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||
MaxPopTimeLabel string `json:"max_pop_time_label,omitempty"`
|
||||
MentionPrecipitation bool `json:"mention_precipitation,omitempty"`
|
||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
|
||||
DominantCondition string `json:"dominant_condition,omitempty"`
|
||||
DominantConditionLower string `json:"dominant_condition_lower,omitempty"`
|
||||
DominantConditionDisplay string `json:"dominant_condition_display,omitempty"`
|
||||
TemperatureTrend string `json:"temperature_trend,omitempty"`
|
||||
TemperatureStartPhraseF string `json:"temperature_start_phrase_f,omitempty"`
|
||||
TemperatureEndPhraseF string `json:"temperature_end_phrase_f,omitempty"`
|
||||
TemperaturePeakPhraseF string `json:"temperature_peak_phrase_f,omitempty"`
|
||||
TemperatureSteadyPhraseF string `json:"temperature_steady_phrase_f,omitempty"`
|
||||
NotableConditions []string `json:"notable_conditions,omitempty"`
|
||||
Snow bool `json:"snow,omitempty"`
|
||||
Ice bool `json:"ice,omitempty"`
|
||||
Fog bool `json:"fog,omitempty"`
|
||||
Heat bool `json:"heat,omitempty"`
|
||||
Cold bool `json:"cold,omitempty"`
|
||||
Wind bool `json:"wind,omitempty"`
|
||||
RelevantAlertCount int `json:"relevant_alert_count,omitempty"`
|
||||
}
|
||||
|
||||
type DerivedDaypartSummaryPromptExport struct {
|
||||
Date string `json:"date,omitempty"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||
MentionPrecipitation bool `json:"mention_precipitation,omitempty"`
|
||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
|
||||
DominantCondition string `json:"dominant_condition,omitempty"`
|
||||
TemperatureTrend string `json:"temperature_trend,omitempty"`
|
||||
TemperatureStartPhraseF string `json:"temperature_start_phrase_f,omitempty"`
|
||||
TemperatureEndPhraseF string `json:"temperature_end_phrase_f,omitempty"`
|
||||
TemperaturePeakPhraseF string `json:"temperature_peak_phrase_f,omitempty"`
|
||||
TemperatureSteadyPhraseF string `json:"temperature_steady_phrase_f,omitempty"`
|
||||
NotableConditions []string `json:"notable_conditions,omitempty"`
|
||||
Snow bool `json:"snow,omitempty"`
|
||||
Ice bool `json:"ice,omitempty"`
|
||||
Fog bool `json:"fog,omitempty"`
|
||||
Heat bool `json:"heat,omitempty"`
|
||||
Cold bool `json:"cold,omitempty"`
|
||||
Wind bool `json:"wind,omitempty"`
|
||||
RelevantAlertCount int `json:"relevant_alert_count,omitempty"`
|
||||
}
|
||||
|
||||
func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
if len(ctx.Derived.DaypartSummaries) == 0 {
|
||||
return nil, fmt.Errorf("daypart summary facts are required")
|
||||
}
|
||||
value := map[string]DerivedDaypartSummaryModule{}
|
||||
prefixDates := multipleSummaryDates(ctx.Derived.DailySummaries)
|
||||
for _, daypart := range ctx.Derived.DaypartSummaries {
|
||||
key := daypartKey(daypart, prefixDates)
|
||||
value[key] = derivedDaypartSummaryValue(daypart, ctx.Timezone)
|
||||
}
|
||||
return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil
|
||||
}
|
||||
|
||||
func exportDerivedDaypartSummariesPromptValue(value any) (any, error) {
|
||||
rich, ok := value.(map[string]DerivedDaypartSummaryModule)
|
||||
if !ok {
|
||||
return nil, unexpectedPromptExportValue(value, map[string]DerivedDaypartSummaryModule{})
|
||||
}
|
||||
out := make(map[string]DerivedDaypartSummaryPromptExport, len(rich))
|
||||
for key, daypart := range rich {
|
||||
out[key] = derivedDaypartSummaryPromptValue(daypart)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func derivedDaypartSummaryPromptValue(rich DerivedDaypartSummaryModule) DerivedDaypartSummaryPromptExport {
|
||||
maxPopTime := rich.MaxPopTime
|
||||
if rich.MaxPopTimeLabel != "" {
|
||||
maxPopTime = rich.MaxPopTimeLabel
|
||||
}
|
||||
return DerivedDaypartSummaryPromptExport{
|
||||
Date: rich.Date,
|
||||
DisplayName: rich.DisplayName,
|
||||
PeriodBegins: rich.PeriodBegins,
|
||||
PeriodEnds: rich.PeriodEnds,
|
||||
TempRangeF: rich.TempRangeF,
|
||||
ApparentTempRangeF: rich.ApparentTempRangeF,
|
||||
MaxPopPercent: copyInt(rich.MaxPopPercent),
|
||||
MaxPopTime: maxPopTime,
|
||||
MentionPrecipitation: rich.MentionPrecipitation,
|
||||
MaxWindGustMph: copyInt(rich.MaxWindGustMph),
|
||||
MaxWindGustTime: rich.MaxWindGustTime,
|
||||
DominantCondition: rich.DominantCondition,
|
||||
TemperatureTrend: rich.TemperatureTrend,
|
||||
TemperatureStartPhraseF: rich.TemperatureStartPhraseF,
|
||||
TemperatureEndPhraseF: rich.TemperatureEndPhraseF,
|
||||
TemperaturePeakPhraseF: rich.TemperaturePeakPhraseF,
|
||||
TemperatureSteadyPhraseF: rich.TemperatureSteadyPhraseF,
|
||||
NotableConditions: append([]string(nil), rich.NotableConditions...),
|
||||
Snow: rich.Snow,
|
||||
Ice: rich.Ice,
|
||||
Fog: rich.Fog,
|
||||
Heat: rich.Heat,
|
||||
Cold: rich.Cold,
|
||||
Wind: rich.Wind,
|
||||
RelevantAlertCount: rich.RelevantAlertCount,
|
||||
}
|
||||
}
|
||||
|
||||
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
|
||||
temperature := daypartTemperatureDisplay(daypart)
|
||||
value := DerivedDaypartSummaryModule{
|
||||
Date: localDateLabel(daypart.Period.Start, timezone),
|
||||
DisplayName: titleWord(strings.TrimSpace(daypart.Name)),
|
||||
PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone),
|
||||
PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone),
|
||||
TempRangeF: rangeLabel(daypart.Temperature),
|
||||
TemperaturePhraseF: temperaturePhraseF(daypart.Temperature),
|
||||
TemperatureTrend: temperature.Trend,
|
||||
TemperatureStartPhraseF: temperature.StartPhrase,
|
||||
TemperatureEndPhraseF: temperature.EndPhrase,
|
||||
TemperaturePeakPhraseF: temperature.PeakPhrase,
|
||||
TemperatureSteadyPhraseF: temperature.SteadyPhrase,
|
||||
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
|
||||
DominantCondition: daypart.DominantCondition,
|
||||
DominantConditionLower: strings.ToLower(daypart.DominantCondition),
|
||||
DominantConditionDisplay: sentenceCase(daypart.DominantCondition),
|
||||
NotableConditions: append([]string(nil), daypart.NotableConditions...),
|
||||
Snow: daypart.Indicators.Snow,
|
||||
Ice: daypart.Indicators.Ice,
|
||||
Fog: daypart.Indicators.Fog,
|
||||
Heat: daypart.Indicators.Heat,
|
||||
Cold: daypart.Indicators.Cold,
|
||||
Wind: daypart.Indicators.Wind,
|
||||
RelevantAlertCount: len(daypart.AlertOverlaps),
|
||||
}
|
||||
if daypart.MaxPrecipitationProbability != nil {
|
||||
value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value)
|
||||
value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone)
|
||||
value.MaxPopTimeLabel = hourMinuteLabel(daypart.MaxPrecipitationProbability.Time, timezone)
|
||||
value.MentionPrecipitation = mentionHourlyForecastPrecipitation(&daypart.MaxPrecipitationProbability.Value, DefaultHourlyForecastPrecipMentionProbabilityThreshold)
|
||||
}
|
||||
if daypart.PeakWindGust != nil {
|
||||
value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value)
|
||||
value.MaxWindGustTime = clockLabel(daypart.PeakWindGust.Time, timezone)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type daypartTemperaturePresentation struct {
|
||||
Trend string
|
||||
StartPhrase string
|
||||
EndPhrase string
|
||||
PeakPhrase string
|
||||
SteadyPhrase string
|
||||
}
|
||||
|
||||
type temperaturePoint struct {
|
||||
value int
|
||||
bandIndex int
|
||||
phrase string
|
||||
}
|
||||
|
||||
const (
|
||||
temperatureTrendRising = "rising"
|
||||
temperatureTrendFalling = "falling"
|
||||
temperatureTrendPeaking = "peaking"
|
||||
temperatureTrendSteady = "steady"
|
||||
)
|
||||
|
||||
func daypartTemperatureDisplay(daypart forecast.DaypartSummary) daypartTemperaturePresentation {
|
||||
points := daypartTemperaturePoints(daypart.HourlyPeriods)
|
||||
if len(points) == 0 {
|
||||
return daypartSteadyTemperatureDisplay(temperaturePhraseF(daypart.Temperature))
|
||||
}
|
||||
if len(points) == 1 {
|
||||
return daypartSteadyTemperatureDisplay(points[0].phrase)
|
||||
}
|
||||
|
||||
first := points[0]
|
||||
last := points[len(points)-1]
|
||||
peak, peakIndex := peakTemperaturePoint(points)
|
||||
if peakIndex > 0 && peakIndex < len(points)-1 && peak.bandIndex > first.bandIndex && peak.bandIndex > last.bandIndex {
|
||||
return daypartTemperaturePresentation{
|
||||
Trend: temperatureTrendPeaking,
|
||||
PeakPhrase: peak.phrase,
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case first.bandIndex < last.bandIndex:
|
||||
return daypartTemperaturePresentation{
|
||||
Trend: temperatureTrendRising,
|
||||
StartPhrase: first.phrase,
|
||||
EndPhrase: last.phrase,
|
||||
}
|
||||
case first.bandIndex > last.bandIndex:
|
||||
return daypartTemperaturePresentation{
|
||||
Trend: temperatureTrendFalling,
|
||||
StartPhrase: first.phrase,
|
||||
EndPhrase: last.phrase,
|
||||
}
|
||||
default:
|
||||
return daypartSteadyTemperatureDisplay(temperaturePhraseF(daypart.Temperature))
|
||||
}
|
||||
}
|
||||
|
||||
func daypartSteadyTemperatureDisplay(phrase string) daypartTemperaturePresentation {
|
||||
if phrase == "" {
|
||||
return daypartTemperaturePresentation{}
|
||||
}
|
||||
return daypartTemperaturePresentation{
|
||||
Trend: temperatureTrendSteady,
|
||||
SteadyPhrase: phrase,
|
||||
}
|
||||
}
|
||||
|
||||
func daypartTemperaturePoints(periods []weatherdata.ForecastPeriod) []temperaturePoint {
|
||||
sorted := append([]weatherdata.ForecastPeriod(nil), periods...)
|
||||
sort.SliceStable(sorted, func(i int, j int) bool {
|
||||
return sorted[i].StartTime.Before(sorted[j].StartTime)
|
||||
})
|
||||
points := make([]temperaturePoint, 0, len(sorted))
|
||||
for _, period := range sorted {
|
||||
temperature := forecastPeriodTemperatureF(period)
|
||||
if temperature == nil {
|
||||
continue
|
||||
}
|
||||
rounded := roundedInt(temperature)
|
||||
if rounded == nil {
|
||||
continue
|
||||
}
|
||||
points = append(points, temperaturePoint{
|
||||
value: *rounded,
|
||||
bandIndex: temperatureBandIndex(*rounded),
|
||||
phrase: temperatureBandPhrase(*rounded),
|
||||
})
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
func peakTemperaturePoint(points []temperaturePoint) (temperaturePoint, int) {
|
||||
peak := points[0]
|
||||
peakIndex := 0
|
||||
for index, point := range points[1:] {
|
||||
if point.value > peak.value {
|
||||
peak = point
|
||||
peakIndex = index + 1
|
||||
}
|
||||
}
|
||||
return peak, peakIndex
|
||||
}
|
||||
|
||||
func forecastPeriodTemperatureF(period weatherdata.ForecastPeriod) *float64 {
|
||||
switch {
|
||||
case period.TemperatureF != nil:
|
||||
return period.TemperatureF
|
||||
case period.TemperatureFMax != nil:
|
||||
return period.TemperatureFMax
|
||||
case period.TemperatureFMin != nil:
|
||||
return period.TemperatureFMin
|
||||
case period.TemperatureC != nil:
|
||||
value := celsiusToFahrenheit(*period.TemperatureC)
|
||||
return &value
|
||||
case period.TemperatureCMax != nil:
|
||||
value := celsiusToFahrenheit(*period.TemperatureCMax)
|
||||
return &value
|
||||
case period.TemperatureCMin != nil:
|
||||
value := celsiusToFahrenheit(*period.TemperatureCMin)
|
||||
return &value
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func celsiusToFahrenheit(value float64) float64 {
|
||||
return value*9/5 + 32
|
||||
}
|
||||
|
||||
func temperatureBandIndex(value int) int {
|
||||
decade := (value / 10) * 10
|
||||
remainder := value - decade
|
||||
if remainder < 0 {
|
||||
remainder = -remainder
|
||||
}
|
||||
band := 1
|
||||
switch {
|
||||
case remainder <= 3:
|
||||
band = 0
|
||||
case remainder >= 7:
|
||||
band = 2
|
||||
}
|
||||
return decade*3 + band
|
||||
}
|
||||
|
||||
func temperaturePhraseF(value forecast.Range) string {
|
||||
if value.Min == nil && value.Max == nil {
|
||||
return ""
|
||||
}
|
||||
if value.Min != nil && value.Max != nil {
|
||||
low := roundedInt(value.Min)
|
||||
high := roundedInt(value.Max)
|
||||
if low == nil || high == nil {
|
||||
return ""
|
||||
}
|
||||
lowPhrase := temperatureBandPhrase(*low)
|
||||
highPhrase := temperatureBandPhrase(*high)
|
||||
if lowPhrase == highPhrase {
|
||||
return lowPhrase
|
||||
}
|
||||
return lowPhrase + " to " + highPhrase
|
||||
}
|
||||
if value.Min != nil {
|
||||
low := roundedInt(value.Min)
|
||||
if low == nil {
|
||||
return ""
|
||||
}
|
||||
return temperatureBandPhrase(*low)
|
||||
}
|
||||
high := roundedInt(value.Max)
|
||||
if high == nil {
|
||||
return ""
|
||||
}
|
||||
return temperatureBandPhrase(*high)
|
||||
}
|
||||
|
||||
func temperatureBandPhrase(value int) string {
|
||||
decade := (value / 10) * 10
|
||||
remainder := value - decade
|
||||
if remainder < 0 {
|
||||
remainder = -remainder
|
||||
}
|
||||
qualifier := "mid"
|
||||
switch {
|
||||
case remainder <= 3:
|
||||
qualifier = "low"
|
||||
case remainder >= 7:
|
||||
qualifier = "upper"
|
||||
}
|
||||
return fmt.Sprintf("%s %ds", qualifier, decade)
|
||||
}
|
||||
|
||||
func sentenceCase(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(trimmed)
|
||||
runes[0] = unicode.ToUpper(runes[0])
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
func multipleSummaryDates(summaries []forecast.DailySummary) bool {
|
||||
seen := map[string]struct{}{}
|
||||
for _, summary := range summaries {
|
||||
seen[summary.Date] = struct{}{}
|
||||
}
|
||||
return len(seen) > 1
|
||||
}
|
||||
|
||||
func daypartKey(daypart forecast.DaypartSummary, prefixDate bool) string {
|
||||
key := normalizedKey(daypart.Name)
|
||||
if key == "" {
|
||||
key = "unnamed"
|
||||
}
|
||||
if !prefixDate {
|
||||
return key
|
||||
}
|
||||
return daypart.Period.Start.Format(timeutil.DateLayout) + "_" + key
|
||||
}
|
||||
|
||||
func normalizedKey(value string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(value))
|
||||
var out strings.Builder
|
||||
lastUnderscore := false
|
||||
for _, r := range lower {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
out.WriteRune(r)
|
||||
lastUnderscore = false
|
||||
continue
|
||||
}
|
||||
if !lastUnderscore {
|
||||
out.WriteByte('_')
|
||||
lastUnderscore = true
|
||||
}
|
||||
}
|
||||
return strings.Trim(out.String(), "_")
|
||||
}
|
||||
876
internal/briefing/derived_modules_test.go
Normal file
876
internal/briefing/derived_modules_test.go
Normal file
@@ -0,0 +1,876 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.Daily)
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[DerivedDailySummaryModule](t, output)
|
||||
|
||||
if value.Date != "Friday, May 29, 2026" {
|
||||
t.Fatalf("Date = %q, want friendly local date", value.Date)
|
||||
}
|
||||
if value.HighTempF == nil || *value.HighTempF != 88 || value.LowTempF == nil || *value.LowTempF != 64 {
|
||||
t.Fatalf("daily temperatures = %#v/%#v, want narrative 88/64", value.HighTempF, value.LowTempF)
|
||||
}
|
||||
if value.DailyPrecipitationProbability == nil || *value.DailyPrecipitationProbability != 55 {
|
||||
t.Fatalf("DailyPrecipitationProbability = %#v, want narrative 55", value.DailyPrecipitationProbability)
|
||||
}
|
||||
if value.MostLikelyPrecipitationHour != "80% at 12 PM" || !value.ThunderMentioned {
|
||||
t.Fatalf("precip timing = %#v, want most likely hour and thunder", value)
|
||||
}
|
||||
if !containsString(value.DominantConditions, "Thunderstorms with gusty wind") || containsString(value.DominantConditions, "Morning storms, then partly sunny.") {
|
||||
t.Fatalf("DominantConditions = %#v, want daypart conditions rather than narrative conditions", value.DominantConditions)
|
||||
}
|
||||
if value.MaxWindGustMph == nil || *value.MaxWindGustMph != 42 {
|
||||
t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph)
|
||||
}
|
||||
if value.HeatIndexMaxF == nil || *value.HeatIndexMaxF != 101 {
|
||||
t.Fatalf("HeatIndexMaxF = %#v, want 101", value.HeatIndexMaxF)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal daily summary: %v", err)
|
||||
}
|
||||
jsonText := string(data)
|
||||
for _, field := range []string{"high_temp_f", "low_temp_f", "daily_precipitation_probability", "most_likely_precipitation_hour", "heat_index_max_f"} {
|
||||
if !strings.Contains(jsonText, field) {
|
||||
t.Fatalf("daily json = %s, want field %s", jsonText, field)
|
||||
}
|
||||
}
|
||||
for _, removed := range []string{"max_pop_percent", "max_pop_window", "first_precip_hour", "last_precip_hour"} {
|
||||
if strings.Contains(jsonText, removed) {
|
||||
t.Fatalf("daily json = %s, want removed field %s omitted", jsonText, removed)
|
||||
}
|
||||
}
|
||||
if strings.Contains(jsonText, "qpf") {
|
||||
t.Fatalf("daily json = %s, want no QPF fields without upstream QPF facts", jsonText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.Daily)
|
||||
ctx.Derived.DailySummaries[0].NarrativePeriods = nil
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[DerivedDailySummaryModule](t, output)
|
||||
|
||||
if value.HighTempF == nil || *value.HighTempF != 96 || value.LowTempF == nil || *value.LowTempF != 31 {
|
||||
t.Fatalf("daily temperatures = %#v/%#v, want fallback 96/31", value.HighTempF, value.LowTempF)
|
||||
}
|
||||
if value.DailyPrecipitationProbability == nil || *value.DailyPrecipitationProbability != 80 {
|
||||
t.Fatalf("DailyPrecipitationProbability = %#v, want hourly fallback 80", value.DailyPrecipitationProbability)
|
||||
}
|
||||
if len(value.DominantConditions) == 0 || !containsString(value.DominantConditions, "Thunderstorms with gusty wind") {
|
||||
t.Fatalf("DominantConditions = %#v, want fallback daypart conditions", value.DominantConditions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.Daily)
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(rainy) error = %v", err)
|
||||
}
|
||||
rainy := moduleValue[PrecipTimingModule](t, output)
|
||||
if rainy.MaxPopPercent == nil || *rainy.MaxPopPercent != 80 || rainy.MaxPopTime != "12 PM" || rainy.ProbabilityThreshold != forecast.DefaultPrecipWindowProbabilityThreshold || !rainy.ThunderMentioned {
|
||||
t.Fatalf("rainy precip timing = %#v, want peak, threshold, and thunder", rainy)
|
||||
}
|
||||
if len(rainy.PrecipitationWindows) != 2 {
|
||||
t.Fatalf("rainy precipitation windows = %#v, want two windows", rainy.PrecipitationWindows)
|
||||
}
|
||||
if rainy.PrecipitationWindows[0].PeriodBegins != "2026-05-29 at 8:00 AM" || rainy.PrecipitationWindows[0].PeriodBeginsHourLabel != "8:00 AM" || rainy.PrecipitationWindows[0].PeriodEnds != "2026-05-29 at 9:00 AM" || rainy.PrecipitationWindows[0].PeriodEndsHourLabel != "9:00 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 || rainy.PrecipitationWindows[0].MaxPopHourLabel != "8:00 AM" {
|
||||
t.Fatalf("first precipitation window = %#v, want 8-9 AM at 60%%", rainy.PrecipitationWindows[0])
|
||||
}
|
||||
if rainy.PrecipitationWindows[0].PrecipitationType != "showers" || rainy.PrecipitationWindows[0].ExpectationPhrase != "Showers likely." {
|
||||
t.Fatalf("first precipitation window phrase = %#v, want showers likely", rainy.PrecipitationWindows[0])
|
||||
}
|
||||
if rainy.PrecipitationWindows[1].PeriodBegins != "2026-05-29 at 12:00 PM" || rainy.PrecipitationWindows[1].PeriodBeginsHourLabel != "12:00 PM" || rainy.PrecipitationWindows[1].PeriodEnds != "2026-05-29 at 2:00 PM" || rainy.PrecipitationWindows[1].PeriodEndsHourLabel != "2:00 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 || rainy.PrecipitationWindows[1].MaxPopHourLabel != "12:00 PM" {
|
||||
t.Fatalf("second precipitation window = %#v, want noon-2 PM at 80%%", rainy.PrecipitationWindows[1])
|
||||
}
|
||||
if rainy.PrecipitationWindows[1].PrecipitationType != "showers and thunderstorms" || rainy.PrecipitationWindows[1].ExpectationPhrase != "Expect showers and thunderstorms." {
|
||||
t.Fatalf("second precipitation window phrase = %#v, want expect showers and thunderstorms", rainy.PrecipitationWindows[1])
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal precip timing: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "precipitation_windows") || !strings.Contains(string(data), "probability_threshold") || !strings.Contains(string(data), "period_begins_hour_label") || !strings.Contains(string(data), "max_pop_hour_label") {
|
||||
t.Fatalf("precip timing json = %s, want threshold and windows", string(data))
|
||||
}
|
||||
if !strings.Contains(string(data), "precipitation_type") || !strings.Contains(string(data), "expectation_phrase") {
|
||||
t.Fatalf("precip timing json = %s, want precipitation type and expectation phrase", string(data))
|
||||
}
|
||||
if strings.Contains(string(data), `"start"`) || strings.Contains(string(data), `"end"`) {
|
||||
t.Fatalf("precip timing json = %s, want period_begins/period_ends instead of start/end", string(data))
|
||||
}
|
||||
if strings.Contains(string(data), "first_precip_hour") || strings.Contains(string(data), "last_precip_hour") {
|
||||
t.Fatalf("precip timing json = %s, want no ambiguous first/last fields", string(data))
|
||||
}
|
||||
|
||||
ctx.Derived.PrecipTiming = forecast.BuildPrecipTiming([]weatherdata.ForecastPeriod{derivedHour("2026-05-29T10:00:00-05:00", "Sunny", 0, 70, nil, 5)})
|
||||
output, err = registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(dry) error = %v", err)
|
||||
}
|
||||
dry := moduleValue[PrecipTimingModule](t, output)
|
||||
if len(dry.PrecipitationWindows) != 0 || dry.ThunderMentioned {
|
||||
t.Fatalf("dry precip timing = %#v, want no precip windows and no thunder", dry)
|
||||
}
|
||||
if dry.MaxPopPercent == nil || *dry.MaxPopPercent != 0 {
|
||||
t.Fatalf("dry MaxPopPercent = %#v, want checked zero", dry.MaxPopPercent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrecipTimingModuleBuildsExpectationPhrases(t *testing.T) {
|
||||
now := mustParseModuleTime("2026-05-29T08:00:00-05:00")
|
||||
tests := []struct {
|
||||
name string
|
||||
maxPop float64
|
||||
descriptions []string
|
||||
wantType string
|
||||
wantPhrase string
|
||||
}{
|
||||
{
|
||||
name: "chance lower bound",
|
||||
maxPop: 40,
|
||||
descriptions: []string{"Scattered showers"},
|
||||
wantType: "showers",
|
||||
wantPhrase: "Chance of showers.",
|
||||
},
|
||||
{
|
||||
name: "chance upper bound",
|
||||
maxPop: 49,
|
||||
descriptions: []string{"Rain possible"},
|
||||
wantType: "rain",
|
||||
wantPhrase: "Chance of rain.",
|
||||
},
|
||||
{
|
||||
name: "likely lower bound",
|
||||
maxPop: 50,
|
||||
descriptions: []string{"Drizzle"},
|
||||
wantType: "drizzle",
|
||||
wantPhrase: "Drizzle likely.",
|
||||
},
|
||||
{
|
||||
name: "likely upper bound",
|
||||
maxPop: 69,
|
||||
descriptions: []string{"Freezing rain"},
|
||||
wantType: "freezing rain",
|
||||
wantPhrase: "Freezing rain likely.",
|
||||
},
|
||||
{
|
||||
name: "expect lower bound",
|
||||
maxPop: 70,
|
||||
descriptions: []string{"Snow"},
|
||||
wantType: "snow",
|
||||
wantPhrase: "Expect snow.",
|
||||
},
|
||||
{
|
||||
name: "showers and thunderstorms preferred",
|
||||
maxPop: 100,
|
||||
descriptions: []string{"Showers likely", "Thunderstorms possible"},
|
||||
wantType: "showers and thunderstorms",
|
||||
wantPhrase: "Expect showers and thunderstorms.",
|
||||
},
|
||||
{
|
||||
name: "thunderstorms only",
|
||||
maxPop: 80,
|
||||
descriptions: []string{"Thunderstorms"},
|
||||
wantType: "thunderstorms",
|
||||
wantPhrase: "Expect thunderstorms.",
|
||||
},
|
||||
{
|
||||
name: "unknown fallback",
|
||||
maxPop: 95,
|
||||
descriptions: []string{"Unsettled conditions"},
|
||||
wantType: "precipitation",
|
||||
wantPhrase: "Expect precipitation.",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
value := precipTimingValue(forecast.PrecipTiming{
|
||||
ProbabilityThreshold: forecast.DefaultPrecipWindowProbabilityThreshold,
|
||||
PrecipitationWindows: []forecast.PrecipitationWindow{
|
||||
{
|
||||
Start: now,
|
||||
MaxPrecipitationProbability: forecast.TimedValue{
|
||||
Value: tt.maxPop,
|
||||
Time: now,
|
||||
},
|
||||
ProbabilityThreshold: forecast.DefaultPrecipWindowProbabilityThreshold,
|
||||
TextDescriptions: tt.descriptions,
|
||||
},
|
||||
},
|
||||
}, "America/Chicago")
|
||||
if len(value.PrecipitationWindows) != 1 {
|
||||
t.Fatalf("PrecipitationWindows = %#v, want one window", value.PrecipitationWindows)
|
||||
}
|
||||
window := value.PrecipitationWindows[0]
|
||||
if window.PrecipitationType != tt.wantType || window.ExpectationPhrase != tt.wantPhrase {
|
||||
t.Fatalf("window = %#v, want type %q and phrase %q", window, tt.wantType, tt.wantPhrase)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrecipTimingModuleUsesDerivedTimingWithoutDaypartSummaries(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.Hourly)
|
||||
ctx.Derived.DailySummaries = nil
|
||||
ctx.Derived.DaypartSummaries = nil
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[PrecipTimingModule](t, output)
|
||||
if value.MaxPopPercent == nil || *value.MaxPopPercent != 80 || len(value.PrecipitationWindows) != 2 {
|
||||
t.Fatalf("precip timing = %#v, want derived timing without daypart summaries", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.Daily)
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[map[string]DerivedDaypartSummaryModule](t, output)
|
||||
|
||||
morning, ok := value["morning"]
|
||||
if !ok {
|
||||
t.Fatalf("daypart keys = %#v, want configured morning key", value)
|
||||
}
|
||||
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 {
|
||||
t.Fatalf("morning = %#v, want temp range and precip peak", morning)
|
||||
}
|
||||
if morning.DisplayName != "Morning" || morning.DominantConditionLower != "showers" || morning.DominantConditionDisplay != "Showers" || morning.TemperaturePhraseF != "upper 50s" || morning.TemperatureTrend != "steady" || morning.TemperatureSteadyPhraseF != "upper 50s" || !morning.MentionPrecipitation || morning.MaxPopTimeLabel != "6:00 AM" {
|
||||
t.Fatalf("morning presentation fields = %#v, want display facts for template composition", morning)
|
||||
}
|
||||
if morning.Date != "2026-05-29" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" {
|
||||
t.Fatalf("morning period = %q/%q/%q, want friendly local date and period labels", morning.Date, morning.PeriodBegins, morning.PeriodEnds)
|
||||
}
|
||||
overnight := value["overnight"]
|
||||
if overnight.MentionPrecipitation {
|
||||
t.Fatalf("overnight MentionPrecipitation = true, want false below threshold")
|
||||
}
|
||||
afternoon := value["afternoon"]
|
||||
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 {
|
||||
t.Fatalf("afternoon = %#v, want heat and wind hazard values", afternoon)
|
||||
}
|
||||
if !overnight.Cold {
|
||||
t.Fatalf("overnight = %#v, want cold hazard", overnight)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal daypart summaries: %v", err)
|
||||
}
|
||||
jsonText := string(data)
|
||||
for _, field := range []string{"date", "display_name", "period_begins", "period_ends", "temp_range_f", "temperature_phrase_f", "temperature_trend", "temperature_steady_phrase_f", "max_pop_percent", "max_pop_time_label", "mention_precipitation", "max_wind_gust_mph", "dominant_condition", "dominant_condition_lower", "dominant_condition_display"} {
|
||||
if !strings.Contains(jsonText, field) {
|
||||
t.Fatalf("daypart json = %s, want field %s", jsonText, field)
|
||||
}
|
||||
}
|
||||
if strings.Contains(jsonText, `"period":`) || strings.Contains(jsonText, `T06:00:00`) {
|
||||
t.Fatalf("daypart json = %s, want friendly period label instead of raw timestamps", jsonText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedDaypartSummariesPromptExportOmitsTemplateHelpers(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.Daily)
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
richText := mustMarshalModuleJSON(t, output.Value)
|
||||
for _, field := range []string{"temperature_phrase_f", "dominant_condition_lower", "dominant_condition_display", "max_pop_time_label"} {
|
||||
if !strings.Contains(richText, field) {
|
||||
t.Fatalf("rich daypart json = %s, want helper field %s", richText, field)
|
||||
}
|
||||
}
|
||||
|
||||
prompt := moduleDataPackageValue[map[string]DerivedDaypartSummaryPromptExport](t, output)
|
||||
morning, ok := prompt["morning"]
|
||||
if !ok {
|
||||
t.Fatalf("daypart prompt keys = %#v, want morning", prompt)
|
||||
}
|
||||
if morning.Date != "2026-05-29" || morning.DisplayName != "Morning" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" {
|
||||
t.Fatalf("morning prompt period = %#v, want date/display/period labels", morning)
|
||||
}
|
||||
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 || morning.MaxPopTime != "6:00 AM" || !morning.MentionPrecipitation {
|
||||
t.Fatalf("morning prompt precip/temp = %#v, want factual prompt fields with friendly max pop time", morning)
|
||||
}
|
||||
if morning.DominantCondition != "Showers" || morning.TemperatureTrend != "steady" || morning.TemperatureSteadyPhraseF != "upper 50s" {
|
||||
t.Fatalf("morning prompt condition/trend = %#v, want condition and trend fields", morning)
|
||||
}
|
||||
if len(morning.NotableConditions) == 0 || morning.NotableConditions[0] != "Showers" {
|
||||
t.Fatalf("morning prompt notable conditions = %#v, want copied conditions", morning.NotableConditions)
|
||||
}
|
||||
afternoon := prompt["afternoon"]
|
||||
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 || afternoon.RelevantAlertCount != 1 {
|
||||
t.Fatalf("afternoon prompt = %#v, want hazard, wind, and alert fields", afternoon)
|
||||
}
|
||||
|
||||
promptText := mustMarshalModuleJSON(t, output.DataPackageValue())
|
||||
for _, field := range []string{"date", "display_name", "period_begins", "period_ends", "temp_range_f", "max_pop_percent", "max_pop_time", "mention_precipitation", "max_wind_gust_mph", "dominant_condition", "temperature_trend", "temperature_steady_phrase_f", "notable_conditions", "relevant_alert_count"} {
|
||||
if !strings.Contains(promptText, field) {
|
||||
t.Fatalf("daypart prompt json = %s, want field %s", promptText, field)
|
||||
}
|
||||
}
|
||||
for _, field := range []string{"temperature_phrase_f", "dominant_condition_lower", "dominant_condition_display", "max_pop_time_label"} {
|
||||
if strings.Contains(promptText, field) {
|
||||
t.Fatalf("daypart prompt json = %s, want omitted helper field %s", promptText, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedDaypartPromptExportTemperatureTrends(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
temps []float64
|
||||
wantTrend string
|
||||
wantStart string
|
||||
wantEnd string
|
||||
wantPeak string
|
||||
wantSteady string
|
||||
}{
|
||||
{
|
||||
name: "rising",
|
||||
temps: []float64{58, 68},
|
||||
wantTrend: "rising",
|
||||
wantStart: "upper 50s",
|
||||
wantEnd: "upper 60s",
|
||||
},
|
||||
{
|
||||
name: "falling",
|
||||
temps: []float64{65, 58},
|
||||
wantTrend: "falling",
|
||||
wantStart: "mid 60s",
|
||||
wantEnd: "upper 50s",
|
||||
},
|
||||
{
|
||||
name: "peaking",
|
||||
temps: []float64{62, 78, 65},
|
||||
wantTrend: "peaking",
|
||||
wantPeak: "upper 70s",
|
||||
},
|
||||
{
|
||||
name: "steady",
|
||||
temps: []float64{77, 78},
|
||||
wantTrend: "steady",
|
||||
wantSteady: "upper 70s",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
rich := derivedDaypartSummaryValue(derivedDaypartWithTemperatures(test.name, "2026-05-29T12:00:00-05:00", "sunny", test.temps...), "America/Chicago")
|
||||
prompt := derivedDaypartSummaryPromptValue(rich)
|
||||
if prompt.TemperatureTrend != test.wantTrend ||
|
||||
prompt.TemperatureStartPhraseF != test.wantStart ||
|
||||
prompt.TemperatureEndPhraseF != test.wantEnd ||
|
||||
prompt.TemperaturePeakPhraseF != test.wantPeak ||
|
||||
prompt.TemperatureSteadyPhraseF != test.wantSteady {
|
||||
t.Fatalf("prompt temperature presentation = %#v", prompt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedDaypartPromptExportMaxPopTimeFallback(t *testing.T) {
|
||||
withLabel := derivedDaypartSummaryPromptValue(DerivedDaypartSummaryModule{
|
||||
MaxPopTime: "6 AM",
|
||||
MaxPopTimeLabel: "6:00 AM",
|
||||
})
|
||||
if withLabel.MaxPopTime != "6:00 AM" {
|
||||
t.Fatalf("MaxPopTime with label = %q, want friendly label", withLabel.MaxPopTime)
|
||||
}
|
||||
|
||||
withoutLabel := derivedDaypartSummaryPromptValue(DerivedDaypartSummaryModule{
|
||||
MaxPopTime: "6 AM",
|
||||
})
|
||||
if withoutLabel.MaxPopTime != "6 AM" {
|
||||
t.Fatalf("MaxPopTime without label = %q, want fallback time", withoutLabel.MaxPopTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedDaypartTemperaturePresentationFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
temps []float64
|
||||
wantTrend string
|
||||
wantStart string
|
||||
wantEnd string
|
||||
wantPeak string
|
||||
wantSteady string
|
||||
}{
|
||||
{
|
||||
name: "rising",
|
||||
temps: []float64{58, 68},
|
||||
wantTrend: "rising",
|
||||
wantStart: "upper 50s",
|
||||
wantEnd: "upper 60s",
|
||||
},
|
||||
{
|
||||
name: "falling",
|
||||
temps: []float64{65, 58},
|
||||
wantTrend: "falling",
|
||||
wantStart: "mid 60s",
|
||||
wantEnd: "upper 50s",
|
||||
},
|
||||
{
|
||||
name: "peaking",
|
||||
temps: []float64{62, 78, 65},
|
||||
wantTrend: "peaking",
|
||||
wantPeak: "upper 70s",
|
||||
},
|
||||
{
|
||||
name: "steady same band",
|
||||
temps: []float64{77, 78},
|
||||
wantTrend: "steady",
|
||||
wantSteady: "upper 70s",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
summary := derivedDaypartWithTemperatures("afternoon", "2026-05-29T12:00:00-05:00", "sunny", test.temps...)
|
||||
value := derivedDaypartSummaryValue(summary, "America/Chicago")
|
||||
if value.DominantConditionDisplay != "Sunny" {
|
||||
t.Fatalf("DominantConditionDisplay = %q, want Sunny", value.DominantConditionDisplay)
|
||||
}
|
||||
if value.TemperatureTrend != test.wantTrend ||
|
||||
value.TemperatureStartPhraseF != test.wantStart ||
|
||||
value.TemperatureEndPhraseF != test.wantEnd ||
|
||||
value.TemperaturePeakPhraseF != test.wantPeak ||
|
||||
value.TemperatureSteadyPhraseF != test.wantSteady {
|
||||
t.Fatalf("temperature presentation = %#v", value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemperaturePhraseF(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value forecast.Range
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "single low band",
|
||||
value: forecast.Range{Min: floatPtr(71), Max: floatPtr(73)},
|
||||
want: "low 70s",
|
||||
},
|
||||
{
|
||||
name: "single upper value",
|
||||
value: forecast.Range{Min: floatPtr(68), Max: floatPtr(68)},
|
||||
want: "upper 60s",
|
||||
},
|
||||
{
|
||||
name: "range across bands",
|
||||
value: forecast.Range{Min: floatPtr(68), Max: floatPtr(75)},
|
||||
want: "upper 60s to mid 70s",
|
||||
},
|
||||
{
|
||||
name: "max only",
|
||||
value: forecast.Range{Max: floatPtr(84)},
|
||||
want: "mid 80s",
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
value: forecast.Range{},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := temperaturePhraseF(test.value); got != test.want {
|
||||
t.Fatalf("temperaturePhraseF() = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.Tomorrow)
|
||||
|
||||
outdoorOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(outdoor windows) error = %v", err)
|
||||
}
|
||||
outdoor := moduleValue[OutdoorWindowsModule](t, outdoorOutput)
|
||||
if outdoor.Best == nil || outdoor.Worst == nil {
|
||||
t.Fatalf("outdoor windows = %#v, want best and worst", outdoor)
|
||||
}
|
||||
if outdoor.Best.Daypart != "overnight" || outdoor.Worst.Daypart != "afternoon" {
|
||||
t.Fatalf("outdoor windows = %#v, want quiet overnight and stormy afternoon", outdoor)
|
||||
}
|
||||
if outdoor.Best.PeriodBegins != "2026-05-29 at 12:00 AM" || outdoor.Best.PeriodEnds != "2026-05-29 at 6:00 AM" {
|
||||
t.Fatalf("best outdoor period = %#v, want overnight period labels", outdoor.Best)
|
||||
}
|
||||
if outdoor.Worst.PeriodBegins != "2026-05-29 at 12:00 PM" || outdoor.Worst.PeriodEnds != "2026-05-29 at 6:00 PM" {
|
||||
t.Fatalf("worst outdoor period = %#v, want afternoon period labels", outdoor.Worst)
|
||||
}
|
||||
|
||||
planningOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TomorrowPlanning})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(tomorrow planning) error = %v", err)
|
||||
}
|
||||
planning := moduleValue[TomorrowPlanningModule](t, planningOutput)
|
||||
if len(planning.MorningReadiness) == 0 || len(planning.CommuteSchoolWorkdayConcerns) == 0 || len(planning.OvernightChangeWatch) == 0 {
|
||||
t.Fatalf("tomorrow planning = %#v, want daily planning notes", planning)
|
||||
}
|
||||
data, err := json.Marshal(planningOutput.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal tomorrow planning: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "morning_readiness") || strings.Contains(string(data), "morningReadiness") {
|
||||
t.Fatalf("tomorrow planning json = %s, want snake_case fields", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyPlanningModulePackagesPlanningFields(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := dailyModuleContext()
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(daily planning) error = %v", err)
|
||||
}
|
||||
if output.ID != module.DailyPlanning || output.StanzaName != "daily_planning" {
|
||||
t.Fatalf("output = %#v, want daily planning stanza", output)
|
||||
}
|
||||
planning := moduleValue[DailyPlanningModule](t, output)
|
||||
if len(planning.MorningReadiness) == 0 ||
|
||||
len(planning.CommuteSchoolWorkdayConcerns) == 0 ||
|
||||
len(planning.OvernightChangeWatch) == 0 {
|
||||
t.Fatalf("daily planning = %#v, want populated planning fields", planning)
|
||||
}
|
||||
if !containsString(planning.MorningReadiness, "Morning precipitation chance peaks near 60%.") {
|
||||
t.Fatalf("MorningReadiness = %#v, want precipitation readiness note", planning.MorningReadiness)
|
||||
}
|
||||
if !containsString(planning.CommuteSchoolWorkdayConcerns, "Afternoon alert overlap needs attention.") {
|
||||
t.Fatalf("CommuteSchoolWorkdayConcerns = %#v, want alert-overlap concern", planning.CommuteSchoolWorkdayConcerns)
|
||||
}
|
||||
if !containsString(planning.OvernightChangeWatch, "Watch for forecast timing or intensity adjustments overnight.") {
|
||||
t.Fatalf("OvernightChangeWatch = %#v, want overnight fallback note", planning.OvernightChangeWatch)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal daily planning: %v", err)
|
||||
}
|
||||
jsonText := string(data)
|
||||
for _, field := range []string{"morning_readiness", "commute_school_workday_concerns", "overnight_change_watch"} {
|
||||
if !strings.Contains(jsonText, field) {
|
||||
t.Fatalf("daily planning json = %s, want field %s", jsonText, field)
|
||||
}
|
||||
}
|
||||
if strings.Contains(jsonText, "tomorrow_planning") || strings.Contains(jsonText, "morningReadiness") {
|
||||
t.Fatalf("daily planning json = %s, want daily snake_case fields only", jsonText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyPlanningModuleRejectsUnsupportedReports(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
for _, id := range []report.ID{report.Today, report.Tomorrow, report.Hourly} {
|
||||
t.Run(string(id), func(t *testing.T) {
|
||||
ctx := derivedModuleContext(id)
|
||||
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning})
|
||||
if err == nil || !strings.Contains(err.Error(), `module "daily_planning" is not compatible with report`) {
|
||||
t.Fatalf("BuildModule(%s) error = %v, want incompatible report", id, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyPlanningModuleHandlesMissingDailySummary(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := dailyModuleContext()
|
||||
ctx.Derived.DailySummaries = nil
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(daily planning) error = %v", err)
|
||||
}
|
||||
planning := moduleValue[DailyPlanningModule](t, output)
|
||||
if len(planning.MorningReadiness) != 0 ||
|
||||
len(planning.CommuteSchoolWorkdayConcerns) != 0 ||
|
||||
len(planning.OvernightChangeWatch) != 0 {
|
||||
t.Fatalf("daily planning = %#v, want empty output without daily summary", planning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodayPlanningModulePackagesPlanningFields(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := todayModuleContext()
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(today planning) error = %v", err)
|
||||
}
|
||||
if output.ID != module.TodayPlanning || output.StanzaName != "today_planning" {
|
||||
t.Fatalf("output = %#v, want today planning stanza", output)
|
||||
}
|
||||
planning := moduleValue[TodayPlanningModule](t, output)
|
||||
if len(planning.MorningReadiness) == 0 ||
|
||||
len(planning.CommuteSchoolWorkdayConcerns) == 0 ||
|
||||
len(planning.OutdoorPlanning) == 0 ||
|
||||
len(planning.LateDayChangeWatch) == 0 {
|
||||
t.Fatalf("today planning = %#v, want populated planning fields", planning)
|
||||
}
|
||||
if !containsString(planning.MorningReadiness, "Morning precipitation chance peaks near 60%.") {
|
||||
t.Fatalf("MorningReadiness = %#v, want precipitation readiness note", planning.MorningReadiness)
|
||||
}
|
||||
if !containsString(planning.OutdoorPlanning, "Best outdoor window: Overnight (cold risk).") ||
|
||||
!containsString(planning.OutdoorPlanning, "Toughest outdoor window: Afternoon (high precipitation chance, gusty wind, alert overlap, heat risk).") {
|
||||
t.Fatalf("OutdoorPlanning = %#v, want deterministic best and toughest windows", planning.OutdoorPlanning)
|
||||
}
|
||||
if !containsString(planning.LateDayChangeWatch, "Afternoon precipitation timing may shift; current peak is near 80%.") {
|
||||
t.Fatalf("LateDayChangeWatch = %#v, want late-day change note", planning.LateDayChangeWatch)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal today planning: %v", err)
|
||||
}
|
||||
jsonText := string(data)
|
||||
for _, field := range []string{"morning_readiness", "commute_school_workday_concerns", "outdoor_planning", "late_day_change_watch"} {
|
||||
if !strings.Contains(jsonText, field) {
|
||||
t.Fatalf("today planning json = %s, want field %s", jsonText, field)
|
||||
}
|
||||
}
|
||||
if strings.Contains(jsonText, "morningReadiness") || strings.Contains(jsonText, "lateDayChangeWatch") {
|
||||
t.Fatalf("today planning json = %s, want snake_case fields", jsonText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodayPlanningModuleRejectsUnsupportedReports(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
for _, id := range []report.ID{report.Tomorrow, report.Daily} {
|
||||
t.Run(string(id), func(t *testing.T) {
|
||||
ctx := derivedModuleContext(id)
|
||||
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning})
|
||||
if err == nil || !strings.Contains(err.Error(), `module "today_planning" is not compatible with report`) {
|
||||
t.Fatalf("BuildModule(%s) error = %v, want incompatible report", id, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodayPlanningModuleHandlesMissingDailySummary(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := todayModuleContext()
|
||||
ctx.Derived.DailySummaries = nil
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(today planning) error = %v", err)
|
||||
}
|
||||
planning := moduleValue[TodayPlanningModule](t, output)
|
||||
if len(planning.MorningReadiness) != 0 ||
|
||||
len(planning.CommuteSchoolWorkdayConcerns) != 0 ||
|
||||
len(planning.OutdoorPlanning) != 0 ||
|
||||
len(planning.LateDayChangeWatch) != 0 {
|
||||
t.Fatalf("today planning = %#v, want empty output without daily summary", planning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedModulesHandleMissingData(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.Daily)
|
||||
ctx.Derived.DailySummaries = nil
|
||||
ctx.Derived.DaypartSummaries = nil
|
||||
|
||||
if _, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary}); err == nil {
|
||||
t.Fatal("BuildModule(derived daily summary) error = nil, want required facts error")
|
||||
}
|
||||
if _, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries}); err == nil {
|
||||
t.Fatal("BuildModule(daypart summaries) error = nil, want required facts error")
|
||||
}
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(outdoor windows) error = %v", err)
|
||||
}
|
||||
windows := moduleValue[OutdoorWindowsModule](t, output)
|
||||
if windows.Best != nil || windows.Worst != nil {
|
||||
t.Fatalf("outdoor windows = %#v, want empty output with missing dayparts", windows)
|
||||
}
|
||||
}
|
||||
|
||||
func derivedModuleContext(id report.ID) ModuleContext {
|
||||
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
|
||||
definition := report.DefaultRegistry().MustLookup(id)
|
||||
summary := forecast.DailySummary{
|
||||
Date: "2026-05-29",
|
||||
Period: timeutil.Period{
|
||||
Start: mustParseModuleTime("2026-05-29T00:00:00-05:00"),
|
||||
End: mustParseModuleTime("2026-05-30T00:00:00-05:00"),
|
||||
},
|
||||
Dayparts: []forecast.DaypartSummary{
|
||||
derivedDaypart("overnight", "2026-05-29T00:00:00-05:00", "2026-05-29T06:00:00-05:00", "Clear and cold", 31, nil, 0, 5),
|
||||
derivedDaypart("morning", "2026-05-29T06:00:00-05:00", "2026-05-29T12:00:00-05:00", "Showers", 58, nil, 60, 15),
|
||||
derivedDaypart("afternoon", "2026-05-29T12:00:00-05:00", "2026-05-29T18:00:00-05:00", "Thunderstorms with gusty wind", 96, floatPtr(101), 80, 42),
|
||||
},
|
||||
}
|
||||
hours := []weatherdata.ForecastPeriod{
|
||||
derivedHour("2026-05-29T00:00:00-05:00", "Clear and cold", 0, 31, nil, 5),
|
||||
derivedHour("2026-05-29T08:00:00-05:00", "Showers", 60, 58, nil, 15),
|
||||
derivedHour("2026-05-29T09:00:00-05:00", "Dry break", 20, 62, nil, 10),
|
||||
derivedHour("2026-05-29T12:00:00-05:00", "Thunderstorms with gusty wind", 80, 96, floatPtr(101), 42),
|
||||
derivedHour("2026-05-29T13:00:00-05:00", "Heavy rain", 70, 82, nil, 30),
|
||||
derivedHour("2026-05-29T14:00:00-05:00", "Drying out", 20, 78, nil, 12),
|
||||
}
|
||||
narrative := []weatherdata.ForecastPeriod{
|
||||
{
|
||||
Name: "Today",
|
||||
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
|
||||
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
|
||||
IsDay: boolPtr(true),
|
||||
TextDescription: "Morning storms, then partly sunny.",
|
||||
TemperatureFMax: floatPtr(88),
|
||||
ProbabilityOfPrecipitationPercent: floatPtr(55),
|
||||
},
|
||||
{
|
||||
Name: "Tonight",
|
||||
StartTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
|
||||
EndTime: mustParseModuleTime("2026-05-30T00:00:00-05:00"),
|
||||
IsDay: boolPtr(false),
|
||||
TextDescription: "Clouds linger tonight.",
|
||||
TemperatureFMin: floatPtr(64),
|
||||
ProbabilityOfPrecipitationPercent: floatPtr(30),
|
||||
},
|
||||
}
|
||||
summary.Dayparts[2].AlertOverlaps = []forecast.AlertOverlap{{Event: "Severe Thunderstorm Watch"}}
|
||||
summary.NarrativePeriods = append([]weatherdata.ForecastPeriod(nil), narrative...)
|
||||
return ModuleContext{
|
||||
Resolved: report.Resolved{
|
||||
Definition: definition,
|
||||
GeneratedAt: generatedAt,
|
||||
Timezone: "America/Chicago",
|
||||
ValidPeriod: summary.Period,
|
||||
},
|
||||
Collected: facts.CollectedFacts{
|
||||
Narrative: &weatherdata.ForecastRun{
|
||||
IssuedAt: mustParseModuleTime("2026-05-29T10:30:00-05:00"),
|
||||
Product: "narrative",
|
||||
Periods: append([]weatherdata.ForecastPeriod(nil), narrative...),
|
||||
},
|
||||
},
|
||||
Derived: facts.DerivedFacts{
|
||||
ValidPeriodHourlyPeriods: hours,
|
||||
ValidPeriodNarrativePeriods: narrative,
|
||||
DailySummaries: []forecast.DailySummary{summary},
|
||||
DaypartSummaries: append([]forecast.DaypartSummary(nil), summary.Dayparts...),
|
||||
PrecipTiming: forecast.BuildPrecipTiming(hours),
|
||||
},
|
||||
Units: "us",
|
||||
Timezone: "America/Chicago",
|
||||
}
|
||||
}
|
||||
|
||||
func todayModuleContext() ModuleContext {
|
||||
ctx := derivedModuleContext(report.Daily)
|
||||
ctx.Resolved.Definition = report.Definition{
|
||||
ID: report.Today,
|
||||
Name: "Today Report",
|
||||
PromptID: "weather.today_generated_text",
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func dailyModuleContext() ModuleContext {
|
||||
ctx := derivedModuleContext(report.Tomorrow)
|
||||
ctx.Resolved.Definition = report.Definition{
|
||||
ID: report.Daily,
|
||||
Name: "Daily Report",
|
||||
PromptID: "weather.daily_generated_text",
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func derivedDaypart(name string, start string, end string, text string, temperature float64, apparent *float64, precip float64, gust float64) forecast.DaypartSummary {
|
||||
hour := derivedHour(start, text, precip, temperature, apparent, gust)
|
||||
return forecast.SummarizeDaypart(name, timeutil.Period{
|
||||
Start: mustParseModuleTime(start),
|
||||
End: mustParseModuleTime(end),
|
||||
}, []weatherdata.ForecastPeriod{hour})
|
||||
}
|
||||
|
||||
func derivedDaypartWithTemperatures(name string, start string, text string, temperatures ...float64) forecast.DaypartSummary {
|
||||
startTime := mustParseModuleTime(start)
|
||||
periods := make([]weatherdata.ForecastPeriod, 0, len(temperatures))
|
||||
for index, temperature := range temperatures {
|
||||
periodStart := startTime.Add(time.Duration(index) * time.Hour)
|
||||
periods = append(periods, weatherdata.ForecastPeriod{
|
||||
StartTime: periodStart,
|
||||
EndTime: periodStart.Add(time.Hour),
|
||||
TextDescription: text,
|
||||
TemperatureF: floatPtr(temperature),
|
||||
})
|
||||
}
|
||||
return forecast.SummarizeDaypart(name, timeutil.Period{
|
||||
Start: startTime,
|
||||
End: startTime.Add(time.Duration(len(temperatures)) * time.Hour),
|
||||
}, periods)
|
||||
}
|
||||
|
||||
func derivedHour(start string, text string, precip float64, temperature float64, apparent *float64, gust float64) weatherdata.ForecastPeriod {
|
||||
startTime := mustParseModuleTime(start)
|
||||
endTime := startTime.Add(time.Hour)
|
||||
return weatherdata.ForecastPeriod{
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
TextDescription: text,
|
||||
TemperatureF: floatPtr(temperature),
|
||||
ApparentTemperatureF: apparent,
|
||||
ProbabilityOfPrecipitationPercent: floatPtr(precip),
|
||||
WindGustMph: floatPtr(gust),
|
||||
}
|
||||
}
|
||||
|
||||
func floatPtr(value float64) *float64 {
|
||||
return &value
|
||||
}
|
||||
|
||||
func boolPtr(value bool) *bool {
|
||||
return &value
|
||||
}
|
||||
|
||||
func containsString(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
247
internal/briefing/hourly_forecast_module.go
Normal file
247
internal/briefing/hourly_forecast_module.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
const DefaultHourlyForecastPrecipMentionProbabilityThreshold = 20
|
||||
|
||||
type HourlyForecastModule struct {
|
||||
Product string `json:"product,omitempty"`
|
||||
IssuedAt time.Time `json:"issued_at,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
SourceLocation string `json:"source_location,omitempty"`
|
||||
SourceLocationID string `json:"source_location_id,omitempty"`
|
||||
Periods []HourlyForecastPeriod `json:"periods,omitempty"`
|
||||
}
|
||||
|
||||
type HourlyForecastPromptExport struct {
|
||||
Product string `json:"product,omitempty"`
|
||||
IssuedAt time.Time `json:"issued_at,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
SourceLocation string `json:"source_location,omitempty"`
|
||||
SourceLocationID string `json:"source_location_id,omitempty"`
|
||||
Periods []HourlyForecastPromptPeriod `json:"periods,omitempty"`
|
||||
}
|
||||
|
||||
type HourlyForecastPeriod struct {
|
||||
HourLabel string `json:"hour_label,omitempty"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
IsDay *bool `json:"is_day,omitempty"`
|
||||
ConditionCode *int `json:"condition_code,omitempty"`
|
||||
TextDescription string `json:"text_description,omitempty"`
|
||||
TextDescriptionLower string `json:"text_description_lower,omitempty"`
|
||||
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||
TemperatureF *float64 `json:"temperature_f,omitempty"`
|
||||
TemperatureCMin *float64 `json:"temperature_c_min,omitempty"`
|
||||
TemperatureFMin *float64 `json:"temperature_f_min,omitempty"`
|
||||
TemperatureCMax *float64 `json:"temperature_c_max,omitempty"`
|
||||
TemperatureFMax *float64 `json:"temperature_f_max,omitempty"`
|
||||
DewpointC *float64 `json:"dewpoint_c,omitempty"`
|
||||
DewpointF *float64 `json:"dewpoint_f,omitempty"`
|
||||
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
|
||||
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
|
||||
WindGustKmh *float64 `json:"wind_gust_kmh,omitempty"`
|
||||
WindGustMph *float64 `json:"wind_gust_mph,omitempty"`
|
||||
WindDirection string `json:"wind_direction,omitempty"`
|
||||
BarometricPressurePa *float64 `json:"barometric_pressure_pa,omitempty"`
|
||||
BarometricPressureInHg *float64 `json:"barometric_pressure_in_hg,omitempty"`
|
||||
VisibilityMeters *float64 `json:"visibility_meters,omitempty"`
|
||||
VisibilityMiles *float64 `json:"visibility_miles,omitempty"`
|
||||
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"`
|
||||
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"`
|
||||
CloudCoverPercent *float64 `json:"cloud_cover_percent,omitempty"`
|
||||
ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"`
|
||||
MentionPrecipitation bool `json:"mention_precipitation,omitempty"`
|
||||
PrecipitationAmountMm *float64 `json:"precipitation_amount_mm,omitempty"`
|
||||
PrecipitationAmountIn *float64 `json:"precipitation_amount_in,omitempty"`
|
||||
SnowfallDepthMM *float64 `json:"snowfall_depth_mm,omitempty"`
|
||||
SnowfallDepthIn *float64 `json:"snowfall_depth_in,omitempty"`
|
||||
UVIndex *float64 `json:"uv_index,omitempty"`
|
||||
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
|
||||
}
|
||||
|
||||
type HourlyForecastPromptPeriod struct {
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
IsDay *bool `json:"is_day,omitempty"`
|
||||
ConditionCode *int `json:"condition_code,omitempty"`
|
||||
TextDescription string `json:"text_description,omitempty"`
|
||||
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||
TemperatureF *float64 `json:"temperature_f,omitempty"`
|
||||
TemperatureCMin *float64 `json:"temperature_c_min,omitempty"`
|
||||
TemperatureFMin *float64 `json:"temperature_f_min,omitempty"`
|
||||
TemperatureCMax *float64 `json:"temperature_c_max,omitempty"`
|
||||
TemperatureFMax *float64 `json:"temperature_f_max,omitempty"`
|
||||
DewpointC *float64 `json:"dewpoint_c,omitempty"`
|
||||
DewpointF *float64 `json:"dewpoint_f,omitempty"`
|
||||
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
|
||||
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
|
||||
WindGustKmh *float64 `json:"wind_gust_kmh,omitempty"`
|
||||
WindGustMph *float64 `json:"wind_gust_mph,omitempty"`
|
||||
WindDirection string `json:"wind_direction,omitempty"`
|
||||
BarometricPressurePa *float64 `json:"barometric_pressure_pa,omitempty"`
|
||||
BarometricPressureInHg *float64 `json:"barometric_pressure_in_hg,omitempty"`
|
||||
VisibilityMeters *float64 `json:"visibility_meters,omitempty"`
|
||||
VisibilityMiles *float64 `json:"visibility_miles,omitempty"`
|
||||
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"`
|
||||
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"`
|
||||
CloudCoverPercent *float64 `json:"cloud_cover_percent,omitempty"`
|
||||
ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"`
|
||||
PrecipitationAmountMm *float64 `json:"precipitation_amount_mm,omitempty"`
|
||||
PrecipitationAmountIn *float64 `json:"precipitation_amount_in,omitempty"`
|
||||
SnowfallDepthMM *float64 `json:"snowfall_depth_mm,omitempty"`
|
||||
SnowfallDepthIn *float64 `json:"snowfall_depth_in,omitempty"`
|
||||
UVIndex *float64 `json:"uv_index,omitempty"`
|
||||
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
|
||||
}
|
||||
|
||||
func buildHourlyForecastModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
hourly := ctx.Collected.Hourly
|
||||
if hourly == nil || len(ctx.Derived.ValidPeriodHourlyPeriods) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
value := HourlyForecastModule{
|
||||
Product: hourly.Product,
|
||||
IssuedAt: hourly.IssuedAt,
|
||||
UpdatedAt: copyTime(hourly.UpdatedAt),
|
||||
SourceLocation: hourly.LocationName,
|
||||
SourceLocationID: hourly.LocationID,
|
||||
Periods: hourlyForecastPeriods(ctx.Derived.ValidPeriodHourlyPeriods, ctx.Timezone),
|
||||
}
|
||||
if value.isEmpty() {
|
||||
return nil, nil
|
||||
}
|
||||
return &module.Output{ID: module.HourlyForecast, StanzaName: "hourly_forecast", Value: value}, nil
|
||||
}
|
||||
|
||||
func exportHourlyForecastPromptValue(value any) (any, error) {
|
||||
rich, ok := value.(HourlyForecastModule)
|
||||
if !ok {
|
||||
return nil, unexpectedPromptExportValue(value, HourlyForecastModule{})
|
||||
}
|
||||
return HourlyForecastPromptExport{
|
||||
Product: rich.Product,
|
||||
IssuedAt: rich.IssuedAt,
|
||||
UpdatedAt: copyTime(rich.UpdatedAt),
|
||||
SourceLocation: rich.SourceLocation,
|
||||
SourceLocationID: rich.SourceLocationID,
|
||||
Periods: hourlyForecastPromptPeriods(rich.Periods),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func hourlyForecastPromptPeriods(periods []HourlyForecastPeriod) []HourlyForecastPromptPeriod {
|
||||
if len(periods) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]HourlyForecastPromptPeriod, 0, len(periods))
|
||||
for _, period := range periods {
|
||||
out = append(out, HourlyForecastPromptPeriod{
|
||||
PeriodBegins: period.PeriodBegins,
|
||||
PeriodEnds: period.PeriodEnds,
|
||||
Name: period.Name,
|
||||
IsDay: copyBool(period.IsDay),
|
||||
ConditionCode: copyInt(period.ConditionCode),
|
||||
TextDescription: period.TextDescription,
|
||||
TemperatureC: copyFloat(period.TemperatureC),
|
||||
TemperatureF: copyFloat(period.TemperatureF),
|
||||
TemperatureCMin: copyFloat(period.TemperatureCMin),
|
||||
TemperatureFMin: copyFloat(period.TemperatureFMin),
|
||||
TemperatureCMax: copyFloat(period.TemperatureCMax),
|
||||
TemperatureFMax: copyFloat(period.TemperatureFMax),
|
||||
DewpointC: copyFloat(period.DewpointC),
|
||||
DewpointF: copyFloat(period.DewpointF),
|
||||
WindSpeedKmh: copyFloat(period.WindSpeedKmh),
|
||||
WindSpeedMph: copyFloat(period.WindSpeedMph),
|
||||
WindGustKmh: copyFloat(period.WindGustKmh),
|
||||
WindGustMph: copyFloat(period.WindGustMph),
|
||||
WindDirection: period.WindDirection,
|
||||
BarometricPressurePa: copyFloat(period.BarometricPressurePa),
|
||||
BarometricPressureInHg: copyFloat(period.BarometricPressureInHg),
|
||||
VisibilityMeters: copyFloat(period.VisibilityMeters),
|
||||
VisibilityMiles: copyFloat(period.VisibilityMiles),
|
||||
ApparentTemperatureC: copyFloat(period.ApparentTemperatureC),
|
||||
ApparentTemperatureF: copyFloat(period.ApparentTemperatureF),
|
||||
CloudCoverPercent: copyFloat(period.CloudCoverPercent),
|
||||
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
|
||||
PrecipitationAmountMm: copyFloat(period.PrecipitationAmountMm),
|
||||
PrecipitationAmountIn: copyFloat(period.PrecipitationAmountIn),
|
||||
SnowfallDepthMM: copyFloat(period.SnowfallDepthMM),
|
||||
SnowfallDepthIn: copyFloat(period.SnowfallDepthIn),
|
||||
UVIndex: copyFloat(period.UVIndex),
|
||||
RelativeHumidityPercent: copyFloat(period.RelativeHumidityPercent),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod {
|
||||
return hourlyForecastPeriodsWithPrecipMentionThreshold(periods, timezone, DefaultHourlyForecastPrecipMentionProbabilityThreshold)
|
||||
}
|
||||
|
||||
func hourlyForecastPeriodsWithPrecipMentionThreshold(periods []weatherdata.ForecastPeriod, timezone string, threshold float64) []HourlyForecastPeriod {
|
||||
out := make([]HourlyForecastPeriod, 0, len(periods))
|
||||
for _, period := range periods {
|
||||
validPeriod := timeutil.Period{Start: period.StartTime, End: period.EndTime}
|
||||
out = append(out, HourlyForecastPeriod{
|
||||
HourLabel: hourMinuteLabel(period.StartTime, timezone),
|
||||
PeriodBegins: friendlyPeriodBeginsLabel(validPeriod, timezone),
|
||||
PeriodEnds: friendlyPeriodEndsLabel(validPeriod, timezone),
|
||||
Name: period.Name,
|
||||
IsDay: copyBool(period.IsDay),
|
||||
ConditionCode: copyInt(period.ConditionCode),
|
||||
TextDescription: period.TextDescription,
|
||||
TextDescriptionLower: strings.ToLower(period.TextDescription),
|
||||
TemperatureC: copyFloat(period.TemperatureC),
|
||||
TemperatureF: copyFloat(period.TemperatureF),
|
||||
TemperatureCMin: copyFloat(period.TemperatureCMin),
|
||||
TemperatureFMin: copyFloat(period.TemperatureFMin),
|
||||
TemperatureCMax: copyFloat(period.TemperatureCMax),
|
||||
TemperatureFMax: copyFloat(period.TemperatureFMax),
|
||||
DewpointC: copyFloat(period.DewpointC),
|
||||
DewpointF: copyFloat(period.DewpointF),
|
||||
WindSpeedKmh: copyFloat(period.WindSpeedKmh),
|
||||
WindSpeedMph: copyFloat(period.WindSpeedMph),
|
||||
WindGustKmh: copyFloat(period.WindGustKmh),
|
||||
WindGustMph: copyFloat(period.WindGustMph),
|
||||
WindDirection: windDirectionLabel(period.WindDirectionDegrees),
|
||||
BarometricPressurePa: copyFloat(period.BarometricPressurePa),
|
||||
BarometricPressureInHg: copyFloat(period.BarometricPressureInHg),
|
||||
VisibilityMeters: copyFloat(period.VisibilityMeters),
|
||||
VisibilityMiles: copyFloat(period.VisibilityMiles),
|
||||
ApparentTemperatureC: copyFloat(period.ApparentTemperatureC),
|
||||
ApparentTemperatureF: copyFloat(period.ApparentTemperatureF),
|
||||
CloudCoverPercent: copyFloat(period.CloudCoverPercent),
|
||||
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
|
||||
MentionPrecipitation: mentionHourlyForecastPrecipitation(period.ProbabilityOfPrecipitationPercent, threshold),
|
||||
PrecipitationAmountMm: copyFloat(period.PrecipitationAmountMm),
|
||||
PrecipitationAmountIn: copyFloat(period.PrecipitationAmountIn),
|
||||
SnowfallDepthMM: copyFloat(period.SnowfallDepthMM),
|
||||
SnowfallDepthIn: copyFloat(period.SnowfallDepthIn),
|
||||
UVIndex: copyFloat(period.UVIndex),
|
||||
RelativeHumidityPercent: copyFloat(period.RelativeHumidityPercent),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mentionHourlyForecastPrecipitation(probability *float64, threshold float64) bool {
|
||||
return probability != nil && *probability >= threshold
|
||||
}
|
||||
|
||||
func (v HourlyForecastModule) isEmpty() bool {
|
||||
return v.Product == "" &&
|
||||
v.IssuedAt.IsZero() &&
|
||||
v.UpdatedAt == nil &&
|
||||
v.SourceLocation == "" &&
|
||||
v.SourceLocationID == "" &&
|
||||
len(v.Periods) == 0
|
||||
}
|
||||
62
internal/briefing/metadata_module.go
Normal file
62
internal/briefing/metadata_module.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type MetadataModule struct {
|
||||
RunID string `json:"run_id"`
|
||||
ReportID report.ID `json:"report_id"`
|
||||
Variant string `json:"variant,omitempty"`
|
||||
PromptID string `json:"prompt_id"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
Units string `json:"units"`
|
||||
Timezone string `json:"timezone"`
|
||||
ValidPeriod timeutil.Period `json:"valid_period"`
|
||||
Location *LocationContext `json:"location,omitempty"`
|
||||
SourceWarnings []SourceWarningSummary `json:"source_warnings,omitempty"`
|
||||
}
|
||||
|
||||
type SourceWarningSummary struct {
|
||||
Source string `json:"source"`
|
||||
Code string `json:"code"`
|
||||
Severity string `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
CompletenessImpact string `json:"completeness_impact,omitempty"`
|
||||
}
|
||||
|
||||
func buildMetadataModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
metadata := ctx.Resolved.Metadata()
|
||||
value := MetadataModule{
|
||||
RunID: metadata.RunID,
|
||||
ReportID: metadata.ReportID,
|
||||
Variant: variantForReport(metadata.ReportID),
|
||||
PromptID: metadata.PromptID,
|
||||
GeneratedAt: metadata.GeneratedAt,
|
||||
Units: ctx.Units,
|
||||
Timezone: ctx.Timezone,
|
||||
ValidPeriod: metadata.ValidPeriod,
|
||||
Location: copyLocation(ctx.Location),
|
||||
SourceWarnings: sourceWarningSummaries(ctx.Collected.SourceWarnings),
|
||||
}
|
||||
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil
|
||||
}
|
||||
|
||||
func sourceWarningSummaries(warnings []weatherdata.SourceWarning) []SourceWarningSummary {
|
||||
out := make([]SourceWarningSummary, 0, len(warnings))
|
||||
for _, warning := range warnings {
|
||||
out = append(out, SourceWarningSummary{
|
||||
Source: warning.Source,
|
||||
Code: warning.Code,
|
||||
Severity: warning.Severity,
|
||||
Message: warning.Message,
|
||||
CompletenessImpact: warning.CompletenessImpact,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
177
internal/briefing/module_format_helpers.go
Normal file
177
internal/briefing/module_format_helpers.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
func rangeLabel(value forecast.Range) string {
|
||||
if value.Min == nil && value.Max == nil {
|
||||
return ""
|
||||
}
|
||||
if value.Min != nil && value.Max != nil {
|
||||
low := roundedInt(value.Min)
|
||||
high := roundedInt(value.Max)
|
||||
if low != nil && high != nil && *low == *high {
|
||||
return fmt.Sprintf("%d", *low)
|
||||
}
|
||||
return fmt.Sprintf("%d-%d", *low, *high)
|
||||
}
|
||||
if value.Min != nil {
|
||||
low := roundedInt(value.Min)
|
||||
return fmt.Sprintf("%d", *low)
|
||||
}
|
||||
high := roundedInt(value.Max)
|
||||
return fmt.Sprintf("%d", *high)
|
||||
}
|
||||
|
||||
func daypartApparentRangeLabel(value forecast.Range) string {
|
||||
if value.Min == nil && value.Max == nil {
|
||||
return ""
|
||||
}
|
||||
return rangeLabel(value)
|
||||
}
|
||||
|
||||
func roundedInt(value *float64) *int {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
rounded := int(*value + 0.5)
|
||||
if *value < 0 {
|
||||
rounded = int(*value - 0.5)
|
||||
}
|
||||
return &rounded
|
||||
}
|
||||
|
||||
func windDirectionLabel(degrees *float64) string {
|
||||
if degrees == nil {
|
||||
return ""
|
||||
}
|
||||
labels := []string{"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"}
|
||||
normalized := math.Mod(*degrees, 360)
|
||||
if normalized < 0 {
|
||||
normalized += 360
|
||||
}
|
||||
sector := int(math.Floor((normalized+11.25)/22.5)) % len(labels)
|
||||
return labels[sector]
|
||||
}
|
||||
|
||||
func windDirectionTextLabel(degrees *float64) string {
|
||||
if degrees == nil {
|
||||
return ""
|
||||
}
|
||||
labels := []string{
|
||||
"north",
|
||||
"north-northeast",
|
||||
"northeast",
|
||||
"east-northeast",
|
||||
"east",
|
||||
"east-southeast",
|
||||
"southeast",
|
||||
"south-southeast",
|
||||
"south",
|
||||
"south-southwest",
|
||||
"southwest",
|
||||
"west-southwest",
|
||||
"west",
|
||||
"west-northwest",
|
||||
"northwest",
|
||||
"north-northwest",
|
||||
}
|
||||
normalized := math.Mod(*degrees, 360)
|
||||
if normalized < 0 {
|
||||
normalized += 360
|
||||
}
|
||||
sector := int(math.Floor((normalized+11.25)/22.5)) % len(labels)
|
||||
return labels[sector]
|
||||
}
|
||||
|
||||
func timedClockLabel(value *forecast.TimedValue, timezone string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return clockLabel(value.Time, timezone)
|
||||
}
|
||||
|
||||
func friendlyPeriodBeginsLabel(period timeutil.Period, timezone string) string {
|
||||
if !period.IsValid() {
|
||||
return ""
|
||||
}
|
||||
return friendlyDateTimeLabel(period.Start, timezone)
|
||||
}
|
||||
|
||||
func friendlyPeriodEndsLabel(period timeutil.Period, timezone string) string {
|
||||
if !period.IsValid() {
|
||||
return ""
|
||||
}
|
||||
return friendlyDateTimeLabel(period.End, timezone)
|
||||
}
|
||||
|
||||
func friendlyDateTimeLabel(value time.Time, timezone string) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
location, err := timeutil.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
location = time.UTC
|
||||
}
|
||||
return value.In(location).Format("2006-01-02 at 3:04 PM")
|
||||
}
|
||||
|
||||
func friendlyMonthDayTimeLabel(value time.Time, timezone string) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
location, err := timeutil.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
location = time.UTC
|
||||
}
|
||||
return value.In(location).Format("January 2 at 3:04 PM")
|
||||
}
|
||||
|
||||
func friendlyDateLabel(date string, timezone string) string {
|
||||
location, err := timeutil.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
location = time.UTC
|
||||
}
|
||||
parsed, err := time.ParseInLocation(timeutil.DateLayout, date, location)
|
||||
if err != nil {
|
||||
return date
|
||||
}
|
||||
return parsed.Format("Monday, January 2, 2006")
|
||||
}
|
||||
|
||||
func localDateLabel(value time.Time, timezone string) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
location, err := timeutil.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
location = time.UTC
|
||||
}
|
||||
return value.In(location).Format(timeutil.DateLayout)
|
||||
}
|
||||
|
||||
func clockLabel(value time.Time, timezone string) string {
|
||||
location, err := timeutil.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
location = time.UTC
|
||||
}
|
||||
label := value.In(location).Format("3 PM")
|
||||
if label == "12 AM" && value.In(location).Minute() == 0 {
|
||||
return "12 AM"
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
func hourMinuteLabel(value time.Time, timezone string) string {
|
||||
location, err := timeutil.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
location = time.UTC
|
||||
}
|
||||
return value.In(location).Format("3:04 PM")
|
||||
}
|
||||
51
internal/briefing/module_format_helpers_test.go
Normal file
51
internal/briefing/module_format_helpers_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package briefing
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestWindDirectionLabelUsesSixteenPointCompass(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
degrees *float64
|
||||
want string
|
||||
}{
|
||||
{name: "nil", degrees: nil, want: ""},
|
||||
{name: "north", degrees: floatPtr(0), want: "N"},
|
||||
{name: "below first boundary", degrees: floatPtr(11.24), want: "N"},
|
||||
{name: "at first boundary", degrees: floatPtr(11.25), want: "NNE"},
|
||||
{name: "northeast", degrees: floatPtr(45), want: "NE"},
|
||||
{name: "south", degrees: floatPtr(180), want: "S"},
|
||||
{name: "wrap to north", degrees: floatPtr(348.75), want: "N"},
|
||||
{name: "full rotation", degrees: floatPtr(360), want: "N"},
|
||||
{name: "negative normalizes", degrees: floatPtr(-45), want: "NW"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := windDirectionLabel(tt.degrees); got != tt.want {
|
||||
t.Fatalf("windDirectionLabel(%v) = %q, want %q", tt.degrees, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindDirectionTextLabelUsesLowercaseCompassText(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
degrees *float64
|
||||
want string
|
||||
}{
|
||||
{name: "nil", degrees: nil, want: ""},
|
||||
{name: "north", degrees: floatPtr(0), want: "north"},
|
||||
{name: "north northeast", degrees: floatPtr(11.25), want: "north-northeast"},
|
||||
{name: "northwest", degrees: floatPtr(315), want: "northwest"},
|
||||
{name: "negative normalizes", degrees: floatPtr(-45), want: "northwest"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := windDirectionTextLabel(tt.degrees); got != tt.want {
|
||||
t.Fatalf("windDirectionTextLabel(%v) = %q, want %q", tt.degrees, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
424
internal/briefing/modules.go
Normal file
424
internal/briefing/modules.go
Normal file
@@ -0,0 +1,424 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
|
||||
type ModuleContext struct {
|
||||
Resolved report.Resolved
|
||||
Collected facts.CollectedFacts
|
||||
Derived facts.DerivedFacts
|
||||
Units string
|
||||
Timezone string
|
||||
Location *LocationContext
|
||||
}
|
||||
|
||||
type ModuleBuilder func(ModuleContext, any) (*module.Output, error)
|
||||
|
||||
type ModulePromptExporter func(value any) (any, error)
|
||||
|
||||
type ModuleDefinition struct {
|
||||
ID module.ID
|
||||
StanzaName string
|
||||
DefaultOptions any
|
||||
RequiredCollected []module.FactRequirement
|
||||
RequiredDerived []module.FactRequirement
|
||||
SupportedReports []report.ID
|
||||
MissingData module.MissingDataBehavior
|
||||
AllowDuplicate bool
|
||||
Builder ModuleBuilder
|
||||
PromptExporter ModulePromptExporter
|
||||
}
|
||||
|
||||
type ModuleRegistry struct {
|
||||
definitions map[module.ID]ModuleDefinition
|
||||
}
|
||||
|
||||
func unexpectedPromptExportValue(got any, want any) error {
|
||||
return fmt.Errorf("value has type %T, want %T", got, want)
|
||||
}
|
||||
|
||||
func DefaultModuleRegistry() (ModuleRegistry, error) {
|
||||
return NewModuleRegistry(defaultModuleDefinitions())
|
||||
}
|
||||
|
||||
func MustDefaultModuleRegistry() ModuleRegistry {
|
||||
registry, err := DefaultModuleRegistry()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return registry
|
||||
}
|
||||
|
||||
func NewModuleRegistry(definitions []ModuleDefinition) (ModuleRegistry, error) {
|
||||
registry := ModuleRegistry{definitions: map[module.ID]ModuleDefinition{}}
|
||||
seenStanzas := map[string]module.ID{}
|
||||
for i, definition := range definitions {
|
||||
if definition.ID == "" {
|
||||
return ModuleRegistry{}, fmt.Errorf("module definition[%d].id is required", i)
|
||||
}
|
||||
if definition.StanzaName == "" {
|
||||
return ModuleRegistry{}, fmt.Errorf("module %q stanza name is required", definition.ID)
|
||||
}
|
||||
if _, ok := registry.definitions[definition.ID]; ok {
|
||||
return ModuleRegistry{}, fmt.Errorf("duplicate module definition %q", definition.ID)
|
||||
}
|
||||
if definition.Builder == nil {
|
||||
return ModuleRegistry{}, fmt.Errorf("module %q has no builder", definition.ID)
|
||||
}
|
||||
if definition.MissingData == module.MissingDataWarn {
|
||||
return ModuleRegistry{}, fmt.Errorf("module %q uses unsupported missing data behavior %q", definition.ID, definition.MissingData)
|
||||
}
|
||||
if existingID, ok := seenStanzas[definition.StanzaName]; ok {
|
||||
return ModuleRegistry{}, fmt.Errorf("duplicate stanza name %q for modules %q and %q", definition.StanzaName, existingID, definition.ID)
|
||||
}
|
||||
seenStanzas[definition.StanzaName] = definition.ID
|
||||
registry.definitions[definition.ID] = definition
|
||||
}
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
func (r ModuleRegistry) Lookup(id module.ID) (ModuleDefinition, error) {
|
||||
definition, ok := r.definitions[id]
|
||||
if !ok {
|
||||
return ModuleDefinition{}, fmt.Errorf("unknown module %q", id)
|
||||
}
|
||||
return definition, nil
|
||||
}
|
||||
|
||||
func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (*module.Output, error) {
|
||||
definition, err := r.Lookup(item.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !definition.SupportsReport(ctx.Resolved.Definition.ID) {
|
||||
return nil, fmt.Errorf("module %q is not compatible with report %q", item.ID, ctx.Resolved.Definition.ID)
|
||||
}
|
||||
if err := definition.ValidateOptions(item.Options); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if definition.Builder == nil {
|
||||
return nil, fmt.Errorf("module %q has no builder", item.ID)
|
||||
}
|
||||
missing := missingRequirements(definition, ctx)
|
||||
if len(missing) > 0 {
|
||||
switch definition.MissingData {
|
||||
case module.MissingDataOmit:
|
||||
return nil, nil
|
||||
case module.MissingDataError:
|
||||
return nil, fmt.Errorf("module %q missing required facts: %s", item.ID, strings.Join(missing, ", "))
|
||||
case module.MissingDataEmpty:
|
||||
case module.MissingDataWarn:
|
||||
return nil, fmt.Errorf("module %q uses unsupported missing data behavior %q", item.ID, definition.MissingData)
|
||||
default:
|
||||
return nil, fmt.Errorf("module %q has unknown missing data behavior %q", item.ID, definition.MissingData)
|
||||
}
|
||||
}
|
||||
options := item.Options
|
||||
if options == nil {
|
||||
options = definition.DefaultOptions
|
||||
}
|
||||
output, err := definition.Builder(ctx, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if output == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if output.ID != definition.ID {
|
||||
return nil, fmt.Errorf("module %q produced output id %q", definition.ID, output.ID)
|
||||
}
|
||||
if output.StanzaName != definition.StanzaName {
|
||||
return nil, fmt.Errorf("module %q produced stanza %q, want %q", definition.ID, output.StanzaName, definition.StanzaName)
|
||||
}
|
||||
if definition.PromptExporter == nil {
|
||||
output.PromptValue = output.Value
|
||||
return output, nil
|
||||
}
|
||||
promptValue, err := definition.PromptExporter(output.Value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("module %q stanza %q prompt export: %w", definition.ID, definition.StanzaName, err)
|
||||
}
|
||||
output.PromptValue = promptValue
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func missingRequirements(definition ModuleDefinition, ctx ModuleContext) []string {
|
||||
var missing []string
|
||||
for _, requirement := range definition.RequiredCollected {
|
||||
if !collectedFactAvailable(requirement, ctx) {
|
||||
missing = append(missing, string(requirement))
|
||||
}
|
||||
}
|
||||
for _, requirement := range definition.RequiredDerived {
|
||||
if !derivedFactAvailable(requirement, ctx) {
|
||||
missing = append(missing, string(requirement))
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContext) bool {
|
||||
switch requirement {
|
||||
case module.CollectedCurrentConditions:
|
||||
return ctx.Collected.Current != nil
|
||||
case module.CollectedNarrativeForecast:
|
||||
return ctx.Collected.Narrative != nil
|
||||
case module.CollectedHourlyForecast:
|
||||
return ctx.Collected.Hourly != nil
|
||||
case module.CollectedAlerts:
|
||||
return ctx.Collected.Alerts != nil
|
||||
case module.CollectedDiscussion:
|
||||
return ctx.Collected.Discussion != nil
|
||||
case module.CollectedWeatherStory:
|
||||
return ctx.Collected.WeatherStory != nil
|
||||
case module.CollectedSPCConvectiveOutlooks:
|
||||
return ctx.Collected.SPCConvectiveOutlooks != nil
|
||||
case module.CollectedSourceMetadata:
|
||||
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func derivedFactAvailable(requirement module.FactRequirement, ctx ModuleContext) bool {
|
||||
switch requirement {
|
||||
case module.RequiresDerivedHourlyPeriods:
|
||||
return len(ctx.Derived.ValidPeriodHourlyPeriods) > 0
|
||||
case module.RequiresDerivedNarrativePeriods:
|
||||
return len(ctx.Derived.ValidPeriodNarrativePeriods) > 0
|
||||
case module.RequiresDerivedAlertOverlaps:
|
||||
return true
|
||||
case module.RequiresDerivedDailySummaries:
|
||||
return len(ctx.Derived.DailySummaries) > 0
|
||||
case module.RequiresDerivedDaypartSummaries:
|
||||
return len(ctx.Derived.DaypartSummaries) > 0
|
||||
case module.RequiresDerivedPrecipTiming:
|
||||
return true
|
||||
case module.RequiresDerivedSPCConvectiveOutlooks:
|
||||
return ctx.Derived.SPCConvectiveOutlooks != nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (r ModuleRegistry) ValidateComposition(reportID report.ID, items []module.ConfigItem) error {
|
||||
seenModules := map[module.ID]struct{}{}
|
||||
seenStanzas := map[string]module.ID{}
|
||||
for i, item := range items {
|
||||
definition, err := r.Lookup(item.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("modules[%d]: %w", i, err)
|
||||
}
|
||||
if _, ok := seenModules[item.ID]; ok && !definition.AllowDuplicate {
|
||||
return fmt.Errorf("modules[%d]: duplicate module %q", i, item.ID)
|
||||
}
|
||||
seenModules[item.ID] = struct{}{}
|
||||
if existingID, ok := seenStanzas[definition.StanzaName]; ok {
|
||||
return fmt.Errorf("modules[%d]: duplicate stanza name %q for modules %q and %q", i, definition.StanzaName, existingID, item.ID)
|
||||
}
|
||||
seenStanzas[definition.StanzaName] = item.ID
|
||||
if !definition.SupportsReport(reportID) {
|
||||
return fmt.Errorf("modules[%d]: module %q is not compatible with report %q", i, item.ID, reportID)
|
||||
}
|
||||
if err := definition.ValidateOptions(item.Options); err != nil {
|
||||
return fmt.Errorf("modules[%d]: %w", i, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d ModuleDefinition) SupportsReport(id report.ID) bool {
|
||||
if len(d.SupportedReports) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, supported := range d.SupportedReports {
|
||||
if supported == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (d ModuleDefinition) ValidateOptions(options any) error {
|
||||
if options == nil {
|
||||
return nil
|
||||
}
|
||||
if d.DefaultOptions == nil {
|
||||
return fmt.Errorf("module %q does not accept options", d.ID)
|
||||
}
|
||||
want := reflect.TypeOf(d.DefaultOptions)
|
||||
got := reflect.TypeOf(options)
|
||||
if got == want {
|
||||
return nil
|
||||
}
|
||||
if got.Kind() == reflect.Pointer && got.Elem() == want {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("module %q options have type %s, want %s", d.ID, got, want)
|
||||
}
|
||||
|
||||
func defaultModuleDefinitions() []ModuleDefinition {
|
||||
allReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly}
|
||||
daypartReports := []report.ID{report.Daily, report.Today, report.Tomorrow}
|
||||
return []ModuleDefinition{
|
||||
{
|
||||
ID: module.Metadata,
|
||||
StanzaName: "metadata",
|
||||
DefaultOptions: module.MetadataOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedSourceMetadata},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildMetadataModule,
|
||||
},
|
||||
{
|
||||
ID: module.CurrentConditions,
|
||||
StanzaName: "current_conditions",
|
||||
DefaultOptions: module.CurrentConditionsOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedCurrentConditions},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildCurrentConditionsModule,
|
||||
PromptExporter: exportCurrentConditionsPromptValue,
|
||||
},
|
||||
{
|
||||
ID: module.NarrativeForecast,
|
||||
StanzaName: "narrative_forecast",
|
||||
DefaultOptions: module.NarrativeForecastOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods},
|
||||
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildNarrativeForecastModule,
|
||||
},
|
||||
{
|
||||
ID: module.HourlyForecast,
|
||||
StanzaName: "hourly_forecast",
|
||||
DefaultOptions: module.HourlyForecastOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
|
||||
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly},
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildHourlyForecastModule,
|
||||
PromptExporter: exportHourlyForecastPromptValue,
|
||||
},
|
||||
{
|
||||
ID: module.DerivedDailySummary,
|
||||
StanzaName: "derived_daily_summary",
|
||||
DefaultOptions: module.DerivedDailySummaryOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming},
|
||||
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
|
||||
MissingData: module.MissingDataError,
|
||||
Builder: buildDerivedDailySummaryModule,
|
||||
},
|
||||
{
|
||||
ID: module.DerivedDaypartSummaries,
|
||||
StanzaName: "derived_daypart_summaries",
|
||||
DefaultOptions: module.DerivedDaypartSummariesOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
|
||||
SupportedReports: daypartReports,
|
||||
MissingData: module.MissingDataError,
|
||||
Builder: buildDerivedDaypartSummariesModule,
|
||||
PromptExporter: exportDerivedDaypartSummariesPromptValue,
|
||||
},
|
||||
{
|
||||
ID: module.PrecipTiming,
|
||||
StanzaName: "precip_timing",
|
||||
DefaultOptions: module.PrecipTimingOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedPrecipTiming},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildPrecipTimingModule,
|
||||
},
|
||||
{
|
||||
ID: module.AlertDigest,
|
||||
StanzaName: "alert_digest",
|
||||
DefaultOptions: module.AlertDigestOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedAlerts},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedAlertOverlaps},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildAlertDigestModule,
|
||||
},
|
||||
{
|
||||
ID: module.SPCConvectiveOutlooks,
|
||||
StanzaName: string(module.SPCConvectiveOutlooks),
|
||||
DefaultOptions: module.SPCConvectiveOutlooksOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildSPCConvectiveOutlooksModule,
|
||||
},
|
||||
{
|
||||
ID: module.AreaForecastDiscussion,
|
||||
StanzaName: "area_forecast_discussion",
|
||||
DefaultOptions: module.AreaForecastDiscussionOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedDiscussion},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildAreaForecastDiscussionModule,
|
||||
},
|
||||
{
|
||||
ID: module.SPCConvectiveDiscussion,
|
||||
StanzaName: string(module.SPCConvectiveDiscussion),
|
||||
DefaultOptions: module.SPCConvectiveDiscussionOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildSPCConvectiveDiscussionModule,
|
||||
},
|
||||
{
|
||||
ID: module.WeatherStory,
|
||||
StanzaName: "weather_story",
|
||||
DefaultOptions: module.WeatherStoryOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedWeatherStory},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildWeatherStoryModule,
|
||||
},
|
||||
{
|
||||
ID: module.OutdoorWindows,
|
||||
StanzaName: "outdoor_windows",
|
||||
DefaultOptions: module.OutdoorWindowsOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
|
||||
SupportedReports: daypartReports,
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildOutdoorWindowsModule,
|
||||
},
|
||||
{
|
||||
ID: module.TodayPlanning,
|
||||
StanzaName: "today_planning",
|
||||
DefaultOptions: module.TodayPlanningOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
||||
SupportedReports: []report.ID{report.Today},
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildTodayPlanningModule,
|
||||
},
|
||||
{
|
||||
ID: module.TomorrowPlanning,
|
||||
StanzaName: "tomorrow_planning",
|
||||
DefaultOptions: module.TomorrowPlanningOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
||||
SupportedReports: []report.ID{report.Tomorrow},
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildTomorrowPlanningModule,
|
||||
},
|
||||
{
|
||||
ID: module.DailyPlanning,
|
||||
StanzaName: "daily_planning",
|
||||
DefaultOptions: module.DailyPlanningOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
||||
SupportedReports: []report.ID{report.Daily},
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildDailyPlanningModule,
|
||||
},
|
||||
}
|
||||
}
|
||||
505
internal/briefing/modules_test.go
Normal file
505
internal/briefing/modules_test.go
Normal file
@@ -0,0 +1,505 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
for _, definition := range report.DefaultRegistry().All() {
|
||||
if err := registry.ValidateComposition(definition.ID, definition.Modules); err != nil {
|
||||
t.Fatalf("ValidateComposition(%s) error = %v", definition.ID, err)
|
||||
}
|
||||
for _, item := range definition.Modules {
|
||||
moduleDefinition, err := registry.Lookup(item.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup(%s) error = %v", item.ID, err)
|
||||
}
|
||||
if moduleDefinition.Builder == nil {
|
||||
t.Fatalf("report %s module %s has no builder", definition.ID, item.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
for _, definition := range report.DefaultRegistry().All() {
|
||||
t.Run(string(definition.ID), func(t *testing.T) {
|
||||
ctx := derivedModuleContext(definition.ID)
|
||||
var outputs []module.Output
|
||||
for _, item := range definition.Modules {
|
||||
output, err := registry.BuildModule(ctx, item)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(%s) error = %v", item.ID, err)
|
||||
}
|
||||
if output != nil {
|
||||
if output.DataPackageValue() == nil {
|
||||
t.Fatalf("BuildModule(%s) data package value = nil", item.ID)
|
||||
}
|
||||
outputs = append(outputs, *output)
|
||||
}
|
||||
}
|
||||
snapshot, err := module.NewSnapshot(outputs)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSnapshot() error = %v", err)
|
||||
}
|
||||
if len(snapshot.Outputs) == 0 {
|
||||
t.Fatal("snapshot outputs = 0, want default report modules")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultModuleDefinitionsDeclarePromptExportPolicy(t *testing.T) {
|
||||
customExporters := map[module.ID]struct{}{
|
||||
module.CurrentConditions: {},
|
||||
module.HourlyForecast: {},
|
||||
module.DerivedDaypartSummaries: {},
|
||||
}
|
||||
passThroughExporters := map[module.ID]struct{}{
|
||||
module.Metadata: {},
|
||||
module.NarrativeForecast: {},
|
||||
module.DerivedDailySummary: {},
|
||||
module.PrecipTiming: {},
|
||||
module.AlertDigest: {},
|
||||
module.SPCConvectiveOutlooks: {},
|
||||
module.AreaForecastDiscussion: {},
|
||||
module.SPCConvectiveDiscussion: {},
|
||||
module.WeatherStory: {},
|
||||
module.OutdoorWindows: {},
|
||||
module.TodayPlanning: {},
|
||||
module.TomorrowPlanning: {},
|
||||
module.DailyPlanning: {},
|
||||
}
|
||||
|
||||
for _, definition := range defaultModuleDefinitions() {
|
||||
_, custom := customExporters[definition.ID]
|
||||
_, passThrough := passThroughExporters[definition.ID]
|
||||
if custom == passThrough {
|
||||
t.Fatalf("module %q exporter policy custom=%v passThrough=%v, want exactly one policy", definition.ID, custom, passThrough)
|
||||
}
|
||||
if custom && definition.PromptExporter == nil {
|
||||
t.Fatalf("module %q PromptExporter = nil, want custom prompt exporter", definition.ID)
|
||||
}
|
||||
if passThrough && definition.PromptExporter != nil {
|
||||
t.Fatalf("module %q PromptExporter is set, want default pass-through", definition.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryAddsPassThroughPromptValue(t *testing.T) {
|
||||
registry, err := NewModuleRegistry([]ModuleDefinition{
|
||||
{
|
||||
ID: module.Metadata,
|
||||
StanzaName: "metadata",
|
||||
Builder: func(ModuleContext, any) (*module.Output, error) {
|
||||
return &module.Output{
|
||||
ID: module.Metadata,
|
||||
StanzaName: "metadata",
|
||||
Value: testRegistryValue{Message: "rich"},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewModuleRegistry() error = %v", err)
|
||||
}
|
||||
|
||||
output, err := registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
if output == nil {
|
||||
t.Fatal("BuildModule() output = nil, want output")
|
||||
}
|
||||
if output.PromptValue != output.Value {
|
||||
t.Fatalf("PromptValue = %#v, want pass-through rich value %#v", output.PromptValue, output.Value)
|
||||
}
|
||||
if output.DataPackageValue() != output.Value {
|
||||
t.Fatalf("DataPackageValue() = %#v, want rich value", output.DataPackageValue())
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryAddsCustomPromptValue(t *testing.T) {
|
||||
registry, err := NewModuleRegistry([]ModuleDefinition{
|
||||
{
|
||||
ID: module.Metadata,
|
||||
StanzaName: "metadata",
|
||||
Builder: func(ModuleContext, any) (*module.Output, error) {
|
||||
return &module.Output{
|
||||
ID: module.Metadata,
|
||||
StanzaName: "metadata",
|
||||
Value: testRegistryValue{Message: "rich"},
|
||||
}, nil
|
||||
},
|
||||
PromptExporter: func(value any) (any, error) {
|
||||
rich, ok := value.(testRegistryValue)
|
||||
if !ok {
|
||||
return nil, errors.New("unexpected rich value type")
|
||||
}
|
||||
return testRegistryValue{Message: rich.Message + " prompt"}, nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewModuleRegistry() error = %v", err)
|
||||
}
|
||||
|
||||
output, err := registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
got, ok := output.PromptValue.(testRegistryValue)
|
||||
if !ok {
|
||||
t.Fatalf("PromptValue type = %T, want testRegistryValue", output.PromptValue)
|
||||
}
|
||||
if got.Message != "rich prompt" {
|
||||
t.Fatalf("PromptValue = %#v, want custom prompt value", got)
|
||||
}
|
||||
if output.DataPackageValue() != output.PromptValue {
|
||||
t.Fatalf("DataPackageValue() = %#v, want custom prompt value", output.DataPackageValue())
|
||||
}
|
||||
if output.Value.(testRegistryValue).Message != "rich" {
|
||||
t.Fatalf("Value = %#v, want rich value unchanged", output.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryWrapsPromptExporterErrors(t *testing.T) {
|
||||
registry, err := NewModuleRegistry([]ModuleDefinition{
|
||||
{
|
||||
ID: module.Metadata,
|
||||
StanzaName: "metadata",
|
||||
Builder: func(ModuleContext, any) (*module.Output, error) {
|
||||
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: testRegistryValue{Message: "rich"}}, nil
|
||||
},
|
||||
PromptExporter: func(any) (any, error) {
|
||||
return nil, errors.New("unsupported value")
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewModuleRegistry() error = %v", err)
|
||||
}
|
||||
|
||||
_, err = registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
||||
if err == nil ||
|
||||
!strings.Contains(err.Error(), `module "metadata" stanza "metadata" prompt export`) ||
|
||||
!strings.Contains(err.Error(), "unsupported value") {
|
||||
t.Fatalf("BuildModule() error = %v, want wrapped exporter error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryValidatesOutputBeforePromptExport(t *testing.T) {
|
||||
called := false
|
||||
registry, err := NewModuleRegistry([]ModuleDefinition{
|
||||
{
|
||||
ID: module.Metadata,
|
||||
StanzaName: "metadata",
|
||||
Builder: func(ModuleContext, any) (*module.Output, error) {
|
||||
return &module.Output{ID: module.CurrentConditions, StanzaName: "metadata", Value: testRegistryValue{Message: "rich"}}, nil
|
||||
},
|
||||
PromptExporter: func(any) (any, error) {
|
||||
called = true
|
||||
return testRegistryValue{Message: "prompt"}, nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewModuleRegistry() error = %v", err)
|
||||
}
|
||||
|
||||
_, err = registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
||||
if err == nil || !strings.Contains(err.Error(), `module "metadata" produced output id "current_conditions"`) {
|
||||
t.Fatalf("BuildModule() error = %v, want output id validation error", err)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("PromptExporter called before output validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryPromptValueIsNotPersistedInSnapshotJSON(t *testing.T) {
|
||||
registry, err := NewModuleRegistry([]ModuleDefinition{
|
||||
{
|
||||
ID: module.Metadata,
|
||||
StanzaName: "metadata",
|
||||
Builder: func(ModuleContext, any) (*module.Output, error) {
|
||||
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: testRegistryValue{Message: "rich"}}, nil
|
||||
},
|
||||
PromptExporter: func(any) (any, error) {
|
||||
return testRegistryValue{Message: "prompt-only"}, nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewModuleRegistry() error = %v", err)
|
||||
}
|
||||
output, err := registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
snapshot, err := module.NewSnapshot([]module.Output{*output})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSnapshot() error = %v", err)
|
||||
}
|
||||
data, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
text := string(data)
|
||||
if !strings.Contains(text, `"message":"rich"`) {
|
||||
t.Fatalf("snapshot JSON missing rich value: %s", text)
|
||||
}
|
||||
if strings.Contains(text, "prompt-only") || strings.Contains(text, "promptValue") || strings.Contains(text, "PromptValue") {
|
||||
t.Fatalf("snapshot JSON includes runtime-only prompt value: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultAreaForecastDiscussionModuleOptions(t *testing.T) {
|
||||
tests := []struct {
|
||||
id report.ID
|
||||
wantSections string
|
||||
}{
|
||||
{id: report.Daily, wantSections: "long_term"},
|
||||
{id: report.Hourly, wantSections: "key_messages,short_term"},
|
||||
}
|
||||
|
||||
registry := report.DefaultRegistry()
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.id), func(t *testing.T) {
|
||||
definition := registry.MustLookup(tt.id)
|
||||
var found bool
|
||||
for _, item := range definition.Modules {
|
||||
if item.ID != module.AreaForecastDiscussion {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
options, ok := item.Options.(module.AreaForecastDiscussionOptions)
|
||||
if !ok {
|
||||
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", item.Options)
|
||||
}
|
||||
if strings.Join(options.Sections, ",") != tt.wantSections {
|
||||
t.Fatalf("AFD sections = %#v, want %s", options.Sections, tt.wantSections)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("default modules missing area_forecast_discussion")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, id := range []report.ID{report.Today, report.Tomorrow} {
|
||||
t.Run(string(id), func(t *testing.T) {
|
||||
definition := registry.MustLookup(id)
|
||||
var found bool
|
||||
for _, item := range definition.Modules {
|
||||
if item.ID != module.AreaForecastDiscussion {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if item.Options != nil {
|
||||
t.Fatalf("AFD options = %#v, want default all sections", item.Options)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("default modules missing area_forecast_discussion")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.ID("unknown")}})
|
||||
if err == nil || !strings.Contains(err.Error(), `unknown module "unknown"`) {
|
||||
t.Fatalf("error = %v, want unknown module", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryRejectsDuplicateModuleIDs(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
|
||||
{ID: module.Metadata},
|
||||
{ID: module.Metadata},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `duplicate module "metadata"`) {
|
||||
t.Fatalf("error = %v, want duplicate module", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryRejectsDuplicateStanzaNames(t *testing.T) {
|
||||
_, err := NewModuleRegistry([]ModuleDefinition{
|
||||
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}, Builder: noopModuleBuilder},
|
||||
{ID: module.CurrentConditions, StanzaName: "metadata", DefaultOptions: module.CurrentConditionsOptions{}, Builder: noopModuleBuilder},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `duplicate stanza name "metadata"`) {
|
||||
t.Fatalf("error = %v, want duplicate stanza name", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.TomorrowPlanning}})
|
||||
if err == nil || !strings.Contains(err.Error(), `module "tomorrow_planning" is not compatible with report "daily"`) {
|
||||
t.Fatalf("error = %v, want incompatible report", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryValidatesTodayPlanningSupport(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
if err := registry.ValidateComposition(report.Today, []module.ConfigItem{{ID: module.TodayPlanning}}); err != nil {
|
||||
t.Fatalf("ValidateComposition(today) error = %v", err)
|
||||
}
|
||||
for _, id := range []report.ID{report.Tomorrow, report.Daily} {
|
||||
t.Run(string(id), func(t *testing.T) {
|
||||
err := registry.ValidateComposition(id, []module.ConfigItem{{ID: module.TodayPlanning}})
|
||||
if err == nil || !strings.Contains(err.Error(), `module "today_planning" is not compatible with report`) {
|
||||
t.Fatalf("ValidateComposition(%s) error = %v, want incompatible report", id, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryValidatesDailyPlanningSupport(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
if err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.DailyPlanning}}); err != nil {
|
||||
t.Fatalf("ValidateComposition(daily) error = %v", err)
|
||||
}
|
||||
for _, id := range []report.ID{report.Today, report.Tomorrow, report.Hourly} {
|
||||
t.Run(string(id), func(t *testing.T) {
|
||||
err := registry.ValidateComposition(id, []module.ConfigItem{{ID: module.DailyPlanning}})
|
||||
if err == nil || !strings.Contains(err.Error(), `module "daily_planning" is not compatible with report`) {
|
||||
t.Fatalf("ValidateComposition(%s) error = %v, want incompatible report", id, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistrySupportsTodayEligibleModules(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
err := registry.ValidateComposition(report.Today, []module.ConfigItem{
|
||||
{ID: module.Metadata},
|
||||
{ID: module.CurrentConditions},
|
||||
{ID: module.NarrativeForecast},
|
||||
{ID: module.HourlyForecast},
|
||||
{ID: module.DerivedDailySummary},
|
||||
{ID: module.DerivedDaypartSummaries},
|
||||
{ID: module.PrecipTiming},
|
||||
{ID: module.AlertDigest},
|
||||
{ID: module.SPCConvectiveOutlooks},
|
||||
{ID: module.AreaForecastDiscussion},
|
||||
{ID: module.SPCConvectiveDiscussion},
|
||||
{ID: module.WeatherStory},
|
||||
{ID: module.OutdoorWindows},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateComposition(today eligible modules) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryRejectsHourlyIncompatibleModules(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
for _, id := range []module.ID{
|
||||
module.NarrativeForecast,
|
||||
module.DerivedDailySummary,
|
||||
module.DerivedDaypartSummaries,
|
||||
module.OutdoorWindows,
|
||||
module.TodayPlanning,
|
||||
module.TomorrowPlanning,
|
||||
module.DailyPlanning,
|
||||
} {
|
||||
t.Run(string(id), func(t *testing.T) {
|
||||
err := registry.ValidateComposition(report.Hourly, []module.ConfigItem{{ID: id}})
|
||||
if err == nil || !strings.Contains(err.Error(), `not compatible with report "hourly"`) {
|
||||
t.Fatalf("ValidateComposition() error = %v, want incompatible hourly module", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryRejectsDefinitionsWithoutBuilders(t *testing.T) {
|
||||
_, err := NewModuleRegistry([]ModuleDefinition{
|
||||
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `module "metadata" has no builder`) {
|
||||
t.Fatalf("error = %v, want missing builder", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryRejectsUnsupportedMissingDataWarn(t *testing.T) {
|
||||
_, err := NewModuleRegistry([]ModuleDefinition{
|
||||
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}, MissingData: module.MissingDataWarn, Builder: noopModuleBuilder},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `unsupported missing data behavior`) {
|
||||
t.Fatalf("error = %v, want unsupported missing-data behavior", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
|
||||
{ID: module.Metadata, Options: module.CurrentConditionsOptions{}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `module "metadata" options have type module.CurrentConditionsOptions, want module.MetadataOptions`) {
|
||||
t.Fatalf("error = %v, want invalid option shape", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
|
||||
{ID: module.Metadata, Options: module.MetadataOptions{}},
|
||||
{ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateComposition() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveOutlookCollectedRequirementAvailability(t *testing.T) {
|
||||
ctx := ModuleContext{}
|
||||
if collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
|
||||
t.Fatal("collectedFactAvailable() = true, want false without source")
|
||||
}
|
||||
|
||||
ctx.Collected = facts.CollectedFacts{SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{}}
|
||||
if !collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
|
||||
t.Fatal("collectedFactAvailable() = false, want true with checked source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveOutlookDerivedRequirementAvailability(t *testing.T) {
|
||||
ctx := ModuleContext{}
|
||||
if derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
|
||||
t.Fatal("derivedFactAvailable() = true, want false without derived outlooks")
|
||||
}
|
||||
|
||||
ctx.Derived = facts.DerivedFacts{SPCConvectiveOutlooks: []weatherdata.ConvectiveOutlook{}}
|
||||
if !derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
|
||||
t.Fatal("derivedFactAvailable() = false, want true for checked empty derived outlooks")
|
||||
}
|
||||
}
|
||||
|
||||
func noopModuleBuilder(ModuleContext, any) (*module.Output, error) {
|
||||
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil
|
||||
}
|
||||
|
||||
type testRegistryValue struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func testRegistryModuleContext() ModuleContext {
|
||||
return ModuleContext{
|
||||
Resolved: report.Resolved{
|
||||
Definition: report.DefaultRegistry().MustLookup(report.Daily),
|
||||
},
|
||||
}
|
||||
}
|
||||
93
internal/briefing/narrative_forecast_module.go
Normal file
93
internal/briefing/narrative_forecast_module.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type NarrativeForecastModule struct {
|
||||
Product string `json:"product,omitempty"`
|
||||
IssuedAt time.Time `json:"issued_at,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
SourceLocation string `json:"source_location,omitempty"`
|
||||
SourceLocationID string `json:"source_location_id,omitempty"`
|
||||
Periods []NarrativeForecastPeriod `json:"periods,omitempty"`
|
||||
}
|
||||
|
||||
type NarrativeForecastPeriod struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
IsDay *bool `json:"is_day,omitempty"`
|
||||
TextDescription string `json:"text_description,omitempty"`
|
||||
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||
TemperatureF *float64 `json:"temperature_f,omitempty"`
|
||||
TemperatureCMin *float64 `json:"temperature_c_min,omitempty"`
|
||||
TemperatureFMin *float64 `json:"temperature_f_min,omitempty"`
|
||||
TemperatureCMax *float64 `json:"temperature_c_max,omitempty"`
|
||||
TemperatureFMax *float64 `json:"temperature_f_max,omitempty"`
|
||||
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
|
||||
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
|
||||
WindGustKmh *float64 `json:"wind_gust_kmh,omitempty"`
|
||||
WindGustMph *float64 `json:"wind_gust_mph,omitempty"`
|
||||
WindDirection string `json:"wind_direction,omitempty"`
|
||||
ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"`
|
||||
}
|
||||
|
||||
func buildNarrativeForecastModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
narrative := ctx.Collected.Narrative
|
||||
if narrative == nil || len(ctx.Derived.ValidPeriodNarrativePeriods) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
value := NarrativeForecastModule{
|
||||
Product: narrative.Product,
|
||||
IssuedAt: narrative.IssuedAt,
|
||||
UpdatedAt: copyTime(narrative.UpdatedAt),
|
||||
SourceLocation: narrative.LocationName,
|
||||
SourceLocationID: narrative.LocationID,
|
||||
Periods: narrativeForecastPeriods(ctx.Derived.ValidPeriodNarrativePeriods, ctx.Timezone),
|
||||
}
|
||||
if value.isEmpty() {
|
||||
return nil, nil
|
||||
}
|
||||
return &module.Output{ID: module.NarrativeForecast, StanzaName: "narrative_forecast", Value: value}, nil
|
||||
}
|
||||
|
||||
func narrativeForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []NarrativeForecastPeriod {
|
||||
out := make([]NarrativeForecastPeriod, 0, len(periods))
|
||||
for _, period := range periods {
|
||||
validPeriod := timeutil.Period{Start: period.StartTime, End: period.EndTime}
|
||||
out = append(out, NarrativeForecastPeriod{
|
||||
Name: period.Name,
|
||||
PeriodBegins: friendlyPeriodBeginsLabel(validPeriod, timezone),
|
||||
PeriodEnds: friendlyPeriodEndsLabel(validPeriod, timezone),
|
||||
IsDay: copyBool(period.IsDay),
|
||||
TextDescription: period.TextDescription,
|
||||
TemperatureC: copyFloat(period.TemperatureC),
|
||||
TemperatureF: copyFloat(period.TemperatureF),
|
||||
TemperatureCMin: copyFloat(period.TemperatureCMin),
|
||||
TemperatureFMin: copyFloat(period.TemperatureFMin),
|
||||
TemperatureCMax: copyFloat(period.TemperatureCMax),
|
||||
TemperatureFMax: copyFloat(period.TemperatureFMax),
|
||||
WindSpeedKmh: copyFloat(period.WindSpeedKmh),
|
||||
WindSpeedMph: copyFloat(period.WindSpeedMph),
|
||||
WindGustKmh: copyFloat(period.WindGustKmh),
|
||||
WindGustMph: copyFloat(period.WindGustMph),
|
||||
WindDirection: windDirectionLabel(period.WindDirectionDegrees),
|
||||
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (v NarrativeForecastModule) isEmpty() bool {
|
||||
return v.Product == "" &&
|
||||
v.IssuedAt.IsZero() &&
|
||||
v.UpdatedAt == nil &&
|
||||
v.SourceLocation == "" &&
|
||||
v.SourceLocationID == "" &&
|
||||
len(v.Periods) == 0
|
||||
}
|
||||
38
internal/briefing/outdoor_windows_module.go
Normal file
38
internal/briefing/outdoor_windows_module.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package briefing
|
||||
|
||||
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
|
||||
type OutdoorWindowsModule struct {
|
||||
Best *OutdoorWindowModule `json:"best,omitempty"`
|
||||
Worst *OutdoorWindowModule `json:"worst,omitempty"`
|
||||
}
|
||||
|
||||
type OutdoorWindowModule struct {
|
||||
Daypart string `json:"daypart"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
Reasons []string `json:"reasons,omitempty"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries)
|
||||
value := OutdoorWindowsModule{
|
||||
Best: outdoorWindowValue(windows.Best, ctx.Timezone),
|
||||
Worst: outdoorWindowValue(windows.Worst, ctx.Timezone),
|
||||
}
|
||||
return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil
|
||||
}
|
||||
|
||||
func outdoorWindowValue(window *OutdoorWindow, timezone string) *OutdoorWindowModule {
|
||||
if window == nil {
|
||||
return nil
|
||||
}
|
||||
return &OutdoorWindowModule{
|
||||
Daypart: window.Daypart,
|
||||
PeriodBegins: friendlyPeriodBeginsLabel(window.Period, timezone),
|
||||
PeriodEnds: friendlyPeriodEndsLabel(window.Period, timezone),
|
||||
Reasons: append([]string(nil), window.Reasons...),
|
||||
Score: window.Score,
|
||||
}
|
||||
}
|
||||
@@ -1,67 +1,68 @@
|
||||
// Package briefing builds report-specific structured briefing packages.
|
||||
// Package briefing builds prompt-facing module values.
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
const SchemaVersion = "weatherreporter.briefing.v1"
|
||||
|
||||
type Package struct {
|
||||
Metadata Metadata `json:"metadata"`
|
||||
Daily *Daily `json:"daily,omitempty"`
|
||||
ThreeDay *ThreeDay `json:"threeDay,omitempty"`
|
||||
Weekend *Weekend `json:"weekend,omitempty"`
|
||||
Storm *Storm `json:"storm,omitempty"`
|
||||
type Metadata struct {
|
||||
RunID string `json:"runId"`
|
||||
ReportID report.ID `json:"reportId"`
|
||||
Variant string `json:"variant,omitempty"`
|
||||
PromptID string `json:"promptId"`
|
||||
GeneratedAt time.Time `json:"generatedAt"`
|
||||
Units string `json:"units"`
|
||||
Timezone string `json:"timezone"`
|
||||
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||
Location *LocationContext `json:"location,omitempty"`
|
||||
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
||||
SourceLocation string `json:"sourceLocation,omitempty"`
|
||||
Sources []SourceMetadata `json:"sources,omitempty"`
|
||||
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
|
||||
Alerts *AlertStatus `json:"alerts,omitempty"`
|
||||
}
|
||||
|
||||
type Metadata struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
RunID string `json:"runId"`
|
||||
ReportID report.ID `json:"reportId"`
|
||||
Variant string `json:"variant,omitempty"`
|
||||
PromptID string `json:"promptId"`
|
||||
GeneratedAt time.Time `json:"generatedAt"`
|
||||
Units string `json:"units"`
|
||||
Timezone string `json:"timezone"`
|
||||
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
||||
SourceLocation string `json:"sourceLocation,omitempty"`
|
||||
Sources []SourceMetadata `json:"sources,omitempty"`
|
||||
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"`
|
||||
type LocationContext struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
}
|
||||
|
||||
type SourceMetadata struct {
|
||||
Name string `json:"name"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
FetchedAt time.Time `json:"fetchedAt"`
|
||||
IssuedAt *time.Time `json:"issuedAt,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
DataSHA256 string `json:"dataSha256,omitempty"`
|
||||
Missing bool `json:"missing,omitempty"`
|
||||
Warnings []forecast.SourceWarning `json:"warnings,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
FetchedAt time.Time `json:"fetchedAt"`
|
||||
IssuedAt *time.Time `json:"issuedAt,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
DataSHA256 string `json:"dataSha256,omitempty"`
|
||||
Missing bool `json:"missing,omitempty"`
|
||||
Warnings []weatherdata.SourceWarning `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type AlertStatus struct {
|
||||
Checked bool `json:"checked"`
|
||||
ActiveCount int `json:"activeCount"`
|
||||
RelevantCount int `json:"relevantCount"`
|
||||
Missing bool `json:"missing,omitempty"`
|
||||
}
|
||||
|
||||
type BuildContext struct {
|
||||
Resolved report.Resolved
|
||||
Bundle *forecast.Bundle
|
||||
Bundle *weatherdata.Bundle
|
||||
Units string
|
||||
Timezone string
|
||||
Location *LocationContext
|
||||
}
|
||||
|
||||
func BuildMetadata(ctx BuildContext) Metadata {
|
||||
metadata := ctx.Resolved.Metadata()
|
||||
sourceLocationID, sourceLocation := sourceLocation(ctx.Bundle)
|
||||
return Metadata{
|
||||
SchemaVersion: SchemaVersion,
|
||||
RunID: metadata.RunID,
|
||||
ReportID: metadata.ReportID,
|
||||
Variant: variantForReport(metadata.ReportID),
|
||||
@@ -70,46 +71,60 @@ func BuildMetadata(ctx BuildContext) Metadata {
|
||||
Units: ctx.Units,
|
||||
Timezone: ctx.Timezone,
|
||||
ValidPeriod: metadata.ValidPeriod,
|
||||
Location: copyLocation(ctx.Location),
|
||||
SourceLocationID: sourceLocationID,
|
||||
SourceLocation: sourceLocation,
|
||||
Sources: sourceMetadata(ctx.Bundle),
|
||||
SourceWarnings: sourceWarnings(ctx.Bundle),
|
||||
Alerts: alertStatus(ctx.Bundle),
|
||||
}
|
||||
}
|
||||
|
||||
func Save(path string, pkg Package) error {
|
||||
data, err := json.MarshalIndent(pkg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal briefing package: %w", err)
|
||||
func copyLocation(location *LocationContext) *LocationContext {
|
||||
if location == nil {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create briefing directory %q: %w", filepath.Dir(path), err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary briefing file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("write temporary briefing file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temporary briefing file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("save briefing %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
copied := *location
|
||||
return &copied
|
||||
}
|
||||
|
||||
func sourceLocation(bundle *forecast.Bundle) (string, string) {
|
||||
func copyBool(value *bool) *bool {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copied := *value
|
||||
return &copied
|
||||
}
|
||||
|
||||
func copyInt(value *int) *int {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copied := *value
|
||||
return &copied
|
||||
}
|
||||
|
||||
func copyFloat(value *float64) *float64 {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copied := *value
|
||||
return &copied
|
||||
}
|
||||
|
||||
func copyTime(value *time.Time) *time.Time {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copied := *value
|
||||
return &copied
|
||||
}
|
||||
|
||||
func sourceLocation(bundle *weatherdata.Bundle) (string, string) {
|
||||
if bundle == nil {
|
||||
return "", ""
|
||||
}
|
||||
for _, run := range []*forecast.ForecastRun{bundle.Hourly, bundle.Narrative, bundle.Daily} {
|
||||
for _, run := range []*weatherdata.ForecastRun{bundle.Hourly, bundle.Narrative, bundle.Daily} {
|
||||
if run == nil {
|
||||
continue
|
||||
}
|
||||
@@ -120,7 +135,7 @@ func sourceLocation(bundle *forecast.Bundle) (string, string) {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func sourceMetadata(bundle *forecast.Bundle) []SourceMetadata {
|
||||
func sourceMetadata(bundle *weatherdata.Bundle) []SourceMetadata {
|
||||
if bundle == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -140,18 +155,39 @@ func sourceMetadata(bundle *forecast.Bundle) []SourceMetadata {
|
||||
return out
|
||||
}
|
||||
|
||||
func sourceWarnings(bundle *forecast.Bundle) []forecast.SourceWarning {
|
||||
func sourceWarnings(bundle *weatherdata.Bundle) []weatherdata.SourceWarning {
|
||||
if bundle == nil {
|
||||
return nil
|
||||
}
|
||||
return bundle.Warnings
|
||||
}
|
||||
|
||||
func alertStatus(bundle *weatherdata.Bundle) *AlertStatus {
|
||||
if bundle == nil {
|
||||
return nil
|
||||
}
|
||||
status := &AlertStatus{}
|
||||
if bundle.Alerts != nil {
|
||||
status.Checked = true
|
||||
status.ActiveCount = len(bundle.Alerts.Alerts)
|
||||
}
|
||||
for _, source := range bundle.Sources {
|
||||
if source.Name == "alerts" && source.Missing {
|
||||
status.Missing = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !status.Checked && !status.Missing {
|
||||
return nil
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func variantForReport(id report.ID) string {
|
||||
switch id {
|
||||
case report.DailyToday:
|
||||
case report.Daily, report.Today:
|
||||
return "today"
|
||||
case report.DailyTomorrow:
|
||||
case report.Tomorrow:
|
||||
return "tomorrow"
|
||||
default:
|
||||
return ""
|
||||
|
||||
109
internal/briefing/precip_timing_module.go
Normal file
109
internal/briefing/precip_timing_module.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
)
|
||||
|
||||
const (
|
||||
precipTimingChanceLowerBound = 40
|
||||
precipTimingLikelyLowerBound = 50
|
||||
precipTimingExpectLowerBound = 70
|
||||
)
|
||||
|
||||
type PrecipTimingModule struct {
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||
ProbabilityThreshold float64 `json:"probability_threshold"`
|
||||
PrecipitationWindows []PrecipitationWindowModule `json:"precipitation_windows,omitempty"`
|
||||
ThunderMentioned bool `json:"thunder_mentioned"`
|
||||
}
|
||||
|
||||
type PrecipitationWindowModule struct {
|
||||
PeriodBegins string `json:"period_begins"`
|
||||
PeriodBeginsHourLabel string `json:"period_begins_hour_label,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
PeriodEndsHourLabel string `json:"period_ends_hour_label,omitempty"`
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||
MaxPopHourLabel string `json:"max_pop_hour_label,omitempty"`
|
||||
PrecipitationType string `json:"precipitation_type,omitempty"`
|
||||
ExpectationPhrase string `json:"expectation_phrase,omitempty"`
|
||||
}
|
||||
|
||||
func buildPrecipTimingModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
value := precipTimingValue(ctx.Derived.PrecipTiming, ctx.Timezone)
|
||||
return &module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: value}, nil
|
||||
}
|
||||
|
||||
func precipTimingValue(timing forecast.PrecipTiming, timezone string) PrecipTimingModule {
|
||||
value := PrecipTimingModule{
|
||||
ProbabilityThreshold: timing.ProbabilityThreshold,
|
||||
ThunderMentioned: timing.ThunderMentioned,
|
||||
}
|
||||
if timing.MaxPrecipitationProbability != nil {
|
||||
value.MaxPopPercent = roundedInt(&timing.MaxPrecipitationProbability.Value)
|
||||
value.MaxPopTime = clockLabel(timing.MaxPrecipitationProbability.Time, timezone)
|
||||
}
|
||||
for _, window := range timing.PrecipitationWindows {
|
||||
item := PrecipitationWindowModule{
|
||||
PeriodBegins: friendlyDateTimeLabel(window.Start, timezone),
|
||||
PeriodBeginsHourLabel: hourMinuteLabel(window.Start, timezone),
|
||||
}
|
||||
if window.End != nil {
|
||||
item.PeriodEnds = friendlyDateTimeLabel(*window.End, timezone)
|
||||
item.PeriodEndsHourLabel = hourMinuteLabel(*window.End, timezone)
|
||||
}
|
||||
item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value)
|
||||
item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone)
|
||||
item.MaxPopHourLabel = hourMinuteLabel(window.MaxPrecipitationProbability.Time, timezone)
|
||||
item.PrecipitationType = precipitationWindowType(window.TextDescriptions)
|
||||
if item.MaxPopPercent != nil {
|
||||
item.ExpectationPhrase = precipitationWindowExpectationPhrase(*item.MaxPopPercent, item.PrecipitationType)
|
||||
}
|
||||
value.PrecipitationWindows = append(value.PrecipitationWindows, item)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func precipitationWindowType(descriptions []string) string {
|
||||
combined := strings.ToLower(strings.Join(descriptions, " "))
|
||||
switch {
|
||||
case (strings.Contains(combined, "thunderstorm") || strings.Contains(combined, "t-storm")) &&
|
||||
(strings.Contains(combined, "shower") || strings.Contains(combined, "rain")):
|
||||
return "showers and thunderstorms"
|
||||
case strings.Contains(combined, "freezing rain"):
|
||||
return "freezing rain"
|
||||
case strings.Contains(combined, "thunderstorm") || strings.Contains(combined, "t-storm"):
|
||||
return "thunderstorms"
|
||||
case strings.Contains(combined, "shower"):
|
||||
return "showers"
|
||||
case strings.Contains(combined, "snow"):
|
||||
return "snow"
|
||||
case strings.Contains(combined, "drizzle"):
|
||||
return "drizzle"
|
||||
case strings.Contains(combined, "rain"):
|
||||
return "rain"
|
||||
default:
|
||||
return "precipitation"
|
||||
}
|
||||
}
|
||||
|
||||
func precipitationWindowExpectationPhrase(maxPopPercent int, precipitationType string) string {
|
||||
if precipitationType == "" {
|
||||
precipitationType = "precipitation"
|
||||
}
|
||||
switch {
|
||||
case maxPopPercent >= precipTimingExpectLowerBound:
|
||||
return fmt.Sprintf("Expect %s.", precipitationType)
|
||||
case maxPopPercent >= precipTimingLikelyLowerBound:
|
||||
return fmt.Sprintf("%s likely.", sentenceCase(precipitationType))
|
||||
case maxPopPercent >= precipTimingChanceLowerBound:
|
||||
return fmt.Sprintf("Chance of %s.", precipitationType)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
94
internal/briefing/spc_convective_discussion_module.go
Normal file
94
internal/briefing/spc_convective_discussion_module.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
const defaultSPCConvectiveDiscussionMinimumSeverityRank = defaultSPCRiskDigestMinimumSeverityRank
|
||||
const spcCategoricalOutlookType = defaultSPCRiskDigestOutlookType
|
||||
|
||||
type SPCConvectiveDiscussionModule struct {
|
||||
IncludedBecause string `json:"included_because"`
|
||||
Discussions []SPCConvectiveDiscussionRecord `json:"discussions"`
|
||||
}
|
||||
|
||||
type SPCConvectiveDiscussionRecord struct {
|
||||
Day int `json:"day,omitempty"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
Headline string `json:"headline,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Discussion string `json:"discussion,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
func buildSPCConvectiveDiscussionModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
qualifyingPeriods := spcConvectiveDiscussionQualifyingPeriods(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod)
|
||||
if len(qualifyingPeriods) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
records := spcConvectiveDiscussionRecords(qualifyingPeriods, ctx.Derived.SPCConvectiveDiscussions, ctx.Timezone)
|
||||
if len(records) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
value := SPCConvectiveDiscussionModule{
|
||||
IncludedBecause: fmt.Sprintf("%s severity_rank >= %d", spcCategoricalOutlookType, defaultSPCConvectiveDiscussionMinimumSeverityRank),
|
||||
Discussions: records,
|
||||
}
|
||||
return &module.Output{ID: module.SPCConvectiveDiscussion, StanzaName: string(module.SPCConvectiveDiscussion), Value: value}, nil
|
||||
}
|
||||
|
||||
func spcConvectiveDiscussionQualifyingPeriods(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period) map[int]timeutil.Period {
|
||||
periods := map[int]timeutil.Period{}
|
||||
for _, outlook := range outlooks {
|
||||
if outlook.OutlookType != spcCategoricalOutlookType {
|
||||
continue
|
||||
}
|
||||
if outlook.SeverityRank == nil || *outlook.SeverityRank < defaultSPCConvectiveDiscussionMinimumSeverityRank {
|
||||
continue
|
||||
}
|
||||
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
|
||||
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(reportPeriod) {
|
||||
continue
|
||||
}
|
||||
if existing, ok := periods[outlook.Day]; ok {
|
||||
if outlookPeriod.Start.Before(existing.Start) {
|
||||
existing.Start = outlookPeriod.Start
|
||||
}
|
||||
if outlookPeriod.End.After(existing.End) {
|
||||
existing.End = outlookPeriod.End
|
||||
}
|
||||
periods[outlook.Day] = existing
|
||||
continue
|
||||
}
|
||||
periods[outlook.Day] = outlookPeriod
|
||||
}
|
||||
return periods
|
||||
}
|
||||
|
||||
func spcConvectiveDiscussionRecords(qualifyingPeriods map[int]timeutil.Period, discussions []weatherdata.ConvectiveOutlookDiscussion, timezone string) []SPCConvectiveDiscussionRecord {
|
||||
records := make([]SPCConvectiveDiscussionRecord, 0, len(discussions))
|
||||
for _, discussion := range discussions {
|
||||
period, ok := qualifyingPeriods[discussion.Day]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if discussion.Discussion == "" {
|
||||
continue
|
||||
}
|
||||
records = append(records, SPCConvectiveDiscussionRecord{
|
||||
Day: discussion.Day,
|
||||
PeriodBegins: friendlyPeriodBeginsLabel(period, timezone),
|
||||
PeriodEnds: friendlyPeriodEndsLabel(period, timezone),
|
||||
Headline: discussion.Headline,
|
||||
Summary: discussion.Summary,
|
||||
Discussion: discussion.Discussion,
|
||||
UpdatedAt: friendlyOptionalTime(discussion.UpdatedAt, timezone),
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
317
internal/briefing/spc_convective_discussion_module_test.go
Normal file
317
internal/briefing/spc_convective_discussion_module_test.go
Normal file
@@ -0,0 +1,317 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
func TestSPCConvectiveDiscussionModuleOmitsBelowThresholdButOutlookRemains(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := spcConvectiveDiscussionContext(2, []weatherdata.ConvectiveOutlookDiscussion{
|
||||
spcDiscussion(1, "Lower risk", "General thunderstorms.", "No organized severe weather is expected.", "2026-05-29T08:30:00-05:00"),
|
||||
})
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(discussion) error = %v", err)
|
||||
}
|
||||
if output != nil {
|
||||
t.Fatalf("discussion output = %#v, want omitted below threshold", output)
|
||||
}
|
||||
|
||||
outlookOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(outlooks) error = %v", err)
|
||||
}
|
||||
outlookValue := moduleValue[SPCConvectiveOutlooksModule](t, outlookOutput)
|
||||
if !outlookValue.Checked || outlookValue.OutlookCount != 1 {
|
||||
t.Fatalf("outlook value = %#v, want lower-risk outlook still emitted", outlookValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveDiscussionModuleIncludesEqualThresholdDiscussion(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := spcConvectiveDiscussionContext(3, []weatherdata.ConvectiveOutlookDiscussion{
|
||||
spcDiscussion(1, "Severe storms possible", "Scattered severe storms are possible.", "A few storms may become severe during the afternoon.", "2026-05-29T08:30:00-05:00"),
|
||||
})
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
if output == nil || output.ID != module.SPCConvectiveDiscussion || output.StanzaName != "spc_convective_discussion" {
|
||||
t.Fatalf("output = %#v, want spc convective discussion stanza", output)
|
||||
}
|
||||
value := moduleValue[SPCConvectiveDiscussionModule](t, output)
|
||||
if value.IncludedBecause != "categorical severity_rank >= 3" || len(value.Discussions) != 1 {
|
||||
t.Fatalf("value = %#v, want threshold reason and one discussion", value)
|
||||
}
|
||||
discussion := value.Discussions[0]
|
||||
if discussion.Day != 1 || discussion.Headline != "Severe storms possible" || discussion.Summary == "" || discussion.Discussion == "" {
|
||||
t.Fatalf("discussion = %#v, want prompt-facing discussion fields", discussion)
|
||||
}
|
||||
if discussion.PeriodBegins != "2026-05-29 at 11:00 AM" || discussion.PeriodEnds != "2026-05-30 at 7:00 AM" {
|
||||
t.Fatalf("Period = %q/%q, want qualifying outlook valid period", discussion.PeriodBegins, discussion.PeriodEnds)
|
||||
}
|
||||
if discussion.UpdatedAt != "2026-05-29 at 8:30 AM" {
|
||||
t.Fatalf("UpdatedAt = %q, want friendly local time", discussion.UpdatedAt)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
text := string(data)
|
||||
for _, field := range []string{"included_because", "discussions", "period_begins", "period_ends", "headline", "summary", "discussion", "updated_at"} {
|
||||
if !strings.Contains(text, field) {
|
||||
t.Fatalf("json = %s, want field %s", text, field)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, `"period":`) {
|
||||
t.Fatalf("json = %s, want period_begins/period_ends instead of period", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveDiscussionModuleIncludesAboveThresholdDiscussion(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := spcConvectiveDiscussionContext(4, []weatherdata.ConvectiveOutlookDiscussion{
|
||||
spcDiscussion(1, "Enhanced severe risk", "Numerous severe storms are possible.", "Severe storms may produce damaging winds.", "2026-05-29T09:15:00-05:00"),
|
||||
})
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[SPCConvectiveDiscussionModule](t, output)
|
||||
if len(value.Discussions) != 1 || value.Discussions[0].Headline != "Enhanced severe risk" {
|
||||
t.Fatalf("value = %#v, want above-threshold discussion", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveDiscussionModuleIgnoresHighRankNonCategoricalOutlook(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
rank := 30
|
||||
ctx := testModuleContext()
|
||||
outlook := weatherdata.ConvectiveOutlook{
|
||||
ID: "day1-wind-30",
|
||||
Day: 1,
|
||||
OutlookType: "wind",
|
||||
Label: "30%",
|
||||
LabelText: "30% Wind Risk",
|
||||
SeverityRank: &rank,
|
||||
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||
}
|
||||
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||
}
|
||||
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
|
||||
spcDiscussion(1, "Wind risk discussion", "High wind probabilities.", "This discussion should not be emitted from wind severity rank.", "2026-05-29T08:30:00-05:00"),
|
||||
}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
if output != nil {
|
||||
t.Fatalf("output = %#v, want non-categorical outlook ignored for discussion threshold", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveDiscussionModuleRequiresCategoricalThresholdForMixedSameDayOutlooks(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
rank2 := 2
|
||||
rank30 := 30
|
||||
ctx := testModuleContext()
|
||||
categorical := weatherdata.ConvectiveOutlook{
|
||||
ID: "day1-marginal",
|
||||
Day: 1,
|
||||
OutlookType: "categorical",
|
||||
Label: "MRGL",
|
||||
LabelText: "Marginal Risk",
|
||||
SeverityRank: &rank2,
|
||||
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||
}
|
||||
wind := weatherdata.ConvectiveOutlook{
|
||||
ID: "day1-wind-30",
|
||||
Day: 1,
|
||||
OutlookType: "wind",
|
||||
Label: "30%",
|
||||
LabelText: "30% Wind Risk",
|
||||
SeverityRank: &rank30,
|
||||
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||
}
|
||||
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{categorical, wind},
|
||||
}
|
||||
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{categorical, wind}
|
||||
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
|
||||
spcDiscussion(1, "Mixed risk discussion", "Only wind is high.", "This discussion should not be emitted without categorical threshold.", "2026-05-29T08:30:00-05:00"),
|
||||
}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
if output != nil {
|
||||
t.Fatalf("output = %#v, want mixed day omitted when categorical outlook is below threshold", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveDiscussionModuleIncludesOnlyQualifyingDays(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
rank2 := 2
|
||||
rank4 := 4
|
||||
ctx := testModuleContext()
|
||||
ctx.Resolved.ValidPeriod.Start = mustParseModuleTime("2026-05-30T00:00:00-05:00")
|
||||
ctx.Resolved.ValidPeriod.End = mustParseModuleTime("2026-05-31T00:00:00-05:00")
|
||||
day1Outlook := weatherdata.ConvectiveOutlook{
|
||||
ID: "day1-marginal",
|
||||
Day: 1,
|
||||
OutlookType: "categorical",
|
||||
Label: "MRGL",
|
||||
LabelText: "Marginal Risk",
|
||||
SeverityRank: &rank2,
|
||||
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||
}
|
||||
day2Outlook := weatherdata.ConvectiveOutlook{
|
||||
ID: "day2-enhanced",
|
||||
Day: 2,
|
||||
OutlookType: "categorical",
|
||||
Label: "ENH",
|
||||
LabelText: "Enhanced Risk",
|
||||
SeverityRank: &rank4,
|
||||
ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||
ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"),
|
||||
}
|
||||
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{day1Outlook, day2Outlook},
|
||||
}
|
||||
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{day1Outlook, day2Outlook}
|
||||
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
|
||||
spcDiscussion(1, "Day 1 regional discussion", "Marginal risk discussion.", "Day 1 text should not be emitted.", "2026-05-29T08:30:00-05:00"),
|
||||
spcDiscussion(2, "Day 2 regional discussion", "Enhanced risk discussion.", "Day 2 text should be emitted.", "2026-05-30T08:30:00-05:00"),
|
||||
}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[SPCConvectiveDiscussionModule](t, output)
|
||||
if len(value.Discussions) != 1 {
|
||||
t.Fatalf("Discussions = %#v, want only the qualifying day discussion", value.Discussions)
|
||||
}
|
||||
if value.Discussions[0].Day != 2 || value.Discussions[0].Headline != "Day 2 regional discussion" {
|
||||
t.Fatalf("Discussions[0] = %#v, want day 2 discussion only", value.Discussions[0])
|
||||
}
|
||||
if value.Discussions[0].PeriodBegins != "2026-05-30 at 7:00 AM" || value.Discussions[0].PeriodEnds != "2026-05-31 at 7:00 AM" {
|
||||
t.Fatalf("Period = %q/%q, want qualifying day 2 outlook period", value.Discussions[0].PeriodBegins, value.Discussions[0].PeriodEnds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveDiscussionModuleOmitsNonOverlappingQualifyingOutlook(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
rank := 5
|
||||
ctx := testModuleContext()
|
||||
outlook := weatherdata.ConvectiveOutlook{
|
||||
ID: "day2-enhanced",
|
||||
Day: 2,
|
||||
OutlookType: "categorical",
|
||||
Label: "ENH",
|
||||
LabelText: "Enhanced Risk",
|
||||
SeverityRank: &rank,
|
||||
ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||
ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"),
|
||||
}
|
||||
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||
}
|
||||
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
|
||||
spcDiscussion(2, "Day 2 regional discussion", "Enhanced risk discussion.", "Day 2 text should not be emitted for today.", "2026-05-30T08:30:00-05:00"),
|
||||
}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
if output != nil {
|
||||
t.Fatalf("output = %#v, want non-overlapping discussion omitted", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveDiscussionModuleOmitsMissingDiscussionText(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := spcConvectiveDiscussionContext(3, []weatherdata.ConvectiveOutlookDiscussion{
|
||||
{Day: 1, Headline: "Severe storms possible", Summary: "Scattered severe storms are possible.", UpdatedAt: ptrModuleTime("2026-05-29T08:30:00-05:00")},
|
||||
})
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
if output != nil {
|
||||
t.Fatalf("output = %#v, want omitted when discussion text is missing", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveDiscussionModuleOmitsMissingSource(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Collected.SPCConvectiveOutlooks = nil
|
||||
ctx.Derived.SPCConvectiveOutlooks = nil
|
||||
ctx.Derived.SPCConvectiveDiscussions = nil
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
if output != nil {
|
||||
t.Fatalf("output = %#v, want omitted for missing optional source", output)
|
||||
}
|
||||
}
|
||||
|
||||
func spcConvectiveDiscussionContext(rank int, discussions []weatherdata.ConvectiveOutlookDiscussion) ModuleContext {
|
||||
ctx := testModuleContext()
|
||||
outlook := weatherdata.ConvectiveOutlook{
|
||||
ID: "day1-categorical",
|
||||
Day: 1,
|
||||
OutlookType: "categorical",
|
||||
Label: "SLGT",
|
||||
LabelText: "Slight Risk",
|
||||
SeverityRank: &rank,
|
||||
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||
}
|
||||
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||
AsOf: ptrModuleTime("2026-05-29T08:00:00-05:00"),
|
||||
IssuedAt: ptrModuleTime("2026-05-29T07:45:00-05:00"),
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||
}
|
||||
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||
ctx.Derived.SPCConvectiveDiscussions = discussions
|
||||
return ctx
|
||||
}
|
||||
|
||||
func spcDiscussion(day int, headline string, summary string, discussion string, updatedAt string) weatherdata.ConvectiveOutlookDiscussion {
|
||||
return weatherdata.ConvectiveOutlookDiscussion{
|
||||
Day: day,
|
||||
Headline: headline,
|
||||
Summary: summary,
|
||||
Discussion: discussion,
|
||||
UpdatedAt: ptrModuleTime(updatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func ptrModuleTime(value string) *time.Time {
|
||||
parsed := mustParseModuleTime(value)
|
||||
return &parsed
|
||||
}
|
||||
37
internal/briefing/spc_convective_outlook_definitions.go
Normal file
37
internal/briefing/spc_convective_outlook_definitions.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed assets/spc_convective_outlook_definitions.json
|
||||
var spcConvectiveOutlookDefinitionAssets embed.FS
|
||||
|
||||
var spcOutlookBackgroundDefinitions = mustLoadSPCOutlookBackgroundDefinitions()
|
||||
|
||||
func mustLoadSPCOutlookBackgroundDefinitions() map[string]SPCOutlookBackgroundDefinition {
|
||||
data, err := spcConvectiveOutlookDefinitionAssets.ReadFile("assets/spc_convective_outlook_definitions.json")
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("read embedded SPC outlook definitions: %v", err))
|
||||
}
|
||||
var definitions map[string]SPCOutlookBackgroundDefinition
|
||||
if err := json.Unmarshal(data, &definitions); err != nil {
|
||||
panic(fmt.Sprintf("decode embedded SPC outlook definitions: %v", err))
|
||||
}
|
||||
return definitions
|
||||
}
|
||||
|
||||
func spcOutlookBackgroundDefinition(outlookType string, label string) *SPCOutlookBackgroundDefinition {
|
||||
definition, ok := spcOutlookBackgroundDefinitions[spcOutlookDefinitionKey(outlookType, label)]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &definition
|
||||
}
|
||||
|
||||
func spcOutlookDefinitionKey(outlookType string, label string) string {
|
||||
return strings.ToLower(strings.TrimSpace(outlookType)) + ":" + strings.ToUpper(strings.TrimSpace(label))
|
||||
}
|
||||
162
internal/briefing/spc_convective_outlooks_module.go
Normal file
162
internal/briefing/spc_convective_outlooks_module.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
const defaultSPCRiskDigestOutlookType = "categorical"
|
||||
const defaultSPCRiskDigestMinimumSeverityRank = 3
|
||||
|
||||
type SPCConvectiveOutlooksModule struct {
|
||||
Checked bool `json:"checked"`
|
||||
AsOf string `json:"as_of,omitempty"`
|
||||
IssuedAt string `json:"issued_at,omitempty"`
|
||||
LocationID string `json:"location_id,omitempty"`
|
||||
LocationName string `json:"location_name,omitempty"`
|
||||
OutlookCount int `json:"outlook_count"`
|
||||
Outlooks []SPCConvectiveOutlookRecord `json:"outlooks,omitempty"`
|
||||
RiskDigest []SPCConvectiveOutlookDigest `json:"risk_digest,omitempty"`
|
||||
}
|
||||
|
||||
type SPCConvectiveOutlookRecord struct {
|
||||
Day int `json:"day,omitempty"`
|
||||
OutlookType string `json:"outlook_type,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
LabelText string `json:"label_text,omitempty"`
|
||||
BackgroundDefinition *SPCOutlookBackgroundDefinition `json:"background_definition,omitempty"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
IssuedAt string `json:"issued_at,omitempty"`
|
||||
ContainsLocation bool `json:"contains_location"`
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
}
|
||||
|
||||
type SPCOutlookBackgroundDefinition struct {
|
||||
PlainLanguage string `json:"plain_language,omitempty"`
|
||||
OfficialDescription string `json:"official_description,omitempty"`
|
||||
RelativeLevel string `json:"relative_level,omitempty"`
|
||||
}
|
||||
|
||||
type SPCConvectiveOutlookDigest struct {
|
||||
LabelText string `json:"label_text,omitempty"`
|
||||
RiskLabel string `json:"risk_label,omitempty"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
}
|
||||
|
||||
func buildSPCConvectiveOutlooksModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
value := SPCConvectiveOutlooksModule{}
|
||||
run := ctx.Collected.SPCConvectiveOutlooks
|
||||
if run != nil {
|
||||
value.Checked = true
|
||||
value.AsOf = friendlyOptionalTime(run.AsOf, ctx.Timezone)
|
||||
value.IssuedAt = friendlyOptionalTime(run.IssuedAt, ctx.Timezone)
|
||||
value.LocationID = run.LocationID
|
||||
value.LocationName = run.LocationName
|
||||
}
|
||||
source, ok := sourceByName(ctx.Collected.SourceProvenance, string(module.SPCConvectiveOutlooks))
|
||||
if value.Checked && value.AsOf == "" && ok && !source.FetchedAt.IsZero() {
|
||||
value.AsOf = friendlyDateTimeLabel(source.FetchedAt, ctx.Timezone)
|
||||
}
|
||||
if value.Checked && value.IssuedAt == "" && ok {
|
||||
value.IssuedAt = friendlyOptionalTime(source.IssuedAt, ctx.Timezone)
|
||||
}
|
||||
|
||||
value.Outlooks = spcConvectiveOutlookRecords(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod, ctx.Timezone)
|
||||
value.RiskDigest = spcConvectiveOutlookRiskDigest(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod, ctx.Timezone, defaultSPCRiskDigestPolicy())
|
||||
value.OutlookCount = len(value.Outlooks)
|
||||
return &module.Output{ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), Value: value}, nil
|
||||
}
|
||||
|
||||
type spcRiskDigestPolicy struct {
|
||||
OutlookType string
|
||||
MinimumSeverityRank int
|
||||
}
|
||||
|
||||
func defaultSPCRiskDigestPolicy() spcRiskDigestPolicy {
|
||||
return spcRiskDigestPolicy{
|
||||
OutlookType: defaultSPCRiskDigestOutlookType,
|
||||
MinimumSeverityRank: defaultSPCRiskDigestMinimumSeverityRank,
|
||||
}
|
||||
}
|
||||
|
||||
func spcConvectiveOutlookRecords(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period, timezone string) []SPCConvectiveOutlookRecord {
|
||||
records := make([]SPCConvectiveOutlookRecord, 0, len(outlooks))
|
||||
for _, outlook := range outlooks {
|
||||
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
|
||||
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(reportPeriod) {
|
||||
continue
|
||||
}
|
||||
records = append(records, SPCConvectiveOutlookRecord{
|
||||
Day: outlook.Day,
|
||||
OutlookType: outlook.OutlookType,
|
||||
Label: outlook.Label,
|
||||
LabelText: outlook.LabelText,
|
||||
BackgroundDefinition: spcOutlookBackgroundDefinition(outlook.OutlookType, outlook.Label),
|
||||
PeriodBegins: friendlyPeriodBeginsLabel(outlookPeriod, timezone),
|
||||
PeriodEnds: friendlyPeriodEndsLabel(outlookPeriod, timezone),
|
||||
IssuedAt: friendlyOptionalTime(outlook.IssuedAt, timezone),
|
||||
ContainsLocation: outlook.ContainsLocation,
|
||||
ImageURL: outlook.ImageURL,
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func spcConvectiveOutlookRiskDigest(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period, timezone string, policy spcRiskDigestPolicy) []SPCConvectiveOutlookDigest {
|
||||
records := make([]SPCConvectiveOutlookDigest, 0, len(outlooks))
|
||||
for _, outlook := range outlooks {
|
||||
if outlook.OutlookType != policy.OutlookType {
|
||||
continue
|
||||
}
|
||||
if outlook.SeverityRank == nil || *outlook.SeverityRank < policy.MinimumSeverityRank {
|
||||
continue
|
||||
}
|
||||
if !outlook.ContainsLocation {
|
||||
continue
|
||||
}
|
||||
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
|
||||
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(reportPeriod) {
|
||||
continue
|
||||
}
|
||||
records = append(records, SPCConvectiveOutlookDigest{
|
||||
LabelText: outlook.LabelText,
|
||||
RiskLabel: spcRiskDigestLabel(outlook.LabelText),
|
||||
PeriodBegins: friendlyMonthDayTimeLabel(outlookPeriod.Start, timezone),
|
||||
PeriodEnds: friendlyMonthDayTimeLabel(outlookPeriod.End, timezone),
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func spcRiskDigestLabel(labelText string) string {
|
||||
label := strings.TrimSpace(labelText)
|
||||
if label == "" {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(strings.ToLower(label))
|
||||
runes[0] = unicode.ToUpper(runes[0])
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
func friendlyOptionalTime(value *time.Time, timezone string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return friendlyDateTimeLabel(*value, timezone)
|
||||
}
|
||||
|
||||
func sourceByName(sources []weatherdata.Source, name string) (weatherdata.Source, bool) {
|
||||
for _, source := range sources {
|
||||
if source.Name == name {
|
||||
return source, true
|
||||
}
|
||||
}
|
||||
return weatherdata.Source{}, false
|
||||
}
|
||||
355
internal/briefing/spc_convective_outlooks_module_test.go
Normal file
355
internal/briefing/spc_convective_outlooks_module_test.go
Normal file
@@ -0,0 +1,355 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
rank := 3
|
||||
asOf := mustParseModuleTime("2026-05-29T14:00:00Z")
|
||||
issuedAt := mustParseModuleTime("2026-05-29T13:45:00Z")
|
||||
expiresAt := mustParseModuleTime("2026-05-30T07:00:00-05:00")
|
||||
outlook := weatherdata.ConvectiveOutlook{
|
||||
ID: "day1-categorical-slight",
|
||||
Provider: "spc",
|
||||
Product: "convective_outlook",
|
||||
Day: 1,
|
||||
OutlookType: "categorical",
|
||||
Label: "SLGT",
|
||||
LabelText: "Slight Risk",
|
||||
Forecaster: "Smith",
|
||||
SeverityRank: &rank,
|
||||
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||
IssuedAt: &issuedAt,
|
||||
ExpiresAt: &expiresAt,
|
||||
SourceURL: "https://www.spc.noaa.gov/products/outlook/day1otlk.html",
|
||||
ImageURL: "https://www.spc.noaa.gov/products/outlook/day1probotlk_2000_torn.gif",
|
||||
ContainsLocation: true,
|
||||
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[]}`),
|
||||
}
|
||||
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||
LocationID: "nws-lsx-grid-90-74",
|
||||
LocationName: "St. Louis, MO",
|
||||
AsOf: &asOf,
|
||||
IssuedAt: &issuedAt,
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||
}
|
||||
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
if output == nil || output.ID != module.SPCConvectiveOutlooks || output.StanzaName != "spc_convective_outlooks" {
|
||||
t.Fatalf("output = %#v, want spc convective outlook output", output)
|
||||
}
|
||||
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
|
||||
if !value.Checked || value.OutlookCount != 1 || value.AsOf != "2026-05-29 at 9:00 AM" || value.IssuedAt != "2026-05-29 at 8:45 AM" {
|
||||
t.Fatalf("SPCConvectiveOutlooksModule = %#v, want checked source timing and one outlook", value)
|
||||
}
|
||||
if value.LocationID != "nws-lsx-grid-90-74" || value.LocationName != "St. Louis, MO" {
|
||||
t.Fatalf("source location = %q/%q, want Weather API location", value.LocationID, value.LocationName)
|
||||
}
|
||||
if len(value.Outlooks) != 1 {
|
||||
t.Fatalf("Outlooks length = %d, want 1", len(value.Outlooks))
|
||||
}
|
||||
got := value.Outlooks[0]
|
||||
if got.Day != 1 || got.OutlookType != "categorical" || got.Label != "SLGT" || got.LabelText != "Slight Risk" {
|
||||
t.Fatalf("outlook = %#v, want categorical slight risk fields", got)
|
||||
}
|
||||
if got.BackgroundDefinition == nil ||
|
||||
got.BackgroundDefinition.PlainLanguage != "Scattered severe storms possible." ||
|
||||
got.BackgroundDefinition.OfficialDescription != "Isolated intense storms are possible within the risk area, but severe weather is generally expected to be short-lived and/or not widespread." ||
|
||||
got.BackgroundDefinition.RelativeLevel != "2 of 5" {
|
||||
t.Fatalf("background definition = %#v, want Slight Risk helper", got.BackgroundDefinition)
|
||||
}
|
||||
if got.PeriodBegins != "2026-05-29 at 11:00 AM" || got.PeriodEnds != "2026-05-30 at 7:00 AM" || got.IssuedAt != "2026-05-29 at 8:45 AM" {
|
||||
t.Fatalf("outlook times = %#v, want friendly local labels", got)
|
||||
}
|
||||
if !got.ContainsLocation || got.ImageURL == "" {
|
||||
t.Fatalf("outlook = %#v, want location flag and image URL", got)
|
||||
}
|
||||
if len(value.RiskDigest) != 1 {
|
||||
t.Fatalf("RiskDigest length = %d, want 1", len(value.RiskDigest))
|
||||
}
|
||||
digest := value.RiskDigest[0]
|
||||
if digest.LabelText != "Slight Risk" || digest.RiskLabel != "Slight risk" || digest.PeriodBegins != "May 29 at 11:00 AM" || digest.PeriodEnds != "May 30 at 7:00 AM" {
|
||||
t.Fatalf("risk digest = %#v, want prompt-facing slight risk record", digest)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
text := string(data)
|
||||
for _, field := range []string{"checked", "as_of", "issued_at", "location_id", "location_name", "outlook_count", "outlooks", "risk_digest", "background_definition", "plain_language", "official_description", "relative_level", "period_begins", "period_ends", "contains_location", "image_url"} {
|
||||
if !strings.Contains(text, field) {
|
||||
t.Fatalf("json = %s, want field %s", text, field)
|
||||
}
|
||||
}
|
||||
for _, omitted := range []string{"geometry", "coordinates", "forecaster", "provider", "severity_rank", "expires_at", "source_url", "valid_start", "valid_end", `"period":`} {
|
||||
if strings.Contains(text, omitted) {
|
||||
t.Fatalf("json = %s, want prompt-safe outlook without %s", text, omitted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCOutlookBackgroundDefinitionLookup(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
outlookType string
|
||||
label string
|
||||
want bool
|
||||
}{
|
||||
{name: "exact slight risk", outlookType: "categorical", label: "SLGT", want: true},
|
||||
{name: "normalized slight risk", outlookType: " Categorical ", label: "slgt", want: true},
|
||||
{name: "expanded marginal risk", outlookType: "categorical", label: "MRGL", want: true},
|
||||
{name: "expanded conditional tornado risk", outlookType: "tornado", label: "CIG3", want: true},
|
||||
{name: "unknown risk", outlookType: "categorical", label: "FOO"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
definition := spcOutlookBackgroundDefinition(tt.outlookType, tt.label)
|
||||
if tt.want && definition == nil {
|
||||
t.Fatalf("spcOutlookBackgroundDefinition(%q, %q) = nil, want definition", tt.outlookType, tt.label)
|
||||
}
|
||||
if !tt.want && definition != nil {
|
||||
t.Fatalf("spcOutlookBackgroundDefinition(%q, %q) = %#v, want nil", tt.outlookType, tt.label, definition)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveOutlooksModuleOmitsUnknownBackgroundDefinition(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
outlook := spcRiskDigestTestOutlook("categorical", "Unknown Risk", 2, true,
|
||||
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00")
|
||||
outlook.Label = "FOO"
|
||||
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||
}
|
||||
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
|
||||
if len(value.Outlooks) != 1 {
|
||||
t.Fatalf("Outlooks length = %d, want 1", len(value.Outlooks))
|
||||
}
|
||||
if value.Outlooks[0].BackgroundDefinition != nil {
|
||||
t.Fatalf("BackgroundDefinition = %#v, want nil for undefined risk", value.Outlooks[0].BackgroundDefinition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCOutlookBackgroundDefinitionsAssetHasUsableEntries(t *testing.T) {
|
||||
wantKeys := []string{
|
||||
"categorical:TSTM",
|
||||
"categorical:MRGL",
|
||||
"categorical:SLGT",
|
||||
"categorical:ENH",
|
||||
"categorical:MDT",
|
||||
"categorical:HIGH",
|
||||
"tornado:CIG1",
|
||||
"tornado:CIG2",
|
||||
"tornado:CIG3",
|
||||
"wind:CIG1",
|
||||
"wind:CIG2",
|
||||
"wind:CIG3",
|
||||
"hail:CIG1",
|
||||
"hail:CIG2",
|
||||
}
|
||||
if len(spcOutlookBackgroundDefinitions) != len(wantKeys) {
|
||||
t.Fatalf("embedded SPC outlook background definitions length = %d, want %d", len(spcOutlookBackgroundDefinitions), len(wantKeys))
|
||||
}
|
||||
for _, key := range wantKeys {
|
||||
if _, ok := spcOutlookBackgroundDefinitions[key]; !ok {
|
||||
t.Fatalf("embedded SPC outlook background definitions missing %q", key)
|
||||
}
|
||||
}
|
||||
for key, definition := range spcOutlookBackgroundDefinitions {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
t.Fatal("embedded SPC outlook background definitions contain empty key")
|
||||
}
|
||||
if definition.PlainLanguage == "" || definition.OfficialDescription == "" || definition.RelativeLevel == "" {
|
||||
t.Fatalf("embedded SPC outlook background definition %q is incomplete: %#v", key, definition)
|
||||
}
|
||||
if strings.Contains(definition.OfficialDescription, ".Note") || strings.Contains(definition.OfficialDescription, "higher.Note") {
|
||||
t.Fatalf("embedded SPC outlook background definition %q has missing sentence spacing: %q", key, definition.OfficialDescription)
|
||||
}
|
||||
if strings.Contains(definition.OfficialDescription, "by themselves") {
|
||||
t.Fatalf("embedded SPC outlook background definition %q has singular grammar issue: %q", key, definition.OfficialDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCRiskDigestDefaultPolicyConstants(t *testing.T) {
|
||||
if defaultSPCRiskDigestOutlookType != "categorical" {
|
||||
t.Fatalf("defaultSPCRiskDigestOutlookType = %q, want categorical", defaultSPCRiskDigestOutlookType)
|
||||
}
|
||||
if defaultSPCRiskDigestMinimumSeverityRank != 3 {
|
||||
t.Fatalf("defaultSPCRiskDigestMinimumSeverityRank = %d, want 3", defaultSPCRiskDigestMinimumSeverityRank)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveOutlooksRiskDigestFilters(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
outlook weatherdata.ConvectiveOutlook
|
||||
wantRisk bool
|
||||
}{
|
||||
{
|
||||
name: "categorical slight risk included",
|
||||
outlook: spcRiskDigestTestOutlook("categorical", "Slight Risk", 3, true,
|
||||
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"),
|
||||
wantRisk: true,
|
||||
},
|
||||
{
|
||||
name: "marginal risk excluded",
|
||||
outlook: spcRiskDigestTestOutlook("categorical", "Marginal Risk", 2, true,
|
||||
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"),
|
||||
},
|
||||
{
|
||||
name: "non categorical high rank excluded",
|
||||
outlook: spcRiskDigestTestOutlook("wind", "30% Wind Risk", 30, true,
|
||||
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"),
|
||||
},
|
||||
{
|
||||
name: "non overlapping excluded",
|
||||
outlook: spcRiskDigestTestOutlook("categorical", "Enhanced Risk", 4, true,
|
||||
"2026-05-30T07:00:00-05:00", "2026-05-31T07:00:00-05:00"),
|
||||
},
|
||||
{
|
||||
name: "location miss excluded",
|
||||
outlook: spcRiskDigestTestOutlook("categorical", "Moderate Risk", 5, false,
|
||||
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{tt.outlook},
|
||||
}
|
||||
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{tt.outlook}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
|
||||
gotRisk := len(value.RiskDigest) > 0
|
||||
if gotRisk != tt.wantRisk {
|
||||
t.Fatalf("RiskDigest = %#v, want included=%v", value.RiskDigest, tt.wantRisk)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveOutlooksModuleSkipsNonOverlappingOutlooks(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
rank := 5
|
||||
outlook := weatherdata.ConvectiveOutlook{
|
||||
ID: "tomorrow-enhanced",
|
||||
Day: 2,
|
||||
OutlookType: "categorical",
|
||||
Label: "ENH",
|
||||
LabelText: "Enhanced Risk",
|
||||
SeverityRank: &rank,
|
||||
ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||
ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"),
|
||||
ContainsLocation: true,
|
||||
}
|
||||
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||
AsOf: ptrModuleTime("2026-05-29T14:00:00Z"),
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||
}
|
||||
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
|
||||
if value.OutlookCount != 0 || len(value.Outlooks) != 0 {
|
||||
t.Fatalf("value = %#v, want non-overlapping outlook omitted", value)
|
||||
}
|
||||
if len(value.RiskDigest) != 0 {
|
||||
t.Fatalf("RiskDigest = %#v, want non-overlapping outlook omitted", value.RiskDigest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveOutlooksModuleBuildsCheckedEmptyStanza(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
asOf := mustParseModuleTime("2026-05-29T14:00:00Z")
|
||||
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||
LocationID: "nws-lsx-grid-90-74",
|
||||
LocationName: "St. Louis, MO",
|
||||
AsOf: &asOf,
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{},
|
||||
}
|
||||
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
|
||||
if !value.Checked || value.OutlookCount != 0 || len(value.Outlooks) != 0 {
|
||||
t.Fatalf("checked empty value = %#v, want checked source with no retained outlooks", value)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
if strings.Contains(string(data), "outlooks") {
|
||||
t.Fatalf("json = %s, want empty outlook list omitted", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveOutlooksModuleBuildsUncheckedStanzaForMissingSource(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Collected.SPCConvectiveOutlooks = nil
|
||||
ctx.Collected.SourceProvenance = []weatherdata.Source{{
|
||||
Name: string(module.SPCConvectiveOutlooks),
|
||||
Missing: true,
|
||||
}}
|
||||
ctx.Derived.SPCConvectiveOutlooks = nil
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
|
||||
if value.Checked || value.OutlookCount != 0 || value.AsOf != "" || value.IssuedAt != "" {
|
||||
t.Fatalf("missing source value = %#v, want unchecked empty stanza", value)
|
||||
}
|
||||
}
|
||||
|
||||
func spcRiskDigestTestOutlook(outlookType string, labelText string, rank int, containsLocation bool, validFrom string, validTo string) weatherdata.ConvectiveOutlook {
|
||||
return weatherdata.ConvectiveOutlook{
|
||||
ID: labelText,
|
||||
Day: 1,
|
||||
OutlookType: outlookType,
|
||||
LabelText: labelText,
|
||||
SeverityRank: &rank,
|
||||
ValidFrom: mustParseModuleTime(validFrom),
|
||||
ValidTo: mustParseModuleTime(validTo),
|
||||
ContainsLocation: containsLocation,
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user