Compare commits
350 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53aa0b0a55 | |||
| fc8ddada9a | |||
| 13b06039b1 | |||
| b9080466a2 | |||
| 142f2f92e7 | |||
| 88fde0df7f | |||
| b985c5faac | |||
| d6829af32b | |||
| cd7b9aef2b | |||
| c3ebf06bd5 | |||
| 7884b9a6c3 | |||
| 17468cb8dd | |||
| 71b7a74d3d | |||
| 2c4c0bbd90 | |||
| 965f16d7a4 | |||
| fb891fad07 | |||
| 0516ee148d | |||
| e6450138c2 | |||
| 57aa27c9de | |||
| 166c4ce53b | |||
| 78fc461a75 | |||
| 5e492cf1fb | |||
| 79cba800ee | |||
| 302f5aba2d | |||
| 0314a302f1 | |||
| 707db5394c | |||
| 70cad789ea | |||
| 4b748c2e53 | |||
| 0b57d99a97 | |||
| 04b8358965 | |||
| f4e3a6f26c | |||
| 44ee389334 | |||
| ef2634c2cb | |||
| a18d5134c7 | |||
| 2bd921f247 | |||
| 360c665a3e | |||
| e2dd8d0e29 | |||
| e520ffb13b | |||
| f8beed04cf | |||
| 44af91cadf | |||
| a38d291f63 | |||
| 27849813db | |||
| 41b86109e3 | |||
| 13829cc65c | |||
| daf0c7efd7 | |||
| 9b4e53702b | |||
| 730929e2ed | |||
| 8e49ba88c7 | |||
| 8fafacf921 | |||
| 8bb7307f22 | |||
| c515529b3a | |||
| 3c1ebab289 | |||
| 2d956f7315 | |||
| 4d5a1d9709 | |||
| 1d3ea64541 | |||
| 706086e3de | |||
| 26a681e0b1 | |||
| 5139c1a586 | |||
| 0b869af75e | |||
| c3eeb298f0 | |||
| 6945306a2f | |||
| 4f52555389 | |||
| fa19452dec | |||
| 91e7e5f321 | |||
| 4bb3913276 | |||
| ab571cd8ab | |||
| 798e6f11c5 | |||
| c49c50bc8d | |||
| d328a1daa6 | |||
| 7ae3820e12 | |||
| a4ef76f17a | |||
| 5ed1e264fc | |||
| c025afcd1a | |||
| 2b06541ef8 | |||
| d92ff0ef48 | |||
| 880ad710ae | |||
| edde330390 | |||
| ae52606772 | |||
| 8a323d5574 | |||
| cfb64ded34 | |||
| 5ed448df11 | |||
| 725c1420dd | |||
| e5250bd6cb | |||
| 00fe0c3e96 | |||
| 6d2c097657 | |||
| e7c7262404 | |||
| 151c536cb9 | |||
| 2b1fb26e7d | |||
| 3c7383e2ce | |||
| eed47b4f68 | |||
| 6c185b8d0e | |||
| faf547e4a8 | |||
| acb476a142 | |||
| 606b4423f1 | |||
| 1716702c99 | |||
| e0229d9c90 | |||
| ccf6b66880 | |||
| b489c56a48 | |||
| d39e42de30 | |||
| d642791c10 | |||
| 236e3d16c4 | |||
| 4fa873983d | |||
| de1ae896b3 | |||
| 6173e50d25 | |||
| 3bca2f41f7 | |||
| 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 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,6 +1,5 @@
|
||||
# Compiled application binary and testing workspace
|
||||
# Compiled application binary
|
||||
/weatherreporter
|
||||
/workspace
|
||||
|
||||
# ---> Go
|
||||
# If you prefer the allow list template instead of the deny list, see community template:
|
||||
|
||||
@@ -2,8 +2,50 @@ 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.25
|
||||
image: golang:1.26.5
|
||||
depends_on:
|
||||
- validate-release
|
||||
commands:
|
||||
- |
|
||||
set -eu
|
||||
@@ -33,8 +75,11 @@ steps:
|
||||
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
|
||||
image: woodpeckerci/plugin-release:0.3.1
|
||||
depends_on:
|
||||
- build-release-assets
|
||||
settings:
|
||||
@@ -42,6 +87,8 @@ steps:
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
25
README.md
25
README.md
@@ -1,22 +1,31 @@
|
||||
# weatherreporter
|
||||
|
||||
`weatherreporter` is a Go application for preparing human-facing weather
|
||||
reports from normalized forecast data. It builds JSON module snapshots, passes
|
||||
YAML prompt data packages to `scriptorium`, and keeps inspectable artifacts
|
||||
under a local workspace. It can also upload successfully generated managed
|
||||
Markdown reports to a configured `distributor` HTTP upload endpoint.
|
||||
Weatherreporter is a Go CLI that turns normalized weather data into
|
||||
human-facing Markdown reports.
|
||||
|
||||
It produces a Markdown report at an operator-owned destination and can upload
|
||||
the completed output through Distributor. It can also compare explicitly
|
||||
selected Promptkit profiles against one shared prepared report and publish a
|
||||
local comparison bundle.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```sh
|
||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.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)
|
||||
- [Troubleshooting](docs/troubleshooting.md)
|
||||
- [Comparison bundle contract](docs/integrations/comparison-bundle.md)
|
||||
- [Development guide](docs/development.md)
|
||||
- [Architecture policy](docs/policy/architecture.md)
|
||||
- [Development policy](docs/policy/development.md)
|
||||
|
||||
@@ -3,14 +3,29 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := cli.Run(context.Background(), os.Args[1:], os.Stdout, os.Stderr); err != nil {
|
||||
if err := runCommand(os.Args[1:], os.Stdout, os.Stderr, cli.Run); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "weatherreporter: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runCommand(args []string, stdout, stderr io.Writer, runner func(context.Context, []string, io.Writer, io.Writer) error) error {
|
||||
return runCommandWithSignalContext(args, stdout, stderr, runner, signal.NotifyContext)
|
||||
}
|
||||
|
||||
type signalContextFunc func(context.Context, ...os.Signal) (context.Context, context.CancelFunc)
|
||||
|
||||
func runCommandWithSignalContext(args []string, stdout, stderr io.Writer, runner func(context.Context, []string, io.Writer, io.Writer) error, signalContext signalContextFunc) error {
|
||||
ctx, stop := signalContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
return runner(ctx, args, stdout, stderr)
|
||||
}
|
||||
|
||||
36
cmd/weatherreporter/main_test.go
Normal file
36
cmd/weatherreporter/main_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunCommandBuildsCancelableSignalContext(t *testing.T) {
|
||||
var signals []os.Signal
|
||||
stopped := false
|
||||
signalContext := func(parent context.Context, requested ...os.Signal) (context.Context, context.CancelFunc) {
|
||||
signals = append([]os.Signal(nil), requested...)
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
cancel()
|
||||
return ctx, func() {
|
||||
stopped = true
|
||||
}
|
||||
}
|
||||
|
||||
err := runCommandWithSignalContext(nil, io.Discard, io.Discard, func(ctx context.Context, _ []string, _, _ io.Writer) error {
|
||||
return ctx.Err()
|
||||
}, signalContext)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("runCommandWithSignalContext() error = %v, want context cancellation", err)
|
||||
}
|
||||
if len(signals) != 2 || signals[0] != os.Interrupt || signals[1] != syscall.SIGTERM {
|
||||
t.Fatalf("requested signals = %#v, want Interrupt and SIGTERM", signals)
|
||||
}
|
||||
if !stopped {
|
||||
t.Fatal("signal context stop function was not called")
|
||||
}
|
||||
}
|
||||
58
cmd/weatherreporter/main_unix_test.go
Normal file
58
cmd/weatherreporter/main_unix_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
//go:build unix
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRunCommandCancelsActionContextOnSignal(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
signal os.Signal
|
||||
}{
|
||||
{name: "Interrupt", signal: os.Interrupt},
|
||||
{name: "Terminate", signal: syscall.SIGTERM},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- runCommand(nil, io.Discard, io.Discard, func(ctx context.Context, _ []string, _, _ io.Writer) error {
|
||||
close(started)
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
})
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("runner did not receive an action context")
|
||||
}
|
||||
|
||||
process, err := os.FindProcess(os.Getpid())
|
||||
if err != nil {
|
||||
t.Fatalf("FindProcess() error = %v", err)
|
||||
}
|
||||
if err := process.Signal(tt.signal); err != nil {
|
||||
t.Fatalf("Signal(%v) error = %v", tt.signal, err)
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("runCommand() error = %v, want context cancellation", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("interrupt did not cancel the action context")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
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.
|
||||
252
docs/cli.md
252
docs/cli.md
@@ -1,102 +1,198 @@
|
||||
# Weatherreporter CLI
|
||||
|
||||
`weatherreporter` generates Markdown weather reports, runs scheduled report
|
||||
batches, and inspects stored artifacts.
|
||||
`weatherreporter` generates Markdown weather reports, runs report batches, and
|
||||
compares explicitly selected Promptkit profiles against one prepared report. 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
|
||||
```
|
||||
|
||||
This loads configuration, fetches weather data, writes managed workspace
|
||||
artifacts, runs `scriptorium render` as a preflight check, runs
|
||||
`scriptorium run`, and writes an extra Markdown copy to `./daily.md`. If
|
||||
distributor notification is enabled in configuration, the command also uploads
|
||||
the managed Markdown report after final metadata is saved.
|
||||
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.
|
||||
|
||||
## Commands
|
||||
## Commands And Usage
|
||||
|
||||
```text
|
||||
weatherreporter --help
|
||||
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
||||
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
|
||||
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
|
||||
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
|
||||
weatherreporter inspect reports [--config PATH] [--limit N]
|
||||
weatherreporter inspect metadata [--config PATH] RUN_ID
|
||||
weatherreporter inspect modules [--config PATH] RUN_ID
|
||||
weatherreporter inspect data-package [--config PATH] RUN_ID
|
||||
weatherreporter inspect prior [--config PATH] RUN_ID
|
||||
weatherreporter inspect sources [--config PATH] RUN_ID
|
||||
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]
|
||||
weatherreporter compare REPORT --profile PROFILE --profile PROFILE [--config PATH] [--units VALUE] [--tz NAME] [--date YYYY-MM-DD] [--out-dir PATH] [--replace] [--llm-debug-dir PATH] [--quiet]
|
||||
```
|
||||
|
||||
`generate` commands write a JSON module snapshot, YAML data package, preflight
|
||||
artifact, managed Markdown report, and metadata under the configured workspace.
|
||||
`--out` writes an extra Markdown copy for the operator; distributor
|
||||
notification uses the managed report path, not the extra copy. `generate storm`
|
||||
requires explicit event-window bounds with `--start` and `--end`.
|
||||
`weatherreporter --version` prints the version embedded in the executable.
|
||||
Tagged release binaries report their semantic version tag; ordinary local
|
||||
builds report `development`.
|
||||
|
||||
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
||||
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch
|
||||
runs continue independent reports after a failure, print a JSON summary to
|
||||
stdout, write compact status lines to stderr, and return nonzero when any report
|
||||
failed. `--out-dir` writes extra Markdown copies for the operator; distributor
|
||||
notification uses each managed report path, not the extra copies. When
|
||||
notification is enabled, batch summaries and status lines include notification
|
||||
status, accepted distributor run ID, or notification error fields for each
|
||||
attempted report.
|
||||
| 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. |
|
||||
| `compare REPORT` | Accepts `daily`, `today`, `tomorrow`, or `hourly`. It requires at least two distinct, nonblank `--profile` values in their supplied order. Daily requires `--date`; Today accepts it optionally; Tomorrow and Hourly do not accept it. |
|
||||
|
||||
`inspect` commands read existing workspace artifacts and emit JSON to stdout.
|
||||
They do not fetch weather data or invoke `scriptorium`.
|
||||
`generate` accepts the four report command names shown above. `run` accepts
|
||||
only `morning` and `evening`. `compare` always requires explicit profile
|
||||
selection: `promptkit.profile` is not used as a comparison default. Batch
|
||||
membership and notification ordering are described in the
|
||||
[operations guide](operations.md).
|
||||
|
||||
## Flags
|
||||
## Output, Errors, And Quiet Mode
|
||||
|
||||
- `-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 for `generate` and `run`.
|
||||
- `--tz NAME`: override configured Weather API timezone for `generate` and `run`.
|
||||
- `--out PATH`: write an extra Markdown report copy for `generate` commands.
|
||||
- `--out-dir PATH`: write extra Markdown report copies for `run morning` and `run evening`.
|
||||
- `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone.
|
||||
- `--start TIME`: required start time for `generate storm`.
|
||||
- `--end TIME`: required end time for `generate storm`.
|
||||
- `--limit N`: maximum records for `inspect reports`; defaults to `20`, and `0` means no limit.
|
||||
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. For `compare`, `--out-dir PATH` selects one exact
|
||||
bundle directory; otherwise the report-derived comparison directory is placed
|
||||
beneath the configured directory or current directory. `--replace` is required
|
||||
to replace an existing nonempty recognized comparison bundle. See the
|
||||
[configuration reference](config.md) for the field's validation and path rules
|
||||
and the [comparison bundle contract](integrations/comparison-bundle.md) for the
|
||||
bundle format.
|
||||
|
||||
Storm times accept `YYYY-MM-DDTHH:MM` in the configured timezone or RFC3339
|
||||
timestamps with explicit offsets.
|
||||
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.
|
||||
|
||||
Distributor notification is configured only through `notify.distributor`; there
|
||||
are no distributor-specific CLI flags.
|
||||
`SIGINT` and `SIGTERM` cancel an active action. Weatherreporter lets that
|
||||
cancellation reach the action before exiting; when the action has a result, it
|
||||
emits the usual failed summary and exits nonzero. A canceled batch retains any
|
||||
reports that were already published, marks interrupted and unstarted reports
|
||||
as `canceled`, skips batch notification, and identifies cancellation separately
|
||||
from report failures.
|
||||
|
||||
## Common Workflows
|
||||
Action commands (`generate`, `run`, and `compare`) 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`.
|
||||
|
||||
When cancellation stops a batch, the summary also includes a nonzero
|
||||
`canceled` count. Canceled reports have `"status": "canceled"`; they are not
|
||||
included in `failed`, and the action still has failed status and exits nonzero.
|
||||
|
||||
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 canceled=0
|
||||
```
|
||||
|
||||
### Compare Summary
|
||||
|
||||
A comparison summary contains these fields in this order: `command`,
|
||||
`comparisonId`, `reportId`, `reportName`, `promptId`, `promptVersion`,
|
||||
`promptHash`, `status`, `startedAt`, `finishedAt`, `timezone`, `validPeriod`,
|
||||
`outputDirectory`, `manifestPath`, `dataPackagePath`, `total`, `succeeded`,
|
||||
`failed`, `results`, and optional `error`. Published artifact paths and each
|
||||
successful `results[].reportPath` are absolute. `results` preserves the
|
||||
supplied profile order and each item contains `position`, `profileId`, optional
|
||||
`backendId`, `modelName`, `status`, optional `validationStatus`, optional
|
||||
`reportPath`, optional `llmDebugPath`, and optional safe `error`.
|
||||
|
||||
The comparison status is `succeeded` only when every selected profile succeeds
|
||||
and the bundle is published. Individual profile failures still publish a
|
||||
complete partial bundle and return a failed command result. Cancellation or a
|
||||
failure before publication omits the artifact paths and returns a safe
|
||||
top-level error; the resolved `outputDirectory` and finalized timestamp remain
|
||||
when available. The safe error includes only a category and message: aggregate
|
||||
and unclassified application failures use `application`; cancellation uses
|
||||
`canceled`; deadlines use `deadline_exceeded`; prompt execution uses its
|
||||
published Promptkit category; destination failures use `destination_<kind>`;
|
||||
and committed cleanup failures use `publication_cleanup` with a message that
|
||||
states whether a complete prior bundle, partial remnants, or no prior bundle
|
||||
remains, or that recovery state could not be inspected. It does not expose
|
||||
provider diagnostics, filesystem causes, or recovery paths. See the
|
||||
[comparison bundle contract](integrations/comparison-bundle.md) for durable
|
||||
artifact fields and failure invariants.
|
||||
|
||||
If the bundle is published but cleanup of its replaced prior bundle fails, the
|
||||
summary still includes the published artifact paths and has status `failed`.
|
||||
Its JSON error is `publication_cleanup`; the returned command error identifies
|
||||
a recovery path only when cleanup left a sibling behind. Only a reported
|
||||
complete prior bundle is a rollback artifact.
|
||||
|
||||
## Flag Reference
|
||||
|
||||
| Flag | Accepted by | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `-h`, `--help` | top level, `compare` | Show help without loading configuration or contacting a provider. |
|
||||
| `--config PATH` | all commands | Load `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`. |
|
||||
| `--units VALUE` | `generate`, `run`, `compare` | Override `weather_api.units` for this command. |
|
||||
| `--tz NAME` | `generate`, `run`, `compare` | 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`, `run`, and `compare` command | On Unix hosts, write requested sensitive prompt diagnostics under this absolute path. Other hosts fail closed when the flag is requested. |
|
||||
| `--profile PROFILE` | `compare` | Select one explicit profile. Repeat at least twice with distinct, nonblank IDs. |
|
||||
| `--out-dir PATH` | `run morning`, `run evening`, `compare` | Write batch reports beneath this directory, or select the exact comparison directory. |
|
||||
| `--replace` | `compare` | Authorize replacement of a recognized nonempty comparison bundle. |
|
||||
| `--quiet` | `generate`, `run`, `compare` | Suppress all action summaries and routine batch status output. |
|
||||
| `--date YYYY-MM-DD` | `generate daily`, `generate today`, `compare daily`, `compare 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 generate tomorrow --out ./tomorrow.md
|
||||
weatherreporter generate three-day --out ./three-day.md
|
||||
weatherreporter generate weekend --out ./weekend.md
|
||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
|
||||
weatherreporter run morning --out-dir ./reports
|
||||
weatherreporter run evening --out-dir ./reports
|
||||
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
|
||||
weatherreporter compare daily --date 2026-05-29 --profile weather-light --profile weather-balanced --out-dir ./comparison-daily-2026-05-29
|
||||
```
|
||||
|
||||
## Inspection
|
||||
|
||||
```sh
|
||||
weatherreporter inspect reports --limit 10
|
||||
weatherreporter inspect metadata 20260529T100000.000000000Z_daily_today
|
||||
weatherreporter inspect modules 20260529T100000.000000000Z_daily_today
|
||||
weatherreporter inspect data-package 20260529T100000.000000000Z_daily_today
|
||||
weatherreporter inspect prior 20260529T100000.000000000Z_daily_today
|
||||
weatherreporter inspect sources 20260529T100000.000000000Z_daily_today
|
||||
```
|
||||
|
||||
`inspect reports` lists recent generated runs with artifact paths and source
|
||||
warning counts. The other inspect commands require a RunID. `inspect modules`
|
||||
returns the persisted ordered module snapshot for a run. `inspect prior`
|
||||
returns the prior comparable snapshot metadata selected from stored metadata, or
|
||||
`null` when none exists. `inspect sources` shows source provenance and source
|
||||
warnings without dumping full weather payloads.
|
||||
|
||||
369
docs/config.md
369
docs/config.md
@@ -1,234 +1,259 @@
|
||||
# Weatherreporter Configuration
|
||||
|
||||
Configuration is YAML. By default, `weatherreporter` reads:
|
||||
Weatherreporter reads YAML configuration. The default path is:
|
||||
|
||||
```text
|
||||
/usr/local/etc/weatherreporter/config.yml
|
||||
```
|
||||
|
||||
Use `--config PATH` to load a different file. If the default file is absent,
|
||||
built-in defaults are used. If `--config PATH` points to a missing file, loading
|
||||
fails.
|
||||
If the default file is absent, Weatherreporter uses built-in defaults. An
|
||||
explicit `--config PATH` must exist. Values are applied in this order:
|
||||
|
||||
Precedence is:
|
||||
1. built-in defaults;
|
||||
2. the configuration file, when present; and
|
||||
3. the `--units` and `--tz` command-line overrides.
|
||||
|
||||
1. CLI flags
|
||||
2. configuration file
|
||||
3. built-in defaults
|
||||
Environment variables do not override configuration fields. Output flags select
|
||||
operator-owned destinations for one command and do not change configuration.
|
||||
|
||||
The CLI configuration overrides are `--units` and `--tz`. Output flags control
|
||||
report copies for the current command but do not change configuration files.
|
||||
Environment variables do not override configuration fields.
|
||||
## Maintained Examples
|
||||
|
||||
## Minimal Config
|
||||
- [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.
|
||||
|
||||
See [examples/minimal-config.yml](../examples/minimal-config.yml).
|
||||
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/
|
||||
```
|
||||
|
||||
`weather_api.base_url` is required for commands that fetch weather data. Other
|
||||
fields fall back to defaults.
|
||||
|
||||
## Production-Oriented Config
|
||||
|
||||
See [examples/config.yml](../examples/config.yml). The example is loaded by the
|
||||
config test suite.
|
||||
`weather_api.base_url` is required for workflows that collect weather data.
|
||||
All omitted fields use their built-in defaults.
|
||||
|
||||
## Field Reference
|
||||
|
||||
### `weather_api`
|
||||
|
||||
- `base_url`: absolute base URL for the Weather API. Required for generation and fetch workflows.
|
||||
- `timeout`: HTTP timeout duration. Default: `10s`.
|
||||
- `precision`: numeric precision query value. Default: `1`.
|
||||
- `units`: Weather API units query value. Default: `us`.
|
||||
- `timezone`: report timezone and Weather API timezone query value where supported. Default: `America/Chicago`.
|
||||
- `format`: Weather API response format. Must be `json`. Default: `json`.
|
||||
| Field | Default | Rules |
|
||||
| --- | --- | --- |
|
||||
| `base_url` | empty | Absolute HTTP(S) 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`.
|
||||
`Stl`, US timezone abbreviations, or signed UTC offsets such as `-5`, `+0930`,
|
||||
and `+09:30`. Numeric offsets require a sign, one or two hour digits, and an
|
||||
optional two-digit minute component with or without a colon. Hours must be
|
||||
from `00` through `23`, minutes from `00` through `59`, so the largest accepted
|
||||
offset magnitude is `23:59`.
|
||||
|
||||
### `location`
|
||||
|
||||
`location` is descriptive prompt context included in module metadata and
|
||||
Scriptorium data packages. It does not select a Weather API endpoint or enable
|
||||
multiple configured forecast locations.
|
||||
`location` supplies descriptive prompt context; it does not choose a Weather
|
||||
API endpoint or configure multiple forecast locations.
|
||||
|
||||
- `id`: short local identifier. Default: `home`.
|
||||
- `name`: human-readable location name. Default: `Brentwood`.
|
||||
- `region`: broader forecast area context. Default: `St. Louis Metro`.
|
||||
| Field | Default |
|
||||
| --- | --- |
|
||||
| `id` | `home` |
|
||||
| `name` | `Brentwood` |
|
||||
| `region` | `St. Louis Metro` |
|
||||
|
||||
The prompt-facing location object also includes `timezone`, derived from the
|
||||
effective `weather_api.timezone` after CLI overrides such as `--tz`.
|
||||
The prompt-facing location timezone is derived from the effective
|
||||
`weather_api.timezone` after command-line overrides.
|
||||
|
||||
### `secrets`
|
||||
|
||||
- `directory`: optional directory of file-backed environment secrets. Default:
|
||||
empty, which disables secret loading.
|
||||
`secrets.directory` defaults to empty, which disables secret loading. When it
|
||||
is set, every regular file directly in that directory is staged after the file
|
||||
and command-line overrides, then applied only after the complete configuration
|
||||
has validated successfully. A rejected load leaves the existing environment
|
||||
unchanged. 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.
|
||||
|
||||
When configured, each regular file directly under `secrets.directory` is loaded
|
||||
after config file parsing and CLI overrides. The file basename must be a valid
|
||||
environment variable name matching `[A-Za-z_][A-Za-z0-9_]*`; the file contents
|
||||
become the environment variable value and overwrite any existing value. One
|
||||
trailing LF or CRLF is stripped. Subdirectories, symlinks, invalid filenames,
|
||||
missing directories, and unreadable files fail config loading.
|
||||
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.
|
||||
|
||||
### `notify`
|
||||
### `output`
|
||||
|
||||
`notify.distributor` controls distributor notification after successful report
|
||||
generation. It is disabled by default and does not add CLI flags. When enabled,
|
||||
weatherreporter uploads one distributor bundle per generated report after
|
||||
`scriptorium run` succeeds and final metadata is saved.
|
||||
`output.directory` selects the ordinary operator-owned publication directory
|
||||
for individual reports, batches, and the default parent of comparison bundles.
|
||||
|
||||
- `enabled`: whether distributor notification config is active. Default:
|
||||
`false`.
|
||||
- `endpoint`: absolute distributor endpoint URL. Required when enabled.
|
||||
Default: `https://distributor.example.com`.
|
||||
- `token_env`: environment variable name that will contain the distributor
|
||||
upload token. Required when enabled. Default: `DISTRIBUTOR_UPLOAD_TOKEN`.
|
||||
- `timeout`: distributor operation timeout. Must be greater than zero when
|
||||
enabled. Default: `30s`.
|
||||
- `failure_policy`: must be `error` when enabled. Default: `error`.
|
||||
- `pipeline_id_template`: template for the distributor pipeline ID. Required
|
||||
when enabled. Default: empty.
|
||||
- `bundle_id_template`: template for distributor bundle IDs. Default:
|
||||
`weatherreporter.{location_id}.{report_id}`.
|
||||
- `idempotency_key_template`: template for distributor idempotency keys.
|
||||
Default: `{bundle_id}.{run_id}`.
|
||||
- `report_path_templates`: ordered list of templates for Markdown report paths
|
||||
inside the distributor bundle. Each rendered path maps to the same managed
|
||||
Markdown report source. Default:
|
||||
```yaml
|
||||
- "{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md"
|
||||
```
|
||||
| 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. |
|
||||
|
||||
Supported template variables are `location_id`, `report_id`, `run_id`,
|
||||
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.
|
||||
For `compare`, `--out-dir` selects its exact bundle directory; without it, the
|
||||
comparison's report-derived directory is placed beneath `output.directory`.
|
||||
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 HTTP(S) base URL with a host and no userinfo, query, or fragment. A path prefix is allowed. |
|
||||
| `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.
|
||||
|
||||
When notification is enabled, Weatherreporter validates the Distributor endpoint
|
||||
before prompt inspection, weather collection, or output publication. Use an
|
||||
HTTP(S) base URL such as `https://distributor.example.com/archive`; do not put
|
||||
credentials, a query string, or a fragment in the endpoint.
|
||||
|
||||
When notification is enabled, each rendered single-report pipeline ID, bundle
|
||||
ID, and idempotency key must contain at least one non-whitespace character.
|
||||
|
||||
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`, and
|
||||
`valid_end_stamp`. Date values use `YYYY-MM-DD`, time values use `HHMM`, and
|
||||
stamp values use `YYYY-MM-DDTHHMM` in the effective report timezone.
|
||||
`pipeline_id_template` and `idempotency_key_template` may also use `bundle_id`.
|
||||
`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.
|
||||
|
||||
The rendered pipeline ID selects the configured distributor `http_upload`
|
||||
workflow. The rendered bundle ID is the stable logical source identity for the
|
||||
report stream. The rendered idempotency key is the per-run retry identity.
|
||||
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.
|
||||
|
||||
Rendered report paths must be unique relative paths with `/` separators. They
|
||||
must not contain backslashes, empty path segments, `.`, `..`, `manifest.json`,
|
||||
or `.distributor.json`.
|
||||
`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:
|
||||
|
||||
The upload token is read from the environment variable named by `token_env`
|
||||
after config loading and `secrets.directory` processing. Config files should
|
||||
name the variable only; they should not contain the token value.
|
||||
| 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`
|
||||
|
||||
- `default`: missing-source behavior for optional sources. One of `error`, `warn`, or `none`. Default: `warn`.
|
||||
- `sources`: optional map of source-specific overrides, using the same policy values.
|
||||
`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 and cannot have a
|
||||
source-specific policy. Supported optional source keys are `observations`,
|
||||
`current`, `narrative`, `alerts`, `discussion`, `weather_story`, and
|
||||
`spc_convective_outlooks`; any other key is rejected.
|
||||
|
||||
Hourly forecast data is required for generated reports. Optional sources use
|
||||
the missing-source policy.
|
||||
### `promptkit`
|
||||
|
||||
### `scriptorium`
|
||||
Promptkit configuration selects the executor and prompt/profile checks for
|
||||
every `generate`, `run`, and `compare` command. A top-level `scriptorium:` configuration
|
||||
key is rejected with a migration error; it is not translated or ignored.
|
||||
|
||||
- `binary`: `scriptorium` executable name or path. Default: `scriptorium`.
|
||||
- `config_path`: optional Scriptorium config path passed to the adapter.
|
||||
- `profile`: optional Scriptorium profile passed to the adapter.
|
||||
- `timeout`: subprocess timeout. Default: `2m`.
|
||||
- `extra_args`: optional additional arguments passed to Scriptorium commands.
|
||||
Prompt debug capture has no YAML setting. Use `--llm-debug-dir PATH` on an
|
||||
individual `generate`, `run`, or `compare` command when explicitly needed.
|
||||
See [optional prompt debug capture](operations.md#optional-prompt-debug-capture)
|
||||
for platform availability, security, and retention requirements.
|
||||
|
||||
### `workspace`
|
||||
| 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. |
|
||||
|
||||
- `root`: workspace root for managed artifacts. Default: `workspace`.
|
||||
- `snapshots_dir`: module snapshot and metadata directory under `workspace.root`. Default: `snapshots`.
|
||||
- `reports_dir`: managed Markdown report directory under `workspace.root`. Default: `reports`.
|
||||
- `data_packages_dir`: prompt input package directory under `workspace.root`. Default: `data-packages`.
|
||||
- `preflight_dir`: Scriptorium render output directory under `workspace.root`. Default: `preflight`.
|
||||
- `notifications_dir`: distributor notification debug artifact directory under `workspace.root`. Default: `notifications`.
|
||||
`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.
|
||||
|
||||
Workspace subdirectories must be relative paths that stay inside
|
||||
`workspace.root`.
|
||||
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 list of named local-time windows used by forecast derivation.
|
||||
Each entry has:
|
||||
`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`).
|
||||
|
||||
- `name`
|
||||
- `start`
|
||||
- `end`
|
||||
|
||||
`start` and `end` use `HH:MM`. The default entries are overnight, morning,
|
||||
midday, afternoon, and evening.
|
||||
|
||||
### `recent_change`
|
||||
|
||||
- `temperature_degrees`: temperature change threshold. Default: `5`.
|
||||
- `precip_probability_points`: precipitation probability threshold. Default: `20`.
|
||||
- `wind_gust_miles_per_hour`: wind gust change threshold. Default: `10`.
|
||||
- `precip_timing_shift_minutes`: precipitation timing shift threshold. Default: `120`.
|
||||
|
||||
Recent Changes are added to prompt input when a prior comparable module
|
||||
snapshot exists and a threshold is crossed.
|
||||
Names remain display text, but each name must have a distinct canonical
|
||||
identity. Canonicalization trims whitespace, lowercases letters, and collapses
|
||||
punctuation and whitespace to underscores; for example, `Morning`,
|
||||
`morning!`, and `morning` conflict. Planning recognizes the canonical
|
||||
identities `morning`, `afternoon`, `evening`, and `overnight` regardless of
|
||||
their display capitalization or punctuation.
|
||||
|
||||
### `reports`
|
||||
|
||||
`reports` optionally overrides the ordered deterministic modules declared by
|
||||
report definitions. Omit a report entry to use its default module order.
|
||||
`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`, `tomorrow`, `three_day`, `weekend`, and
|
||||
`storm`. Canonical report IDs such as `daily_today` and `daily_tomorrow` are
|
||||
also accepted.
|
||||
Supported report keys are `daily`, `today`, `tomorrow`, and `hourly`. Keys are
|
||||
trimmed, case-folded to lowercase, and normalize hyphens to underscores before
|
||||
lookup.
|
||||
|
||||
Each report entry supports:
|
||||
Each report entry can contain:
|
||||
|
||||
- `deterministic_modules`: ordered module list. Entries may be string module
|
||||
IDs or objects with `id` and optional `options`.
|
||||
- `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.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
reports:
|
||||
daily:
|
||||
deterministic_modules:
|
||||
- metadata
|
||||
- current_conditions
|
||||
- narrative_forecast
|
||||
- id: area_forecast_discussion
|
||||
options:
|
||||
sections:
|
||||
- short_term
|
||||
- hourly_forecast
|
||||
```
|
||||
|
||||
Unknown reports, unknown modules, duplicate modules, incompatible report/module
|
||||
combinations, duplicate stanza names, and invalid options fail config loading.
|
||||
`area_forecast_discussion.options.sections` may contain `product`,
|
||||
`key_messages`, `short_term`, and `long_term`. Empty or omitted `sections`
|
||||
includes all available AFD sections.
|
||||
|
||||
The module registry accepts all module IDs documented in
|
||||
[Module Contract Internals](internal/module.md). Unknown or unimplemented
|
||||
module IDs fail validation instead of being skipped.
|
||||
|
||||
## Secrets
|
||||
|
||||
Configuration files should not contain raw secrets. Use `secrets.directory` to
|
||||
load secret values from files into environment variables for integrations that
|
||||
read credentials from the environment. Secret file names become environment
|
||||
variable names, and secret file contents become values. For distributor
|
||||
notification, this allows a file such as
|
||||
`<secrets.directory>/DISTRIBUTOR_UPLOAD_TOKEN` to supply the token referenced by
|
||||
`notify.distributor.token_env`.
|
||||
|
||||
## Maintained Examples
|
||||
|
||||
- [examples/minimal-config.yml](../examples/minimal-config.yml): smallest
|
||||
useful config for generation and fetching.
|
||||
- [examples/config.yml](../examples/config.yml): production-oriented config
|
||||
covering maintained fields.
|
||||
|
||||
Both example files are loaded by the config test suite.
|
||||
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).
|
||||
|
||||
88
docs/development.md
Normal file
88
docs/development.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# 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, comparison, collection, output publication, or notification workflow | [App orchestration internals](internal/app-orchestration.md), [comparison execution internals](internal/comparison-execution.md), and [comparison publication internals](internal/comparison-publication.md) | They own workflow ordering, concurrent profile execution, 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, prepared report inputs, or result handling | `internal/promptexec`, the Promptkit adapter, [prepared report internals](internal/prepared-report.md), and [prompt-input internals](internal/prompt-input.md) | These separate the executor contract, immutable preparation, and input construction. |
|
||||
| Durable comparison bundles or their compatibility | [Comparison bundle contract](integrations/comparison-bundle.md) and [comparison publication internals](internal/comparison-publication.md) | The integration document owns the external schema; internals own how it is published. |
|
||||
| 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), [App orchestration internals](internal/app-orchestration.md), and [comparison publication internals](internal/comparison-publication.md) | Operations owns operator workflows; internals own implementation boundaries. |
|
||||
| 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, comparisons, collection coordination, output publication, and notification. |
|
||||
| `internal/comparison` | Comparison identities, logical bundles, guarded destinations, and atomic bundle publication. |
|
||||
| `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.
|
||||
103
docs/integrations/comparison-bundle.md
Normal file
103
docs/integrations/comparison-bundle.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Comparison Bundle Contract
|
||||
|
||||
A comparison bundle is the durable, flat artifact produced when one report is
|
||||
executed with multiple explicit Promptkit profiles. This document is the
|
||||
canonical contract for consumers of those bundles. Command invocation and JSON
|
||||
action summaries belong to the [CLI reference](../cli.md); destination handling
|
||||
and retention belong to the [operations guide](../operations.md).
|
||||
|
||||
## Version And Layout
|
||||
|
||||
The current and only supported manifest schema version is
|
||||
`weatherreporter.comparison.v1`. A bundle directory contains exactly these
|
||||
regular, non-symlinked files:
|
||||
|
||||
```text
|
||||
comparison.json
|
||||
data-package.yml
|
||||
NN-profile-slug.md
|
||||
```
|
||||
|
||||
`comparison.json` is the manifest and `data-package.yml` is the exact YAML
|
||||
input supplied to every selected profile. There is one Markdown file for each
|
||||
successful result and none for failed results. `NN` is the one-based selected
|
||||
profile position, zero padded to at least two digits (and widened only when
|
||||
needed for 100 or more profiles). The profile slug preserves ASCII letters,
|
||||
digits, `-`, and `_`; each run of other characters becomes one `-`; edge `-`
|
||||
and `_` characters are removed; the value is capped at 64 bytes; and an empty
|
||||
slug becomes `profile`. Logical profile IDs remain authoritative in the
|
||||
manifest.
|
||||
|
||||
All manifest paths are basenames relative to the bundle root. They never use
|
||||
path separators, `.` or `..`. The CLI reports absolute paths only after a
|
||||
bundle has been published.
|
||||
|
||||
## Manifest Schema
|
||||
|
||||
The manifest is UTF-8 JSON, encoded as two-space-indented JSON with one
|
||||
trailing newline. Its fields appear in this order:
|
||||
|
||||
```text
|
||||
schemaVersion, comparisonId, startedAt, finishedAt, reportId, validPeriod,
|
||||
timezone, promptId, promptVersion, promptHash, dataPackage, total, succeeded,
|
||||
failed, results
|
||||
```
|
||||
|
||||
`validPeriod` contains `start` and `end`; it is a nonempty half-open period.
|
||||
`dataPackage` contains `path` (always `data-package.yml`) and `sha256` (the
|
||||
lowercase, 64-character SHA-256 digest of that file's exact bytes). `results`
|
||||
is in the explicit profile-selection order. Its result-object fields appear in
|
||||
this order:
|
||||
|
||||
```text
|
||||
position, profileId, backendId, modelName, status, validationStatus,
|
||||
reportPath, error
|
||||
```
|
||||
|
||||
`startedAt` and `finishedAt` are nonzero UTC timestamps, and the latter is not
|
||||
earlier than the former. `validPeriod` retains its resolved time offset.
|
||||
`reportId`, `timezone`, prompt identity, model name, and comparison ID are
|
||||
nonblank. `promptHash` and `dataPackage.sha256` are lowercase SHA-256 digests.
|
||||
|
||||
## Result Invariants
|
||||
|
||||
`total` is at least two and equals the number of results. Positions are
|
||||
contiguous from one, profile IDs are distinct and nonblank, and
|
||||
`succeeded + failed == total`.
|
||||
|
||||
A successful result has `status: "succeeded"`, `validationStatus: "passed"`,
|
||||
a `reportPath` exactly equal to the canonical `NN-profile-slug.md` filename for
|
||||
its position, total, and logical profile ID, and no `error`. A failed result has
|
||||
`status: "failed"`, no `reportPath`, and an `error` object with nonblank
|
||||
`category` and `message`. Its validation status is absent, `failed`, or
|
||||
`skipped`. Error messages are valid UTF-8 and no longer than 1,024 bytes.
|
||||
`backendId` and `validationStatus` are omitted when unavailable.
|
||||
|
||||
Every successful Markdown file is declared by exactly one successful result.
|
||||
The directory contains no extra entries. Consumers can therefore verify the
|
||||
data-package digest and the full manifest-to-file mapping without scanning a
|
||||
larger workspace.
|
||||
|
||||
## Compatibility And Sensitivity
|
||||
|
||||
Weatherreporter recognizes a replaceable bundle only when it exactly satisfies
|
||||
the current version, schema, file set, file types, relative-path rules, and
|
||||
data-package digest. JSON field names are case-sensitive canonical names and a
|
||||
field may appear only once in each manifest object. It rejects unknown,
|
||||
case-variant, or duplicate fields; multiple JSON values; extra entries;
|
||||
symlinks; and future or otherwise unsupported versions. Treat a bundle that
|
||||
fails recognition as an ordinary directory, not as a compatible bundle.
|
||||
|
||||
When replacing a recognized bundle, cancellation observed before the new
|
||||
bundle is installed preserves the prior bundle rather than committing the
|
||||
replacement.
|
||||
|
||||
Cleanup of a prior bundle occurs only after its replacement is committed and
|
||||
does not affect the new bundle's compatibility. A cleanup error may identify a
|
||||
complete recovery bundle, partial remnants, no remaining sibling, or an
|
||||
uninspectable state; this operational state is not recorded in the manifest.
|
||||
|
||||
The manifest contains safe operational provenance, but `data-package.yml` and
|
||||
the generated Markdown can contain sensitive weather or location context. Do
|
||||
not assume these artifacts are safe for public distribution. Handle retention,
|
||||
access, and deletion according to the [operations guide](../operations.md).
|
||||
@@ -1,136 +1,77 @@
|
||||
# Upstream Producer Integration
|
||||
# Distributor HTTP Upload Contract
|
||||
|
||||
Audience: developers and LLM coding agents adding `distributor` support to an upstream Go producer application.
|
||||
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).
|
||||
|
||||
This document is the copyable implementation guide for submitting producer outputs to a `distributor` pipeline whose source backend is `http_upload`.
|
||||
## Upload Admission
|
||||
|
||||
## Required Inputs
|
||||
Weatherreporter uses an absolute HTTP(S) endpoint with a host as a base URL.
|
||||
It allows a path prefix but rejects userinfo, query strings, and fragments
|
||||
before local report work begins. The client posts a gzip-compressed source
|
||||
bundle to:
|
||||
|
||||
The upstream application needs these values from deployment or operator configuration:
|
||||
|
||||
- distributor endpoint: the HTTP server base URL, such as `https://distributor.example.com`;
|
||||
- upload token: bearer token that authenticates the producer;
|
||||
- pipeline id: configured `http_upload` pipeline that should process this upload;
|
||||
- generated files: regular local files to include in the source bundle;
|
||||
- bundle id: stable identifier for the logical report stream or artifact;
|
||||
- idempotency key: unique key for one producer run, reused only when retrying that same run.
|
||||
|
||||
Do not put destination routing, public URLs, transform settings, or credentials in the source manifest. Those belong in the `distributor` pipeline configuration.
|
||||
|
||||
The token, pipeline id, bundle id, and idempotency key have different jobs. The token authenticates the producer. The pipeline id selects the configured distributor workflow, including destinations and publishing policy. The bundle id tells `distributor` whether a new upload is a newer version of the same source; keep it stable across runs that should replace the same managed destination artifact. The idempotency key tells `distributor` whether an upload request is a retry; change it for each distinct producer run so new content is enqueued.
|
||||
|
||||
## Recommended Workflow
|
||||
|
||||
Use `gitea.maximumdirect.net/eric/distributor/pkg/upload`.
|
||||
|
||||
For most producers, use `UploadFiles`. It accepts producer-generated files, builds a temporary valid source bundle with `pkg/bundle`, uploads a gzip-compressed tar archive, and removes temporary files when the call returns.
|
||||
|
||||
Use `UploadBundle` only when the producer already assembled a complete bundle directory containing `manifest.json`.
|
||||
|
||||
Add the dependency from the upstream application:
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/distributor
|
||||
```text
|
||||
POST /v1/pipelines/<pipeline_id>/upload
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/gzip
|
||||
Idempotency-Key: <key>
|
||||
```
|
||||
|
||||
## Minimal Go Example
|
||||
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.
|
||||
|
||||
```go
|
||||
package reports
|
||||
The adapter requires nonblank pipeline ID, bundle ID, and idempotency key, plus
|
||||
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).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
Weatherreporter reads at most 1 MiB from each Distributor response. An
|
||||
oversized response fails notification with a stable local diagnostic. Normal
|
||||
Weatherreporter results retain upload and status identity but do not repeat
|
||||
Distributor response bodies, status reports, or remote error text.
|
||||
|
||||
"gitea.maximumdirect.net/eric/distributor/pkg/bundle"
|
||||
"gitea.maximumdirect.net/eric/distributor/pkg/upload"
|
||||
)
|
||||
## Idempotency
|
||||
|
||||
func SubmitReport(reportPath, summaryPath string) error {
|
||||
endpoint := os.Getenv("DISTRIBUTOR_UPLOAD_ENDPOINT")
|
||||
token := os.Getenv("DISTRIBUTOR_UPLOAD_TOKEN")
|
||||
if endpoint == "" || token == "" {
|
||||
return fmt.Errorf("distributor endpoint and token are required")
|
||||
}
|
||||
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.
|
||||
|
||||
pipelineID := "weather-hourly"
|
||||
reportID := "weather.hourly.brentwood"
|
||||
runID := time.Now().UTC().Format("20060102T150405.000000000Z")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
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.
|
||||
|
||||
client, err := upload.NewClient(upload.ClientOptions{
|
||||
Endpoint: endpoint,
|
||||
Token: token,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
## Run Status And Retention
|
||||
|
||||
result, err := client.UploadFiles(ctx, upload.UploadFilesOptions{
|
||||
PipelineID: pipelineID,
|
||||
ID: reportID,
|
||||
IdempotencyKey: reportID + "." + runID,
|
||||
Files: []bundle.BundleFile{
|
||||
{SourcePath: reportPath, Path: "report.md"},
|
||||
{SourcePath: summaryPath, Path: "summary.txt"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
var conflict *upload.IdempotencyConflictError
|
||||
if errors.As(err, &conflict) {
|
||||
return fmt.Errorf("idempotency key was reused for different bundle content: %w", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
After acceptance, Weatherreporter reads:
|
||||
|
||||
fmt.Printf("distributor accepted run %s\n", result.RunID)
|
||||
return nil
|
||||
}
|
||||
```text
|
||||
GET /runs/<run_id>
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
## Producer Responsibilities
|
||||
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.
|
||||
|
||||
- Use a stable bundle id for the logical producer output that should replace the same destination artifact, such as `weather.hourly.brentwood`.
|
||||
- Set `PipelineID` to the configured upload pipeline that should process the bundle.
|
||||
- Do not include per-run timestamps, random values, or job ids in the bundle id unless each run should be treated as a different source.
|
||||
- Use an idempotency key that changes for every distinct producer run, such as `<bundle-id>.<run-id>`.
|
||||
- Reuse the same idempotency key only when retrying the exact same producer run with the same source manifest.
|
||||
- Map each generated file to a clean slash-separated bundle path, such as `report.md` or `assets/chart.png`.
|
||||
- Include only regular files. Symlinks, directories as files, devices, FIFOs, and sockets are rejected.
|
||||
- Keep file contents stable after upload inputs are selected. Bundle digests are calculated from file bytes.
|
||||
- Treat upload success as admission only. `UploadFiles` and `UploadBundle` return after the server accepts and validates the upload, not after all destinations publish.
|
||||
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).
|
||||
|
||||
Valid bundle paths are relative slash paths. They must not be empty, absolute, contain backslashes, contain `.` or `..` path segments, contain empty path segments, or use reserved basenames `manifest.json` or `.distributor.json`.
|
||||
## Compatibility Reference
|
||||
|
||||
## Idempotency And Status
|
||||
|
||||
`pkg/upload` sends `Idempotency-Key` on every upload. If the caller omits one, the package generates a random key for that call and reuses it for in-process retries. That is enough for transient network retry within one process, but it does not give cross-process retry identity.
|
||||
|
||||
For producer jobs that may retry after process restart, supply a key derived from the producer run, such as `<bundle-id>.<run-id>`. Reusing the same key with the same token, pipeline id, and normalized source manifest returns the original accepted run. Reusing the same key with different source content in that scope returns a conflict. Reusing one key across multiple distinct report generations prevents those generations from being treated as new uploads.
|
||||
|
||||
`Status` polls `/runs/<run-id>` while the distributor server retains the in-memory status record. Status values are `accepted`, `queued`, `running`, `succeeded`, and `failed`. Completed records expire according to the server's `server.http.retention` setting, and server restart clears status and idempotency records.
|
||||
|
||||
Optional status check:
|
||||
|
||||
```go
|
||||
status, err := client.Status(ctx, result.RunID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status.Status == "failed" {
|
||||
return fmt.Errorf("distributor run failed: %s", status.Error)
|
||||
}
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
In the `distributor` source tree:
|
||||
|
||||
- `docs/consumers/pkg-upload.md`: Go upload package workflow.
|
||||
- `docs/consumers/pkg-bundle.md`: Go bundle package workflow.
|
||||
- `docs/integrations/http-upload.md`: canonical HTTP upload wire contract.
|
||||
- `docs/integrations/source-bundle.md`: canonical source bundle file-format contract.
|
||||
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.
|
||||
|
||||
@@ -1,90 +1,36 @@
|
||||
# `pkg/bundle`
|
||||
# Distributor Source Bundle Mapping
|
||||
|
||||
Audience: upstream Go producer developers and LLM coding agents using `distributor` source bundle helpers.
|
||||
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.
|
||||
|
||||
Import path:
|
||||
## File Mappings
|
||||
|
||||
```go
|
||||
import "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
|
||||
```
|
||||
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.
|
||||
|
||||
`pkg/bundle` builds, writes, parses, and validates local source bundles. Use it directly when a producer writes bundles for `distributor` to discover, or when a producer wants to assemble and validate a bundle before using another transport.
|
||||
The 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.
|
||||
|
||||
The canonical source bundle file-format contract is [Source Bundle Contract](../integrations/source-bundle.md).
|
||||
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.
|
||||
|
||||
## Preferred Complete-Bundle Workflow
|
||||
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.
|
||||
|
||||
Use `WriteBundle` when producer-generated files live outside the final bundle root.
|
||||
## Compatibility Reference
|
||||
|
||||
```go
|
||||
manifest, err := bundle.WriteBundle(bundle.WriteBundleOptions{
|
||||
Root: "/var/spool/distributor/weather/hourly-2026-06-07T15",
|
||||
ID: "weather.hourly.brentwood",
|
||||
Files: []bundle.BundleFile{
|
||||
{SourcePath: "/tmp/weather/report.md", Path: "report.md"},
|
||||
{SourcePath: "/tmp/weather/summary.txt", Path: "summary.txt"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = manifest
|
||||
```
|
||||
|
||||
`WriteBundle` copies each source file into a staged bundle root, writes `manifest.json`, validates the staged bundle, and promotes it into place. Set `Overwrite: true` only when the producer intentionally replaces an existing bundle root.
|
||||
|
||||
## Existing Bundle Root Workflow
|
||||
|
||||
Use `BuildManifest` and `WriteManifest` when files are already staged under the final bundle root.
|
||||
|
||||
```go
|
||||
root := "/var/spool/distributor/weather/hourly-2026-06-07T15"
|
||||
manifest, err := bundle.BuildManifest(bundle.BuildOptions{
|
||||
Root: root,
|
||||
ID: "weather.hourly.brentwood",
|
||||
Files: []string{"report.md", "summary.txt"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bundle.WriteManifest(root, manifest, bundle.WriteManifestOptions{}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bundle.ValidateBundle(root, manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
Use `Scan: true` instead of `Files` only when every valid regular file under the root should be included. Scan mode includes dotfiles, skips reserved metadata files, rejects symlinks, and sorts paths lexically.
|
||||
|
||||
## Paths And Ordering
|
||||
|
||||
Bundle paths are slash-separated paths relative to the bundle root.
|
||||
|
||||
Invalid paths include:
|
||||
|
||||
- empty paths;
|
||||
- absolute paths;
|
||||
- paths containing backslashes;
|
||||
- `.` or `..` path segments;
|
||||
- empty path segments;
|
||||
- any basename of `manifest.json` or `.distributor.json`.
|
||||
|
||||
Explicit file lists preserve caller order. File order is part of the bundle digest, so producers should choose it deliberately and keep it stable.
|
||||
|
||||
The manifest `ID` is the logical source identity used by `distributor` destination comparison. Keep it stable for runs that should replace the same managed destination artifact. If every run uses a different manifest `ID`, `distributor` treats those runs as different sources and may report a destination conflict instead of replacing older output.
|
||||
|
||||
## Validation And Digest Helpers
|
||||
|
||||
Use `ValidateBundle` before handing an existing local bundle to another process. It verifies manifest semantics, file existence, regular-file type, file size, per-file SHA-256 digests, and bundle digest.
|
||||
|
||||
Useful helpers:
|
||||
|
||||
- `LoadManifest`: read `manifest.json` from a bundle root.
|
||||
- `ParseManifest` and `MarshalManifest`: parse or write manifest bytes.
|
||||
- `ValidateManifest`: validate manifest-only semantics.
|
||||
- `FileDigest`, `BundleDigest`, and `ValidateDigest`: digest helpers for diagnostics and tests.
|
||||
|
||||
## Boundaries
|
||||
|
||||
`pkg/bundle` does not upload bundles, publish destinations, transform Markdown, select pipelines, configure credentials, or write destination state. Those concerns belong to `pkg/upload` or the `distributor` application.
|
||||
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.
|
||||
|
||||
@@ -1,122 +1,56 @@
|
||||
# `pkg/upload`
|
||||
# Distributor Upload Client Contract
|
||||
|
||||
Audience: upstream Go producer developers and LLM coding agents submitting bundles to `distributor serve`.
|
||||
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.
|
||||
|
||||
Import path:
|
||||
## Client And Upload
|
||||
|
||||
```go
|
||||
import "gitea.maximumdirect.net/eric/distributor/pkg/upload"
|
||||
```
|
||||
The adapter constructs the client with the prevalidated HTTP(S) endpoint,
|
||||
bearer token, and an HTTP client whose timeout is the configured Distributor
|
||||
timeout. The endpoint may include a path prefix but never userinfo, a query, or
|
||||
a fragment. It passes no custom retry options, so the pinned client's defaults
|
||||
apply: three attempts, 100 ms base delay, and one-second maximum delay.
|
||||
The adapter bounds every response to 1 MiB before handing it to the pinned
|
||||
client. A response above that boundary is rejected as a local overflow rather
|
||||
than decoding or retaining a prefix.
|
||||
|
||||
`pkg/upload` is the producer-facing HTTP upload client. It builds on `pkg/bundle`, packages valid source bundles as gzip-compressed tar archives, sends bearer authentication, routes uploads to a configured pipeline, includes idempotency keys, and exposes a status polling helper.
|
||||
For each notification, Weatherreporter calls `UploadFiles` with:
|
||||
|
||||
`UploadFiles` examples also use:
|
||||
- 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.
|
||||
|
||||
```go
|
||||
import "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
|
||||
```
|
||||
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.
|
||||
|
||||
The canonical HTTP wire contract is [HTTP Upload API Contract](../integrations/http-upload.md).
|
||||
## Retry, Conflict, And Status
|
||||
|
||||
## Client Construction
|
||||
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.
|
||||
|
||||
```go
|
||||
client, err := upload.NewClient(upload.ClientOptions{
|
||||
Endpoint: "https://distributor.example.com",
|
||||
Token: token,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
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.
|
||||
|
||||
`Endpoint` is the distributor server base URL. The client derives `/v1/pipelines/<pipeline-id>/upload` and `/runs/<run-id>`. `Token` is required and is sent as `Authorization: Bearer <token>`. Token values are redacted from client errors.
|
||||
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. Normal diagnostics use local status classifications; they
|
||||
do not expose remote response text or the status report. 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).
|
||||
|
||||
`HTTPClient` and `Retry` are optional. Defaults use a 30 second HTTP timeout and safe retry settings.
|
||||
## Compatibility Reference
|
||||
|
||||
## Upload Producer Files
|
||||
|
||||
Use `UploadFiles` when the producer has generated output files but has not assembled a bundle directory.
|
||||
|
||||
```go
|
||||
result, err := client.UploadFiles(ctx, upload.UploadFilesOptions{
|
||||
PipelineID: "weather-hourly",
|
||||
ID: "weather.hourly.brentwood",
|
||||
IdempotencyKey: "weather.hourly.brentwood.20260607T150000Z",
|
||||
Files: []bundle.BundleFile{
|
||||
{SourcePath: "/tmp/weather/report.md", Path: "report.md"},
|
||||
{SourcePath: "/tmp/weather/summary.txt", Path: "summary.txt"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = result.RunID
|
||||
```
|
||||
|
||||
`PipelineID` is required and selects the configured distributor workflow for this upload. `ID` is the source manifest id and identifies the logical artifact inside that workflow. `UploadFiles` creates a temporary bundle, writes and validates a manifest, uploads the archive, and removes temporary files when the call returns. It does not write into producer source directories.
|
||||
|
||||
## Upload An Existing Bundle
|
||||
|
||||
Use `UploadBundle` when the producer already has a complete local bundle root containing `manifest.json`.
|
||||
|
||||
```go
|
||||
result, err := client.UploadBundle(ctx, upload.UploadBundleOptions{
|
||||
PipelineID: "weather-hourly",
|
||||
Root: "/var/spool/weather/hourly-2026-06-07T15",
|
||||
IdempotencyKey: "weather.hourly.brentwood.20260607T150000Z",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = result.RunID
|
||||
```
|
||||
|
||||
`PipelineID` is required for existing bundles too. `UploadBundle` validates the local bundle by default and uploads only `manifest.json` plus manifest-listed files. Unlisted files are not uploaded.
|
||||
|
||||
## Result And Status
|
||||
|
||||
Upload success means the server returned `202 Accepted` after staging and validating the upload. It does not mean all configured destinations have published.
|
||||
|
||||
Poll status while the server retains the in-memory run record:
|
||||
|
||||
```go
|
||||
status, err := client.Status(ctx, result.RunID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status.Status == "failed" {
|
||||
return fmt.Errorf("distributor run failed: %s", status.Error)
|
||||
}
|
||||
```
|
||||
|
||||
Status values are `accepted`, `queued`, `running`, `succeeded`, and `failed`. Completed records expire according to `server.http.retention`; server restart clears run status and idempotency records.
|
||||
|
||||
## Idempotency And Retry
|
||||
|
||||
Every upload request includes `Idempotency-Key`.
|
||||
|
||||
If `IdempotencyKey` is omitted, the client generates a random 128-bit lowercase hexadecimal key for that upload operation and reuses it for retries within the same call. For cross-process retry safety, producers should pass a key derived from the producer run, such as `<bundle-id>.<run-id>`.
|
||||
|
||||
Do not reuse the same idempotency key for multiple distinct report generations. Reuse it only when retrying the exact same run with the same token, pipeline id, and source manifest. A repeated key with the same manifest in that scope returns the original accepted run instead of enqueueing another run; a repeated key with different content returns an idempotency conflict.
|
||||
|
||||
The client retries only safe cases:
|
||||
|
||||
- `503 Service Unavailable`;
|
||||
- temporary network errors;
|
||||
- ambiguous mid-upload failures.
|
||||
|
||||
It does not retry after `202 Accepted` and does not retry `400`, `401`, `403`, `404`, `409`, `413`, or `415`.
|
||||
|
||||
Detect conflicting key reuse with `errors.As`:
|
||||
|
||||
```go
|
||||
var conflict *upload.IdempotencyConflictError
|
||||
if errors.As(err, &conflict) {
|
||||
return fmt.Errorf("idempotency key was reused for different bundle content: %w", err)
|
||||
}
|
||||
```
|
||||
|
||||
## Boundaries
|
||||
|
||||
`pkg/upload` does not configure server pipelines, choose destinations, wait for publication completion automatically, persist client queues, provide durable idempotency across server restarts, or expose destination state. It submits complete source bundles to the configured HTTP upload API.
|
||||
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.
|
||||
|
||||
60
docs/integrations/promptkit.md
Normal file
60
docs/integrations/promptkit.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# 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 report's exact generated-text report/schema/template catalog binding, prompt version and hash, output contract, and selected profile. Active profiles must resolve a nonblank backend and model identity. 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. Before accepting that JSON, Weatherreporter requires exactly one preparation callback and reconciles its prompt/profile/backend/model and rendered/input hashes with the inspected identity and completed result. The callback output contract and completed validation must use the report's expected JSON Schema mode and path. The package contains only reviewed prompt-facing warning summaries, never source transport or provenance details. Safe active provenance remains in memory. Content-rich diagnostics are opt-in through `--llm-debug-dir`; see [operations](../operations.md) for retention and permissions.
|
||||
|
||||
When capture is enabled, its preparation artifact projects a provider endpoint
|
||||
to its scheme and host and retains only reviewed execution settings. Provider
|
||||
extras and URL user information, paths, queries, and fragments are omitted.
|
||||
Capture storage remains confined to the operator-selected debug root; an unsafe
|
||||
filesystem path causes the requested execution to fail. Host availability and
|
||||
operator handling are documented in the
|
||||
[operations guide](../operations.md#optional-prompt-debug-capture).
|
||||
|
||||
## Comparison Execution
|
||||
|
||||
For `compare`, Weatherreporter validates the report's generated-text catalog
|
||||
binding, one exact prompt, and every explicitly selected profile before weather
|
||||
collection. It prepares one deterministic YAML
|
||||
data package, retains immutable copies of the report inputs, and executes every
|
||||
profile against the same exact data-package bytes. Each profile remains an
|
||||
independent Promptkit execution: one provider, provenance, or validation failure does not
|
||||
stop its peers, while caller cancellation applies to every in-flight execution.
|
||||
|
||||
Weatherreporter starts selected profile executions concurrently and does not
|
||||
add an application-level concurrency limit. Promptkit owns backend capacity and
|
||||
any profile or backend concurrency policy. A shared Weatherreporter executor
|
||||
must safely accept those concurrent `Execute` calls. The durable comparison output and
|
||||
its compatibility rules are defined by the
|
||||
[comparison bundle contract](comparison-bundle.md); the user-facing command
|
||||
contract is in the [CLI reference](../cli.md).
|
||||
|
||||
The generated-text schemas require `summary`, `forecast_discussion`, and `precipitation_timing`, and reject additional properties. Promptkit results are accepted only when their raw JSON is at most 64 KiB; the adapter drops larger results before copying them into Weatherreporter's execution values or debug artifacts. The validator also limits total generated prose to 20,000 characters, with 4,000-character summary and timing fields, a 12,000-character Hourly discussion, and at most 12 day-style paragraphs of 4,000 characters each. 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,98 +0,0 @@
|
||||
# Scriptorium Integration
|
||||
|
||||
This document describes the external Scriptorium CLI contract used by
|
||||
`weatherreporter`.
|
||||
|
||||
## Purpose
|
||||
|
||||
`weatherreporter` invokes Scriptorium as a subprocess to preflight prompt input
|
||||
and generate Markdown reports. This page documents the CLI surface the adapter
|
||||
uses, not the full Scriptorium product.
|
||||
|
||||
## Commands Used
|
||||
|
||||
Render preflight:
|
||||
|
||||
```bash
|
||||
scriptorium render \
|
||||
--prompt <prompt_id> \
|
||||
--input data_package=<path> \
|
||||
--format json
|
||||
```
|
||||
|
||||
Report generation:
|
||||
|
||||
```bash
|
||||
scriptorium run \
|
||||
--prompt <prompt_id> \
|
||||
--input data_package=<path> \
|
||||
--out <artifact_path>
|
||||
```
|
||||
|
||||
`weatherreporter` always passes prompt input as
|
||||
`--input data_package=<path>`. The data package is structured YAML created by
|
||||
`internal/promptinput`; module snapshots remain separate JSON artifacts for
|
||||
inspection and Recent Changes.
|
||||
|
||||
## Configured Arguments
|
||||
|
||||
The adapter can prepend configured flags before prompt-specific arguments:
|
||||
|
||||
- `--config <path>` from `scriptorium.config_path`
|
||||
- `--profile <profile>` from `scriptorium.profile`
|
||||
|
||||
It appends `scriptorium.extra_args` after the built-in arguments. Extra
|
||||
arguments are passed directly as argv items.
|
||||
|
||||
`scriptorium.binary` selects the executable name or path. If unset inside the
|
||||
adapter, it falls back to `scriptorium`.
|
||||
|
||||
## Execution Behavior
|
||||
|
||||
The adapter runs Scriptorium without shell interpolation. Arguments are passed
|
||||
through `exec.CommandContext`.
|
||||
|
||||
`scriptorium.timeout` limits each subprocess call when configured. Context
|
||||
cancellation or timeout returns an execution error.
|
||||
|
||||
Stdout and stderr are captured separately. Each stream is capped at 1 MiB and
|
||||
the result records whether truncation occurred.
|
||||
|
||||
## Results
|
||||
|
||||
Render results include:
|
||||
|
||||
- full argv recorded as `command`
|
||||
- stdout
|
||||
- stderr
|
||||
- exit code
|
||||
- truncation flags when applicable
|
||||
|
||||
Run results include the same fields plus the requested output path.
|
||||
|
||||
`weatherreporter` persists render preflight JSON when orchestration reaches the
|
||||
preflight save point. The final Markdown artifact is written by Scriptorium to
|
||||
the `--out` path.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
The adapter validates required request fields before starting Scriptorium:
|
||||
|
||||
- prompt ID
|
||||
- data package path
|
||||
- output path for `run`
|
||||
|
||||
Nonzero exits return both the captured result and an error containing the exit
|
||||
code and stderr. A `run` exit code such as `2` is still treated as an error by
|
||||
the adapter, even if Scriptorium wrote output to the requested artifact path.
|
||||
|
||||
Subprocess start failures, context cancellation, and timeouts return errors
|
||||
without fabricating a successful result.
|
||||
|
||||
## Security Notes
|
||||
|
||||
- The adapter does not invoke a shell.
|
||||
- Generated artifacts, rendered prompt context, stdout, and stderr can contain
|
||||
operationally sensitive data.
|
||||
- API keys should be provided through the Scriptorium environment or
|
||||
Scriptorium configuration, not through `weatherreporter` CLI arguments.
|
||||
@@ -1,26 +1,56 @@
|
||||
# Weather API Integration
|
||||
|
||||
This document describes the external Weather API contract used by
|
||||
`weatherreporter`.
|
||||
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).
|
||||
|
||||
## Purpose
|
||||
## Base URL And Requests
|
||||
|
||||
`weatherreporter` uses a configured Weather API base URL to fetch normalized
|
||||
weather source data and assemble a `weatherdata.Bundle`. This is an integration
|
||||
contract for the project adapter, not a complete public API reference for the
|
||||
upstream service.
|
||||
`weather_api.base_url` must be an absolute HTTP(S) 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.
|
||||
|
||||
## Base URL
|
||||
Every request sends `format` and, except where noted below, `units`. The
|
||||
configured format must be `json`.
|
||||
|
||||
`weather_api.base_url` must be an absolute URL. Adapter requests join this base
|
||||
URL with the endpoint paths listed below. Generation and explicit bundle fetches
|
||||
fail before any HTTP request when the base URL is empty or not absolute.
|
||||
Before retrieving sources, Weatherreporter requests `/conditions/current` with
|
||||
the same `format`, `units`, and `precision` query parameters used for current
|
||||
conditions. After a readable 2xx response, it retains that response for the
|
||||
normal current-conditions source step rather than making a second identical
|
||||
request. Failure after the readiness request's internal retry budget stops the
|
||||
fetch before source requests begin.
|
||||
|
||||
The HTTP client uses `weather_api.timeout`.
|
||||
## Endpoints And Query Parameters
|
||||
|
||||
The adapter makes one source request for each endpoint, subject to retry on
|
||||
transient failures. A successful readiness request supplies the current
|
||||
conditions source response. The remaining independent source requests run
|
||||
concurrently, then their results are processed in the source order shown below.
|
||||
This keeps source provenance, missing-source policy, and surfaced errors
|
||||
deterministic regardless of response order.
|
||||
|
||||
| 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 |
|
||||
|
||||
`precision` comes from `weather_api.precision`; `tz` comes from
|
||||
`weather_api.timezone`. Weatherreporter does not call day-slice forecast or
|
||||
discussion-subsection endpoints.
|
||||
|
||||
## Response Envelope
|
||||
|
||||
Every response used by the adapter must be JSON with a top-level `data` field:
|
||||
Each endpoint response must be JSON with a top-level `data` member:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -28,105 +58,97 @@ Every response used by the adapter must be JSON with a top-level `data` field:
|
||||
}
|
||||
```
|
||||
|
||||
For most sources, `data: null` is treated as a missing source. Missing optional
|
||||
sources follow the configured missing-source policy. Missing hourly forecast
|
||||
data fails bundle fetching because hourly periods are required for report
|
||||
generation.
|
||||
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.
|
||||
|
||||
`/alerts/active` is the exception: a successful response with `data: null`
|
||||
means the endpoint was checked and there are no current active alerts. The
|
||||
adapter records a non-missing alerts source and an empty alert run.
|
||||
Hourly forecast data must be present and contain at least one `period`. Every
|
||||
hourly period needs nonzero `startTime` and `endTime` values, with `endTime`
|
||||
after `startTime`; a missing, malformed, empty, or invalidly bounded 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 JSON envelopes, non-2xx statuses, and response read failures include
|
||||
endpoint context in returned errors. Decode errors include source context when
|
||||
they fail the fetch; optional malformed sources follow the missing-source policy.
|
||||
Malformed top-level JSON envelopes and HTTP failures are direct request errors.
|
||||
Malformed `data` for an optional source follows its missing-source policy.
|
||||
|
||||
## Query Parameters
|
||||
## Payload Fields Used
|
||||
|
||||
The adapter sends these query parameters:
|
||||
Weatherreporter decodes only the fields below; additional upstream fields are
|
||||
ignored. Timestamps must be JSON values accepted by Go's `time.Time` decoder.
|
||||
|
||||
- `format`: from `weather_api.format`; configuration validation requires `json`
|
||||
- `units`: from `weather_api.units`
|
||||
- `precision`: from `weather_api.precision` on observations, current
|
||||
conditions, hourly forecast, and narrative forecast requests
|
||||
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast, and
|
||||
discussion requests
|
||||
### Observations And Current Conditions
|
||||
|
||||
Alerts do not receive `precision` or `tz`. Weather story requests receive only
|
||||
`format=json`.
|
||||
`/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`.
|
||||
|
||||
## Endpoints Used
|
||||
`/conditions/current` uses `conditionText`, `isDay`,
|
||||
`relativeHumidityPercent`, `windDirectionDegrees`, `temperatureC`,
|
||||
`temperatureF`, `apparentTemperatureC`, `apparentTemperatureF`, `dewpointC`,
|
||||
`dewpointF`, `windSpeedKmh`, and `windSpeedMph`.
|
||||
|
||||
The adapter fetches these endpoints once per bundle:
|
||||
### Hourly And Narrative Forecasts
|
||||
|
||||
- `/observations`
|
||||
- `/conditions/current`
|
||||
- `/forecast/hourly`
|
||||
- `/forecast/narrative`
|
||||
- `/alerts/active`
|
||||
- `/discussion`
|
||||
- `/weatherstories/latest`
|
||||
Both forecast endpoints use run-level `locationId`, `locationName`, `issuedAt`,
|
||||
`updatedAt`, `product`, `latitude`, `longitude`, `elevationMeters`,
|
||||
`elevationFeet`, and `periods`.
|
||||
|
||||
`weatherreporter` does not call day-slice forecast endpoints or discussion
|
||||
subsection endpoints. Report-period selection and daypart summarization happen
|
||||
inside Go after the full hourly and narrative products are fetched.
|
||||
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`.
|
||||
|
||||
## Required And Optional Sources
|
||||
### Alerts, Discussion, And Weather Story
|
||||
|
||||
Hourly forecast is required:
|
||||
`/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.
|
||||
|
||||
- `data: null` for `/forecast/hourly` fails the fetch.
|
||||
- an hourly forecast with no `periods` fails the fetch.
|
||||
- malformed hourly data fails the fetch.
|
||||
`/discussion` uses `officeId`, `officeName`, `product`, `issuedAt`,
|
||||
`updatedAt`, `keyMessages`, and the `shortTerm` and `longTerm` sections. Each
|
||||
section uses `qualifier`, `text`, and `issuedAt`.
|
||||
|
||||
Other fetched sources are optional and follow `missing_source.default` or a
|
||||
source-specific `missing_source.sources` policy:
|
||||
`/weatherstories/latest` uses `officeId`, `startTime`, `endTime`, `updatedAt`,
|
||||
`title`, `description`, `altText`, `priority`, `order`, and `downloadUrl`.
|
||||
|
||||
- `observations` for `/observations`
|
||||
- `current` for `/conditions/current`
|
||||
- `narrative` for `/forecast/narrative`
|
||||
- `alerts` for `/alerts/active`
|
||||
- `discussion` for `/discussion`
|
||||
- `weather_story` for `/weatherstories/latest`
|
||||
### SPC Convective Outlooks
|
||||
|
||||
Policy behavior:
|
||||
`/outlooks/convective` uses run-level `locationId`, `locationName`, `asOf`,
|
||||
`issuedAt`, `updatedAt`, `product`, `outlooks`, and `discussions`.
|
||||
|
||||
- `error`: fail the fetch for that source
|
||||
- `warn`: omit the source data, add a warning, and continue
|
||||
- `none`: omit the source data and continue without a warning
|
||||
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`.
|
||||
|
||||
For `/alerts/active`, an HTTP error or missing `data` field still fails or
|
||||
follows the relevant error path, but explicit `data: null` is not a
|
||||
missing-source condition.
|
||||
## Timeouts, Retries, And Failures
|
||||
|
||||
## Source Identity
|
||||
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.
|
||||
|
||||
For source payloads accepted into the bundle, including the explicit `null`
|
||||
alerts payload, the adapter records:
|
||||
The adapter accepts response bodies up to 10 MiB and rejects larger bodies
|
||||
before decoding. A non-2xx response reports its relative endpoint and status,
|
||||
without including upstream response text. Request construction, response-limit,
|
||||
read, and decode failures include endpoint context in their errors.
|
||||
|
||||
- source name
|
||||
- endpoint path
|
||||
- query parameters sent
|
||||
- fetch time
|
||||
- source issue and update timestamps when present in the payload
|
||||
- SHA-256 hash of the compact raw `data` JSON
|
||||
|
||||
Warnings are recorded both on the affected source and on the bundle-level
|
||||
warnings list.
|
||||
|
||||
## Compatibility Assumptions
|
||||
|
||||
The adapter expects payload fields compatible with the internal forecast bundle
|
||||
types in `internal/forecast/bundle.go`, including:
|
||||
|
||||
- observation timestamps and observation values
|
||||
- current condition values
|
||||
- forecast run metadata and `periods`
|
||||
- active alert run data
|
||||
- discussion metadata, key messages, and short/long-term section text
|
||||
- latest weather story title, description, timing, priority, order, alt text,
|
||||
and download URL
|
||||
|
||||
The adapter intentionally keeps upstream transport and envelope details inside
|
||||
`internal/adapters/weatherapi`; downstream packages consume the normalized
|
||||
bundle.
|
||||
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.
|
||||
|
||||
@@ -1,148 +1,72 @@
|
||||
# App Orchestration Internals
|
||||
# Application Orchestration Internals
|
||||
|
||||
This document describes the workflow coordinator in `internal/app`.
|
||||
`internal/app` owns stateless report generation, batch execution, comparison
|
||||
orchestration, 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).
|
||||
|
||||
## Purpose
|
||||
## Single-Report Flow
|
||||
|
||||
`internal/app` coordinates the top-level use cases after CLI parsing and config
|
||||
loading are complete. It resolves report definitions, fetches weather data,
|
||||
builds collected and derived facts, builds module snapshots and prompt-input
|
||||
artifacts, invokes Scriptorium through the adapter boundary, optionally
|
||||
notifies distributor through an app-owned notifier boundary, persists managed
|
||||
state, runs batches, and reads existing artifacts for inspection.
|
||||
`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. Output preflight validates the final filename, permits only an absent or regular final destination, and validates the bounded same-directory temporary form without creating a missing parent. It then validates the report's generated-text catalog binding, exact Promptkit prompt, and selected profile before collecting weather data. The resolved profile, backend, and model are carried in the active result.
|
||||
|
||||
## Inputs And Outputs
|
||||
The workflow builds facts, a module snapshot, briefing metadata, and the YAML prompt package in memory. It executes Promptkit only against the inspected prompt and profile, reconciles the preparation callback and completed result with that identity and the prepared report schema, validates the returned generated text, builds a render context, and renders Markdown. `fileutil` writes the completed Markdown through a same-directory temporary file, rechecks the final destination and context after close and immediately before the atomic rename. Only after that write succeeds does single-report notification run.
|
||||
|
||||
Inputs:
|
||||
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.
|
||||
|
||||
- `GenerateRequest` for one report command
|
||||
- `BatchRequest` for morning or evening batch commands
|
||||
- `FetchBundleRequest` for explicit bundle fetch and save workflows
|
||||
- `ReportRequest` for single-report generation
|
||||
- resolved report definitions from `internal/report`
|
||||
- weather data bundles from `internal/adapters/weatherapi`
|
||||
- prior snapshots loaded from `internal/state`
|
||||
- optional renderer, notifier, and state-store fakes for tests
|
||||
## Batches
|
||||
|
||||
Outputs:
|
||||
`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 generated-text catalog, 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.
|
||||
|
||||
- generated report results with JSON module snapshot, YAML data package,
|
||||
preflight, report, metadata, prior snapshot, Recent Changes, Scriptorium
|
||||
result details, and notification result when attempted
|
||||
- batch summaries with per-report status, artifact paths, error text, and
|
||||
notification outcome when attempted
|
||||
- saved Weather API bundle JSON for fetch workflows
|
||||
- inspection JSON values for reports, metadata, module snapshots, data
|
||||
packages, prior snapshots, and source provenance
|
||||
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
|
||||
Cancellation and deadline expiry stop the sequential loop before another report
|
||||
starts. Completed report results and published paths remain successful; the
|
||||
interrupted and unstarted planned reports have `canceled` status and are counted
|
||||
separately from failed reports. The batch notification result records that
|
||||
delivery was skipped, and the returned error retains the original context cause
|
||||
for callers and CLI projection.
|
||||
|
||||
`internal/app` owns workflow order and request composition. It does not parse
|
||||
CLI flags, load YAML files directly, implement HTTP transport, own fact
|
||||
derivation algorithms, define report periods, compare rendered Markdown, or
|
||||
construct Scriptorium argv.
|
||||
## Comparisons
|
||||
|
||||
Report selection and report identity policy come from `internal/report`.
|
||||
Collected and derived fact contracts come from `internal/facts`.
|
||||
Weather API transport stays in `internal/adapters/weatherapi`. Scriptorium
|
||||
subprocess behavior stays in `internal/adapters/scriptorium`. Distributor
|
||||
upload behavior stays in `internal/adapters/distributor`. Filesystem layout and
|
||||
persisted metadata stay in `internal/state`.
|
||||
`CompareDetailed` validates ordered explicit profile IDs, resolves the report,
|
||||
and preflights the exact bundle destination before initializing optional prompt
|
||||
debugging, prompt inspection, or collection. It then validates the report's
|
||||
generated-text catalog binding, inspects the one prompt and every selected
|
||||
profile, collects once, and delegates shared report
|
||||
construction to the prepared-report flow. It does not accept a notifier.
|
||||
|
||||
## Config Fields Used
|
||||
Once the destination is resolved, the partial result retains its absolute
|
||||
output directory even when later preflight, debug initialization, inspection,
|
||||
collection, or preparation fails. Every initialized result is finalized with a
|
||||
finished timestamp. If prompt inspection succeeds before a later profile
|
||||
inspection fails, the partial result retains the resolved prompt ID, version,
|
||||
and hash. Artifact paths are added only after publication commits.
|
||||
|
||||
- `weather_api.*` for Weather API client construction and module metadata
|
||||
- `scriptorium.*` for renderer construction
|
||||
- `workspace.*` for filesystem state
|
||||
- `dayparts` for daily and outlook summarization
|
||||
- `recent_change.*` for structured Recent Changes thresholds
|
||||
- `notify.distributor.*` for optional notification after report generation
|
||||
The comparison execution core starts each inspected profile independently,
|
||||
keeps results in selection order, and waits for all started work. Every profile
|
||||
reconciles its callback and completion provenance before its JSON can be
|
||||
rendered. Independent profile failures are recorded and do not stop peers; a
|
||||
completed profile failure remains recorded if cancellation happens later.
|
||||
Context cancellation marks only unfinished or cancellation-terminated work and
|
||||
prevents publication. Details of
|
||||
prepared values, execution and debugging, and publication are documented in [prepared report
|
||||
internals](prepared-report.md), [comparison execution
|
||||
internals](comparison-execution.md), and [comparison publication
|
||||
internals](comparison-publication.md).
|
||||
|
||||
Output copy flags are command request fields. They are not configuration
|
||||
defaults.
|
||||
When publication has committed its new bundle, application results contain the
|
||||
absolute manifest, data-package, and successful report paths even if removal of
|
||||
the previous sibling backup then fails. That cleanup failure is still returned
|
||||
as an operational error rather than treating the new bundle as unpublished;
|
||||
the returned error identifies the observed recovery state and includes a path
|
||||
only when cleanup left a sibling behind.
|
||||
|
||||
## Generation Workflow
|
||||
## Boundaries And Verification
|
||||
|
||||
Single-report generation follows this order:
|
||||
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.
|
||||
|
||||
1. Resolve the command report to a `report.Resolved` value.
|
||||
2. Create or use a filesystem store.
|
||||
3. Locate any prior compatible snapshot through `internal/state`.
|
||||
4. Fetch a Weather API bundle.
|
||||
5. Build collected and derived facts once.
|
||||
6. Execute configured modules and save the module snapshot.
|
||||
7. Compute Recent Changes from structured prior and current module snapshots.
|
||||
8. Build and save the YAML Scriptorium `data_package`.
|
||||
9. Run Scriptorium render preflight.
|
||||
10. Save preflight JSON when a render result is available.
|
||||
11. Save metadata for inspection.
|
||||
12. Run Scriptorium report generation to the managed report path.
|
||||
13. Copy the managed report to the requested `--out` path when provided.
|
||||
14. Save metadata with the managed report path.
|
||||
15. If distributor notification is enabled, notify using the managed report
|
||||
path as the source file.
|
||||
16. Save a distributor notification debug artifact and update metadata with its
|
||||
path.
|
||||
Focused checks:
|
||||
|
||||
If render preflight returns both a result and an error, preflight JSON and
|
||||
metadata are persisted before the error is returned. If Scriptorium report
|
||||
generation returns an error after writing output, the managed report and
|
||||
metadata remain inspectable. Notification is not attempted after Weather API,
|
||||
module snapshot, prompt input, render, Scriptorium run, or metadata-save
|
||||
failures.
|
||||
When notification is attempted, the debug artifact records request identity,
|
||||
including rendered pipeline ID, bundle paths, accepted upload fields,
|
||||
distributor status fields, raw status report JSON when available, and redacted
|
||||
failure context.
|
||||
`--out` copies are never used as notification source files.
|
||||
|
||||
## Batch Workflow
|
||||
|
||||
`run morning` resolves Daily Today, 3-Day Outlook, and Weekend Outlook except
|
||||
on Sunday. `run evening` resolves Daily Tomorrow. Batch output copy names come
|
||||
from report definitions. Batch generation continues independent reports after a
|
||||
failure, records each result, writes compact status lines to stderr, emits a
|
||||
JSON summary to stdout, and returns an aggregate error when any report failed.
|
||||
When notification is enabled, each successfully generated report is notified
|
||||
independently. Notification failure marks that report failed, records
|
||||
notification fields in the batch result, and does not stop later reports.
|
||||
`--out-dir` copies are never used as notification source files.
|
||||
|
||||
## Inspection Workflow
|
||||
|
||||
Inspection workflows load existing filesystem state only. They do not fetch
|
||||
weather data or invoke Scriptorium. Run-specific inspect commands share the same
|
||||
store and metadata lookup path, then load the requested artifact or derived
|
||||
inspection view.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- Resolve errors stop the requested workflow before fetching weather data.
|
||||
- Weather API and module execution errors stop that report before Scriptorium
|
||||
runs.
|
||||
- Prompt input validation fails before render preflight.
|
||||
- Render and run errors preserve Scriptorium stderr and exit-code context.
|
||||
- Notification errors are wrapped with report ID, RunID, and managed report path
|
||||
context and are recorded separately in batch results.
|
||||
- Metadata and artifact path errors include filesystem context.
|
||||
- Batch failures are recorded per report and surfaced through an aggregate
|
||||
batch error.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/app/app_test.go`
|
||||
- `internal/cli/root_test.go`
|
||||
- `internal/state/filesystem_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Report behavior is resolved through `internal/report`.
|
||||
- Generated reports use the same app request and result types regardless of
|
||||
report ID.
|
||||
- Render preflight precedes Scriptorium report generation.
|
||||
- Recent Changes are computed from structured module snapshots.
|
||||
- Metadata links artifacts produced for a run.
|
||||
- Distributor notification maps the managed Markdown report path to configured
|
||||
bundle paths; extra output copies are not upload sources.
|
||||
```sh
|
||||
go test ./internal/app ./internal/collect
|
||||
```
|
||||
|
||||
@@ -1,104 +1,103 @@
|
||||
# Module Builder Internals
|
||||
|
||||
This document describes module builder behavior in `internal/briefing`.
|
||||
`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` turns report metadata, collected weather data, and derived
|
||||
forecast facts into prompt-facing module outputs. The package also owns the
|
||||
module registry used to validate report composition and config overrides.
|
||||
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. The
|
||||
briefing-owned fact-requirement vocabulary supplies each prerequisite's stable
|
||||
identity, category, and availability predicate; registry construction rejects
|
||||
unknown requirements and requirements listed under the wrong category.
|
||||
|
||||
Module outputs are structured prompt inputs. They are not rendered report prose
|
||||
and they are not persisted by this package.
|
||||
`BuildModule` first verifies the requested module, report compatibility, and
|
||||
option shape. It then applies the declared missing-data behavior:
|
||||
|
||||
## Inputs And Outputs
|
||||
- `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.
|
||||
|
||||
Inputs:
|
||||
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.
|
||||
|
||||
- resolved report definition, generation time, timezone, and valid period
|
||||
- collected facts built from `weatherdata.Bundle`
|
||||
- derived daily, daypart, precipitation, alert, and storm-window facts where
|
||||
required
|
||||
- configured units, timezone, and descriptive location context
|
||||
- typed module options from report defaults or config overrides
|
||||
## Built value families
|
||||
|
||||
Outputs:
|
||||
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.
|
||||
|
||||
- `ModuleDefinition` values with module ID, stanza name, option type,
|
||||
supported reports, fact requirements, missing-data behavior, and builder
|
||||
- `module.Output` values for source-oriented stanzas:
|
||||
`metadata`, `current_conditions`, `narrative_forecast`, `hourly_forecast`,
|
||||
`alert_digest`, `area_forecast_discussion`, and `weather_story`
|
||||
- `module.Output` values for derived stanzas:
|
||||
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
|
||||
`outdoor_windows`, and `tomorrow_planning`
|
||||
The daily summary preserves generic feels-like values as
|
||||
`apparent_temperature_max_f`; it does not label them as a heat index. Daypart
|
||||
temperature phrases retain below-zero meaning, including through temperature
|
||||
trends that cross zero. Outdoor windows add a 25-point risk penalty and an
|
||||
explicit reason for each snow, ice, or fog indicator. Equal scores retain input
|
||||
order for both best and worst windows.
|
||||
|
||||
Every registered composition entry has a builder. Unknown or unimplemented
|
||||
module IDs fail validation instead of being skipped.
|
||||
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).
|
||||
Alert digests are built from selected alert items and source provenance, not a
|
||||
provider response envelope.
|
||||
|
||||
Prompt-facing module values use local, human-readable date and time labels
|
||||
where the LLM is expected to reason about report content. Canonical timestamps
|
||||
remain in report metadata, source provenance, and integration artifacts.
|
||||
Derived daypart-summary maps use the forecast package's canonical daypart
|
||||
identity and reject any collision instead of replacing an earlier value.
|
||||
Planning applies the same identity when recognizing morning, afternoon,
|
||||
evening, and overnight windows; display labels remain separate and preserve
|
||||
configured text with rune-safe first-letter capitalization.
|
||||
|
||||
## Boundaries
|
||||
The embedded SPC background-definition asset records its authoritative sources,
|
||||
source update dates, and maintainer review schedule. Its categorical
|
||||
`official_description` values transcribe the [SPC convective-outlook risk
|
||||
table](https://www.spc.noaa.gov/about/outlooks/); its Conditional Intensity
|
||||
Group entries follow the [SPC conditional-intensity
|
||||
reference](https://www.spc.noaa.gov/exper/conditional-intensity-information).
|
||||
`plain_language` values are Weatherreporter summaries. Weatherreporter
|
||||
maintainers review the asset annually and whenever either source changes.
|
||||
|
||||
- This package selects and shapes already-collected weather facts for prompts.
|
||||
- It validates module composition against report compatibility and option
|
||||
types.
|
||||
- It does not fetch weather data, compare prior snapshots, write module
|
||||
snapshots, build YAML data packages, invoke Scriptorium, or write workflow
|
||||
metadata.
|
||||
`area_forecast_discussion` accepts an optional typed section filter. Accepted
|
||||
typed option pointers are normalized to the declared value type before builder
|
||||
execution. Planning modules are report-specific: `daily_planning` supports Daily,
|
||||
`today_planning` supports Today, and `tomorrow_planning` supports Tomorrow.
|
||||
|
||||
## Config Fields Used
|
||||
## Missing data and boundaries
|
||||
|
||||
The app layer passes effective units, timezone, and location context into the
|
||||
module context. `internal/facts` consumes daypart configuration before module
|
||||
builders run. Configured `location` values are prompt context only; Weather API
|
||||
`sourceLocationId` and `sourceLocation` remain source provenance.
|
||||
Optional current conditions, narrative products, discussions, and weather
|
||||
stories may be omitted. A weather story is usable only when it has non-blank
|
||||
displayable content (title, description, alternate text, or download URL) or a
|
||||
valid start/end period; otherwise collection applies its optional-source policy
|
||||
and the module is 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.
|
||||
|
||||
`area_forecast_discussion` uses optional `sections` configuration to include a
|
||||
subset of discussion fields.
|
||||
`ModuleContext` carries the effective units, timezone, location context, and
|
||||
prepared identity. Report preparation creates that one `PreparedIdentity` for
|
||||
the shared report identity, timing, configuration context, and source warnings
|
||||
before module construction. The metadata module projects its matching fields
|
||||
from that value and retains its prompt-safe shape. Field defaults are owned by
|
||||
[configuration](../config.md), and prompt-package layout is owned by [prompt
|
||||
input](prompt-input.md).
|
||||
|
||||
## External Adapters Used
|
||||
## Verification and invariants
|
||||
|
||||
None directly.
|
||||
Focused tests cover source and derived values, registry validation, option
|
||||
handling, prompt exporters, support rules, and missing-data behavior:
|
||||
|
||||
## State Or Manifest Behavior
|
||||
```sh
|
||||
go test ./internal/briefing
|
||||
```
|
||||
|
||||
None. `internal/app` collects module outputs into a `module.Snapshot`, and
|
||||
`internal/state` persists that snapshot.
|
||||
|
||||
## Skip And Resume Behavior
|
||||
|
||||
None. Builders either emit a module output, omit optional unavailable data, or
|
||||
return an error for invalid required inputs.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- Required derived modules return errors when their dependent facts are not
|
||||
available.
|
||||
- Module registry construction rejects duplicate module IDs and duplicate
|
||||
stanza names.
|
||||
- Composition validation rejects unknown modules, duplicate modules,
|
||||
incompatible report/module combinations, duplicate stanza names, and invalid
|
||||
option shapes.
|
||||
- Source-oriented module builders omit missing optional current conditions,
|
||||
forecast discussion, and weather story stanzas.
|
||||
- Alert digest output distinguishes checked empty alert data from missing alert
|
||||
source data.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/briefing/base_modules_test.go`
|
||||
- `internal/briefing/derived_modules_test.go`
|
||||
- `internal/briefing/modules_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Module outputs contain structured weather facts and source context.
|
||||
- Common metadata includes RunID, report ID, prompt ID, valid period, source
|
||||
provenance, source hashes, source warnings, and configured prompt location.
|
||||
- Prompt input packaging and Scriptorium execution remain outside this package.
|
||||
Builders emit structured facts, never report prose. The app collects their
|
||||
outputs into an in-memory module snapshot for prompt input and rendering.
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
# Changes Internals
|
||||
|
||||
This document describes structured Recent Changes comparison.
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/changes` compares current and prior module snapshots and emits
|
||||
compact change records for prompt input data packages.
|
||||
|
||||
## Inputs And Outputs
|
||||
|
||||
Inputs:
|
||||
|
||||
- prior module snapshot
|
||||
- current module snapshot
|
||||
- comparison thresholds from configuration
|
||||
|
||||
Outputs:
|
||||
|
||||
- ordered `changes.Change` items with type, message, previous value, and current
|
||||
value where useful
|
||||
|
||||
## Boundaries
|
||||
|
||||
- This package compares structured module snapshot data only.
|
||||
- It does not read filesystem state, find prior snapshots, render Markdown,
|
||||
invoke Scriptorium, or compare generated report text.
|
||||
|
||||
## Config Fields Used
|
||||
|
||||
The app maps these 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`
|
||||
|
||||
## External Adapters Used
|
||||
|
||||
None.
|
||||
|
||||
## State Or Manifest Behavior
|
||||
|
||||
None directly. The app loads prior module snapshots through `internal/state`
|
||||
before calling comparison functions.
|
||||
|
||||
## Skip And Resume Behavior
|
||||
|
||||
No resume behavior. When the app has no prior comparable snapshot, it sends an
|
||||
empty Recent Changes list without calling a comparison function.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- Daily comparison requires `derived_daily_summary` and
|
||||
`derived_daypart_summaries` stanzas. It also uses `alert_digest` and
|
||||
`precip_timing` when present.
|
||||
- 3-Day comparison requires `derived_daypart_summaries`.
|
||||
- Weekend comparison requires `derived_daypart_summaries`.
|
||||
- Storm Report comparison returns no changes.
|
||||
|
||||
## Tests
|
||||
|
||||
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.
|
||||
- Report compatibility is determined outside this package by report definitions
|
||||
and state lookup.
|
||||
- Output stays compact enough for prompt input.
|
||||
21
docs/internal/cli.md
Normal file
21
docs/internal/cli.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# 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.
|
||||
|
||||
The executable derives its action context from `SIGINT` and `SIGTERM` and
|
||||
passes it to `Runner.Run`. Signal cancellation therefore uses the same action,
|
||||
summary, and error paths as other context cancellation.
|
||||
|
||||
For each `generate`, `run`, or `compare` action, `Runner` constructs one project-owned Promptkit executor after request preflight and configuration loading. 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 generation, batch, and comparison actions. It has no persisted-run or inspection dispatch. Generation and batch summaries include report identity, status, output path, effective profile/backend/model, source warnings, validation, requested debug path, and notification result when available. Comparison summaries retain their ordered profile results and published bundle paths when available. All summaries 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 unless `--quiet` is set.
|
||||
|
||||
CLI code owns report-date flag acceptance and date resolution, but not report
|
||||
composition, weather collection, output publication, provider execution, or
|
||||
notification policy. Focused checks:
|
||||
|
||||
```sh
|
||||
go test ./internal/cli
|
||||
```
|
||||
36
docs/internal/collect.md
Normal file
36
docs/internal/collect.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Collection Internals
|
||||
|
||||
`internal/collect` is the small application-facing boundary that obtains one
|
||||
normalized Weather API bundle. The external HTTP contract belongs in the
|
||||
[Weather API integration guide](../integrations/weatherapi.md); normalized
|
||||
source values belong in [weather-data internals](weather-data.md).
|
||||
|
||||
## Contract
|
||||
|
||||
`Run` receives a context and effective configuration in `Request`. It creates
|
||||
the Weather API adapter, calls `FetchBundle`, and returns the adapter's
|
||||
normalized bundle in `Result`. Adapter construction errors are wrapped as
|
||||
weather-collection setup errors and fetch errors as bundle-collection errors.
|
||||
|
||||
The package neither chooses reports nor derives facts, builds modules, invokes
|
||||
Promptkit, writes files, or sends notifications. Request scheduling, endpoint
|
||||
retrieval, response limits, and source-level warnings belong to the Weather
|
||||
API adapter and its integration contract.
|
||||
|
||||
## Application Use
|
||||
|
||||
`internal/app` owns the `Collector` interface used by report workflows and
|
||||
tests. Its default implementation delegates to `collect.Run`; callers may
|
||||
substitute a collector at that boundary. Application orchestration owns
|
||||
collection timing, reuse across a workflow, and the handling of nil collection
|
||||
results. See [app orchestration internals](app-orchestration.md) for that
|
||||
flow.
|
||||
|
||||
## Verification
|
||||
|
||||
Focused package tests cover a successful fetch and wrapping failures from
|
||||
adapter construction and bundle retrieval:
|
||||
|
||||
```sh
|
||||
go test ./internal/collect
|
||||
```
|
||||
32
docs/internal/comparison-execution.md
Normal file
32
docs/internal/comparison-execution.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Comparison Execution Internals
|
||||
|
||||
The comparison execution core receives an already prepared report and an
|
||||
already inspected, ordered profile list. It initializes an outcome for every
|
||||
selected profile, launches each started profile in its own goroutine, and
|
||||
waits for every started goroutine before returning. Results retain the supplied
|
||||
selection order even though execution completes in an arbitrary order.
|
||||
|
||||
Every profile uses the exact inspected prompt identity and a private copy of
|
||||
the same prepared data package. Provider, generated-text validation, rendering,
|
||||
or debug-write failure becomes that profile's safe failed outcome and does not
|
||||
cancel its peers. The shared executor must support those concurrent `Execute`
|
||||
calls. The application deliberately imposes no additional semaphore: Promptkit
|
||||
owns backend capacity. A profile failure completed before a later cancellation
|
||||
remains its original safe outcome; cancellation or a deadline marks only
|
||||
unfinished or cancellation-terminated outcomes as skipped or failed, joins work,
|
||||
and prevents bundle publication.
|
||||
|
||||
When debugging is enabled, each execution receives a deterministic reference
|
||||
derived from the comparison identity, ordered profile position, and safe
|
||||
profile slug. This keeps concurrent captures separate. The debug writer itself
|
||||
owns secure-root validation and file permissions. It safely creates shared
|
||||
missing ancestors during concurrent writes, then rejects symlink and non-
|
||||
directory components. Operational retention and sensitivity are documented in
|
||||
the [operations guide](../operations.md). This secure writer is enabled only on
|
||||
Unix hosts; comparison fails before execution when another host requests debug
|
||||
capture.
|
||||
|
||||
The output result and its safe errors are converted into the durable contract
|
||||
only by comparison publication. See [comparison publication
|
||||
internals](comparison-publication.md) and the external [comparison bundle
|
||||
contract](../integrations/comparison-bundle.md).
|
||||
47
docs/internal/comparison-publication.md
Normal file
47
docs/internal/comparison-publication.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Comparison Publication Internals
|
||||
|
||||
`internal/comparison` separates the logical bundle from filesystem mechanics.
|
||||
The application builds a validated manifest, exact shared data-package bytes,
|
||||
and only the Markdown files for successful profiles. The durable layout,
|
||||
schema, and compatibility rules are owned by the [comparison bundle
|
||||
contract](../integrations/comparison-bundle.md).
|
||||
|
||||
Recognition first token-validates the manifest's object fields, rejecting
|
||||
unknown, case-variant, and duplicate names before decoding its typed schema.
|
||||
Manifest validation derives each successful report filename from its ordered
|
||||
position, total profile count, and logical profile ID; logical-bundle and
|
||||
filesystem validation then require that exact path and file set.
|
||||
|
||||
Destination planning is read-only. It requires an exact absolute target that
|
||||
is neither the filesystem root nor the working directory, rejects unsafe
|
||||
symlinks and non-directories, accepts a missing or empty directory, and permits
|
||||
replacement only for a recognized current bundle. Publication rechecks the
|
||||
destination namespace and type immediately before it writes a private sibling
|
||||
staging directory. For replacement, it moves the prior bundle to a private
|
||||
sibling backup, fully reauthorizes that moved entry, checks for cancellation,
|
||||
and restores it if cancellation or installing the new bundle prevents
|
||||
replacement. If guarded restoration fails, the error retains the prior bundle's
|
||||
recovery path.
|
||||
|
||||
Planning also validates the final component and the bounded fixed names used
|
||||
for private staging and backup siblings. A destination that cannot form those
|
||||
names is rejected before publication creates a missing parent directory; a
|
||||
maximum-length valid destination remains usable because transaction siblings do
|
||||
not incorporate its basename.
|
||||
|
||||
The new bundle is committed only after the staged directory has been installed
|
||||
at the target. From that point its artifact paths are authoritative: a failure
|
||||
to remove the retained sibling backup does not roll back the new bundle.
|
||||
After a cleanup failure, publication inspects the sibling without masking the
|
||||
original filesystem cause. Its inspectable cleanup result distinguishes a
|
||||
complete recognized recovery bundle, partial remnants, an absent sibling, or
|
||||
an uninspectable state. A recovery path is reported only when something
|
||||
remains; only a complete recognized bundle is suitable for rollback recovery.
|
||||
|
||||
The application preflights before prompt inspection and collection. Publication
|
||||
performs its transaction-boundary checks and final moved-destination
|
||||
authorization before installation. A cancellation or any failure before the
|
||||
commit leaves the prior destination untouched. Completed bundles include
|
||||
partial profile results; comparison publication never coordinates Distributor
|
||||
notification. Operator-facing lifecycle and cleanup are in the
|
||||
[operations guide](../operations.md).
|
||||
@@ -1,123 +1,71 @@
|
||||
# Distributor Adapter Internals
|
||||
|
||||
This document describes the distributor upload adapter in
|
||||
`internal/adapters/distributor`.
|
||||
`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).
|
||||
|
||||
## Purpose
|
||||
## Client construction
|
||||
|
||||
The adapter submits generated weatherreporter Markdown reports to a configured
|
||||
distributor HTTP upload endpoint. It isolates distributor package types,
|
||||
token-env lookup, upload client construction, source-bundle file mapping,
|
||||
timeout handling, status polling, and upload error wrapping from app
|
||||
orchestration.
|
||||
`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. Its transport reads at most 1 MiB from any
|
||||
Distributor response before the pinned client decodes it; an oversized response
|
||||
is a distinct local failure and does not trigger an extra upload attempt.
|
||||
|
||||
## Inputs And Outputs
|
||||
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).
|
||||
|
||||
Inputs:
|
||||
## Upload translation
|
||||
|
||||
- distributor endpoint URL
|
||||
- token environment variable name
|
||||
- upload timeout
|
||||
- pipeline ID
|
||||
- bundle ID
|
||||
- idempotency key
|
||||
- source Markdown report path and bundle-relative path mappings
|
||||
- bundle created timestamp
|
||||
- context for cancellation
|
||||
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:
|
||||
|
||||
Outputs:
|
||||
| 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 |
|
||||
|
||||
- accepted distributor run ID
|
||||
- accepted distributor upload status
|
||||
- distributor run status, status polling error, and raw run report JSON when available
|
||||
- weatherreporter-owned idempotency conflict error when applicable
|
||||
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.
|
||||
|
||||
## Boundaries
|
||||
## Status and errors
|
||||
|
||||
`internal/adapters/distributor` is the only weatherreporter package that imports
|
||||
`gitea.maximumdirect.net/eric/distributor/pkg/upload` or
|
||||
`gitea.maximumdirect.net/eric/distributor/pkg/bundle`.
|
||||
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 and lifecycle timestamps.
|
||||
Remote response bodies, status reports, and remote error text are not retained
|
||||
in normal results. HTTP failures retain a local typed status-code and
|
||||
retryability classification; conflicts retain the local idempotency-conflict
|
||||
type.
|
||||
|
||||
The app layer passes weatherreporter-owned request values to the adapter. The
|
||||
adapter does not choose report types, render templates, select output copies,
|
||||
configure destinations, wait for downstream publication, transform Markdown, or
|
||||
persist notification state.
|
||||
Status lookup or polling errors are preserved in `UploadResult.StatusError` so
|
||||
the caller can report an accepted-but-unconfirmed delivery, using a bounded
|
||||
repository-owned diagnostic rather than remote text. 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.
|
||||
|
||||
Full upstream distributor package and HTTP contract details stay under
|
||||
`docs/integrations/distributor/`.
|
||||
## Verification
|
||||
|
||||
## Config Fields Used
|
||||
Focused tests cover configuration validation, request mapping, response size
|
||||
boundaries, safe diagnostics, timeouts and polling, status translation, conflict
|
||||
handling, and token redaction. A local HTTP server exercises the production
|
||||
upload and status boundary:
|
||||
|
||||
The adapter is built from `notify.distributor` config:
|
||||
|
||||
- `endpoint`
|
||||
- `token_env`
|
||||
- `timeout`
|
||||
|
||||
The app layer renders pipeline ID, bundle ID, idempotency key, and bundle paths
|
||||
from:
|
||||
|
||||
- `pipeline_id_template`
|
||||
- `bundle_id_template`
|
||||
- `idempotency_key_template`
|
||||
- `report_path_templates`
|
||||
|
||||
The token value is read from the environment variable named by `token_env`
|
||||
after config loading and `secrets.directory` processing.
|
||||
|
||||
## Upload Behavior
|
||||
|
||||
The adapter calls distributor `UploadFiles` with one or more file mappings:
|
||||
|
||||
- pipeline ID: the rendered distributor workflow selector
|
||||
- source path: the managed Markdown report path selected by app orchestration
|
||||
- bundle paths: rendered bundle-relative report paths
|
||||
- created: the report generation timestamp
|
||||
|
||||
The adapter creates a distributor upload client with the configured endpoint,
|
||||
bearer token, and timeout-backed HTTP client. It also wraps the upload context
|
||||
with the configured timeout when the timeout is greater than zero.
|
||||
|
||||
After upload acceptance, the adapter polls distributor `Status` for the accepted
|
||||
run ID until the run reaches `succeeded` or `failed`, or until the configured
|
||||
timeout expires. It returns the latest status, error text, and raw report JSON in
|
||||
weatherreporter-owned types so app orchestration can persist them in the
|
||||
notification debug artifact. Status lookup failures or timeout before a terminal
|
||||
state are kept as debug status errors on an otherwise accepted upload. A
|
||||
terminal distributor run status of `failed` is returned as a notification failure
|
||||
with the status report preserved.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
The adapter validates required endpoint, token env name, token value, pipeline
|
||||
ID, bundle ID, idempotency key, upload files, source paths, bundle paths, and
|
||||
upload client inputs before uploading.
|
||||
|
||||
Upload failures include endpoint, pipeline ID, bundle ID, idempotency key,
|
||||
source paths, and bundle paths context. Token values are redacted from adapter
|
||||
errors.
|
||||
|
||||
Distributor idempotency conflicts are exposed as a weatherreporter-owned
|
||||
`IdempotencyConflictError`, so callers do not depend on upstream distributor
|
||||
types.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/adapters/distributor/client_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
- `internal/cli/root_test.go`
|
||||
|
||||
Adapter tests use a fake upload client factory and do not require a live
|
||||
distributor service.
|
||||
|
||||
## Invariants
|
||||
|
||||
- Distributor package types do not leak outside the adapter.
|
||||
- Only the managed Markdown report is uploaded.
|
||||
- The adapter never scans the workspace.
|
||||
- Token values are not included in errors, CLI output, metadata, docs, or
|
||||
examples.
|
||||
- Destination routing and Markdown-to-HTML transformation belong to
|
||||
distributor, not weatherreporter.
|
||||
```sh
|
||||
go test ./internal/adapters/distributor
|
||||
```
|
||||
|
||||
@@ -1,72 +1,71 @@
|
||||
# Fact Contracts Internals
|
||||
|
||||
This document describes the fact contract boundary.
|
||||
`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).
|
||||
|
||||
## Purpose
|
||||
## Collected facts
|
||||
|
||||
`internal/facts` separates normalized upstream facts collected for a report run
|
||||
from conservative report-scoped facts derived from them. The package gives app
|
||||
orchestration one place to build reusable facts before module execution.
|
||||
`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.
|
||||
|
||||
## Inputs And Outputs
|
||||
Alert facts retain individual alert payloads for period selection together with
|
||||
their copied source provenance; they do not retain a provider response envelope.
|
||||
|
||||
Inputs:
|
||||
A nil bundle produces an empty collected value. Collection itself, missing
|
||||
source policy, and source hashes are outside this package.
|
||||
|
||||
- `weatherdata.Bundle` from the Weather API adapter
|
||||
- resolved report definition and valid period
|
||||
- report timezone
|
||||
- configured daypart definitions
|
||||
## Report-scoped derivation
|
||||
|
||||
Outputs:
|
||||
`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.
|
||||
It rejects collected hourly data with a precipitation probability outside the
|
||||
finite 0 through 100 percentage domain before constructing derived facts.
|
||||
|
||||
- `facts.CollectedFacts` with normalized source facts plus separate source
|
||||
provenance and warnings
|
||||
- `facts.DerivedFacts` with valid-period forecast slices, alert overlaps,
|
||||
daily summaries, daypart summaries, and Storm Report window summary
|
||||
Report identity controls the summary shape:
|
||||
|
||||
## Boundaries
|
||||
| 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 |
|
||||
|
||||
- This package owns fact assembly and reusable deterministic derivation for a
|
||||
report run.
|
||||
- It does not fetch upstream data, build prompt wording, compare prior
|
||||
snapshots, write workflow state, invoke Scriptorium, or define modules.
|
||||
`DaypartSummaries` is collected from the resulting daily summaries.
|
||||
The detailed grouping, daypart-window, and alert rules are owned by
|
||||
[forecast derivation](forecast-derivation.md).
|
||||
Daily alert overlaps remain scoped to the civil day, while overnight daypart
|
||||
summaries retain alerts that overlap their complete next-day window.
|
||||
|
||||
## Config Fields Used
|
||||
## Missing data and failures
|
||||
|
||||
- `dayparts[].name`
|
||||
- `dayparts[].start`
|
||||
- `dayparts[].end`
|
||||
- `weather_api.timezone`
|
||||
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.
|
||||
|
||||
## External Adapters Used
|
||||
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.
|
||||
|
||||
None directly. Collected facts are built from `weatherdata.Bundle`.
|
||||
## Verification and invariants
|
||||
|
||||
## State Or Manifest Behavior
|
||||
Focused tests cover collected-fact separation, report-period selection,
|
||||
hourly behavior, daily summaries, and convective outlook selection:
|
||||
|
||||
None. Source provenance and warnings remain data fields for downstream metadata
|
||||
and inspection.
|
||||
```sh
|
||||
go test ./internal/facts
|
||||
```
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- Invalid or missing report valid periods return an error.
|
||||
- Invalid timezone names return an error.
|
||||
- Missing required hourly forecast data returns the underlying forecast
|
||||
derivation error for reports that require daily summaries.
|
||||
- Missing optional narrative, alert, discussion, daily, or weather story data
|
||||
produces empty or nil derived fields.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/facts/facts_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Collected facts are built once from a fetched bundle.
|
||||
- Derived facts are scoped to one resolved report.
|
||||
- Source provenance and warnings stay separate from ordinary fact fields.
|
||||
- Prompt-specific wording and one-off presentation decisions stay outside this
|
||||
package.
|
||||
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,76 +1,47 @@
|
||||
# Forecast Derivation Internals
|
||||
|
||||
This document describes deterministic forecast summarization in
|
||||
`internal/forecast`.
|
||||
`internal/forecast` deterministically selects and summarizes normalized
|
||||
forecast data. It has no transport, filesystem, CLI, subprocess, or report
|
||||
registry dependency. The report-scoped caller is [fact
|
||||
contracts](facts.md), which owns the choice of data required by each report.
|
||||
|
||||
## Purpose
|
||||
## Daily Derivation
|
||||
|
||||
`internal/forecast` converts normalized weather data into daily and period
|
||||
summaries used by fact builders and module builders.
|
||||
`BuildDailySummary` builds one summary for one local civil day. The facts
|
||||
layer calls it for Daily, Today, and Tomorrow reports; it does not provide a
|
||||
multi-day or arbitrary-period summary constructor. `timeutil.Period` supplies
|
||||
the shared half-open overlap rule used while selecting source values.
|
||||
|
||||
## Inputs And Outputs
|
||||
`ResolveDayparts` turns configured local clock ranges into windows. A range
|
||||
whose end is not after its start continues into the next civil day. The
|
||||
available daypart and timezone settings are defined in the
|
||||
[configuration reference](../config.md).
|
||||
|
||||
Inputs:
|
||||
The summary keeps selected hourly and narrative values, the discussion,
|
||||
source warnings and provenance, alert overlaps, and one summary for each
|
||||
resolved daypart. Daypart summaries derive their measurements, conditions,
|
||||
weather indicators, and precipitation timing from normalized forecast
|
||||
periods. `BuildPrecipTiming` is also available to the facts layer for a
|
||||
report's selected hourly periods.
|
||||
|
||||
- `weatherdata.Bundle`
|
||||
- local date or resolved report period
|
||||
- timezone
|
||||
- configured daypart definitions
|
||||
## Boundaries And Failures
|
||||
|
||||
Outputs:
|
||||
Daily-summary construction requires a bundle with hourly forecast data,
|
||||
valid precipitation probabilities, and valid daypart definitions. Optional
|
||||
normalized products remain absent when unavailable. Invalid alerts are ignored
|
||||
while valid overlaps are selected for the relevant day or daypart window.
|
||||
|
||||
- `forecast.DailySummary` for one local civil day
|
||||
- one clipped daily summary per local day or partial day from
|
||||
`BuildPeriodDailySummaries`
|
||||
- daypart summaries with selected hourly periods, ranges, timed maximums,
|
||||
conditions, indicators, and alert overlaps
|
||||
Thresholds, text classification, unit normalization, and alert selection are
|
||||
package implementation rules. Report identity, period selection, and the
|
||||
resulting derived-fact shape are owned by [fact contracts](facts.md); external
|
||||
source semantics are owned by [weather-data internals](weather-data.md).
|
||||
|
||||
## Boundaries
|
||||
## Verification
|
||||
|
||||
- This package groups, selects, and summarizes already-normalized forecast
|
||||
data.
|
||||
- It does not perform HTTP calls, parse CLI flags, resolve report definitions,
|
||||
compare prior snapshots, build prompt input packages, or invoke Scriptorium.
|
||||
Focused `internal/forecast` tests exercise daily and overnight dayparts,
|
||||
summary derivation, invalid precipitation data, precipitation timing, and
|
||||
alert overlap handling. `internal/facts` tests cover the report-scoped caller:
|
||||
|
||||
## Config Fields Used
|
||||
|
||||
- `dayparts[].name`
|
||||
- `dayparts[].start`
|
||||
- `dayparts[].end`
|
||||
|
||||
Threshold constants for basic indicators live in forecast code rather than
|
||||
configuration.
|
||||
|
||||
## External Adapters Used
|
||||
|
||||
None directly. Forecast data arrives through `weatherdata.Bundle`.
|
||||
|
||||
## State Or Manifest Behavior
|
||||
|
||||
None. Source warnings and provenance from the bundle are carried into summaries
|
||||
for later metadata and module output.
|
||||
|
||||
## Skip And Resume Behavior
|
||||
|
||||
None. Missing optional source context can produce empty selections, but missing
|
||||
required hourly data fails summarization.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- A nil bundle or missing hourly forecast data returns an error.
|
||||
- Invalid daypart definitions return parse errors with context.
|
||||
- Alert records without parseable RFC3339 timing are skipped.
|
||||
- Empty selected periods produce empty summaries rather than generated prose.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/forecast/derive_test.go`
|
||||
- `internal/timeutil/periods_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Go owns report-period selection and meteorological summarization.
|
||||
- Weather facts come from normalized source data.
|
||||
- Outputs remain JSON-inspectable and independent of CLI, state, and adapters.
|
||||
```sh
|
||||
go test ./internal/forecast ./internal/facts
|
||||
```
|
||||
|
||||
76
docs/internal/generatedtext.md
Normal file
76
docs/internal/generatedtext.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# 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` requires the exact report, schema, and
|
||||
template triple and rejects unknown IDs, unsupported pairs, and a pair that
|
||||
belongs to another report before the run begins. A handler validates and
|
||||
normalizes raw JSON into a typed value, 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 requires the exact lowercase JSON field names,
|
||||
rejects missing, duplicate, case-variant, and unknown fields, and checks field
|
||||
shapes; no general-purpose JSON Schema engine is used at runtime.
|
||||
|
||||
The validator accepts at most 64 KiB of raw JSON before it allocates typed
|
||||
values. Its JSON Schemas and typed checks limit `summary` and
|
||||
`precipitation_timing` to 4,000 characters each. Hourly
|
||||
`forecast_discussion` is limited to 12,000 characters. Day-style discussion
|
||||
accepts at most 12 paragraphs of at most 4,000 characters each. Across all
|
||||
prose fields, one report may contain at most 20,000 characters. These bounds
|
||||
apply before trimming, filtering, normalization, and template rendering.
|
||||
|
||||
Malformed JSON and field values return short, content-safe errors. They name
|
||||
only canonical fields where useful and never echo provider values or unknown
|
||||
field names. The Promptkit adapter also drops an oversized provider result
|
||||
before copying it into execution or debug state; direct executor implementations
|
||||
receive the same enforcement in this package.
|
||||
|
||||
## Render contexts
|
||||
|
||||
The catalog's report-specific builders receive the prepared report identity, a
|
||||
rich module snapshot, derived facts needed to order dayparts, and the matching
|
||||
validated generated text. They require the identity's report ID to match the
|
||||
selected builder. When the optional metadata stanza is present, every shared
|
||||
identity field must agree with that prepared authority before context
|
||||
construction continues. Builders then decode the module stanzas needed by the
|
||||
template and build typed Daily, Today, Tomorrow, or Hourly contexts. Contexts
|
||||
expose only display-ready report values, generated prose, and module values;
|
||||
they do not expose complete collected or derived fact bundles. Ordered slices
|
||||
remain the template iteration surface rather than maps.
|
||||
|
||||
Optional source stanzas become nil or fallback context fields. Today also
|
||||
computes whether its ordered dayparts contain a displayable condition so the
|
||||
template can render either rows or its explicit no-details fallback. Missing
|
||||
required stanzas, type-decoding failures, conflicting identity values, 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. The renderer applies its plain-text policy to every
|
||||
generated prose insertion, preserving ordinary text and paragraph breaks while
|
||||
preventing provider text from creating Markdown or HTML structure. Every report
|
||||
definition must resolve to exactly one supported catalog pair.
|
||||
@@ -1,98 +1,67 @@
|
||||
# Module Contract Internals
|
||||
|
||||
This document describes the module contract in `internal/module`.
|
||||
`internal/module` defines the 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).
|
||||
|
||||
## Purpose
|
||||
## Outputs and snapshots
|
||||
|
||||
`internal/module` defines the shared identifiers and data envelopes used for
|
||||
prompt-facing modules. Report definitions use module IDs for composition,
|
||||
module builders produce outputs with stanza names, prompt input packages consume
|
||||
snapshots, and Recent Changes compares snapshot stanzas.
|
||||
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.
|
||||
|
||||
## Inputs And Outputs
|
||||
`NewSnapshot` builds and validates the ordered in-memory snapshot. Its JSON
|
||||
representation carries a package-owned schema marker, 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.
|
||||
|
||||
Inputs:
|
||||
Snapshots reject missing schema versions, empty IDs or stanza names, and
|
||||
duplicate IDs or stanza names. Output order is caller-owned and preserved.
|
||||
|
||||
- ordered `module.ConfigItem` values from report definitions or config
|
||||
overrides
|
||||
- `module.Output` values produced by module builders
|
||||
## Registered IDs and default composition
|
||||
|
||||
Outputs:
|
||||
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`.
|
||||
|
||||
- stable `module.ID` constants
|
||||
- typed option structs for registered modules
|
||||
- `module.Snapshot` with schema version `weatherreporter.modules.v1`
|
||||
- ordered snapshot outputs with module ID, stanza name, and typed value
|
||||
- typed stanza lookup through `module.StanzaValue`
|
||||
The registry declares these ordered default compositions:
|
||||
|
||||
## Registered Module IDs
|
||||
| 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 registry recognizes these IDs:
|
||||
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. Accepted typed option pointers are
|
||||
canonicalized to the declared value type before a module builder receives them.
|
||||
|
||||
- `metadata`
|
||||
- `current_conditions`
|
||||
- `narrative_forecast`
|
||||
- `hourly_forecast`
|
||||
- `derived_daily_summary`
|
||||
- `derived_daypart_summaries`
|
||||
- `precip_timing`
|
||||
- `alert_digest`
|
||||
- `area_forecast_discussion`
|
||||
- `weather_story`
|
||||
- `outdoor_windows`
|
||||
- `tomorrow_planning`
|
||||
## Rich and prompt-facing values
|
||||
|
||||
Every registered module has a builder. Report composition entries that refer to
|
||||
unknown or unimplemented module IDs fail validation instead of being skipped.
|
||||
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).
|
||||
|
||||
## Options
|
||||
## Verification and invariants
|
||||
|
||||
Most modules use an empty options struct. `area_forecast_discussion` accepts:
|
||||
Focused tests cover snapshot validation and order, typed stanza lookup, and
|
||||
prompt-value fallback:
|
||||
|
||||
```yaml
|
||||
sections:
|
||||
- product
|
||||
- key_messages
|
||||
- short_term
|
||||
- long_term
|
||||
```sh
|
||||
go test ./internal/module
|
||||
```
|
||||
|
||||
An omitted or empty `sections` list includes all available discussion sections.
|
||||
Invalid option shapes fail during config normalization or composition
|
||||
validation.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- This package owns module identifiers, config item envelopes, output
|
||||
envelopes, snapshot validation, and typed stanza lookup.
|
||||
- It does not define report IDs, execute builders, fetch weather data, derive
|
||||
forecast facts, write state, or invoke Scriptorium.
|
||||
|
||||
## State Or Manifest Behavior
|
||||
|
||||
`module.Snapshot` values are persisted by `internal/state` as JSON. Snapshot
|
||||
validation rejects missing schema version, missing module IDs, missing stanza
|
||||
names, duplicate module outputs, and duplicate stanza names while preserving
|
||||
output order.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- Snapshot construction fails for duplicate module outputs or duplicate stanza
|
||||
names.
|
||||
- Typed stanza lookup returns `found=false` for missing stanzas.
|
||||
- Typed stanza lookup wraps JSON marshal/decode failures with stanza context.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/module/module_test.go`
|
||||
- `internal/briefing/modules_test.go`
|
||||
- `internal/report/period_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- `internal/module` does not import `internal/report`.
|
||||
- Module IDs are stable strings.
|
||||
- Each emitted module output has exactly one stanza name and one typed value.
|
||||
- Snapshot output order is caller-owned and preserved.
|
||||
Module IDs and stanza names are stable, every emitted output has one of each,
|
||||
and this package never imports the report registry.
|
||||
|
||||
37
docs/internal/prepared-report.md
Normal file
37
docs/internal/prepared-report.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Prepared Report Internals
|
||||
|
||||
`internal/app` validates the report's generated-text catalog binding during
|
||||
prompt inspection, before collection, and carries the resulting handler into
|
||||
`preparedReport` construction after collection. This is the immutable boundary
|
||||
shared by ordinary report generation and profile comparison; it is not a
|
||||
durable artifact.
|
||||
|
||||
Preparation first establishes one `PreparedIdentity` for the report run, report
|
||||
and prompt IDs, variant, generation time, units, timezone, valid period,
|
||||
location, and source warnings. It passes that identity to the configured module
|
||||
snapshot, curated prompt-input package, serialized YAML, generated-text render
|
||||
context, and generated-text definition. Each boundary projects only the fields
|
||||
it needs from that prepared authority.
|
||||
|
||||
Preparation deep-copies mutable facts, snapshots, identity, and data-package
|
||||
bytes before returning them. Consumers receive independent copies so one
|
||||
execution cannot change another's input or rendering context.
|
||||
|
||||
Before accepting generated JSON, the execution boundary reconciles the prepared
|
||||
report definition, inspected prompt hash and selected profile identity, the one
|
||||
preparation callback, and the completed Promptkit result. The callback and
|
||||
completion must agree on prompt, profile, backend, model, and rendered/input
|
||||
hashes; the callback output and completed validation must name the prepared
|
||||
report's JSON Schema. A mismatch produces no rendered Markdown and leaves
|
||||
results with only the inspected safe identity.
|
||||
|
||||
Single-report generation executes one prepared profile and publishes its
|
||||
Markdown. Comparison prepares once, gives every selected profile the same YAML
|
||||
bytes, and only then assembles the resulting logical bundle. The prompt-input
|
||||
shape is owned by [prompt-input internals](prompt-input.md); profile execution
|
||||
semantics are owned by [Promptkit integration](../integrations/promptkit.md).
|
||||
|
||||
Catalog incompatibility stops prompt inspection before weather collection or
|
||||
model work. Preparation failure has no publication side effects. Tests for this
|
||||
boundary cover catalog-preflight ordering, mutation isolation, byte equality,
|
||||
and reuse by both execution paths.
|
||||
@@ -1,121 +1,36 @@
|
||||
# Prompt Input Internals
|
||||
|
||||
This document describes YAML prompt data package construction in
|
||||
`internal/promptinput`.
|
||||
`internal/promptinput` turns prepared report metadata and an ordered module
|
||||
snapshot into the YAML data package passed to Promptkit. The externally visible
|
||||
prompt and inline-input contract is owned by the [Promptkit integration
|
||||
guide](../integrations/promptkit.md); preparation of the inputs is owned by
|
||||
[prepared report internals](prepared-report.md).
|
||||
|
||||
## Purpose
|
||||
## Package Construction
|
||||
|
||||
`internal/promptinput` converts report metadata, an ordered module snapshot,
|
||||
Recent Changes, and source warnings into the `data_package` file passed to
|
||||
Scriptorium.
|
||||
`Build` projects report identity, the report-local current date, source-warning
|
||||
summaries, and each snapshot output's prompt-facing value into a package. It
|
||||
does not expose source transport or provenance details. The module snapshot
|
||||
defines stanza order and selects curated prompt values; the corresponding
|
||||
module contracts are documented in [module internals](module.md) and [briefing
|
||||
internals](briefing.md).
|
||||
|
||||
The persisted data package is YAML with schema version
|
||||
`weatherreporter.data_package.v2`. It is separate from the JSON module snapshot
|
||||
used for inspection and comparison.
|
||||
`MarshalYAML` validates the package before serializing it. Serialization emits
|
||||
the metadata stanza first, then groups the remaining recognized stanzas in the
|
||||
package's fixed category order while preserving snapshot order within a
|
||||
category. `Validate` enforces the supported schema version, required report
|
||||
identity and period values, and a nonempty, complete ordered briefing.
|
||||
|
||||
## Inputs And Outputs
|
||||
This package does not collect weather, choose an output destination, execute a
|
||||
provider, or persist data packages. The application passes its in-memory YAML
|
||||
to the Promptkit adapter as part of prepared report execution.
|
||||
|
||||
Inputs:
|
||||
## Verification
|
||||
|
||||
- report metadata from app/state orchestration
|
||||
- `module.Snapshot`
|
||||
- optional `[]changes.Change`
|
||||
Focused tests cover package construction, report-local dates, validation,
|
||||
curated snapshot exports, deterministic YAML grouping, and safe source-warning
|
||||
projection:
|
||||
|
||||
Outputs:
|
||||
|
||||
- `promptinput.Package` with schema version, RunID, report metadata, named
|
||||
module stanzas grouped for prompt presentation, Recent Changes, and source
|
||||
warnings
|
||||
- YAML bytes from `promptinput.MarshalYAML`
|
||||
- YAML file written atomically by `promptinput.Save`
|
||||
|
||||
The YAML shape includes:
|
||||
|
||||
```yaml
|
||||
schema_version: weatherreporter.data_package.v2
|
||||
run_id: <run_id>
|
||||
report:
|
||||
id: <report_id>
|
||||
prompt_id: <prompt_id>
|
||||
briefing:
|
||||
metadata: {}
|
||||
applicable_risk_products:
|
||||
alert_digest: {}
|
||||
derived_summaries:
|
||||
derived_daily_summary: {}
|
||||
derived_daypart_summaries: {}
|
||||
precip_timing: {}
|
||||
outdoor_windows: {}
|
||||
narrative_products:
|
||||
narrative_forecast: {}
|
||||
area_forecast_discussion: {}
|
||||
weather_story: {}
|
||||
raw_data:
|
||||
current_conditions: {}
|
||||
hourly_forecast: {}
|
||||
recent_changes:
|
||||
items: []
|
||||
```sh
|
||||
go test ./internal/promptinput
|
||||
```
|
||||
|
||||
The `briefing` mapping keeps `metadata` directly under `briefing` and groups
|
||||
weather module stanzas under prompt-facing categories. This grouping is a YAML
|
||||
presentation concern only: module snapshots remain flat, and loaded
|
||||
`promptinput.Package` values expose flat stanza names in `Briefing.Values`.
|
||||
Within each category, stanza order follows the module snapshot output order.
|
||||
|
||||
Current categories are:
|
||||
|
||||
- `applicable_risk_products`: location-applicable alerts, warnings, outlooks,
|
||||
discussions, and similar risk products.
|
||||
- `derived_summaries`: deterministic summaries and calculated report facts.
|
||||
- `narrative_products`: official narrative text products and forecast stories.
|
||||
- `raw_data`: minimally transformed underlying weather data.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- This package owns prompt package schema, YAML marshaling, YAML loading, and
|
||||
validation.
|
||||
- It does not fetch weather data, derive forecast summaries, execute modules,
|
||||
find prior snapshots, compare changes, choose artifact paths, or invoke
|
||||
Scriptorium.
|
||||
|
||||
## Config Fields Used
|
||||
|
||||
None directly. Config-derived values such as timezone, units, and prompt
|
||||
location are already present in report metadata and module stanzas before this
|
||||
package runs.
|
||||
|
||||
## External Adapters Used
|
||||
|
||||
None.
|
||||
|
||||
## State Or Manifest Behavior
|
||||
|
||||
`promptinput.Save` writes YAML atomically. Managed workspace paths are owned by
|
||||
`internal/state`.
|
||||
|
||||
## Skip And Resume Behavior
|
||||
|
||||
None. Recent Changes is always present as an `items` list and may be empty.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
Validation fails before render preflight when required top-level fields are
|
||||
missing or inconsistent, when the valid period is invalid, or when no module
|
||||
stanzas are present. Save failures include filesystem operation and path
|
||||
context.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/promptinput/package_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Scriptorium receives structured YAML through `--input data_package=<path>`.
|
||||
- Module stanza order is deterministic within each prompt-facing category.
|
||||
- Every non-metadata module stanza has exactly one prompt-input category.
|
||||
- Recent Changes are provided by `internal/changes`; this package does not
|
||||
infer changes from rendered report text.
|
||||
|
||||
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,99 +1,38 @@
|
||||
# Report Registry Internals
|
||||
|
||||
This document describes report identity, valid-period resolution, batch
|
||||
membership, output naming, artifact grouping, and comparison declarations in
|
||||
`internal/report`.
|
||||
`internal/report` owns the in-process registry of report identities and the
|
||||
resolution of a report's valid period. Command names and configuration aliases
|
||||
belong to the [CLI reference](../cli.md) and [configuration
|
||||
reference](../config.md), respectively.
|
||||
|
||||
## Purpose
|
||||
## Registry And Resolution
|
||||
|
||||
`internal/report` is the canonical source for report definitions. App, state,
|
||||
module building, and CLI wiring consume resolved definitions instead of owning
|
||||
report identity policy themselves.
|
||||
`DefaultRegistry` supplies the maintained definitions. `Lookup` returns a
|
||||
definition by its internal ID, while `Resolve` combines it with a request time,
|
||||
location, and optional date to produce `Resolved`. The result carries the
|
||||
definition, generation time, timezone, and resolved valid period; its metadata
|
||||
and output-name helpers keep derived identity values consistent for callers.
|
||||
|
||||
## Definition Fields
|
||||
Definitions carry the internal collaborators needed downstream: prompt and
|
||||
template identity, module configuration, output naming, Distributor path
|
||||
templates, and fixed batch eligibility. The external prompt contract is owned
|
||||
by the [Promptkit integration guide](../integrations/promptkit.md), template
|
||||
surface by the [report template guide](../templates.md), and published
|
||||
Distributor paths by the [Distributor bundle guide](../integrations/distributor/pkg-bundle.md).
|
||||
|
||||
Each report definition declares:
|
||||
`WithModuleOverrides` returns an independently cloned registry with replacement
|
||||
module configuration for recognized report IDs. The application owns batch
|
||||
planning and data-dependent inclusion; see [app orchestration
|
||||
internals](app-orchestration.md).
|
||||
|
||||
- report ID and display name
|
||||
- Scriptorium prompt ID
|
||||
- valid-period resolver
|
||||
- comparison strategy
|
||||
- managed artifact group
|
||||
- batch output copy filename
|
||||
- generated-report eligibility
|
||||
- prior-report compatibility list
|
||||
- morning or evening batch membership
|
||||
- default ordered module composition
|
||||
The registry never collects weather data, parses CLI flags, writes output,
|
||||
executes Promptkit, or delivers a report.
|
||||
|
||||
## Reports
|
||||
## Verification
|
||||
|
||||
| Report | ID | Prompt | Artifact group | Batch copy | Prior compatibility |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Daily Today | `daily_today` | `weather.daily_report` | `daily` | `daily.md` | Daily Today, Daily Tomorrow |
|
||||
| Daily Tomorrow | `daily_tomorrow` | `weather.daily_report` | `daily` | `tomorrow.md` | Daily Today, Daily Tomorrow |
|
||||
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `three-day` | `three-day.md` | 3-Day Outlook |
|
||||
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `weekend` | `weekend.md` | Weekend Outlook |
|
||||
| Storm Report | `storm` | `weather.storm_report` | `storm` | `storm.md` | Storm Report |
|
||||
Focused tests protect retained report definitions, period resolution, Daily
|
||||
run-ID disambiguation, and rejection of retired command or configuration names:
|
||||
|
||||
All report definitions are eligible for generation.
|
||||
|
||||
## Valid Periods
|
||||
|
||||
- Daily Today covers the selected local civil day, or the current local civil
|
||||
day when no date override is supplied.
|
||||
- Daily Tomorrow covers the next local civil day from generation time.
|
||||
- 3-Day Outlook covers the interval from generation time through local midnight
|
||||
three days later.
|
||||
- Weekend Outlook covers the upcoming weekend window and is not scheduled for
|
||||
Sunday morning batch resolution.
|
||||
- Storm Report covers an explicit event window supplied by the caller.
|
||||
|
||||
Storm event windows can be parsed from local `YYYY-MM-DDTHH:MM` timestamps in
|
||||
the configured timezone or RFC3339 timestamps with explicit offsets. End time
|
||||
must be after start time.
|
||||
|
||||
## Boundaries
|
||||
|
||||
`internal/report` defines report metadata and time coverage. It does not fetch
|
||||
weather data, build module values, compare snapshot contents, write state,
|
||||
parse CLI flags, or invoke Scriptorium.
|
||||
|
||||
The CLI owns public command names. The app maps those command names to report
|
||||
IDs, then uses the registry for report policy.
|
||||
|
||||
## Config Fields Used
|
||||
|
||||
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
|
||||
output path copying uses batch output names from report definitions.
|
||||
|
||||
## State And App Usage
|
||||
|
||||
- State paths use `ArtifactGroup`.
|
||||
- Batch output copies use `BatchOutputName`.
|
||||
- Generation checks `Generated`.
|
||||
- Module composition defaults use `Modules`.
|
||||
- Prior lookup checks `CompatiblePriorIDs` and the comparison strategy.
|
||||
- RunIDs include the resolved report ID.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- Unknown report IDs and batch names return actionable errors.
|
||||
- Weekend Outlook resolution returns an error when resolved directly on Sunday.
|
||||
- Storm Report resolution requires start and end, with end after start.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/report/period_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
- `internal/cli/root_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Report selection goes through the registry.
|
||||
- Daily Today and Daily Tomorrow both use `weather.daily_report`.
|
||||
- Valid periods are half-open intervals independent of rendered report text.
|
||||
- Artifact grouping, batch output filenames, generated-report eligibility,
|
||||
default module composition, comparison compatibility, and comparison strategy
|
||||
are declared by report definition.
|
||||
```sh
|
||||
go test ./internal/report
|
||||
```
|
||||
|
||||
56
docs/internal/reporttemplate.md
Normal file
56
docs/internal/reporttemplate.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# 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 or complete fact bundles, and it must not reimplement weather
|
||||
selection or generated-text validation. Context construction rejects
|
||||
report-identity disagreements before template execution. Every generated-prose
|
||||
insertion uses the `plainText` helper. It retains ordinary prose and paragraph
|
||||
breaks but renders Markdown/HTML syntax, code indentation, and control
|
||||
characters as safe text, so the repository templates remain the sole owners of
|
||||
report structure.
|
||||
|
||||
## 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, daypart
|
||||
fallbacks, 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,101 +0,0 @@
|
||||
# Scriptorium Adapter Internals
|
||||
|
||||
This document describes the subprocess adapter in
|
||||
`internal/adapters/scriptorium`.
|
||||
|
||||
## Purpose
|
||||
|
||||
The adapter runs `scriptorium render` for prompt preflight and `scriptorium run`
|
||||
for Markdown report generation. It isolates subprocess execution, argv
|
||||
construction, timeout handling, output capture, and exit-code interpretation
|
||||
from app and domain packages.
|
||||
|
||||
## Inputs And Outputs
|
||||
|
||||
Inputs:
|
||||
|
||||
- prompt ID
|
||||
- YAML prompt input data package path
|
||||
- report output path for `run`
|
||||
- configured binary, config path, profile, timeout, and extra arguments
|
||||
- context for cancellation
|
||||
|
||||
Outputs:
|
||||
|
||||
- argv used for execution
|
||||
- captured stdout and stderr
|
||||
- truncation flags for captured output
|
||||
- exit code
|
||||
- report output path for `run`
|
||||
|
||||
## Boundaries
|
||||
|
||||
`internal/adapters/scriptorium` owns Scriptorium command construction and
|
||||
subprocess execution. It does not choose report types, build prompt input,
|
||||
fetch weather data, decide workflow order, or persist workflow metadata.
|
||||
|
||||
The adapter exposes request and result structs for render and run operations.
|
||||
State persistence uses a state-owned preflight artifact shape; app
|
||||
orchestration converts render results before saving.
|
||||
|
||||
## Config Fields Used
|
||||
|
||||
- `scriptorium.binary`
|
||||
- `scriptorium.config_path`
|
||||
- `scriptorium.profile`
|
||||
- `scriptorium.timeout`
|
||||
- `scriptorium.extra_args`
|
||||
|
||||
## Commands
|
||||
|
||||
Render preflight argv starts with:
|
||||
|
||||
```text
|
||||
scriptorium render --prompt <prompt_id> --input data_package=<path> --format json
|
||||
```
|
||||
|
||||
Report generation argv starts with:
|
||||
|
||||
```text
|
||||
scriptorium run --prompt <prompt_id> --input data_package=<path> --out <path>
|
||||
```
|
||||
|
||||
Configured `--config` and `--profile` flags are inserted after the subcommand
|
||||
and before prompt-specific arguments. Extra arguments are appended after the
|
||||
built-in arguments.
|
||||
|
||||
## Execution Behavior
|
||||
|
||||
The adapter runs commands without shell interpolation. The same private
|
||||
execution path is used by render and run after command-specific request
|
||||
validation and argv construction.
|
||||
|
||||
When `scriptorium.timeout` is greater than zero, each subprocess call uses a
|
||||
context with that timeout. Stdout and stderr are captured separately, capped at
|
||||
1 MiB each, and marked as truncated when the cap is reached.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- Missing prompt ID or data package path returns an error before subprocess
|
||||
execution.
|
||||
- Missing run output path returns an error before subprocess execution.
|
||||
- Subprocess start errors, context cancellation, and timeouts are wrapped with
|
||||
operation context by the caller-facing method.
|
||||
- Nonzero render and run exits return the captured result plus an error
|
||||
containing the exit code and stderr.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/adapters/scriptorium/runner_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
- `internal/cli/root_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- No shell interpolation is used.
|
||||
- The Scriptorium input name is `data_package`.
|
||||
- The file at the data package path is YAML produced by `internal/promptinput`.
|
||||
- Render and run preserve command-specific result structs.
|
||||
- Scriptorium-specific flags stay inside adapter and config boundaries.
|
||||
@@ -1,124 +0,0 @@
|
||||
# State Internals
|
||||
|
||||
This document describes filesystem state in `internal/state`.
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/state` owns managed workspace paths, atomic JSON writes, persisted
|
||||
metadata, prior snapshot lookup, and read-only artifact inspection helpers.
|
||||
|
||||
## Inputs And Outputs
|
||||
|
||||
Inputs:
|
||||
|
||||
- workspace configuration
|
||||
- resolved report definition and valid period
|
||||
- module snapshot
|
||||
- prompt input data package
|
||||
- preflight artifact
|
||||
- rendered report path preparation request
|
||||
- RunID for inspection lookups
|
||||
|
||||
Outputs:
|
||||
|
||||
- module snapshot JSON path
|
||||
- prompt input data package YAML path
|
||||
- render preflight JSON path
|
||||
- managed Markdown report path
|
||||
- metadata JSON path
|
||||
- prior comparable snapshot metadata
|
||||
- loaded module snapshot or data package
|
||||
- recent report records for inspection
|
||||
|
||||
## Boundaries
|
||||
|
||||
`internal/state` owns local filesystem layout, path validation, durable writes,
|
||||
metadata reads, prior lookup, and report listing. It does not fetch weather
|
||||
data, derive forecasts, build prompt input content, compare module contents,
|
||||
invoke Scriptorium, import adapter result types, or parse CLI flags.
|
||||
|
||||
Preflight persistence uses the state-owned `PreflightArtifact` shape. The app
|
||||
converts adapter render results into that shape before saving.
|
||||
|
||||
## Config Fields Used
|
||||
|
||||
- `workspace.root`
|
||||
- `workspace.snapshots_dir`
|
||||
- `workspace.reports_dir`
|
||||
- `workspace.data_packages_dir`
|
||||
- `workspace.preflight_dir`
|
||||
- `workspace.notifications_dir`
|
||||
|
||||
Workspace subdirectories must be relative paths that stay under
|
||||
`workspace.root`.
|
||||
|
||||
## Managed Layout
|
||||
|
||||
Paths are derived from the resolved report definition's artifact group, the
|
||||
valid-period start date for dated artifacts, and the RunID.
|
||||
|
||||
```text
|
||||
<workspace.root>/
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.modules.json
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.metadata.json
|
||||
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.yaml
|
||||
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json
|
||||
notifications/<artifact_group>/<YYYY-MM-DD>/<run_id>.distributor.json
|
||||
reports/<artifact_group>/<run_id>.md
|
||||
```
|
||||
|
||||
Metadata is stored beside module snapshots and links the module snapshot, data
|
||||
package, preflight, report paths, notification path when attempted, and
|
||||
configured prompt location. Report listing walks metadata files under the
|
||||
snapshots directory.
|
||||
|
||||
## Prior Lookup
|
||||
|
||||
Prior snapshot lookup reads stored metadata through the shared lookup path and
|
||||
selects the latest earlier snapshot whose report ID is compatible with the
|
||||
current report definition.
|
||||
|
||||
- Daily Today and Daily Tomorrow are compatible with each other for the same
|
||||
valid local date.
|
||||
- 3-Day Outlook compares with prior 3-Day snapshots for the same valid local
|
||||
date.
|
||||
- Weekend Outlook compares with prior Weekend snapshots for the same weekend
|
||||
window.
|
||||
- Storm Report has no prior lookup because explicit event-window comparison is
|
||||
not searched by the filesystem store.
|
||||
|
||||
## Writes And Inspection
|
||||
|
||||
Durable JSON writes use shared atomic file helpers. Managed Markdown reports are
|
||||
prepared by creating their parent directory; Scriptorium writes the report body
|
||||
to the prepared path. Extra Markdown copies are handled by app orchestration.
|
||||
Distributor notification debug artifacts are written atomically when
|
||||
notification is attempted and include rendered distributor pipeline ID, bundle
|
||||
ID, idempotency key, bundle paths, upload status, latest run status, and
|
||||
redacted errors.
|
||||
|
||||
Inspection helpers read existing metadata, module snapshot, and data package
|
||||
files. Missing metadata directories return no inspection records or no prior
|
||||
snapshot rather than creating state.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- Invalid workspace paths return validation errors.
|
||||
- Missing required metadata fields prevent metadata writes.
|
||||
- JSON writes use a temporary file followed by rename where practical.
|
||||
- Read and decode failures include path context.
|
||||
- Unknown RunIDs produce an actionable lookup error.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/state/filesystem_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Managed paths stay under the configured workspace root.
|
||||
- Artifact grouping comes from report definitions.
|
||||
- Metadata links artifacts produced for a run.
|
||||
- Prior lookup is based on structured metadata, not rendered report text.
|
||||
@@ -1,88 +1,76 @@
|
||||
# Weather Data Internals
|
||||
|
||||
This document describes Weather API ingestion into `weatherdata.Bundle`.
|
||||
`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 the bundle consumed by forecast
|
||||
derivation and module builders. Module builders expose normalized current
|
||||
conditions and weather story context when those sources are available.
|
||||
`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 |
|
||||
|
||||
Inputs:
|
||||
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 URL, timeout, format, units, timezone,
|
||||
precision, and missing-source policy
|
||||
- HTTP responses using the Weather API `data` envelope
|
||||
An alert run retains its check time and individual alert payloads for overlap
|
||||
selection. Its source entry retains provider provenance; the full provider
|
||||
envelope is not carried into the normalized bundle.
|
||||
|
||||
Outputs:
|
||||
## Source provenance
|
||||
|
||||
- `weatherdata.Bundle` with observation, current conditions, hourly forecast,
|
||||
narrative forecast, active alerts, discussion, latest weather story, source
|
||||
records, and source warnings
|
||||
- optional saved bundle JSON through app fetch helpers
|
||||
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.
|
||||
|
||||
## Boundaries
|
||||
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).
|
||||
|
||||
- The adapter owns HTTP calls, response-envelope handling, source hashing, and
|
||||
decoding into internal bundle types.
|
||||
- It does not derive dayparts, resolve report periods, build module values, compare
|
||||
snapshots, write report state, or invoke Scriptorium.
|
||||
Accepted hourly forecast periods always have nonzero start and end times, with
|
||||
the end after the start. Collection rejects a required hourly product that does
|
||||
not meet those bounds before it enters downstream derivation.
|
||||
|
||||
## Config Fields Used
|
||||
## Warning semantics
|
||||
|
||||
- `weather_api.base_url`
|
||||
- `weather_api.timeout`
|
||||
- `weather_api.format`
|
||||
- `weather_api.units`
|
||||
- `weather_api.timezone`
|
||||
- `weather_api.precision`
|
||||
- `missing_source.default`
|
||||
- `missing_source.sources`
|
||||
`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 Adapters Used
|
||||
Warnings describe data completeness, not rendering or delivery failures.
|
||||
Those failures are reported by [application orchestration](app-orchestration.md).
|
||||
|
||||
- Weather API HTTP service
|
||||
## Boundaries and verification
|
||||
|
||||
See [Weather API integration](../integrations/weatherapi.md) for the external
|
||||
contract used by this project.
|
||||
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:
|
||||
|
||||
## State Or Manifest Behavior
|
||||
|
||||
The adapter records source name, endpoint, query, fetch time, source timestamps
|
||||
when available, SHA-256 hash over compact raw `data` JSON, missing status, and
|
||||
source warnings. Successful `data: null` responses from `/alerts/active`
|
||||
represent a checked empty active-alert list, not a missing source.
|
||||
`app.FetchAndSaveBundle` can write bundle JSON atomically for inspection.
|
||||
|
||||
## Skip And Resume Behavior
|
||||
|
||||
No resume behavior. Optional missing or malformed sources may be omitted,
|
||||
warned, or treated as errors according to missing-source policy. Hourly forecast
|
||||
data is required and cannot be skipped.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- Missing or invalid `weather_api.base_url` prevents client construction.
|
||||
- HTTP errors, response read failures, and envelope decode failures include
|
||||
endpoint context.
|
||||
- Missing hourly data or hourly forecasts with no periods fail bundle fetch.
|
||||
- Optional sources follow missing-source policy.
|
||||
- Explicit `data: null` from `/alerts/active` produces an empty, non-missing
|
||||
alert run.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/adapters/weatherapi/client_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Weather facts come from normalized source data.
|
||||
- Full hourly and narrative products are fetched; Go owns report-period
|
||||
selection.
|
||||
- Source provenance and warnings remain inspectable downstream.
|
||||
```sh
|
||||
go test ./internal/weatherdata
|
||||
go test ./internal/adapters/weatherapi
|
||||
```
|
||||
|
||||
@@ -1,257 +1,227 @@
|
||||
# Weatherreporter Operations
|
||||
|
||||
This guide covers normal operation, generated artifacts, inspection, recovery,
|
||||
and operational caveats. For symptom-specific diagnosis, see
|
||||
[Troubleshooting](troubleshooting.md).
|
||||
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.
|
||||
|
||||
## Normal Workflow
|
||||
## Normal Operation
|
||||
|
||||
Generation commands:
|
||||
After configuring a Weather API endpoint, generate one report:
|
||||
|
||||
```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
|
||||
```sh
|
||||
weatherreporter generate today
|
||||
```
|
||||
|
||||
Each command resolves a report period, fetches a Weather API bundle, builds a
|
||||
JSON module snapshot, builds a YAML prompt input data package, runs
|
||||
`scriptorium render`, runs `scriptorium run`, and writes managed artifacts under
|
||||
the configured workspace.
|
||||
When distributor notification is enabled, weatherreporter uploads the managed
|
||||
Markdown report after `scriptorium run` succeeds and final metadata is saved.
|
||||
`--out PATH` writes an extra Markdown copy for the current generated report; it
|
||||
is not used as the distributor upload source.
|
||||
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.
|
||||
|
||||
Batch commands:
|
||||
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.
|
||||
|
||||
```text
|
||||
weatherreporter run morning
|
||||
weatherreporter run evening
|
||||
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.
|
||||
|
||||
Weatherreporter validates the final output filename before prompt inspection or
|
||||
weather collection. A valid long filename is published through a short,
|
||||
same-directory temporary sibling, so temporary naming does not shorten the
|
||||
operator-selected destination. A rejected filename does not create a missing
|
||||
parent directory. The final destination itself must be absent or a regular
|
||||
file: symlinks, directories, named pipes, sockets, and other special objects
|
||||
are rejected before prompt inspection or weather collection. The destination is
|
||||
checked again immediately before the atomic replacement; cancellation or a
|
||||
deadline at that point leaves the prior report unchanged and skips notification.
|
||||
|
||||
`SIGINT` and `SIGTERM` request orderly cancellation of an active action. The
|
||||
command lets cancellation and related cleanup finish before it exits; use the
|
||||
usual failed result or error to determine whether an output was published.
|
||||
|
||||
## Batch Outputs And Distributor Notification
|
||||
|
||||
Run a scheduled batch with an explicit output directory when appropriate:
|
||||
|
||||
```sh
|
||||
weatherreporter run morning --out-dir ./reports
|
||||
```
|
||||
|
||||
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
||||
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch
|
||||
commands print a JSON summary to stdout, write compact per-report status lines
|
||||
to stderr, continue independent reports after one report fails, and return
|
||||
nonzero when any report failed. When notification is configured, the summary and
|
||||
status lines include notification status, accepted distributor run ID, or
|
||||
notification error fields for each attempted report. `--out-dir PATH` writes
|
||||
extra Markdown copies using report default filenames such as `daily.md`,
|
||||
`three-day.md`, `weekend.md`, and `tomorrow.md`; these copies are not used as
|
||||
distributor upload sources.
|
||||
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. If cancellation or a deadline is observed during the
|
||||
sequence, Weatherreporter stops before starting another report. It retains
|
||||
already published files, marks interrupted and unstarted reports as canceled in
|
||||
the result, and skips batch notification.
|
||||
|
||||
## Filesystem Layout
|
||||
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.
|
||||
Remote Distributor response text is not included in command output. Instead,
|
||||
notification failures use stable local diagnostics while retaining the upload
|
||||
and status identities needed to investigate delivery with Distributor.
|
||||
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.
|
||||
|
||||
The default workspace root is `workspace`.
|
||||
For a single report, Distributor notification follows the atomic output write.
|
||||
Enabled notification configuration, including the HTTP(S) endpoint and
|
||||
templates, is validated before report processing. A malformed endpoint does not
|
||||
collect weather data, generate a report, publish output, or invoke Distributor.
|
||||
See the [configuration reference](config.md) for endpoint, pipeline, bundle,
|
||||
idempotency-key, and per-report path templates.
|
||||
|
||||
```text
|
||||
workspace/
|
||||
snapshots/
|
||||
daily/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.modules.json
|
||||
<run_id>.metadata.json
|
||||
three-day/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.modules.json
|
||||
<run_id>.metadata.json
|
||||
weekend/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.modules.json
|
||||
<run_id>.metadata.json
|
||||
storm/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.modules.json
|
||||
<run_id>.metadata.json
|
||||
data-packages/
|
||||
daily/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.yaml
|
||||
three-day/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.yaml
|
||||
weekend/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.yaml
|
||||
storm/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.yaml
|
||||
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
|
||||
notifications/
|
||||
daily/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.distributor.json
|
||||
three-day/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.distributor.json
|
||||
weekend/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.distributor.json
|
||||
storm/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.distributor.json
|
||||
reports/
|
||||
daily/
|
||||
<run_id>.md
|
||||
three-day/
|
||||
<run_id>.md
|
||||
weekend/
|
||||
<run_id>.md
|
||||
storm/
|
||||
<run_id>.md
|
||||
## Comparison Bundles
|
||||
|
||||
Use `compare` when an operator needs to evaluate explicit Promptkit profiles
|
||||
against the same report input. The command writes one flat, operator-owned
|
||||
bundle directory and never sends a Distributor notification. Command syntax,
|
||||
profile validation, JSON output, and exit behavior belong to the
|
||||
[CLI reference](cli.md); the durable file contract belongs to the
|
||||
[comparison bundle contract](integrations/comparison-bundle.md).
|
||||
|
||||
The output destination follows the normal `output.directory` fallback. An
|
||||
explicit `--out-dir` takes precedence and names the exact bundle directory,
|
||||
not a parent to be combined with another name. The standard names are derived
|
||||
from the report output name, such as `comparison-today` and
|
||||
`comparison-daily-2026-05-29`; see the [configuration reference](config.md)
|
||||
for output-directory resolution.
|
||||
|
||||
A comparison bundle contains the shared data package, a manifest, and one
|
||||
Markdown file for every successful profile. Treat all of these files as
|
||||
potentially sensitive: the data package and generated reports can contain
|
||||
location or forecast context. Weatherreporter creates no application-owned
|
||||
history, retention store, or cleanup job. Retain, archive, or remove only the
|
||||
specific bundle directories your operating policy permits.
|
||||
|
||||
The destination is preflighted before prompt inspection and collection, then
|
||||
rechecked immediately before an atomic publish. A missing or empty directory
|
||||
is usable. A nonempty directory can be replaced only when `--replace` is given
|
||||
and it is recognized as a current Weatherreporter comparison bundle; ordinary
|
||||
directories, symlinks, and unsafe destinations are rejected. Cancellation and
|
||||
all failures before publication preserve an existing bundle, including a
|
||||
cancellation observed while a replacement is being prepared. If guarded
|
||||
restoration cannot complete, the error names the retained sibling bundle for
|
||||
manual recovery. Profile failures are different: the command publishes a
|
||||
complete partial bundle, with failed profiles represented in the manifest and
|
||||
no Markdown file for those profiles.
|
||||
Comparison preflight also checks that private publication siblings can be
|
||||
formed. An infeasible destination name is rejected before a missing parent
|
||||
directory is created.
|
||||
|
||||
## Local Prompt Profile Override
|
||||
|
||||
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
|
||||
period 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. Comparison
|
||||
captures additionally identify each selected profile so concurrent executions
|
||||
remain distinct. Normal output, summaries, and routine logs omit that sensitive
|
||||
content. Debug capture is never created for an ordinary command without
|
||||
`--llm-debug-dir`.
|
||||
|
||||
## RunID And Metadata
|
||||
Secure prompt debug capture is currently available only on Unix hosts, where
|
||||
Weatherreporter can keep every traversal and write anchored to opened directory
|
||||
descriptors without following symbolic links. On other platforms, requesting
|
||||
`--llm-debug-dir` fails before prompt inspection, weather collection, or
|
||||
provider execution; ordinary commands without the flag remain available.
|
||||
|
||||
RunIDs are based on generation time plus report ID:
|
||||
Preparation captures retain only the provider endpoint origin and reviewed
|
||||
execution settings. URL user information, paths, queries, fragments, and
|
||||
unrecognized provider parameters are omitted.
|
||||
|
||||
```text
|
||||
20260529T100000.123456789Z_daily_today
|
||||
Capture writes are confined to the requested root and fail if an unsafe
|
||||
filesystem component prevents secure artifact creation.
|
||||
|
||||
If capture creation or writing fails, the affected run fails rather than
|
||||
silently continuing without the requested diagnostics.
|
||||
|
||||
## Diagnosing Failures
|
||||
|
||||
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. For a comparison
|
||||
failure, inspect the published manifest when its path is present: individual
|
||||
profile failures retain their safe result and successful Markdown files, while
|
||||
cancellation and pre-publication errors leave the prior destination unchanged.
|
||||
|
||||
If a replacement commits but cleanup of its prior sibling backup fails, the new
|
||||
bundle remains valid and its artifact paths appear in the failed command
|
||||
summary. The summary records a safe `publication_cleanup` error that indicates
|
||||
whether a complete prior bundle remains, only partial remnants remain, or no
|
||||
prior bundle remains; it also identifies when the sibling cannot be inspected.
|
||||
The returned command error includes a recovery path only when a sibling remains.
|
||||
Preserve a complete recognized recovery bundle until it has been inspected and
|
||||
cleaned up manually; partial remnants are not a rollback artifact. Do not
|
||||
remove the new bundle to retry cleanup.
|
||||
|
||||
Enable explicit debug capture only when content-rich Promptkit diagnostics are
|
||||
necessary.
|
||||
|
||||
Weatherreporter does not retain runs for later inspection, resume failed work,
|
||||
or provide automatic cleanup, archival, remote state, daemon operation, or
|
||||
automatic storm monitoring.
|
||||
|
||||
## Manual Cleanup Of Legacy Workspaces
|
||||
|
||||
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.
|
||||
|
||||
For example, from the directory that contains the old directory:
|
||||
|
||||
```sh
|
||||
rm -rf ./workspace
|
||||
```
|
||||
|
||||
Each generated report writes metadata that links:
|
||||
|
||||
- RunID, report ID, variant, and prompt ID
|
||||
- generation time, timezone, and valid period
|
||||
- source location, source hashes, and source warnings
|
||||
- module snapshot path
|
||||
- prompt input data package path
|
||||
- preflight output path
|
||||
- managed Markdown report path
|
||||
- distributor notification debug artifact path, when notification is attempted
|
||||
|
||||
Batch summaries include report status, error text when applicable, notification
|
||||
outcome when attempted, valid period, and known artifact paths for each
|
||||
attempted report. Notification fields are `notificationStatus`,
|
||||
`notificationRunId`, and `notificationError`.
|
||||
|
||||
## Distributor Notification
|
||||
|
||||
Distributor notification is configured with `notify.distributor` and is
|
||||
disabled by default. When enabled, weatherreporter uploads the managed Markdown
|
||||
report path recorded in the report result and metadata. That single source file
|
||||
can be mapped to one or more configured bundle paths. By default, it is mapped
|
||||
to one dated report path. Extra copies written by `--out` or `--out-dir` are
|
||||
operator conveniences only.
|
||||
|
||||
The rendered pipeline ID selects the configured distributor `http_upload`
|
||||
workflow. The default bundle ID is a stable logical source identity derived from
|
||||
producer name, location ID, and report ID:
|
||||
|
||||
```text
|
||||
weatherreporter.{location_id}.{report_id}
|
||||
```
|
||||
|
||||
The default idempotency key appends RunID to the rendered bundle ID so each
|
||||
report generation has a distinct retry identity. The default bundle path uses
|
||||
the valid-period start date, artifact group, and RunID. Distributor owns
|
||||
destination merge, retention, and derived snapshot behavior such as `latest`.
|
||||
|
||||
Notification happens after final metadata save. Weather API, module snapshot,
|
||||
data-package, render preflight, Scriptorium run, and metadata-save failures do
|
||||
not trigger notification. A notification failure fails that report.
|
||||
In a batch, other reports continue, the failed report includes notification
|
||||
fields in the JSON summary, and the batch returns nonzero.
|
||||
|
||||
Each notification attempt writes a debug artifact under `notifications/`. The
|
||||
artifact records the rendered pipeline ID, bundle ID, idempotency key, managed
|
||||
source path, bundle-relative paths, bundle created timestamp, accepted upload
|
||||
response, and the latest distributor run status response when available.
|
||||
Weatherreporter polls status until distributor reports `succeeded` or `failed`,
|
||||
or until the configured notification timeout expires. The run status includes
|
||||
the distributor status, error text, and raw run report JSON, which can show
|
||||
actions such as `replace_older`, `skip_same`, `skip_destination_newer`, or
|
||||
`failed`. Token values are not written.
|
||||
|
||||
Weatherreporter is responsible for selecting the managed Markdown report,
|
||||
constructing a source bundle, and submitting it to the configured distributor
|
||||
HTTP endpoint. Distributor remains responsible for destination routing,
|
||||
publication, and any downstream Markdown-to-HTML transformation. Distributor
|
||||
leaves destination files alone when they are not tracked by a newly uploaded
|
||||
bundle, so existing uploaded dated report paths can remain available.
|
||||
|
||||
## Inspection
|
||||
|
||||
Inspection commands read existing workspace artifacts and emit JSON to stdout.
|
||||
They do not fetch weather data or run `scriptorium`.
|
||||
|
||||
```text
|
||||
weatherreporter inspect reports --limit 10
|
||||
weatherreporter inspect metadata RUN_ID
|
||||
weatherreporter inspect modules RUN_ID
|
||||
weatherreporter inspect data-package RUN_ID
|
||||
weatherreporter inspect prior RUN_ID
|
||||
weatherreporter inspect sources RUN_ID
|
||||
```
|
||||
|
||||
Use `inspect reports` to find RunIDs and artifact paths. Use
|
||||
`inspect metadata` to see the artifact links recorded for a run. Use
|
||||
`inspect modules` to review the persisted ordered module snapshot, and
|
||||
`inspect data-package` to review the structured prompt package used for
|
||||
rendering. Use `inspect prior` to see the prior comparable snapshot selected for
|
||||
Recent Changes, or `null` when none exists. Use `inspect sources` to review
|
||||
source provenance and warnings without dumping full weather payloads.
|
||||
|
||||
## Recent Changes
|
||||
|
||||
Recent Changes are computed from structured module snapshots, not rendered
|
||||
Markdown or YAML text.
|
||||
|
||||
Daily Today and Daily Tomorrow can compare with each other when they cover the
|
||||
same valid local date. 3-Day Outlook compares with prior compatible 3-Day
|
||||
snapshots for the same valid local date. Weekend Outlook compares with prior
|
||||
compatible Weekend snapshots for the same weekend window. Storm Report leaves
|
||||
Recent Changes empty.
|
||||
|
||||
When no prior comparable snapshot exists, or no configured threshold is crossed,
|
||||
`recentChanges.items` is empty.
|
||||
|
||||
## Recovery
|
||||
|
||||
A failed generation run may still leave useful artifacts:
|
||||
|
||||
- If `scriptorium render` returns a result with a nonzero exit code, the
|
||||
preflight JSON and metadata are written for inspection.
|
||||
- If `scriptorium run` exits nonzero after writing a report, the managed report
|
||||
and metadata remain available.
|
||||
- If distributor notification fails, report artifacts and final metadata remain
|
||||
available, but the report or batch command returns nonzero.
|
||||
- For batch commands, inspect the stdout JSON summary first, then inspect the
|
||||
artifact paths for each failed report.
|
||||
|
||||
For a bad report, start with:
|
||||
|
||||
```text
|
||||
weatherreporter inspect metadata RUN_ID
|
||||
weatherreporter inspect sources RUN_ID
|
||||
weatherreporter inspect modules RUN_ID
|
||||
weatherreporter inspect data-package RUN_ID
|
||||
weatherreporter inspect prior RUN_ID
|
||||
```
|
||||
|
||||
## Operational Caveats
|
||||
|
||||
- The application uses one configured Weather API endpoint.
|
||||
- The application writes local filesystem state only.
|
||||
- The application does not implement resume, cleanup, archive, remote storage,
|
||||
daemon operation, or automatic storm monitoring.
|
||||
- Generated reports and Scriptorium stderr can contain sensitive operational
|
||||
context. Store workspace artifacts with appropriate filesystem permissions.
|
||||
This removal cannot be recovered by Weatherreporter. Keep or archive any
|
||||
historical files that are still needed before deleting them.
|
||||
|
||||
@@ -1,125 +1,99 @@
|
||||
# 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 module snapshots and prompt packages, compares module snapshots against prior runs, 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 module-based prompt 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, module composition, 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, module snapshot path, and output path. Recent Changes must be based on structured snapshot comparison rather than comparison of rendered Markdown report text.
|
||||
An explicit profile comparison prepares one report input once, executes the
|
||||
same exact prompt and data package across selected profiles concurrently, and
|
||||
atomically publishes one operator-owned comparison bundle. It remains local:
|
||||
it does not create application state or send a Distributor notification.
|
||||
|
||||
`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.
|
||||
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.
|
||||
|
||||
`distributor` is also an external adapter. Upload behavior must be isolated
|
||||
under `internal/adapters/distributor`, dependency types from the distributor
|
||||
module must not leak outside that adapter, and the selected upload source must
|
||||
be the managed Markdown report rather than optional output copies or broad
|
||||
workspace scans.
|
||||
## 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.
|
||||
- `internal/comparison` owns comparison identity, durable logical bundle
|
||||
validation, safe destination recognition, and atomic bundle publication.
|
||||
- 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.
|
||||
- Comparison validates every explicit profile before collection, prepares one
|
||||
immutable report input, and delegates backend capacity to Promptkit rather
|
||||
than adding an application-wide execution limit.
|
||||
- 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.
|
||||
- A single-report final destination is either absent or a regular file.
|
||||
Symlinks and special filesystem objects are rejected during preflight and
|
||||
rechecked immediately before the atomic replacement.
|
||||
- Configuration or explicit CLI input selects that operator-owned destination;
|
||||
it does not create an application-owned state boundary.
|
||||
- Comparison bundles are flat, versioned operator outputs. Their guarded
|
||||
replacement accepts only a recognized current bundle; cancellation and every
|
||||
pre-publication failure preserve a prior bundle, while individual profile
|
||||
failures can publish a complete partial bundle.
|
||||
- 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.
|
||||
- Comparison never invokes Distributor notification.
|
||||
- Profile comparison supports operator review only: it does not score, rank,
|
||||
select, resample, or replay profile executions.
|
||||
- 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.
|
||||
## Non-Goals
|
||||
|
||||
## 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`.
|
||||
|
||||
Configuration precedence is:
|
||||
|
||||
1. CLI flags
|
||||
2. configuration file
|
||||
3. built-in defaults
|
||||
|
||||
Prefer YAML configuration unless the project has a strong reason to use another format. Config files should be discovered at `/usr/local/etc/<app_name>/config.yml`, with a CLI override via `--config`.
|
||||
|
||||
Configuration files should not contain raw secrets unless the application is explicitly designed for that. Prefer environment variables or secret files for secrets. File-backed secrets are loaded through `secrets.directory`; secret values must not be logged, persisted, or included in user-facing output.
|
||||
|
||||
## Adapters and External Integrations
|
||||
|
||||
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.
|
||||
|
||||
## Components and Registries
|
||||
|
||||
When the application has major workflow components, each component should live
|
||||
near the package that owns its contract and have explicit inputs and outputs.
|
||||
|
||||
The orchestrator should compose components in an explicit order using a default
|
||||
sequence, dependency graph, or documented orchestration rule.
|
||||
|
||||
If users can select components, 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-step workflows should
|
||||
respect cancellation and timeouts.
|
||||
|
||||
## State, Files, and Safety
|
||||
|
||||
If the application writes durable state, writes should be atomic where
|
||||
practical. Multi-step workflows should preserve enough state to support
|
||||
inspection and retry diagnosis after failure.
|
||||
|
||||
Code that deletes, moves, or overwrites files must use narrow, explicit paths. Avoid broad parent-directory operations. Cleanup that can cause data loss must be opt-in.
|
||||
|
||||
## 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. Component contracts should have focused tests that do
|
||||
not require running the full application unless end-to-end coverage is
|
||||
intentional.
|
||||
|
||||
## Documentation
|
||||
|
||||
Documentation should follow the project documentation policy. Keep user docs focused on implemented behavior. Put future, planned, or aspirational work only under `docs/roadmap/`.
|
||||
|
||||
When changing architecture, config, CLI behavior, adapters, or component
|
||||
contracts, update the relevant docs and examples in the same change.
|
||||
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,204 +0,0 @@
|
||||
# Development Policy
|
||||
|
||||
This document is the contributor workflow policy for `weatherreporter`.
|
||||
Developers and LLM coding agents should use it with
|
||||
`docs/policy/architecture.md` and `docs/policy/documentation.md`.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
- `cmd/weatherreporter`: binary entry point.
|
||||
- `internal/app`: orchestration for generation, batches, fetch helpers, and
|
||||
inspection.
|
||||
- `internal/cli`: command parsing, flag handling, help text, and JSON output.
|
||||
- `internal/config`: configuration structs, defaults, loading, overrides, and
|
||||
validation.
|
||||
- `internal/fileutil`: shared atomic filesystem write and copy helpers.
|
||||
- `internal/adapters/distributor`: Distributor upload adapter.
|
||||
- `internal/adapters/weatherapi`: Weather API HTTP adapter.
|
||||
- `internal/adapters/scriptorium`: Scriptorium subprocess adapter.
|
||||
- `internal/weatherdata`: normalized weather source facts, source metadata, and
|
||||
source warnings.
|
||||
- `internal/forecast`: deterministic forecast derivation.
|
||||
- `internal/facts`: collected and derived report fact contracts.
|
||||
- `internal/module`: module IDs, config items, output envelopes, and snapshots.
|
||||
- `internal/report`: report definitions, valid periods, batches, output names,
|
||||
and comparison declarations.
|
||||
- `internal/briefing`: prompt-facing module value builders and module registry.
|
||||
- `internal/changes`: structured Recent Changes comparison.
|
||||
- `internal/promptinput`: Scriptorium `data_package` construction and
|
||||
validation.
|
||||
- `internal/state`: filesystem paths, atomic JSON writes, metadata, lookup, and
|
||||
inspection support.
|
||||
- `internal/timeutil`: clock, date, timezone, and period helpers.
|
||||
- `docs`: user, operator, developer, integration, internal, policy, and roadmap
|
||||
documentation.
|
||||
- `examples`: maintained copyable examples.
|
||||
|
||||
## Local Validation
|
||||
|
||||
Use focused checks while editing and broader checks before committing:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go run ./cmd/weatherreporter --help
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Useful focused checks:
|
||||
|
||||
```bash
|
||||
go test ./internal/cli ./internal/config
|
||||
go test ./internal/app ./internal/state
|
||||
go test ./internal/adapters/distributor ./internal/adapters/weatherapi ./internal/adapters/scriptorium
|
||||
go test ./internal/forecast ./internal/report ./internal/briefing ./internal/changes ./internal/promptinput
|
||||
```
|
||||
|
||||
Run `gofmt -w` on changed Go files before committing.
|
||||
|
||||
## Coding Conventions
|
||||
|
||||
- Keep domain logic out of `cmd`, `internal/cli`, and adapter packages.
|
||||
- Prefer small explicit structs and functions over broad framework-style
|
||||
abstractions.
|
||||
- Keep package APIs narrow and named around implemented behavior.
|
||||
- Return errors with operation, path, endpoint, report, or RunID context.
|
||||
- Do not log or expose secrets.
|
||||
- Use `context.Context` for external calls, subprocesses, and orchestrated
|
||||
workflows that may be canceled.
|
||||
- Use atomic writes for durable JSON artifacts where practical.
|
||||
- Keep report selection and prompt IDs centralized in `internal/report`.
|
||||
- Keep Scriptorium argv construction inside `internal/adapters/scriptorium`.
|
||||
- Keep distributor package types and upload-client construction inside
|
||||
`internal/adapters/distributor`.
|
||||
- Keep Weather API transport and envelope handling inside
|
||||
`internal/adapters/weatherapi`.
|
||||
|
||||
## Dependency Policy
|
||||
|
||||
Prefer the Go standard library. Add dependencies only when they materially
|
||||
improve correctness, interoperability, security, or maintainability.
|
||||
|
||||
Current external dependencies:
|
||||
|
||||
- `gitea.maximumdirect.net/eric/distributor` for distributor source bundle
|
||||
construction and HTTP upload client behavior.
|
||||
- `gopkg.in/yaml.v3` for YAML configuration parsing.
|
||||
|
||||
When adding a dependency:
|
||||
|
||||
- explain why the standard library is not enough;
|
||||
- keep dependency types from leaking across unrelated package boundaries;
|
||||
- add tests for the behavior the dependency supports;
|
||||
- update this policy if the dependency becomes part of contributor workflow.
|
||||
|
||||
## Configuration Changes
|
||||
|
||||
Configuration is owned by `internal/config`.
|
||||
|
||||
When adding or changing a field:
|
||||
|
||||
- update `Config` and the nested config struct in `config.go`;
|
||||
- add or adjust defaults in `defaults.go` when the field has a safe default;
|
||||
- update loading or CLI override behavior in `load.go` only when needed;
|
||||
- validate required values and accepted ranges in `validate.go`;
|
||||
- add or update config tests;
|
||||
- update `docs/config.md` and maintained examples when the field is user
|
||||
visible;
|
||||
- keep secrets out of example config files.
|
||||
|
||||
Configuration precedence is:
|
||||
|
||||
1. CLI overrides supported by `config.LoadOptions`;
|
||||
2. configuration file values;
|
||||
3. built-in defaults.
|
||||
|
||||
The default config path is `/usr/local/etc/weatherreporter/config.yml`.
|
||||
|
||||
## CLI Changes
|
||||
|
||||
The CLI is owned by `internal/cli`.
|
||||
|
||||
When adding or changing a command or flag:
|
||||
|
||||
- update help text and parser behavior together;
|
||||
- convert parsed values into app-layer request structs;
|
||||
- keep domain decisions in `internal/app` or domain packages;
|
||||
- add parser or command tests in `internal/cli`;
|
||||
- update `docs/cli.md`;
|
||||
- update `docs/operations.md` or `docs/troubleshooting.md` when behavior affects
|
||||
operators.
|
||||
|
||||
CLI commands should return concise actionable errors and avoid printing partial
|
||||
JSON when command construction fails.
|
||||
|
||||
## Components And Adapters
|
||||
|
||||
Use existing package boundaries before adding a package.
|
||||
|
||||
Add a new internal component only when it owns a distinct implemented contract.
|
||||
Define its inputs, outputs, state behavior, failure behavior, tests, and
|
||||
invariants in `docs/internal/`.
|
||||
|
||||
Adapters should stay thin:
|
||||
|
||||
- HTTP adapters own transport, request construction, envelope handling, and
|
||||
decode boundaries.
|
||||
- subprocess adapters own argv construction, timeout handling, stdout/stderr
|
||||
capture, and exit-code interpretation.
|
||||
- adapter packages should not own report selection, forecast summarization,
|
||||
Recent Changes, or prompt input schema decisions.
|
||||
|
||||
When an external contract changes, update the matching file under
|
||||
`docs/integrations/`.
|
||||
|
||||
## Tests
|
||||
|
||||
Core tests must not require live Weather API, Scriptorium, or distributor
|
||||
services.
|
||||
|
||||
Preferred test patterns:
|
||||
|
||||
- fake command runners for subprocess behavior;
|
||||
- `httptest.Server` for Weather API behavior;
|
||||
- fake distributor upload clients for notification behavior;
|
||||
- filesystem temp directories for state behavior;
|
||||
- deterministic clocks for report periods and RunIDs;
|
||||
- table tests for config validation, CLI parsing, period resolution, and
|
||||
threshold behavior.
|
||||
|
||||
Add focused tests near the package that owns the behavior. Use app-level tests
|
||||
for workflow ordering, persistence, and cross-package contracts.
|
||||
|
||||
## Examples
|
||||
|
||||
Examples under `examples/` must be real, maintained, and free of secrets.
|
||||
|
||||
When updating examples:
|
||||
|
||||
- use implemented config fields only;
|
||||
- avoid private endpoints and credentials;
|
||||
- keep comments short and operationally useful;
|
||||
- add or update validation coverage when a new example file is introduced;
|
||||
- link maintained examples from `docs/config.md`.
|
||||
|
||||
Do not add generated report examples unless they can be kept current without
|
||||
live external services.
|
||||
|
||||
## Documentation Checklist
|
||||
|
||||
Documentation updates are part of behavior changes.
|
||||
|
||||
Update:
|
||||
|
||||
- `README.md` for project orientation or quickstart changes;
|
||||
- `docs/cli.md` for command and flag changes;
|
||||
- `docs/config.md` for config fields, defaults, and precedence changes;
|
||||
- `docs/operations.md` for state, artifact, batch, inspection, and recovery
|
||||
behavior;
|
||||
- `docs/troubleshooting.md` for recurring operator-facing failure modes;
|
||||
- `docs/internal/` for component contracts and invariants;
|
||||
- `docs/integrations/` for external Weather API, Scriptorium, or distributor
|
||||
contract changes;
|
||||
- `docs/roadmap/` only for unimplemented or deferred work.
|
||||
|
||||
Non-roadmap docs must describe implemented behavior only.
|
||||
@@ -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.
|
||||
66
docs/releases/v0.12.0.md
Normal file
66
docs/releases/v0.12.0.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Weatherreporter v0.12.0
|
||||
|
||||
This release completes a repository-wide correctness, security, efficiency,
|
||||
test-durability, and documentation audit.
|
||||
|
||||
## Summary
|
||||
|
||||
Weatherreporter now applies stricter validation and bounded diagnostics across
|
||||
its configuration, weather collection, Promptkit, rendering, publication,
|
||||
comparison, and Distributor boundaries. Report preparation and execution carry
|
||||
one reconciled identity, independent weather sources are collected
|
||||
concurrently, and cancellation preserves completed report and comparison
|
||||
outcomes.
|
||||
|
||||
The release also removes obsolete compatibility surfaces and consolidates
|
||||
duplicated implementation and test policy without changing ordinary report
|
||||
commands or output identities.
|
||||
|
||||
## Compatibility
|
||||
|
||||
This release is compatible with `v0.11.0` for ordinary `generate`, `run`, and
|
||||
`compare` commands, configuration files, report filenames, comparison bundles,
|
||||
and Distributor integration.
|
||||
|
||||
Sensitive prompt-debug capture through `--llm-debug-dir` is now supported only
|
||||
on Unix hosts. Non-Unix hosts reject an explicit capture request before prompt
|
||||
inspection, weather collection, or provider execution because the required
|
||||
handle-relative, no-follow filesystem guarantees are unavailable there.
|
||||
|
||||
Several unused internal compatibility exports were removed. They were not part
|
||||
of the documented CLI, configuration, artifact, or integration contracts.
|
||||
|
||||
## Upgrade
|
||||
|
||||
No special action is required for ordinary installations. Operators who use
|
||||
`--llm-debug-dir` on Windows must run that diagnostic workflow on a Unix host.
|
||||
Review any automation that depended on undocumented internal Go APIs removed by
|
||||
this release.
|
||||
|
||||
## Changes
|
||||
|
||||
- Hardened configuration loading, source validation, secrets rollback,
|
||||
endpoint validation, HTTP diagnostics, generated-text limits, prompt-debug
|
||||
redaction, output publication, comparison replacement, and Distributor
|
||||
failure reporting.
|
||||
- Reconciled inspected, prepared, callback, and completed Promptkit identity
|
||||
and provenance before accepting generated content.
|
||||
- Preserved metric values, civil-day and daypart identity, overnight alerts,
|
||||
precipitation semantics, and Markdown structure across deterministic report
|
||||
preparation and rendering.
|
||||
- Collected independent Weather API sources concurrently and reused readiness
|
||||
data while retaining deterministic normalized results.
|
||||
- Preserved completed report and comparison failures independently from shared
|
||||
cancellation, stopped unfinished work, and skipped batch notification after
|
||||
cancellation or partial report failure.
|
||||
- Made secure prompt-debug traversal descriptor-relative on Unix and fail
|
||||
closed elsewhere. See the [operations
|
||||
guide](../operations.md#optional-prompt-debug-capture).
|
||||
- Strengthened default test portability and determinism, including
|
||||
capability-aware symbolic-link fixtures and platform-appropriate process
|
||||
signal coverage.
|
||||
- Removed obsolete compatibility helpers, duplicated test ownership, dormant
|
||||
persistence code, and completed audit and implementation roadmaps.
|
||||
- Updated the [architecture policy](../policy/architecture.md), [testing
|
||||
policy](../policy/testing.md), and focused internal guides to describe the
|
||||
implemented final state.
|
||||
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.
|
||||
@@ -1,22 +1,58 @@
|
||||
# Future Roadmap
|
||||
|
||||
This roadmap contains project work that is not implemented. Current behavior is
|
||||
documented outside `docs/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
|
||||
|
||||
Manual Storm Report generation is implemented through
|
||||
`weatherreporter generate storm --start TIME --end TIME`. Automatic storm-event
|
||||
evaluation remains deferred.
|
||||
Status: Proposed and unimplemented.
|
||||
|
||||
Proposed direction:
|
||||
Storm reporting, whether manual or automatic, is unimplemented.
|
||||
|
||||
1. detect candidate events deterministically from alerts, forecast discussion,
|
||||
weather story context, hourly thresholds, and material forecast changes;
|
||||
2. evaluate candidates through Scriptorium or another narrow evaluator adapter;
|
||||
3. persist storm lifecycle state;
|
||||
4. generate or update Storm Reports only when a meaningful event is present;
|
||||
5. suppress ordinary low-impact thunder or rain chances.
|
||||
Possible 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:
|
||||
|
||||
@@ -27,122 +63,126 @@ Possible lifecycle states:
|
||||
- `deescalating`
|
||||
- `resolved`
|
||||
|
||||
Acceptance criteria before implementation:
|
||||
Before implementation, the design must preserve scheduled report behavior,
|
||||
inspectable evaluator failures, and fixture coverage for deterministic
|
||||
candidate detection.
|
||||
|
||||
- scheduled reports and manual Storm Reports remain stable;
|
||||
- candidate detection has fixture coverage;
|
||||
- evaluator failures are inspectable and do not create noisy report output;
|
||||
- manual Storm Report generation remains available.
|
||||
## Future Report Types
|
||||
|
||||
## Future Report Types And Modules
|
||||
|
||||
The module-based prompt package architecture is implemented. Future work should
|
||||
add only modules backed by implemented upstream facts and clear report needs.
|
||||
Status: Proposed and unimplemented.
|
||||
|
||||
Possible future report types:
|
||||
|
||||
- `next_6_hours` or another short-fuse planning report;
|
||||
- event-specific reports with stable event IDs;
|
||||
- storm review or yesterday-style reports using historical observations;
|
||||
- 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.
|
||||
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` if a separate stanza is useful beyond current Recent
|
||||
Changes;
|
||||
- `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;
|
||||
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`;
|
||||
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.
|
||||
upstream sources and report requirements exist
|
||||
|
||||
QPF fields such as `measurable_qpf_total_in` and `max_hourly_qpf_in` should
|
||||
remain omitted until a real upstream quantitative precipitation source is
|
||||
represented in `CollectedFacts`.
|
||||
|
||||
Future module work should preserve these boundaries:
|
||||
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):
|
||||
|
||||
- collect upstream facts once per report run;
|
||||
- keep upstream fetching out of modules;
|
||||
- keep broad reusable calculations in `DerivedFacts`;
|
||||
- keep prompt-facing field shape inside module builders;
|
||||
- use typed options for configurable module behavior;
|
||||
- keep module snapshots structured and deterministic for Recent Changes.
|
||||
- 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
|
||||
|
||||
Distributor notification currently uploads one managed Markdown report per
|
||||
successful generated report through the configured HTTP upload pipeline.
|
||||
Status: Proposed and unimplemented.
|
||||
|
||||
These enhancements are not current behavior:
|
||||
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`;
|
||||
- uploading metadata, module snapshots, data packages, or preflight artifacts;
|
||||
- polling distributor status after upload acceptance;
|
||||
- durable upload retry queues;
|
||||
- distributor-specific CLI flags;
|
||||
- making distributor scan the weatherreporter workspace;
|
||||
- handling destination routing, Markdown-to-HTML transformation, public URLs,
|
||||
or nginx layout inside weatherreporter.
|
||||
- `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 existing adapter boundary:
|
||||
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.
|
||||
|
||||
## Comparison Profile Diagnostics
|
||||
|
||||
Status: Proposed and unimplemented.
|
||||
|
||||
Comparison preflight failures could identify the profile being inspected and
|
||||
preserve a safe, actionable Promptkit cause, such as a duplicate profile ID,
|
||||
instead of reporting only a generic `profile_load` failure. Any improvement
|
||||
must continue to omit credentials, endpoints, and other sensitive profile
|
||||
values. Regression coverage should include a comparison that mixes built-in
|
||||
and configured-directory profiles and a directory containing duplicate IDs.
|
||||
|
||||
## Alternate Runtime Integrations
|
||||
|
||||
These ideas are not current behavior:
|
||||
Status: Proposed and 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.
|
||||
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.
|
||||
|
||||
## Cleanup Refactors
|
||||
## Deferred Refactors
|
||||
|
||||
The initial cleanup pass intentionally left these refactors out because the
|
||||
current implementation does not yet make them worth the added abstraction.
|
||||
Status: Deferred.
|
||||
|
||||
Revisit these only when new source types, report types, operational
|
||||
requirements, or recurring maintenance costs make the duplication materially
|
||||
more expensive:
|
||||
These refactors should remain deferred until new requirements or recurring
|
||||
maintenance costs make the added abstraction worthwhile:
|
||||
|
||||
- Weather API optional-source specification/helper refactor: consider when
|
||||
additional Weather API sources make per-source fan-out, policy handling, and
|
||||
provenance wiring repetitive enough to obscure adapter behavior.
|
||||
- Broad briefing weather-signal consolidation: consider when multiple module
|
||||
builders repeatedly derive the same weather signals and tests begin to need
|
||||
coordinated fixture updates.
|
||||
- Generic workflow engine: defer unless generation, inspection, recovery, or
|
||||
future background workflows gain enough shared step semantics to justify a
|
||||
declared execution model.
|
||||
- Cobra migration: defer while the standard-library CLI remains small,
|
||||
explicit, and covered by parser tests.
|
||||
- Manifest, resume, or progress system: defer until operators need resumable
|
||||
runs, checkpoint recovery, or richer audit trails than the current durable
|
||||
artifacts and metadata provide.
|
||||
- Global test helper package: defer while package-local helpers keep tests
|
||||
clear; revisit only if setup duplication starts to hide behavior.
|
||||
- Logging subsystem: defer until there are recurring operator diagnostics that
|
||||
cannot be handled with current errors, metadata, inspection commands, and
|
||||
artifact output.
|
||||
- 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 existing public CLI, artifact
|
||||
paths, report identities, module boundaries, and adapter boundaries unless a
|
||||
separate roadmap explicitly changes them.
|
||||
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.
|
||||
|
||||
184
docs/templates.md
Normal file
184
docs/templates.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# 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 requires each report's
|
||||
exact schema/template pair; keep the matching prompt definition aligned with
|
||||
that report-specific triple.
|
||||
|
||||
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.
|
||||
- Render every `.GeneratedText` value through `plainText`. It preserves prose
|
||||
and paragraph breaks while escaping Markdown and HTML syntax, removing code
|
||||
indentation, and replacing control characters. Never interpolate generated
|
||||
prose directly: repository templates alone own headings, lists, links, and
|
||||
other Markdown structure.
|
||||
- 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. The Today partial uses
|
||||
`.Modules.HasDaypartDetails` to ensure its heading has either rows or the
|
||||
explicit no-details fallback.
|
||||
|
||||
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 }}
|
||||
{{ plainText . }}
|
||||
{{ 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 |
|
||||
| `plainText` | a generated prose string | a readable plain-text rendering that preserves paragraph breaks without allowing dynamic Markdown or HTML structure |
|
||||
|
||||
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 three 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. |
|
||||
|
||||
### 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; at most 4,000 characters. |
|
||||
| `.GeneratedText.ForecastDiscussion` | `string` | `[]string` | Required; Hourly permits 12,000 characters. Day-style values permit up to 12 paragraphs of 4,000 characters each. |
|
||||
| `.GeneratedText.PrecipitationTiming` | `string` | `string` | Required field, at most 4,000 characters; 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). All validated generated prose together
|
||||
is limited to 20,000 characters, so template edits can rely on a bounded prose
|
||||
surface.
|
||||
|
||||
### 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.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.HasDaypartDetails` | Today |
|
||||
| `.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,340 +0,0 @@
|
||||
# Weatherreporter Troubleshooting
|
||||
|
||||
This guide lists recurring failures with likely causes, diagnostics, and safe
|
||||
fixes. See [CLI reference](cli.md), [Configuration reference](config.md), and
|
||||
[Operations guide](operations.md) for normal usage.
|
||||
|
||||
## `weather_api.base_url is required`
|
||||
|
||||
Symptom: a generation command fails before fetching weather data.
|
||||
|
||||
Likely cause: no Weather API base URL is configured.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
weatherreporter generate daily --config ./config.yml --date 2026-05-29
|
||||
```
|
||||
|
||||
Safe fix: add `weather_api.base_url` to the config file, or pass the intended
|
||||
config path with `--config`.
|
||||
|
||||
Relevant docs: [Configuration reference](config.md).
|
||||
|
||||
## `weather_api.base_url must be an absolute URL`
|
||||
|
||||
Symptom: config loading fails with a base URL validation error.
|
||||
|
||||
Likely cause: `weather_api.base_url` is missing a scheme or host.
|
||||
|
||||
Diagnostic: inspect the configured value in the file passed to `--config`.
|
||||
|
||||
Safe fix: use an absolute URL such as `https://weather.api.example.com/`.
|
||||
|
||||
Relevant docs: [Configuration reference](config.md).
|
||||
|
||||
## Invalid Timezone
|
||||
|
||||
Symptom: config loading fails with `weather_api.timezone` context, or a CLI
|
||||
timezone override fails.
|
||||
|
||||
Likely cause: `weather_api.timezone` or `--tz` is not recognized.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
weatherreporter generate daily --tz America/Chicago --date 2026-05-29
|
||||
```
|
||||
|
||||
Safe fix: use an accepted timezone value, such as an IANA timezone name,
|
||||
`Chicago`, `Stl`, a US timezone abbreviation, or a UTC offset.
|
||||
|
||||
Relevant docs: [Configuration reference](config.md).
|
||||
|
||||
## Storm Command Rejects Time Bounds
|
||||
|
||||
Symptom: `generate storm` fails with `requires --start`, `requires --end`, or
|
||||
`requires --end after --start`.
|
||||
|
||||
Likely cause: the manual event window is missing or invalid.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
|
||||
```
|
||||
|
||||
Safe fix: provide both bounds. Use `YYYY-MM-DDTHH:MM` in the configured
|
||||
timezone, or RFC3339 timestamps with explicit offsets.
|
||||
|
||||
Relevant docs: [CLI reference](cli.md).
|
||||
|
||||
## Weather API Fetch Fails
|
||||
|
||||
Symptom: generation fails with `fetch /...`, an HTTP status, or request context.
|
||||
|
||||
Likely cause: the configured Weather API endpoint is unreachable, returned a
|
||||
non-2xx response, or returned an invalid response envelope.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
weatherreporter generate daily --config ./config.yml --date 2026-05-29
|
||||
```
|
||||
|
||||
Safe fix: verify `weather_api.base_url`, network access, and the Weather API
|
||||
service response. The adapter fetches `/observations`, `/conditions/current`,
|
||||
`/forecast/hourly`, `/forecast/narrative`, `/alerts/active`, and `/discussion`.
|
||||
|
||||
Relevant docs: [Configuration reference](config.md).
|
||||
|
||||
## Hourly Forecast Is Missing
|
||||
|
||||
Symptom: generation fails with hourly forecast context, such as missing hourly
|
||||
data or an hourly forecast containing no periods.
|
||||
|
||||
Likely cause: hourly forecast data is required for generated reports.
|
||||
|
||||
Diagnostic: check the Weather API response for `/forecast/hourly`.
|
||||
|
||||
Safe fix: restore hourly forecast data at the Weather API. Missing-source
|
||||
policy cannot make hourly optional.
|
||||
|
||||
Relevant docs: [Configuration reference](config.md), [Operations guide](operations.md).
|
||||
|
||||
## Source Warnings Appear
|
||||
|
||||
Symptom: generation succeeds, but metadata or `inspect sources` shows source
|
||||
warnings.
|
||||
|
||||
Likely cause: an optional source was missing or malformed under a warning
|
||||
missing-source policy.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
weatherreporter inspect sources RUN_ID
|
||||
weatherreporter inspect metadata RUN_ID
|
||||
```
|
||||
|
||||
Safe fix: inspect the warning `source`, `code`, `message`, and `endpoint`. Fix
|
||||
the upstream optional source, or intentionally change the relevant
|
||||
`missing_source` policy.
|
||||
|
||||
Relevant docs: [Configuration reference](config.md), [Operations guide](operations.md).
|
||||
|
||||
## `scriptorium` Is Not Found Or Cannot Start
|
||||
|
||||
Symptom: generation fails with `run scriptorium render` or `run scriptorium`
|
||||
and an executable or OS error.
|
||||
|
||||
Likely cause: the configured Scriptorium binary is unavailable or not
|
||||
executable.
|
||||
|
||||
Diagnostic: check `scriptorium.binary` in config and run the same binary outside
|
||||
`weatherreporter`.
|
||||
|
||||
Safe fix: install Scriptorium, update `scriptorium.binary`, or fix executable
|
||||
permissions.
|
||||
|
||||
Relevant docs: [Configuration reference](config.md),
|
||||
[Scriptorium integration](integrations/scriptorium.md).
|
||||
|
||||
## Render Preflight Fails
|
||||
|
||||
Symptom: generation fails with `scriptorium render exited with code ...`.
|
||||
|
||||
Likely cause: Scriptorium rejected the prompt, config, profile, or
|
||||
`data_package` input before report generation.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
weatherreporter inspect metadata RUN_ID
|
||||
weatherreporter inspect data-package RUN_ID
|
||||
```
|
||||
|
||||
Then read the preflight path from metadata. It contains captured stdout, stderr,
|
||||
exit code, and command.
|
||||
|
||||
Safe fix: fix the Scriptorium configuration, prompt ID, profile, or data package
|
||||
input indicated by stderr.
|
||||
|
||||
Relevant docs: [Operations guide](operations.md),
|
||||
[Scriptorium integration](integrations/scriptorium.md).
|
||||
|
||||
## Scriptorium Run Fails
|
||||
|
||||
Symptom: generation fails with `scriptorium run exited with code ...`.
|
||||
|
||||
Likely cause: Scriptorium failed during report generation or validation.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
weatherreporter inspect metadata RUN_ID
|
||||
weatherreporter inspect data-package RUN_ID
|
||||
```
|
||||
|
||||
If metadata includes a rendered report path, inspect that report as well. A
|
||||
nonzero run can still leave a managed report artifact.
|
||||
|
||||
Safe fix: use the captured stderr and data package to fix the Scriptorium
|
||||
prompt, profile, model configuration, or validation issue.
|
||||
|
||||
Relevant docs: [Operations guide](operations.md),
|
||||
[Scriptorium integration](integrations/scriptorium.md).
|
||||
|
||||
## Batch Command Returns Nonzero
|
||||
|
||||
Symptom: `run morning` or `run evening` returns nonzero.
|
||||
|
||||
Likely cause: at least one report in the batch failed.
|
||||
|
||||
Diagnostic: inspect stdout for the JSON summary and stderr for compact status
|
||||
lines.
|
||||
|
||||
Safe fix: use the failed report's artifact paths from the summary, then inspect
|
||||
metadata, sources, module snapshot, and data package for that RunID.
|
||||
|
||||
Relevant docs: [CLI reference](cli.md), [Operations guide](operations.md).
|
||||
|
||||
## Invalid Secrets Directory
|
||||
|
||||
Symptom: config loading fails with `read secrets directory`, `secret file`, or
|
||||
environment variable name context.
|
||||
|
||||
Likely cause: `secrets.directory` points to a missing directory or contains an
|
||||
invalid entry. Secret entries must be regular files directly under the
|
||||
configured directory, and file basenames must match
|
||||
`[A-Za-z_][A-Za-z0-9_]*`.
|
||||
|
||||
Diagnostic: list the configured directory and inspect entry names and file
|
||||
types. Do not print secret file contents.
|
||||
|
||||
Safe fix: create the directory, remove subdirectories or symlinks, fix invalid
|
||||
filenames, and ensure the weatherreporter process can read each secret file.
|
||||
|
||||
Relevant docs: [Configuration reference](config.md).
|
||||
|
||||
## Distributor Token Is Missing
|
||||
|
||||
Symptom: notification fails with a message that the distributor token
|
||||
environment variable is not set.
|
||||
|
||||
Likely cause: `notify.distributor.enabled` is true, but the environment
|
||||
variable named by `notify.distributor.token_env` was not populated directly or
|
||||
through `secrets.directory`.
|
||||
|
||||
Diagnostic: check `notify.distributor.token_env`, then verify a matching secret
|
||||
file exists under `secrets.directory` or that the process environment includes
|
||||
the variable. Do not print the token value.
|
||||
|
||||
Safe fix: create a readable secret file whose basename matches `token_env`, or
|
||||
set the environment variable through the service manager.
|
||||
|
||||
Relevant docs: [Configuration reference](config.md),
|
||||
[Operations guide](operations.md).
|
||||
|
||||
## Distributor Upload Conflict
|
||||
|
||||
Symptom: notification fails with idempotency conflict context.
|
||||
|
||||
Likely cause: the same idempotency key was reused for different bundle content
|
||||
within the same distributor token and pipeline. By default the bundle ID is a
|
||||
stable report-stream identity and the idempotency key appends RunID.
|
||||
|
||||
Diagnostic: inspect the failed batch JSON or stderr line for pipeline, bundle,
|
||||
and idempotency context. Compare the configured templates with the report RunID
|
||||
and report path.
|
||||
|
||||
Also inspect the notification artifact linked from metadata. It records the
|
||||
rendered pipeline ID, bundle ID, idempotency key, upload result, distributor run
|
||||
status, status error, and raw run report JSON when available.
|
||||
|
||||
Safe fix: keep idempotency templates stable for retries of the same generated
|
||||
report, but do not reuse the same rendered key for different generated report
|
||||
content.
|
||||
|
||||
Relevant docs: [Operations guide](operations.md),
|
||||
[Distributor adapter internals](internal/distributor-adapter.md).
|
||||
|
||||
## Distributor Upload Rejected
|
||||
|
||||
Symptom: notification fails with distributor upload rejection, HTTP status, or
|
||||
bundle validation context.
|
||||
|
||||
Likely cause: the distributor endpoint rejected the token, pipeline ID, bundle
|
||||
ID, idempotency key, source file, or one of the rendered bundle paths.
|
||||
|
||||
Diagnostic: inspect stdout JSON or stderr status lines for
|
||||
`notificationError`. Confirm `notify.distributor.endpoint`,
|
||||
`notify.distributor.pipeline_id_template`,
|
||||
`notify.distributor.report_path_templates`, and token configuration. Token
|
||||
values are redacted from weatherreporter errors.
|
||||
|
||||
If the upload was accepted but destination output did not change, inspect the
|
||||
notification artifact's `runStatus.report`. Distributor actions such as
|
||||
`replace_older`, `skip_same`, `skip_destination_newer`, or `failed` explain how
|
||||
the destination handled the uploaded bundle.
|
||||
|
||||
Safe fix: fix the endpoint, token, templates, or distributor-side upload
|
||||
configuration. The weatherreporter upload source is the managed Markdown report,
|
||||
not `--out` or `--out-dir` copies.
|
||||
|
||||
Relevant docs: [Configuration reference](config.md),
|
||||
[Operations guide](operations.md),
|
||||
[Distributor adapter internals](internal/distributor-adapter.md).
|
||||
|
||||
## Distributor Unavailable
|
||||
|
||||
Symptom: notification fails with network, timeout, or service unavailable
|
||||
context.
|
||||
|
||||
Likely cause: the configured distributor endpoint is unreachable, slow, or
|
||||
temporarily unavailable.
|
||||
|
||||
Diagnostic: check network access from the weatherreporter host to
|
||||
`notify.distributor.endpoint`. For batch runs, inspect which reports have
|
||||
`notificationStatus: "failed"`.
|
||||
|
||||
Safe fix: restore distributor service availability and rerun the affected
|
||||
report or batch. Stable idempotency keys make retrying the same generated report
|
||||
safe unless the distributor reports a conflict.
|
||||
|
||||
Relevant docs: [Operations guide](operations.md).
|
||||
|
||||
## Unknown RunID
|
||||
|
||||
Symptom: an inspect command fails with `metadata for run id ... was not found`.
|
||||
|
||||
Likely cause: the RunID is mistyped or the command is reading a different
|
||||
workspace.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
weatherreporter inspect reports --config ./config.yml --limit 20
|
||||
```
|
||||
|
||||
Safe fix: copy a RunID from `inspect reports`, or use the same `--config` and
|
||||
workspace that generated the report.
|
||||
|
||||
Relevant docs: [Operations guide](operations.md).
|
||||
|
||||
## Workspace Path Error
|
||||
|
||||
Symptom: startup or inspection fails with workspace path validation or
|
||||
filesystem read/write context.
|
||||
|
||||
Likely cause: a workspace subdirectory is absolute, escapes `workspace.root`, or
|
||||
the process cannot read or write the configured path.
|
||||
|
||||
Diagnostic: review `workspace.root`, `workspace.snapshots_dir`,
|
||||
`workspace.reports_dir`, `workspace.data_packages_dir`, and
|
||||
`workspace.preflight_dir`.
|
||||
|
||||
Safe fix: keep workspace subdirectories relative to `workspace.root`, and grant
|
||||
the process appropriate filesystem permissions.
|
||||
|
||||
Relevant docs: [Configuration reference](config.md), [Operations guide](operations.md).
|
||||
@@ -1,7 +1,7 @@
|
||||
weather_api:
|
||||
base_url: https://weather.api.rakestrawhome.com/
|
||||
base_url: https://weather.api.example.com/
|
||||
timeout: 15s
|
||||
precision: 1
|
||||
precision: 0
|
||||
units: us
|
||||
timezone: "America/Chicago"
|
||||
format: json
|
||||
@@ -14,6 +14,9 @@ location:
|
||||
secrets:
|
||||
directory: ""
|
||||
|
||||
output:
|
||||
directory: /var/lib/weatherreporter/reports
|
||||
|
||||
notify:
|
||||
distributor:
|
||||
enabled: false
|
||||
@@ -21,28 +24,24 @@ notify:
|
||||
token_env: DISTRIBUTOR_UPLOAD_TOKEN
|
||||
timeout: 30s
|
||||
failure_policy: error
|
||||
pipeline_id_template: "weatherreporter.{artifact_group}"
|
||||
pipeline_id_template: "weatherreporter.{report_id}"
|
||||
bundle_id_template: "weatherreporter.{location_id}.{report_id}"
|
||||
idempotency_key_template: "{bundle_id}.{run_id}"
|
||||
report_path_templates:
|
||||
- "{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md"
|
||||
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
|
||||
notifications_dir: notifications
|
||||
local:
|
||||
concurrency_limit: 1
|
||||
|
||||
dayparts:
|
||||
- name: overnight
|
||||
@@ -61,14 +60,12 @@ dayparts:
|
||||
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
|
||||
@@ -77,6 +74,26 @@ reports:
|
||||
- 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:
|
||||
@@ -84,6 +101,23 @@ reports:
|
||||
- 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
|
||||
|
||||
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
|
||||
9
go.mod
9
go.mod
@@ -4,4 +4,11 @@ go 1.26
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
|
||||
require gitea.maximumdirect.net/eric/distributor v0.5.0
|
||||
require (
|
||||
gitea.maximumdirect.net/eric/distributor v0.5.0
|
||||
gitea.maximumdirect.net/eric/promptkit v0.5.0
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
||||
golang.org/x/sys v0.45.0
|
||||
)
|
||||
|
||||
require golang.org/x/text v0.14.0 // indirect
|
||||
|
||||
8
go.sum
8
go.sum
@@ -1,5 +1,7 @@
|
||||
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=
|
||||
@@ -36,16 +38,22 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3
|
||||
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=
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
package distributor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -22,6 +24,7 @@ type Client struct {
|
||||
TokenEnv string
|
||||
Timeout time.Duration
|
||||
newUploadClient uploadClientFactory
|
||||
pollWait func(context.Context, time.Duration) error
|
||||
}
|
||||
|
||||
type UploadRequest struct {
|
||||
@@ -107,6 +110,10 @@ type runStatus struct {
|
||||
|
||||
const statusPollInterval = 250 * time.Millisecond
|
||||
|
||||
const maxDistributorResponseBytes int64 = 1 << 20
|
||||
|
||||
var errDistributorResponseTooLarge = fmt.Errorf("distributor response exceeds the %d-byte limit", maxDistributorResponseBytes)
|
||||
|
||||
func New(cfg config.DistributorNotifyConfig) *Client {
|
||||
return newClient(cfg, newDistributorUploadClient)
|
||||
}
|
||||
@@ -120,6 +127,7 @@ func newClient(cfg config.DistributorNotifyConfig, factory uploadClientFactory)
|
||||
TokenEnv: cfg.TokenEnv,
|
||||
Timeout: cfg.Timeout,
|
||||
newUploadClient: factory,
|
||||
pollWait: waitForPoll,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,7 +209,12 @@ func (c *Client) Upload(ctx context.Context, req UploadRequest) (UploadResult, e
|
||||
Status: result.Status,
|
||||
UploadStatus: result.Status,
|
||||
}
|
||||
status, statusErr := waitForRunStatus(runCtx, uploadClient, result.RunID, c.Timeout > 0)
|
||||
pollWait := c.pollWait
|
||||
if pollWait == nil {
|
||||
pollWait = waitForPoll
|
||||
}
|
||||
status, statusErr := waitForRunStatus(runCtx, uploadClient, result.RunID, c.Timeout > 0, pollWait)
|
||||
status = sanitizeRunStatus(status)
|
||||
if status.RunID != "" || status.Status != "" {
|
||||
uploadResult.RunStatus = &RunStatus{
|
||||
RunID: status.RunID,
|
||||
@@ -218,7 +231,7 @@ func (c *Client) Upload(ctx context.Context, req UploadRequest) (UploadResult, e
|
||||
}
|
||||
}
|
||||
if statusErr != nil {
|
||||
uploadResult.StatusError = redactTokenString(statusErr.Error(), token)
|
||||
uploadResult.StatusError = safeDistributorDiagnostic(statusErr, token).Error()
|
||||
return uploadResult, nil
|
||||
}
|
||||
if status.Status == "failed" {
|
||||
@@ -227,19 +240,15 @@ func (c *Client) Upload(ctx context.Context, req UploadRequest) (UploadResult, e
|
||||
return uploadResult, nil
|
||||
}
|
||||
|
||||
func waitForRunStatus(ctx context.Context, client uploadClient, runID string, poll bool) (runStatus, error) {
|
||||
func waitForRunStatus(ctx context.Context, client uploadClient, runID string, poll bool, wait func(context.Context, time.Duration) error) (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:
|
||||
if err := wait(ctx, statusPollInterval); err != nil {
|
||||
return status, fmt.Errorf("distributor run %q did not reach terminal status before timeout: %w", runID, err)
|
||||
}
|
||||
|
||||
next, err := client.Status(ctx, runID)
|
||||
@@ -253,6 +262,17 @@ func waitForRunStatus(ctx context.Context, client uploadClient, runID string, po
|
||||
}
|
||||
}
|
||||
|
||||
func waitForPoll(ctx context.Context, interval time.Duration) error {
|
||||
timer := time.NewTimer(interval)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func terminalRunStatus(status string) bool {
|
||||
return status == "succeeded" || status == "failed"
|
||||
}
|
||||
@@ -261,10 +281,52 @@ type distributorUploadClient struct {
|
||||
client *distributorupload.Client
|
||||
}
|
||||
|
||||
type boundedResponseTransport struct {
|
||||
base http.RoundTripper
|
||||
limit int64
|
||||
}
|
||||
|
||||
func (t boundedResponseTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
base := t.base
|
||||
if base == nil {
|
||||
base = http.DefaultTransport
|
||||
}
|
||||
response, err := base.RoundTrip(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(response.Body, t.limit+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > t.limit {
|
||||
return nil, errDistributorResponseTooLarge
|
||||
}
|
||||
response.Body = io.NopCloser(bytes.NewReader(data))
|
||||
response.ContentLength = int64(len(data))
|
||||
return response, nil
|
||||
}
|
||||
|
||||
type RemoteResponseError struct {
|
||||
StatusCode int
|
||||
Retryable bool
|
||||
}
|
||||
|
||||
func (e *RemoteResponseError) Error() string {
|
||||
if e == nil || e.StatusCode == 0 {
|
||||
return "distributor request failed"
|
||||
}
|
||||
return fmt.Sprintf("distributor request failed with HTTP status %d", e.StatusCode)
|
||||
}
|
||||
|
||||
func newDistributorUploadClient(endpoint, token string, timeout time.Duration) (uploadClient, error) {
|
||||
httpClient := (*http.Client)(nil)
|
||||
httpClient := &http.Client{
|
||||
Transport: boundedResponseTransport{base: http.DefaultTransport, limit: maxDistributorResponseBytes},
|
||||
}
|
||||
if timeout > 0 {
|
||||
httpClient = &http.Client{Timeout: timeout}
|
||||
httpClient.Timeout = timeout
|
||||
}
|
||||
client, err := distributorupload.NewClient(distributorupload.ClientOptions{
|
||||
Endpoint: endpoint,
|
||||
@@ -306,7 +368,7 @@ func (c distributorUploadClient) Status(ctx context.Context, runID string) (runS
|
||||
if err != nil {
|
||||
return runStatus{}, err
|
||||
}
|
||||
return runStatus{
|
||||
return sanitizeRunStatus(runStatus{
|
||||
RunID: status.RunID,
|
||||
PipelineID: status.PipelineID,
|
||||
Status: status.Status,
|
||||
@@ -315,7 +377,7 @@ func (c distributorUploadClient) Status(ctx context.Context, runID string) (runS
|
||||
FinishedAt: status.FinishedAt,
|
||||
Report: append(json.RawMessage(nil), status.Report...),
|
||||
Error: status.Error,
|
||||
}, nil
|
||||
}), nil
|
||||
}
|
||||
|
||||
type uploadErrorContext struct {
|
||||
@@ -331,7 +393,7 @@ type uploadErrorContext struct {
|
||||
func wrapUploadError(err error, ctx uploadErrorContext) error {
|
||||
var conflict *distributorupload.IdempotencyConflictError
|
||||
isConflict := errors.As(err, &conflict)
|
||||
err = redactToken(err, ctx.Token)
|
||||
err = safeDistributorDiagnostic(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),
|
||||
@@ -340,6 +402,31 @@ func wrapUploadError(err error, ctx uploadErrorContext) error {
|
||||
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 safeDistributorDiagnostic(err error, token string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, errDistributorResponseTooLarge) {
|
||||
return errDistributorResponseTooLarge
|
||||
}
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return redactToken(err, token)
|
||||
}
|
||||
var httpErr *distributorupload.HTTPError
|
||||
if errors.As(err, &httpErr) {
|
||||
return &RemoteResponseError{StatusCode: httpErr.StatusCode, Retryable: httpErr.Retryable}
|
||||
}
|
||||
return errors.New("distributor request failed")
|
||||
}
|
||||
|
||||
func sanitizeRunStatus(status runStatus) runStatus {
|
||||
status.Report = nil
|
||||
if status.Error != "" {
|
||||
status.Error = "distributor reported a failed run"
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func uploadSourcePaths(files []UploadFile) []string {
|
||||
paths := make([]string, 0, len(files))
|
||||
for _, file := range files {
|
||||
|
||||
310
internal/adapters/distributor/client_http_test.go
Normal file
310
internal/adapters/distributor/client_http_test.go
Normal file
@@ -0,0 +1,310 @@
|
||||
package distributor
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
)
|
||||
|
||||
const oversizedRemoteDiagnostic = "REMOTE-DIAGNOSTIC"
|
||||
|
||||
func TestUploadUsesProductionHTTPBoundary(t *testing.T) {
|
||||
const token = "test-upload-token"
|
||||
var uploadCalls, statusCalls int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/prefix/v1/pipelines/weather/upload":
|
||||
uploadCalls++
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer "+token {
|
||||
t.Fatalf("authorization = %q", got)
|
||||
}
|
||||
if got := r.Header.Get("Idempotency-Key"); got != "bundle-key" {
|
||||
t.Fatalf("idempotency key = %q", got)
|
||||
}
|
||||
if got := r.Header.Get("Content-Type"); got != "application/gzip" {
|
||||
t.Fatalf("content type = %q", got)
|
||||
}
|
||||
verifyUploadedArchive(t, r.Body, "daily/report.md", "report body")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = io.WriteString(w, `{"run_id":"run-123","status":"accepted"}`)
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/prefix/runs/run-123":
|
||||
statusCalls++
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer "+token {
|
||||
t.Fatalf("authorization = %q", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"run_id":"run-123","pipeline_id":"weather","status":"succeeded","report":{"detail":"REMOTE-DETAIL"}}`)
|
||||
default:
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := productionClient(t, server.URL+"/prefix", token)
|
||||
result, err := client.Upload(context.Background(), productionUploadRequest(t))
|
||||
if err != nil || uploadCalls != 1 || statusCalls != 1 || result.RunID != "run-123" || result.Status != "succeeded" || result.UploadStatus != "accepted" || result.RunStatus == nil || result.RunStatus.PipelineID != "weather" || len(result.RunStatus.Report) != 0 {
|
||||
t.Fatalf("result/error/calls = %#v/%v/%d/%d", result, err, uploadCalls, statusCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadClassifiesRemoteHTTPDiagnostics(t *testing.T) {
|
||||
const token = "test-upload-token"
|
||||
const remote = oversizedRemoteDiagnostic
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
handle func(http.ResponseWriter, *http.Request)
|
||||
check func(t *testing.T, result UploadResult, err error)
|
||||
}{
|
||||
{
|
||||
name: "upload failure",
|
||||
handle: func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("method = %s", r.Method)
|
||||
}
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = io.WriteString(w, `{"error":"REMOTE-DIAGNOSTIC","retryable":true}`)
|
||||
},
|
||||
check: func(t *testing.T, _ UploadResult, err error) {
|
||||
t.Helper()
|
||||
var remoteErr *RemoteResponseError
|
||||
if err == nil || !errors.As(err, &remoteErr) || remoteErr.StatusCode != http.StatusBadRequest || !remoteErr.Retryable {
|
||||
t.Fatalf("error = %T %v", err, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "status failure",
|
||||
handle: func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = io.WriteString(w, `{"run_id":"run-123","status":"accepted"}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = io.WriteString(w, remote)
|
||||
},
|
||||
check: func(t *testing.T, result UploadResult, err error) {
|
||||
t.Helper()
|
||||
if err != nil || result.Status != "accepted" || result.StatusError != "distributor request failed with HTTP status 500" {
|
||||
t.Fatalf("result/error = %#v/%v", result, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "failed run",
|
||||
handle: func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = io.WriteString(w, `{"run_id":"run-123","status":"accepted"}`)
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"run_id":"run-123","status":"failed","error":"REMOTE-DIAGNOSTIC","report":{"detail":"REMOTE-DIAGNOSTIC"}}`)
|
||||
},
|
||||
check: func(t *testing.T, result UploadResult, err error) {
|
||||
t.Helper()
|
||||
if err == nil || result.Status != "failed" || result.RunStatus == nil || result.RunStatus.Error != "distributor reported a failed run" || len(result.RunStatus.Report) != 0 {
|
||||
t.Fatalf("result/error = %#v/%v", result, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(tt.handle))
|
||||
defer server.Close()
|
||||
result, err := productionClient(t, server.URL, token).Upload(context.Background(), productionUploadRequest(t))
|
||||
tt.check(t, result, err)
|
||||
for _, value := range []string{fmt.Sprint(result), fmt.Sprint(err)} {
|
||||
if strings.Contains(value, remote) || strings.Contains(value, token) {
|
||||
t.Fatalf("normal diagnostic leaked remote value: %q", value)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadBoundsHTTPResponses(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
response func(size int) string
|
||||
statusCode int
|
||||
statusBody func(size int) string
|
||||
check func(t *testing.T, result UploadResult, err error, overflow bool)
|
||||
}{
|
||||
{
|
||||
name: "accepted response",
|
||||
response: func(size int) string {
|
||||
return paddedJSON(t, `{"run_id":"run-123","status":"accepted","detail":"REMOTE-DIAGNOSTIC"}`, size)
|
||||
},
|
||||
statusBody: func(_ int) string {
|
||||
return `{"run_id":"run-123","status":"succeeded"}`
|
||||
},
|
||||
check: func(t *testing.T, result UploadResult, err error, overflow bool) {
|
||||
t.Helper()
|
||||
if overflow {
|
||||
if !errors.Is(err, errDistributorResponseTooLarge) || result.RunID != "" {
|
||||
t.Fatalf("overflow result/error = %#v/%v", result, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || result.Status != "succeeded" {
|
||||
t.Fatalf("bounded result/error = %#v/%v", result, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "status report",
|
||||
response: func(_ int) string {
|
||||
return `{"run_id":"run-123","status":"accepted"}`
|
||||
},
|
||||
statusBody: func(size int) string { return statusReportBody(t, size) },
|
||||
check: func(t *testing.T, result UploadResult, err error, overflow bool) {
|
||||
t.Helper()
|
||||
if overflow {
|
||||
if err != nil || result.Status != "accepted" || result.StatusError != errDistributorResponseTooLarge.Error() {
|
||||
t.Fatalf("overflow result/error = %#v/%v", result, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || result.Status != "succeeded" || result.RunStatus == nil || len(result.RunStatus.Report) != 0 {
|
||||
t.Fatalf("bounded result/error = %#v/%v", result, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "error response",
|
||||
response: func(size int) string { return repeatedToLength(oversizedRemoteDiagnostic, size) },
|
||||
statusCode: http.StatusBadRequest,
|
||||
check: func(t *testing.T, result UploadResult, err error, overflow bool) {
|
||||
t.Helper()
|
||||
if overflow {
|
||||
if !errors.Is(err, errDistributorResponseTooLarge) || result.RunID != "" {
|
||||
t.Fatalf("overflow result/error = %#v/%v", result, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
var remoteErr *RemoteResponseError
|
||||
if !errors.As(err, &remoteErr) || remoteErr.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("bounded result/error = %#v/%v", result, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
} {
|
||||
for _, overflow := range []bool{false, true} {
|
||||
t.Run(tt.name+"/"+map[bool]string{false: "limit", true: "over-limit"}[overflow], func(t *testing.T) {
|
||||
size := int(maxDistributorResponseBytes)
|
||||
if overflow {
|
||||
size++
|
||||
}
|
||||
var uploadCalls int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
uploadCalls++
|
||||
statusCode := tt.statusCode
|
||||
if statusCode == 0 {
|
||||
statusCode = http.StatusAccepted
|
||||
}
|
||||
w.WriteHeader(statusCode)
|
||||
_, _ = io.WriteString(w, tt.response(size))
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, tt.statusBody(size))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := productionClient(t, server.URL, "test-upload-token").Upload(context.Background(), productionUploadRequest(t))
|
||||
tt.check(t, result, err, overflow)
|
||||
if strings.Contains(fmt.Sprint(result), oversizedRemoteDiagnostic) || strings.Contains(fmt.Sprint(err), oversizedRemoteDiagnostic) {
|
||||
t.Fatalf("result/error leaked oversized response detail: %#v/%v", result, err)
|
||||
}
|
||||
if uploadCalls != 1 {
|
||||
t.Fatalf("upload calls = %d, want one", uploadCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func productionClient(t *testing.T, endpoint, token string) *Client {
|
||||
t.Helper()
|
||||
cfg := config.Defaults().Notify.Distributor
|
||||
cfg.Endpoint = endpoint
|
||||
cfg.Timeout = 0
|
||||
t.Setenv(cfg.TokenEnv, token)
|
||||
return New(cfg)
|
||||
}
|
||||
|
||||
func productionUploadRequest(t *testing.T) UploadRequest {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "report.md")
|
||||
if err := os.WriteFile(path, []byte("report body"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return UploadRequest{
|
||||
PipelineID: "weather", BundleID: "bundle", IdempotencyKey: "bundle-key",
|
||||
Files: []UploadFile{{SourcePath: path, BundlePath: "daily/report.md"}},
|
||||
CreatedAt: time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC),
|
||||
}
|
||||
}
|
||||
|
||||
func verifyUploadedArchive(t *testing.T, body io.Reader, wantPath, wantContents string) {
|
||||
t.Helper()
|
||||
reader, err := gzip.NewReader(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reader.Close()
|
||||
archive := tar.NewReader(reader)
|
||||
for {
|
||||
header, err := archive.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if header.Name != wantPath {
|
||||
continue
|
||||
}
|
||||
contents, err := io.ReadAll(archive)
|
||||
if err != nil || string(contents) != wantContents {
|
||||
t.Fatalf("archive file contents/error = %q/%v", contents, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("archive did not contain %q", wantPath)
|
||||
}
|
||||
|
||||
func paddedJSON(t *testing.T, value string, size int) string {
|
||||
t.Helper()
|
||||
if len(value) > size {
|
||||
t.Fatalf("JSON length = %d, exceeds requested size %d", len(value), size)
|
||||
}
|
||||
return value + strings.Repeat(" ", size-len(value))
|
||||
}
|
||||
|
||||
func statusReportBody(t *testing.T, size int) string {
|
||||
t.Helper()
|
||||
const prefix = `{"run_id":"run-123","pipeline_id":"weather","status":"succeeded","report":"`
|
||||
const suffix = `"}`
|
||||
if len(prefix)+len(suffix) > size {
|
||||
t.Fatalf("status response exceeds requested size %d", size)
|
||||
}
|
||||
return prefix + repeatedToLength(oversizedRemoteDiagnostic, size-len(prefix)-len(suffix)) + suffix
|
||||
}
|
||||
|
||||
func repeatedToLength(value string, size int) string {
|
||||
return strings.Repeat(value, size/len(value)+1)[:size]
|
||||
}
|
||||
@@ -45,8 +45,8 @@ func TestUploadUsesConfiguredClientAndFiles(t *testing.T) {
|
||||
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 result.RunStatus == nil || result.RunStatus.PipelineID != "reports" || len(result.RunStatus.Report) != 0 {
|
||||
t.Fatalf("RunStatus = %#v, want safe status details", result.RunStatus)
|
||||
}
|
||||
if factory.endpoint != cfg.Endpoint {
|
||||
t.Fatalf("factory endpoint = %q, want %q", factory.endpoint, cfg.Endpoint)
|
||||
@@ -242,13 +242,14 @@ func TestUploadPollsUntilTerminalStatus(t *testing.T) {
|
||||
},
|
||||
}
|
||||
client := newClient(cfg, factory.newClient)
|
||||
client.pollWait = func(context.Context, time.Duration) error { return nil }
|
||||
|
||||
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 result.Status != "succeeded" || result.RunStatus == nil || len(result.RunStatus.Report) != 0 {
|
||||
t.Fatalf("result = %#v, want terminal succeeded status without remote report", result)
|
||||
}
|
||||
if factory.client.statusCalls != 2 {
|
||||
t.Fatalf("status calls = %d, want 2", factory.client.statusCalls)
|
||||
@@ -298,8 +299,8 @@ func TestUploadFailsWhenDistributorRunFailed(t *testing.T) {
|
||||
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 result.RunStatus == nil || result.RunStatus.Status != "failed" || len(result.RunStatus.Report) != 0 || result.RunStatus.Error != "distributor reported a failed run" {
|
||||
t.Fatalf("result = %#v, want safe failed run status", 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)
|
||||
|
||||
330
internal/adapters/promptkit/adapter.go
Normal file
330
internal/adapters/promptkit/adapter.go
Normal file
@@ -0,0 +1,330 @@
|
||||
// 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/generatedtext"
|
||||
"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.
|
||||
// It supports concurrent Execute calls on the shared executor.
|
||||
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,
|
||||
)
|
||||
rawOutput := []byte(nil)
|
||||
if len(value.RawOutput) <= generatedtext.MaxGeneratedTextBytes {
|
||||
rawOutput = []byte(value.RawOutput)
|
||||
} else {
|
||||
validation = promptexec.NewValidation(
|
||||
promptexec.ValidationFailed,
|
||||
string(value.Validation.Mode),
|
||||
value.Validation.SchemaPath,
|
||||
[]string{"generated output exceeds the configured size limit"},
|
||||
)
|
||||
}
|
||||
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: rawOutput,
|
||||
}
|
||||
if captureDebug {
|
||||
execution.Debug = &promptexec.ExecutionDebug{
|
||||
RawOutput: append([]byte(nil), 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"`
|
||||
}{
|
||||
Temperature: value.Temperature,
|
||||
MaxTokens: value.MaxTokens,
|
||||
TopP: value.TopP,
|
||||
TimeoutSeconds: value.TimeoutSeconds,
|
||||
ServiceTier: value.ServiceTier,
|
||||
ReasoningEffort: value.ReasoningEffort,
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
626
internal/adapters/promptkit/adapter_test.go
Normal file
626
internal/adapters/promptkit/adapter_test.go
Normal file
@@ -0,0 +1,626 @@
|
||||
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/generatedtext"
|
||||
"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
|
||||
started chan struct{}
|
||||
}
|
||||
|
||||
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
|
||||
started := client.started
|
||||
response := client.response
|
||||
err := client.err
|
||||
client.mu.Unlock()
|
||||
if started != nil {
|
||||
started <- struct{}{}
|
||||
}
|
||||
if block {
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return response, err
|
||||
}
|
||||
|
||||
func TestExecuteSupportsConcurrentCalls(t *testing.T) {
|
||||
client := &fakeClient{response: validResponse(), block: true, started: make(chan struct{}, 2)}
|
||||
adapter := newTestAdapter(t, client)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
executionErrors := make(chan error, 2)
|
||||
for range 2 {
|
||||
go func() {
|
||||
_, err := adapter.Execute(ctx, testExecuteRequest(), nil)
|
||||
executionErrors <- err
|
||||
}()
|
||||
}
|
||||
for range 2 {
|
||||
select {
|
||||
case <-client.started:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for concurrent Promptkit calls")
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
for range 2 {
|
||||
if err := <-executionErrors; promptexec.CategoryOf(err) != promptexec.Canceled {
|
||||
t.Fatalf("Execute() error/category = %v/%q", err, promptexec.CategoryOf(err))
|
||||
}
|
||||
}
|
||||
if client.callCount() != 2 {
|
||||
t.Fatalf("provider calls = %d, want 2", client.callCount())
|
||||
}
|
||||
}
|
||||
|
||||
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 TestMarshalDebugParametersOmitsProviderExtras(t *testing.T) {
|
||||
const marker = "private-debug-marker"
|
||||
parameters := string(marshalDebugParameters(promptkit.ExecutionTarget{
|
||||
Temperature: 0.2,
|
||||
MaxTokens: 400,
|
||||
TopP: 0.9,
|
||||
TimeoutSeconds: 30,
|
||||
ServiceTier: "flex",
|
||||
ReasoningEffort: "high",
|
||||
ExtraParams: map[string]any{
|
||||
"access-key": marker,
|
||||
"signature": marker,
|
||||
},
|
||||
}))
|
||||
if strings.Contains(parameters, marker) || strings.Contains(parameters, "extra_params") {
|
||||
t.Fatalf("debug parameters leaked provider extras: %s", parameters)
|
||||
}
|
||||
for _, want := range []string{`"temperature":0.2`, `"max_tokens":400`, `"top_p":0.9`, `"timeout_seconds":30`, `"service_tier":"flex"`, `"reasoning_effort":"high"`} {
|
||||
if !strings.Contains(parameters, want) {
|
||||
t.Fatalf("debug parameters missing safe value %q: %s", want, parameters)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 TestExecuteDropsOversizedGeneratedOutput(t *testing.T) {
|
||||
client := &fakeClient{response: &promptkit.GenerateResponse{Content: strings.Repeat("x", generatedtext.MaxGeneratedTextBytes+1)}}
|
||||
adapter := newTestAdapter(t, client)
|
||||
request := testExecuteRequest()
|
||||
request.CaptureDebug = true
|
||||
result, err := adapter.Execute(context.Background(), request, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if result == nil || result.Validation.Status != promptexec.ValidationFailed || len(result.RawOutput) != 0 || result.Debug == nil || len(result.Debug.RawOutput) != 0 {
|
||||
t.Fatalf("execution = %#v", result)
|
||||
}
|
||||
if len(result.Validation.Diagnostics) != 1 || result.Validation.Diagnostics[0] != "generated output exceeds the configured size limit" {
|
||||
t.Fatalf("diagnostics = %#v", result.Validation.Diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
t.Setenv("WEATHERREPORTER_TEST_MISSING_KEY", "")
|
||||
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,248 +0,0 @@
|
||||
// Package scriptorium adapts the external scriptorium CLI.
|
||||
package scriptorium
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"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")
|
||||
}
|
||||
execution, err := r.execute(ctx, r.renderArgs(req))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("run scriptorium render: %w", err)
|
||||
}
|
||||
result := &RenderResult{
|
||||
Command: execution.argv(),
|
||||
Stdout: string(execution.result.Stdout),
|
||||
Stderr: string(execution.result.Stderr),
|
||||
StdoutTruncated: execution.result.StdoutTruncated,
|
||||
StderrTruncated: execution.result.StderrTruncated,
|
||||
ExitCode: execution.result.ExitCode,
|
||||
}
|
||||
if execution.result.ExitCode != 0 {
|
||||
return result, fmt.Errorf("scriptorium render exited with code %d: %s", execution.result.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")
|
||||
}
|
||||
execution, err := r.execute(ctx, r.runArgs(req))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("run scriptorium: %w", err)
|
||||
}
|
||||
result := &RunResult{
|
||||
Command: execution.argv(),
|
||||
Stdout: string(execution.result.Stdout),
|
||||
Stderr: string(execution.result.Stderr),
|
||||
StdoutTruncated: execution.result.StdoutTruncated,
|
||||
StderrTruncated: execution.result.StderrTruncated,
|
||||
ExitCode: execution.result.ExitCode,
|
||||
OutputPath: req.OutputPath,
|
||||
}
|
||||
if execution.result.ExitCode != 0 {
|
||||
return result, fmt.Errorf("scriptorium run exited with code %d: %s", execution.result.ExitCode, result.Stderr)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type execution struct {
|
||||
binary string
|
||||
args []string
|
||||
result CommandResult
|
||||
}
|
||||
|
||||
func (r Runner) execute(ctx context.Context, args []string) (execution, error) {
|
||||
binary := r.Binary
|
||||
if binary == "" {
|
||||
binary = "scriptorium"
|
||||
}
|
||||
commands := r.Commands
|
||||
if commands == nil {
|
||||
commands = ExecRunner{}
|
||||
}
|
||||
result, err := commands.Run(ctx, binary, args, r.Timeout)
|
||||
if err != nil {
|
||||
return execution{}, err
|
||||
}
|
||||
return execution{binary: binary, args: args, result: result}, nil
|
||||
}
|
||||
|
||||
func (e execution) argv() []string {
|
||||
return append([]string{e.binary}, e.args...)
|
||||
}
|
||||
|
||||
func (r Runner) renderArgs(req RenderRequest) []string {
|
||||
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
|
||||
}
|
||||
|
||||
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.yaml",
|
||||
})
|
||||
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.yaml",
|
||||
"--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.yaml",
|
||||
})
|
||||
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.yaml",
|
||||
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.yaml",
|
||||
"--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.yaml",
|
||||
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
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -14,13 +15,28 @@ import (
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
const (
|
||||
convectiveOutlooksEndpoint = "/outlooks/convective"
|
||||
currentConditionsEndpoint = "/conditions/current"
|
||||
sourceSPCConvectiveOutlooks = config.MissingSourceSPCConvectiveOutlooks
|
||||
|
||||
defaultWarmupEndpoint = currentConditionsEndpoint
|
||||
defaultWarmupAttempts = 3
|
||||
defaultWarmupDelay = time.Second
|
||||
defaultFetchAttempts = 2
|
||||
defaultFetchRetryDelay = time.Second
|
||||
maxResponseBodyBytes = 10 << 20
|
||||
)
|
||||
|
||||
var errResponseBodyTooLarge = errors.New("response exceeds 10 MiB limit")
|
||||
|
||||
type Client struct {
|
||||
baseURL *url.URL
|
||||
httpClient *http.Client
|
||||
@@ -30,6 +46,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)
|
||||
@@ -58,6 +80,9 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
|
||||
if err != nil || baseURL.Scheme == "" || baseURL.Host == "" {
|
||||
return nil, fmt.Errorf("weather_api.base_url must be an absolute URL")
|
||||
}
|
||||
if !strings.EqualFold(baseURL.Scheme, "http") && !strings.EqualFold(baseURL.Scheme, "https") {
|
||||
return nil, fmt.Errorf("weather_api.base_url must use http or https")
|
||||
}
|
||||
|
||||
timeout := cfg.WeatherAPI.Timeout
|
||||
if timeout <= 0 {
|
||||
@@ -75,7 +100,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,94 +114,172 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
|
||||
}
|
||||
|
||||
func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
|
||||
fetchedAt := c.now()
|
||||
builder := bundleBuilder{
|
||||
client: c,
|
||||
bundle: &weatherdata.Bundle{FetchedAt: fetchedAt},
|
||||
fetchedAt: fetchedAt,
|
||||
warmup, err := c.warmup(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := builder.fetchObservation(ctx); err != nil {
|
||||
return nil, err
|
||||
fetchedAt := c.now()
|
||||
builder := bundleBuilder{
|
||||
client: c,
|
||||
bundle: &weatherdata.Bundle{FetchedAt: fetchedAt},
|
||||
}
|
||||
if err := builder.fetchCurrent(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := builder.fetchHourly(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := builder.fetchNarrative(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := builder.fetchAlerts(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := builder.fetchDiscussion(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := builder.fetchWeatherStory(ctx); err != nil {
|
||||
return nil, err
|
||||
|
||||
for _, acquired := range builder.acquireSources(ctx, warmup) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, fmt.Errorf("fetch weather API sources: %w", err)
|
||||
}
|
||||
if err := builder.mergeSource(acquired); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return builder.bundle, nil
|
||||
}
|
||||
|
||||
type bundleBuilder struct {
|
||||
client *Client
|
||||
bundle *weatherdata.Bundle
|
||||
fetchedAt time.Time
|
||||
client *Client
|
||||
bundle *weatherdata.Bundle
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, "observations", "/observations", queryOptions{precision: true})
|
||||
if err != nil {
|
||||
type sourceRequest struct {
|
||||
name string
|
||||
endpoint string
|
||||
query queryOptions
|
||||
missingMessage string
|
||||
required bool
|
||||
decodeLabel string
|
||||
}
|
||||
|
||||
type fetchedSource struct {
|
||||
raw json.RawMessage
|
||||
source weatherdata.Source
|
||||
}
|
||||
|
||||
type warmupResponse struct {
|
||||
endpoint string
|
||||
requestURL *url.URL
|
||||
body []byte
|
||||
fetchedAt time.Time
|
||||
}
|
||||
|
||||
type sourceAcquisition struct {
|
||||
request sourceRequest
|
||||
fetched fetchedSource
|
||||
err error
|
||||
warmup warmupResponse
|
||||
usesWarmup bool
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) acquireSources(ctx context.Context, warmup warmupResponse) []sourceAcquisition {
|
||||
sources := []sourceAcquisition{
|
||||
{request: sourceRequest{name: config.MissingSourceObservations, endpoint: "/observations", query: queryOptions{precision: true}, missingMessage: "observation data is missing"}},
|
||||
{request: currentConditionsRequest()},
|
||||
{request: sourceRequest{name: "hourly", endpoint: "/forecast/hourly", query: queryOptions{precision: true, timezone: true}, missingMessage: "hourly forecast data is missing", required: true, decodeLabel: "hourly forecast"}},
|
||||
{request: sourceRequest{name: config.MissingSourceNarrative, endpoint: "/forecast/narrative", query: queryOptions{precision: true, timezone: true}, missingMessage: "narrative forecast data is missing"}},
|
||||
{request: sourceRequest{name: config.MissingSourceAlerts, endpoint: "/alerts/active", query: queryOptions{allowNull: true}, missingMessage: "active alerts data is missing"}},
|
||||
{request: sourceRequest{name: config.MissingSourceDiscussion, endpoint: "/discussion", query: queryOptions{timezone: true}, missingMessage: "forecast discussion data is missing"}},
|
||||
{request: sourceRequest{name: config.MissingSourceWeatherStory, endpoint: "/weatherstories/latest", query: queryOptions{omitUnits: true}, missingMessage: "NWS weather story data is missing"}},
|
||||
{request: sourceRequest{name: sourceSPCConvectiveOutlooks, endpoint: convectiveOutlooksEndpoint, query: queryOptions{timezone: true, omitUnits: true}, missingMessage: "SPC convective outlook data is missing"}},
|
||||
}
|
||||
if warmup.endpoint == currentConditionsEndpoint {
|
||||
sources[1].warmup = warmup
|
||||
sources[1].usesWarmup = true
|
||||
}
|
||||
|
||||
var group sync.WaitGroup
|
||||
for i := range sources {
|
||||
if sources[i].usesWarmup {
|
||||
continue
|
||||
}
|
||||
group.Add(1)
|
||||
go func(index int) {
|
||||
defer group.Done()
|
||||
request := sources[index].request
|
||||
raw, source, err := b.client.fetch(ctx, request.name, request.endpoint, request.query)
|
||||
sources[index].fetched = fetchedSource{raw: raw, source: source}
|
||||
sources[index].err = err
|
||||
}(i)
|
||||
}
|
||||
group.Wait()
|
||||
return sources
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) mergeSource(acquired sourceAcquisition) error {
|
||||
switch acquired.request.name {
|
||||
case config.MissingSourceObservations:
|
||||
return b.fetchObservation(acquired)
|
||||
case config.MissingSourceCurrent:
|
||||
return b.fetchCurrent(acquired)
|
||||
case "hourly":
|
||||
return b.fetchHourly(acquired)
|
||||
case config.MissingSourceNarrative:
|
||||
return b.fetchNarrative(acquired)
|
||||
case config.MissingSourceAlerts:
|
||||
return b.fetchAlerts(acquired)
|
||||
case config.MissingSourceDiscussion:
|
||||
return b.fetchDiscussion(acquired)
|
||||
case config.MissingSourceWeatherStory:
|
||||
return b.fetchWeatherStory(acquired)
|
||||
case sourceSPCConvectiveOutlooks:
|
||||
return b.fetchSPCConvectiveOutlooks(acquired)
|
||||
default:
|
||||
return fmt.Errorf("merge unknown weather source %q", acquired.request.name)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchObservation(acquired sourceAcquisition) error {
|
||||
var observation weatherdata.Observation
|
||||
fetched, ok, err := b.fetchDecodedSource(acquired, &observation)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "observation data is missing", false)
|
||||
}
|
||||
var observation weatherdata.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)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, "current", "/conditions/current", queryOptions{precision: true})
|
||||
if err != nil {
|
||||
func currentConditionsRequest() sourceRequest {
|
||||
return sourceRequest{
|
||||
name: config.MissingSourceCurrent,
|
||||
endpoint: currentConditionsEndpoint,
|
||||
query: queryOptions{precision: true},
|
||||
missingMessage: "current conditions data is missing",
|
||||
}
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchCurrent(acquired sourceAcquisition) error {
|
||||
var current weatherdata.Current
|
||||
fetched, ok, err := b.fetchDecodedSource(acquired, ¤t)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "current conditions data is missing", false)
|
||||
}
|
||||
var current weatherdata.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 {
|
||||
func (b *bundleBuilder) fetchHourly(acquired sourceAcquisition) error {
|
||||
var hourly weatherdata.ForecastRun
|
||||
fetched, ok, err := b.fetchDecodedSource(acquired, &hourly)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "hourly forecast data is missing", true)
|
||||
}
|
||||
var hourly weatherdata.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)
|
||||
}
|
||||
for i, period := range hourly.Periods {
|
||||
if !period.HasUsableTimeBounds() {
|
||||
return fmt.Errorf("hourly forecast from %s has unusable time bounds for period %d", source.Endpoint, i+1)
|
||||
}
|
||||
if !period.HasValidPrecipitationProbability() {
|
||||
return fmt.Errorf("hourly forecast from %s has invalid precipitation probability for period %d", source.Endpoint, i+1)
|
||||
}
|
||||
}
|
||||
source.IssuedAt = &hourly.IssuedAt
|
||||
source.UpdatedAt = hourly.UpdatedAt
|
||||
b.bundle.Hourly = &hourly
|
||||
@@ -179,18 +287,13 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
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 {
|
||||
func (b *bundleBuilder) fetchNarrative(acquired sourceAcquisition) error {
|
||||
var narrative weatherdata.ForecastRun
|
||||
fetched, ok, err := b.fetchDecodedSource(acquired, &narrative)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "narrative forecast data is missing", false)
|
||||
}
|
||||
var narrative weatherdata.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
|
||||
@@ -198,24 +301,21 @@ func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, "alerts", "/alerts/active", queryOptions{allowNull: true})
|
||||
if err != nil {
|
||||
func (b *bundleBuilder) fetchAlerts(acquired sourceAcquisition) error {
|
||||
fetched, ok, err := b.fetchSource(acquired)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "active alerts data is missing", false)
|
||||
}
|
||||
raw, source := fetched.raw, fetched.source
|
||||
if isJSONNull(raw) {
|
||||
b.bundle.Alerts = &weatherdata.AlertRun{Raw: append(json.RawMessage(nil), raw...)}
|
||||
b.bundle.Alerts = &weatherdata.AlertRun{}
|
||||
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, acquired.request)
|
||||
}
|
||||
alerts.Raw = append(json.RawMessage(nil), raw...)
|
||||
if alerts.AsOf != nil {
|
||||
source.IssuedAt = alerts.AsOf
|
||||
}
|
||||
@@ -224,18 +324,13 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, "discussion", "/discussion", queryOptions{timezone: true})
|
||||
if err != nil {
|
||||
func (b *bundleBuilder) fetchDiscussion(acquired sourceAcquisition) error {
|
||||
var discussion weatherdata.Discussion
|
||||
fetched, ok, err := b.fetchDecodedSource(acquired, &discussion)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "forecast discussion data is missing", false)
|
||||
}
|
||||
var discussion weatherdata.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
|
||||
@@ -243,17 +338,15 @@ func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, "weather_story", "/weatherstories/latest", queryOptions{omitUnits: true})
|
||||
if err != nil {
|
||||
func (b *bundleBuilder) fetchWeatherStory(acquired sourceAcquisition) error {
|
||||
var story weatherdata.WeatherStory
|
||||
fetched, ok, err := b.fetchDecodedSource(acquired, &story)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "NWS weather story data is missing", false)
|
||||
}
|
||||
var story weatherdata.WeatherStory
|
||||
if err := decodeSource(raw, &story); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
source := fetched.source
|
||||
if !story.HasUsableContent() {
|
||||
return b.handleMalformed(&source, fmt.Errorf("weather story has no usable content"), acquired.request)
|
||||
}
|
||||
if !story.StartTime.IsZero() {
|
||||
source.IssuedAt = &story.StartTime
|
||||
@@ -264,6 +357,55 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchSPCConvectiveOutlooks(acquired sourceAcquisition) error {
|
||||
var run weatherdata.ConvectiveOutlookRun
|
||||
fetched, ok, err := b.fetchDecodedSource(acquired, &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(acquired sourceAcquisition, target any) (fetchedSource, bool, error) {
|
||||
fetched, ok, err := b.fetchSource(acquired)
|
||||
if err != nil || !ok {
|
||||
return fetchedSource{}, false, err
|
||||
}
|
||||
return b.decodeFetchedSource(fetched, acquired.request, target)
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) decodeFetchedSource(fetched fetchedSource, request sourceRequest, target any) (fetchedSource, bool, error) {
|
||||
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(acquired sourceAcquisition) (fetchedSource, bool, error) {
|
||||
if acquired.usesWarmup {
|
||||
raw, source, err := b.client.decodeSourceResponse(acquired.request.name, acquired.request.endpoint, acquired.request.query, acquired.warmup.requestURL, acquired.warmup.body, acquired.warmup.fetchedAt)
|
||||
if err != nil {
|
||||
return fetchedSource{}, false, err
|
||||
}
|
||||
acquired.fetched = fetchedSource{raw: raw, source: source}
|
||||
} else if acquired.err != nil {
|
||||
return fetchedSource{}, false, acquired.err
|
||||
}
|
||||
if acquired.fetched.raw == nil {
|
||||
return fetchedSource{}, false, b.handleMissing(&acquired.fetched.source, acquired.request.missingMessage, acquired.request.required)
|
||||
}
|
||||
return acquired.fetched, true, nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error {
|
||||
source.Missing = true
|
||||
if required {
|
||||
@@ -272,9 +414,13 @@ func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string
|
||||
return b.applyMissingPolicy(source, "missing_source", message)
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) handleMalformed(source *weatherdata.Source, err error, required bool) error {
|
||||
if required {
|
||||
return fmt.Errorf("decode %s from %s: %w", source.Name, source.Endpoint, err)
|
||||
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))
|
||||
@@ -324,26 +470,14 @@ type envelope struct {
|
||||
}
|
||||
|
||||
func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string, opts queryOptions) (json.RawMessage, weatherdata.Source, error) {
|
||||
reqURL := c.endpointURL(endpoint, opts)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
|
||||
reqURL, body, err := c.fetchHTTP(ctx, endpoint, opts)
|
||||
if err != nil {
|
||||
return nil, weatherdata.Source{}, fmt.Errorf("create request for %s: %w", endpoint, err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, weatherdata.Source{}, fmt.Errorf("fetch %s: %w", endpoint, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
||||
if err != nil {
|
||||
return nil, weatherdata.Source{}, fmt.Errorf("read %s response: %w", endpoint, err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, weatherdata.Source{}, fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
return nil, weatherdata.Source{}, err
|
||||
}
|
||||
return c.decodeSourceResponse(sourceName, endpoint, opts, reqURL, body, c.now())
|
||||
}
|
||||
|
||||
func (c *Client) decodeSourceResponse(sourceName string, endpoint string, opts queryOptions, reqURL *url.URL, body []byte, fetchedAt time.Time) (json.RawMessage, weatherdata.Source, error) {
|
||||
var env envelope
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
return nil, weatherdata.Source{}, fmt.Errorf("decode %s envelope: %w", endpoint, err)
|
||||
@@ -353,7 +487,7 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
|
||||
Name: sourceName,
|
||||
Endpoint: endpoint,
|
||||
Query: queryMap(reqURL.Query()),
|
||||
FetchedAt: c.now(),
|
||||
FetchedAt: fetchedAt,
|
||||
}
|
||||
if len(env.Data) == 0 || (isJSONNull(env.Data) && !opts.allowNull) {
|
||||
source.Missing = true
|
||||
@@ -367,6 +501,171 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
|
||||
return env.Data, source, nil
|
||||
}
|
||||
|
||||
func (c *Client) warmup(ctx context.Context) (warmupResponse, error) {
|
||||
endpoint := c.warmupEndpoint
|
||||
if strings.TrimSpace(endpoint) == "" {
|
||||
endpoint = defaultWarmupEndpoint
|
||||
}
|
||||
attempts := positiveAttemptCount(c.warmupAttempts)
|
||||
var lastErr error
|
||||
var lastRetryable bool
|
||||
for attempt := 1; attempt <= attempts; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return warmupResponse{}, fmt.Errorf("warm up weather API via %s: %w", endpoint, err)
|
||||
}
|
||||
reqURL, body, err := c.warmupOnce(ctx, endpoint)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
lastRetryable = isRetryableRequestError(err)
|
||||
} else {
|
||||
return warmupResponse{endpoint: endpoint, requestURL: reqURL, body: body, fetchedAt: c.now()}, nil
|
||||
}
|
||||
if !lastRetryable || attempt == attempts {
|
||||
break
|
||||
}
|
||||
if err := waitForRetry(ctx, c.warmupDelay); err != nil {
|
||||
return warmupResponse{}, fmt.Errorf("warm up weather API via %s after %d attempt(s): %w", endpoint, attempt, err)
|
||||
}
|
||||
}
|
||||
if !lastRetryable {
|
||||
return warmupResponse{}, lastErr
|
||||
}
|
||||
return warmupResponse{}, 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) (*url.URL, []byte, error) {
|
||||
return c.fetchHTTPOnce(ctx, endpoint, queryOptions{precision: true})
|
||||
}
|
||||
|
||||
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 := readResponseBody(resp.Body)
|
||||
if err != nil {
|
||||
return reqURL, nil, responseReadError(ctx, endpoint, err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
err := fmt.Errorf("fetch %s: unexpected HTTP status %d", endpoint, resp.StatusCode)
|
||||
if isRetryableHTTPStatus(resp.StatusCode) {
|
||||
return reqURL, nil, retryableRequestError{err: err}
|
||||
}
|
||||
return reqURL, nil, err
|
||||
}
|
||||
return reqURL, body, nil
|
||||
}
|
||||
|
||||
func readResponseBody(body io.Reader) ([]byte, error) {
|
||||
data, err := io.ReadAll(io.LimitReader(body, maxResponseBodyBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > maxResponseBodyBytes {
|
||||
return nil, errResponseBodyTooLarge
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func responseReadError(ctx context.Context, endpoint string, err error) error {
|
||||
err = fmt.Errorf("read %s response: %w", endpoint, err)
|
||||
if errors.Is(err, errResponseBodyTooLarge) || ctx.Err() != nil {
|
||||
return err
|
||||
}
|
||||
return retryableRequestError{err: err}
|
||||
}
|
||||
|
||||
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"))
|
||||
}
|
||||
@@ -417,10 +716,3 @@ func sourceHash(raw json.RawMessage) (string, error) {
|
||||
sum := sha256.Sum256(compact.Bytes())
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@ package weatherapi
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +17,12 @@ import (
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func TestFetchBundleFromFixtures(t *testing.T) {
|
||||
var requested []string
|
||||
server := fixtureServer(t, nil, &requested)
|
||||
@@ -55,12 +63,45 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
||||
if bundle.WeatherStory.UpdatedAt == nil {
|
||||
t.Fatalf("WeatherStory.UpdatedAt = nil, want update timestamp")
|
||||
}
|
||||
if len(bundle.Sources) != 7 {
|
||||
t.Fatalf("Sources length = %d, want 7", len(bundle.Sources))
|
||||
if 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) != 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) {
|
||||
t.Fatalf("requested paths = %v, want %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 got := countPath(requested, currentConditionsEndpoint); got != 1 {
|
||||
t.Fatalf("conditions/current requests = %d, want 1; requested paths = %v", got, requested)
|
||||
}
|
||||
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
|
||||
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
|
||||
}
|
||||
@@ -70,6 +111,194 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
||||
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 TestFetchBundleMergesConcurrentSourcesInSourceOrder(t *testing.T) {
|
||||
paths := []string{
|
||||
"/observations",
|
||||
"/forecast/hourly",
|
||||
"/forecast/narrative",
|
||||
"/alerts/active",
|
||||
"/discussion",
|
||||
"/weatherstories/latest",
|
||||
convectiveOutlooksEndpoint,
|
||||
}
|
||||
started := make(chan string, len(paths))
|
||||
release := make(map[string]chan struct{}, len(paths))
|
||||
for _, path := range paths {
|
||||
release[path] = make(chan struct{})
|
||||
}
|
||||
var releaseOnce sync.Once
|
||||
releaseAll := func() {
|
||||
releaseOnce.Do(func() {
|
||||
for i := len(paths) - 1; i >= 0; i-- {
|
||||
close(release[paths[i]])
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Cleanup(releaseAll)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == currentConditionsEndpoint {
|
||||
if !serveWeatherFixture(w, r) {
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
return
|
||||
}
|
||||
ready, ok := release[r.URL.Path]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
started <- r.URL.Path
|
||||
<-ready
|
||||
if !serveWeatherFixture(w, r) {
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
client := newTestClient(t, server.URL+"/", nil)
|
||||
|
||||
type fetchResult struct {
|
||||
bundle *weatherdata.Bundle
|
||||
err error
|
||||
}
|
||||
result := make(chan fetchResult, 1)
|
||||
go func() {
|
||||
bundle, err := client.FetchBundle(context.Background())
|
||||
result <- fetchResult{bundle: bundle, err: err}
|
||||
}()
|
||||
|
||||
seen := make(map[string]bool, len(paths))
|
||||
for range paths {
|
||||
select {
|
||||
case path := <-started:
|
||||
seen[path] = true
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("independent requests started = %v, want %v", seen, paths)
|
||||
}
|
||||
}
|
||||
releaseAll()
|
||||
|
||||
select {
|
||||
case got := <-result:
|
||||
if got.err != nil {
|
||||
t.Fatalf("FetchBundle() error = %v", got.err)
|
||||
}
|
||||
wantSources := []string{
|
||||
config.MissingSourceObservations,
|
||||
config.MissingSourceCurrent,
|
||||
"hourly",
|
||||
config.MissingSourceNarrative,
|
||||
config.MissingSourceAlerts,
|
||||
config.MissingSourceDiscussion,
|
||||
config.MissingSourceWeatherStory,
|
||||
sourceSPCConvectiveOutlooks,
|
||||
}
|
||||
gotSources := make([]string, 0, len(got.bundle.Sources))
|
||||
for _, source := range got.bundle.Sources {
|
||||
gotSources = append(gotSources, source.Name)
|
||||
}
|
||||
if strings.Join(gotSources, ",") != strings.Join(wantSources, ",") {
|
||||
t.Fatalf("source order = %v, want %v", gotSources, wantSources)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("FetchBundle() did not finish after all source responses were released")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchBundleReportsConcurrentFailuresInSourceOrder(t *testing.T) {
|
||||
var requested []string
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
"/forecast/hourly": {status: http.StatusBadRequest, body: `invalid hourly request`},
|
||||
"/forecast/narrative": {status: http.StatusBadRequest, body: `invalid narrative request`},
|
||||
}, &requested)
|
||||
client := newTestClient(t, server.URL+"/", nil)
|
||||
|
||||
_, err := client.FetchBundle(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("FetchBundle() error = nil, want source error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "/forecast/hourly") {
|
||||
t.Fatalf("error = %q, want the earlier hourly source failure", err.Error())
|
||||
}
|
||||
if !containsPath(requested, "/forecast/narrative") {
|
||||
t.Fatalf("requested paths = %v, want independent narrative request", requested)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchBundleCancelsConcurrentSourceRequests(t *testing.T) {
|
||||
paths := []string{
|
||||
"/observations",
|
||||
"/forecast/hourly",
|
||||
"/forecast/narrative",
|
||||
"/alerts/active",
|
||||
"/discussion",
|
||||
"/weatherstories/latest",
|
||||
convectiveOutlooksEndpoint,
|
||||
}
|
||||
started := make(chan string, len(paths))
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == currentConditionsEndpoint {
|
||||
if !serveWeatherFixture(w, r) {
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, path := range paths {
|
||||
if r.URL.Path == path {
|
||||
started <- path
|
||||
<-r.Context().Done()
|
||||
return
|
||||
}
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
client := newTestClient(t, server.URL+"/", nil)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := client.FetchBundle(ctx)
|
||||
result <- err
|
||||
}()
|
||||
for range paths {
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
cancel()
|
||||
t.Fatal("not all independent requests started before cancellation")
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case err := <-result:
|
||||
if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) {
|
||||
t.Fatalf("FetchBundle() error = %v, want context cancellation", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("FetchBundle() did not return after cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchBundleRejectsInvalidHourlyPrecipitationProbability(t *testing.T) {
|
||||
for _, probability := range []string{"-1", "101"} {
|
||||
t.Run(probability, func(t *testing.T) {
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
"/forecast/hourly": {status: http.StatusOK, body: `{"data":{"periods":[{"startTime":"2026-05-29T13:00:00Z","endTime":"2026-05-29T14:00:00Z","probabilityOfPrecipitationPercent":` + probability + `}]}}`},
|
||||
}, nil)
|
||||
client := newTestClient(t, server.URL+"/", nil)
|
||||
|
||||
_, err := client.FetchBundle(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid precipitation probability") {
|
||||
t.Fatalf("FetchBundle() error = %v, want invalid precipitation probability", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
||||
@@ -92,13 +321,25 @@ func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
||||
}
|
||||
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=America%2FChicago") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,11 +368,31 @@ func TestFetchBundleRecordsSourceHash(t *testing.T) {
|
||||
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) {
|
||||
const marker = "upstream-secret-marker"
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
"/conditions/current": {status: http.StatusBadGateway, body: `upstream failed`},
|
||||
"/forecast/hourly": {status: http.StatusBadGateway, body: marker + strings.Repeat("x", 4096)},
|
||||
}, nil)
|
||||
client := newTestClient(t, server.URL+"/", nil)
|
||||
|
||||
@@ -139,15 +400,282 @@ 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())
|
||||
}
|
||||
if strings.Contains(err.Error(), marker) {
|
||||
t.Fatalf("error = %q, must not contain upstream response text", 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 != 2 {
|
||||
t.Fatalf("conditions/current calls = %d, want failed and successful warmup attempts", 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 TestWarmupDoesNotRetryPermanentStatus(t *testing.T) {
|
||||
var requested []string
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
defaultWarmupEndpoint: {status: http.StatusNotFound, body: `not found`},
|
||||
}, &requested)
|
||||
client := newTestClient(t, server.URL+"/", nil)
|
||||
|
||||
_, err := client.FetchBundle(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "404") {
|
||||
t.Fatalf("FetchBundle() error = %v, want non-retryable warmup status", err)
|
||||
}
|
||||
if got := countPath(requested, defaultWarmupEndpoint); got != 1 {
|
||||
t.Fatalf("warmup requests = %d, want 1; all requests = %v", got, requested)
|
||||
}
|
||||
if containsPath(requested, "/observations") {
|
||||
t.Fatalf("requested paths = %v, want warmup failure before source fetches", requested)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarmupErrorDiagnosticsRedactResponseBody(t *testing.T) {
|
||||
const marker = "upstream-secret-marker"
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
defaultWarmupEndpoint: {status: http.StatusNotFound, body: marker + strings.Repeat("x", 4096)},
|
||||
}, nil)
|
||||
client := newTestClient(t, server.URL+"/", nil)
|
||||
|
||||
_, err := client.FetchBundle(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("FetchBundle() error = nil, want warmup error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), defaultWarmupEndpoint) || !strings.Contains(err.Error(), "404") {
|
||||
t.Fatalf("error = %q, want warmup endpoint and status", err.Error())
|
||||
}
|
||||
if strings.Contains(err.Error(), marker) {
|
||||
t.Fatalf("error = %q, must not contain upstream response text", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAcceptsResponseAtBodyLimit(t *testing.T) {
|
||||
body := paddedJSON(t, `{"data":null}`, int(maxResponseBodyBytes))
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
"/forecast/narrative": {handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(body))
|
||||
}},
|
||||
}, nil)
|
||||
client := newTestClient(t, server.URL+"/", nil)
|
||||
|
||||
if _, err := client.FetchBundle(context.Background()); err != nil {
|
||||
t.Fatalf("FetchBundle() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRejectsOversizedResponseWithoutRetry(t *testing.T) {
|
||||
var requested []string
|
||||
var narrativeCalls int
|
||||
oversizedBody := paddedJSON(t, `{"data":null}`, int(maxResponseBodyBytes)) + "x"
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
"/forecast/narrative": {handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
narrativeCalls++
|
||||
_, _ = w.Write([]byte(oversizedBody))
|
||||
}},
|
||||
}, &requested)
|
||||
client := newTestClient(t, server.URL+"/", nil)
|
||||
|
||||
_, err := client.FetchBundle(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("FetchBundle() error = nil, want oversized response error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "/forecast/narrative") || !strings.Contains(err.Error(), errResponseBodyTooLarge.Error()) {
|
||||
t.Fatalf("error = %q, want endpoint and response limit", err.Error())
|
||||
}
|
||||
if narrativeCalls != 1 {
|
||||
t.Fatalf("narrative calls = %d, want no retry", narrativeCalls)
|
||||
}
|
||||
if !containsPath(requested, "/alerts/active") {
|
||||
t.Fatalf("requested paths = %v, want independent source requests despite narrative failure", requested)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarmupRejectsOversizedResponseWithoutRetry(t *testing.T) {
|
||||
var requested []string
|
||||
oversizedBody := paddedJSON(t, `{"data":{}}`, int(maxResponseBodyBytes)) + "x"
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
defaultWarmupEndpoint: {handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(oversizedBody))
|
||||
}},
|
||||
}, &requested)
|
||||
client := newTestClient(t, server.URL+"/", nil)
|
||||
client.warmupAttempts = 2
|
||||
|
||||
_, err := client.FetchBundle(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("FetchBundle() error = nil, want oversized warmup response error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), defaultWarmupEndpoint) || !strings.Contains(err.Error(), errResponseBodyTooLarge.Error()) {
|
||||
t.Fatalf("error = %q, want warmup endpoint and response limit", err.Error())
|
||||
}
|
||||
if got := countPath(requested, defaultWarmupEndpoint); got != 1 {
|
||||
t.Fatalf("warmup requests = %d, want no retry; 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 TestNewValidatesWeatherAPIBaseURLSchemeWithoutRequests(t *testing.T) {
|
||||
requests := 0
|
||||
httpClient := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
|
||||
requests++
|
||||
return nil, errors.New("unexpected request")
|
||||
})}
|
||||
tests := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "local HTTP", baseURL: "http://127.0.0.1:8080/weather/"},
|
||||
{name: "local HTTPS", baseURL: "https://127.0.0.1:8443/weather/"},
|
||||
{name: "unsupported scheme", baseURL: "ftp://weather.example.test/", wantErr: "weather_api.base_url must use http or https"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := testConfig(tt.baseURL)
|
||||
_, err := New(cfg, WithHTTPClient(httpClient))
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
} else if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("New() error = %v, want %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
if requests != 0 {
|
||||
t.Fatalf("HTTP requests = %d, want none", requests)
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
@@ -157,6 +685,69 @@ 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 TestRequiredHourlyForecastValidatesPeriodBounds(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid period",
|
||||
body: `{"data":{"periods":[{"startTime":"2026-05-29T13:00:00Z","endTime":"2026-05-29T14:00:00Z"}]}}`,
|
||||
},
|
||||
{
|
||||
name: "missing start",
|
||||
body: `{"data":{"periods":[{"endTime":"2026-05-29T14:00:00Z"}]}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing end",
|
||||
body: `{"data":{"periods":[{"startTime":"2026-05-29T13:00:00Z"}]}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty range",
|
||||
body: `{"data":{"periods":[{"startTime":"2026-05-29T13:00:00Z","endTime":"2026-05-29T13:00:00Z"}]}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "reversed range",
|
||||
body: `{"data":{"periods":[{"startTime":"2026-05-29T14:00:00Z","endTime":"2026-05-29T13:00:00Z"}]}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var requested []string
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
"/forecast/hourly": {status: http.StatusOK, body: tt.body},
|
||||
}, &requested)
|
||||
client := newTestClient(t, server.URL+"/", nil)
|
||||
|
||||
bundle, err := client.FetchBundle(context.Background())
|
||||
if tt.wantErr {
|
||||
if err == nil || !strings.Contains(err.Error(), "hourly forecast") || !strings.Contains(err.Error(), "time bounds") {
|
||||
t.Fatalf("FetchBundle() error = %v, want hourly time-bounds failure", err)
|
||||
}
|
||||
if got := countPath(requested, "/forecast/hourly"); got != 1 {
|
||||
t.Fatalf("hourly requests = %d, want no retry; all requests = %v", got, requested)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("FetchBundle() error = %v", err)
|
||||
}
|
||||
if bundle.Hourly == nil || len(bundle.Hourly.Periods) != 1 {
|
||||
t.Fatalf("Hourly = %#v, want accepted hourly period", bundle.Hourly)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNullAlertsMeansNoActiveAlerts(t *testing.T) {
|
||||
@@ -189,6 +780,59 @@ func TestNullAlertsMeansNoActiveAlerts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -297,6 +941,44 @@ func TestMalformedWeatherStoryUsesPolicy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyWeatherStoryUsesPolicy(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
policy config.MissingSourcePolicy
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "warn", policy: config.MissingSourceWarn},
|
||||
{name: "error", policy: config.MissingSourceError, wantErr: true},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
"/weatherstories/latest": {status: http.StatusOK, body: `{"data": {}}`},
|
||||
}, nil)
|
||||
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
|
||||
"weather_story": tt.policy,
|
||||
})
|
||||
|
||||
bundle, err := client.FetchBundle(context.Background())
|
||||
if tt.wantErr {
|
||||
if err == nil || !strings.Contains(err.Error(), "weather story has no usable content") {
|
||||
t.Fatalf("FetchBundle() error = %v, want unusable weather story error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("FetchBundle() error = %v", err)
|
||||
}
|
||||
if bundle.WeatherStory != nil {
|
||||
t.Fatalf("WeatherStory = %#v, want nil for empty source", bundle.WeatherStory)
|
||||
}
|
||||
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()
|
||||
@@ -312,6 +994,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)
|
||||
@@ -324,68 +1043,65 @@ 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())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveBundle(t *testing.T) {
|
||||
server := fixtureServer(t, nil, nil)
|
||||
client := newTestClient(t, server.URL+"/", nil)
|
||||
bundle, err := client.FetchBundle(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("FetchBundle() error = %v", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(t.TempDir(), "nested", "bundle.json")
|
||||
if err := SaveBundle(path, bundle); err != nil {
|
||||
t.Fatalf("SaveBundle() error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read saved bundle: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"hourly"`) {
|
||||
t.Fatalf("saved bundle missing hourly source:\n%s", string(data))
|
||||
}
|
||||
type handlerOverride struct {
|
||||
status int
|
||||
body string
|
||||
handler http.HandlerFunc
|
||||
}
|
||||
|
||||
type handlerOverride struct {
|
||||
status int
|
||||
body string
|
||||
var weatherFixtureFiles = 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",
|
||||
"/weatherstories/latest": "weather_story.json",
|
||||
convectiveOutlooksEndpoint: "convective_outlooks.json",
|
||||
}
|
||||
|
||||
func serveWeatherFixture(w http.ResponseWriter, r *http.Request) bool {
|
||||
name, ok := weatherFixtureFiles[r.URL.Path]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
http.ServeFile(w, r, filepath.Join("testdata", name))
|
||||
return true
|
||||
}
|
||||
|
||||
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",
|
||||
"/weatherstories/latest": "weather_story.json",
|
||||
}
|
||||
var requestedMu sync.Mutex
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if requested != nil {
|
||||
requestedMu.Lock()
|
||||
*requested = append(*requested, r.URL.String())
|
||||
requestedMu.Unlock()
|
||||
}
|
||||
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
|
||||
}
|
||||
name, ok := fixtures[r.URL.Path]
|
||||
if !ok {
|
||||
if !serveWeatherFixture(w, r) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, filepath.Join("testdata", name))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
return server
|
||||
@@ -401,6 +1117,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
|
||||
}
|
||||
|
||||
@@ -414,6 +1132,14 @@ func fixedNow() time.Time {
|
||||
return time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
func paddedJSON(t *testing.T, value string, size int) string {
|
||||
t.Helper()
|
||||
if len(value) > size {
|
||||
t.Fatalf("JSON value length = %d, exceeds requested size %d", len(value), size)
|
||||
}
|
||||
return value + strings.Repeat(" ", size-len(value))
|
||||
}
|
||||
|
||||
func containsPath(requested []string, path string) bool {
|
||||
for _, rawURL := range requested {
|
||||
if strings.HasPrefix(rawURL, path+"?") || rawURL == path {
|
||||
@@ -423,6 +1149,16 @@ func containsPath(requested []string, path string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
1004
internal/app/app.go
1004
internal/app/app.go
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
361
internal/app/batch_generation_test.go
Normal file
361
internal/app/batch_generation_test.go
Normal file
@@ -0,0 +1,361 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
)
|
||||
|
||||
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.Canceled != 0 || 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 TestRunBatchDetailedStopsAfterReportCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
bundle := generationBundle(t)
|
||||
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
||||
notifier := &generationNotifier{}
|
||||
executor := &generationExecutor{cancelBeforeReturn: cancel}
|
||||
|
||||
result, err := RunBatchDetailed(ctx, 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 !errors.Is(err, context.Canceled) || result == nil || result.Total != 2 || result.Succeeded != 0 || result.Failed != 0 || result.Canceled != 2 || executor.executeCalls != 1 || notifier.batchCalls != 0 || result.Notification == nil || result.Notification.Status != "skipped" || result.Notification.Reason != "batch canceled" {
|
||||
t.Fatalf("RunBatchDetailed() result/error/executor/notifier = %#v/%v/%#v/%#v", result, err, executor, notifier)
|
||||
}
|
||||
for _, item := range result.Reports {
|
||||
if item.Status != "canceled" || item.OutputPath != "" {
|
||||
t.Fatalf("canceled report = %#v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatchDetailedPreservesIndependentFailureDuringCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
bundle := generationBundle(t)
|
||||
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
||||
notifier := &generationNotifier{}
|
||||
executor := &generationExecutor{
|
||||
executeErr: errors.New("independent report failure"),
|
||||
beforeExecute: func(promptexec.ExecuteRequest) {
|
||||
cancel()
|
||||
},
|
||||
}
|
||||
|
||||
result, err := RunBatchDetailed(ctx, 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 !errors.Is(err, context.Canceled) || result == nil || result.Total != 2 || result.Succeeded != 0 || result.Failed != 1 || result.Canceled != 1 || notifier.batchCalls != 0 || result.Notification == nil || result.Notification.Status != "skipped" || result.Notification.Reason != "batch canceled" {
|
||||
t.Fatalf("RunBatchDetailed() result/error/notifier = %#v/%v/%#v", result, err, notifier)
|
||||
}
|
||||
if result.Reports[0].Status != "failed" || result.Reports[0].Error == "" || result.Reports[1].Status != "canceled" {
|
||||
t.Fatalf("report results = %#v", result.Reports)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyBatchSkipsCancellationObservedAfterReportsComplete(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
notifier := &generationNotifier{}
|
||||
|
||||
result := notifyBatch(batchNotificationInput{
|
||||
ctx: ctx, cfg: generationDistributorConfig(), batch: BatchMorning,
|
||||
runID: "run-id", startedAt: generationTime("2026-05-29T08:30:00-05:00"),
|
||||
result: &BatchResult{Total: 1, Succeeded: 1, Reports: []BatchReportResult{{Status: "succeeded"}}},
|
||||
notifier: notifier,
|
||||
})
|
||||
|
||||
if result == nil || result.Status != "skipped" || result.Reason != "batch canceled" || notifier.batchCalls != 0 {
|
||||
t.Fatalf("notifyBatch() result/notifier = %#v/%#v", result, notifier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatchDetailedRetainsPublishedReportBeforeCancellation(t *testing.T) {
|
||||
for _, cause := range []error{context.Canceled, context.DeadlineExceeded} {
|
||||
t.Run(cause.Error(), func(t *testing.T) {
|
||||
bundle := generationBundle(t)
|
||||
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
||||
notifier := &generationNotifier{}
|
||||
ctx := &publicationGateContext{Context: context.Background(), err: cause, afterChecks: 4}
|
||||
|
||||
result, err := RunBatchDetailed(ctx, 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: notifier,
|
||||
})
|
||||
if !errors.Is(err, cause) || result == nil || result.Total != 2 || result.Succeeded != 1 || result.Failed != 0 || result.Canceled != 1 || len(result.Reports) != 2 || result.Reports[0].Status != "succeeded" || result.Reports[0].OutputPath == "" || result.Reports[1].Status != "canceled" || result.Reports[1].OutputPath != "" || notifier.batchCalls != 0 || result.Notification == nil || result.Notification.Status != "skipped" || result.Notification.Reason != "batch canceled" {
|
||||
t.Fatalf("RunBatchDetailed() result/error/notifier = %#v/%v/%#v", result, err, notifier)
|
||||
}
|
||||
if _, statErr := os.Stat(result.Reports[0].OutputPath); statErr != nil {
|
||||
t.Fatalf("published report %q: %v", result.Reports[0].OutputPath, statErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatchPreservesCancellationCause(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
bundle := generationBundle(t)
|
||||
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
||||
err := RunBatch(ctx, 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{cancelBeforeReturn: cancel}, Notifier: &generationNotifier{},
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("RunBatch() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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 TestRunBatchDetailedRejectsUnsupportedDistributorEndpointBeforeWork(t *testing.T) {
|
||||
outputDir := t.TempDir()
|
||||
cfg := generationDistributorConfig()
|
||||
cfg.Notify.Distributor.Endpoint = "ftp://distributor.example.test"
|
||||
bundle := generationBundle(t)
|
||||
collector := &generationCollector{bundle: &bundle}
|
||||
executor := &generationExecutor{}
|
||||
notifier := &generationNotifier{}
|
||||
|
||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||
Config: cfg, Batch: BatchMorning,
|
||||
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: outputDir,
|
||||
Collector: collector, Executor: executor, Notifier: notifier,
|
||||
})
|
||||
if err == nil || result != nil || collector.called || executor.promptInspections != 0 || executor.called || notifier.calls != 0 || notifier.batchCalls != 0 {
|
||||
t.Fatalf("RunBatchDetailed() result/error/collector/executor/notifier = %#v/%v/%t/%#v/%#v", result, err, collector.called, executor, notifier)
|
||||
}
|
||||
entries, readErr := os.ReadDir(outputDir)
|
||||
if readErr != nil || len(entries) != 0 {
|
||||
t.Fatalf("output directory entries/error = %v/%v", entries, readErr)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
318
internal/app/batch_notification.go
Normal file
318
internal/app/batch_notification.go
Normal file
@@ -0,0 +1,318 @@
|
||||
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)
|
||||
}
|
||||
|
||||
type batchNotificationInput struct {
|
||||
ctx context.Context
|
||||
cancellation error
|
||||
cfg config.Config
|
||||
batch BatchKind
|
||||
runID string
|
||||
startedAt time.Time
|
||||
result *BatchResult
|
||||
planned []plannedBatchReport
|
||||
notifier Notifier
|
||||
}
|
||||
|
||||
func batchRunID(startedAt time.Time, batch BatchKind) string {
|
||||
return startedAt.UTC().Format(runIDTimestampLayout) + "_" + string(batch)
|
||||
}
|
||||
|
||||
func notifyBatch(input batchNotificationInput) *BatchNotificationResult {
|
||||
if !input.cfg.Notify.Distributor.Enabled {
|
||||
return nil
|
||||
}
|
||||
if !input.cfg.Notify.Distributor.Batch.Enabled {
|
||||
return nil
|
||||
}
|
||||
if input.result == nil {
|
||||
return failedBatchNotificationResult(batchNotificationRequest{}, fmt.Errorf("batch result is required"))
|
||||
}
|
||||
if input.cancellation != nil || batchContextCancellationCause(input.ctx) != nil || input.result.Canceled > 0 {
|
||||
return &BatchNotificationResult{
|
||||
Status: "skipped",
|
||||
Reason: "batch canceled",
|
||||
}
|
||||
}
|
||||
if input.result.Failed > 0 {
|
||||
return &BatchNotificationResult{
|
||||
Status: "skipped",
|
||||
Reason: "one or more reports failed",
|
||||
}
|
||||
}
|
||||
|
||||
req, err := buildBatchNotificationRequest(input.cfg, input.batch, input.runID, input.startedAt, input.result.Reports, input.planned)
|
||||
if err != nil {
|
||||
return failedBatchNotificationResult(batchNotificationRequest{}, err)
|
||||
}
|
||||
|
||||
batchNotifier, err := resolveBatchNotifier(input.cfg, input.notifier)
|
||||
if err != nil {
|
||||
return failedBatchNotificationResult(req, err)
|
||||
}
|
||||
|
||||
notification, notifyErr := batchNotifier.NotifyBatch(input.ctx, req)
|
||||
wrappedErr := notifyErr
|
||||
if notifyErr != nil {
|
||||
wrappedErr = fmt.Errorf("notify batch %q run %q bundle %q: %w", input.batch, input.runID, req.BundleID, notifyErr)
|
||||
}
|
||||
batchResult := batchNotificationResult(req, notification)
|
||||
if wrappedErr != nil {
|
||||
batchResult.Status = "failed"
|
||||
batchResult.Error = safeDistributorNotificationFailure(wrappedErr)
|
||||
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 = safeDistributorRunError(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 = safeDistributorNotificationFailure(err)
|
||||
}
|
||||
return notification
|
||||
}
|
||||
|
||||
func safeDistributorNotificationFailure(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return "distributor notification failed"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
237
internal/app/comparison.go
Normal file
237
internal/app/comparison.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
// ComparisonRequest describes one explicit, multi-profile report comparison.
|
||||
// It deliberately does not accept a notifier: comparison publication is local.
|
||||
type ComparisonRequest struct {
|
||||
Config config.Config
|
||||
Report ReportKind
|
||||
ProfileIDs []string
|
||||
WorkingDir string
|
||||
OutputDir string
|
||||
Replace bool
|
||||
LLMDebugDir string
|
||||
Date time.Time
|
||||
Clock timeutil.Clock
|
||||
Collector Collector
|
||||
Executor promptexec.Executor
|
||||
}
|
||||
|
||||
// ComparisonResult records the resolved comparison and profile outcomes.
|
||||
type ComparisonResult struct {
|
||||
ComparisonID string
|
||||
ReportID report.ID
|
||||
ReportName string
|
||||
PromptID string
|
||||
PromptVersion string
|
||||
PromptHash string
|
||||
StartedAt time.Time
|
||||
FinishedAt time.Time
|
||||
Timezone string
|
||||
ValidPeriod timeutil.Period
|
||||
OutputDirectory string
|
||||
ManifestPath string
|
||||
DataPackagePath string
|
||||
Total int
|
||||
Succeeded int
|
||||
Failed int
|
||||
Results []ComparisonProfileResult
|
||||
}
|
||||
|
||||
// ComparisonProfileResult records one explicitly selected profile.
|
||||
type ComparisonProfileResult struct {
|
||||
Position int
|
||||
ProfileID string
|
||||
BackendID string
|
||||
ModelName string
|
||||
Status string
|
||||
ValidationStatus promptexec.ValidationStatus
|
||||
ReportPath string
|
||||
LLMDebugPath string
|
||||
Error *comparison.SafeError
|
||||
}
|
||||
|
||||
type comparisonPublisher func(context.Context, comparison.DestinationPlan, comparison.LogicalBundle) (comparison.PublicationResult, error)
|
||||
|
||||
// CompareDetailed assembles, executes, and atomically publishes a comparison
|
||||
// bundle. Profile failures publish a complete partial bundle. Failures before
|
||||
// commit leave the destination untouched; a post-commit cleanup failure leaves
|
||||
// the new bundle installed and returns its artifact paths with an error.
|
||||
func CompareDetailed(ctx context.Context, req ComparisonRequest) (*ComparisonResult, error) {
|
||||
return compareDetailed(ctx, req, comparison.Publish)
|
||||
}
|
||||
|
||||
func compareDetailed(ctx context.Context, req ComparisonRequest, publish comparisonPublisher) (*ComparisonResult, error) {
|
||||
if err := comparison.ValidateProfileIDs(req.ProfileIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
clock := req.Clock
|
||||
if clock == nil {
|
||||
clock = timeutil.SystemClock{}
|
||||
}
|
||||
now := clock.Now()
|
||||
resolved, err := ResolveGenerate(GenerateRequest{Config: req.Config, Report: req.Report, Date: req.Date}, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metadata := resolved.Metadata()
|
||||
comparisonID, err := comparison.BuildComparisonID(metadata.RunID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build comparison identity: %w", err)
|
||||
}
|
||||
result := initialComparisonResult(req, resolved, comparisonID, now.UTC())
|
||||
defer func() {
|
||||
if result.FinishedAt.IsZero() {
|
||||
finalizeComparisonResult(result, clock)
|
||||
}
|
||||
}()
|
||||
|
||||
outputName, err := resolved.OutputName()
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("resolve comparison output name: %w", err)
|
||||
}
|
||||
outputDirectory, err := resolveComparisonOutputDirectory(req.WorkingDir, req.OutputDir, req.Config.Output.Directory, outputName)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.OutputDirectory = outputDirectory
|
||||
publicationPlan, err := comparison.PlanDestination(req.WorkingDir, outputDirectory, req.Replace)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("preflight comparison destination: %w", err)
|
||||
}
|
||||
|
||||
debugWriter, err := promptdebug.NewPromptDebugWriter(req.LLMDebugDir)
|
||||
if err != nil {
|
||||
return result, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
|
||||
}
|
||||
defer func() { _ = debugWriter.Close() }()
|
||||
inspection, err := InspectComparisonExecution(ctx, ComparisonInspectionRequest{
|
||||
Resolved: resolved, ProfileIDs: req.ProfileIDs, Executor: req.Executor, LookupEnv: os.LookupEnv,
|
||||
})
|
||||
result.PromptID, result.PromptVersion, result.PromptHash = inspection.PromptID, inspection.PromptVersion, inspection.PromptHash
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
collection, err := collectWeather(ctx, req.Config, req.Collector)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
prepared, err := prepareReport(prepareReportRequest{Config: req.Config, Resolved: resolved, Collection: *collection, handler: inspection.handler})
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("prepare comparison report: %w", err)
|
||||
}
|
||||
|
||||
executed := executeComparisonProfiles(ctx, comparisonExecutionRequest{
|
||||
Prepared: prepared, Inspection: inspection, ComparisonID: comparisonID, DebugWriter: debugWriter, Executor: req.Executor,
|
||||
})
|
||||
finalizeComparisonResult(result, clock)
|
||||
copyComparisonOutcomes(result, executed.Outcomes, false)
|
||||
if executed.Canceled {
|
||||
return result, fmt.Errorf("comparison execution: %w", ctx.Err())
|
||||
}
|
||||
|
||||
bundle := comparisonBundle(result, prepared.dataPackageCopy(), executed.Outcomes)
|
||||
if err := bundle.Validate(); err != nil {
|
||||
return result, fmt.Errorf("build comparison bundle: %w", err)
|
||||
}
|
||||
publication, err := publish(ctx, publicationPlan, bundle)
|
||||
if publication.Committed {
|
||||
result.OutputDirectory = publicationPlan.Target
|
||||
result.ManifestPath = filepath.Join(publicationPlan.Target, comparison.ManifestFilename)
|
||||
result.DataPackagePath = filepath.Join(publicationPlan.Target, comparison.DataPackageFilename)
|
||||
copyComparisonOutcomes(result, executed.Outcomes, true)
|
||||
}
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("publish comparison bundle: %w", err)
|
||||
}
|
||||
|
||||
if result.Failed > 0 {
|
||||
return result, fmt.Errorf("comparison completed with %d failed profiles", result.Failed)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func finalizeComparisonResult(result *ComparisonResult, clock timeutil.Clock) {
|
||||
finishedAt := clock.Now().UTC()
|
||||
if finishedAt.IsZero() {
|
||||
finishedAt = time.Unix(0, 1).UTC()
|
||||
}
|
||||
if finishedAt.Before(result.StartedAt) {
|
||||
finishedAt = result.StartedAt
|
||||
}
|
||||
result.FinishedAt = finishedAt
|
||||
}
|
||||
|
||||
func initialComparisonResult(req ComparisonRequest, resolved report.Resolved, comparisonID string, startedAt time.Time) *ComparisonResult {
|
||||
metadata := resolved.Metadata()
|
||||
return &ComparisonResult{
|
||||
ComparisonID: comparisonID,
|
||||
ReportID: resolved.Definition.ID,
|
||||
ReportName: resolved.Definition.Name,
|
||||
StartedAt: startedAt,
|
||||
Timezone: req.Config.WeatherAPI.Timezone,
|
||||
ValidPeriod: metadata.ValidPeriod,
|
||||
}
|
||||
}
|
||||
|
||||
func copyComparisonOutcomes(result *ComparisonResult, outcomes []comparisonProfileOutcome, published bool) {
|
||||
result.Results = make([]ComparisonProfileResult, len(outcomes))
|
||||
result.Total, result.Succeeded, result.Failed = len(outcomes), 0, 0
|
||||
for index, outcome := range outcomes {
|
||||
profile := ComparisonProfileResult{
|
||||
Position: outcome.Position, ProfileID: outcome.ProfileID, BackendID: outcome.BackendID, ModelName: outcome.ModelName,
|
||||
Status: outcome.Status, ValidationStatus: outcome.ValidationStatus, LLMDebugPath: outcome.LLMDebugPath, Error: outcome.Error,
|
||||
}
|
||||
if published && outcome.Status == comparison.StatusSucceeded {
|
||||
profile.ReportPath = filepath.Join(result.OutputDirectory, outcome.ReportPath)
|
||||
}
|
||||
result.Results[index] = profile
|
||||
if outcome.Status == comparison.StatusSucceeded {
|
||||
result.Succeeded++
|
||||
} else {
|
||||
result.Failed++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func comparisonBundle(result *ComparisonResult, dataPackage []byte, outcomes []comparisonProfileOutcome) comparison.LogicalBundle {
|
||||
manifest := comparison.Manifest{
|
||||
SchemaVersion: comparison.SchemaVersion, ComparisonID: result.ComparisonID,
|
||||
StartedAt: result.StartedAt.UTC(), FinishedAt: result.FinishedAt.UTC(),
|
||||
ReportID: string(result.ReportID), Timezone: result.Timezone,
|
||||
ValidPeriod: comparison.ValidPeriod{Start: result.ValidPeriod.Start, End: result.ValidPeriod.End},
|
||||
PromptID: result.PromptID, PromptVersion: result.PromptVersion, PromptHash: result.PromptHash,
|
||||
DataPackage: comparison.DataPackageReference{Path: comparison.DataPackageFilename, SHA256: comparison.SHA256(dataPackage)},
|
||||
Total: result.Total, Succeeded: result.Succeeded, Failed: result.Failed,
|
||||
Results: make([]comparison.Result, len(outcomes)),
|
||||
}
|
||||
bundle := comparison.LogicalBundle{Manifest: manifest, DataPackage: dataPackage}
|
||||
for index, outcome := range outcomes {
|
||||
manifestResult := comparison.Result{
|
||||
Position: outcome.Position, ProfileID: outcome.ProfileID, BackendID: outcome.BackendID, ModelName: outcome.ModelName,
|
||||
Status: outcome.Status, ValidationStatus: string(outcome.ValidationStatus), Error: outcome.Error,
|
||||
}
|
||||
if outcome.Status == comparison.StatusSucceeded {
|
||||
manifestResult.ReportPath = outcome.ReportPath
|
||||
bundle.Reports = append(bundle.Reports, comparison.BundleReport{Position: outcome.Position, Path: outcome.ReportPath, Markdown: outcome.Markdown})
|
||||
}
|
||||
bundle.Manifest.Results[index] = manifestResult
|
||||
}
|
||||
return bundle
|
||||
}
|
||||
177
internal/app/comparison_execution.go
Normal file
177
internal/app/comparison_execution.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
)
|
||||
|
||||
type comparisonExecutionRequest struct {
|
||||
Prepared preparedReport
|
||||
Inspection ComparisonInspectionResult
|
||||
ComparisonID string
|
||||
DebugWriter *promptdebug.PromptDebugWriter
|
||||
Executor promptexec.Executor
|
||||
}
|
||||
|
||||
type comparisonExecutionResult struct {
|
||||
Outcomes []comparisonProfileOutcome
|
||||
Canceled bool
|
||||
}
|
||||
|
||||
type comparisonProfileOutcome struct {
|
||||
Position int
|
||||
ProfileID string
|
||||
BackendID string
|
||||
ModelName string
|
||||
Status string
|
||||
ValidationStatus promptexec.ValidationStatus
|
||||
ReportPath string
|
||||
Markdown []byte
|
||||
LLMDebugPath string
|
||||
Error *comparison.SafeError
|
||||
canceled bool
|
||||
}
|
||||
|
||||
type comparisonProfileExecutionState uint8
|
||||
|
||||
const (
|
||||
comparisonProfilePending comparisonProfileExecutionState = iota
|
||||
comparisonProfileRunning
|
||||
comparisonProfileComplete
|
||||
)
|
||||
|
||||
func executeComparisonProfiles(ctx context.Context, req comparisonExecutionRequest) comparisonExecutionResult {
|
||||
profiles := req.Inspection.Profiles
|
||||
result := comparisonExecutionResult{Outcomes: make([]comparisonProfileOutcome, len(profiles))}
|
||||
states := make([]comparisonProfileExecutionState, len(profiles))
|
||||
for index, profile := range profiles {
|
||||
result.Outcomes[index] = comparisonProfileOutcome{
|
||||
Position: index + 1,
|
||||
ProfileID: profile.ProfileID,
|
||||
BackendID: profile.BackendID,
|
||||
ModelName: profile.ModelName,
|
||||
Status: comparison.StatusFailed,
|
||||
}
|
||||
}
|
||||
|
||||
var waitGroup sync.WaitGroup
|
||||
for index, profile := range profiles {
|
||||
if err := ctx.Err(); err != nil {
|
||||
result.Canceled = true
|
||||
break
|
||||
}
|
||||
index, profile := index, profile
|
||||
states[index] = comparisonProfileRunning
|
||||
waitGroup.Add(1)
|
||||
go func() {
|
||||
defer waitGroup.Done()
|
||||
result.Outcomes[index] = executeComparisonProfile(ctx, req, index, profile)
|
||||
states[index] = comparisonProfileComplete
|
||||
}()
|
||||
}
|
||||
waitGroup.Wait()
|
||||
if err := ctx.Err(); err != nil {
|
||||
result.Canceled = true
|
||||
for index := range result.Outcomes {
|
||||
if states[index] != comparisonProfileComplete || result.Outcomes[index].canceled {
|
||||
markCanceledComparisonOutcome(&result.Outcomes[index], err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func executeComparisonProfile(ctx context.Context, req comparisonExecutionRequest, index int, profile ComparisonProfileInspection) comparisonProfileOutcome {
|
||||
position := index + 1
|
||||
outcome := comparisonProfileOutcome{
|
||||
Position: position, ProfileID: profile.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName,
|
||||
Status: comparison.StatusFailed,
|
||||
}
|
||||
debugRef := promptdebug.PromptDebugRef{
|
||||
ReportID: req.Prepared.resolved.Definition.ID,
|
||||
ValidDate: req.Prepared.resolved.ValidPeriod.Start.Format("2006-01-02"),
|
||||
RunID: comparisonDebugRunID(req.ComparisonID, position, len(req.Inspection.Profiles), profile.ProfileID),
|
||||
}
|
||||
execution, markdown, err := executePreparedProfile(ctx, profileExecutionRequest{
|
||||
Prepared: req.Prepared,
|
||||
Prompt: PromptInspectionResult{
|
||||
PromptID: req.Inspection.PromptID, PromptVersion: req.Inspection.PromptVersion, PromptHash: req.Inspection.PromptHash,
|
||||
},
|
||||
Profile: promptexec.ProfileInspection{ProfileID: profile.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName},
|
||||
Executor: req.Executor, DebugWriter: req.DebugWriter, DebugRef: &debugRef,
|
||||
})
|
||||
outcome.ProfileID, outcome.BackendID, outcome.ModelName = execution.ProfileID, execution.BackendID, execution.ModelName
|
||||
outcome.ValidationStatus = execution.ValidationStatus
|
||||
outcome.LLMDebugPath = execution.LLMDebugPath
|
||||
if err != nil {
|
||||
outcome.canceled = cancellationError(err)
|
||||
safe := comparisonSafeExecutionError(err)
|
||||
outcome.Error = &safe
|
||||
return outcome
|
||||
}
|
||||
reportPath, err := comparison.ReportFilename(position, len(req.Inspection.Profiles), profile.ProfileID)
|
||||
if err != nil {
|
||||
safe := comparison.NewSafeError("application", "derive comparison report filename failed")
|
||||
outcome.Error = &safe
|
||||
return outcome
|
||||
}
|
||||
outcome.Status = comparison.StatusSucceeded
|
||||
outcome.ReportPath = reportPath
|
||||
outcome.Markdown = append([]byte(nil), markdown...)
|
||||
return outcome
|
||||
}
|
||||
|
||||
func comparisonDebugRunID(comparisonID string, position, profileCount int, profileID string) string {
|
||||
return fmt.Sprintf("%s_%0*d-%s", comparisonID, comparison.OrdinalWidth(profileCount), position, comparison.ProfileSlug(profileID))
|
||||
}
|
||||
|
||||
func markCanceledComparisonOutcome(outcome *comparisonProfileOutcome, err error) {
|
||||
outcome.Status = comparison.StatusFailed
|
||||
outcome.ValidationStatus = promptexec.ValidationSkipped
|
||||
outcome.ReportPath = ""
|
||||
outcome.Markdown = nil
|
||||
safe := comparisonSafeExecutionError(err)
|
||||
outcome.Error = &safe
|
||||
}
|
||||
|
||||
func cancellationError(err error) bool {
|
||||
category := promptexec.CategoryOf(err)
|
||||
return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) ||
|
||||
category == promptexec.Canceled || category == promptexec.DeadlineExceeded
|
||||
}
|
||||
|
||||
func comparisonSafeExecutionError(err error) comparison.SafeError {
|
||||
category := promptexec.CategoryOf(err)
|
||||
if category == "" {
|
||||
switch {
|
||||
case errors.Is(err, context.Canceled):
|
||||
category = promptexec.Canceled
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
category = promptexec.DeadlineExceeded
|
||||
}
|
||||
}
|
||||
if category == "" {
|
||||
return comparison.NewSafeError("application", comparisonExecutionMessage(err))
|
||||
}
|
||||
return comparison.NewSafeError(string(category), comparisonExecutionMessage(err))
|
||||
}
|
||||
|
||||
func comparisonExecutionMessage(err error) string {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return "profile execution canceled"
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return "profile execution deadline exceeded"
|
||||
}
|
||||
var execution *profileExecutionError
|
||||
if errors.As(err, &execution) {
|
||||
return comparison.TruncateErrorMessage(execution.operation + " failed")
|
||||
}
|
||||
return "profile execution failed"
|
||||
}
|
||||
320
internal/app/comparison_execution_test.go
Normal file
320
internal/app/comparison_execution_test.go
Normal file
@@ -0,0 +1,320 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
)
|
||||
|
||||
func TestExecuteComparisonProfilesRunsOrderedProfilesConcurrently(t *testing.T) {
|
||||
prepared, prompt := preparedDailyProfile(t)
|
||||
profiles := comparisonProfiles(10)
|
||||
executor := newBarrierExecutor(profiles)
|
||||
results := startComparisonExecution(t, context.Background(), comparisonExecutionRequest{
|
||||
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", Executor: executor,
|
||||
}, executor)
|
||||
waitForProfileStarts(t, executor, profiles, results)
|
||||
if executor.maximumInFlight() < 2 {
|
||||
t.Fatalf("maximum in-flight executions = %d, want overlap", executor.maximumInFlight())
|
||||
}
|
||||
for index := len(profiles) - 1; index >= 0; index-- {
|
||||
executor.release(profiles[index].ProfileID)
|
||||
}
|
||||
result := <-results
|
||||
if result.Canceled || len(result.Outcomes) != len(profiles) {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
for index, profile := range profiles {
|
||||
outcome := result.Outcomes[index]
|
||||
wantPath, err := comparison.ReportFilename(index+1, len(profiles), profile.ProfileID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if outcome.Position != index+1 || outcome.ProfileID != profile.ProfileID || outcome.Status != comparison.StatusSucceeded || outcome.ValidationStatus != promptexec.ValidationPassed || outcome.ReportPath != wantPath || len(outcome.Markdown) == 0 || outcome.Error != nil {
|
||||
t.Fatalf("outcome[%d] = %#v", index, outcome)
|
||||
}
|
||||
request, ok := executor.request(profile.ProfileID)
|
||||
if !ok || request.PromptVersion != prompt.PromptVersion || !bytesEqual(request.DataPackage, prepared.dataPackage) {
|
||||
t.Fatalf("request for %q = %#v, want prompt version %q and shared data package", profile.ProfileID, request, prompt.PromptVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteComparisonProfilesContinuesAfterProfileFailure(t *testing.T) {
|
||||
prepared, prompt := preparedDailyProfile(t)
|
||||
profiles := comparisonProfiles(3)
|
||||
executor := newBarrierExecutor(profiles)
|
||||
executor.setError(profiles[1].ProfileID, errors.New("provider response body must not escape"))
|
||||
results := startComparisonExecution(t, context.Background(), comparisonExecutionRequest{
|
||||
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", Executor: executor,
|
||||
}, executor)
|
||||
waitForProfileStarts(t, executor, profiles, results)
|
||||
for _, profile := range profiles {
|
||||
executor.release(profile.ProfileID)
|
||||
}
|
||||
result := <-results
|
||||
if result.Canceled || result.Outcomes[0].Status != comparison.StatusSucceeded || result.Outcomes[1].Status != comparison.StatusFailed || result.Outcomes[2].Status != comparison.StatusSucceeded {
|
||||
t.Fatalf("outcomes = %#v", result.Outcomes)
|
||||
}
|
||||
failure := result.Outcomes[1]
|
||||
if failure.Error == nil || failure.Error.Category != string(promptexec.Generation) || failure.Error.Message != "execute prompt failed" || failure.ReportPath != "" || len(failure.Markdown) != 0 {
|
||||
t.Fatalf("failure outcome = %#v", failure)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteComparisonProfilesPropagatesCancellationAndJoins(t *testing.T) {
|
||||
prepared, prompt := preparedDailyProfile(t)
|
||||
profiles := comparisonProfiles(4)
|
||||
executor := newBarrierExecutor(profiles)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
results := startComparisonExecution(t, ctx, comparisonExecutionRequest{
|
||||
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", Executor: executor,
|
||||
}, executor)
|
||||
waitForProfileStarts(t, executor, profiles, results)
|
||||
cancel()
|
||||
result := <-results
|
||||
if !result.Canceled || executor.inFlightCount() != 0 {
|
||||
t.Fatalf("result/in-flight = %#v/%d", result, executor.inFlightCount())
|
||||
}
|
||||
for _, outcome := range result.Outcomes {
|
||||
if outcome.Status != comparison.StatusFailed || outcome.Error == nil || outcome.Error.Category != string(promptexec.Canceled) || outcome.ValidationStatus != promptexec.ValidationSkipped || outcome.ReportPath != "" || len(outcome.Markdown) != 0 {
|
||||
t.Fatalf("canceled outcome = %#v", outcome)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteComparisonProfilesUsesDistinctDeterministicDebugReferences(t *testing.T) {
|
||||
prepared, prompt := preparedDailyProfile(t)
|
||||
profiles := []ComparisonProfileInspection{
|
||||
{ProfileID: "light.one", BackendID: "local", ModelName: "light"},
|
||||
{ProfileID: "deep/two", BackendID: "cloud", ModelName: "deep"},
|
||||
}
|
||||
debugWriter, err := promptdebug.NewPromptDebugWriter(t.TempDir())
|
||||
if errors.Is(err, promptdebug.ErrSecureCaptureUnsupported) {
|
||||
t.Skipf("secure prompt debug capture is unavailable: %v", err)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("NewPromptDebugWriter() error = %v", err)
|
||||
}
|
||||
executor := newBarrierExecutor(profiles)
|
||||
results := startComparisonExecution(t, context.Background(), comparisonExecutionRequest{
|
||||
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", DebugWriter: debugWriter, Executor: executor,
|
||||
}, executor)
|
||||
waitForProfileStarts(t, executor, profiles, results)
|
||||
for _, profile := range profiles {
|
||||
executor.release(profile.ProfileID)
|
||||
}
|
||||
result := <-results
|
||||
paths := map[string]struct{}{}
|
||||
for index, outcome := range result.Outcomes {
|
||||
wantName := fmt.Sprintf("comparison_daily_%0*d-%s", comparison.OrdinalWidth(len(profiles)), index+1, comparison.ProfileSlug(outcome.ProfileID))
|
||||
if filepath.Base(outcome.LLMDebugPath) != wantName {
|
||||
t.Fatalf("debug path = %q, want base %q", outcome.LLMDebugPath, wantName)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outcome.LLMDebugPath, "preparation.json")); err != nil {
|
||||
t.Fatalf("preparation artifact %q: %v", outcome.LLMDebugPath, err)
|
||||
}
|
||||
paths[outcome.LLMDebugPath] = struct{}{}
|
||||
}
|
||||
if len(paths) != len(profiles) {
|
||||
t.Fatalf("debug paths = %#v", paths)
|
||||
}
|
||||
}
|
||||
|
||||
type barrierExecutor struct {
|
||||
mu sync.Mutex
|
||||
started chan string
|
||||
callbackFailures chan error
|
||||
releases map[string]chan struct{}
|
||||
requests map[string]promptexec.ExecuteRequest
|
||||
errors map[string]error
|
||||
profiles map[string]ComparisonProfileInspection
|
||||
inFlight int
|
||||
maximum int
|
||||
}
|
||||
|
||||
func newBarrierExecutor(profiles []ComparisonProfileInspection) *barrierExecutor {
|
||||
releases := make(map[string]chan struct{}, len(profiles))
|
||||
identities := make(map[string]ComparisonProfileInspection, len(profiles))
|
||||
for _, profile := range profiles {
|
||||
releases[profile.ProfileID] = make(chan struct{})
|
||||
identities[profile.ProfileID] = profile
|
||||
}
|
||||
return &barrierExecutor{
|
||||
started: make(chan string, len(profiles)), callbackFailures: make(chan error, len(profiles)), releases: releases,
|
||||
requests: make(map[string]promptexec.ExecuteRequest, len(profiles)), errors: map[string]error{}, profiles: identities,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *barrierExecutor) InspectPrompt(context.Context, string, string) (promptexec.PromptInspection, error) {
|
||||
return promptexec.PromptInspection{}, errors.New("unexpected prompt inspection")
|
||||
}
|
||||
|
||||
func (e *barrierExecutor) InspectProfile(context.Context, string) (promptexec.ProfileInspection, error) {
|
||||
return promptexec.ProfileInspection{}, errors.New("unexpected profile inspection")
|
||||
}
|
||||
|
||||
func (e *barrierExecutor) Execute(ctx context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {
|
||||
stamp := time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
|
||||
e.mu.Lock()
|
||||
profile := e.profiles[req.ProfileID]
|
||||
e.mu.Unlock()
|
||||
if err := callback(promptexec.Preparation{PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: generationPromptHash, ProfileID: req.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName, Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: generationDefinitionForPrompt(req.PromptID).GeneratedTextSchemaID + ".generated_text.schema.json"}, StartedAt: stamp, EndedAt: stamp}, nil); err != nil {
|
||||
e.callbackFailures <- err
|
||||
return nil, err
|
||||
}
|
||||
e.mu.Lock()
|
||||
e.requests[req.ProfileID] = promptexec.ExecuteRequest{PromptID: req.PromptID, PromptVersion: req.PromptVersion, ProfileID: req.ProfileID, DataPackage: append([]byte(nil), req.DataPackage...), CaptureDebug: req.CaptureDebug}
|
||||
e.inFlight++
|
||||
if e.inFlight > e.maximum {
|
||||
e.maximum = e.inFlight
|
||||
}
|
||||
release := e.releases[req.ProfileID]
|
||||
e.mu.Unlock()
|
||||
e.started <- req.ProfileID
|
||||
select {
|
||||
case <-release:
|
||||
case <-ctx.Done():
|
||||
e.mu.Lock()
|
||||
e.inFlight--
|
||||
e.mu.Unlock()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
e.mu.Lock()
|
||||
e.inFlight--
|
||||
err := e.errors[req.ProfileID]
|
||||
e.mu.Unlock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &promptexec.Execution{
|
||||
PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: generationPromptHash,
|
||||
ProfileID: req.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName,
|
||||
StartedAt: stamp, EndedAt: stamp, RawOutput: comparisonRawOutput(),
|
||||
Validation: promptexec.NewValidation(promptexec.ValidationPassed, "json_schema", generationDefinitionForPrompt(req.PromptID).GeneratedTextSchemaID+".generated_text.schema.json", nil),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *barrierExecutor) request(profileID string) (promptexec.ExecuteRequest, bool) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
request, ok := e.requests[profileID]
|
||||
return request, ok
|
||||
}
|
||||
|
||||
func (e *barrierExecutor) setError(profileID string, err error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.errors[profileID] = err
|
||||
}
|
||||
|
||||
func (e *barrierExecutor) release(profileID string) {
|
||||
close(e.releases[profileID])
|
||||
}
|
||||
|
||||
func (e *barrierExecutor) releaseAll() {
|
||||
for _, release := range e.releases {
|
||||
select {
|
||||
case <-release:
|
||||
default:
|
||||
close(release)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *barrierExecutor) maximumInFlight() int {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.maximum
|
||||
}
|
||||
|
||||
func (e *barrierExecutor) inFlightCount() int {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.inFlight
|
||||
}
|
||||
|
||||
const comparisonExecutionTestTimeout = 5 * time.Second
|
||||
|
||||
func startComparisonExecution(t *testing.T, ctx context.Context, request comparisonExecutionRequest, executor *barrierExecutor) <-chan comparisonExecutionResult {
|
||||
t.Helper()
|
||||
results := make(chan comparisonExecutionResult, 1)
|
||||
finished := make(chan struct{})
|
||||
t.Cleanup(func() {
|
||||
executor.releaseAll()
|
||||
timeout := time.NewTimer(comparisonExecutionTestTimeout)
|
||||
defer timeout.Stop()
|
||||
select {
|
||||
case <-finished:
|
||||
case <-timeout.C:
|
||||
t.Error("comparison execution workers did not finish after release")
|
||||
}
|
||||
})
|
||||
go func() {
|
||||
defer close(finished)
|
||||
results <- executeComparisonProfiles(ctx, request)
|
||||
}()
|
||||
return results
|
||||
}
|
||||
|
||||
func waitForProfileStarts(t *testing.T, executor *barrierExecutor, profiles []ComparisonProfileInspection, results <-chan comparisonExecutionResult) {
|
||||
t.Helper()
|
||||
timeout := time.NewTimer(comparisonExecutionTestTimeout)
|
||||
defer timeout.Stop()
|
||||
seen := map[string]struct{}{}
|
||||
for range profiles {
|
||||
var profileID string
|
||||
select {
|
||||
case profileID = <-executor.started:
|
||||
case err := <-executor.callbackFailures:
|
||||
executor.releaseAll()
|
||||
select {
|
||||
case result := <-results:
|
||||
t.Fatalf("comparison profile preparation failed before executor entry: %v; result: %#v", err, result)
|
||||
case <-timeout.C:
|
||||
t.Fatalf("comparison profile preparation failed before executor entry: %v; comparison did not finish", err)
|
||||
}
|
||||
case result := <-results:
|
||||
t.Fatalf("comparison completed before all profiles started: %#v", result)
|
||||
case <-timeout.C:
|
||||
t.Fatal("timed out waiting for comparison profile starts")
|
||||
}
|
||||
if _, duplicate := seen[profileID]; duplicate {
|
||||
t.Fatalf("duplicate execution start for %q", profileID)
|
||||
}
|
||||
seen[profileID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func comparisonProfiles(count int) []ComparisonProfileInspection {
|
||||
profiles := make([]ComparisonProfileInspection, 0, count)
|
||||
for index := 1; index <= count; index++ {
|
||||
profiles = append(profiles, ComparisonProfileInspection{ProfileID: fmt.Sprintf("profile.%02d", index), BackendID: "backend", ModelName: "model"})
|
||||
}
|
||||
return profiles
|
||||
}
|
||||
|
||||
func comparisonInspection(prompt PromptInspectionResult, profiles []ComparisonProfileInspection) ComparisonInspectionResult {
|
||||
return ComparisonInspectionResult{PromptID: prompt.PromptID, PromptVersion: prompt.PromptVersion, PromptHash: prompt.PromptHash, Profiles: profiles}
|
||||
}
|
||||
|
||||
func comparisonRawOutput() []byte {
|
||||
return []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."}`)
|
||||
}
|
||||
|
||||
func bytesEqual(left, right []byte) bool {
|
||||
return reflect.DeepEqual(left, right)
|
||||
}
|
||||
|
||||
var _ promptexec.Executor = (*barrierExecutor)(nil)
|
||||
399
internal/app/comparison_test.go
Normal file
399
internal/app/comparison_test.go
Normal file
@@ -0,0 +1,399 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
func TestCompareDetailedPublishesOneCoherentBundle(t *testing.T) {
|
||||
cfg := comparisonConfig()
|
||||
bundle := generationBundle(t)
|
||||
workingDir := t.TempDir()
|
||||
executor := &generationExecutor{}
|
||||
inspectedBeforeCollection := false
|
||||
result, err := CompareDetailed(context.Background(), ComparisonRequest{
|
||||
Config: cfg, Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
|
||||
WorkingDir: workingDir, Date: generationTime("2026-05-29T12:00:00-05:00"),
|
||||
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")},
|
||||
Collector: &generationCollector{bundle: &bundle, beforeRun: func() {
|
||||
inspectedBeforeCollection = executor.promptInspections == 1 && executor.profileInspections == 2
|
||||
}}, Executor: executor,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CompareDetailed() error = %v", err)
|
||||
}
|
||||
if result == nil || result.Total != 2 || result.Succeeded != 2 || result.Failed != 0 || executor.promptInspections != 1 || executor.profileInspections != 2 || executor.executeCalls != 2 || !inspectedBeforeCollection || result.ManifestPath == "" || result.DataPackagePath == "" {
|
||||
t.Fatalf("result/executor = %#v/%#v", result, executor)
|
||||
}
|
||||
if result.OutputDirectory != filepath.Dir(result.ManifestPath) || !filepath.IsAbs(result.ManifestPath) || !filepath.IsAbs(result.DataPackagePath) {
|
||||
t.Fatalf("published paths = %#v", result)
|
||||
}
|
||||
for index, profile := range result.Results {
|
||||
if profile.Position != index+1 || profile.Status != comparison.StatusSucceeded || !filepath.IsAbs(profile.ReportPath) || profile.Error != nil {
|
||||
t.Fatalf("profile result = %#v", profile)
|
||||
}
|
||||
}
|
||||
data, readErr := os.ReadFile(result.ManifestPath)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
var manifest comparison.Manifest
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if manifest.ComparisonID != result.ComparisonID || manifest.Total != result.Total || manifest.Succeeded != result.Succeeded || manifest.DataPackage.SHA256 == "" || len(manifest.Results) != 2 {
|
||||
t.Fatalf("manifest = %#v", manifest)
|
||||
}
|
||||
if manifest.Results[0].ReportPath != filepath.Base(result.Results[0].ReportPath) || manifest.Results[1].ReportPath != filepath.Base(result.Results[1].ReportPath) {
|
||||
t.Fatalf("manifest report paths = %#v", manifest.Results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareDetailedPublishesPartialBundleAndReturnsAggregateError(t *testing.T) {
|
||||
cfg := comparisonConfig()
|
||||
bundle := generationBundle(t)
|
||||
executor := &generationExecutor{executeErrors: map[string]error{"weather-deep": errors.New("provider detail must not escape")}}
|
||||
result, err := CompareDetailed(context.Background(), ComparisonRequest{
|
||||
Config: cfg, Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep", "weather-fallback"},
|
||||
WorkingDir: t.TempDir(), Date: generationTime("2026-05-29T12:00:00-05:00"),
|
||||
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")},
|
||||
Collector: &generationCollector{bundle: &bundle}, Executor: executor,
|
||||
})
|
||||
if err == nil || err.Error() != "comparison completed with 1 failed profiles" || result == nil || result.Total != 3 || result.Succeeded != 2 || result.Failed != 1 {
|
||||
t.Fatalf("CompareDetailed() result/error = %#v/%v", result, err)
|
||||
}
|
||||
failure := result.Results[1]
|
||||
if failure.Status != comparison.StatusFailed || failure.ReportPath != "" || failure.Error == nil || strings.Contains(failure.Error.Message, "provider detail") {
|
||||
t.Fatalf("failure = %#v", failure)
|
||||
}
|
||||
if _, statErr := os.Stat(result.ManifestPath); statErr != nil {
|
||||
t.Fatalf("partial manifest: %v", statErr)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(result.OutputDirectory, filepath.Base(result.Results[0].ReportPath))); statErr != nil {
|
||||
t.Fatalf("successful partial report: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareDetailedRetainsCommittedPathsWhenBackupCleanupFails(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
state comparison.BackupRecoveryState
|
||||
path bool
|
||||
}{
|
||||
{name: "complete recovery bundle", state: comparison.BackupRecoveryComplete, path: true},
|
||||
{name: "partial remnants", state: comparison.BackupRecoveryPartial, path: true},
|
||||
{name: "absent backup", state: comparison.BackupRecoveryAbsent},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
bundle := generationBundle(t)
|
||||
recoveryPath := ""
|
||||
if test.path {
|
||||
recoveryPath = filepath.Join(t.TempDir(), ".comparison-daily.backup-recovery")
|
||||
}
|
||||
cleanupCause := errors.New("backup cleanup failed")
|
||||
publish := func(context.Context, comparison.DestinationPlan, comparison.LogicalBundle) (comparison.PublicationResult, error) {
|
||||
return comparison.PublicationResult{Committed: true, RecoveryState: test.state, RecoveryPath: recoveryPath}, &comparison.PublicationCleanupError{RecoveryState: test.state, RecoveryPath: recoveryPath, Err: cleanupCause}
|
||||
}
|
||||
|
||||
result, err := compareDetailed(context.Background(), ComparisonRequest{
|
||||
Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
|
||||
WorkingDir: t.TempDir(), Date: generationTime("2026-05-29T12:00:00-05:00"),
|
||||
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")},
|
||||
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{},
|
||||
}, publish)
|
||||
var cleanupErr *comparison.PublicationCleanupError
|
||||
if result == nil || !errors.As(err, &cleanupErr) || !errors.Is(err, cleanupCause) || cleanupErr.RecoveryState != test.state || cleanupErr.RecoveryPath != recoveryPath || !filepath.IsAbs(result.ManifestPath) || !filepath.IsAbs(result.DataPackagePath) {
|
||||
t.Fatalf("CompareDetailed() result/error = %#v/%v", result, err)
|
||||
}
|
||||
for _, profile := range result.Results {
|
||||
if profile.Status == comparison.StatusSucceeded && !filepath.IsAbs(profile.ReportPath) {
|
||||
t.Fatalf("published profile result = %#v", profile)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareDetailedPreflightsBeforePromptOrCollection(t *testing.T) {
|
||||
invalidDestination := filepath.Join(t.TempDir(), "not-a-directory")
|
||||
if err := os.WriteFile(invalidDestination, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bundle := generationBundle(t)
|
||||
collector := &generationCollector{bundle: &bundle}
|
||||
executor := &generationExecutor{}
|
||||
result, err := CompareDetailed(context.Background(), ComparisonRequest{
|
||||
Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
|
||||
WorkingDir: t.TempDir(), OutputDir: invalidDestination, Date: generationTime("2026-05-29T12:00:00-05:00"),
|
||||
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")}, Collector: collector, Executor: executor,
|
||||
})
|
||||
if err == nil || result == nil || collector.called || executor.promptInspections != 0 || executor.executeCalls != 0 || result.ManifestPath != "" {
|
||||
t.Fatalf("result/error/collector/executor = %#v/%v/%#v/%#v", result, err, collector, executor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareDetailedFinalizesUnpublishedFailures(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
prepare func(t *testing.T, outputDirectory string)
|
||||
debugDir string
|
||||
executor *generationExecutor
|
||||
collector *generationCollector
|
||||
wantPrompt bool
|
||||
wantCollection bool
|
||||
}{
|
||||
{
|
||||
name: "destination preflight",
|
||||
prepare: func(t *testing.T, outputDirectory string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(outputDirectory, []byte("not a directory"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
executor: &generationExecutor{},
|
||||
},
|
||||
{
|
||||
name: "debug initialization",
|
||||
debugDir: "relative-debug-directory",
|
||||
executor: &generationExecutor{},
|
||||
collector: &generationCollector{},
|
||||
},
|
||||
{
|
||||
name: "prompt preflight",
|
||||
executor: &generationExecutor{inspectErr: promptexec.NewError(promptexec.PromptLoad, "unsafe prompt detail", errors.New("unsafe cause"))},
|
||||
collector: &generationCollector{},
|
||||
wantPrompt: false,
|
||||
},
|
||||
{
|
||||
name: "profile preflight",
|
||||
executor: &generationExecutor{profileInspectErrors: map[string]error{
|
||||
"weather-deep": promptexec.NewError(promptexec.MissingCredential, "profile credential is unavailable", errors.New("unsafe cause")),
|
||||
}},
|
||||
collector: &generationCollector{},
|
||||
wantPrompt: true,
|
||||
},
|
||||
{
|
||||
name: "collection",
|
||||
executor: &generationExecutor{},
|
||||
collector: &generationCollector{err: errors.New("collection failed")},
|
||||
wantPrompt: true,
|
||||
wantCollection: true,
|
||||
},
|
||||
{
|
||||
name: "preparation",
|
||||
executor: &generationExecutor{},
|
||||
collector: &generationCollector{bundle: &weatherdata.Bundle{}},
|
||||
wantPrompt: true,
|
||||
wantCollection: true,
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
workingDirectory := t.TempDir()
|
||||
outputDirectory := filepath.Join(workingDirectory, "comparison-output")
|
||||
if test.prepare != nil {
|
||||
test.prepare(t, outputDirectory)
|
||||
}
|
||||
collector := test.collector
|
||||
if collector == nil {
|
||||
bundle := generationBundle(t)
|
||||
collector = &generationCollector{bundle: &bundle}
|
||||
}
|
||||
result, err := CompareDetailed(context.Background(), ComparisonRequest{
|
||||
Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
|
||||
WorkingDir: workingDirectory, OutputDir: outputDirectory, LLMDebugDir: test.debugDir,
|
||||
Date: generationTime("2026-05-29T12:00:00-05:00"), Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")},
|
||||
Collector: collector, Executor: test.executor,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("CompareDetailed() error = nil")
|
||||
}
|
||||
assertUnpublishedComparisonResult(t, result, outputDirectory)
|
||||
if (result.PromptID != "") != test.wantPrompt || (result.PromptHash != "") != test.wantPrompt {
|
||||
t.Fatalf("prompt identity = %q/%q, want resolved=%t", result.PromptID, result.PromptHash, test.wantPrompt)
|
||||
}
|
||||
if collector.called != test.wantCollection || test.executor.executeCalls != 0 {
|
||||
t.Fatalf("collection/execution = %t/%d, want collection=%t and no execution", collector.called, test.executor.executeCalls, test.wantCollection)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareDetailedLeavesDestinationWhenCollectionOrPreparationFails(t *testing.T) {
|
||||
collectionErr := errors.New("weather collection failed")
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
collector *generationCollector
|
||||
}{
|
||||
{name: "collection", collector: &generationCollector{err: collectionErr}},
|
||||
{name: "preparation", collector: &generationCollector{bundle: &weatherdata.Bundle{}}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
workingDir := t.TempDir()
|
||||
executor := &generationExecutor{}
|
||||
result, err := CompareDetailed(context.Background(), ComparisonRequest{
|
||||
Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
|
||||
WorkingDir: workingDir, Date: generationTime("2026-05-29T12:00:00-05:00"),
|
||||
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")}, Collector: test.collector, Executor: executor,
|
||||
})
|
||||
if err == nil || result == nil || executor.executeCalls != 0 || result.ManifestPath != "" || result.DataPackagePath != "" {
|
||||
t.Fatalf("CompareDetailed() result/error/executor = %#v/%v/%#v", result, err, executor)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(workingDir, "comparison-daily-2026-05-29")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("comparison destination stat error = %v", statErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareDetailedPublishesManifestWhenEveryProfileFails(t *testing.T) {
|
||||
bundle := generationBundle(t)
|
||||
executor := &generationExecutor{executeErrors: map[string]error{
|
||||
"weather-light": errors.New("first provider failure"), "weather-deep": errors.New("second provider failure"),
|
||||
}}
|
||||
result, err := CompareDetailed(context.Background(), ComparisonRequest{
|
||||
Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
|
||||
WorkingDir: t.TempDir(), Date: generationTime("2026-05-29T12:00:00-05:00"),
|
||||
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")},
|
||||
Collector: &generationCollector{bundle: &bundle}, Executor: executor,
|
||||
})
|
||||
if err == nil || err.Error() != "comparison completed with 2 failed profiles" || result == nil || result.Succeeded != 0 || result.Failed != 2 || result.ManifestPath == "" {
|
||||
t.Fatalf("CompareDetailed() result/error = %#v/%v", result, err)
|
||||
}
|
||||
for _, profile := range result.Results {
|
||||
if profile.ReportPath != "" || profile.Error == nil {
|
||||
t.Fatalf("failed profile = %#v", profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareDetailedCancellationPreservesPublishedBundle(t *testing.T) {
|
||||
workingDir := t.TempDir()
|
||||
bundle := generationBundle(t)
|
||||
request := ComparisonRequest{
|
||||
Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
|
||||
WorkingDir: workingDir, Date: generationTime("2026-05-29T12:00:00-05:00"),
|
||||
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")}, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{},
|
||||
}
|
||||
previous, err := CompareDetailed(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatalf("initial CompareDetailed() error = %v", err)
|
||||
}
|
||||
before, err := os.ReadFile(previous.ManifestPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
request.Replace = true
|
||||
request.Executor = &generationExecutor{cancelBeforeReturn: cancel}
|
||||
result, err := CompareDetailed(ctx, request)
|
||||
if !errors.Is(err, context.Canceled) || result == nil || result.ManifestPath != "" || result.DataPackagePath != "" {
|
||||
t.Fatalf("canceled CompareDetailed() result/error = %#v/%v", result, err)
|
||||
}
|
||||
after, readErr := os.ReadFile(previous.ManifestPath)
|
||||
if readErr != nil || string(after) != string(before) {
|
||||
t.Fatalf("published manifest changed = %q, error = %v", after, readErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareDetailedPreservesCompletedProfileFailureWhenCanceled(t *testing.T) {
|
||||
bundle := generationBundle(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
failureStarted := make(chan struct{})
|
||||
var signalFailure sync.Once
|
||||
executor := &generationExecutor{
|
||||
validations: map[string]promptexec.ValidationStatus{"weather-light": promptexec.ValidationFailed},
|
||||
waitForCancellation: map[string]bool{"weather-deep": true},
|
||||
beforeExecute: func(request promptexec.ExecuteRequest) {
|
||||
if request.ProfileID == "weather-light" {
|
||||
signalFailure.Do(func() { close(failureStarted) })
|
||||
}
|
||||
},
|
||||
}
|
||||
results := make(chan struct {
|
||||
result *ComparisonResult
|
||||
err error
|
||||
}, 1)
|
||||
go func() {
|
||||
result, err := CompareDetailed(ctx, ComparisonRequest{
|
||||
Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
|
||||
WorkingDir: t.TempDir(), Date: generationTime("2026-05-29T12:00:00-05:00"),
|
||||
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")},
|
||||
Collector: &generationCollector{bundle: &bundle}, Executor: executor,
|
||||
})
|
||||
results <- struct {
|
||||
result *ComparisonResult
|
||||
err error
|
||||
}{result: result, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-failureStarted:
|
||||
cancel()
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for the completed profile failure")
|
||||
}
|
||||
completed := <-results
|
||||
if !errors.Is(completed.err, context.Canceled) || completed.result == nil || completed.result.ManifestPath != "" || completed.result.DataPackagePath != "" || completed.result.Succeeded != 0 || completed.result.Failed != 2 {
|
||||
t.Fatalf("CompareDetailed() result/error = %#v/%v", completed.result, completed.err)
|
||||
}
|
||||
failed, canceled := completed.result.Results[0], completed.result.Results[1]
|
||||
if failed.Error == nil || failed.Error.Category != string(promptexec.ValidationRejected) || failed.ValidationStatus != promptexec.ValidationFailed || failed.ReportPath != "" {
|
||||
t.Fatalf("completed failure = %#v", failed)
|
||||
}
|
||||
if canceled.Error == nil || canceled.Error.Category != string(promptexec.Canceled) || canceled.ValidationStatus != promptexec.ValidationSkipped || canceled.ReportPath != "" {
|
||||
t.Fatalf("canceled profile = %#v", canceled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareDetailedLeavesExistingBundleWhenPublicationPreflightChanges(t *testing.T) {
|
||||
workingDir := t.TempDir()
|
||||
target := filepath.Join(workingDir, "comparison-output")
|
||||
bundle := generationBundle(t)
|
||||
executor := &generationExecutor{beforeExecute: func(promptexec.ExecuteRequest) {
|
||||
_ = os.WriteFile(target, []byte("changed"), 0o600)
|
||||
}}
|
||||
result, err := CompareDetailed(context.Background(), ComparisonRequest{
|
||||
Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
|
||||
WorkingDir: workingDir, OutputDir: target, Date: generationTime("2026-05-29T12:00:00-05:00"),
|
||||
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")}, Collector: &generationCollector{bundle: &bundle}, Executor: executor,
|
||||
})
|
||||
if err == nil || result == nil || result.ManifestPath != "" || result.DataPackagePath != "" || result.Results[0].ReportPath != "" {
|
||||
t.Fatalf("CompareDetailed() result/error = %#v/%v", result, err)
|
||||
}
|
||||
data, readErr := os.ReadFile(target)
|
||||
if readErr != nil || string(data) != "changed" {
|
||||
t.Fatalf("destination = %q, error = %v", data, readErr)
|
||||
}
|
||||
}
|
||||
|
||||
func comparisonConfig() config.Config {
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.Timezone, cfg.Location.ID = "America/Chicago", "home"
|
||||
return cfg
|
||||
}
|
||||
|
||||
func assertUnpublishedComparisonResult(t *testing.T, result *ComparisonResult, outputDirectory string) {
|
||||
t.Helper()
|
||||
if result == nil || result.OutputDirectory != outputDirectory || !filepath.IsAbs(result.OutputDirectory) || result.FinishedAt.IsZero() || result.FinishedAt.Location() != time.UTC || result.FinishedAt.Before(result.StartedAt) || result.ManifestPath != "" || result.DataPackagePath != "" {
|
||||
t.Fatalf("unpublished comparison result = %#v", result)
|
||||
}
|
||||
for _, profile := range result.Results {
|
||||
if profile.ReportPath != "" {
|
||||
t.Fatalf("unpublished profile result = %#v", profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
45
internal/app/distributor_notification_test.go
Normal file
45
internal/app/distributor_notification_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
|
||||
)
|
||||
|
||||
func TestNotificationResultFromUploadExcludesRemoteResponseDetails(t *testing.T) {
|
||||
const remote = "REMOTE-DIAGNOSTIC"
|
||||
notification := notificationResultFromUpload("weather", "bundle", "key", distributoradapter.UploadResult{
|
||||
RunID: "run-123", Status: "failed", UploadStatus: "accepted", StatusError: remote,
|
||||
RunStatus: &distributoradapter.RunStatus{PipelineID: "weather", Status: "failed", Report: []byte(`{"detail":"REMOTE-DIAGNOSTIC"}`), Error: remote},
|
||||
})
|
||||
if notification == nil || notification.StatusError != "distributor status could not be confirmed" || notification.Error != "distributor reported a failed run" || len(notification.Report) != 0 {
|
||||
t.Fatalf("notification = %#v", notification)
|
||||
}
|
||||
if strings.Contains(notification.StatusError, remote) || strings.Contains(notification.Error, remote) {
|
||||
t.Fatalf("notification includes remote detail: %#v", notification)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchNotificationResultExcludesRemoteResponseDetails(t *testing.T) {
|
||||
const remote = "REMOTE-DIAGNOSTIC"
|
||||
notification := batchNotificationResult(batchNotificationRequest{PipelineID: "weather", BundleID: "bundle", IdempotencyKey: "key"}, &NotificationResult{Status: "failed", Error: remote})
|
||||
if notification == nil || notification.Error != "distributor reported a failed run" {
|
||||
t.Fatalf("notification = %#v", notification)
|
||||
}
|
||||
if strings.Contains(notification.Error, remote) {
|
||||
t.Fatalf("notification includes remote detail: %#v", notification)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedBatchNotificationResultExcludesRemoteResponseDetails(t *testing.T) {
|
||||
const remote = "REMOTE-DIAGNOSTIC"
|
||||
notification := failedBatchNotificationResult(batchNotificationRequest{PipelineID: "weather", BundleID: "bundle", IdempotencyKey: "key"}, errors.New(remote))
|
||||
if notification == nil || notification.Error != "distributor notification failed" {
|
||||
t.Fatalf("notification = %#v", notification)
|
||||
}
|
||||
if strings.Contains(notification.Error, remote) {
|
||||
t.Fatalf("notification includes remote detail: %#v", notification)
|
||||
}
|
||||
}
|
||||
609
internal/app/generation_test.go
Normal file
609
internal/app/generation_test.go
Normal file
@@ -0,0 +1,609 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/testutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type generationCollector struct {
|
||||
bundle *weatherdata.Bundle
|
||||
err error
|
||||
called bool
|
||||
calls int
|
||||
beforeRun func()
|
||||
}
|
||||
|
||||
type publicationGateContext struct {
|
||||
context.Context
|
||||
err error
|
||||
checks int
|
||||
afterChecks int
|
||||
}
|
||||
|
||||
func (c *publicationGateContext) Err() error {
|
||||
c.checks++
|
||||
afterChecks := c.afterChecks
|
||||
if afterChecks == 0 {
|
||||
afterChecks = 2
|
||||
}
|
||||
if c.checks >= afterChecks {
|
||||
return c.err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *generationCollector) Run(context.Context, collect.Request) (*collect.Result, error) {
|
||||
if c.beforeRun != nil {
|
||||
c.beforeRun()
|
||||
}
|
||||
c.called = true
|
||||
c.calls++
|
||||
return &collect.Result{Bundle: c.bundle}, c.err
|
||||
}
|
||||
|
||||
type generationExecutor struct {
|
||||
called bool
|
||||
executeCalls int
|
||||
promptInspections int
|
||||
profileInspections int
|
||||
inspectErr error
|
||||
profileInspectErrors map[string]error
|
||||
executeErr error
|
||||
executeErrors map[string]error
|
||||
beforeExecute func(promptexec.ExecuteRequest)
|
||||
cancelBeforeReturn context.CancelFunc
|
||||
validation promptexec.ValidationStatus
|
||||
validations map[string]promptexec.ValidationStatus
|
||||
rawOutput []byte
|
||||
waitForCancellation map[string]bool
|
||||
failedPrompt string
|
||||
skipPreparation bool
|
||||
preparationCalls int
|
||||
prepare func(*promptexec.Preparation)
|
||||
complete func(*promptexec.Execution)
|
||||
}
|
||||
|
||||
var generationExecutorMu sync.Mutex
|
||||
|
||||
func (e *generationExecutor) InspectPrompt(_ context.Context, id, version string) (promptexec.PromptInspection, error) {
|
||||
generationExecutorMu.Lock()
|
||||
defer generationExecutorMu.Unlock()
|
||||
e.promptInspections++
|
||||
if e.inspectErr != nil {
|
||||
return promptexec.PromptInspection{}, e.inspectErr
|
||||
}
|
||||
definition := generationDefinitionForPrompt(id)
|
||||
return promptexec.PromptInspection{PromptID: id, PromptVersion: version, PromptHash: generationPromptHash, 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 (e *generationExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) {
|
||||
generationExecutorMu.Lock()
|
||||
defer generationExecutorMu.Unlock()
|
||||
e.profileInspections++
|
||||
if err := e.profileInspectErrors[id]; err != nil {
|
||||
return promptexec.ProfileInspection{}, err
|
||||
}
|
||||
return promptexec.ProfileInspection{ProfileID: id, BackendID: "fixture", ModelName: "fixture-model"}, nil
|
||||
}
|
||||
func (e *generationExecutor) Execute(ctx context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {
|
||||
stamp := time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
|
||||
generationExecutorMu.Lock()
|
||||
skipPreparation := e.skipPreparation
|
||||
prepare := e.prepare
|
||||
preparationCalls := e.preparationCalls
|
||||
generationExecutorMu.Unlock()
|
||||
if !skipPreparation {
|
||||
calls := preparationCalls
|
||||
if calls == 0 {
|
||||
calls = 1
|
||||
}
|
||||
for range calls {
|
||||
preparation := promptexec.Preparation{PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: generationPromptHash, RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "fixture", ModelName: "fixture-model", Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: generationDefinitionForPrompt(req.PromptID).GeneratedTextSchemaID + ".generated_text.schema.json"}, StartedAt: stamp, EndedAt: stamp}
|
||||
if prepare != nil {
|
||||
prepare(&preparation)
|
||||
}
|
||||
if err := callback(preparation, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
generationExecutorMu.Lock()
|
||||
e.called = true
|
||||
e.executeCalls++
|
||||
beforeExecute := e.beforeExecute
|
||||
profileErr := e.executeErrors[req.ProfileID]
|
||||
executeErr := e.executeErr
|
||||
status := e.validation
|
||||
if profileStatus, ok := e.validations[req.ProfileID]; ok {
|
||||
status = profileStatus
|
||||
}
|
||||
rawOutput := append([]byte(nil), e.rawOutput...)
|
||||
waitForCancellation := e.waitForCancellation[req.ProfileID]
|
||||
failedPrompt := e.failedPrompt
|
||||
cancelBeforeReturn := e.cancelBeforeReturn
|
||||
complete := e.complete
|
||||
generationExecutorMu.Unlock()
|
||||
if beforeExecute != nil {
|
||||
beforeExecute(req)
|
||||
}
|
||||
if waitForCancellation {
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if profileErr != nil {
|
||||
return nil, profileErr
|
||||
}
|
||||
if executeErr != nil {
|
||||
return nil, executeErr
|
||||
}
|
||||
if status == "" {
|
||||
status = promptexec.ValidationPassed
|
||||
}
|
||||
if failedPrompt == req.PromptID {
|
||||
status = promptexec.ValidationFailed
|
||||
}
|
||||
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 cancelBeforeReturn != nil {
|
||||
cancelBeforeReturn()
|
||||
}
|
||||
execution := &promptexec.Execution{RunID: "provider-run", PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: generationPromptHash, 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)}
|
||||
if complete != nil {
|
||||
complete(execution)
|
||||
}
|
||||
return execution, nil
|
||||
}
|
||||
|
||||
const generationPromptHash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
|
||||
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{}
|
||||
collector := &generationCollector{bundle: &bundle}
|
||||
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: collector, Executor: executor})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateDetailed() error = %v", err)
|
||||
}
|
||||
if !executor.called || executor.executeCalls != 1 || collector.calls != 1 || 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 TestGenerateDetailedRejectsOverlongOutputBeforeWork(t *testing.T) {
|
||||
missingDirectory := filepath.Join(t.TempDir(), "missing")
|
||||
outputPath := filepath.Join(missingDirectory, strings.Repeat("a", 253)+".md")
|
||||
bundle := generationBundle(t)
|
||||
collector := &generationCollector{bundle: &bundle}
|
||||
executor := &generationExecutor{}
|
||||
|
||||
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: collector, Executor: executor,
|
||||
})
|
||||
if err == nil || result == nil || collector.called || executor.promptInspections != 0 || executor.called {
|
||||
t.Fatalf("GenerateDetailed() result/error/collector/executor = %#v/%v/%t/%#v", result, err, collector.called, executor)
|
||||
}
|
||||
if _, statErr := os.Stat(missingDirectory); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("missing output directory exists after preflight failure: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDetailedRejectsUnsupportedDistributorEndpointBeforeWork(t *testing.T) {
|
||||
outputPath := filepath.Join(t.TempDir(), "daily.md")
|
||||
cfg := generationDistributorConfig()
|
||||
cfg.Notify.Distributor.Endpoint = "ftp://distributor.example.test"
|
||||
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(), OutputPath: outputPath, 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 _, statErr := os.Stat(outputPath); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("output exists after endpoint preflight failure: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
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 TestGenerateDetailedPreservesDestinationWhenContextChangesDuringPublication(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
err error
|
||||
category promptexec.ErrorCategory
|
||||
}{
|
||||
{name: "canceled", err: context.Canceled, category: promptexec.Canceled},
|
||||
{name: "deadline", err: context.DeadlineExceeded, category: promptexec.DeadlineExceeded},
|
||||
} {
|
||||
t.Run(tt.name, func(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 := &publicationGateContext{Context: context.Background(), err: tt.err}
|
||||
cfg := generationConfig()
|
||||
cfg.Notify.Distributor.Enabled = true
|
||||
cfg.Notify.Distributor.PipelineIDTemplate = "weather"
|
||||
bundle := generationBundle(t)
|
||||
notifier := &generationNotifier{}
|
||||
result, err := GenerateDetailed(ctx, 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,
|
||||
})
|
||||
data, readErr := os.ReadFile(outputPath)
|
||||
matches, globErr := filepath.Glob(filepath.Join(filepath.Dir(outputPath), ".weatherreporter-*.tmp"))
|
||||
if !errors.Is(err, tt.err) || promptexec.CategoryOf(err) != tt.category || result == nil || result.OutputPath != "" || notifier.calls != 0 || readErr != nil || string(data) != previousReport || globErr != nil || len(matches) != 0 {
|
||||
t.Fatalf("GenerateDetailed() result/error/output/notification/temp = %#v/%v/%q/%#v/%v/%v", result, err, data, notifier, matches, globErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
collector := &generationCollector{bundle: &bundle}
|
||||
executor := &generationExecutor{}
|
||||
notifier := &generationNotifier{}
|
||||
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: collector, Executor: executor, Notifier: notifier})
|
||||
info, statErr := os.Stat(outputPath)
|
||||
if err == nil || result == nil || statErr != nil || !info.IsDir() || collector.called || executor.promptInspections != 0 || executor.called || notifier.calls != 0 {
|
||||
t.Fatalf("GenerateDetailed() result/error/output-info/collector/executor/notifier = %#v/%v/%#v (%v)/%t/%#v/%#v", result, err, info, statErr, collector.called, executor, notifier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDetailedDoesNotReplaceSymbolicLinkOutput(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
backing := filepath.Join(dir, "backing.md")
|
||||
if err := os.WriteFile(backing, []byte("previous report"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outputPath := filepath.Join(dir, "daily.md")
|
||||
testutil.RequireSymlink(t, backing, outputPath)
|
||||
bundle := generationBundle(t)
|
||||
collector := &generationCollector{bundle: &bundle}
|
||||
executor := &generationExecutor{}
|
||||
notifier := &generationNotifier{}
|
||||
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: collector, Executor: executor, Notifier: notifier})
|
||||
info, statErr := os.Lstat(outputPath)
|
||||
data, readErr := os.ReadFile(backing)
|
||||
if err == nil || result == nil || statErr != nil || info.Mode()&os.ModeSymlink == 0 || readErr != nil || string(data) != "previous report" || collector.called || executor.promptInspections != 0 || executor.called || notifier.calls != 0 {
|
||||
t.Fatalf("GenerateDetailed() result/error/output/backing/collector/executor/notifier = %#v/%v/%#v (%v)/%q (%v)/%t/%#v/%#v", result, err, info, statErr, data, readErr, collector.called, executor, notifier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDetailedWritesRequestedPromptDebugArtifacts(t *testing.T) {
|
||||
bundle := generationBundle(t)
|
||||
debugRoot := t.TempDir()
|
||||
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(), LLMDebugDir: debugRoot, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{},
|
||||
})
|
||||
if errors.Is(err, promptdebug.ErrSecureCaptureUnsupported) {
|
||||
t.Skipf("secure prompt debug capture is unavailable: %v", err)
|
||||
}
|
||||
if err != nil || result == nil || result.LLMDebugPath == "" {
|
||||
t.Fatalf("GenerateDetailed() result/error = %#v/%v", result, err)
|
||||
}
|
||||
for _, name := range []string{"preparation.json", "execution.json"} {
|
||||
if _, statErr := os.Stat(filepath.Join(result.LLMDebugPath, name)); statErr != nil {
|
||||
t.Fatalf("debug artifact %q: %v", name, 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,126 +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/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type InspectReportsRequest struct {
|
||||
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 []weatherdata.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) {
|
||||
inspection, err := inspectRun(ctx, req)
|
||||
return inspection.metadata, err
|
||||
}
|
||||
|
||||
func InspectModules(ctx context.Context, req InspectRunRequest) (module.Snapshot, error) {
|
||||
inspection, err := inspectRun(ctx, req)
|
||||
if err != nil {
|
||||
return module.Snapshot{}, err
|
||||
}
|
||||
return inspection.store.LoadModuleSnapshot(ctx, inspection.metadata.ModuleSnapshotPath)
|
||||
}
|
||||
|
||||
func InspectDataPackage(ctx context.Context, req InspectRunRequest) (promptinput.Package, error) {
|
||||
inspection, err := inspectRun(ctx, req)
|
||||
if err != nil {
|
||||
return promptinput.Package{}, err
|
||||
}
|
||||
return inspection.store.LoadDataPackage(ctx, inspection.metadata.DataPackagePath)
|
||||
}
|
||||
|
||||
func InspectPriorSnapshot(ctx context.Context, req InspectRunRequest) (*state.PriorSnapshot, error) {
|
||||
inspection, err := inspectRun(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolved, err := resolvedFromMetadata(inspection.metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return inspection.store.FindPriorSnapshot(ctx, resolved)
|
||||
}
|
||||
|
||||
func InspectSources(ctx context.Context, req InspectRunRequest) (SourceInspection, error) {
|
||||
inspection, err := inspectRun(ctx, req)
|
||||
if err != nil {
|
||||
return SourceInspection{}, err
|
||||
}
|
||||
metadata := inspection.metadata
|
||||
return SourceInspection{
|
||||
RunID: metadata.RunID,
|
||||
ReportID: metadata.ReportID,
|
||||
SourceLocation: metadata.SourceLocation,
|
||||
Sources: metadata.Sources,
|
||||
Warnings: metadata.SourceWarnings,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type runInspection struct {
|
||||
store *state.FilesystemStore
|
||||
metadata state.Metadata
|
||||
}
|
||||
|
||||
func inspectRun(ctx context.Context, req InspectRunRequest) (runInspection, error) {
|
||||
store, err := defaultStore(req.Config)
|
||||
if err != nil {
|
||||
return runInspection{}, err
|
||||
}
|
||||
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
|
||||
if err != nil {
|
||||
return runInspection{}, err
|
||||
}
|
||||
return runInspection{store: store, metadata: metadata}, nil
|
||||
}
|
||||
|
||||
func resolvedFromMetadata(metadata state.Metadata) (report.Resolved, error) {
|
||||
definition, err := report.DefaultRegistry().Lookup(metadata.ReportID)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
192
internal/app/output.go
Normal file
192
internal/app/output.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||
"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 resolveComparisonOutputDirectory(workingDir, override, configuredDir, reportOutputName string) (string, error) {
|
||||
workingDir, err := validateWorkingDir(workingDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if override != "" {
|
||||
return resolveComparisonDirectoryPath(workingDir, override)
|
||||
}
|
||||
outputDir, err := resolveOutputDir(workingDir, configuredDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
name, err := comparison.DefaultDirectoryName(reportOutputName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(outputDir, name), nil
|
||||
}
|
||||
|
||||
func resolveComparisonDirectoryPath(workingDir, directory string) (string, error) {
|
||||
if strings.TrimSpace(directory) == "" {
|
||||
return "", fmt.Errorf("comparison output directory is required")
|
||||
}
|
||||
if !filepath.IsAbs(directory) {
|
||||
directory = filepath.Join(workingDir, directory)
|
||||
}
|
||||
return filepath.Clean(directory), nil
|
||||
}
|
||||
|
||||
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 err := fileutil.ValidateAtomicPath(path); err != nil {
|
||||
return "", fmt.Errorf("validate final output path %q: %w", path, err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
59
internal/app/output_linux_test.go
Normal file
59
internal/app/output_linux_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
//go:build linux
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateDetailedRejectsSpecialOutputBeforeWork(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
setup func(t *testing.T, path string)
|
||||
}{
|
||||
{
|
||||
name: "named pipe",
|
||||
setup: func(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := syscall.Mkfifo(path, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "socket",
|
||||
setup: func(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = listener.Close() })
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
outputPath := filepath.Join(t.TempDir(), "daily.md")
|
||||
tt.setup(t, outputPath)
|
||||
bundle := generationBundle(t)
|
||||
collector := &generationCollector{bundle: &bundle}
|
||||
executor := &generationExecutor{}
|
||||
notifier := &generationNotifier{}
|
||||
|
||||
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: collector, Executor: executor, Notifier: notifier,
|
||||
})
|
||||
info, statErr := os.Lstat(outputPath)
|
||||
if err == nil || result == nil || statErr != nil || info.Mode().IsRegular() || collector.called || executor.promptInspections != 0 || executor.called || notifier.calls != 0 {
|
||||
t.Fatalf("GenerateDetailed() result/error/output/collector/executor/notifier = %#v/%v/%#v (%v)/%t/%#v/%#v", result, err, info, statErr, collector.called, executor, notifier)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
73
internal/app/output_test.go
Normal file
73
internal/app/output_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/testutil"
|
||||
)
|
||||
|
||||
func TestResolveComparisonOutputDirectory(t *testing.T) {
|
||||
workingDir := t.TempDir()
|
||||
configured := filepath.Join(workingDir, "configured")
|
||||
blocked := filepath.Join(workingDir, "not-a-directory")
|
||||
if err := os.WriteFile(blocked, []byte("blocked"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
override string
|
||||
configuredDir string
|
||||
reportOutputName string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "working directory default", reportOutputName: "today.md", want: filepath.Join(workingDir, "comparison-today")},
|
||||
{name: "configured relative directory", configuredDir: "configured", reportOutputName: "tomorrow.md", want: filepath.Join(configured, "comparison-tomorrow")},
|
||||
{name: "configured absolute directory", configuredDir: configured, reportOutputName: "hourly.md", want: filepath.Join(configured, "comparison-hourly")},
|
||||
{name: "relative explicit directory", override: "exact", configuredDir: blocked, reportOutputName: "daily-2026-08-24.md", want: filepath.Join(workingDir, "exact")},
|
||||
{name: "absolute explicit directory", override: filepath.Join(workingDir, "absolute"), reportOutputName: "today.md", want: filepath.Join(workingDir, "absolute")},
|
||||
{name: "invalid report suffix", reportOutputName: "today.txt", wantErr: true},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := resolveComparisonOutputDirectory(workingDir, test.override, test.configuredDir, test.reportOutputName)
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Fatalf("resolveComparisonOutputDirectory() error = %v, want error %t", err, test.wantErr)
|
||||
}
|
||||
if !test.wantErr && got != test.want {
|
||||
t.Fatalf("resolveComparisonOutputDirectory() = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOutputDirRejectsDanglingSymlinkComponents(t *testing.T) {
|
||||
workingDir := t.TempDir()
|
||||
dangling := filepath.Join(workingDir, "dangling")
|
||||
testutil.RequireSymlink(t, filepath.Join(workingDir, "missing"), dangling)
|
||||
|
||||
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")
|
||||
testutil.RequireSymlink(t, target, link)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
148
internal/app/prepared_report.go
Normal file
148
internal/app/prepared_report.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
// preparedReport contains the immutable deterministic inputs shared by prompt
|
||||
// executions for one resolved report.
|
||||
type preparedReport struct {
|
||||
resolved report.Resolved
|
||||
derived facts.DerivedFacts
|
||||
moduleSnapshot module.Snapshot
|
||||
identity briefing.PreparedIdentity
|
||||
sourceWarnings []weatherdata.SourceWarning
|
||||
dataPackage []byte
|
||||
handler generatedtext.Handler
|
||||
}
|
||||
|
||||
type prepareReportRequest struct {
|
||||
Config config.Config
|
||||
Resolved report.Resolved
|
||||
Collection collect.Result
|
||||
handler generatedtext.Handler
|
||||
}
|
||||
|
||||
type preparationError struct {
|
||||
operation string
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *preparationError) Error() string {
|
||||
return e.operation + ": " + e.err.Error()
|
||||
}
|
||||
|
||||
func (e *preparationError) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
func prepareReport(req prepareReportRequest) (preparedReport, error) {
|
||||
if req.Collection.Bundle == nil {
|
||||
return preparedReport{}, &preparationError{operation: "prepare report", err: fmt.Errorf("collected weather bundle is required")}
|
||||
}
|
||||
|
||||
reportFacts, err := BuildReportFacts(ModuleSnapshotRequest{Config: req.Config, Resolved: req.Resolved}, req.Collection.Bundle)
|
||||
if err != nil {
|
||||
return preparedReport{}, &preparationError{operation: "build report facts", err: err}
|
||||
}
|
||||
buildContext := briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected)
|
||||
identity := briefing.BuildPreparedIdentity(buildContext)
|
||||
moduleSnapshot, err := BuildModuleSnapshotFromFacts(ModuleSnapshotRequest{Config: req.Config, Resolved: req.Resolved, Identity: identity}, reportFacts)
|
||||
if err != nil {
|
||||
return preparedReport{}, &preparationError{operation: "build module snapshot", err: err}
|
||||
}
|
||||
dataPackage, err := promptinput.Build(promptinput.BuildRequest{Metadata: promptMetadata(identity), Modules: moduleSnapshot})
|
||||
if err != nil {
|
||||
return preparedReport{}, &preparationError{operation: "build data package", err: err}
|
||||
}
|
||||
serializedDataPackage, err := promptinput.MarshalYAML(dataPackage)
|
||||
if err != nil {
|
||||
return preparedReport{}, &preparationError{operation: "marshal data package", err: err}
|
||||
}
|
||||
clonedDerived, err := clonePreparedValue(reportFacts.Derived)
|
||||
if err != nil {
|
||||
return preparedReport{}, &preparationError{operation: "copy prepared derived facts", err: err}
|
||||
}
|
||||
clonedSnapshot, err := clonePreparedValue(moduleSnapshot)
|
||||
if err != nil {
|
||||
return preparedReport{}, &preparationError{operation: "copy prepared module snapshot", err: err}
|
||||
}
|
||||
clonedIdentity, err := clonePreparedValue(identity)
|
||||
if err != nil {
|
||||
return preparedReport{}, &preparationError{operation: "copy prepared identity", err: err}
|
||||
}
|
||||
prepared := preparedReport{
|
||||
resolved: cloneResolved(req.Resolved),
|
||||
derived: clonedDerived,
|
||||
moduleSnapshot: clonedSnapshot,
|
||||
identity: clonedIdentity,
|
||||
sourceWarnings: append([]weatherdata.SourceWarning(nil), clonedIdentity.SourceWarnings...),
|
||||
dataPackage: append([]byte(nil), serializedDataPackage...),
|
||||
handler: req.handler,
|
||||
}
|
||||
return prepared, nil
|
||||
}
|
||||
|
||||
func cloneResolved(value report.Resolved) report.Resolved {
|
||||
cloned := value
|
||||
cloned.Definition.DistributorPathTemplates = append([]string(nil), value.Definition.DistributorPathTemplates...)
|
||||
cloned.Definition.Modules = make([]module.ConfigItem, len(value.Definition.Modules))
|
||||
for i, item := range value.Definition.Modules {
|
||||
cloned.Definition.Modules[i] = item
|
||||
switch options := item.Options.(type) {
|
||||
case module.AreaForecastDiscussionOptions:
|
||||
options.Sections = append([]string(nil), options.Sections...)
|
||||
cloned.Definition.Modules[i].Options = options
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func (p preparedReport) dataPackageCopy() []byte {
|
||||
return append([]byte(nil), p.dataPackage...)
|
||||
}
|
||||
|
||||
func (p preparedReport) sourceWarningsCopy() []weatherdata.SourceWarning {
|
||||
return append([]weatherdata.SourceWarning(nil), p.sourceWarnings...)
|
||||
}
|
||||
|
||||
func (p preparedReport) renderInputs() (briefing.PreparedIdentity, module.Snapshot, facts.DerivedFacts, error) {
|
||||
identity, err := clonePreparedValue(p.identity)
|
||||
if err != nil {
|
||||
return briefing.PreparedIdentity{}, module.Snapshot{}, facts.DerivedFacts{}, err
|
||||
}
|
||||
snapshot, err := clonePreparedValue(p.moduleSnapshot)
|
||||
if err != nil {
|
||||
return briefing.PreparedIdentity{}, module.Snapshot{}, facts.DerivedFacts{}, err
|
||||
}
|
||||
derived, err := clonePreparedValue(p.derived)
|
||||
if err != nil {
|
||||
return briefing.PreparedIdentity{}, module.Snapshot{}, facts.DerivedFacts{}, err
|
||||
}
|
||||
return identity, snapshot, derived, nil
|
||||
}
|
||||
|
||||
func clonePreparedValue[T any](value T) (T, error) {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
var zero T
|
||||
return zero, fmt.Errorf("marshal immutable prepared value: %w", err)
|
||||
}
|
||||
var cloned T
|
||||
if err := json.Unmarshal(encoded, &cloned); err != nil {
|
||||
var zero T
|
||||
return zero, fmt.Errorf("unmarshal immutable prepared value: %w", err)
|
||||
}
|
||||
return cloned, nil
|
||||
}
|
||||
121
internal/app/prepared_report_test.go
Normal file
121
internal/app/prepared_report_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
func TestPrepareReportBuildsImmutableDeterministicInputs(t *testing.T) {
|
||||
cfg := generationConfig()
|
||||
bundle := generationBundle(t)
|
||||
resolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg, Report: ReportDaily,
|
||||
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
|
||||
}, generationTime("2026-05-29T08:30:00-05:00"))
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||
}
|
||||
|
||||
request := prepareReportRequest{Config: cfg, Resolved: resolved, Collection: collect.Result{Bundle: &bundle}, handler: preparedHandler(t, resolved)}
|
||||
prepared, err := prepareReport(request)
|
||||
if err != nil {
|
||||
t.Fatalf("prepareReport() error = %v", err)
|
||||
}
|
||||
repeated, err := prepareReport(request)
|
||||
if err != nil {
|
||||
t.Fatalf("second prepareReport() error = %v", err)
|
||||
}
|
||||
if len(prepared.dataPackage) == 0 || !bytes.Equal(prepared.dataPackage, repeated.dataPackage) || !reflect.DeepEqual(prepared.identity, repeated.identity) {
|
||||
t.Fatalf("prepared package and identity are not deterministic: %q/%#v", prepared.dataPackage, prepared.identity)
|
||||
}
|
||||
|
||||
originalDataPackage := append([]byte(nil), prepared.dataPackage...)
|
||||
originalIdentity := prepared.identity
|
||||
originalDerived := prepared.derived
|
||||
originalWarnings := append([]weatherdata.SourceWarning(nil), prepared.sourceWarnings...)
|
||||
identity, snapshot, derived, err := prepared.renderInputs()
|
||||
if err != nil {
|
||||
t.Fatalf("renderInputs() error = %v", err)
|
||||
}
|
||||
identity.SourceWarnings = append(identity.SourceWarnings, weatherdata.SourceWarning{Source: "test", Message: "consumer mutation"})
|
||||
snapshot.Outputs = nil
|
||||
derived.PrecipTiming.ThunderMentioned = false
|
||||
bundle.Hourly.Periods[0].TextDescription = "mutated after preparation"
|
||||
bundle.Warnings = append(bundle.Warnings, weatherdata.SourceWarning{Source: "test", Message: "mutated warning"})
|
||||
if len(bundle.Sources) > 0 {
|
||||
if bundle.Sources[0].Query == nil {
|
||||
bundle.Sources[0].Query = map[string]string{}
|
||||
}
|
||||
bundle.Sources[0].Query["mutated"] = "true"
|
||||
}
|
||||
|
||||
if !bytes.Equal(prepared.dataPackage, originalDataPackage) || !reflect.DeepEqual(prepared.identity, originalIdentity) || !reflect.DeepEqual(prepared.derived, originalDerived) || !reflect.DeepEqual(prepared.sourceWarnings, originalWarnings) {
|
||||
t.Fatalf("prepared values changed after caller mutation: %#v", prepared)
|
||||
}
|
||||
if len(prepared.moduleSnapshot.Outputs) == 0 {
|
||||
t.Fatal("prepared report values retain consumer mutation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareReportProjectsPreparedIdentity(t *testing.T) {
|
||||
cfg := generationConfig()
|
||||
bundle := generationBundle(t)
|
||||
resolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg, Report: ReportDaily,
|
||||
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
|
||||
}, generationTime("2026-05-29T08:30:00-05:00"))
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||
}
|
||||
prepared, err := prepareReport(prepareReportRequest{Config: cfg, Resolved: resolved, Collection: collect.Result{Bundle: &bundle}, handler: preparedHandler(t, resolved)})
|
||||
if err != nil {
|
||||
t.Fatalf("prepareReport() error = %v", err)
|
||||
}
|
||||
|
||||
identity := prepared.identity
|
||||
renderIdentity, _, _, err := prepared.renderInputs()
|
||||
if err != nil {
|
||||
t.Fatalf("renderInputs() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(renderIdentity, identity) {
|
||||
t.Fatalf("render identity = %#v, want %#v", renderIdentity, identity)
|
||||
}
|
||||
prompt := promptMetadata(identity)
|
||||
if prompt.RunID != identity.RunID || prompt.ReportID != identity.ReportID || prompt.Variant != identity.Variant || prompt.PromptID != identity.PromptID || !prompt.GeneratedAt.Equal(identity.GeneratedAt) || prompt.Timezone != identity.Timezone || prompt.ValidPeriod != identity.ValidPeriod || !reflect.DeepEqual(prompt.SourceWarnings, identity.SourceWarnings) {
|
||||
t.Fatalf("prompt metadata does not match prepared identity: %#v/%#v", prompt, identity)
|
||||
}
|
||||
|
||||
moduleMetadata, found, err := module.StanzaValue[briefing.MetadataModule](prepared.moduleSnapshot, "metadata")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("metadata stanza = %#v/%t/%v", moduleMetadata, found, err)
|
||||
}
|
||||
if moduleMetadata.RunID != identity.RunID || moduleMetadata.ReportID != identity.ReportID || moduleMetadata.Variant != identity.Variant || moduleMetadata.PromptID != identity.PromptID || !moduleMetadata.GeneratedAt.Equal(identity.GeneratedAt) || moduleMetadata.Units != identity.Units || moduleMetadata.Timezone != identity.Timezone || moduleMetadata.ValidPeriod != identity.ValidPeriod || !reflect.DeepEqual(moduleMetadata.Location, identity.Location) {
|
||||
t.Fatalf("module metadata does not match prepared identity: %#v/%#v", moduleMetadata, identity)
|
||||
}
|
||||
if len(moduleMetadata.SourceWarnings) != len(identity.SourceWarnings) {
|
||||
t.Fatalf("module source warnings = %#v, want %#v", moduleMetadata.SourceWarnings, identity.SourceWarnings)
|
||||
}
|
||||
for index, warning := range identity.SourceWarnings {
|
||||
summary := moduleMetadata.SourceWarnings[index]
|
||||
if summary.Source != warning.Source || summary.Code != warning.Code || summary.Severity != warning.Severity || summary.Message != warning.Message || summary.CompletenessImpact != warning.CompletenessImpact {
|
||||
t.Fatalf("module source warning %d = %#v, want %#v", index, summary, warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func preparedHandler(t *testing.T, resolved report.Resolved) generatedtext.Handler {
|
||||
t.Helper()
|
||||
handler, err := generatedtext.LookupDefinition(resolved.Definition)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupDefinition() error = %v", err)
|
||||
}
|
||||
return handler
|
||||
}
|
||||
200
internal/app/profile_execution.go
Normal file
200
internal/app/profile_execution.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
)
|
||||
|
||||
type profileExecutionRequest struct {
|
||||
Prepared preparedReport
|
||||
Prompt PromptInspectionResult
|
||||
Profile promptexec.ProfileInspection
|
||||
Executor promptexec.Executor
|
||||
DebugWriter *promptdebug.PromptDebugWriter
|
||||
DebugRef *promptdebug.PromptDebugRef
|
||||
}
|
||||
|
||||
type profileExecutionOutcome struct {
|
||||
ProfileID string
|
||||
BackendID string
|
||||
ModelName string
|
||||
ValidationStatus promptexec.ValidationStatus
|
||||
LLMDebugPath string
|
||||
}
|
||||
|
||||
type profileExecutionError struct {
|
||||
operation string
|
||||
err error
|
||||
callbackFailure bool
|
||||
}
|
||||
|
||||
func (e *profileExecutionError) Error() string {
|
||||
return e.operation + ": " + e.err.Error()
|
||||
}
|
||||
|
||||
func (e *profileExecutionError) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
func executePreparedProfile(ctx context.Context, req profileExecutionRequest) (profileExecutionOutcome, []byte, error) {
|
||||
outcome := profileExecutionOutcome{
|
||||
ProfileID: req.Profile.ProfileID,
|
||||
BackendID: req.Profile.BackendID,
|
||||
ModelName: req.Profile.ModelName,
|
||||
}
|
||||
if req.Executor == nil {
|
||||
return outcome, nil, &profileExecutionError{operation: "execute prompt", err: promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is required", nil)}
|
||||
}
|
||||
if err := validatePreparedExecutionRequest(req); err != nil {
|
||||
return outcome, nil, &profileExecutionError{operation: "validate prompt provenance", err: err}
|
||||
}
|
||||
|
||||
callbackFailed := false
|
||||
preparationCount := 0
|
||||
var preparation promptexec.Preparation
|
||||
preparationCallback := func(value promptexec.Preparation, debug *promptexec.PreparationDebug) error {
|
||||
preparationCount++
|
||||
if preparationCount != 1 {
|
||||
return promptProvenanceError()
|
||||
}
|
||||
if err := validatePreparationProvenance(req, value); err != nil {
|
||||
return err
|
||||
}
|
||||
preparation = clonePreparation(value)
|
||||
if req.DebugWriter == nil || !req.DebugWriter.Enabled() {
|
||||
return nil
|
||||
}
|
||||
if req.DebugRef == nil {
|
||||
callbackFailed = true
|
||||
return promptDebugWriteError(fmt.Errorf("prompt debug reference is required"))
|
||||
}
|
||||
path, err := req.DebugWriter.WritePreparation(*req.DebugRef, value, debug)
|
||||
if err != nil {
|
||||
callbackFailed = true
|
||||
return promptDebugWriteError(err)
|
||||
}
|
||||
outcome.LLMDebugPath = path
|
||||
return nil
|
||||
}
|
||||
|
||||
captureDebug := req.DebugWriter != nil && req.DebugWriter.Enabled()
|
||||
execution, err := req.Executor.Execute(ctx, promptexec.ExecuteRequest{
|
||||
PromptID: req.Prompt.PromptID,
|
||||
PromptVersion: req.Prompt.PromptVersion,
|
||||
ProfileID: req.Profile.ProfileID,
|
||||
DataPackage: req.Prepared.dataPackageCopy(),
|
||||
CaptureDebug: captureDebug,
|
||||
}, preparationCallback)
|
||||
if err != nil {
|
||||
if callbackFailed {
|
||||
return outcome, nil, &profileExecutionError{operation: "execute prompt", err: err, callbackFailure: true}
|
||||
}
|
||||
return outcome, nil, &profileExecutionError{operation: "execute prompt", err: classifiedPromptError("prompt execution failed", err)}
|
||||
}
|
||||
if execution == nil {
|
||||
return outcome, nil, &profileExecutionError{operation: "execute prompt", err: promptexec.NewError(promptexec.Generation, "prompt executor returned no execution", nil)}
|
||||
}
|
||||
if preparationCount != 1 {
|
||||
return outcome, nil, &profileExecutionError{operation: "validate prompt provenance", err: promptProvenanceError()}
|
||||
}
|
||||
if err := validateExecutionProvenance(req, preparation, *execution); err != nil {
|
||||
return outcome, nil, &profileExecutionError{operation: "validate prompt provenance", err: err}
|
||||
}
|
||||
outcome.ValidationStatus = execution.Validation.Status
|
||||
if err := generatedtext.ValidateRawOutput(execution.RawOutput); err != nil {
|
||||
return outcome, nil, &profileExecutionError{operation: "validate generated text", err: err}
|
||||
}
|
||||
if req.DebugWriter != nil && req.DebugWriter.Enabled() {
|
||||
if req.DebugRef == nil {
|
||||
return outcome, nil, &profileExecutionError{operation: "write prompt debug", err: promptDebugWriteError(fmt.Errorf("prompt debug reference is required"))}
|
||||
}
|
||||
path, err := req.DebugWriter.WriteExecution(*req.DebugRef, *execution)
|
||||
if err != nil {
|
||||
return outcome, nil, &profileExecutionError{operation: "write prompt debug", err: promptDebugWriteError(err)}
|
||||
}
|
||||
if path != "" {
|
||||
outcome.LLMDebugPath = path
|
||||
}
|
||||
}
|
||||
|
||||
if execution.Validation.Status != promptexec.ValidationPassed && execution.Validation.Status != promptexec.ValidationFailed {
|
||||
return outcome, nil, &profileExecutionError{operation: "validate prompt execution", err: promptexec.NewError(promptexec.OperationalValidation, "prompt execution did not complete validation", nil)}
|
||||
}
|
||||
if execution.Validation.Status == promptexec.ValidationFailed {
|
||||
return outcome, nil, &profileExecutionError{operation: "validate prompt execution", err: promptexec.NewError(promptexec.ValidationRejected, "prompt output did not satisfy its schema", nil)}
|
||||
}
|
||||
|
||||
generatedText, err := req.Prepared.handler.Validate(execution.RawOutput)
|
||||
if err != nil {
|
||||
return outcome, nil, &profileExecutionError{operation: "validate generated text", err: err}
|
||||
}
|
||||
identity, snapshot, derived, err := req.Prepared.renderInputs()
|
||||
if err != nil {
|
||||
return outcome, nil, &profileExecutionError{operation: "copy prepared render inputs", err: err}
|
||||
}
|
||||
renderContext, err := req.Prepared.handler.BuildRenderContext(identity, snapshot, derived, generatedText)
|
||||
if err != nil {
|
||||
return outcome, nil, &profileExecutionError{operation: "build render context", err: err}
|
||||
}
|
||||
rendered, err := req.Prepared.handler.Render(renderContext)
|
||||
if err != nil {
|
||||
return outcome, nil, &profileExecutionError{operation: "render template", err: err}
|
||||
}
|
||||
return outcome, rendered, nil
|
||||
}
|
||||
|
||||
func validatePreparedExecutionRequest(req profileExecutionRequest) error {
|
||||
definition := req.Prepared.resolved.Definition
|
||||
if definition.PromptID != req.Prompt.PromptID || definition.PromptVersion != req.Prompt.PromptVersion ||
|
||||
definition.GeneratedTextSchemaID != req.Prepared.handler.SchemaID() {
|
||||
return promptProvenanceError()
|
||||
}
|
||||
if req.Prompt.ProfileID != "" && (req.Prompt.ProfileID != req.Profile.ProfileID || req.Prompt.BackendID != req.Profile.BackendID || req.Prompt.ModelName != req.Profile.ModelName) {
|
||||
return promptProvenanceError()
|
||||
}
|
||||
if req.Prompt.PromptHash == "" || req.Profile.ProfileID == "" || req.Profile.BackendID == "" || req.Profile.ModelName == "" {
|
||||
return promptProvenanceError()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePreparationProvenance(req profileExecutionRequest, preparation promptexec.Preparation) error {
|
||||
definition := req.Prepared.resolved.Definition
|
||||
if preparation.PromptID != req.Prompt.PromptID || preparation.PromptVersion != req.Prompt.PromptVersion || preparation.PromptHash != req.Prompt.PromptHash ||
|
||||
preparation.ProfileID != req.Profile.ProfileID || preparation.BackendID != req.Profile.BackendID || preparation.ModelName != req.Profile.ModelName ||
|
||||
!validPromptOutput(definition, preparation.Output) {
|
||||
return promptProvenanceError()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateExecutionProvenance(req profileExecutionRequest, preparation promptexec.Preparation, execution promptexec.Execution) error {
|
||||
definition := req.Prepared.resolved.Definition
|
||||
if execution.PromptID != preparation.PromptID || execution.PromptVersion != preparation.PromptVersion || execution.PromptHash != preparation.PromptHash ||
|
||||
execution.RenderedPromptHash != preparation.RenderedPromptHash || !reflect.DeepEqual(execution.InputHashes, preparation.InputHashes) ||
|
||||
execution.ProfileID != preparation.ProfileID || execution.BackendID != preparation.BackendID || execution.ModelName != preparation.ModelName ||
|
||||
execution.Validation.Mode != "json_schema" || execution.Validation.SchemaPath != definition.GeneratedTextSchemaID+".generated_text.schema.json" {
|
||||
return promptProvenanceError()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func promptProvenanceError() error {
|
||||
return promptexec.NewError(promptexec.InvalidConfiguration, "prompt execution provenance is inconsistent", nil)
|
||||
}
|
||||
|
||||
func clonePreparation(value promptexec.Preparation) promptexec.Preparation {
|
||||
if value.InputHashes != nil {
|
||||
inputHashes := make(map[string]string, len(value.InputHashes))
|
||||
for name, hash := range value.InputHashes {
|
||||
inputHashes[name] = hash
|
||||
}
|
||||
value.InputHashes = inputHashes
|
||||
}
|
||||
return value
|
||||
}
|
||||
176
internal/app/profile_execution_test.go
Normal file
176
internal/app/profile_execution_test.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
)
|
||||
|
||||
func TestExecutePreparedProfileRendersWithoutPublishing(t *testing.T) {
|
||||
prepared, inspection := preparedDailyProfile(t)
|
||||
executor := &generationExecutor{}
|
||||
outputPath := filepath.Join(t.TempDir(), "report.md")
|
||||
outcome, rendered, err := executePreparedProfile(context.Background(), profileExecutionRequest{
|
||||
Prepared: prepared, Prompt: inspection,
|
||||
Profile: promptexec.ProfileInspection{ProfileID: inspection.ProfileID, BackendID: inspection.BackendID, ModelName: inspection.ModelName},
|
||||
Executor: executor,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("executePreparedProfile() error = %v", err)
|
||||
}
|
||||
if len(rendered) == 0 || outcome.ValidationStatus != promptexec.ValidationPassed || outcome.ProfileID != inspection.ProfileID || executor.executeCalls != 1 {
|
||||
t.Fatalf("outcome/rendered/execution calls = %#v/%q/%d", outcome, rendered, executor.executeCalls)
|
||||
}
|
||||
if _, statErr := os.Stat(outputPath); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("execution unexpectedly published %q: %v", outputPath, statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutePreparedProfileKeepsDebugCallbackFailureLocal(t *testing.T) {
|
||||
prepared, inspection := preparedDailyProfile(t)
|
||||
debugWriter, err := promptdebug.NewPromptDebugWriter(t.TempDir())
|
||||
if errors.Is(err, promptdebug.ErrSecureCaptureUnsupported) {
|
||||
t.Skipf("secure prompt debug capture is unavailable: %v", err)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("NewPromptDebugWriter() error = %v", err)
|
||||
}
|
||||
executor := &generationExecutor{}
|
||||
outcome, rendered, err := executePreparedProfile(context.Background(), profileExecutionRequest{
|
||||
Prepared: prepared, Prompt: inspection,
|
||||
Profile: promptexec.ProfileInspection{ProfileID: inspection.ProfileID, BackendID: inspection.BackendID, ModelName: inspection.ModelName},
|
||||
Executor: executor, DebugWriter: debugWriter,
|
||||
DebugRef: &promptdebug.PromptDebugRef{ReportID: inspectionResolved(t).Definition.ID, ValidDate: "2026-05-29", RunID: "invalid/path"},
|
||||
})
|
||||
var executionErr *profileExecutionError
|
||||
if err == nil || !errors.As(err, &executionErr) || !executionErr.callbackFailure || promptexec.CategoryOf(err) != promptexec.InvalidConfiguration || len(rendered) != 0 || executor.executeCalls != 0 || outcome.LLMDebugPath != "" {
|
||||
t.Fatalf("outcome/rendered/error/execution calls = %#v/%q/%v/%d", outcome, rendered, err, executor.executeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutePreparedProfileBoundsOversizedExecutorOutput(t *testing.T) {
|
||||
prepared, inspection := preparedDailyProfile(t)
|
||||
marker := "provider-controlled-marker"
|
||||
executor := &generationExecutor{rawOutput: []byte(strings.Repeat("x", generatedtext.MaxGeneratedTextBytes+1) + marker)}
|
||||
_, _, err := executePreparedProfile(context.Background(), profileExecutionRequest{
|
||||
Prepared: prepared, Prompt: inspection,
|
||||
Profile: promptexec.ProfileInspection{ProfileID: inspection.ProfileID, BackendID: inspection.BackendID, ModelName: inspection.ModelName},
|
||||
Executor: executor,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "65536-byte limit") {
|
||||
t.Fatalf("executePreparedProfile() error = %v, want bounded raw size error", err)
|
||||
}
|
||||
if len(err.Error()) > 160 || strings.Contains(err.Error(), marker) {
|
||||
t.Fatalf("ordinary error leaked provider content: %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutePreparedProfileRejectsInconsistentProvenance(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*preparedReport, *PromptInspectionResult, *promptexec.ProfileInspection, *generationExecutor)
|
||||
invoked bool
|
||||
}{
|
||||
{
|
||||
name: "prepared definition", mutate: func(prepared *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, _ *generationExecutor) {
|
||||
prepared.resolved.Definition.PromptVersion = "different-version"
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing callback", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
|
||||
executor.skipPreparation = true
|
||||
}, invoked: true,
|
||||
},
|
||||
{
|
||||
name: "duplicate callback", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
|
||||
executor.preparationCalls = 2
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "callback prompt hash", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
|
||||
executor.prepare = func(value *promptexec.Preparation) { value.PromptHash = "different-hash" }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "callback output schema", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
|
||||
executor.prepare = func(value *promptexec.Preparation) { value.Output.SchemaPath = "other.generated_text.schema.json" }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "completed profile", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
|
||||
executor.complete = func(value *promptexec.Execution) { value.ProfileID = "different-profile" }
|
||||
}, invoked: true,
|
||||
},
|
||||
{
|
||||
name: "completed rendered prompt hash", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
|
||||
executor.complete = func(value *promptexec.Execution) { value.RenderedPromptHash = "different-rendered-hash" }
|
||||
}, invoked: true,
|
||||
},
|
||||
{
|
||||
name: "completed input hashes", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
|
||||
executor.prepare = func(value *promptexec.Preparation) {
|
||||
value.InputHashes = map[string]string{"data_package": "prepared-hash"}
|
||||
}
|
||||
executor.complete = func(value *promptexec.Execution) {
|
||||
value.InputHashes = map[string]string{"data_package": "completed-hash"}
|
||||
}
|
||||
}, invoked: true,
|
||||
},
|
||||
{
|
||||
name: "completed validation mode", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
|
||||
executor.complete = func(value *promptexec.Execution) { value.Validation.Mode = "other" }
|
||||
}, invoked: true,
|
||||
},
|
||||
{
|
||||
name: "completed validation schema", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
|
||||
executor.complete = func(value *promptexec.Execution) { value.Validation.SchemaPath = "other.generated_text.schema.json" }
|
||||
}, invoked: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
prepared, inspection := preparedDailyProfile(t)
|
||||
profile := promptexec.ProfileInspection{ProfileID: inspection.ProfileID, BackendID: inspection.BackendID, ModelName: inspection.ModelName}
|
||||
executor := &generationExecutor{}
|
||||
tt.mutate(&prepared, &inspection, &profile, executor)
|
||||
|
||||
outcome, rendered, err := executePreparedProfile(context.Background(), profileExecutionRequest{Prepared: prepared, Prompt: inspection, Profile: profile, Executor: executor})
|
||||
if err == nil || promptexec.CategoryOf(err) != promptexec.InvalidConfiguration || len(rendered) != 0 {
|
||||
t.Fatalf("outcome/rendered/error = %#v/%q/%v", outcome, rendered, err)
|
||||
}
|
||||
if outcome.ProfileID != profile.ProfileID || outcome.BackendID != profile.BackendID || outcome.ModelName != profile.ModelName || outcome.ValidationStatus != "" {
|
||||
t.Fatalf("outcome retained unverified provenance: %#v", outcome)
|
||||
}
|
||||
if (executor.executeCalls == 1) != tt.invoked {
|
||||
t.Fatalf("executor calls = %d, want invoked=%t", executor.executeCalls, tt.invoked)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func preparedDailyProfile(t *testing.T) (preparedReport, PromptInspectionResult) {
|
||||
t.Helper()
|
||||
cfg := generationConfig()
|
||||
resolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg, Report: ReportDaily,
|
||||
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
|
||||
}, generationTime("2026-05-29T08:30:00-05:00"))
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||
}
|
||||
bundle := generationBundle(t)
|
||||
prepared, err := prepareReport(prepareReportRequest{Config: cfg, Resolved: resolved, Collection: collect.Result{Bundle: &bundle}, handler: preparedHandler(t, resolved)})
|
||||
if err != nil {
|
||||
t.Fatalf("prepareReport() error = %v", err)
|
||||
}
|
||||
return prepared, PromptInspectionResult{PromptID: resolved.Definition.PromptID, PromptVersion: resolved.Definition.PromptVersion, PromptHash: generationPromptHash, ProfileID: "fixture", BackendID: "fixture", ModelName: "fixture-model"}
|
||||
}
|
||||
145
internal/app/prompt_generate.go
Normal file
145
internal/app/prompt_generate.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
|
||||
type promptReportRequest struct {
|
||||
GenerateRequest
|
||||
Resolved report.Resolved
|
||||
Collection collect.Result
|
||||
Inspection PromptInspectionResult
|
||||
DebugWriter *promptdebug.PromptDebugWriter
|
||||
Result *ReportResult
|
||||
noNotify bool
|
||||
}
|
||||
|
||||
func generatePromptReport(ctx context.Context, req promptReportRequest) (*ReportResult, 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)
|
||||
}
|
||||
prepared, err := prepareReport(prepareReportRequest{Config: req.Config, Resolved: req.Resolved, Collection: req.Collection, handler: req.Inspection.handler})
|
||||
if err != nil {
|
||||
return result, generatedPreparationError(req.Resolved, result.RunID, err)
|
||||
}
|
||||
result.SourceWarnings = prepared.sourceWarningsCopy()
|
||||
debugRef := promptdebug.PromptDebugRef{ReportID: result.ReportID, ValidDate: prepared.resolved.ValidPeriod.Start.Format("2006-01-02"), RunID: result.RunID}
|
||||
outcome, rendered, err := executePreparedProfile(ctx, profileExecutionRequest{
|
||||
Prepared: prepared,
|
||||
Prompt: req.Inspection,
|
||||
Profile: promptexec.ProfileInspection{
|
||||
ProfileID: req.Inspection.ProfileID,
|
||||
BackendID: req.Inspection.BackendID,
|
||||
ModelName: req.Inspection.ModelName,
|
||||
},
|
||||
Executor: req.Executor, DebugWriter: req.DebugWriter, DebugRef: &debugRef,
|
||||
})
|
||||
result.ProfileID, result.BackendID, result.ModelName = outcome.ProfileID, outcome.BackendID, outcome.ModelName
|
||||
result.ValidationStatus = outcome.ValidationStatus
|
||||
result.LLMDebugPath = outcome.LLMDebugPath
|
||||
if err != nil {
|
||||
return result, generatedProfileExecutionError(req.Resolved, result.RunID, err)
|
||||
}
|
||||
return publishPromptReport(ctx, promptPublicationRequest{
|
||||
GenerateRequest: req.GenerateRequest,
|
||||
Resolved: req.Resolved,
|
||||
OutputPath: req.OutputPath,
|
||||
Result: result,
|
||||
Markdown: rendered,
|
||||
suppressNotification: req.noNotify,
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
type promptPublicationRequest struct {
|
||||
GenerateRequest
|
||||
Resolved report.Resolved
|
||||
OutputPath string
|
||||
Result *ReportResult
|
||||
Markdown []byte
|
||||
suppressNotification bool
|
||||
}
|
||||
|
||||
func publishPromptReport(ctx context.Context, req promptPublicationRequest) (*ReportResult, error) {
|
||||
if err := publicationContextError(ctx); err != nil {
|
||||
return req.Result, generatedReportError(req.Resolved, req.Result.RunID, "publish report", err)
|
||||
}
|
||||
if err := fileutil.WriteFileAtomicContext(ctx, req.OutputPath, req.Markdown); err != nil {
|
||||
if contextErr := publicationContextError(ctx); contextErr != nil {
|
||||
return req.Result, generatedReportError(req.Resolved, req.Result.RunID, "publish report", contextErr)
|
||||
}
|
||||
return req.Result, err
|
||||
}
|
||||
req.Result.OutputPath = req.OutputPath
|
||||
if req.suppressNotification {
|
||||
return req.Result, nil
|
||||
}
|
||||
notification, err := notifyReport(ctx, req.Config, req.Resolved, req.Result.OutputPath, req.Result.RunID, req.Result.GeneratedAt, req.Notifier)
|
||||
req.Result.Notification = notification
|
||||
if err != nil {
|
||||
return req.Result, err
|
||||
}
|
||||
return req.Result, nil
|
||||
}
|
||||
|
||||
func generatedPreparationError(resolved report.Resolved, runID string, err error) error {
|
||||
var preparation *preparationError
|
||||
if errors.As(err, &preparation) {
|
||||
return generatedReportError(resolved, runID, preparation.operation, preparation.err)
|
||||
}
|
||||
return generatedReportError(resolved, runID, "prepare report", err)
|
||||
}
|
||||
|
||||
func generatedProfileExecutionError(resolved report.Resolved, runID string, err error) error {
|
||||
var execution *profileExecutionError
|
||||
if errors.As(err, &execution) {
|
||||
if execution.callbackFailure {
|
||||
return execution.err
|
||||
}
|
||||
return generatedReportError(resolved, runID, execution.operation, execution.err)
|
||||
}
|
||||
return generatedReportError(resolved, runID, "execute prompt", 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)
|
||||
}
|
||||
240
internal/app/prompt_inspection.go
Normal file
240
internal/app/prompt_inspection.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
|
||||
"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
|
||||
handler generatedtext.Handler
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// ComparisonInspectionRequest contains the explicit profile selection for one
|
||||
// resolved prompt comparison. It intentionally has no configured profile field.
|
||||
type ComparisonInspectionRequest struct {
|
||||
Resolved report.Resolved
|
||||
ProfileIDs []string
|
||||
Executor promptexec.Executor
|
||||
LookupEnv func(string) (string, bool)
|
||||
}
|
||||
|
||||
// ComparisonInspectionResult contains the safe, shared prompt identity and
|
||||
// ordered effective profile identities for a comparison.
|
||||
type ComparisonInspectionResult struct {
|
||||
PromptID string
|
||||
PromptVersion string
|
||||
PromptHash string
|
||||
Profiles []ComparisonProfileInspection
|
||||
handler generatedtext.Handler
|
||||
}
|
||||
|
||||
// ComparisonProfileInspection contains one requested profile's safe effective
|
||||
// execution identity.
|
||||
type ComparisonProfileInspection struct {
|
||||
ProfileID string
|
||||
BackendID string
|
||||
ModelName string
|
||||
}
|
||||
|
||||
// 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
|
||||
handler, err := generatedtext.LookupDefinition(definition)
|
||||
if err != nil {
|
||||
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "report generated-text catalog is incompatible", err)
|
||||
}
|
||||
inspection, err := inspectPromptContract(ctx, req.Executor, definition)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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,
|
||||
handler: handler,
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// InspectComparisonExecution validates one exact prompt and every explicitly
|
||||
// requested profile before collection or model execution. Profiles are
|
||||
// inspected sequentially in request order. If a profile fails, the returned
|
||||
// partial result retains the prompt identity and successfully inspected prefix.
|
||||
func InspectComparisonExecution(ctx context.Context, req ComparisonInspectionRequest) (ComparisonInspectionResult, error) {
|
||||
if err := comparison.ValidateProfileIDs(req.ProfileIDs); err != nil {
|
||||
return ComparisonInspectionResult{}, promptexec.NewError(promptexec.InvalidRequest, "comparison profile selection is invalid", err)
|
||||
}
|
||||
if req.Executor == nil {
|
||||
return ComparisonInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is required", nil)
|
||||
}
|
||||
|
||||
handler, err := generatedtext.LookupDefinition(req.Resolved.Definition)
|
||||
if err != nil {
|
||||
return ComparisonInspectionResult{}, comparisonInspectionError("comparison generated-text catalog inspection failed", promptexec.NewError(promptexec.InvalidConfiguration, "report generated-text catalog is incompatible", err))
|
||||
}
|
||||
inspection, err := inspectPromptContract(ctx, req.Executor, req.Resolved.Definition)
|
||||
if err != nil {
|
||||
return ComparisonInspectionResult{}, comparisonInspectionError("comparison prompt inspection failed", err)
|
||||
}
|
||||
result := ComparisonInspectionResult{
|
||||
PromptID: inspection.PromptID,
|
||||
PromptVersion: inspection.PromptVersion,
|
||||
PromptHash: inspection.PromptHash,
|
||||
Profiles: make([]ComparisonProfileInspection, 0, len(req.ProfileIDs)),
|
||||
handler: handler,
|
||||
}
|
||||
for _, profileID := range req.ProfileIDs {
|
||||
profile, err := inspectPromptProfile(ctx, req.Executor, profileID, req.LookupEnv)
|
||||
if err != nil {
|
||||
return result, comparisonInspectionError("comparison profile inspection failed", err)
|
||||
}
|
||||
result.Profiles = append(result.Profiles, ComparisonProfileInspection{
|
||||
ProfileID: profile.ProfileID,
|
||||
BackendID: profile.BackendID,
|
||||
ModelName: profile.ModelName,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func inspectPromptContract(ctx context.Context, executor promptexec.Executor, definition report.Definition) (promptexec.PromptInspection, error) {
|
||||
if strings.TrimSpace(definition.PromptID) == "" || strings.TrimSpace(definition.PromptVersion) == "" {
|
||||
return promptexec.PromptInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "report prompt identity is incomplete", nil)
|
||||
}
|
||||
inspection, err := executor.InspectPrompt(ctx, definition.PromptID, definition.PromptVersion)
|
||||
if err != nil {
|
||||
return promptexec.PromptInspection{}, promptInspectionError("prompt inspection failed", err)
|
||||
}
|
||||
if inspection.PromptID != definition.PromptID || inspection.PromptVersion != definition.PromptVersion {
|
||||
return promptexec.PromptInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt inspection did not return the requested prompt version", nil)
|
||||
}
|
||||
if strings.TrimSpace(inspection.PromptHash) == "" {
|
||||
return promptexec.PromptInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt inspection did not return a prompt hash", nil)
|
||||
}
|
||||
if !validPromptInput(inspection.Inputs) {
|
||||
return promptexec.PromptInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt must declare exactly one required application/yaml data_package input", nil)
|
||||
}
|
||||
if !validPromptOutput(definition, inspection.Output) {
|
||||
return promptexec.PromptInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt must declare the report JSON Schema output contract", nil)
|
||||
}
|
||||
return inspection, 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)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(profile.BackendID) == "" || strings.TrimSpace(profile.ModelName) == "" {
|
||||
return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "profile inspection did not return a complete execution identity", 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)
|
||||
}
|
||||
|
||||
func comparisonInspectionError(operation string, err error) error {
|
||||
category := promptexec.CategoryOf(err)
|
||||
if category == "" {
|
||||
category = promptexec.InvalidConfiguration
|
||||
}
|
||||
return promptexec.NewError(category, operation, err)
|
||||
}
|
||||
374
internal/app/prompt_inspection_test.go
Normal file
374
internal/app/prompt_inspection_test.go
Normal file
@@ -0,0 +1,374 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"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: "missing prompt hash",
|
||||
prompt: func() promptexec.PromptInspection {
|
||||
value := basePrompt
|
||||
value.PromptHash = ""
|
||||
return value
|
||||
}(),
|
||||
wantCategory: promptexec.InvalidConfiguration,
|
||||
},
|
||||
{
|
||||
name: "missing profile backend",
|
||||
prompt: basePrompt,
|
||||
profile: promptexec.ProfileInspection{ProfileID: "default-profile", ModelName: "model"},
|
||||
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 := inspectionResolvedFor(t, report.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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptInspectionRejectsIncompatibleGeneratedTextCatalogBeforeExecutorWork(t *testing.T) {
|
||||
base := inspectionResolved(t)
|
||||
tests := []struct {
|
||||
name string
|
||||
resolved report.Resolved
|
||||
inspect func(context.Context, report.Resolved, *inspectionExecutor) error
|
||||
}{
|
||||
{
|
||||
name: "single report unknown template",
|
||||
resolved: func() report.Resolved {
|
||||
resolved := base
|
||||
resolved.Definition.TemplateID = "unknown"
|
||||
return resolved
|
||||
}(),
|
||||
inspect: func(ctx context.Context, resolved report.Resolved, executor *inspectionExecutor) error {
|
||||
_, err := InspectPromptExecution(ctx, PromptInspectionRequest{Resolved: resolved, Executor: executor})
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "batch known pair for another report",
|
||||
resolved: func() report.Resolved {
|
||||
resolved := base
|
||||
resolved.Definition.GeneratedTextSchemaID = "today"
|
||||
resolved.Definition.TemplateID = "today"
|
||||
return resolved
|
||||
}(),
|
||||
inspect: func(ctx context.Context, resolved report.Resolved, executor *inspectionExecutor) error {
|
||||
_, err := InspectPromptExecutions(ctx, PromptExecutionsInspectionRequest{Resolved: []report.Resolved{resolved}, Executor: executor})
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "comparison known pair for another report",
|
||||
resolved: func() report.Resolved {
|
||||
resolved := base
|
||||
resolved.Definition.GeneratedTextSchemaID = "today"
|
||||
resolved.Definition.TemplateID = "today"
|
||||
return resolved
|
||||
}(),
|
||||
inspect: func(ctx context.Context, resolved report.Resolved, executor *inspectionExecutor) error {
|
||||
_, err := InspectComparisonExecution(ctx, ComparisonInspectionRequest{Resolved: resolved, ProfileIDs: []string{"weather-light", "weather-deep"}, Executor: executor})
|
||||
return err
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
executor := &inspectionExecutor{}
|
||||
err := test.inspect(context.Background(), test.resolved, executor)
|
||||
if err == nil || promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
|
||||
t.Fatalf("inspection error/category = %v/%q, want invalid configuration", err, promptexec.CategoryOf(err))
|
||||
}
|
||||
if len(executor.promptRequests) != 0 || len(executor.profileRequests) != 0 || executor.executeRequests != 0 {
|
||||
t.Fatalf("incompatible catalog performed executor work: prompts %#v profiles %#v executions %d", executor.promptRequests, executor.profileRequests, executor.executeRequests)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectComparisonExecutionPreservesOrderedExplicitProfiles(t *testing.T) {
|
||||
resolved := inspectionResolved(t)
|
||||
executor := &inspectionExecutor{
|
||||
prompt: validPromptInspection(resolved.Definition),
|
||||
profiles: map[string]promptexec.ProfileInspection{
|
||||
"weather-light": {ProfileID: "weather-light", BackendID: "local", ModelName: "light-model"},
|
||||
"weather-deep": {ProfileID: "weather-deep", BackendID: "cloud", ModelName: "deep-model"},
|
||||
},
|
||||
}
|
||||
profileIDs := []string{"weather-light", "weather-deep"}
|
||||
result, err := InspectComparisonExecution(context.Background(), ComparisonInspectionRequest{
|
||||
Resolved: resolved, ProfileIDs: profileIDs, Executor: executor,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InspectComparisonExecution() error = %v", err)
|
||||
}
|
||||
if result.PromptID != resolved.Definition.PromptID || result.PromptVersion != resolved.Definition.PromptVersion || result.PromptHash != "prompt-hash" {
|
||||
t.Fatalf("prompt result = %#v", result)
|
||||
}
|
||||
if !reflect.DeepEqual(executor.profileRequests, profileIDs) || len(executor.promptRequests) != 1 || executor.executeRequests != 0 {
|
||||
t.Fatalf("prompt/profile/execute requests = %#v/%#v/%d", executor.promptRequests, executor.profileRequests, executor.executeRequests)
|
||||
}
|
||||
wantProfiles := []ComparisonProfileInspection{
|
||||
{ProfileID: "weather-light", BackendID: "local", ModelName: "light-model"},
|
||||
{ProfileID: "weather-deep", BackendID: "cloud", ModelName: "deep-model"},
|
||||
}
|
||||
if !reflect.DeepEqual(result.Profiles, wantProfiles) {
|
||||
t.Fatalf("profiles = %#v, want %#v", result.Profiles, wantProfiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectComparisonExecutionRejectsInvalidProfilesBeforeInspection(t *testing.T) {
|
||||
resolved := inspectionResolved(t)
|
||||
for _, profileIDs := range [][]string{
|
||||
{"weather-light"},
|
||||
{"weather-light", " \t"},
|
||||
{"weather-light", "weather-light"},
|
||||
} {
|
||||
t.Run(strings.Join(profileIDs, ","), func(t *testing.T) {
|
||||
executor := &inspectionExecutor{prompt: validPromptInspection(resolved.Definition)}
|
||||
_, err := InspectComparisonExecution(context.Background(), ComparisonInspectionRequest{
|
||||
Resolved: resolved, ProfileIDs: profileIDs, Executor: executor,
|
||||
})
|
||||
if err == nil || promptexec.CategoryOf(err) != promptexec.InvalidRequest {
|
||||
t.Fatalf("error/category = %v/%q, want invalid request", err, promptexec.CategoryOf(err))
|
||||
}
|
||||
if len(executor.promptRequests) != 0 || len(executor.profileRequests) != 0 || executor.executeRequests != 0 {
|
||||
t.Fatalf("invalid profile selection performed prompt/profile/execution work: %#v/%#v/%d", executor.promptRequests, executor.profileRequests, executor.executeRequests)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectComparisonExecutionStopsAtFirstProfileFailure(t *testing.T) {
|
||||
resolved := inspectionResolved(t)
|
||||
executor := &inspectionExecutor{
|
||||
prompt: validPromptInspection(resolved.Definition),
|
||||
profiles: map[string]promptexec.ProfileInspection{
|
||||
"weather-light": {ProfileID: "weather-light", BackendID: "local", ModelName: "light-model"},
|
||||
"missing-key": {ProfileID: "missing-key", APIKeyEnv: "PROMPT_API_KEY"},
|
||||
"weather-deep": {ProfileID: "weather-deep", BackendID: "cloud", ModelName: "deep-model"},
|
||||
},
|
||||
}
|
||||
result, err := InspectComparisonExecution(context.Background(), ComparisonInspectionRequest{
|
||||
Resolved: resolved, ProfileIDs: []string{"weather-light", "missing-key", "weather-deep"}, Executor: executor,
|
||||
LookupEnv: func(string) (string, bool) { return "", false },
|
||||
})
|
||||
if err == nil || promptexec.CategoryOf(err) != promptexec.MissingCredential {
|
||||
t.Fatalf("error/category = %v/%q, want missing credential", err, promptexec.CategoryOf(err))
|
||||
}
|
||||
if !reflect.DeepEqual(executor.profileRequests, []string{"weather-light", "missing-key"}) || len(executor.promptRequests) != 1 || executor.executeRequests != 0 {
|
||||
t.Fatalf("prompt/profile/execute requests = %#v/%#v/%d", executor.promptRequests, executor.profileRequests, executor.executeRequests)
|
||||
}
|
||||
if result.PromptID != resolved.Definition.PromptID || result.PromptVersion != resolved.Definition.PromptVersion || result.PromptHash == "" || len(result.Profiles) != 1 || result.Profiles[0].ProfileID != "weather-light" {
|
||||
t.Fatalf("partial inspection result = %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectComparisonExecutionStopsBeforeProfileInspectionWhenPromptFails(t *testing.T) {
|
||||
resolved := inspectionResolved(t)
|
||||
executor := &inspectionExecutor{promptErr: promptexec.NewError(promptexec.PromptNotFound, "prompt is unavailable", nil)}
|
||||
_, err := InspectComparisonExecution(context.Background(), ComparisonInspectionRequest{
|
||||
Resolved: resolved, ProfileIDs: []string{"weather-light", "weather-deep"}, Executor: executor,
|
||||
})
|
||||
if err == nil || promptexec.CategoryOf(err) != promptexec.PromptNotFound || !strings.Contains(err.Error(), "comparison prompt") {
|
||||
t.Fatalf("error/category = %v/%q, want prompt-context prompt not found", err, promptexec.CategoryOf(err))
|
||||
}
|
||||
if len(executor.promptRequests) != 1 || len(executor.profileRequests) != 0 || executor.executeRequests != 0 {
|
||||
t.Fatalf("prompt/profile/execute requests = %#v/%#v/%d", executor.promptRequests, executor.profileRequests, executor.executeRequests)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
executeRequests int
|
||||
}
|
||||
|
||||
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) {
|
||||
e.executeRequests++
|
||||
return nil, errors.New("unexpected execution")
|
||||
}
|
||||
|
||||
func inspectionResolved(t *testing.T) report.Resolved {
|
||||
return inspectionResolvedFor(t, report.Daily)
|
||||
}
|
||||
|
||||
func inspectionResolvedFor(t *testing.T, id report.ID) report.Resolved {
|
||||
t.Helper()
|
||||
request := report.ResolveRequest{Now: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC), Location: time.UTC}
|
||||
if id == report.Daily {
|
||||
request.Date = time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
resolved, err := report.DefaultRegistry().Resolve(id, request)
|
||||
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
|
||||
}
|
||||
74
internal/app/prompt_profile_integration_test.go
Normal file
74
internal/app/prompt_profile_integration_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
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
|
||||
backend: openrouter
|
||||
model: local-weather
|
||||
`)})
|
||||
if err != nil {
|
||||
t.Fatalf("New(override) error = %v", err)
|
||||
}
|
||||
inspect(t, override, report.Hourly, "", "weather-light", "openrouter", "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)
|
||||
}
|
||||
}
|
||||
@@ -16,20 +16,24 @@ type AlertDigestModule struct {
|
||||
}
|
||||
|
||||
type AlertSummary struct {
|
||||
Event string `json:"event,omitempty"`
|
||||
Headline string `json:"headline,omitempty"`
|
||||
Severity string `json:"severity,omitempty"`
|
||||
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)
|
||||
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) *AlertDigestModule {
|
||||
func alertDigest(collected facts.CollectedFacts, overlaps []forecast.AlertOverlap, timezone string) *AlertDigestModule {
|
||||
missing := sourceMissing(collected.SourceProvenance, "alerts")
|
||||
if collected.Alerts == nil && !missing {
|
||||
return nil
|
||||
@@ -42,9 +46,13 @@ func alertDigest(collected facts.CollectedFacts, overlaps []forecast.AlertOverla
|
||||
value.RelevantCount = len(overlaps)
|
||||
for _, overlap := range overlaps {
|
||||
value.Relevant = append(value.Relevant, AlertSummary{
|
||||
Event: overlap.Event,
|
||||
Headline: overlap.Headline,
|
||||
Severity: overlap.Severity,
|
||||
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
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"provenance": {
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.spc.noaa.gov/about/outlooks/",
|
||||
"updated_on": "2026-03-03",
|
||||
"applies_to": "categorical outlook descriptions"
|
||||
},
|
||||
{
|
||||
"url": "https://www.spc.noaa.gov/exper/conditional-intensity-information",
|
||||
"updated_on": "2026-02-04",
|
||||
"applies_to": "conditional intensity group descriptions"
|
||||
}
|
||||
],
|
||||
"reviewed_on": "2026-08-13",
|
||||
"review_owner": "Weatherreporter maintainers",
|
||||
"review_schedule": "Review annually and whenever SPC updates either referenced page."
|
||||
},
|
||||
"definitions": {
|
||||
"categorical:TSTM": {
|
||||
"plain_language": "General or non-severe thunderstorms.",
|
||||
"official_description": "Encloses a 10% or higher probability of thunderstorms.",
|
||||
"relative_level": "0 of 5"
|
||||
},
|
||||
"categorical:MRGL": {
|
||||
"plain_language": "Isolated severe storms possible.",
|
||||
"official_description": "Includes severe storms of either limited organization and longevity or very low coverage.",
|
||||
"relative_level": "1 of 5"
|
||||
},
|
||||
"categorical:SLGT": {
|
||||
"plain_language": "Scattered severe storms possible.",
|
||||
"official_description": "Implies organized severe thunderstorms are expected, but usually in low coverage with varying levels of intensity.",
|
||||
"relative_level": "2 of 5"
|
||||
},
|
||||
"categorical:ENH": {
|
||||
"plain_language": "Numerous severe storms possible.",
|
||||
"official_description": "Depicts a greater concentration of organized severe thunderstorms with varying levels of intensity.",
|
||||
"relative_level": "3 of 5"
|
||||
},
|
||||
"categorical:MDT": {
|
||||
"plain_language": "Widespread severe storms likely.",
|
||||
"official_description": "Indicates potential for widespread severe weather with several tornadoes and/or numerous severe thunderstorms, some of which may be intense.",
|
||||
"relative_level": "4 of 5"
|
||||
},
|
||||
"categorical:HIGH": {
|
||||
"plain_language": "Major severe outbreak expected.",
|
||||
"official_description": "Suggests a severe weather outbreak is expected from either numerous intense to violent long-track tornadoes or a long-lived derecho system with hurricane-force wind gusts producing widespread damage.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,21 +59,24 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
|
||||
t.Fatalf("HourlyForecast = %#v, want hourly metadata and one valid-period period", value)
|
||||
}
|
||||
period := value.Periods[0]
|
||||
if period.TextDescription != "Showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 {
|
||||
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.StartTime != "2026-05-29 at 8:00 AM" || period.EndTime != "2026-05-29 at 9:00 AM" {
|
||||
t.Fatalf("HourlyForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime)
|
||||
if period.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", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} {
|
||||
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)
|
||||
}
|
||||
@@ -81,22 +84,100 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
|
||||
if strings.Contains(jsonText, "wind_direction_degrees") || strings.Contains(jsonText, "Tomorrow") {
|
||||
t.Fatalf("hourly json = %s, want valid-period prompt fields only", jsonText)
|
||||
}
|
||||
if strings.Contains(jsonText, `"start_time":"2026-05-29T`) || strings.Contains(jsonText, `"end_time":"2026-05-29T`) {
|
||||
t.Fatalf("hourly json = %s, want friendly local start/end times", jsonText)
|
||||
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", 20)
|
||||
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.DefaultRegistry().MustLookup(report.Weekend)
|
||||
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 "weekend"`) {
|
||||
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()
|
||||
@@ -113,8 +194,8 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
||||
if period.Name != "Today" || period.TextDescription != "Morning storms, then partly sunny." {
|
||||
t.Fatalf("NarrativeForecast period = %#v, want Today narrative", period)
|
||||
}
|
||||
if period.StartTime != "2026-05-29 at 6:00 AM" || period.EndTime != "2026-05-29 at 6:00 PM" {
|
||||
t.Fatalf("NarrativeForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime)
|
||||
if period.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)
|
||||
@@ -127,7 +208,7 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
||||
t.Fatalf("Marshal narrative forecast: %v", err)
|
||||
}
|
||||
jsonText := string(data)
|
||||
for _, field := range []string{"source_location_id", "text_description", "temperature_f", "wind_speed_mph", "wind_direction", "probability_of_precipitation_percent"} {
|
||||
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)
|
||||
}
|
||||
@@ -135,8 +216,8 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
||||
if strings.Contains(jsonText, "wind_direction_degrees") {
|
||||
t.Fatalf("narrative json = %s, want compass wind_direction without degrees field", jsonText)
|
||||
}
|
||||
if strings.Contains(jsonText, `"start_time":"2026-05-29T`) || strings.Contains(jsonText, `"end_time":"2026-05-29T`) {
|
||||
t.Fatalf("narrative json = %s, want friendly local start/end times", jsonText)
|
||||
if strings.Contains(jsonText, `"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)
|
||||
@@ -146,10 +227,10 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
||||
func TestNarrativeForecastModuleRejectsUnsupportedReports(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Weekend)
|
||||
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 "weekend"`) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -163,7 +244,7 @@ func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[MetadataModule](t, output)
|
||||
if value.RunID == "" || value.ReportID != report.DailyToday || value.PromptID != "weather.daily_report" {
|
||||
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" {
|
||||
@@ -172,9 +253,6 @@ func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
|
||||
if len(value.SourceWarnings) != 1 || value.SourceWarnings[0].CompletenessImpact != "source omitted" {
|
||||
t.Fatalf("SourceWarnings = %#v, want warning summary", value.SourceWarnings)
|
||||
}
|
||||
if value.Alerts == nil || !value.Alerts.Checked || value.Alerts.ActiveCount != 1 || value.Alerts.RelevantCount != 1 {
|
||||
t.Fatalf("Alerts = %#v, want checked alert status", value.Alerts)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal metadata: %v", err)
|
||||
@@ -183,6 +261,9 @@ func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
|
||||
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) {
|
||||
@@ -194,18 +275,24 @@ func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[CurrentConditionsModule](t, output)
|
||||
if value.ConditionText != "Partly cloudy" || value.TemperatureF == nil || *value.TemperatureF != 74 {
|
||||
t.Fatalf("CurrentConditions = %#v, want current condition facts", value)
|
||||
if value.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", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction"} {
|
||||
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)
|
||||
}
|
||||
@@ -215,6 +302,44 @@ func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -242,6 +367,39 @@ func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -285,38 +443,139 @@ func TestAreaForecastDiscussionAndWeatherStoryModules(t *testing.T) {
|
||||
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") {
|
||||
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"}},
|
||||
})
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
options any
|
||||
}{
|
||||
{name: "value", options: module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}}},
|
||||
{name: "pointer", options: &module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}}},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{
|
||||
ID: module.AreaForecastDiscussion,
|
||||
Options: tt.options,
|
||||
})
|
||||
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 TestWeatherStoryModuleOmitsEmptyContent(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Collected.WeatherStory = &weatherdata.WeatherStory{OfficeID: "LSX", Priority: true, Order: 1}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.WeatherStory})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
if output != nil {
|
||||
t.Fatalf("output = %#v, want omitted weather story", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeatherStoryModulePreservesZeroPriorityAndOrder(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Collected.WeatherStory = &weatherdata.WeatherStory{Title: "Rain Chances"}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.WeatherStory})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
story := moduleValue[WeatherStoryModule](t, output)
|
||||
if !story.Available || story.Priority || story.Order != 0 {
|
||||
t.Fatalf("WeatherStory = %#v, want available story with zero priority and order", story)
|
||||
}
|
||||
}
|
||||
|
||||
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 afd.ShortTerm != "Showers increase this afternoon." {
|
||||
t.Fatalf("ShortTerm = %q, want selected short term section", afd.ShortTerm)
|
||||
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 != "" || len(afd.KeyMessages) != 0 || afd.LongTerm != "" {
|
||||
t.Fatalf("AFD = %#v, want only short_term section", 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.DailyToday)
|
||||
definition := report.DefaultRegistry().MustLookup(report.Daily)
|
||||
resolved := report.Resolved{
|
||||
Definition: definition,
|
||||
GeneratedAt: generatedAt,
|
||||
@@ -327,10 +586,10 @@ func testModuleContext() ModuleContext {
|
||||
},
|
||||
}
|
||||
isDay := true
|
||||
tempF := 74.0
|
||||
apparentF := 76.0
|
||||
humidity := 71.0
|
||||
windMph := 8.0
|
||||
tempF := 74.4
|
||||
apparentF := 75.6
|
||||
humidity := 70.6
|
||||
windMph := 8.4
|
||||
windDirection := 190.0
|
||||
narrativeTempF := 81.0
|
||||
narrativePop := 60.0
|
||||
@@ -341,7 +600,7 @@ func testModuleContext() ModuleContext {
|
||||
hourlyHumidity := 66.0
|
||||
hourlyWindMph := 14.0
|
||||
updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00")
|
||||
return ModuleContext{
|
||||
ctx := ModuleContext{
|
||||
Resolved: resolved,
|
||||
Collected: facts.CollectedFacts{
|
||||
Current: &weatherdata.Current{
|
||||
@@ -469,6 +728,14 @@ func testModuleContext() ModuleContext {
|
||||
Timezone: "America/Chicago",
|
||||
},
|
||||
}
|
||||
ctx.Identity = BuildPreparedIdentity(BuildContext{
|
||||
Resolved: ctx.Resolved,
|
||||
Bundle: ctx.Collected.Bundle(),
|
||||
Units: ctx.Units,
|
||||
Timezone: ctx.Timezone,
|
||||
Location: ctx.Location,
|
||||
})
|
||||
return ctx
|
||||
}
|
||||
|
||||
func moduleValue[T any](t *testing.T, output *module.Output) T {
|
||||
@@ -484,6 +751,28 @@ func moduleValue[T any](t *testing.T, output *module.Output) T {
|
||||
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 {
|
||||
|
||||
@@ -1,20 +1,41 @@
|
||||
package briefing
|
||||
|
||||
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
)
|
||||
|
||||
type CurrentConditionsModule struct {
|
||||
ConditionText string `json:"condition_text,omitempty"`
|
||||
IsDay *bool `json:"is_day,omitempty"`
|
||||
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||
TemperatureF *float64 `json:"temperature_f,omitempty"`
|
||||
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"`
|
||||
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"`
|
||||
DewpointC *float64 `json:"dewpoint_c,omitempty"`
|
||||
DewpointF *float64 `json:"dewpoint_f,omitempty"`
|
||||
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
|
||||
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
|
||||
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
|
||||
WindDirection string `json:"wind_direction,omitempty"`
|
||||
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) {
|
||||
@@ -24,17 +45,19 @@ func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, err
|
||||
}
|
||||
value := CurrentConditionsModule{
|
||||
ConditionText: current.ConditionText,
|
||||
ConditionTextLower: strings.ToLower(current.ConditionText),
|
||||
IsDay: copyBool(current.IsDay),
|
||||
TemperatureC: copyFloat(current.TemperatureC),
|
||||
TemperatureF: copyFloat(current.TemperatureF),
|
||||
ApparentTemperatureC: copyFloat(current.ApparentTemperatureC),
|
||||
ApparentTemperatureF: copyFloat(current.ApparentTemperatureF),
|
||||
DewpointC: copyFloat(current.DewpointC),
|
||||
DewpointF: copyFloat(current.DewpointF),
|
||||
RelativeHumidityPercent: copyFloat(current.RelativeHumidityPercent),
|
||||
WindSpeedKmh: copyFloat(current.WindSpeedKmh),
|
||||
WindSpeedMph: copyFloat(current.WindSpeedMph),
|
||||
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
|
||||
@@ -42,8 +65,30 @@ func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, err
|
||||
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 &&
|
||||
@@ -54,5 +99,6 @@ func (v CurrentConditionsModule) isEmpty() bool {
|
||||
v.RelativeHumidityPercent == nil &&
|
||||
v.WindSpeedKmh == nil &&
|
||||
v.WindSpeedMph == nil &&
|
||||
v.WindDirection == ""
|
||||
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
|
||||
}
|
||||
@@ -16,7 +16,7 @@ type DerivedDailySummaryModule struct {
|
||||
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"`
|
||||
ApparentTemperatureMaxF *int `json:"apparent_temperature_max_f,omitempty"`
|
||||
DominantConditions []string `json:"dominant_conditions,omitempty"`
|
||||
Hazards []string `json:"hazards,omitempty"`
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.Pre
|
||||
} else {
|
||||
value.LowTempF = roundedInt(temperature.Min)
|
||||
}
|
||||
value.HeatIndexMaxF = roundedInt(apparent.Max)
|
||||
value.ApparentTemperatureMaxF = roundedInt(apparent.Max)
|
||||
narrativePrecipitation := narrativeMaxPrecipitation(summary.NarrativePeriods)
|
||||
if narrativePrecipitation != nil {
|
||||
value.DailyPrecipitationProbability = roundedInt(&narrativePrecipitation.Value)
|
||||
|
||||
@@ -2,32 +2,73 @@ 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"`
|
||||
Period string `json:"period,omitempty"`
|
||||
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
|
||||
DominantCondition string `json:"dominant_condition,omitempty"`
|
||||
NotableConditions []string `json:"notable_conditions,omitempty"`
|
||||
Snow bool `json:"snow,omitempty"`
|
||||
Ice bool `json:"ice,omitempty"`
|
||||
Fog bool `json:"fog,omitempty"`
|
||||
Heat bool `json:"heat,omitempty"`
|
||||
Cold bool `json:"cold,omitempty"`
|
||||
Wind bool `json:"wind,omitempty"`
|
||||
RelevantAlertCount int `json:"relevant_alert_count,omitempty"`
|
||||
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) {
|
||||
@@ -38,30 +79,92 @@ func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Outpu
|
||||
prefixDates := multipleSummaryDates(ctx.Derived.DailySummaries)
|
||||
for _, daypart := range ctx.Derived.DaypartSummaries {
|
||||
key := daypartKey(daypart, prefixDates)
|
||||
if existing, exists := value[key]; exists {
|
||||
return nil, fmt.Errorf("daypart summary key %q collides with display name %q", key, existing.DisplayName)
|
||||
}
|
||||
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),
|
||||
Period: friendlyPeriodLabel(daypart.Period, timezone),
|
||||
TempRangeF: rangeLabel(daypart.Temperature),
|
||||
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
|
||||
DominantCondition: daypart.DominantCondition,
|
||||
NotableConditions: append([]string(nil), daypart.NotableConditions...),
|
||||
Snow: daypart.Indicators.Snow,
|
||||
Ice: daypart.Indicators.Ice,
|
||||
Fog: daypart.Indicators.Fog,
|
||||
Heat: daypart.Indicators.Heat,
|
||||
Cold: daypart.Indicators.Cold,
|
||||
Wind: daypart.Indicators.Wind,
|
||||
RelevantAlertCount: len(daypart.AlertOverlaps),
|
||||
Date: localDateLabel(daypart.Period.Start, timezone),
|
||||
DisplayName: capitalizeFirst(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: capitalizeFirst(strings.TrimSpace(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, hourlyForecastPrecipMentionProbabilityThreshold)
|
||||
}
|
||||
if daypart.PeakWindGust != nil {
|
||||
value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value)
|
||||
@@ -70,6 +173,216 @@ func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string
|
||||
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, remainder := temperatureBandParts(value)
|
||||
band := temperatureBandQualifierIndex(remainder)
|
||||
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 {
|
||||
if value < 0 {
|
||||
decade, remainder := temperatureBandParts(-value)
|
||||
qualifier := temperatureBandQualifier(remainder)
|
||||
if decade == 0 {
|
||||
return fmt.Sprintf("%s single digits below zero", qualifier)
|
||||
}
|
||||
return fmt.Sprintf("%s %ds below zero", qualifier, decade)
|
||||
}
|
||||
decade, remainder := temperatureBandParts(value)
|
||||
qualifier := temperatureBandQualifier(remainder)
|
||||
return fmt.Sprintf("%s %ds", qualifier, decade)
|
||||
}
|
||||
|
||||
func temperatureBandParts(value int) (int, int) {
|
||||
decade := value / 10
|
||||
if value < 0 && value%10 != 0 {
|
||||
decade--
|
||||
}
|
||||
return decade * 10, value - decade*10
|
||||
}
|
||||
|
||||
func temperatureBandQualifierIndex(remainder int) int {
|
||||
switch {
|
||||
case remainder <= 3:
|
||||
return 0
|
||||
case remainder >= 7:
|
||||
return 2
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func temperatureBandQualifier(remainder int) string {
|
||||
switch temperatureBandQualifierIndex(remainder) {
|
||||
case 0:
|
||||
return "low"
|
||||
case 2:
|
||||
return "upper"
|
||||
default:
|
||||
return "mid"
|
||||
}
|
||||
}
|
||||
|
||||
func multipleSummaryDates(summaries []forecast.DailySummary) bool {
|
||||
seen := map[string]struct{}{}
|
||||
for _, summary := range summaries {
|
||||
@@ -79,7 +392,7 @@ func multipleSummaryDates(summaries []forecast.DailySummary) bool {
|
||||
}
|
||||
|
||||
func daypartKey(daypart forecast.DaypartSummary, prefixDate bool) string {
|
||||
key := normalizedKey(daypart.Name)
|
||||
key := forecast.CanonicalDaypartKey(daypart.Name)
|
||||
if key == "" {
|
||||
key = "unnamed"
|
||||
}
|
||||
@@ -88,21 +401,3 @@ func daypartKey(daypart forecast.DaypartSummary, prefixDate bool) string {
|
||||
}
|
||||
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(), "_")
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
|
||||
func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.DailyToday)
|
||||
ctx := derivedModuleContext(report.Daily)
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
|
||||
if err != nil {
|
||||
@@ -42,19 +42,22 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
||||
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)
|
||||
if value.ApparentTemperatureMaxF == nil || *value.ApparentTemperatureMaxF != 101 {
|
||||
t.Fatalf("ApparentTemperatureMaxF = %#v, want 101", value.ApparentTemperatureMaxF)
|
||||
}
|
||||
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"} {
|
||||
for _, field := range []string{"high_temp_f", "low_temp_f", "daily_precipitation_probability", "most_likely_precipitation_hour", "apparent_temperature_max_f"} {
|
||||
if !strings.Contains(jsonText, field) {
|
||||
t.Fatalf("daily json = %s, want field %s", jsonText, field)
|
||||
}
|
||||
}
|
||||
if strings.Contains(jsonText, "heat_index") {
|
||||
t.Fatalf("daily json = %s, want no heat-index label for generic apparent temperature", jsonText)
|
||||
}
|
||||
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)
|
||||
@@ -65,9 +68,57 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedDailySummaryPreservesApparentTemperatureMeaning(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
temperature float64
|
||||
want int
|
||||
}{
|
||||
{name: "hot", temperature: 101, want: 101},
|
||||
{name: "mild", temperature: 63, want: 63},
|
||||
{name: "below freezing", temperature: -12, want: -12},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
value, err := derivedDailySummaryValue(forecast.DailySummary{
|
||||
Date: "2026-05-29",
|
||||
Dayparts: []forecast.DaypartSummary{{
|
||||
ApparentTemperature: forecast.Range{Max: floatPtr(tt.temperature)},
|
||||
}},
|
||||
}, forecast.PrecipTiming{}, "America/Chicago")
|
||||
if err != nil {
|
||||
t.Fatalf("derivedDailySummaryValue() error = %v", err)
|
||||
}
|
||||
if value.ApparentTemperatureMaxF == nil || *value.ApparentTemperatureMaxF != tt.want {
|
||||
t.Fatalf("ApparentTemperatureMaxF = %#v, want %d", value.ApparentTemperatureMaxF, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedDailySummaryLabelsMetricApparentTemperature(t *testing.T) {
|
||||
start := mustParseModuleTime("2026-05-29T12:00:00-05:00")
|
||||
period := timeutil.Period{Start: start, End: start.Add(time.Hour)}
|
||||
daypart := forecast.SummarizeDaypart("afternoon", period, []weatherdata.ForecastPeriod{{
|
||||
StartTime: period.Start,
|
||||
EndTime: period.End,
|
||||
TemperatureC: floatPtr(20),
|
||||
ApparentTemperatureC: floatPtr(20),
|
||||
}})
|
||||
value, err := derivedDailySummaryValue(forecast.DailySummary{
|
||||
Date: "2026-05-29",
|
||||
Dayparts: []forecast.DaypartSummary{daypart},
|
||||
}, forecast.PrecipTiming{}, "America/Chicago")
|
||||
if err != nil {
|
||||
t.Fatalf("derivedDailySummaryValue() error = %v", err)
|
||||
}
|
||||
if value.ApparentTemperatureMaxF == nil || *value.ApparentTemperatureMaxF != 68 {
|
||||
t.Fatalf("ApparentTemperatureMaxF = %#v, want converted 68", value.ApparentTemperatureMaxF)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.DailyToday)
|
||||
ctx := derivedModuleContext(report.Daily)
|
||||
ctx.Derived.DailySummaries[0].NarrativePeriods = nil
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
|
||||
@@ -89,32 +140,44 @@ func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
|
||||
|
||||
func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.DailyToday)
|
||||
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 {
|
||||
if rainy.MaxPopPercent == nil || *rainy.MaxPopPercent != 80 || rainy.MaxPopTime != "12 PM" || rainy.ProbabilityThreshold != 40 || !rainy.ThunderMentioned {
|
||||
t.Fatalf("rainy precip timing = %#v, want peak, threshold, and thunder", rainy)
|
||||
}
|
||||
if len(rainy.PrecipitationWindows) != 2 {
|
||||
t.Fatalf("rainy precipitation windows = %#v, want two windows", rainy.PrecipitationWindows)
|
||||
}
|
||||
if rainy.PrecipitationWindows[0].Start != "8 AM" || rainy.PrecipitationWindows[0].End != "9 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 {
|
||||
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[1].Start != "12 PM" || rainy.PrecipitationWindows[1].End != "2 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 {
|
||||
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") {
|
||||
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))
|
||||
}
|
||||
@@ -133,9 +196,119 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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: 40,
|
||||
PrecipitationWindows: []forecast.PrecipitationWindow{
|
||||
{
|
||||
Start: now,
|
||||
MaxPrecipitationProbability: forecast.TimedValue{
|
||||
Value: tt.maxPop,
|
||||
Time: now,
|
||||
},
|
||||
ProbabilityThreshold: 40,
|
||||
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.DailyToday)
|
||||
ctx := derivedModuleContext(report.Daily)
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
|
||||
if err != nil {
|
||||
@@ -150,14 +323,20 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
||||
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 {
|
||||
t.Fatalf("morning = %#v, want temp range and precip peak", morning)
|
||||
}
|
||||
if morning.Date != "2026-05-29" || morning.Period != "2026-05-29 at 6:00 AM to 2026-05-29 at 12:00 PM" {
|
||||
t.Fatalf("morning period = %q/%q, want friendly local date and period labels", morning.Date, morning.Period)
|
||||
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)
|
||||
}
|
||||
overnight := value["overnight"]
|
||||
if !overnight.Cold {
|
||||
t.Fatalf("overnight = %#v, want cold hazard", overnight)
|
||||
}
|
||||
@@ -166,19 +345,439 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
||||
t.Fatalf("marshal daypart summaries: %v", err)
|
||||
}
|
||||
jsonText := string(data)
|
||||
for _, field := range []string{"date", "period", "temp_range_f", "max_pop_percent", "max_wind_gust_mph", "dominant_condition"} {
|
||||
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":{"start"`) || strings.Contains(jsonText, `T06:00:00`) {
|
||||
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 TestDerivedDaypartSummariesRejectCanonicalKeyCollisions(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.Daily)
|
||||
first := ctx.Derived.DaypartSummaries[0]
|
||||
first.Name = "Morning"
|
||||
second := first
|
||||
second.Name = "morning!"
|
||||
ctx.Derived.DaypartSummaries = []forecast.DaypartSummary{first, second}
|
||||
ctx.Derived.DailySummaries = []forecast.DailySummary{{Date: first.Period.Start.Format(timeutil.DateLayout)}}
|
||||
|
||||
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
|
||||
if err == nil || !strings.Contains(err.Error(), "collides") {
|
||||
t.Fatalf("BuildModule() error = %v, want canonical daypart-key collision", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedDaypartSummaryDisplayCapitalizesUnicodeNames(t *testing.T) {
|
||||
value := derivedDaypartSummaryValue(forecast.DaypartSummary{
|
||||
Name: "mañana",
|
||||
DominantCondition: "llovizna",
|
||||
}, "UTC")
|
||||
if value.DisplayName != "Mañana" || value.DominantConditionDisplay != "Llovizna" {
|
||||
t.Fatalf("daypart display = %#v, want rune-safe capitalization", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedDaypartSummariesKeepDistinctUnicodeKeys(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.Daily)
|
||||
first := ctx.Derived.DaypartSummaries[0]
|
||||
first.Name = "mañana"
|
||||
second := first
|
||||
second.Name = "manana"
|
||||
ctx.Derived.DaypartSummaries = []forecast.DaypartSummary{first, second}
|
||||
ctx.Derived.DailySummaries = []forecast.DailySummary{{Date: first.Period.Start.Format(timeutil.DateLayout)}}
|
||||
|
||||
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)
|
||||
if len(value) != 2 || value["mañana"].DisplayName != "Mañana" || value["manana"].DisplayName != "Manana" {
|
||||
t.Fatalf("daypart summaries = %#v, want distinct Unicode canonical keys", value)
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
{
|
||||
name: "rising across zero",
|
||||
temps: []float64{-5, 5},
|
||||
wantTrend: "rising",
|
||||
wantStart: "mid single digits below zero",
|
||||
wantEnd: "mid 0s",
|
||||
},
|
||||
{
|
||||
name: "falling across zero",
|
||||
temps: []float64{5, -5},
|
||||
wantTrend: "falling",
|
||||
wantStart: "mid 0s",
|
||||
wantEnd: "mid single digits below zero",
|
||||
},
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
{
|
||||
name: "rising across zero",
|
||||
temps: []float64{-5, 5},
|
||||
wantTrend: "rising",
|
||||
wantStart: "mid single digits below zero",
|
||||
wantEnd: "mid 0s",
|
||||
},
|
||||
{
|
||||
name: "falling across zero",
|
||||
temps: []float64{5, -5},
|
||||
wantTrend: "falling",
|
||||
wantStart: "mid 0s",
|
||||
wantEnd: "mid single digits below zero",
|
||||
},
|
||||
}
|
||||
|
||||
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: "below zero range",
|
||||
value: forecast.Range{Min: floatPtr(-9), Max: floatPtr(-1)},
|
||||
want: "upper single digits below zero to low single digits below zero",
|
||||
},
|
||||
{
|
||||
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 TestTemperatureBandIndexPreservesSignedOrder(t *testing.T) {
|
||||
values := []int{-11, -10, -9, -5, -1, 0, 1, 9}
|
||||
previous := temperatureBandIndex(values[0])
|
||||
for _, value := range values[1:] {
|
||||
current := temperatureBandIndex(value)
|
||||
if current < previous {
|
||||
t.Fatalf("temperatureBandIndex(%d) = %d, want at least %d", value, current, previous)
|
||||
}
|
||||
previous = current
|
||||
}
|
||||
for _, tt := range []struct {
|
||||
value int
|
||||
want string
|
||||
}{
|
||||
{value: -11, want: "low 10s below zero"},
|
||||
{value: -10, want: "low 10s below zero"},
|
||||
{value: -9, want: "upper single digits below zero"},
|
||||
{value: -5, want: "mid single digits below zero"},
|
||||
{value: -1, want: "low single digits below zero"},
|
||||
{value: 0, want: "low 0s"},
|
||||
{value: 1, want: "low 0s"},
|
||||
{value: 9, want: "upper 0s"},
|
||||
} {
|
||||
if got := temperatureBandPhrase(tt.value); got != tt.want {
|
||||
t.Fatalf("temperatureBandPhrase(%d) = %q, want %q", tt.value, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutdoorWindowsScoreSnowIceAndFog(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
indicators forecast.Indicators
|
||||
reason string
|
||||
}{
|
||||
{name: "snow", indicators: forecast.Indicators{Snow: true}, reason: "snow risk"},
|
||||
{name: "ice", indicators: forecast.Indicators{Ice: true}, reason: "ice risk"},
|
||||
{name: "fog", indicators: forecast.Indicators{Fog: true}, reason: "fog risk"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
window := scoreOutdoorWindow(forecast.DaypartSummary{Name: tt.name, Indicators: tt.indicators})
|
||||
if window.Score != outdoorIndicatorRiskScore || !containsString(window.Reasons, tt.reason) || containsString(window.Reasons, "quiet weather") {
|
||||
t.Fatalf("outdoor window = %#v, want indicator risk without quiet weather", window)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
dayparts := []forecast.DaypartSummary{
|
||||
{Name: "snow", HourlyPeriods: []weatherdata.ForecastPeriod{{}}, Indicators: forecast.Indicators{Snow: true}},
|
||||
{Name: "ice and fog", HourlyPeriods: []weatherdata.ForecastPeriod{{}}, Indicators: forecast.Indicators{Ice: true, Fog: true}},
|
||||
}
|
||||
windows := buildOutdoorWindows(dayparts)
|
||||
if windows.Best == nil || windows.Best.Daypart != "snow" || windows.Worst == nil || windows.Worst.Daypart != "ice and fog" {
|
||||
t.Fatalf("outdoor windows = %#v, want mixed hazards ranked by accumulated risk", windows)
|
||||
}
|
||||
|
||||
tied := buildOutdoorWindows([]forecast.DaypartSummary{
|
||||
{Name: "first", HourlyPeriods: []weatherdata.ForecastPeriod{{}}, Indicators: forecast.Indicators{Snow: true}},
|
||||
{Name: "second", HourlyPeriods: []weatherdata.ForecastPeriod{{}}, Indicators: forecast.Indicators{Ice: true}},
|
||||
})
|
||||
if tied.Best == nil || tied.Best.Daypart != "first" || tied.Worst == nil || tied.Worst.Daypart != "first" {
|
||||
t.Fatalf("tied outdoor windows = %#v, want input-order tie behavior", tied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanningUsesCanonicalDaypartIdentities(t *testing.T) {
|
||||
timedValue := func(value float64) *forecast.TimedValue {
|
||||
return &forecast.TimedValue{Value: value}
|
||||
}
|
||||
containsText := func(values []string, text string) bool {
|
||||
return strings.Contains(strings.Join(values, "\n"), text)
|
||||
}
|
||||
summary := &forecast.DailySummary{Dayparts: []forecast.DaypartSummary{
|
||||
{Name: "Overnight!", MaxPrecipitationProbability: timedValue(60)},
|
||||
{Name: "MORNING", MaxPrecipitationProbability: timedValue(60)},
|
||||
{Name: "Afternoon!!!", MaxPrecipitationProbability: timedValue(60)},
|
||||
{Name: "EVENING!", MaxPrecipitationProbability: timedValue(60)},
|
||||
}}
|
||||
|
||||
today := buildTodayPlanning(summary)
|
||||
if !containsText(today.MorningReadiness, "Morning precipitation chance peaks near 60%.") {
|
||||
t.Fatalf("today morning readiness = %#v, want canonical morning window", today.MorningReadiness)
|
||||
}
|
||||
if !containsText(today.CommuteSchoolWorkdayConcerns, "Afternoon!!! precipitation chance reaches 60%.") ||
|
||||
containsText(today.CommuteSchoolWorkdayConcerns, "Overnight!") ||
|
||||
containsText(today.CommuteSchoolWorkdayConcerns, "EVENING!") {
|
||||
t.Fatalf("today workday concerns = %#v, want only canonical workday windows", today.CommuteSchoolWorkdayConcerns)
|
||||
}
|
||||
if !containsText(today.LateDayChangeWatch, "Afternoon!!! precipitation timing may shift") ||
|
||||
!containsText(today.LateDayChangeWatch, "EVENING! precipitation timing may shift") {
|
||||
t.Fatalf("today late-day watch = %#v, want canonical afternoon and evening windows", today.LateDayChangeWatch)
|
||||
}
|
||||
|
||||
base := buildMorningCommuteOvernightPlanning(summary)
|
||||
if !containsText(base.MorningReadiness, "Morning precipitation chance peaks near 60%.") ||
|
||||
!containsText(base.OvernightChangeWatch, "Overnight precipitation timing may shift") ||
|
||||
containsText(base.CommuteSchoolWorkdayConcerns, "Overnight!") ||
|
||||
containsText(base.CommuteSchoolWorkdayConcerns, "EVENING!") {
|
||||
t.Fatalf("daily/tomorrow planning = %#v, want canonical daypart treatment", base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapitalizeFirst(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{name: "empty", input: "", want: ""},
|
||||
{name: "ASCII", input: "morning", want: "Morning"},
|
||||
{name: "multibyte", input: "mañana", want: "Mañana"},
|
||||
{name: "already uppercase", input: "Morning", want: "Morning"},
|
||||
{name: "leading space", input: " morning", want: " morning"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := capitalizeFirst(tt.input); got != tt.want {
|
||||
t.Fatalf("capitalizeFirst(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.DailyTomorrow)
|
||||
ctx := derivedModuleContext(report.Tomorrow)
|
||||
|
||||
outdoorOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows})
|
||||
if err != nil {
|
||||
@@ -191,6 +790,12 @@ func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing
|
||||
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 {
|
||||
@@ -209,9 +814,154 @@ func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
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.DailyToday)
|
||||
ctx := derivedModuleContext(report.Daily)
|
||||
ctx.Derived.DailySummaries = nil
|
||||
ctx.Derived.DaypartSummaries = nil
|
||||
|
||||
@@ -302,6 +1052,26 @@ func derivedModuleContext(id report.ID) ModuleContext {
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
@@ -310,6 +1080,24 @@ func derivedDaypart(name string, start string, end string, text string, temperat
|
||||
}, []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)
|
||||
|
||||
85
internal/briefing/fact_requirements.go
Normal file
85
internal/briefing/fact_requirements.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package briefing
|
||||
|
||||
type factRequirementCategory string
|
||||
|
||||
const (
|
||||
collectedFactRequirement factRequirementCategory = "collected"
|
||||
derivedFactRequirement factRequirementCategory = "derived"
|
||||
)
|
||||
|
||||
type factRequirement struct {
|
||||
identity string
|
||||
category factRequirementCategory
|
||||
available func(ModuleContext) bool
|
||||
}
|
||||
|
||||
func (r factRequirement) String() string {
|
||||
return r.identity
|
||||
}
|
||||
|
||||
var (
|
||||
currentConditionsRequirement = &factRequirement{identity: "collected.current_conditions", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
|
||||
return ctx.Collected.Current != nil
|
||||
}}
|
||||
narrativeForecastRequirement = &factRequirement{identity: "collected.narrative_forecast", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
|
||||
return ctx.Collected.Narrative != nil
|
||||
}}
|
||||
hourlyForecastRequirement = &factRequirement{identity: "collected.hourly_forecast", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
|
||||
return ctx.Collected.Hourly != nil
|
||||
}}
|
||||
alertsRequirement = &factRequirement{identity: "collected.alerts", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
|
||||
return ctx.Collected.Alerts != nil
|
||||
}}
|
||||
discussionRequirement = &factRequirement{identity: "collected.discussion", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
|
||||
return ctx.Collected.Discussion != nil
|
||||
}}
|
||||
weatherStoryRequirement = &factRequirement{identity: "collected.weather_story", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
|
||||
return ctx.Collected.WeatherStory != nil
|
||||
}}
|
||||
spcOutlooksRequirement = &factRequirement{identity: "collected.spc_convective_outlooks", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
|
||||
return ctx.Collected.SPCConvectiveOutlooks != nil
|
||||
}}
|
||||
sourceMetadataRequirement = &factRequirement{identity: "collected.source_metadata", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
|
||||
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
|
||||
}}
|
||||
|
||||
hourlyPeriodsRequirement = &factRequirement{identity: "derived.hourly_periods", category: derivedFactRequirement, available: func(ctx ModuleContext) bool {
|
||||
return len(ctx.Derived.ValidPeriodHourlyPeriods) > 0
|
||||
}}
|
||||
narrativePeriodsRequirement = &factRequirement{identity: "derived.narrative_periods", category: derivedFactRequirement, available: func(ctx ModuleContext) bool {
|
||||
return len(ctx.Derived.ValidPeriodNarrativePeriods) > 0
|
||||
}}
|
||||
alertOverlapsRequirement = &factRequirement{identity: "derived.alert_overlaps", category: derivedFactRequirement, available: func(ModuleContext) bool {
|
||||
return true
|
||||
}}
|
||||
dailySummariesRequirement = &factRequirement{identity: "derived.daily_summaries", category: derivedFactRequirement, available: func(ctx ModuleContext) bool {
|
||||
return len(ctx.Derived.DailySummaries) > 0
|
||||
}}
|
||||
daypartSummariesRequirement = &factRequirement{identity: "derived.daypart_summaries", category: derivedFactRequirement, available: func(ctx ModuleContext) bool {
|
||||
return len(ctx.Derived.DaypartSummaries) > 0
|
||||
}}
|
||||
precipTimingRequirement = &factRequirement{identity: "derived.precip_timing", category: derivedFactRequirement, available: func(ModuleContext) bool {
|
||||
return true
|
||||
}}
|
||||
spcDerivedOutlooksRequirement = &factRequirement{identity: "derived.spc_convective_outlooks", category: derivedFactRequirement, available: func(ctx ModuleContext) bool {
|
||||
return ctx.Derived.SPCConvectiveOutlooks != nil
|
||||
}}
|
||||
)
|
||||
|
||||
var factRequirementVocabulary = []*factRequirement{
|
||||
currentConditionsRequirement,
|
||||
narrativeForecastRequirement,
|
||||
hourlyForecastRequirement,
|
||||
alertsRequirement,
|
||||
discussionRequirement,
|
||||
weatherStoryRequirement,
|
||||
spcOutlooksRequirement,
|
||||
sourceMetadataRequirement,
|
||||
hourlyPeriodsRequirement,
|
||||
narrativePeriodsRequirement,
|
||||
alertOverlapsRequirement,
|
||||
dailySummariesRequirement,
|
||||
daypartSummariesRequirement,
|
||||
precipTimingRequirement,
|
||||
spcDerivedOutlooksRequirement,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user