Compare commits
153 Commits
473f252aee
...
v0.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -2,8 +2,50 @@ when:
|
|||||||
- event: tag
|
- event: tag
|
||||||
|
|
||||||
steps:
|
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
|
- name: build-release-assets
|
||||||
image: golang:1.25
|
image: golang:1.26.5
|
||||||
|
depends_on:
|
||||||
|
- validate-release
|
||||||
commands:
|
commands:
|
||||||
- |
|
- |
|
||||||
set -eu
|
set -eu
|
||||||
@@ -33,8 +75,11 @@ steps:
|
|||||||
build_binary windows amd64 ".exe"
|
build_binary windows amd64 ".exe"
|
||||||
build_binary windows arm64 ".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
|
- name: publish-release
|
||||||
image: woodpeckerci/plugin-release
|
image: woodpeckerci/plugin-release:0.3.1
|
||||||
depends_on:
|
depends_on:
|
||||||
- build-release-assets
|
- build-release-assets
|
||||||
settings:
|
settings:
|
||||||
@@ -42,6 +87,8 @@ steps:
|
|||||||
from_secret: GITEA_RELEASE_TOKEN
|
from_secret: GITEA_RELEASE_TOKEN
|
||||||
files:
|
files:
|
||||||
- dist/weatherreporter-*
|
- dist/weatherreporter-*
|
||||||
|
title: Weatherreporter ${CI_COMMIT_TAG}
|
||||||
|
note: docs/releases/${CI_COMMIT_TAG}.md
|
||||||
checksum: sha256
|
checksum: sha256
|
||||||
checksum-file: SHA256SUMS
|
checksum-file: SHA256SUMS
|
||||||
checksum-flatten: true
|
checksum-flatten: true
|
||||||
|
|||||||
@@ -1,4 +1 @@
|
|||||||
Please carefully review the documents in `docs/policy` before making any changes to this repository.
|
Please review `docs/development.md` for initial orientation in this repository and follow its task-specific reading guide.
|
||||||
- `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.
|
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -1,22 +1,25 @@
|
|||||||
# weatherreporter
|
# weatherreporter
|
||||||
|
|
||||||
`weatherreporter` is a Go application for preparing human-facing weather
|
Weatherreporter is a Go CLI that turns normalized weather data into managed,
|
||||||
reports from normalized forecast data. It builds JSON module snapshots, passes
|
human-facing Markdown reports.
|
||||||
YAML prompt data packages to `scriptorium`, and keeps inspectable artifacts
|
|
||||||
under a local workspace. It can also upload successfully generated managed
|
It provides repeatable reports with inspectable local artifacts, so operators
|
||||||
Markdown reports to a configured `distributor` HTTP upload endpoint.
|
can review what was collected and generated for every run.
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
weatherreporter generate today --out ./today.md
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Configure a Weather API endpoint first; see the
|
||||||
|
[configuration reference](docs/config.md).
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [CLI reference](docs/cli.md)
|
- [CLI reference](docs/cli.md)
|
||||||
- [Configuration reference](docs/config.md)
|
- [Configuration reference](docs/config.md)
|
||||||
- [Operations guide](docs/operations.md)
|
- [Operations guide](docs/operations.md)
|
||||||
- [Troubleshooting](docs/troubleshooting.md)
|
- [Troubleshooting](docs/troubleshooting.md)
|
||||||
|
- [Development guide](docs/development.md)
|
||||||
- [Architecture policy](docs/policy/architecture.md)
|
- [Architecture policy](docs/policy/architecture.md)
|
||||||
- [Development policy](docs/policy/development.md)
|
|
||||||
|
|||||||
197
docs/cli.md
197
docs/cli.md
@@ -1,32 +1,29 @@
|
|||||||
# Weatherreporter CLI
|
# Weatherreporter CLI
|
||||||
|
|
||||||
`weatherreporter` generates Markdown weather reports, runs scheduled report
|
`weatherreporter` generates weather reports, runs report batches, and inspects
|
||||||
batches, and inspects stored artifacts.
|
artifacts already stored in its workspace.
|
||||||
|
|
||||||
## Shortest Useful Command
|
## Shortest Useful Command
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
weatherreporter generate today --out ./today.md
|
||||||
```
|
```
|
||||||
|
|
||||||
This loads configuration, fetches weather data, writes managed workspace
|
The command uses the configured Weather API and writes an extra Markdown copy
|
||||||
artifacts, runs `scriptorium render` as a preflight check, runs
|
at `./today.md`. See the [configuration reference](config.md) to supply the
|
||||||
`scriptorium run`, and writes an extra Markdown copy to `./daily.md`. If
|
required Weather API endpoint.
|
||||||
distributor notification is enabled in configuration, the command also uploads
|
|
||||||
the managed Markdown report after final metadata is saved.
|
|
||||||
|
|
||||||
## Commands
|
## Commands And Usage
|
||||||
|
|
||||||
```text
|
```text
|
||||||
weatherreporter --help
|
weatherreporter --help
|
||||||
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
weatherreporter --version
|
||||||
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet]
|
||||||
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] [--llm-debug-dir PATH] [--quiet]
|
||||||
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet]
|
||||||
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet]
|
||||||
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] [--llm-debug-dir PATH] [--quiet]
|
||||||
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
|
weatherreporter run evening [--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]
|
|
||||||
weatherreporter inspect reports [--config PATH] [--limit N]
|
weatherreporter inspect reports [--config PATH] [--limit N]
|
||||||
weatherreporter inspect metadata [--config PATH] RUN_ID
|
weatherreporter inspect metadata [--config PATH] RUN_ID
|
||||||
weatherreporter inspect modules [--config PATH] RUN_ID
|
weatherreporter inspect modules [--config PATH] RUN_ID
|
||||||
@@ -35,78 +32,126 @@ weatherreporter inspect prior [--config PATH] RUN_ID
|
|||||||
weatherreporter inspect sources [--config PATH] RUN_ID
|
weatherreporter inspect sources [--config PATH] RUN_ID
|
||||||
```
|
```
|
||||||
|
|
||||||
Implemented `generate` commands write a JSON module snapshot, YAML data package,
|
`weatherreporter --version` prints the version embedded in the executable.
|
||||||
preflight artifact, managed Markdown report, and metadata under the configured
|
Tagged release binaries report their semantic version tag; ordinary local
|
||||||
workspace. `--out` writes an extra Markdown copy for the operator; distributor
|
builds report `development`.
|
||||||
notification uses the managed report path, not the extra copy. `generate
|
|
||||||
tomorrow` and `generate hourly` write managed generated-text artifacts,
|
|
||||||
validate structured text from Scriptorium, and render the managed Markdown
|
|
||||||
report from embedded templates. `generate hourly` covers the next six hours in
|
|
||||||
the effective report timezone and does not accept date or event window flags.
|
|
||||||
`generate storm` requires explicit event-window bounds with `--start` and
|
|
||||||
`--end`.
|
|
||||||
|
|
||||||
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
| Command | Contract |
|
||||||
except on Sunday. `run evening` generates the Tomorrow Report. Batch
|
| --- | --- |
|
||||||
runs continue independent reports after a failure, print a JSON summary to
|
| `generate daily` | Requires `--date YYYY-MM-DD`; the date is interpreted in the effective report timezone. |
|
||||||
stdout, write compact status lines to stderr, and return nonzero when any report
|
| `generate today` | Accepts an optional `--date YYYY-MM-DD`; without it, the current local date in the effective report timezone is used. |
|
||||||
failed. `--out-dir` writes extra Markdown copies for the operator; distributor
|
| `generate tomorrow` | Uses the next local civil day and accepts the common generate flags. |
|
||||||
notification uses each managed report path, not the extra copies. When
|
| `generate hourly` | Covers the next six hours in the effective report timezone. It does not accept `--date`, `--hours`, or `--duration`. |
|
||||||
notification is enabled, batch summaries and status lines include notification
|
| `run morning` and `run evening` | Run their defined report batches. `--out-dir` writes extra Markdown copies; `--out` is not accepted. |
|
||||||
status, accepted distributor run ID, or notification error fields for each
|
|
||||||
attempted report.
|
|
||||||
|
|
||||||
Hourly Report is explicit only; it is not included in `run morning` or `run
|
`generate` accepts the four report command names shown above. `run` accepts
|
||||||
evening`.
|
only `morning` and `evening`. Batch membership, workspace artifacts, and
|
||||||
|
notification sequencing are described in the [operations guide](operations.md).
|
||||||
|
|
||||||
`inspect` commands read existing workspace artifacts and emit JSON to stdout.
|
## Output, Errors, And Quiet Mode
|
||||||
They do not fetch weather data or invoke `scriptorium`.
|
|
||||||
|
|
||||||
## Flags
|
Action commands (`generate` and `run`) write a JSON summary to stdout unless
|
||||||
|
`--quiet` is set. `run` also writes compact per-report and batch status lines
|
||||||
|
to stderr. A pre-run error, such as an invalid flag, missing required argument,
|
||||||
|
or configuration-load failure, produces no partial JSON summary. When an action
|
||||||
|
fails after it has produced a result, its summary has `"status": "failed"` and
|
||||||
|
an `error` field.
|
||||||
|
|
||||||
- `-h`, `--help`: show help.
|
`--quiet` is supported by action commands only. It suppresses action summaries
|
||||||
- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`.
|
and routine batch status output; it does not suppress command errors.
|
||||||
- `--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 where supported by the `generate` command.
|
|
||||||
- `--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.
|
|
||||||
|
|
||||||
Storm times accept `YYYY-MM-DDTHH:MM` in the configured timezone or RFC3339
|
Inspection commands always write their requested JSON value to stdout and do
|
||||||
timestamps with explicit offsets.
|
not accept `--quiet`.
|
||||||
|
|
||||||
Distributor notification is configured only through `notify.distributor`; there
|
### Generate Summary
|
||||||
are no distributor-specific CLI flags.
|
|
||||||
|
|
||||||
## Common Workflows
|
A generate summary always identifies the command, report, run, generation
|
||||||
|
time, valid period, and status:
|
||||||
|
|
||||||
```sh
|
```json
|
||||||
weatherreporter generate tomorrow --out ./tomorrow.md
|
{
|
||||||
weatherreporter generate hourly
|
"command": "generate",
|
||||||
weatherreporter generate three-day --out ./three-day.md
|
"reportId": "today",
|
||||||
weatherreporter generate weekend --out ./weekend.md
|
"reportName": "Today Report",
|
||||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
|
"promptId": "weather.today_generated_text",
|
||||||
weatherreporter run morning --out-dir ./reports
|
"runId": "20260529T120000.000000000Z_today",
|
||||||
weatherreporter run evening --out-dir ./reports
|
"status": "succeeded",
|
||||||
|
"generatedAt": "2026-05-29T12:00:00Z",
|
||||||
|
"validPeriod": {
|
||||||
|
"start": "2026-05-29T00:00:00-05:00",
|
||||||
|
"end": "2026-05-30T00:00:00-05:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Inspection
|
When available, the summary also includes `reportPath`, `metadataPath`,
|
||||||
|
`dataPackagePath`, `preparationPath`, `executionPath`, `generatedTextRawPath`,
|
||||||
|
`generatedTextPath`, `renderContextPath`, and `llmDebugPath`. `outputPath` is included only
|
||||||
|
when `--out` wrote an extra copy. Distributor notification, when attempted,
|
||||||
|
adds `notificationPath` and may add a compact `notification` object.
|
||||||
|
|
||||||
|
### Run Summary And Stderr
|
||||||
|
|
||||||
|
A run summary contains `command`, `batch`, `status`, `startedAt`, `finishedAt`,
|
||||||
|
`total`, `succeeded`, `failed`, and a `reports` array. It may also contain a
|
||||||
|
top-level `notification` object and `error`. Batch status is `failed` if any
|
||||||
|
report or the batch notification fails.
|
||||||
|
|
||||||
|
Without `--quiet`, batch status lines use this form:
|
||||||
|
|
||||||
|
```text
|
||||||
|
report=today status=succeeded output="reports/today.md"
|
||||||
|
batch=morning total=2 succeeded=2 failed=0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Flag Reference
|
||||||
|
|
||||||
|
| Flag | Accepted by | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `-h`, `--help` | top level | Show help. |
|
||||||
|
| `--config PATH` | all commands | Load `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`. |
|
||||||
|
| `--units VALUE` | `generate`, `run` | Override `weather_api.units` for this command. |
|
||||||
|
| `--tz NAME` | `generate`, `run` | Override `weather_api.timezone` for this command. |
|
||||||
|
| `--out PATH` | every `generate` command | Write an extra Markdown report copy. |
|
||||||
|
| `--llm-debug-dir PATH` | every `generate` and `run` command | Write requested sensitive prompt diagnostics outside the managed workspace. The path must be absolute. |
|
||||||
|
| `--out-dir PATH` | `run morning`, `run evening` | Write extra Markdown report copies in `PATH`. |
|
||||||
|
| `--quiet` | `generate`, `run` | Suppress action summaries and routine batch status output. |
|
||||||
|
| `--date YYYY-MM-DD` | `generate daily`, `generate today` | Required for Daily; optional for Today. |
|
||||||
|
| `--limit N` | `inspect reports` | Maximum runs to list. Defaults to `20`; `0` means no limit. |
|
||||||
|
|
||||||
|
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 daily --date 2026-05-29 --out ./daily.md
|
||||||
|
weatherreporter generate today --date 2026-05-29 --out ./today.md
|
||||||
|
weatherreporter generate hourly --out ./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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Inspection Commands
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
weatherreporter inspect reports --limit 10
|
weatherreporter inspect reports --limit 10
|
||||||
weatherreporter inspect metadata 20260529T100000.000000000Z_daily_today
|
weatherreporter inspect metadata 20260529T100000.000000000Z_today
|
||||||
weatherreporter inspect modules 20260529T100000.000000000Z_daily_today
|
weatherreporter inspect modules 20260529T100000.000000000Z_today
|
||||||
weatherreporter inspect data-package 20260529T100000.000000000Z_daily_today
|
weatherreporter inspect data-package 20260529T100000.000000000Z_today
|
||||||
weatherreporter inspect prior 20260529T100000.000000000Z_daily_today
|
weatherreporter inspect prior 20260529T100000.000000000Z_today
|
||||||
weatherreporter inspect sources 20260529T100000.000000000Z_daily_today
|
weatherreporter inspect sources 20260529T100000.000000000Z_today
|
||||||
```
|
```
|
||||||
|
|
||||||
`inspect reports` lists recent generated runs with artifact paths and source
|
| Command | JSON returned |
|
||||||
warning counts. The other inspect commands require a RunID. `inspect modules`
|
| --- | --- |
|
||||||
returns the persisted ordered module snapshot for a run. `inspect prior`
|
| `inspect reports` | Recent generated runs, including artifact paths and source-warning counts. |
|
||||||
returns the prior comparable snapshot metadata selected from stored metadata, or
|
| `inspect metadata RUN_ID` | Persisted metadata for the run. |
|
||||||
`null` when none exists. `inspect sources` shows source provenance and source
|
| `inspect modules RUN_ID` | The run's persisted ordered module snapshot. |
|
||||||
warnings without dumping full weather payloads.
|
| `inspect data-package RUN_ID` | The run's persisted prompt data package. |
|
||||||
|
| `inspect prior RUN_ID` | Prior comparable snapshot metadata, or `null` when none exists. |
|
||||||
|
| `inspect sources RUN_ID` | Source provenance and source warnings without full weather payloads. |
|
||||||
|
|
||||||
|
Inspection is read-only: it does not collect weather data or invoke Promptkit.
|
||||||
|
See the [operations guide](operations.md) for artifact lifecycle
|
||||||
|
and recovery.
|
||||||
|
|||||||
329
docs/config.md
329
docs/config.md
@@ -1,254 +1,209 @@
|
|||||||
# Weatherreporter Configuration
|
# Weatherreporter Configuration
|
||||||
|
|
||||||
Configuration is YAML. By default, `weatherreporter` reads:
|
Weatherreporter reads YAML configuration. The default path is:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/usr/local/etc/weatherreporter/config.yml
|
/usr/local/etc/weatherreporter/config.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `--config PATH` to load a different file. If the default file is absent,
|
If the default file is absent, Weatherreporter uses built-in defaults. An
|
||||||
built-in defaults are used. If `--config PATH` points to a missing file, loading
|
explicit `--config PATH` must exist. Values are applied in this order:
|
||||||
fails.
|
|
||||||
|
|
||||||
Precedence is:
|
1. built-in defaults;
|
||||||
|
2. the configuration file, when present; and
|
||||||
|
3. the `--units` and `--tz` command-line overrides.
|
||||||
|
|
||||||
1. CLI flags
|
Environment variables do not override configuration fields. Output flags write
|
||||||
2. configuration file
|
extra report copies for a command and do not change configuration.
|
||||||
3. built-in defaults
|
|
||||||
|
|
||||||
The CLI configuration overrides are `--units` and `--tz`. Output flags control
|
## Maintained Examples
|
||||||
report copies for the current command but do not change configuration files.
|
|
||||||
Environment variables do not override configuration fields.
|
|
||||||
|
|
||||||
## 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.
|
||||||
|
|
||||||
See [examples/minimal-config.yml](../examples/minimal-config.yml).
|
Both files are loaded by the configuration test suite.
|
||||||
|
|
||||||
|
## Minimal Configuration
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
weather_api:
|
weather_api:
|
||||||
base_url: https://weather.api.example.com/
|
base_url: https://weather.api.example.com/
|
||||||
```
|
```
|
||||||
|
|
||||||
`weather_api.base_url` is required for commands that fetch weather data. Other
|
`weather_api.base_url` is required for workflows that collect weather data.
|
||||||
fields fall back to defaults.
|
All omitted fields use their built-in defaults.
|
||||||
|
|
||||||
## Production-Oriented Config
|
|
||||||
|
|
||||||
See [examples/config.yml](../examples/config.yml). The example is loaded by the
|
|
||||||
config test suite.
|
|
||||||
|
|
||||||
## Field Reference
|
## Field Reference
|
||||||
|
|
||||||
### `weather_api`
|
### `weather_api`
|
||||||
|
|
||||||
- `base_url`: absolute base URL for the Weather API. Required for generation and fetch workflows.
|
| Field | Default | Rules |
|
||||||
- `timeout`: HTTP timeout duration. Default: `10s`.
|
| --- | --- | --- |
|
||||||
- `precision`: numeric precision query value. Default: `1`.
|
| `base_url` | empty | Absolute Weather API URL. Required for collection and generation. |
|
||||||
- `units`: Weather API units query value. Default: `us`.
|
| `timeout` | `10s` | Must be greater than zero. |
|
||||||
- `timezone`: report timezone and Weather API timezone query value where supported. Default: `America/Chicago`.
|
| `precision` | `0` | Must be zero or greater. Sent as the Weather API precision query value. |
|
||||||
- `format`: Weather API response format. Must be `json`. Default: `json`.
|
| `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
|
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 UTC offsets such as `-5` and `+09:30`.
|
||||||
|
|
||||||
### `location`
|
### `location`
|
||||||
|
|
||||||
`location` is descriptive prompt context included in module metadata and
|
`location` supplies descriptive prompt context; it does not choose a Weather
|
||||||
Scriptorium data packages. It does not select a Weather API endpoint or enable
|
API endpoint or configure multiple forecast locations.
|
||||||
multiple configured forecast locations.
|
|
||||||
|
|
||||||
- `id`: short local identifier. Default: `home`.
|
| Field | Default |
|
||||||
- `name`: human-readable location name. Default: `Brentwood`.
|
| --- | --- |
|
||||||
- `region`: broader forecast area context. Default: `St. Louis Metro`.
|
| `id` | `home` |
|
||||||
|
| `name` | `Brentwood` |
|
||||||
|
| `region` | `St. Louis Metro` |
|
||||||
|
|
||||||
The prompt-facing location object also includes `timezone`, derived from the
|
The prompt-facing location timezone is derived from the effective
|
||||||
effective `weather_api.timezone` after CLI overrides such as `--tz`.
|
`weather_api.timezone` after command-line overrides.
|
||||||
|
|
||||||
### `secrets`
|
### `secrets`
|
||||||
|
|
||||||
- `directory`: optional directory of file-backed environment secrets. Default:
|
`secrets.directory` defaults to empty, which disables secret loading. When it
|
||||||
empty, which disables secret loading.
|
is set, every regular file directly in that directory is loaded after the file
|
||||||
|
and command-line overrides. A file basename must match
|
||||||
|
`[A-Za-z_][A-Za-z0-9_]*`; it becomes an environment variable name, and the
|
||||||
|
file contents replace any existing value. One trailing LF or CRLF is removed.
|
||||||
|
|
||||||
When configured, each regular file directly under `secrets.directory` is loaded
|
Missing directories, unreadable files, subdirectories, symlinks, non-regular
|
||||||
after config file parsing and CLI overrides. The file basename must be a valid
|
files, and invalid names fail configuration loading. Put only secret values in
|
||||||
environment variable name matching `[A-Za-z_][A-Za-z0-9_]*`; the file contents
|
this directory, never in the YAML file.
|
||||||
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.
|
|
||||||
|
|
||||||
### `notify`
|
### `notify.distributor`
|
||||||
|
|
||||||
`notify.distributor` controls distributor notification after successful report
|
Distributor notification is disabled by default. Its fields are:
|
||||||
generation. It is disabled by default and does not add CLI flags. When enabled,
|
|
||||||
weatherreporter uploads one distributor bundle per generated report after
|
|
||||||
report rendering succeeds and final metadata is saved.
|
|
||||||
|
|
||||||
- `enabled`: whether distributor notification config is active. Default:
|
| Field | Default | Rules when notification is enabled |
|
||||||
`false`.
|
| --- | --- | --- |
|
||||||
- `endpoint`: absolute distributor endpoint URL. Required when enabled.
|
| `enabled` | `false` | Activates Distributor notification validation. |
|
||||||
Default: `https://distributor.example.com`.
|
| `endpoint` | `https://distributor.example.com` | Must be an absolute URL. |
|
||||||
- `token_env`: environment variable name that will contain the distributor
|
| `token_env` | `DISTRIBUTOR_UPLOAD_TOKEN` | Must name a valid environment variable. |
|
||||||
upload token. Required when enabled. Default: `DISTRIBUTOR_UPLOAD_TOKEN`.
|
| `timeout` | `30s` | Must be greater than zero. |
|
||||||
- `timeout`: distributor operation timeout. Must be greater than zero when
|
| `failure_policy` | `error` | Must be `error`. |
|
||||||
enabled. Default: `30s`.
|
| `pipeline_id_template` | empty | Required single-report pipeline ID template. |
|
||||||
- `failure_policy`: must be `error` when enabled. Default: `error`.
|
| `bundle_id_template` | `weatherreporter.{location_id}.{report_id}` | Required single-report bundle ID template. |
|
||||||
- `pipeline_id_template`: template for the distributor pipeline ID. Required
|
| `idempotency_key_template` | `{bundle_id}.{run_id}` | Required single-report idempotency-key template. |
|
||||||
when enabled. Default: empty.
|
| `batch.enabled` | `true` | Activates batch notification validation when Distributor notification is enabled. |
|
||||||
- `bundle_id_template`: template for distributor bundle IDs. Default:
|
| `batch.pipeline_id_template` | `weatherreporter` | Required when batch notification is enabled. |
|
||||||
`weatherreporter.{location_id}.{report_id}`.
|
| `batch.bundle_id_template` | `weatherreporter.{location_id}.{batch}` | Required when batch notification is enabled. |
|
||||||
- `idempotency_key_template`: template for distributor idempotency keys.
|
| `batch.idempotency_key_template` | `{bundle_id}.{batch_run_id}` | Required when batch notification is enabled. |
|
||||||
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"
|
|
||||||
```
|
|
||||||
|
|
||||||
Supported template variables are `location_id`, `report_id`, `run_id`,
|
The upload token is read from the environment variable named by `token_env`.
|
||||||
|
Use `secrets.directory` when a file-backed secret is appropriate.
|
||||||
|
|
||||||
|
Single-report bundle templates accept `location_id`, `report_id`, `run_id`,
|
||||||
`artifact_group`, `batch_output_name`, `valid_start_date`, `valid_end_date`,
|
`artifact_group`, `batch_output_name`, `valid_start_date`, `valid_end_date`,
|
||||||
`valid_start_time`, `valid_end_time`, `valid_start_stamp`, and
|
`valid_start_time`, `valid_end_time`, `valid_start_stamp`, `valid_end_stamp`,
|
||||||
`valid_end_stamp`. Date values use `YYYY-MM-DD`, time values use `HHMM`, and
|
Pipeline and idempotency-key templates may also use `bundle_id`. Dates use
|
||||||
stamp values use `YYYY-MM-DDTHHMM` in the effective report timezone.
|
`YYYY-MM-DD`; times use `HHMM`; and stamps use `YYYY-MM-DDTHHMM` in the
|
||||||
`pipeline_id_template` and `idempotency_key_template` may also use `bundle_id`.
|
effective report timezone.
|
||||||
|
|
||||||
The rendered pipeline ID selects the configured distributor `http_upload`
|
Batch bundle and pipeline templates accept `location_id`, `batch`,
|
||||||
workflow. The rendered bundle ID is the stable logical source identity for the
|
`batch_run_id`, and `batch_started_date`; batch idempotency-key templates may
|
||||||
report stream. The rendered idempotency key is the per-run retry identity.
|
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
|
`reports.<report>.distributor.path_templates` overrides the default ordered
|
||||||
must not contain backslashes, empty path segments, `.`, `..`, `manifest.json`,
|
Distributor paths for that report. Each rendered path must be a unique relative
|
||||||
or `.distributor.json`.
|
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`
|
| Report | Paths |
|
||||||
after config loading and `secrets.directory` processing. Config files should
|
| --- | --- |
|
||||||
name the variable only; they should not contain the token value.
|
| `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
|
||||||
|
artifact selection, and failure handling.
|
||||||
|
|
||||||
### `missing_source`
|
### `missing_source`
|
||||||
|
|
||||||
- `default`: missing-source behavior for optional sources. One of `error`, `warn`, or `none`. Default: `warn`.
|
`missing_source.default` defaults to `warn` and accepts `error`, `warn`, or
|
||||||
- `sources`: optional map of source-specific overrides, using the same policy values.
|
`none`. `missing_source.sources` optionally overrides that policy by source.
|
||||||
|
Hourly forecast data is required for generated reports. Supported optional
|
||||||
|
source keys are `observations`, `current`, `narrative`, `alerts`, `discussion`,
|
||||||
|
`weather_story`, and `spc_convective_outlooks`.
|
||||||
|
|
||||||
Hourly forecast data is required for generated reports. Optional sources use
|
### `promptkit`
|
||||||
the missing-source policy. Source override keys include `observations`,
|
|
||||||
`current`, `narrative`, `alerts`, `discussion`, `weather_story`, and
|
|
||||||
`spc_convective_outlooks`.
|
|
||||||
|
|
||||||
### `scriptorium`
|
Promptkit configuration selects the executor and prompt/profile checks for
|
||||||
|
every `generate` and `run` command. A top-level `scriptorium:` configuration
|
||||||
|
key is rejected with a migration error; it is not translated or ignored.
|
||||||
|
|
||||||
- `binary`: `scriptorium` executable name or path. Default: `scriptorium`.
|
Prompt debug capture has no YAML setting. Use `--llm-debug-dir PATH` on an
|
||||||
- `config_path`: optional Scriptorium config path passed to the adapter.
|
individual `generate` or `run` command when explicitly needed.
|
||||||
- `profile`: optional Scriptorium profile passed to the adapter.
|
|
||||||
- `timeout`: subprocess timeout. Default: `2m`.
|
| Field | Default | Rules |
|
||||||
- `extra_args`: optional additional arguments passed to Scriptorium commands.
|
| --- | --- | --- |
|
||||||
|
| `profile` | empty | Optional explicit execution profile. Otherwise the prompt's declared default is used. |
|
||||||
|
| `profile_file` | empty | Optional external profile file. Cannot be combined with `profile_dir`. |
|
||||||
|
| `profile_dir` | empty | Optional external profile directory. Cannot be combined with `profile_file`. |
|
||||||
|
| `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. |
|
||||||
|
|
||||||
### `workspace`
|
### `workspace`
|
||||||
|
|
||||||
- `root`: workspace root for managed artifacts. Default: `workspace`.
|
| Field | Default |
|
||||||
- `snapshots_dir`: module snapshot and metadata directory under `workspace.root`. Default: `snapshots`.
|
| --- | --- |
|
||||||
- `reports_dir`: managed Markdown report directory under `workspace.root`. Default: `reports`.
|
| `root` | `workspace` |
|
||||||
- `data_packages_dir`: prompt input package directory under `workspace.root`. Default: `data-packages`.
|
| `snapshots_dir` | `snapshots` |
|
||||||
- `preflight_dir`: Scriptorium render output directory under `workspace.root`. Default: `preflight`.
|
| `reports_dir` | `reports` |
|
||||||
- `notifications_dir`: distributor notification debug artifact directory under `workspace.root`. Default: `notifications`.
|
| `data_packages_dir` | `data-packages` |
|
||||||
|
| `preflight_dir` | `preflight` |
|
||||||
|
| `notifications_dir` | `notifications` |
|
||||||
|
|
||||||
Workspace subdirectories must be relative paths that stay inside
|
`workspace.root` is required. Each workspace subdirectory must be a relative
|
||||||
`workspace.root`.
|
path that stays within the root. See the [operations guide](operations.md) for
|
||||||
|
the managed workspace layout and lifecycle.
|
||||||
|
|
||||||
### `dayparts`
|
### `dayparts`
|
||||||
|
|
||||||
`dayparts` is a list of named local-time windows used by forecast derivation.
|
`dayparts` is a non-empty list of named local-time windows used in forecast
|
||||||
Each entry has:
|
derivation. Every item needs `name`, `start`, and `end`; start and end use
|
||||||
|
`HH:MM`. Defaults are `overnight` (`00:00`–`06:00`), `morning`
|
||||||
- `name`
|
(`06:00`–`10:00`), `midday` (`10:00`–`15:00`), `afternoon`
|
||||||
- `start`
|
(`15:00`–`17:00`), and `evening` (`17:00`–`24:00`).
|
||||||
- `end`
|
|
||||||
|
|
||||||
`start` and `end` use `HH:MM`. The default entries are overnight, morning,
|
|
||||||
midday, afternoon, and evening.
|
|
||||||
|
|
||||||
### `recent_change`
|
### `recent_change`
|
||||||
|
|
||||||
- `temperature_degrees`: temperature change threshold. Default: `5`.
|
| Field | Default |
|
||||||
- `precip_probability_points`: precipitation probability threshold. Default: `20`.
|
| --- | --- |
|
||||||
- `wind_gust_miles_per_hour`: wind gust change threshold. Default: `10`.
|
| `temperature_degrees` | `5` |
|
||||||
- `precip_timing_shift_minutes`: precipitation timing shift threshold. Default: `120`.
|
| `precip_probability_points` | `20` |
|
||||||
|
| `wind_gust_miles_per_hour` | `10` |
|
||||||
|
| `precip_timing_shift_minutes` | `120` |
|
||||||
|
|
||||||
Recent Changes are added to prompt input when a prior comparable module
|
These thresholds control when Recent Changes are included in prompt input for a
|
||||||
snapshot exists and a threshold is crossed.
|
prior comparable module snapshot.
|
||||||
|
|
||||||
### `reports`
|
### `reports`
|
||||||
|
|
||||||
`reports` optionally overrides the ordered deterministic modules declared by
|
`reports` optionally overrides a report's ordered deterministic modules and
|
||||||
report definitions. Omit a report entry to use its default module order.
|
Distributor path templates. Omit a report entry to retain its defaults.
|
||||||
|
|
||||||
Supported report keys are `daily`, `tomorrow`, `hourly`, `three_day`,
|
Supported report keys are `daily`, `today`, `tomorrow`, and `hourly`; hyphens
|
||||||
`weekend`, and `storm`. Canonical report IDs such as `daily_today` are also
|
and underscores are equivalent.
|
||||||
accepted.
|
|
||||||
|
|
||||||
Each report entry supports:
|
Each report entry can contain:
|
||||||
|
|
||||||
- `deterministic_modules`: ordered module list. Entries may be string module
|
- `deterministic_modules`: an ordered list of module IDs, or objects with `id`
|
||||||
IDs or objects with `id` and optional `options`.
|
and optional `options`.
|
||||||
|
- `distributor.path_templates`: an optional, non-empty ordered list of
|
||||||
|
Distributor paths for that report.
|
||||||
|
|
||||||
Example:
|
Unknown reports and modules, duplicate modules, incompatible report-module
|
||||||
|
combinations, duplicate stanza names, invalid path templates, and invalid
|
||||||
```yaml
|
module options fail configuration loading. The accepted module IDs and module
|
||||||
reports:
|
option contracts are documented in the [module contract internals](internal/module.md).
|
||||||
daily:
|
|
||||||
deterministic_modules:
|
|
||||||
- metadata
|
|
||||||
- current_conditions
|
|
||||||
- narrative_forecast
|
|
||||||
- alert_digest
|
|
||||||
- spc_convective_outlooks
|
|
||||||
- id: area_forecast_discussion
|
|
||||||
options:
|
|
||||||
sections:
|
|
||||||
- short_term
|
|
||||||
- spc_convective_discussion
|
|
||||||
- hourly_forecast
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
87
docs/development.md
Normal file
87
docs/development.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# 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 managed Markdown reports, and can upload completed
|
||||||
|
reports through Distributor. Start with the [README](../README.md) for product
|
||||||
|
context and the [architecture policy](policy/architecture.md) for system
|
||||||
|
boundaries and invariants.
|
||||||
|
|
||||||
|
## What To Read
|
||||||
|
|
||||||
|
| When working on | Read | Why |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Product behavior or the shortest useful workflow | [README](../README.md), [CLI reference](cli.md), and [operations guide](operations.md) | These own product orientation, invocation, and normal operation. |
|
||||||
|
| Application shape, package boundaries, dependency direction, safety properties, or architectural invariants | [Architecture policy](policy/architecture.md) and relevant ADRs under `docs/adr/`, when present | Architecture defines the intended system; ADRs preserve significant decision rationale. |
|
||||||
|
| Any documentation addition, revision, move, or removal | [Documentation policy](policy/documentation.md) | It defines canonical owners, audience boundaries, current-state rules, and document lifecycle. |
|
||||||
|
| Adding, changing, reviewing, or deleting tests | [Testing policy](policy/testing.md) and focused package tests | The policy defines risk-based sufficiency, durable test boundaries, doubles, and test-maintenance criteria. |
|
||||||
|
| CLI commands, flags, output, quiet mode, or command wiring | [CLI reference](cli.md) and [CLI internals](internal/cli.md) | The reference owns the user contract; the internal guide owns command composition and output flow. |
|
||||||
|
| Configuration fields, defaults, loading, overrides, validation, or secrets | [Configuration reference](config.md), [architecture policy](policy/architecture.md), and tests under `internal/config` | These separate the user-visible contract, architectural rules, and executable behavior. |
|
||||||
|
| Top-level generation, batch, collection, inspection, or notification workflow | [App orchestration internals](internal/app-orchestration.md) | It owns workflow ordering, persistence points, 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. |
|
||||||
|
| Recent Changes comparison | [Changes internals](internal/changes.md) and [operations guide](operations.md) | The internal guide owns structured comparison; operations owns user-visible artifact behavior. |
|
||||||
|
| Prompt execution, profiles, prompt inputs, or result handling | `internal/promptexec`, the Promptkit adapter, and [prompt-input internals](internal/prompt-input.md) | These separate the executor contract and input construction. |
|
||||||
|
| Generated-text schemas, validation, render contexts, templates, or Markdown rendering | [Generated-text internals](internal/generatedtext.md), [report-template internals](internal/reporttemplate.md), and [report template guide](templates.md) | These own structured text, renderer implementation, and the maintainer-facing template surface. |
|
||||||
|
| Workspace paths, metadata, atomic persistence, lookup, inspection, or recovery | [State internals](internal/state.md), [operations guide](operations.md), and [troubleshooting guide](troubleshooting.md) | These separate implementation, operator workflows, and symptom-based recovery. |
|
||||||
|
| Distributor bundles, uploads, notification artifacts, 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` | Generation, batches, collection coordination, notification, and inspection orchestration. |
|
||||||
|
| `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`, `internal/changes` | Report registry, module contracts and values, and structured comparison. |
|
||||||
|
| `internal/promptinput`, `internal/generatedtext`, `internal/reporttemplate` | Prompt packages, generated-text validation, render contexts, and Markdown templates. |
|
||||||
|
| `internal/state`, `internal/fileutil`, `internal/timeutil` | Durable artifacts, atomic file 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.
|
||||||
@@ -1,136 +1,70 @@
|
|||||||
# 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 as a base URL. The client
|
||||||
|
posts a gzip-compressed source bundle to:
|
||||||
|
|
||||||
The upstream application needs these values from deployment or operator configuration:
|
```text
|
||||||
|
POST /v1/pipelines/<pipeline_id>/upload
|
||||||
- distributor endpoint: the HTTP server base URL, such as `https://distributor.example.com`;
|
Authorization: Bearer <token>
|
||||||
- upload token: bearer token that authenticates the producer;
|
Content-Type: application/gzip
|
||||||
- pipeline id: configured `http_upload` pipeline that should process this upload;
|
Idempotency-Key: <key>
|
||||||
- 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
|
The adapter requires a pipeline ID, bundle ID, idempotency key, and at least one
|
||||||
package reports
|
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 (
|
## Idempotency
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/distributor/pkg/bundle"
|
Distributor scopes idempotency to the token, pipeline ID, and key. Keys must be
|
||||||
"gitea.maximumdirect.net/eric/distributor/pkg/upload"
|
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.
|
||||||
|
|
||||||
func SubmitReport(reportPath, summaryPath string) error {
|
Reusing a key for the same normalized source manifest returns the original
|
||||||
endpoint := os.Getenv("DISTRIBUTOR_UPLOAD_ENDPOINT")
|
accepted run. Reusing it for different content returns `409 Conflict`, which
|
||||||
token := os.Getenv("DISTRIBUTOR_UPLOAD_TOKEN")
|
the adapter exposes as a Weatherreporter idempotency-conflict error. A distinct
|
||||||
if endpoint == "" || token == "" {
|
report or batch run therefore needs a distinct key; reuse a key only when
|
||||||
return fmt.Errorf("distributor endpoint and token are required")
|
retrying that same upload.
|
||||||
}
|
|
||||||
|
|
||||||
pipelineID := "weather-hourly"
|
## Run Status And Retention
|
||||||
reportID := "weather.hourly.brentwood"
|
|
||||||
runID := time.Now().UTC().Format("20060102T150405.000000000Z")
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
client, err := upload.NewClient(upload.ClientOptions{
|
After acceptance, Weatherreporter reads:
|
||||||
Endpoint: endpoint,
|
|
||||||
Token: token,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := client.UploadFiles(ctx, upload.UploadFilesOptions{
|
```text
|
||||||
PipelineID: pipelineID,
|
GET /runs/<run_id>
|
||||||
ID: reportID,
|
Authorization: Bearer <token>
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("distributor accepted run %s\n", result.RunID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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`.
|
Run and idempotency records are in-memory. Completed records expire according
|
||||||
- Set `PipelineID` to the configured upload pipeline that should process the bundle.
|
to Distributor's `server.http.retention`, and a Distributor restart removes
|
||||||
- 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.
|
retained status and idempotency state. Status polling decisions and persistence
|
||||||
- Use an idempotency key that changes for every distinct producer run, such as `<bundle-id>.<run-id>`.
|
of notification artifacts are internal orchestration behavior; see the
|
||||||
- Reuse the same idempotency key only when retrying the exact same producer run with the same source manifest.
|
[Distributor adapter](../../internal/distributor-adapter.md) and
|
||||||
- Map each generated file to a clean slash-separated bundle path, such as `report.md` or `assets/chart.png`.
|
[application orchestration](../../internal/app-orchestration.md).
|
||||||
- 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.
|
|
||||||
|
|
||||||
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
|
The upstream canonical HTTP wire contract is
|
||||||
|
`docs/integrations/http-upload.md` in the Distributor repository. This page
|
||||||
`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.
|
documents only the portion exercised by Weatherreporter.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
@@ -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
|
Every mapping pairs a managed Markdown report source with one bundle-relative
|
||||||
import "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
|
path. A single-report notification maps its one managed report to each rendered
|
||||||
```
|
path configured for that report. A batch notification combines mappings for
|
||||||
|
every included managed report 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 never an `--out` copy or an arbitrary workspace scan. The
|
||||||
|
application selects it and renders notification paths; see the [operations guide](../../operations.md)
|
||||||
|
for the managed-upload rule and the [Distributor adapter](../../internal/distributor-adapter.md)
|
||||||
|
for the adapter 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
|
The upstream canonical file-format contract is
|
||||||
manifest, err := bundle.WriteBundle(bundle.WriteBundleOptions{
|
`docs/integrations/source-bundle.md` in the Distributor repository. It defines
|
||||||
Root: "/var/spool/distributor/weather/hourly-2026-06-07T15",
|
the complete manifest and archive format; this page records only the mapping and
|
||||||
ID: "weather.hourly.brentwood",
|
path constraints Weatherreporter relies on.
|
||||||
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.
|
|
||||||
|
|||||||
@@ -1,122 +1,51 @@
|
|||||||
# `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
|
The adapter constructs the client with the configured endpoint, bearer token,
|
||||||
import "gitea.maximumdirect.net/eric/distributor/pkg/upload"
|
and an HTTP client whose timeout is the configured Distributor timeout. It
|
||||||
```
|
passes no custom retry options, so the pinned client's defaults apply: three
|
||||||
|
attempts, 100 ms base delay, and one-second maximum delay.
|
||||||
|
|
||||||
`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 managed-report-to-bundle-path mappings described in the
|
||||||
|
[bundle mapping contract](pkg-bundle.md); and
|
||||||
|
- a rendered idempotency key.
|
||||||
|
|
||||||
```go
|
It leaves bundle validation enabled. `UploadFiles` creates the temporary source
|
||||||
import "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
|
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
|
The client decodes the accepted upload result (`run_id`, `status`) and the run
|
||||||
client, err := upload.NewClient(upload.ClientOptions{
|
status record. A `409` response is an upstream idempotency conflict; the
|
||||||
Endpoint: "https://distributor.example.com",
|
adapter translates it to its own conflict error without exposing the token.
|
||||||
Token: token,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`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. Polling cadence, final failure handling, redaction, and
|
||||||
|
notification artifact persistence 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
|
The upstream package workflow is documented in
|
||||||
|
`docs/consumers/pkg-upload.md` in the Distributor repository. Weatherreporter
|
||||||
Use `UploadFiles` when the producer has generated output files but has not assembled a bundle directory.
|
uses only the client construction, `UploadFiles`, retry/conflict behavior, and
|
||||||
|
`Status` operations described here.
|
||||||
```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.
|
|
||||||
|
|||||||
22
docs/integrations/promptkit.md
Normal file
22
docs/integrations/promptkit.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# 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
|
||||||
|
`1.0.0`. Their prompt assets and generated-text JSON Schemas are embedded by
|
||||||
|
`internal/promptassets`.
|
||||||
|
|
||||||
|
Before collection, Weatherreporter inspects the exact prompt version, requires one required
|
||||||
|
`data_package` input with content type `application/yaml`, and requires the report's JSON
|
||||||
|
Schema output contract. It selects `promptkit.profile` when configured, otherwise the
|
||||||
|
prompt's declared default profile. Profiles that require a direct API key are unsupported; a
|
||||||
|
profile that reports `APIKeyEnv` requires a nonblank value in that environment variable.
|
||||||
|
|
||||||
|
Execution receives the already-persisted YAML package, prepares it once, and returns structured
|
||||||
|
JSON that Weatherreporter validates before rendering its own Markdown template. Preparation and
|
||||||
|
execution receipts are project-owned, safe provenance records. Content-rich diagnostics are
|
||||||
|
opt-in through `--llm-debug-dir`; see [operations](../operations.md) for retention and permissions.
|
||||||
|
|
||||||
|
Prompt/profile configuration is owned by the [configuration reference](../config.md). Adapter
|
||||||
|
construction and mapping are documented in the [Promptkit adapter internals](../internal/promptkit-adapter.md).
|
||||||
|
Durable metadata compatibility is described in [state internals](../internal/state.md).
|
||||||
@@ -1,118 +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 report artifacts. 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>
|
|
||||||
```
|
|
||||||
|
|
||||||
Structured generated-text report generation uses the same command shape:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scriptorium run \
|
|
||||||
--prompt <prompt_id> \
|
|
||||||
--input data_package=<path> \
|
|
||||||
--out <generated_text_raw_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.
|
|
||||||
|
|
||||||
For generated-text reports, Scriptorium selects the structured output schema
|
|
||||||
from the prompt configuration associated with the prompt ID. `weatherreporter`
|
|
||||||
does not pass `--format`, schema path, or JSON Schema flags for structured
|
|
||||||
generation.
|
|
||||||
|
|
||||||
## 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. Structured
|
|
||||||
generated-text run results use the same captured fields and output-path
|
|
||||||
recording, with the output path pointing at the raw generated-text JSON
|
|
||||||
artifact.
|
|
||||||
|
|
||||||
`weatherreporter` persists render preflight JSON when orchestration reaches the
|
|
||||||
preflight save point. For direct Markdown reports, Scriptorium writes the
|
|
||||||
managed Markdown artifact to the `--out` path. For generated-text-template
|
|
||||||
reports, Scriptorium writes raw JSON to the `--out` path; later
|
|
||||||
weatherreporter workflow steps validate those bytes and render Markdown from an
|
|
||||||
embedded template.
|
|
||||||
|
|
||||||
## Failure Behavior
|
|
||||||
|
|
||||||
The adapter validates required request fields before starting Scriptorium:
|
|
||||||
|
|
||||||
- prompt ID
|
|
||||||
- data package path
|
|
||||||
- output path for `run` and structured generated-text `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,51 @@
|
|||||||
# Weather API Integration
|
# Weather API Integration
|
||||||
|
|
||||||
This document describes the external Weather API contract used by
|
Weatherreporter fetches normalized weather inputs from a configured Weather API
|
||||||
`weatherreporter`.
|
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_api.base_url` must be an absolute URL. Weatherreporter joins each
|
||||||
weather source data and assemble a `weatherdata.Bundle`. This is an integration
|
endpoint path to the configured base URL path, so a service hosted under a path
|
||||||
contract for the project adapter, not a complete public API reference for the
|
prefix must keep that prefix available. Requests use `GET` and carry the
|
||||||
upstream service.
|
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
|
Before retrieving sources, Weatherreporter warms up
|
||||||
URL with the endpoint paths listed below. Generation and explicit bundle fetches
|
`/conditions/current` with the same `format`, `units`, and `precision` query
|
||||||
fail before any HTTP request when the base URL is empty or not absolute.
|
parameters used for current conditions. The warmup only requires a readable
|
||||||
|
2xx response; its body is not decoded. Failure after its internal retry budget
|
||||||
|
stops the fetch before source requests begin.
|
||||||
|
|
||||||
The HTTP client uses `weather_api.timeout`.
|
## Endpoints And Query Parameters
|
||||||
|
|
||||||
|
The adapter makes one source request for each endpoint after a successful
|
||||||
|
warmup, subject to retry on transient failures.
|
||||||
|
|
||||||
|
| 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
|
## 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
|
```json
|
||||||
{
|
{
|
||||||
@@ -28,171 +53,95 @@ 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
|
An absent `data` member is treated as a missing source. For ordinary sources,
|
||||||
sources follow the configured missing-source policy. Missing hourly forecast
|
`data: null` is also missing. The active-alert exception is listed above: its
|
||||||
data fails bundle fetching because hourly periods are required for report
|
explicit `null` payload represents an empty alert result.
|
||||||
generation.
|
|
||||||
|
|
||||||
`/alerts/active` is the exception: a successful response with `data: null`
|
Hourly forecast data must be present and contain at least one `period`; a
|
||||||
means the endpoint was checked and there are no current active alerts. The
|
missing, malformed, or empty hourly product fails collection. The remaining
|
||||||
adapter records a non-missing alerts source and an empty alert run.
|
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.
|
||||||
|
|
||||||
For `/outlooks/convective`, `data: null` means no latest run is available and
|
Malformed top-level JSON envelopes and HTTP failures are direct request errors.
|
||||||
follows missing-source policy. A non-null run with empty `outlooks` and
|
Malformed `data` for an optional source follows its missing-source policy.
|
||||||
`discussions` arrays is checked empty data, not a missing source.
|
|
||||||
|
|
||||||
Malformed JSON envelopes, non-2xx statuses, and response read failures include
|
## Payload Fields Used
|
||||||
endpoint context in returned errors. Decode errors include source context when
|
|
||||||
they fail the fetch; optional malformed sources follow the missing-source policy.
|
|
||||||
|
|
||||||
## 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.
|
||||||
|
|
||||||
The adapter sends these query parameters:
|
### Observations And Current Conditions
|
||||||
|
|
||||||
- `format`: from `weather_api.format`; configuration validation requires `json`
|
`/observations` uses `stationId`, `stationName`, `timestamp`, `conditionCode`,
|
||||||
- `units`: from `weather_api.units`
|
`isDay`, `textDescription`, `temperatureC`, `temperatureF`, `dewpointC`,
|
||||||
- `precision`: from `weather_api.precision` on observations, current
|
`dewpointF`, `windSpeedKmh`, `windSpeedMph`, `windGustKmh`, `windGustMph`,
|
||||||
conditions, hourly forecast, and narrative forecast requests
|
`windDirectionDegrees`, `barometricPressurePa`, `barometricPressureInHg`,
|
||||||
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast,
|
`visibilityMeters`, `visibilityMiles`, `relativeHumidityPercent`,
|
||||||
discussion, and SPC convective outlook requests
|
`apparentTemperatureC`, `apparentTemperatureF`, and `presentWeather`.
|
||||||
|
|
||||||
Alerts do not receive `precision` or `tz`. Weather story requests receive only
|
`/conditions/current` uses `conditionText`, `isDay`,
|
||||||
`format=json`. SPC convective outlook requests receive only `format=json` and
|
`relativeHumidityPercent`, `windDirectionDegrees`, `temperatureC`,
|
||||||
`tz`; they do not receive `units` or `precision`.
|
`temperatureF`, `apparentTemperatureC`, `apparentTemperatureF`, `dewpointC`,
|
||||||
|
`dewpointF`, `windSpeedKmh`, and `windSpeedMph`.
|
||||||
|
|
||||||
## SPC Convective Outlooks
|
### Hourly And Narrative Forecasts
|
||||||
|
|
||||||
The adapter fetches SPC convective outlook data from:
|
Both forecast endpoints use run-level `locationId`, `locationName`, `issuedAt`,
|
||||||
|
`updatedAt`, `product`, `latitude`, `longitude`, `elevationMeters`,
|
||||||
|
`elevationFeet`, and `periods`.
|
||||||
|
|
||||||
```text
|
Each `periods` item uses `startTime`, `endTime`, `name`, `isDay`,
|
||||||
GET /outlooks/convective?format=json&tz=<weather_api.timezone>
|
`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`.
|
||||||
|
|
||||||
The response uses the standard `data` envelope. `data: null` means no latest
|
### Alerts, Discussion, And Weather Story
|
||||||
run is available and follows missing-source policy. A non-null object with
|
|
||||||
empty `outlooks` and `discussions` arrays is accepted as checked empty data.
|
|
||||||
|
|
||||||
Run fields consumed by weatherreporter:
|
`/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.
|
||||||
|
|
||||||
- `locationId`
|
`/discussion` uses `officeId`, `officeName`, `product`, `issuedAt`,
|
||||||
- `locationName`
|
`updatedAt`, `keyMessages`, and the `shortTerm` and `longTerm` sections. Each
|
||||||
- `asOf`
|
section uses `qualifier`, `text`, and `issuedAt`.
|
||||||
- `issuedAt`
|
|
||||||
- `updatedAt`
|
|
||||||
- `product`
|
|
||||||
- `outlooks`
|
|
||||||
- `discussions`
|
|
||||||
|
|
||||||
Outlook fields consumed:
|
`/weatherstories/latest` uses `officeId`, `startTime`, `endTime`, `updatedAt`,
|
||||||
|
`title`, `description`, `altText`, `priority`, `order`, and `downloadUrl`.
|
||||||
|
|
||||||
- `id`
|
### SPC Convective Outlooks
|
||||||
- `provider`
|
|
||||||
- `product`
|
|
||||||
- `day`
|
|
||||||
- `outlookType`
|
|
||||||
- `label`
|
|
||||||
- `labelText`
|
|
||||||
- `forecaster`
|
|
||||||
- `severityRank`
|
|
||||||
- `validFrom`
|
|
||||||
- `validTo`
|
|
||||||
- `issuedAt`
|
|
||||||
- `expiresAt`
|
|
||||||
- `sourceUrl`
|
|
||||||
- `imageUrl`
|
|
||||||
- `containsLocation`
|
|
||||||
- `geometry`
|
|
||||||
|
|
||||||
Discussion fields consumed:
|
`/outlooks/convective` uses run-level `locationId`, `locationName`, `asOf`,
|
||||||
|
`issuedAt`, `updatedAt`, `product`, `outlooks`, and `discussions`.
|
||||||
|
|
||||||
- `day`
|
Each outlook uses `id`, `provider`, `product`, `day`, `outlookType`, `label`,
|
||||||
- `headline`
|
`labelText`, `forecaster`, `severityRank`, `validFrom`, `validTo`, `issuedAt`,
|
||||||
- `summary`
|
`expiresAt`, `sourceUrl`, `imageUrl`, `containsLocation`, and GeoJSON
|
||||||
- `discussion`
|
`geometry`. Each discussion uses `day`, `headline`, `summary`, `discussion`,
|
||||||
- `updatedAt`
|
and `updatedAt`.
|
||||||
|
|
||||||
GeoJSON `geometry` is decoded into collected weather facts and persisted in
|
## Timeouts, Retries, And Failures
|
||||||
bundle/debug artifacts, but prompt-facing SPC module output omits geometry.
|
|
||||||
|
|
||||||
## Endpoints Used
|
The configured Weather API timeout applies to each warmup and source HTTP
|
||||||
|
attempt. Weatherreporter retries transient transport and response-read failures
|
||||||
|
and these response statuses: `408`, `429`, `500`, `502`, `503`, and `504`.
|
||||||
|
It does not retry other HTTP statuses, malformed envelopes, missing data, or
|
||||||
|
payload decoding failures. A canceled context also stops an in-progress retry
|
||||||
|
delay.
|
||||||
|
|
||||||
The adapter fetches these endpoints once per bundle:
|
The adapter reads at most 10 MiB from one response body. A non-2xx response,
|
||||||
|
request construction failure, read failure, or decode failure includes endpoint
|
||||||
|
context in its error.
|
||||||
|
|
||||||
- `/observations`
|
Retry counts and delays are adapter behavior rather than Weather API request
|
||||||
- `/conditions/current`
|
parameters. Do not depend on a particular attempt count when implementing the
|
||||||
- `/forecast/hourly`
|
service.
|
||||||
- `/forecast/narrative`
|
|
||||||
- `/alerts/active`
|
|
||||||
- `/discussion`
|
|
||||||
- `/weatherstories/latest`
|
|
||||||
- `/outlooks/convective`
|
|
||||||
|
|
||||||
`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.
|
|
||||||
|
|
||||||
## Required And Optional Sources
|
|
||||||
|
|
||||||
Hourly forecast is required:
|
|
||||||
|
|
||||||
- `data: null` for `/forecast/hourly` fails the fetch.
|
|
||||||
- an hourly forecast with no `periods` fails the fetch.
|
|
||||||
- malformed hourly data fails the fetch.
|
|
||||||
|
|
||||||
Other fetched sources are optional and follow `missing_source.default` or a
|
|
||||||
source-specific `missing_source.sources` policy:
|
|
||||||
|
|
||||||
- `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` for `/outlooks/convective`
|
|
||||||
|
|
||||||
Policy behavior:
|
|
||||||
|
|
||||||
- `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
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
For `/outlooks/convective`, a non-null data object with empty outlook and
|
|
||||||
discussion arrays is accepted as checked empty data.
|
|
||||||
|
|
||||||
## Source Identity
|
|
||||||
|
|
||||||
For source payloads accepted into the bundle, including the explicit `null`
|
|
||||||
alerts payload, the adapter records:
|
|
||||||
|
|
||||||
- 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 weather data
|
|
||||||
bundle types in `internal/weatherdata/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
|
|
||||||
- SPC convective outlook run metadata, outlooks, discussions, and GeoJSON
|
|
||||||
geometry
|
|
||||||
|
|
||||||
The adapter intentionally keeps upstream transport and envelope details inside
|
|
||||||
`internal/adapters/weatherapi`; downstream packages consume the normalized
|
|
||||||
bundle.
|
|
||||||
|
|||||||
@@ -1,188 +1,48 @@
|
|||||||
# App Orchestration Internals
|
# Application Orchestration Internals
|
||||||
|
|
||||||
This document describes the workflow coordinator in `internal/app`.
|
`internal/app` owns top-level generation, batch, collection, inspection, and
|
||||||
|
notification ordering after the CLI has parsed arguments and loaded configuration.
|
||||||
|
|
||||||
## Purpose
|
## Generation
|
||||||
|
|
||||||
`internal/app` coordinates the top-level use cases after CLI parsing and config
|
`GenerateDetailed` resolves one of the four report definitions, initializes an
|
||||||
loading are complete. It resolves report definitions, fetches weather data,
|
optional debug root, and inspects the exact Promptkit prompt/profile before it
|
||||||
builds collected and derived facts, builds module snapshots and prompt-input
|
collects weather or writes managed state. It then builds facts and modules,
|
||||||
artifacts, invokes Scriptorium through the adapter boundary, optionally
|
saves the YAML data package, persists preparation metadata from the executor
|
||||||
notifies distributor through an app-owned notifier boundary, persists managed
|
callback, executes the prepared prompt, saves execution provenance and raw
|
||||||
state, runs batches, and reads existing artifacts for inspection.
|
output, validates generated text, renders Markdown, and optionally copies or
|
||||||
|
notifies from the managed report.
|
||||||
|
|
||||||
## Inputs And Outputs
|
After a completed prompt run, each successfully written downstream artifact is
|
||||||
|
atomically added to the execution record before the corresponding metadata
|
||||||
|
rewrite. Later failures therefore leave the original Promptkit outcome and its
|
||||||
|
last durable set of reached paths inspectable.
|
||||||
|
|
||||||
Inputs:
|
Failure results retain all safe paths reached so far. Validation rejection
|
||||||
|
persists raw output and execution provenance but does not render a report.
|
||||||
|
|
||||||
- `GenerateRequest` for one report command
|
## Batches
|
||||||
- `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
|
|
||||||
|
|
||||||
Outputs:
|
`RunBatchDetailed` constructs a single debug writer and uses the request's
|
||||||
|
single executor. Before collection it inspects Today, Tomorrow, and Daily for
|
||||||
|
morning, or Tomorrow and Daily for evening, deduplicating effective profile
|
||||||
|
inspection. It then collects once, plans eligible Daily dates, and calls the
|
||||||
|
same prompt-generation core sequentially for each planned report. Per-report
|
||||||
|
notification is suppressed; a failed report does not stop later reports.
|
||||||
|
|
||||||
- generated report results with JSON module snapshot, YAML data package,
|
Batch notification is skipped when disabled or when any report failed.
|
||||||
preflight, report, metadata, prior snapshot, Recent Changes, Scriptorium
|
Successful notification uses the completed managed report paths only. Batch
|
||||||
result details, generated-text artifact paths when applicable, and
|
items retain preparation, execution, and optional debug paths when reached.
|
||||||
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
|
|
||||||
|
|
||||||
## Boundaries
|
## Inspection And Boundaries
|
||||||
|
|
||||||
`internal/app` owns workflow order and request composition. It does not parse
|
Inspection loads persisted state only. It does not collect weather, invoke
|
||||||
CLI flags, load YAML files directly, implement HTTP transport, own fact
|
Promptkit, or upload reports. The app coordinates project-owned contracts but
|
||||||
derivation algorithms, define report periods, compare rendered Markdown, or
|
does not parse flags, load YAML, implement transport, construct provider SDKs,
|
||||||
construct Scriptorium argv.
|
or define report-period policy.
|
||||||
|
|
||||||
Report selection and report identity policy come from `internal/report`.
|
Focused checks:
|
||||||
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`.
|
|
||||||
|
|
||||||
## Data Flow Terms
|
```sh
|
||||||
|
go test ./internal/app ./internal/collect
|
||||||
- `CollectedFacts` are normalized source facts fetched once from Weather API
|
```
|
||||||
and made available to derivation and module builders.
|
|
||||||
- `DerivedFacts` are deterministic calculations over collected facts, the
|
|
||||||
resolved valid period, daypart configuration, and report-specific windows.
|
|
||||||
- `module.Output` values are ordered deterministic stanzas built from collected
|
|
||||||
and derived facts for prompt input and inspection.
|
|
||||||
- `GeneratedText` is structured prose returned by Scriptorium for
|
|
||||||
generated-text-template reports and validated by `internal/generatedtext`.
|
|
||||||
- `RenderContext` is the typed template input built from report metadata,
|
|
||||||
module outputs, and validated generated text before Markdown rendering.
|
|
||||||
|
|
||||||
## Config Fields Used
|
|
||||||
|
|
||||||
- `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
|
|
||||||
|
|
||||||
Output copy flags are command request fields. They are not configuration
|
|
||||||
defaults.
|
|
||||||
|
|
||||||
## Generation Workflow
|
|
||||||
|
|
||||||
Single-report generation shares this setup:
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
For `scriptorium_markdown` reports, generation then:
|
|
||||||
|
|
||||||
12. Runs Scriptorium report generation to the managed report path.
|
|
||||||
13. Copies the managed report to the requested `--out` path when provided.
|
|
||||||
14. Saves metadata with the managed report path.
|
|
||||||
15. If distributor notification is enabled, notifies using the managed report
|
|
||||||
path as the source file.
|
|
||||||
16. Saves a distributor notification debug artifact and updates metadata with
|
|
||||||
its path.
|
|
||||||
|
|
||||||
For `generated_text_template` reports, generation then:
|
|
||||||
|
|
||||||
12. Runs structured Scriptorium generation to the raw generated-text JSON path.
|
|
||||||
13. Saves the structured Scriptorium run result.
|
|
||||||
14. Validates and saves normalized generated text.
|
|
||||||
15. Builds and saves a typed render context.
|
|
||||||
16. Renders Markdown from the embedded template to the managed report path.
|
|
||||||
17. Saves final metadata with generated-text paths, render context path, schema
|
|
||||||
ID, and managed report path.
|
|
||||||
18. Copies the managed report to the requested `--out` path when provided.
|
|
||||||
19. If distributor notification is enabled, notifies using the managed report
|
|
||||||
path as the source file.
|
|
||||||
20. Saves a distributor notification debug artifact and updates metadata with
|
|
||||||
its path.
|
|
||||||
|
|
||||||
If render preflight returns both a result and an error, preflight JSON and
|
|
||||||
metadata are persisted before the error is returned. If Scriptorium report
|
|
||||||
generation returns an error after writing output, the managed report and
|
|
||||||
metadata remain inspectable. Notification is not attempted after Weather API,
|
|
||||||
module snapshot, prompt input, render, Scriptorium run, or metadata-save
|
|
||||||
failures.
|
|
||||||
Generated-text report failures are returned with report ID, RunID, and the
|
|
||||||
failed operation. When available, the app preserves the latest generated-text
|
|
||||||
artifacts already reached by the workflow: preflight output, structured run
|
|
||||||
result, raw generated text, validated generated text, and render context.
|
|
||||||
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 Tomorrow Report. 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.
|
|
||||||
- Generated-text report errors preserve available intermediate artifacts and do
|
|
||||||
not create extra output copies.
|
|
||||||
- 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.
|
|
||||||
- Generated-text reports render Markdown from a curated render context, not from
|
|
||||||
a raw data package.
|
|
||||||
- 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.
|
|
||||||
|
|||||||
@@ -1,140 +1,69 @@
|
|||||||
# Module Builder Internals
|
# 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
|
Every `ModuleDefinition` declares an ID, stanza name, default option value,
|
||||||
forecast facts into prompt-facing module outputs. The package also owns the
|
required collected and derived facts, supported report IDs, missing-data
|
||||||
module registry used to validate report composition and config overrides.
|
behavior, duplicate policy, builder, and optional prompt exporter.
|
||||||
|
|
||||||
Module outputs are structured prompt inputs. They are not rendered report prose
|
`BuildModule` first verifies the requested module, report compatibility, and
|
||||||
and they are not persisted by this package.
|
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
|
## Built value families
|
||||||
- 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
|
|
||||||
|
|
||||||
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,
|
The module registry preserves rich values for templates and snapshots while
|
||||||
supported reports, fact requirements, missing-data behavior, and builder
|
curating prompt exports where needed. In particular, source warnings are a
|
||||||
- `module.Output` values for source-oriented stanzas:
|
metadata summary, checked-empty alerts and SPC outlooks remain distinct from
|
||||||
`metadata`, `current_conditions`, `narrative_forecast`, `hourly_forecast`,
|
missing sources, and prompt-safe SPC values omit geometry and other
|
||||||
`alert_digest`, `spc_convective_outlooks`,
|
template-only or source details. The complete module composition is in
|
||||||
`area_forecast_discussion`, `spc_convective_discussion`, and
|
[module internals](module.md); fact derivation is in [fact contracts](facts.md).
|
||||||
`weather_story`
|
|
||||||
- `module.Output` values for derived stanzas:
|
|
||||||
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
|
|
||||||
`outdoor_windows`, and `tomorrow_planning`
|
|
||||||
|
|
||||||
Every registered composition entry has a builder. Unknown or unimplemented
|
`area_forecast_discussion` accepts an optional typed section filter. Planning
|
||||||
module IDs fail validation instead of being skipped.
|
modules are report-specific: `daily_planning` supports Daily,
|
||||||
|
`today_planning` supports Today, and `tomorrow_planning` supports Tomorrow.
|
||||||
|
|
||||||
Tomorrow Report supports the Daily-style civil-day modules plus
|
## Missing data and boundaries
|
||||||
`tomorrow_planning` and `hourly_forecast`; those outputs feed the Tomorrow
|
|
||||||
GeneratedText prompt package and embedded Markdown template.
|
|
||||||
|
|
||||||
Hourly Report supports source and valid-period modules that operate over its
|
Optional current conditions, narrative products, discussions, and weather
|
||||||
rolling six-hour period: `metadata`, `current_conditions`, `hourly_forecast`,
|
stories may be omitted. Required derived modules fail when their declared facts
|
||||||
`precip_timing`, `alert_digest`, `spc_convective_outlooks`,
|
are unavailable. Empty alert and outlook runs can still produce checked-empty
|
||||||
`area_forecast_discussion`, `spc_convective_discussion`, and `weather_story`.
|
modules. SPC discussion is omitted unless a retained categorical outlook meets
|
||||||
It does not support daily/daypart-only modules such as
|
the package's severity criterion and matching discussion text exists.
|
||||||
`derived_daily_summary`, `derived_daypart_summaries`, `outdoor_windows`, or
|
|
||||||
`tomorrow_planning`.
|
|
||||||
|
|
||||||
Prompt-facing module values use local, human-readable date and time labels
|
Effective units, timezone, and location context arrive in `ModuleContext` from
|
||||||
where the LLM is expected to reason about report content. Canonical timestamps
|
configuration and resolved report metadata. Field defaults are owned by
|
||||||
remain in report metadata, source provenance, and integration artifacts.
|
[configuration](../config.md), and prompt-package layout is owned by
|
||||||
|
[prompt input](prompt-input.md).
|
||||||
|
|
||||||
## Boundaries
|
## Verification and invariants
|
||||||
|
|
||||||
- This package selects and shapes already-collected weather facts for prompts.
|
Focused tests cover source and derived values, registry validation, option
|
||||||
- It validates module composition against report compatibility and option
|
handling, prompt exporters, support rules, and missing-data behavior:
|
||||||
types.
|
|
||||||
- It does not fetch weather data, compare prior snapshots, write module
|
|
||||||
snapshots, build YAML data packages, invoke Scriptorium, or write workflow
|
|
||||||
metadata.
|
|
||||||
|
|
||||||
## Config Fields Used
|
```sh
|
||||||
|
go test ./internal/briefing
|
||||||
|
```
|
||||||
|
|
||||||
The app layer passes effective units, timezone, and location context into the
|
Builders emit structured facts, never report prose. The app collects their
|
||||||
module context. `internal/facts` consumes daypart configuration before module
|
outputs into a module snapshot, and state persists that snapshot.
|
||||||
builders run. Configured `location` values are prompt context only; Weather API
|
|
||||||
`sourceLocationId` and `sourceLocation` remain source provenance.
|
|
||||||
|
|
||||||
`area_forecast_discussion` uses optional `sections` configuration to include a
|
|
||||||
subset of discussion fields. Hourly Report defaults this module to
|
|
||||||
`key_messages` and `short_term`.
|
|
||||||
|
|
||||||
`spc_convective_outlooks` uses collected SPC run metadata and derived
|
|
||||||
report-period outlooks. It emits `checked: true` for a successfully fetched
|
|
||||||
empty run, reports `outlook_count`, and includes prompt-facing outlook fields
|
|
||||||
such as risk label, `period_begins`, `period_ends`, image URL, and whether the
|
|
||||||
outlook contains the configured location. It does not emit GeoJSON geometry,
|
|
||||||
source URL, expiration time, or severity rank.
|
|
||||||
|
|
||||||
Prompt-facing module intervals use friendly local `period_begins` and
|
|
||||||
`period_ends` labels. Canonical report metadata, source provenance,
|
|
||||||
`issued_at`, `updated_at`, and point-in-time fields remain separate.
|
|
||||||
|
|
||||||
`spc_convective_discussion` uses the same derived report-period outlooks and
|
|
||||||
discussion records. It is omitted unless at least one retained categorical
|
|
||||||
outlook for the same SPC day has severity rank `3` or higher and matching
|
|
||||||
discussion text exists.
|
|
||||||
|
|
||||||
## External Adapters Used
|
|
||||||
|
|
||||||
None directly.
|
|
||||||
|
|
||||||
## State Or Manifest Behavior
|
|
||||||
|
|
||||||
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.
|
|
||||||
- SPC convective outlook output distinguishes checked empty outlook data from
|
|
||||||
missing outlook source data and omits GeoJSON geometry from prompt-facing
|
|
||||||
fields.
|
|
||||||
- SPC convective discussion output is omitted unless a retained outlook has
|
|
||||||
severity rank `3` or higher and matching discussion text is available.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|||||||
@@ -1,75 +1,56 @@
|
|||||||
# Changes Internals
|
# Changes Internals
|
||||||
|
|
||||||
This document describes structured Recent Changes comparison.
|
`internal/changes` deterministically compares a compatible prior module
|
||||||
|
snapshot with the current snapshot. It returns compact structured changes for
|
||||||
|
prompt input; it never reads state, finds a prior report, renders Markdown, or
|
||||||
|
compares generated text. Snapshot construction belongs to
|
||||||
|
[module internals](module.md), and prior-snapshot discovery belongs to
|
||||||
|
[state internals](state.md).
|
||||||
|
|
||||||
## Purpose
|
## Comparison inputs and output
|
||||||
|
|
||||||
`internal/changes` compares current and prior module snapshots and emits
|
Each comparator receives a prior snapshot, a current snapshot, and
|
||||||
compact change records for prompt input data packages.
|
`Thresholds`. A `Change` has a stable type and message plus previous and
|
||||||
|
current values where useful. Changes are sorted by type and then message, so
|
||||||
|
the same inputs always yield the same order.
|
||||||
|
|
||||||
## Inputs And Outputs
|
Threshold values are supplied by application orchestration from the
|
||||||
|
[Recent Changes configuration](../config.md#recent_change); this package does
|
||||||
|
not load configuration or choose defaults. Numeric changes are emitted when
|
||||||
|
the absolute difference meets the configured threshold. Precipitation also
|
||||||
|
requires a change between its low, possible, likely, and high categories.
|
||||||
|
|
||||||
Inputs:
|
## Strategies
|
||||||
|
|
||||||
- prior module snapshot
|
| Comparator | Required snapshot data | Compared values |
|
||||||
- current module snapshot
|
| --- | --- | --- |
|
||||||
- comparison thresholds from configuration
|
| `CompareDaily` | `derived_daily_summary`, `derived_daypart_summaries` | Low and high temperature, daily precipitation probability and timing, peak gust, alerts, and aggregate indicators |
|
||||||
|
|
||||||
Outputs:
|
For daily comparison, `alert_digest` and `precip_timing` are optional: alerts
|
||||||
|
are compared when present, and timing is compared only when both snapshots
|
||||||
|
contain it.
|
||||||
|
|
||||||
- ordered `changes.Change` items with type, message, previous value, and current
|
The application selects a comparator only after state lookup establishes a
|
||||||
value where useful
|
compatible prior snapshot. Daily, Today, and Tomorrow use the daily comparator.
|
||||||
|
Hourly reports do not produce a Recent Changes list.
|
||||||
|
|
||||||
## Boundaries
|
## Missing data and failures
|
||||||
|
|
||||||
- This package compares structured module snapshot data only.
|
Required stanzas that are absent or cannot be decoded return an error with the
|
||||||
- It does not read filesystem state, find prior snapshots, render Markdown,
|
snapshot and stanza context. Optional stanzas may be absent. A snapshot with no
|
||||||
invoke Scriptorium, or compare generated report text.
|
eligible predecessor is not a comparison failure: the caller supplies an empty
|
||||||
|
change list without invoking this package.
|
||||||
|
|
||||||
## Config Fields Used
|
The package has no filesystem, transport, CLI, renderer, or persistence
|
||||||
|
behavior. It does not decide report compatibility or retain snapshots.
|
||||||
|
|
||||||
The app maps these fields into comparison thresholds:
|
## Verification and invariants
|
||||||
|
|
||||||
- `recent_change.temperature_degrees`
|
Focused tests cover the daily strategy, threshold boundaries, indicator and
|
||||||
- `recent_change.precip_probability_points`
|
alert changes, and missing required stanzas:
|
||||||
- `recent_change.wind_gust_miles_per_hour`
|
|
||||||
- `recent_change.precip_timing_shift_minutes`
|
|
||||||
|
|
||||||
## External Adapters Used
|
```sh
|
||||||
|
go test ./internal/changes
|
||||||
|
```
|
||||||
|
|
||||||
None.
|
Recent Changes always compare structured snapshot values, never report prose.
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|||||||
27
docs/internal/cli.md
Normal file
27
docs/internal/cli.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# CLI Internals
|
||||||
|
|
||||||
|
`internal/cli` parses terminal arguments, loads configuration, constructs app
|
||||||
|
requests, and translates app results to bounded JSON summaries. The user
|
||||||
|
contract belongs in the [CLI reference](../cli.md).
|
||||||
|
|
||||||
|
The root `--version` flag reports the build version supplied by
|
||||||
|
`internal/buildinfo`. Tagged release builds replace its development default at
|
||||||
|
link time.
|
||||||
|
|
||||||
|
For each `generate` or `run` action, `Runner` constructs one project-owned
|
||||||
|
Promptkit executor after configuration loads. It passes the executor and any
|
||||||
|
`--llm-debug-dir` request into the app. `run` accepts the debug flag as well
|
||||||
|
as `generate`; the app, not the CLI, secures and initializes the debug root.
|
||||||
|
|
||||||
|
Summaries include identity, status, safe artifact paths, and notification
|
||||||
|
provenance. They intentionally exclude module values, YAML package bodies, raw
|
||||||
|
generated text, rendered prompts, schemas, endpoints, credentials, and full
|
||||||
|
Distributor payloads. A failed action with a partial result still emits its
|
||||||
|
safe summary before its error is returned.
|
||||||
|
|
||||||
|
CLI code owns no report policy, weather collection, persistence, provider
|
||||||
|
execution, or notification policy. Focused checks:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./internal/cli
|
||||||
|
```
|
||||||
43
docs/internal/collect.md
Normal file
43
docs/internal/collect.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Collection Internals
|
||||||
|
|
||||||
|
`internal/collect` is the application-facing boundary for collecting the
|
||||||
|
normalized Weather API bundle. The external HTTP contract belongs in the
|
||||||
|
[Weather API integration guide](../integrations/weatherapi.md); normalized data
|
||||||
|
semantics belong in [weather-data internals](weather-data.md).
|
||||||
|
|
||||||
|
## Contract
|
||||||
|
|
||||||
|
`Run` accepts a `context.Context` and a `Request` containing effective
|
||||||
|
`config.Config`. It constructs the Weather API adapter from that configuration,
|
||||||
|
calls `FetchBundle`, and returns `Result{Bundle: *weatherdata.Bundle}`.
|
||||||
|
|
||||||
|
The package wraps adapter construction failures as weather-collection setup
|
||||||
|
errors and fetch failures as bundle-collection errors. It does not retry,
|
||||||
|
persist, select reports, derive facts, build modules, invoke Promptkit, or
|
||||||
|
notify Distributor.
|
||||||
|
|
||||||
|
## Application Composition
|
||||||
|
|
||||||
|
`internal/app` owns the narrow `Collector` interface used by workflow tests;
|
||||||
|
the production implementation delegates to `collect.Run`. Generation, batch
|
||||||
|
execution, and explicit bundle fetching all use this boundary. Application
|
||||||
|
orchestration rejects a nil collector result or a nil bundle before report work
|
||||||
|
can continue.
|
||||||
|
|
||||||
|
Single-report generation and a batch each collect once. A batch passes the same
|
||||||
|
normalized collection to planning and to every report it generates. Collection
|
||||||
|
failure prevents later workflow work for that request.
|
||||||
|
|
||||||
|
## Boundaries And Invariants
|
||||||
|
|
||||||
|
Collection owns adapter creation and retrieval of one normalized bundle. It
|
||||||
|
must not make report, period, batch, prompt, module, filesystem, or notification
|
||||||
|
decisions.
|
||||||
|
|
||||||
|
- App-facing Weather API collection always passes through this package.
|
||||||
|
- The returned value is normalized source data, not facts or prompt input.
|
||||||
|
- Context cancellation is passed to the Weather API adapter.
|
||||||
|
- Errors retain whether setup or fetching failed.
|
||||||
|
|
||||||
|
Focused tests are in `internal/collect/collect_test.go`; orchestration use is
|
||||||
|
also covered by `internal/app/app_test.go`.
|
||||||
@@ -1,123 +1,63 @@
|
|||||||
# Distributor Adapter Internals
|
# Distributor Adapter Internals
|
||||||
|
|
||||||
This document describes the distributor upload adapter in
|
`internal/adapters/distributor` translates a local delivery request into the
|
||||||
`internal/adapters/distributor`.
|
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
|
`Client` holds the endpoint, the name of the environment variable containing
|
||||||
distributor HTTP upload endpoint. It isolates distributor package types,
|
the token, an optional timeout, and an injectable upstream-client factory.
|
||||||
token-env lookup, upload client construction, source-bundle file mapping,
|
`New` validates its configuration before creating the adapter. For each upload,
|
||||||
timeout handling, status polling, and upload error wrapping from app
|
the adapter reads the token from the configured environment variable and builds
|
||||||
orchestration.
|
the upstream client with that endpoint, token, and an HTTP client whose timeout
|
||||||
|
matches the local positive timeout.
|
||||||
|
|
||||||
## 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
|
Before calling the dependency, `Upload` validates the endpoint and token
|
||||||
- token environment variable name
|
configuration plus the local pipeline ID, bundle ID, idempotency key, and every
|
||||||
- upload timeout
|
file's source and bundle paths. It maps the request as follows:
|
||||||
- pipeline ID
|
|
||||||
- bundle ID
|
|
||||||
- idempotency key
|
|
||||||
- source Markdown report path and bundle-relative path mappings
|
|
||||||
- bundle created timestamp
|
|
||||||
- context for cancellation
|
|
||||||
|
|
||||||
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
|
The call inherits the caller's context and applies the configured positive
|
||||||
- accepted distributor upload status
|
timeout. The adapter does not read report files, construct bundle layouts, or
|
||||||
- distributor run status, status polling error, and raw run report JSON when available
|
persist notification artifacts.
|
||||||
- weatherreporter-owned idempotency conflict error when applicable
|
|
||||||
|
|
||||||
## Boundaries
|
## Status and errors
|
||||||
|
|
||||||
`internal/adapters/distributor` is the only weatherreporter package that imports
|
An accepted upload is followed by one status request. When a timeout is
|
||||||
`gitea.maximumdirect.net/eric/distributor/pkg/upload` or
|
configured, a nonterminal result is polled until `succeeded` or `failed`, or
|
||||||
`gitea.maximumdirect.net/eric/distributor/pkg/bundle`.
|
until the context ends. The translated `UploadResult` contains the run ID,
|
||||||
|
status, and `RunStatus`, including pipeline ID, lifecycle timestamps, report,
|
||||||
|
and remote error details.
|
||||||
|
|
||||||
The app layer passes weatherreporter-owned request values to the adapter. The
|
Status lookup or polling errors are preserved in `UploadResult.StatusError` so
|
||||||
adapter does not choose report types, render templates, select output copies,
|
the caller can record an accepted-but-unconfirmed delivery. A terminal failed
|
||||||
configure destinations, wait for downstream publication, transform Markdown, or
|
run returns that result and an error. Upload failures return no result. Upstream
|
||||||
persist notification state.
|
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
|
## Verification
|
||||||
`docs/integrations/distributor/`.
|
|
||||||
|
|
||||||
## Config Fields Used
|
Focused tests cover configuration validation, request mapping, timeouts and
|
||||||
|
polling, status translation, conflict handling, and token redaction:
|
||||||
|
|
||||||
The adapter is built from `notify.distributor` config:
|
```sh
|
||||||
|
go test ./internal/adapters/distributor
|
||||||
- `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.
|
|
||||||
|
|||||||
@@ -1,94 +1,64 @@
|
|||||||
# Fact Contracts Internals
|
# 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
|
`BuildCollected` projects a `weatherdata.Bundle` into `CollectedFacts`. It
|
||||||
from conservative report-scoped facts derived from them. The package gives app
|
retains the fetched timestamp and every normalized product: observations,
|
||||||
orchestration one place to build reusable facts before module execution.
|
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
|
A nil bundle produces an empty collected value. Collection itself, missing
|
||||||
|
source policy, and source hashes are outside this package.
|
||||||
|
|
||||||
Inputs:
|
## Report-scoped derivation
|
||||||
|
|
||||||
- `weatherdata.Bundle` from the Weather API adapter
|
`BuildDerived` requires a valid resolved period and a valid report timezone. It
|
||||||
- resolved report definition and valid period
|
uses half-open period overlap to select hourly, narrative, daily, and alert
|
||||||
- report timezone
|
data; it also derives precipitation timing. Convective outlooks are retained
|
||||||
- configured daypart definitions
|
only when their valid interval overlaps the report period, with discussions
|
||||||
|
kept for represented outlook days. Both collections are sorted deterministically.
|
||||||
|
|
||||||
Outputs:
|
Report identity controls the summary shape:
|
||||||
|
|
||||||
- `facts.CollectedFacts` with normalized source facts plus separate source
|
| Report family | Derived summary |
|
||||||
provenance and warnings. SPC convective outlook source data is carried
|
| --- | --- |
|
||||||
through when present in the bundle, including upstream geometry and source
|
| Hourly | Rolling-period selections and precipitation timing; no daily or daypart summary |
|
||||||
provenance.
|
| Daily, Today, Tomorrow | One local civil-day summary and its dayparts |
|
||||||
- `facts.DerivedFacts` with valid-period forecast slices, alert overlaps,
|
|
||||||
report-period SPC convective outlooks and discussions, daily summaries,
|
|
||||||
daypart summaries, and Storm Report window summary
|
|
||||||
|
|
||||||
Hourly Report uses the generic valid-period hourly and narrative selection
|
`DaypartSummaries` is collected from the resulting daily summaries.
|
||||||
for its rolling six-hour window. Its derived facts include precipitation timing
|
The detailed grouping, daypart-window, and alert rules are owned by
|
||||||
from the selected hourly periods, alert overlaps for the six-hour period, and
|
[forecast derivation](forecast-derivation.md).
|
||||||
SPC outlooks/discussions overlapping that period. It does not build daily
|
|
||||||
summaries, daypart summaries, or a storm-window summary.
|
|
||||||
|
|
||||||
## Boundaries
|
## Missing data and failures
|
||||||
|
|
||||||
- This package owns fact assembly and reusable deterministic derivation for a
|
Optional normalized products remain nil or yield empty selections; the package
|
||||||
report run.
|
does not create substitute values. A present convective-outlook run with no
|
||||||
- SPC convective outlook derivation selects already-collected outlooks whose
|
matching outlooks produces non-nil empty outlook and discussion slices, while
|
||||||
half-open valid intervals overlap the resolved report period and retains
|
a missing run produces nil slices.
|
||||||
discussions for represented outlook days.
|
|
||||||
- Derived SPC outlook records preserve the collected outlook fields, including
|
|
||||||
geometry, for downstream components that need source-level facts. Prompt
|
|
||||||
modules decide which fields are exposed to Scriptorium.
|
|
||||||
- It does not fetch upstream data, build prompt wording, compare prior
|
|
||||||
snapshots, write workflow state, invoke Scriptorium, or define modules.
|
|
||||||
|
|
||||||
## Config Fields 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.
|
||||||
|
|
||||||
- `dayparts[].name`
|
## Verification and invariants
|
||||||
- `dayparts[].start`
|
|
||||||
- `dayparts[].end`
|
|
||||||
- `weather_api.timezone`
|
|
||||||
|
|
||||||
## External Adapters Used
|
Focused tests cover collected-fact separation, report-period selection,
|
||||||
|
hourly behavior, daily summaries, and convective outlook selection:
|
||||||
|
|
||||||
None directly. Collected facts are built from `weatherdata.Bundle`.
|
```sh
|
||||||
|
go test ./internal/facts
|
||||||
|
```
|
||||||
|
|
||||||
## State Or Manifest Behavior
|
Facts are derived once for a resolved report from already collected data.
|
||||||
|
They remain reusable structured values: prompt wording, state persistence,
|
||||||
None. Source provenance and warnings remain data fields for downstream metadata
|
prior-report comparison, and template presentation are owned elsewhere.
|
||||||
and inspection.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
- Hourly Report can derive its default module facts without daily or
|
|
||||||
daypart summaries.
|
|
||||||
- Missing optional narrative, alert, discussion, daily, or weather story data
|
|
||||||
produces empty or nil derived fields.
|
|
||||||
- Missing optional SPC convective outlook data produces a nil collected field.
|
|
||||||
- A present SPC convective outlook source with no report-period matches
|
|
||||||
produces non-nil empty derived outlook and discussion slices.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
- SPC convective outlook selection uses the resolved report period and the
|
|
||||||
already-collected outlook run.
|
|
||||||
- Source provenance and warnings stay separate from ordinary fact fields.
|
|
||||||
- Prompt-specific wording and one-off presentation decisions stay outside this
|
|
||||||
package.
|
|
||||||
|
|||||||
@@ -1,76 +1,63 @@
|
|||||||
# Forecast Derivation Internals
|
# Forecast Derivation Internals
|
||||||
|
|
||||||
This document describes deterministic forecast summarization in
|
`internal/forecast` deterministically selects and summarizes normalized
|
||||||
`internal/forecast`.
|
forecast data. It has no transport, filesystem, CLI, subprocess, or report
|
||||||
|
registry dependency. Its summaries are consumed by
|
||||||
|
[fact contracts](facts.md) and later module builders.
|
||||||
|
|
||||||
## Purpose
|
## Period and daypart semantics
|
||||||
|
|
||||||
`internal/forecast` converts normalized weather data into daily and period
|
Selections use `timeutil.Period` half-open overlap: a value is selected only
|
||||||
summaries used by fact builders and module builders.
|
when both intervals share time. `BuildDailySummary` creates one local civil
|
||||||
|
day; `BuildPeriodDailySummaries` intersects every local civil day with the
|
||||||
|
requested period, preserving partial first and last days.
|
||||||
|
|
||||||
## Inputs And Outputs
|
`ResolveDayparts` converts each configured name, start clock, and end clock
|
||||||
|
into a local window. An end clock at or before its start clock wraps into the
|
||||||
|
next civil day. The daypart and timezone defaults are defined in the
|
||||||
|
[configuration reference](../config.md), not here.
|
||||||
|
|
||||||
Inputs:
|
## Deterministic summaries
|
||||||
|
|
||||||
- `weatherdata.Bundle`
|
`BuildDailySummary` requires an hourly run with at least one period. It adds
|
||||||
- local date or resolved report period
|
the selected narrative periods, discussion, alert overlaps, source provenance,
|
||||||
- timezone
|
source warnings, and one `DaypartSummary` per resolved window. A daypart keeps
|
||||||
- configured daypart definitions
|
its selected hourly periods and derives temperature and apparent-temperature
|
||||||
|
ranges, timed precipitation and wind maxima, dominant and notable conditions,
|
||||||
|
and weather indicators.
|
||||||
|
|
||||||
Outputs:
|
Indicators are deterministic checks over normalized values and condition text:
|
||||||
|
heat, cold, and wind use package-owned numeric cutoffs; snow, ice, fog, and
|
||||||
|
wind text are detected from the forecast description. `BuildPrecipTiming`
|
||||||
|
sorts periods, records the maximum and first precipitation, groups contiguous
|
||||||
|
periods at or above its package-owned probability threshold, and records
|
||||||
|
thunder mentions.
|
||||||
|
|
||||||
- `forecast.DailySummary` for one local civil day
|
Alert overlap parsing supports the normalized alert payload's available timing
|
||||||
- one clipped daily summary per local day or partial day from
|
fields. Unparseable alerts and invalid intervals are ignored; valid overlaps
|
||||||
`BuildPeriodDailySummaries`
|
are clipped to the requested period and ordered by alert start time.
|
||||||
- daypart summaries with selected hourly periods, ranges, timed maximums,
|
|
||||||
conditions, indicators, and alert overlaps
|
|
||||||
|
|
||||||
## Boundaries
|
## Missing data and failures
|
||||||
|
|
||||||
- This package groups, selects, and summarizes already-normalized forecast
|
Empty selections yield empty summary fields rather than generated prose.
|
||||||
data.
|
Direct daily or period-summary calls fail when their required bundle, valid
|
||||||
- It does not perform HTTP calls, parse CLI flags, resolve report definitions,
|
period, hourly data, or daypart definitions are invalid. A nil location uses
|
||||||
compare prior snapshots, build prompt input packages, or invoke Scriptorium.
|
UTC when these APIs are called directly. Optional narrative, discussion, and
|
||||||
|
alerts remain absent when their normalized products are absent.
|
||||||
|
|
||||||
## Config Fields Used
|
Forecast thresholds used for brief indicators and precipitation timing are
|
||||||
|
implementation rules. User-configurable Recent Changes thresholds are applied
|
||||||
|
by [changes internals](changes.md), whose defaults are documented in
|
||||||
|
[configuration](../config.md).
|
||||||
|
|
||||||
- `dayparts[].name`
|
## Verification and invariants
|
||||||
- `dayparts[].start`
|
|
||||||
- `dayparts[].end`
|
|
||||||
|
|
||||||
Threshold constants for basic indicators live in forecast code rather than
|
Focused tests cover local civil days, clipped periods, daypart resolution,
|
||||||
configuration.
|
summary metrics, precipitation windows, threshold helpers, and alert overlap:
|
||||||
|
|
||||||
## External Adapters Used
|
```sh
|
||||||
|
go test ./internal/forecast ./internal/timeutil
|
||||||
|
```
|
||||||
|
|
||||||
None directly. Forecast data arrives through `weatherdata.Bundle`.
|
The package preserves normalized inputs as inspectable structured values and
|
||||||
|
never decides report identity, delivery, or presentation wording.
|
||||||
## 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.
|
|
||||||
|
|||||||
@@ -1,94 +1,51 @@
|
|||||||
# Generated Text Internals
|
# Generated Text Internals
|
||||||
|
|
||||||
This document describes structured generated-text handling in
|
`internal/generatedtext` validates the structured prose produced for generated-
|
||||||
`internal/generatedtext`.
|
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).
|
||||||
|
|
||||||
## Purpose
|
## Catalog and validation
|
||||||
|
|
||||||
`internal/generatedtext` validates structured text returned for
|
The Daily, Today, Tomorrow, and Hourly report definitions each use structured
|
||||||
generated-text-template reports and builds curated render contexts for
|
generated text. `LookupDefinition` rejects unknown schema or template IDs and
|
||||||
templates. The implemented contracts are Tomorrow Report and Hourly Report.
|
unsupported schema/template pairs before the run begins. A handler validates raw JSON, returns a typed
|
||||||
|
value and canonical normalized JSON, loads its canonical schema through
|
||||||
|
`internal/promptassets`, builds a render context, and renders through
|
||||||
|
`internal/reporttemplate`.
|
||||||
|
|
||||||
## Inputs And Outputs
|
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. Each form permits optional trimmed
|
||||||
|
precipitation-timing and confidence prose. Typed decoding rejects unknown JSON
|
||||||
|
fields; no general-purpose JSON Schema engine is used at runtime.
|
||||||
|
|
||||||
Inputs:
|
## Render contexts
|
||||||
|
|
||||||
- raw GeneratedText JSON for Tomorrow Report or Hourly Report
|
The catalog's report-specific builders receive briefing metadata, a rich module
|
||||||
- report metadata from `internal/briefing`
|
snapshot, collected facts, derived facts, and the matching validated generated
|
||||||
- a module snapshot from `internal/module`
|
text. They decode the module stanzas needed by the template and build typed
|
||||||
- validated generated text
|
Daily, Today, Tomorrow, or Hourly contexts. Context construction validates
|
||||||
|
metadata and periods, preserves rich module values, and uses ordered slices for
|
||||||
|
template iteration rather than maps.
|
||||||
|
|
||||||
Outputs:
|
Optional source stanzas become nil or fallback context fields. Missing required
|
||||||
|
stanzas, type-decoding failures, invalid metadata, or a generated-text type
|
||||||
|
that does not match the chosen handler fail before template execution. Prompt
|
||||||
|
packages, raw Promptkit output, state persistence, and template asset lookup
|
||||||
|
remain outside this package.
|
||||||
|
|
||||||
- typed `Tomorrow` generated text
|
## Verification and invariants
|
||||||
- typed `Hourly` generated text
|
|
||||||
- normalized stable JSON for validated generated text
|
|
||||||
- typed `TomorrowRenderContext` values for `internal/reporttemplate`
|
|
||||||
- typed `HourlyRenderContext` values for `internal/reporttemplate`
|
|
||||||
|
|
||||||
The hourly generated text JSON accepts:
|
Focused tests cover the catalog, each report-specific validator, normalization,
|
||||||
|
schema/template mismatches, context construction, optional modules, and typed
|
||||||
|
stanza errors:
|
||||||
|
|
||||||
```json
|
```sh
|
||||||
{
|
go test ./internal/generatedtext
|
||||||
"summary": "string",
|
|
||||||
"forecast_discussion": "string",
|
|
||||||
"precipitation_timing": "string",
|
|
||||||
"confidence": "string"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`summary` and `forecast_discussion` are required after trimming whitespace.
|
Generated text supplies prose slots only; deterministic weather facts remain in
|
||||||
`precipitation_timing` and `confidence` are optional and omitted from normalized
|
module and fact values. Every report definition must resolve to exactly one
|
||||||
JSON when blank.
|
supported catalog pair.
|
||||||
|
|
||||||
The Tomorrow generated text JSON accepts:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"summary": "string",
|
|
||||||
"forecast_discussion": ["string"],
|
|
||||||
"precipitation_timing": "string",
|
|
||||||
"confidence": "string"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`summary` is required after trimming whitespace. `forecast_discussion` must
|
|
||||||
contain at least one nonblank paragraph after trimming blank items.
|
|
||||||
`precipitation_timing` and `confidence` are optional and omitted from normalized
|
|
||||||
JSON when blank.
|
|
||||||
|
|
||||||
## Boundaries
|
|
||||||
|
|
||||||
- This package owns typed generated-text validation and render-context shaping.
|
|
||||||
- It uses typed module snapshot decoding through `module.StanzaValue`.
|
|
||||||
- It does not invoke Scriptorium, write state artifacts, choose report
|
|
||||||
definitions, compare snapshots, or render templates directly in production
|
|
||||||
workflows.
|
|
||||||
- It does not use a Go JSON Schema dependency; schema enforcement in Go is
|
|
||||||
limited to typed JSON decoding, unknown-field rejection, and required-field
|
|
||||||
checks.
|
|
||||||
|
|
||||||
## Failure Behavior
|
|
||||||
|
|
||||||
- Malformed generated-text JSON fails with decode context.
|
|
||||||
- Unknown generated-text JSON fields fail during decoding.
|
|
||||||
- Empty required fields fail after trimming whitespace.
|
|
||||||
- Tomorrow forecast discussion fails when no nonblank paragraphs remain.
|
|
||||||
- Missing optional render-context stanzas become nil module pointers.
|
|
||||||
- Invalid render metadata, including missing timezone, missing generated time,
|
|
||||||
or invalid valid period, fails before template rendering.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
Inspect:
|
|
||||||
|
|
||||||
- `internal/generatedtext/hourly_test.go`
|
|
||||||
- `internal/generatedtext/tomorrow_test.go`
|
|
||||||
- `internal/generatedtext/render_context_test.go`
|
|
||||||
|
|
||||||
## Invariants
|
|
||||||
|
|
||||||
- Render contexts are curated structs, not raw prompt-input packages.
|
|
||||||
- Required generated text is normalized before downstream artifact storage.
|
|
||||||
- Missing optional weather narrative stanzas produce empty or fallback render
|
|
||||||
context fields rather than forcing raw module data into templates.
|
|
||||||
|
|||||||
@@ -1,167 +1,66 @@
|
|||||||
# Module Contract Internals
|
# Module Contract Internals
|
||||||
|
|
||||||
This document describes the module contract in `internal/module`.
|
`internal/module` defines the stable envelope between report composition,
|
||||||
|
module builders, snapshots, comparisons, 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
|
Each `Output` has a module ID, stanza name, rich `Value`, and runtime-only
|
||||||
prompt-facing modules. Report definitions use module IDs for composition,
|
`PromptValue`. `DataPackageValue` returns the prompt value when present and
|
||||||
module builders produce outputs with stanza names, prompt input packages consume
|
otherwise the rich value. This permits custom prompt exports without shrinking
|
||||||
snapshots, and Recent Changes compares snapshot stanzas.
|
the template and inspection value.
|
||||||
|
|
||||||
## Inputs And Outputs
|
`NewSnapshot` builds the ordered `weatherreporter.modules.v1` snapshot and
|
||||||
|
validates it. Snapshot JSON persists 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
|
## Registered IDs and default composition
|
||||||
overrides
|
|
||||||
- `module.Output` values produced by module builders
|
|
||||||
|
|
||||||
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
|
The registry declares these ordered default compositions:
|
||||||
- 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`
|
|
||||||
|
|
||||||
## 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.
|
||||||
|
|
||||||
- `metadata`
|
## Rich and prompt-facing values
|
||||||
- `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`
|
|
||||||
- `tomorrow_planning`
|
|
||||||
|
|
||||||
Every registered module has a builder. Report composition entries that refer to
|
Rich values remain available to snapshots, comparisons, and render contexts.
|
||||||
unknown or unimplemented module IDs fail validation instead of being skipped.
|
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).
|
||||||
|
|
||||||
## Tomorrow Composition
|
## Verification and invariants
|
||||||
|
|
||||||
The default Tomorrow Report module order is:
|
Focused tests cover snapshot validation and order, typed stanza lookup, and
|
||||||
|
prompt-value fallback:
|
||||||
|
|
||||||
1. `metadata`
|
```sh
|
||||||
2. `current_conditions`
|
go test ./internal/module
|
||||||
3. `narrative_forecast`
|
|
||||||
4. `derived_daily_summary`
|
|
||||||
5. `derived_daypart_summaries`
|
|
||||||
6. `precip_timing`
|
|
||||||
7. `alert_digest`
|
|
||||||
8. `spc_convective_outlooks`
|
|
||||||
9. `area_forecast_discussion`
|
|
||||||
10. `spc_convective_discussion`
|
|
||||||
11. `weather_story`
|
|
||||||
12. `outdoor_windows`
|
|
||||||
13. `tomorrow_planning`
|
|
||||||
14. `hourly_forecast`
|
|
||||||
|
|
||||||
The embedded Tomorrow template uses selected deterministic fields from these
|
|
||||||
module outputs after GeneratedText validation.
|
|
||||||
|
|
||||||
## Hourly Composition
|
|
||||||
|
|
||||||
The default Hourly Report module order is:
|
|
||||||
|
|
||||||
1. `metadata`
|
|
||||||
2. `current_conditions`
|
|
||||||
3. `hourly_forecast`
|
|
||||||
4. `precip_timing`
|
|
||||||
5. `alert_digest`
|
|
||||||
6. `spc_convective_outlooks`
|
|
||||||
7. `area_forecast_discussion`
|
|
||||||
8. `spc_convective_discussion`
|
|
||||||
9. `weather_story`
|
|
||||||
|
|
||||||
Hourly Report does not include daily or daypart summary modules by default.
|
|
||||||
Its `area_forecast_discussion` item is configured to include only
|
|
||||||
`key_messages` and `short_term`.
|
|
||||||
|
|
||||||
## Options
|
|
||||||
|
|
||||||
Most modules use an empty options struct, including
|
|
||||||
`spc_convective_outlooks` and `spc_convective_discussion`.
|
|
||||||
`area_forecast_discussion` accepts:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
sections:
|
|
||||||
- product
|
|
||||||
- key_messages
|
|
||||||
- short_term
|
|
||||||
- long_term
|
|
||||||
```
|
```
|
||||||
|
|
||||||
An omitted or empty `sections` list includes all available discussion sections.
|
Module IDs and stanza names are stable, every emitted output has one of each,
|
||||||
Invalid option shapes fail during config normalization or composition
|
and this package never imports the report registry.
|
||||||
validation.
|
|
||||||
|
|
||||||
## SPC Convective Module Outputs
|
|
||||||
|
|
||||||
`spc_convective_outlooks` emits a prompt-facing risk-product stanza with:
|
|
||||||
|
|
||||||
- `checked`
|
|
||||||
- `as_of`
|
|
||||||
- `issued_at`
|
|
||||||
- `location_id`
|
|
||||||
- `location_name`
|
|
||||||
- `outlook_count`
|
|
||||||
- `outlooks`
|
|
||||||
|
|
||||||
Each outlook entry may include `day`, `outlook_type`, `label`, `label_text`,
|
|
||||||
`period_begins`, `period_ends`, `issued_at`, `contains_location`, and
|
|
||||||
`image_url`. It omits GeoJSON geometry, source URL, expiration time, and
|
|
||||||
severity rank.
|
|
||||||
|
|
||||||
`spc_convective_discussion` emits a narrative stanza only when a retained
|
|
||||||
report-period categorical outlook has severity rank `3` or higher and matching
|
|
||||||
discussion text is available. Its output includes `included_because` and
|
|
||||||
`discussions`; each discussion may include `day`, `period_begins`,
|
|
||||||
`period_ends`, `headline`, `summary`, `discussion`, and `updated_at`.
|
|
||||||
Discussions are included only for SPC days whose retained categorical outlooks
|
|
||||||
meet the severity threshold.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|||||||
@@ -1,144 +1,61 @@
|
|||||||
# Prompt Input Internals
|
# Prompt Input Internals
|
||||||
|
|
||||||
This document describes YAML prompt data package construction in
|
|
||||||
`internal/promptinput`.
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
`internal/promptinput` converts report metadata, an ordered module snapshot,
|
`internal/promptinput` converts report metadata, an ordered module snapshot,
|
||||||
Recent Changes, and source warnings into the `data_package` file passed to
|
Recent Changes, and source warnings into the YAML `data_package` consumed by
|
||||||
Scriptorium.
|
Promptkit. It owns this package's schema, grouping, serialization, loading,
|
||||||
|
and validation—not weather collection, module construction, path choice, or
|
||||||
|
provider execution.
|
||||||
|
|
||||||
The persisted data package is YAML with schema version
|
## Package construction
|
||||||
`weatherreporter.data_package.v2`. It is separate from the JSON module snapshot
|
|
||||||
used for inspection and comparison.
|
|
||||||
|
|
||||||
## Inputs And Outputs
|
`Build` produces `weatherreporter.data_package.v3`. It copies the run ID;
|
||||||
|
report ID, variant, prompt ID, generation time, timezone, local current date,
|
||||||
|
and valid period; ordered briefing stanzas; Recent Changes; and source
|
||||||
|
warnings. A nil Recent Changes slice becomes an empty `items` list.
|
||||||
|
|
||||||
Inputs:
|
Briefing starts as a flat snapshot order and stanza-value map. `Build` uses
|
||||||
|
each output's `DataPackageValue`, so runtime prompt exports take precedence and
|
||||||
|
rich values are used only as a fallback. Prompt exports are selected by the
|
||||||
|
[briefing registry](briefing.md), while the rich-versus-prompt contract is in
|
||||||
|
[module internals](module.md).
|
||||||
|
|
||||||
- report metadata from app/state orchestration
|
## YAML ordering and grouping
|
||||||
- `module.Snapshot`
|
|
||||||
- optional `[]changes.Change`
|
|
||||||
|
|
||||||
Outputs:
|
Serialization keeps `metadata` directly under `briefing`. Every other known
|
||||||
|
stanza is placed in exactly one category, emitted in category order and in its
|
||||||
|
original snapshot order within that category:
|
||||||
|
|
||||||
- `promptinput.Package` with schema version, RunID, report metadata, named
|
| Category | Current stanzas |
|
||||||
module stanzas grouped for prompt presentation, Recent Changes, and source
|
| --- | --- |
|
||||||
warnings
|
| `applicable_risk_products` | alert digest, SPC convective outlooks |
|
||||||
- YAML bytes from `promptinput.MarshalYAML`
|
| `derived_summaries` | deterministic summaries, precipitation timing, outdoor windows, and planning values |
|
||||||
- YAML file written atomically by `promptinput.Save`
|
| `narrative_products` | narrative forecast, discussions, and weather story |
|
||||||
|
| `raw_data` | current conditions and hourly forecast |
|
||||||
|
|
||||||
The YAML shape includes:
|
This YAML presentation does not alter the flat snapshot model. `LoadYAML`
|
||||||
|
accepts the same category layout and reconstructs flat `Order` and `Values`,
|
||||||
|
rejecting misplaced, duplicate, unknown, or uncategorized stanzas.
|
||||||
|
|
||||||
```yaml
|
## Validation and persistence
|
||||||
schema_version: weatherreporter.data_package.v2
|
|
||||||
run_id: <run_id>
|
`Validate` requires the current schema version, run and report identifiers,
|
||||||
report:
|
prompt ID, generation timestamp, timezone, current local date, valid period,
|
||||||
id: <report_id>
|
and at least one ordered briefing stanza. It rejects duplicate stanza names,
|
||||||
prompt_id: <prompt_id>
|
missing values, and a missing category for every non-metadata stanza.
|
||||||
briefing:
|
|
||||||
metadata: {}
|
`MarshalYAML` and `LoadYAML` validate their result. `Save` writes the serialized
|
||||||
applicable_risk_products:
|
YAML atomically; managed workspace paths are owned by [state internals](state.md).
|
||||||
alert_digest: {}
|
Generated-text artifacts and template render contexts are later workflow
|
||||||
spc_convective_outlooks: {}
|
artifacts, not members of this package.
|
||||||
derived_summaries:
|
|
||||||
derived_daily_summary: {}
|
## Verification and invariants
|
||||||
derived_daypart_summaries: {}
|
|
||||||
precip_timing: {}
|
Focused tests cover construction, curated exports, category ordering, YAML
|
||||||
outdoor_windows: {}
|
round trips, invalid layout, validation, and atomic saves:
|
||||||
narrative_products:
|
|
||||||
narrative_forecast: {}
|
```sh
|
||||||
area_forecast_discussion: {}
|
go test ./internal/promptinput
|
||||||
spc_convective_discussion: {}
|
|
||||||
weather_story: {}
|
|
||||||
raw_data:
|
|
||||||
current_conditions: {}
|
|
||||||
hourly_forecast: {}
|
|
||||||
recent_changes:
|
|
||||||
items: []
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The `briefing` mapping keeps `metadata` directly under `briefing` and groups
|
The package is narrower than a template render context and never infers changes
|
||||||
weather module stanzas under prompt-facing categories. This grouping is a YAML
|
from report prose.
|
||||||
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.
|
|
||||||
Prompt-facing module intervals use local `period_begins` and `period_ends`
|
|
||||||
labels; canonical report metadata and source timestamps remain structured
|
|
||||||
timestamps where applicable.
|
|
||||||
|
|
||||||
Tomorrow Report and Hourly Report module snapshots use the same package schema
|
|
||||||
and categories when converted into prompt input. The default hourly module list
|
|
||||||
places
|
|
||||||
`precip_timing` under `derived_summaries`, alert and SPC outlooks under
|
|
||||||
`applicable_risk_products`, AFD/SPC discussion/weather story under
|
|
||||||
`narrative_products`, and current/hourly data under `raw_data`. It does not
|
|
||||||
include civil-day summary stanzas. Generated-text and render context artifacts
|
|
||||||
are produced later in app orchestration and are not part of the YAML data
|
|
||||||
package.
|
|
||||||
|
|
||||||
The default Tomorrow module list includes civil-day summary stanzas,
|
|
||||||
`tomorrow_planning`, and `hourly_forecast` in the data package before
|
|
||||||
structured GeneratedText is requested from Scriptorium.
|
|
||||||
|
|
||||||
Current categories are:
|
|
||||||
|
|
||||||
- `applicable_risk_products`: location-applicable alerts, warnings, outlooks,
|
|
||||||
and similar risk products. Current stanzas include `alert_digest` and
|
|
||||||
`spc_convective_outlooks`.
|
|
||||||
- `derived_summaries`: deterministic summaries and calculated report facts.
|
|
||||||
- `narrative_products`: official narrative text products and forecast stories.
|
|
||||||
Current stanzas include `narrative_forecast`,
|
|
||||||
`area_forecast_discussion`, `spc_convective_discussion`, and
|
|
||||||
`weather_story`.
|
|
||||||
- `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.
|
|
||||||
|
|||||||
24
docs/internal/promptkit-adapter.md
Normal file
24
docs/internal/promptkit-adapter.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# 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 exposes exact prompt and profile inspection plus prepared execution. It maps Promptkit
|
||||||
|
inspection values to project-owned prompt input, output-contract, profile, preparation, execution,
|
||||||
|
validation, and optional debug values. It classifies adapter failures without copying provider secrets
|
||||||
|
or unbounded response bodies into application errors or normal state.
|
||||||
|
|
||||||
|
The app calls the executor's preparation callback before provider execution to persist safe preparation
|
||||||
|
provenance. Completed executions are then persisted as safe execution provenance and raw generated text
|
||||||
|
is validated by `internal/generatedtext`. The adapter does not write workspace 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,120 +1,69 @@
|
|||||||
# Report Registry Internals
|
# Report Registry Internals
|
||||||
|
|
||||||
This document describes report identity, valid-period resolution, batch
|
`internal/report` owns the registry of report identities and the data declared
|
||||||
membership, output naming, artifact grouping, and comparison declarations in
|
for each one: resolution, prompt identity and version, comparison policy,
|
||||||
`internal/report`.
|
artifact group, output-copy name, default module composition, and Distributor
|
||||||
|
path declarations. The public command syntax is owned by the
|
||||||
|
[CLI reference](../cli.md); configuration aliases and overrides are owned by
|
||||||
|
the [configuration reference](../config.md).
|
||||||
|
|
||||||
## Purpose
|
## Definitions and resolution
|
||||||
|
|
||||||
`internal/report` is the canonical source for report definitions. App, state,
|
Each `Definition` declares a stable ID and display name, prompt ID, generation
|
||||||
module building, and CLI wiring consume resolved definitions instead of owning
|
version, template and generated-text schema IDs, valid-period resolver,
|
||||||
report identity policy themselves.
|
comparison strategy, artifact group, batch-copy filename, Distributor path
|
||||||
|
templates, generation eligibility, compatible prior IDs, default modules, and
|
||||||
|
batch eligibility flags. `Resolved` combines that definition with the valid
|
||||||
|
period and run metadata for one invocation.
|
||||||
|
|
||||||
## Definition Fields
|
| Report ID | Prompt version | Period policy | Comparison | Registry batch flag | Output copy |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| `daily` | `1.0.0` | Explicit local civil day | Same valid date | Dynamic Daily inclusion is app-owned | `daily.md` |
|
||||||
|
| `today` | `1.0.0` | Selected or current local civil day | Same valid date | Morning | `today.md` |
|
||||||
|
| `tomorrow` | `1.0.0` | Next local civil day | Same valid date | Evening | `tomorrow.md` |
|
||||||
|
| `hourly` | `1.0.0` | Rolling six-hour interval | Rolling window | — | `hourly.md` |
|
||||||
|
|
||||||
Each report definition declares:
|
Each report pairs its ID and prompt version with matching template and schema
|
||||||
|
IDs. Exact template fields and schema assets belong to [report templates](../templates.md)
|
||||||
|
and [generated-text internals](generatedtext.md).
|
||||||
|
|
||||||
- report ID and display name
|
All valid periods are half-open.
|
||||||
- Scriptorium prompt ID
|
|
||||||
- generation mode
|
|
||||||
- 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
|
|
||||||
|
|
||||||
Markdown report definitions use the `scriptorium_markdown` generation mode.
|
## Registry collaborators
|
||||||
Their template and structured-text schema identifiers are empty. Tomorrow
|
|
||||||
Report and Hourly Report declare `generated_text_template`; the app uses their
|
|
||||||
template and schema identifiers to validate generated text and render embedded
|
|
||||||
Markdown templates.
|
|
||||||
|
|
||||||
## Reports
|
`DefaultRegistry` is the only source of the four report definitions.
|
||||||
|
`Lookup`, `Resolve`, and report-name helpers prevent callers from duplicating
|
||||||
|
report identity rules. Registry overrides clone a definition and replace its
|
||||||
|
module list only after the report ID is recognized.
|
||||||
|
|
||||||
| Report | ID | Prompt | Generation mode | Artifact group | Batch copy | Prior compatibility |
|
The definition's `DistributorPathTemplates` are internal declarations consumed
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
by app orchestration. Their rendered external bundle paths and compatibility
|
||||||
| Daily Today | `daily_today` | `weather.daily_report` | `scriptorium_markdown` | `daily` | `daily.md` | Daily Today |
|
contract are documented in the [Distributor bundle guide](../integrations/distributor/pkg-bundle.md), not repeated here.
|
||||||
| Tomorrow Report | `tomorrow` | `weather.tomorrow_generated_text` | `generated_text_template` | `tomorrow` | `tomorrow.md` | Tomorrow Report |
|
|
||||||
| Hourly Report | `hourly` | `weather.hourly_generated_text` | `generated_text_template` | `hourly` | `hourly.md` | Hourly Report |
|
|
||||||
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `scriptorium_markdown` | `three-day` | `three-day.md` | 3-Day Outlook |
|
|
||||||
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `scriptorium_markdown` | `weekend` | `weekend.md` | Weekend Outlook |
|
|
||||||
| Storm Report | `storm` | `weather.storm_report` | `scriptorium_markdown` | `storm` | `storm.md` | Storm Report |
|
|
||||||
|
|
||||||
All report definitions are eligible for generation.
|
`morning` and `evening` are registry-owned batch names. Registry flags declare
|
||||||
|
fixed report eligibility; app orchestration determines data-dependent Daily
|
||||||
|
membership and produces the actual batch plan.
|
||||||
|
|
||||||
## Valid Periods
|
## Module composition and failures
|
||||||
|
|
||||||
- Daily Today covers the selected local civil day, or the current local civil
|
Each definition supplies an ordered `[]module.ConfigItem`; the complete
|
||||||
day when no date override is supplied.
|
report-to-module mapping is maintained in [module internals](module.md).
|
||||||
- Tomorrow Report covers the next local civil day from generation time.
|
`ArtifactGroup`, `BatchOutputName`, and comparison compatibility
|
||||||
- Hourly Report covers the half-open six-hour period from generation time in
|
are likewise consumed by state and orchestration rather than recomputed there.
|
||||||
the effective report timezone. The duration is an internal report constant,
|
|
||||||
not a configuration field.
|
|
||||||
- 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
|
Unknown report IDs or batch names return errors. The registry never collects
|
||||||
the configured timezone or RFC3339 timestamps with explicit offsets. End time
|
weather data, builds modules, parses CLI flags, writes state, executes
|
||||||
must be after start time.
|
Promptkit, or delivers a report.
|
||||||
|
|
||||||
## Boundaries
|
## Verification and invariants
|
||||||
|
|
||||||
`internal/report` defines report metadata and time coverage. It does not fetch
|
Focused tests cover definition completeness, command and alias lookup, period
|
||||||
weather data, build module values, compare snapshot contents, write state,
|
resolution, run IDs, path declarations, composition defaults, and override
|
||||||
parse CLI flags, or invoke Scriptorium.
|
validation:
|
||||||
|
|
||||||
The CLI owns public command names. The app maps those command names to report
|
```sh
|
||||||
IDs, then uses the registry for report policy.
|
go test ./internal/report
|
||||||
|
```
|
||||||
|
|
||||||
## Config Fields Used
|
All report selection goes through the registry, and the registry is the source
|
||||||
|
of truth for report identity—not rendered report text or app-local constants.
|
||||||
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
|
|
||||||
output path copying uses batch output names from report definitions. Report
|
|
||||||
module overrides can use short keys such as `tomorrow` and `hourly`, or
|
|
||||||
canonical report IDs such as `daily_today`.
|
|
||||||
|
|
||||||
## Batch Membership
|
|
||||||
|
|
||||||
Morning batches include Daily Today, 3-Day Outlook, and Weekend Outlook except
|
|
||||||
on Sunday. Evening batches include Tomorrow Report. Hourly Report is not part
|
|
||||||
of a scheduled batch.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
- Direct Markdown reports have empty template and generated-text schema IDs.
|
|
||||||
- Generated-text-template reports declare prompt, template, and schema IDs in
|
|
||||||
their report definition.
|
|
||||||
- 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.
|
|
||||||
|
|||||||
@@ -1,100 +1,51 @@
|
|||||||
# Report Template Internals
|
# Report Template Internals
|
||||||
|
|
||||||
This document describes embedded Markdown templates and GeneratedText schemas
|
`internal/reporttemplate` embeds and renders the repository's native Markdown
|
||||||
in `internal/reporttemplate`.
|
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).
|
||||||
|
|
||||||
## Purpose
|
## Assets and lookup
|
||||||
|
|
||||||
`internal/reporttemplate` owns repository-native report templates and companion
|
The package embeds top-level templates and shared partials. `Template` returns
|
||||||
GeneratedText JSON schemas. The implemented template contracts are Tomorrow
|
the requested embedded template and fails with the requested ID when it is
|
||||||
Report and Hourly Report.
|
unknown or unreadable.
|
||||||
|
|
||||||
The package embeds assets from:
|
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.
|
||||||
|
|
||||||
- `internal/reporttemplate/templates/*.md.tmpl`
|
## Rendering
|
||||||
- `internal/reporttemplate/schemas/*.schema.json`
|
|
||||||
|
|
||||||
## Inputs And Outputs
|
`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.
|
||||||
|
|
||||||
Inputs:
|
Top-level templates decide which shared partials they invoke. The current
|
||||||
|
partials cover daypart forecast variants, alert digest, and precipitation
|
||||||
|
timing. Template code receives curated typed contexts rather than raw data
|
||||||
|
packages, and it must not reimplement weather selection or generated-text
|
||||||
|
validation.
|
||||||
|
|
||||||
- template ID from a report definition
|
## Boundaries and verification
|
||||||
- typed render context built by `internal/generatedtext`
|
|
||||||
|
|
||||||
Outputs:
|
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.
|
||||||
|
|
||||||
- template source for inspection and tests
|
Focused tests cover template lookup, rendering, partial
|
||||||
- GeneratedText schema bytes for prompt/schema configuration
|
behavior, missing keys, and malformed context:
|
||||||
- rendered Markdown bytes for app orchestration to persist
|
|
||||||
|
|
||||||
The implemented template IDs are `tomorrow` and `hourly`. The implemented
|
```sh
|
||||||
schema IDs are also `tomorrow` and `hourly`, backed by
|
go test ./internal/reporttemplate
|
||||||
`tomorrow.generated_text.schema.json` and `hourly.generated_text.schema.json`.
|
```
|
||||||
|
|
||||||
## Boundaries
|
Embedded templates stay as separate files and shared fragments stay under the
|
||||||
|
partial directory. Generated-text schemas are embedded separately by
|
||||||
This package owns embedded asset lookup, Go template parsing, and Markdown
|
`internal/promptassets` and describe prose slots rather than deterministic
|
||||||
template execution. It does not fetch weather data, build module outputs,
|
weather facts.
|
||||||
validate GeneratedText, construct render contexts, choose report definitions,
|
|
||||||
write artifacts, invoke Scriptorium, or notify distributor.
|
|
||||||
|
|
||||||
GeneratedText validation is owned by `internal/generatedtext`. App
|
|
||||||
orchestration decides which template and schema IDs apply to a report through
|
|
||||||
`internal/report` definitions.
|
|
||||||
|
|
||||||
## Template Contracts
|
|
||||||
|
|
||||||
Tomorrow and Hourly rendering use typed render contexts with:
|
|
||||||
|
|
||||||
- report metadata labels such as title, location, valid period, and generation
|
|
||||||
time
|
|
||||||
- validated GeneratedText prose slots
|
|
||||||
- deterministic labels derived from module outputs, including current
|
|
||||||
conditions, hourly forecast rows, precipitation timing, alerts, SPC outlooks,
|
|
||||||
forecast discussion, SPC discussion, and weather story
|
|
||||||
|
|
||||||
Tomorrow additionally exposes forecast-date labels, ordered daypart forecast
|
|
||||||
rows, daily/daypart summaries, tomorrow planning facts, and a multi-paragraph
|
|
||||||
forecast discussion generated-text slot. The ordered daypart slice is built in
|
|
||||||
Go so templates do not range over maps.
|
|
||||||
|
|
||||||
Templates use `text/template` with `missingkey=error`, so missing context fields
|
|
||||||
fail rendering instead of producing incomplete Markdown.
|
|
||||||
|
|
||||||
## Schema Contract
|
|
||||||
|
|
||||||
The GeneratedText schemas describe the structured prose Scriptorium is expected
|
|
||||||
to write for each generated-text prompt. Hourly requires:
|
|
||||||
|
|
||||||
- `summary`
|
|
||||||
- `forecast_discussion`
|
|
||||||
|
|
||||||
Tomorrow requires `summary` and a nonempty `forecast_discussion` array. Both
|
|
||||||
schemas allow optional `precipitation_timing` and `confidence`, and reject
|
|
||||||
additional properties. Weather truth remains in module outputs; GeneratedText
|
|
||||||
is limited to prose slots consumed by the template.
|
|
||||||
|
|
||||||
## Failure Behavior
|
|
||||||
|
|
||||||
- Unknown template IDs return actionable lookup errors.
|
|
||||||
- Unknown schema IDs return actionable lookup errors.
|
|
||||||
- Template parse errors include the template ID.
|
|
||||||
- Template execution errors include the template ID and usually identify the
|
|
||||||
missing context field.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
Inspect:
|
|
||||||
|
|
||||||
- `internal/reporttemplate/reporttemplate_test.go`
|
|
||||||
- `internal/generatedtext/render_context_test.go`
|
|
||||||
- `internal/app/app_test.go`
|
|
||||||
- `internal/cli/root_test.go`
|
|
||||||
|
|
||||||
## Invariants
|
|
||||||
|
|
||||||
- Embedded templates and schemas live as separate files, not inline Go strings.
|
|
||||||
- Report definitions select templates by ID.
|
|
||||||
- Templates render from curated render contexts, not raw data packages.
|
|
||||||
- GeneratedText schemas describe LLM prose slots, not deterministic weather
|
|
||||||
facts.
|
|
||||||
|
|||||||
@@ -1,110 +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 or structured generated-text output. 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`
|
|
||||||
- raw generated-text output path for structured `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`
|
|
||||||
- raw generated-text output path for structured `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, Markdown run, and
|
|
||||||
structured generated-text run operations. State persistence uses state-owned
|
|
||||||
artifact shapes; app orchestration converts adapter 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>
|
|
||||||
```
|
|
||||||
|
|
||||||
Structured generated-text argv uses the same `scriptorium run` form, with the
|
|
||||||
`--out` value set to the raw generated-text JSON artifact path. The adapter
|
|
||||||
does not add `--format`, schema path, or JSON Schema flags for structured
|
|
||||||
generation; Scriptorium selects the structured output schema from prompt
|
|
||||||
configuration.
|
|
||||||
|
|
||||||
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, Markdown run, and structured 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, Markdown run, and structured 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, Markdown run, and structured run preserve command-specific result
|
|
||||||
structs.
|
|
||||||
- Scriptorium-specific flags stay inside adapter and config boundaries.
|
|
||||||
@@ -1,146 +1,56 @@
|
|||||||
# State Internals
|
# State Internals
|
||||||
|
|
||||||
This document describes filesystem state in `internal/state`.
|
`internal/state` owns safe workspace paths, atomic artifact writes, metadata,
|
||||||
|
prior-snapshot lookup, and read-only inspection. Operators should use the
|
||||||
|
[operations guide](../operations.md) for lifecycle and retention.
|
||||||
|
|
||||||
## Purpose
|
## Artifact Paths
|
||||||
|
|
||||||
`internal/state` owns managed workspace paths, atomic JSON writes, persisted
|
For each run, paths are grouped by artifact group and valid start date:
|
||||||
metadata, prior snapshot lookup, and read-only artifact inspection helpers.
|
|
||||||
|
|
||||||
## Inputs And Outputs
|
| Artifact | Location |
|
||||||
|
| --- | --- |
|
||||||
|
| Module snapshot | `snapshots/<group>/<date>/modules.<run-id>.json` |
|
||||||
|
| Metadata | `snapshots/<group>/<date>/metadata.<run-id>.json` |
|
||||||
|
| Data package | `data-packages/<group>/<date>/data_package.<run-id>.yaml` |
|
||||||
|
| Prompt preparation | `preflight/<group>/<date>/prompt_preparation.<run-id>.json` |
|
||||||
|
| Prompt execution | `snapshots/<group>/<date>/prompt_execution.<run-id>.json` |
|
||||||
|
| Raw generated text | `snapshots/<group>/<date>/generated_text_raw.<run-id>.json` |
|
||||||
|
| Validated generated text | `snapshots/<group>/<date>/generated_text.<run-id>.json` |
|
||||||
|
| Render context | `snapshots/<group>/<date>/render_context.<run-id>.json` |
|
||||||
|
| Managed report | `reports/<group>/<date>/report.<run-id>.md` |
|
||||||
|
| Notification | `notifications/<group>/<date>/distributor.<run-id>.json` |
|
||||||
|
|
||||||
Inputs:
|
Batch notification records are `notifications/batches/<batch>/<local-date>/distributor.<batch-run-id>.json`.
|
||||||
|
|
||||||
- workspace configuration
|
## Metadata And Debug Storage
|
||||||
- resolved report definition and valid period
|
|
||||||
- module snapshot
|
|
||||||
- prompt input data package
|
|
||||||
- preflight artifact
|
|
||||||
- generated-text raw, run-result, validated text, and render-context artifacts
|
|
||||||
- rendered report path preparation request
|
|
||||||
- RunID for inspection lookups
|
|
||||||
|
|
||||||
Outputs:
|
New metadata is `weatherreporter.metadata.v2` and gains preparation and
|
||||||
|
execution paths only after those artifacts are saved. Legacy V1 records remain
|
||||||
|
readable; their historic preflight and generated-text-result fields are mapped
|
||||||
|
to the corresponding preparation and execution views during inspection. New
|
||||||
|
runs never write V1 records.
|
||||||
|
|
||||||
- module snapshot JSON path
|
Prompt preparation and execution records are validated on both save and load.
|
||||||
- prompt input data package YAML path
|
They require exact report/prompt identity, complete timing, internally
|
||||||
- render preflight JSON path
|
consistent provenance, and status-appropriate validation or bounded classified
|
||||||
- generated-text raw JSON path
|
errors. Completed execution provenance keeps Promptkit's run identity distinct
|
||||||
- generated-text run-result JSON path
|
from the Weatherreporter run identity.
|
||||||
- validated generated-text JSON path
|
|
||||||
- render context JSON path
|
|
||||||
- managed Markdown report path
|
|
||||||
- metadata JSON path
|
|
||||||
- prior comparable snapshot metadata
|
|
||||||
- loaded module snapshot, data package, generated text, generated-text run
|
|
||||||
result, or render context
|
|
||||||
- recent report records for inspection
|
|
||||||
|
|
||||||
## Boundaries
|
For a completed prompt run, the execution record is atomically replaced after
|
||||||
|
each downstream artifact is saved. Its path set therefore records the raw and
|
||||||
|
normalized generated text, render context, managed report, requested output
|
||||||
|
copy, and notification artifact actually reached without changing the original
|
||||||
|
Promptkit outcome.
|
||||||
|
|
||||||
`internal/state` owns local filesystem layout, path validation, durable writes,
|
`PromptDebugWriter` is separate from workspace state. An empty root disables
|
||||||
metadata reads, prior lookup, and report listing. It does not fetch weather
|
it. An enabled absolute root is checked for safe directories and symlinks, then
|
||||||
data, derive forecasts, build prompt input content, compare module contents,
|
stores `preparation.json` and `execution.json` beneath
|
||||||
invoke Scriptorium, import adapter result types, or parse CLI flags.
|
`<root>/<report-id>/<valid-date>/<run-id>/`. Directories are `0700`; files are
|
||||||
|
atomic `0600`. Normal state discovery does not read this root.
|
||||||
|
|
||||||
Preflight persistence uses the state-owned `PreflightArtifact` shape. The app
|
Focused checks:
|
||||||
converts adapter render results into that shape before saving.
|
|
||||||
|
|
||||||
## Config Fields Used
|
```sh
|
||||||
|
go test ./internal/state
|
||||||
- `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
|
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.raw.json
|
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.run.json
|
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.json
|
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.render_context.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. For generated-text-template reports, metadata also
|
|
||||||
records the generated text schema ID and links the raw generated text,
|
|
||||||
Scriptorium run result, validated generated text, and render context artifacts.
|
|
||||||
Markdown-report metadata omits those generated-text fields. 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 compares with prior Daily Today snapshots for the same valid
|
|
||||||
local date.
|
|
||||||
- Tomorrow Report compares with prior Tomorrow Report snapshots 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.
|
|
||||||
- Hourly Report uses the rolling-window comparison strategy and currently
|
|
||||||
returns no prior snapshot from filesystem lookup.
|
|
||||||
- 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. Generated-text raw and
|
|
||||||
validated JSON artifacts are written atomically as bytes; generated-text run
|
|
||||||
result and render context artifacts are written atomically as JSON. 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, data package,
|
|
||||||
generated text, generated-text run result, and render context 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.
|
|
||||||
- Generated-text artifacts live under the snapshots tree beside module
|
|
||||||
snapshots and metadata.
|
|
||||||
- Prior lookup is based on structured metadata, not rendered report text.
|
|
||||||
|
|||||||
@@ -1,101 +1,69 @@
|
|||||||
# Weather Data Internals
|
# 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 and persistence. 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 the
|
`Bundle` has a collection timestamp (`FetchedAt`), source provenance
|
||||||
configured Weather API and assembles the bundle consumed by forecast derivation
|
(`Sources`), and collection-level warnings (`Warnings`). Its product fields are
|
||||||
and module builders. Module builders expose normalized current conditions and
|
optional so an allowed missing source can be represented without manufacturing
|
||||||
weather story context when those sources are available.
|
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,
|
## Source provenance
|
||||||
precision, and missing-source policy
|
|
||||||
- HTTP responses using the Weather API `data` envelope
|
|
||||||
|
|
||||||
Outputs:
|
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.
|
||||||
|
|
||||||
- `weatherdata.Bundle` with observation, current conditions, hourly forecast,
|
An empty product can be meaningful checked data. For example, an explicit
|
||||||
narrative forecast, active alerts, discussion, latest weather story, source
|
empty alerts result is not missing and retains its source hash. A source is
|
||||||
records, source warnings, and typed SPC convective outlook data when that
|
marked missing only when the adapter's missing-source policy treats the
|
||||||
optional source is available
|
response or parsing failure as unavailable. The policy itself belongs to the
|
||||||
- optional saved bundle JSON through app fetch helpers
|
[configuration reference](../config.md).
|
||||||
|
|
||||||
## Boundaries
|
## Warning semantics
|
||||||
|
|
||||||
- The adapter owns HTTP calls, response-envelope handling, source hashing, and
|
`SourceWarning` has a source name, stable code, severity, explanatory message,
|
||||||
decoding into internal bundle types.
|
endpoint, and `CompletenessImpact`. When collection proceeds with a warning,
|
||||||
- It does not derive dayparts, resolve report periods, build module values, compare
|
the same warning appears in `Source.Warnings` and `Bundle.Warnings` so both
|
||||||
snapshots, write report state, or invoke Scriptorium.
|
local provenance and whole-run consumers see it. A policy that treats a missing
|
||||||
|
source as an error returns no partial bundle.
|
||||||
|
|
||||||
## Config Fields Used
|
Warnings describe data completeness, not rendering or delivery failures.
|
||||||
|
Those failures are recorded by the application and state layers; see
|
||||||
|
[application orchestration](app-orchestration.md) and [state internals](state.md).
|
||||||
|
|
||||||
- `weather_api.base_url`
|
## Boundaries and verification
|
||||||
- `weather_api.timeout`
|
|
||||||
- `weather_api.format`
|
|
||||||
- `weather_api.units`
|
|
||||||
- `weather_api.timezone`
|
|
||||||
- `weather_api.precision`
|
|
||||||
- `missing_source.default`
|
|
||||||
- `missing_source.sources`
|
|
||||||
|
|
||||||
## External Adapters Used
|
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:
|
||||||
|
|
||||||
- Weather API HTTP service
|
```sh
|
||||||
|
go test ./internal/weatherdata
|
||||||
See [Weather API integration](../integrations/weatherapi.md) for the external
|
go test ./internal/adapters/weatherapi
|
||||||
contract used by this project.
|
```
|
||||||
|
|
||||||
## 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. Successful
|
|
||||||
non-null `/outlooks/convective` responses with empty outlook and discussion
|
|
||||||
arrays represent checked empty outlook data.
|
|
||||||
`app.FetchAndSaveBundle` can write bundle JSON atomically for inspection.
|
|
||||||
|
|
||||||
SPC convective outlook data is stored on
|
|
||||||
`weatherdata.Bundle.SPCConvectiveOutlooks`. The collected run keeps upstream
|
|
||||||
run metadata, location identifiers, ordered outlook records, discussion
|
|
||||||
records, and each outlook's raw GeoJSON geometry. Source provenance for this
|
|
||||||
payload uses the `spc_convective_outlooks` source name, endpoint
|
|
||||||
`/outlooks/convective`, the query sent by the adapter, timestamps, and a hash
|
|
||||||
of the raw `data` object.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
- Explicit `data: null` from `/outlooks/convective` follows optional
|
|
||||||
missing-source policy.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|||||||
@@ -1,313 +1,178 @@
|
|||||||
# Weatherreporter Operations
|
# Weatherreporter Operations
|
||||||
|
|
||||||
This guide covers normal operation, generated artifacts, inspection, recovery,
|
This guide covers normal operation, managed workspace state, inspection,
|
||||||
and operational caveats. For symptom-specific diagnosis, see
|
recovery, and operational caveats. See the [CLI reference](cli.md) for complete
|
||||||
|
command syntax and the [configuration reference](config.md) for fields,
|
||||||
|
defaults, and notification templates. For symptom-based diagnosis, see
|
||||||
[Troubleshooting](troubleshooting.md).
|
[Troubleshooting](troubleshooting.md).
|
||||||
|
|
||||||
## Normal Workflow
|
## Normal Operation
|
||||||
|
|
||||||
Generation commands:
|
After configuring a Weather API endpoint, generate one report:
|
||||||
|
|
||||||
```text
|
```sh
|
||||||
weatherreporter generate daily --date 2026-05-29
|
weatherreporter generate today --out ./today.md
|
||||||
weatherreporter generate tomorrow
|
|
||||||
weatherreporter generate hourly
|
|
||||||
weatherreporter generate three-day
|
|
||||||
weatherreporter generate weekend
|
|
||||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Generation commands resolve a report period, fetch a Weather API bundle, build
|
A generation collects weather data, resolves the report period, builds and
|
||||||
a JSON module snapshot, build a YAML prompt input data package, run
|
persists the module snapshot and prompt data package, records Promptkit
|
||||||
`scriptorium render`, and write managed artifacts under the configured
|
preparation provenance before provider execution, then persists raw output and
|
||||||
workspace. Markdown-path reports then run `scriptorium run` directly to the
|
execution provenance, validates the structured generated text, and renders the
|
||||||
managed Markdown report path.
|
managed Markdown report from the validated text and deterministic values.
|
||||||
|
|
||||||
`generate tomorrow` and `generate hourly` use the generated-text-template
|
The managed report and its final metadata are saved before single-report
|
||||||
workflow. They run structured `scriptorium run` to raw GeneratedText JSON,
|
Distributor notification is attempted. `--out` writes an extra operator copy;
|
||||||
validate the structured text, save a render context, and render the managed
|
it never changes the managed report or upload source. A successful generate
|
||||||
Markdown report from embedded templates. `generate hourly` covers the six-hour
|
command prints its summary to stdout unless `--quiet` is used.
|
||||||
rolling period from generation time in the effective report timezone and is not
|
|
||||||
part of scheduled morning or evening batches.
|
|
||||||
|
|
||||||
When distributor notification is enabled, weatherreporter uploads the managed
|
## Optional Prompt Debug Capture
|
||||||
Markdown report after report rendering succeeds and final metadata is saved.
|
|
||||||
`--out PATH` writes an extra Markdown copy for generated reports; it is not used
|
|
||||||
as the distributor upload source.
|
|
||||||
|
|
||||||
Batch commands:
|
Use `--llm-debug-dir` only when content-rich prompt diagnostics are required:
|
||||||
|
|
||||||
```text
|
```sh
|
||||||
weatherreporter run morning
|
weatherreporter generate today --llm-debug-dir /var/tmp/weatherreporter-debug
|
||||||
weatherreporter run evening
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
The directory must be absolute and is initialized before prompt inspection or
|
||||||
except on Sunday. `run evening` generates the Tomorrow Report. Batch
|
weather collection. Capture files are stored outside the managed workspace,
|
||||||
commands print a JSON summary to stdout, write compact per-report status lines
|
with restrictive permissions, under the report ID, valid date, and RunID.
|
||||||
to stderr, continue independent reports after one report fails, and return
|
They can contain rendered prompts and generated output, so the normal metadata,
|
||||||
nonzero when any report failed. When notification is configured, the summary and
|
CLI summary, and routine logs contain only the optional directory path—not
|
||||||
status lines include notification status, accepted distributor run ID, or
|
their content. A capture-write failure stops that run before later work can
|
||||||
notification error fields for each attempted report. `--out-dir PATH` writes
|
continue.
|
||||||
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.
|
|
||||||
|
|
||||||
## Filesystem Layout
|
Run a scheduled batch with the same configured collection:
|
||||||
|
|
||||||
The default workspace root is `workspace`.
|
```sh
|
||||||
|
weatherreporter run morning --out-dir ./reports --llm-debug-dir /var/tmp/weatherreporter-debug
|
||||||
|
```
|
||||||
|
|
||||||
|
Each batch validates its configured prompt/profile candidates, then collects once before it plans reports. 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 entire local civil day. A batch continues
|
||||||
|
after an individual report fails and returns an aggregate failure when any
|
||||||
|
report or batch notification fails.
|
||||||
|
|
||||||
|
`--out-dir` writes extra copies such as `today.md`, `tomorrow.md`, and
|
||||||
|
`daily-YYYY-MM-DD.md`. These copies are never upload sources. Batch report
|
||||||
|
copies and notification behavior are summarized in the CLI result; use the
|
||||||
|
[CLI reference](cli.md) for its exact JSON and stderr contract.
|
||||||
|
|
||||||
|
## Managed Workspace
|
||||||
|
|
||||||
|
The default workspace root is `workspace`. Artifact paths use the report
|
||||||
|
definition's artifact group, the valid-period start date in the effective
|
||||||
|
timezone, and the RunID:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
workspace/
|
workspace/
|
||||||
snapshots/
|
reports/<artifact_group>/<YYYY-MM-DD>/report.<run_id>.md
|
||||||
daily/
|
|
||||||
YYYY-MM-DD/
|
snapshots/<artifact_group>/<YYYY-MM-DD>/modules.<run_id>.json
|
||||||
<run_id>.modules.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/metadata.<run_id>.json
|
||||||
<run_id>.metadata.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_raw.<run_id>.json
|
||||||
three-day/
|
snapshots/<artifact_group>/<YYYY-MM-DD>/prompt_execution.<run_id>.json
|
||||||
YYYY-MM-DD/
|
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text.<run_id>.json
|
||||||
<run_id>.modules.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/render_context.<run_id>.json
|
||||||
<run_id>.metadata.json
|
|
||||||
weekend/
|
data-packages/<artifact_group>/<YYYY-MM-DD>/data_package.<run_id>.yaml
|
||||||
YYYY-MM-DD/
|
preflight/<artifact_group>/<YYYY-MM-DD>/prompt_preparation.<run_id>.json
|
||||||
<run_id>.modules.json
|
|
||||||
<run_id>.metadata.json
|
notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json
|
||||||
hourly/
|
notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.modules.json
|
|
||||||
<run_id>.metadata.json
|
|
||||||
<run_id>.generated_text.raw.json
|
|
||||||
<run_id>.generated_text.run.json
|
|
||||||
<run_id>.generated_text.json
|
|
||||||
<run_id>.render_context.json
|
|
||||||
tomorrow/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.modules.json
|
|
||||||
<run_id>.metadata.json
|
|
||||||
<run_id>.generated_text.raw.json
|
|
||||||
<run_id>.generated_text.run.json
|
|
||||||
<run_id>.generated_text.json
|
|
||||||
<run_id>.render_context.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
|
|
||||||
hourly/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.data_package.yaml
|
|
||||||
tomorrow/
|
|
||||||
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
|
|
||||||
hourly/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.render.json
|
|
||||||
tomorrow/
|
|
||||||
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
|
|
||||||
hourly/
|
|
||||||
YYYY-MM-DD/
|
|
||||||
<run_id>.distributor.json
|
|
||||||
tomorrow/
|
|
||||||
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
|
|
||||||
hourly/
|
|
||||||
<run_id>.md
|
|
||||||
tomorrow/
|
|
||||||
<run_id>.md
|
|
||||||
storm/
|
|
||||||
<run_id>.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Managed artifact filenames use the RunID, so repeated runs for the same valid
|
The generated-text and render-context artifacts are written for every completed
|
||||||
period do not overwrite each other.
|
single-report generation.
|
||||||
|
A report's metadata links the module snapshot, data package, preparation and
|
||||||
|
execution receipts, managed report, generated-text artifacts, and any available single-report
|
||||||
|
notification artifact. Batch notification artifacts are separate batch-level
|
||||||
|
records under `notifications/batches`.
|
||||||
|
|
||||||
## RunID And Metadata
|
RunIDs begin with the UTC generation timestamp and report ID. A Daily RunID
|
||||||
|
also contains its local valid date so multiple Daily reports in one batch have
|
||||||
RunIDs are based on generation time plus report ID:
|
different managed paths. Batch notification RunIDs contain the UTC batch start
|
||||||
|
timestamp and batch name.
|
||||||
```text
|
|
||||||
20260529T100000.123456789Z_daily_today
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
|
||||||
- generated text schema ID and generated-text artifact paths for
|
|
||||||
generated-text-template reports
|
|
||||||
- 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
|
||||||
|
|
||||||
Distributor notification is configured with `notify.distributor` and is
|
When `notify.distributor.enabled` is enabled, a successful `generate`
|
||||||
disabled by default. When enabled, weatherreporter uploads the managed Markdown
|
uploads only the managed Markdown report after final metadata has been saved.
|
||||||
report path recorded in the report result and metadata. That single source file
|
The extra copy from `--out` is never uploaded. A notification attempt writes
|
||||||
can be mapped to one or more configured bundle paths. By default, it is mapped
|
a redacted debug artifact at
|
||||||
to one dated report path. Extra copies written by `--out` or `--out-dir` are
|
`notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json`; its
|
||||||
operator conveniences only.
|
path is then recorded in report metadata.
|
||||||
|
|
||||||
The rendered pipeline ID selects the configured distributor `http_upload`
|
Batches suppress per-report notification. When both Distributor and its batch
|
||||||
workflow. The default bundle ID is a stable logical source identity derived from
|
notification are enabled, Weatherreporter submits one multi-report upload after
|
||||||
producer name, location ID, and report ID:
|
every planned report succeeds. If any report fails, it records a top-level
|
||||||
|
`skipped` notification with reason `one or more reports failed` and does not
|
||||||
|
call Distributor. If batch notification is disabled, a batch does not fall back
|
||||||
|
to individual uploads.
|
||||||
|
|
||||||
```text
|
A batch notification attempt writes
|
||||||
weatherreporter.{location_id}.{report_id}
|
`notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json`.
|
||||||
```
|
A notification failure makes the batch fail but does not change successful
|
||||||
|
individual report items into failed items. The debug artifacts contain rendered
|
||||||
|
identifiers, managed source and bundle paths, upload and status results, and
|
||||||
|
redacted errors; they do not contain tokens.
|
||||||
|
|
||||||
The default idempotency key appends RunID to the rendered bundle ID so each
|
## Inspecting Stored Runs
|
||||||
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 for generated reports. Weather
|
Inspection is read-only: it neither collects weather data nor invokes
|
||||||
API, module snapshot, data-package, render preflight, Scriptorium run,
|
Promptkit or Distributor. Start by finding a RunID:
|
||||||
generated-text validation, template rendering, 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
|
```sh
|
||||||
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 reports --limit 10
|
||||||
weatherreporter inspect metadata RUN_ID
|
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
|
| Command | Reads |
|
||||||
`inspect metadata` to see the artifact links recorded for a run. Use
|
| --- | --- |
|
||||||
`inspect modules` to review the persisted ordered module snapshot, and
|
| `inspect reports` | Metadata files under the workspace snapshots tree. |
|
||||||
`inspect data-package` to review the structured prompt package used for
|
| `inspect metadata RUN_ID` | Metadata located by RunID. |
|
||||||
rendering. Use `inspect prior` to see the prior comparable snapshot selected for
|
| `inspect modules RUN_ID` | The module snapshot path recorded in metadata. |
|
||||||
Recent Changes, or `null` when none exists. Use `inspect sources` to review
|
| `inspect data-package RUN_ID` | The data-package path recorded in metadata. |
|
||||||
source provenance and warnings without dumping full weather payloads.
|
| `inspect prior RUN_ID` | The run metadata, then compatible earlier metadata for its comparison policy. |
|
||||||
|
| `inspect sources RUN_ID` | Source provenance and warnings in the run metadata. |
|
||||||
|
|
||||||
## Recent Changes
|
A missing snapshots directory produces no listed reports. An unknown or empty
|
||||||
|
RunID is an error; use `inspect reports` to obtain a valid value.
|
||||||
|
|
||||||
Recent Changes are computed from structured module snapshots, not rendered
|
New runs write `weatherreporter.metadata.v2`, including preparation and
|
||||||
Markdown or YAML text.
|
execution references once those receipts exist. `inspect metadata` also reads
|
||||||
|
historic V1 records; their legacy preflight and generated-text-result fields
|
||||||
Daily Today compares with prior Daily Today snapshots for the same valid local
|
remain visible for compatibility, but Weatherreporter does not write them for
|
||||||
date. Tomorrow Report compares with prior Tomorrow Report snapshots for the
|
new runs.
|
||||||
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. Hourly Report and
|
|
||||||
Storm Report leave Recent Changes empty.
|
|
||||||
|
|
||||||
When no prior comparable snapshot exists, or no configured threshold is crossed,
|
|
||||||
`recentChanges.items` is empty.
|
|
||||||
|
|
||||||
## Recovery
|
## Recovery
|
||||||
|
|
||||||
A failed generation run may still leave useful artifacts:
|
Keep the workspace when a run fails: artifacts reached before the failure
|
||||||
|
remain available where they can be safely persisted.
|
||||||
|
|
||||||
- If `scriptorium render` returns a result with a nonzero exit code, the
|
- A preparation failure can leave its classified receipt and metadata.
|
||||||
preflight JSON and metadata are written for inspection.
|
- A report-generation failure can leave the managed report, module snapshot,
|
||||||
- If `scriptorium run` exits nonzero after writing a report, the managed report
|
data package, and metadata.
|
||||||
and metadata remain available.
|
- A completed prompt validation rejection leaves raw text, an execution receipt,
|
||||||
- Hourly generated-text failures preserve available intermediate artifacts,
|
and metadata. Later generated-text failures can also leave validated text and
|
||||||
such as the structured run result, raw generated-text JSON, validated
|
a render-context artifact, depending on where they stopped.
|
||||||
generated text, and render context. Metadata links those paths when it can be
|
- A single-report notification failure preserves the report and final metadata,
|
||||||
safely written.
|
including its notification artifact when it was written.
|
||||||
- If distributor notification fails, report artifacts and final metadata remain
|
- A batch notification failure preserves each report's artifacts and adds the
|
||||||
available, but the report or batch command returns nonzero.
|
top-level batch notification artifact.
|
||||||
- For batch commands, inspect the stdout JSON summary first, then inspect the
|
|
||||||
artifact paths for each failed report.
|
|
||||||
|
|
||||||
For a bad report, start with:
|
Use the RunID from the action summary with the inspection commands above. For
|
||||||
|
a batch failure, inspect the summary first, then inspect the affected report
|
||||||
```text
|
RunIDs or the batch notification path. Do not remove the whole workspace as a
|
||||||
weatherreporter inspect metadata RUN_ID
|
first response; retain it until the failure is understood.
|
||||||
weatherreporter inspect sources RUN_ID
|
|
||||||
weatherreporter inspect modules RUN_ID
|
|
||||||
weatherreporter inspect data-package RUN_ID
|
|
||||||
weatherreporter inspect prior RUN_ID
|
|
||||||
```
|
|
||||||
|
|
||||||
## Operational Caveats
|
## Operational Caveats
|
||||||
|
|
||||||
- The application uses one configured Weather API endpoint.
|
- Workspace files and generated reports can contain
|
||||||
- The application writes local filesystem state only.
|
sensitive operational context. Set appropriate filesystem permissions and do
|
||||||
- The application does not implement resume, cleanup, archive, remote storage,
|
not publish them unintentionally.
|
||||||
daemon operation, or automatic storm monitoring.
|
- Weatherreporter uses one configured Weather API endpoint and local workspace
|
||||||
- Generated reports and Scriptorium stderr can contain sensitive operational
|
state.
|
||||||
context. Store workspace artifacts with appropriate filesystem permissions.
|
- It does not provide automatic resume, cleanup, archival, remote state, daemon
|
||||||
|
operation, or automatic storm monitoring.
|
||||||
|
|||||||
@@ -1,125 +1,73 @@
|
|||||||
# 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
|
This policy defines Weatherreporter's system shape, ownership, dependency direction,
|
||||||
`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.
|
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,
|
||||||
|
compares prior snapshots, executes exact-version Promptkit prompts, validates
|
||||||
|
structured generated prose, and renders repository-owned Markdown. Completed
|
||||||
|
managed Markdown may 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.
|
The supported report products are Daily, Today, Tomorrow, and Hourly. A batch
|
||||||
|
collects once, validates its complete candidate prompt/profile set before
|
||||||
|
collection, then executes planned reports sequentially with one executor. It
|
||||||
|
continues after independent report failures and sends a batch notification only
|
||||||
|
after every planned report succeeds.
|
||||||
|
|
||||||
`scriptorium` is an external adapter, not domain logic. Subprocess execution must be isolated under `internal/adapters/scriptorium`, use context-aware execution, avoid shell interpolation, capture actionable stderr, and keep scriptorium-specific flags from leaking into domain packages.
|
## Ownership And Boundaries
|
||||||
|
|
||||||
`distributor` is also an external adapter. Upload behavior must be isolated
|
- `internal/cli` owns command parsing, help, summaries, and one executor
|
||||||
under `internal/adapters/distributor`, dependency types from the distributor
|
construction per action.
|
||||||
module must not leak outside that adapter, and the selected upload source must
|
- `internal/config` owns defaults, loading, validation, and secret loading.
|
||||||
be the managed Markdown report rather than optional output copies or broad
|
- `internal/app` owns workflow order, partial results, and notification
|
||||||
workspace scans.
|
coordination through project-owned contracts.
|
||||||
|
- Deterministic domain packages own weather derivation, report periods, modules,
|
||||||
|
generated-text validation, and template contexts.
|
||||||
|
- `internal/adapters/weatherapi`, `internal/adapters/promptkit`, and
|
||||||
|
`internal/adapters/distributor` own their external dependency mechanics.
|
||||||
|
- `internal/state` owns workspace paths, V2 metadata, atomic persistence, and
|
||||||
|
read-only inspection.
|
||||||
|
|
||||||
## Project Shape
|
Dependency-specific Promptkit types remain inside its adapter. The application
|
||||||
|
does not parse flags, construct provider clients, or render provider output
|
||||||
|
directly.
|
||||||
|
|
||||||
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.
|
## Prompt Execution Invariants
|
||||||
|
|
||||||
Business/domain logic should live outside CLI, transport, and external-adapter packages.
|
- Prompts receive curated module packages, never unbounded raw weather payloads.
|
||||||
|
- Every execution inspects 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.
|
||||||
|
- Prepared execution persists safe preparation provenance before provider work.
|
||||||
|
Completed execution persists safe execution provenance; raw output is
|
||||||
|
validated before template rendering.
|
||||||
|
- Generated text fills defined prose slots only. Deterministic facts remain
|
||||||
|
authoritative and repository-owned templates produce all managed Markdown.
|
||||||
|
- Sensitive rendered prompts, schemas, input bodies, provider endpoints, and
|
||||||
|
credentials never enter normal metadata, summaries, logs, or workspace
|
||||||
|
artifacts. They are written only to an explicit secure debug root when
|
||||||
|
requested.
|
||||||
|
|
||||||
## Dependency Policy
|
## State, Notification, And Testing Invariants
|
||||||
|
|
||||||
Prefer the Go standard library where practical.
|
- Managed writes are atomic where practical and stay beneath the configured
|
||||||
|
workspace root. Reached artifacts remain inspectable after later failures.
|
||||||
|
- New records use `weatherreporter.metadata.v2`; V1 records remain readable for
|
||||||
|
inspection compatibility.
|
||||||
|
- Distributor uploads use only the managed Markdown report, never output copies
|
||||||
|
or workspace scans. Notification follows report and final metadata success.
|
||||||
|
- Default tests are deterministic, offline, and use Promptkit/provider fakes
|
||||||
|
rather than live provider calls. See the [testing policy](testing.md).
|
||||||
|
|
||||||
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.
|
## Non-Goals
|
||||||
|
|
||||||
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.
|
Weatherreporter is not a weather-data ingestion service, general LLM
|
||||||
|
orchestration framework, plugin platform, HTTP service, multi-user job system,
|
||||||
## Package Layout
|
or a replacement for Promptkit or Distributor.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
@@ -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,232 @@
|
|||||||
# Go Project Documentation Policy
|
# Documentation Policy
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Project documentation must help four audiences:
|
This policy assigns each Weatherreporter documentation topic to one canonical
|
||||||
|
owner. Its goal is to keep documentation accurate, concise, discoverable, and
|
||||||
1. users who need to run the application;
|
resistant to drift for users, operators, developers, integrators, maintainers,
|
||||||
2. administrators/operators who need to configure and operate it;
|
and coding agents.
|
||||||
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.
|
|
||||||
|
|
||||||
## Core Rules
|
## Core Rules
|
||||||
|
|
||||||
### 1. Keep docs concise
|
### One Canonical Documentation Owner
|
||||||
|
|
||||||
Each document should cover a defined scope and only the essentials for that scope.
|
Each authoritative fact belongs in one canonical document or documentation
|
||||||
|
area. A non-owning document may give a short, stable summary for orientation,
|
||||||
Avoid:
|
but it must link to the canonical owner instead of maintaining a second
|
||||||
- long background explanations;
|
definition.
|
||||||
- repeated reference material;
|
|
||||||
- implementation detail in user-facing docs;
|
Volatile details include commands, flags, configuration fields and defaults,
|
||||||
- aspirational language outside roadmap docs;
|
report and module IDs, schemas, file names, paths, status and exit behavior,
|
||||||
- verbose examples where one minimal example is clearer.
|
retry behavior, and runtime guarantees. If readers could reasonably treat a
|
||||||
|
statement as a contract, its exact documentation belongs with the owner named
|
||||||
### 2. Document only implemented behavior outside roadmap files
|
in this policy.
|
||||||
|
|
||||||
Unimplemented, planned, aspirational, experimental, or future work may be described only under:
|
Executable sources of truth and documentation owners serve different purposes.
|
||||||
|
Code, schemas, and embedded assets determine runtime behavior. The canonical
|
||||||
- `docs/roadmap/`
|
document owns the corresponding explanation or reference for readers. Both may
|
||||||
|
necessarily express the same contract, but other documentation should summarize
|
||||||
No other documentation file, including `README.md`, should describe code, features, modules, stages, commands, config fields, or behaviors that do not currently exist.
|
and link rather than create another complete reference. When implementation and
|
||||||
|
documentation disagree, verify the intended behavior and update them together.
|
||||||
If a feature is partial, non-roadmap docs may describe only the implemented portion and its current boundary.
|
|
||||||
|
### Current State, Decisions, And Future Work
|
||||||
### 3. Use canonical homes
|
|
||||||
|
Outside `docs/roadmap/`, documentation describes implemented behavior only.
|
||||||
Each type of information should have one canonical location.
|
Partial features may be described only to their implemented boundary.
|
||||||
|
|
||||||
Canonical homes:
|
An accepted architecture decision may describe an approved direction before it
|
||||||
|
is implemented, but acceptance is not evidence that the behavior exists.
|
||||||
- project purpose and quickstart: `README.md`
|
Current-state documents change when the implementation lands. Temporary
|
||||||
- development principles: `docs/policy/architecture.md`
|
roadmaps own future work, sequencing, and implementation status; they do not
|
||||||
- configuration reference: `docs/config.md`
|
replace durable policies, decisions, or current contracts.
|
||||||
- CLI reference: `docs/cli.md`
|
|
||||||
- operations and recovery: `docs/operations.md`
|
### Audience And Detail
|
||||||
- troubleshooting: `docs/troubleshooting.md`
|
|
||||||
- implemented internals: `docs/internal/`
|
Write for the document's stated audience and include only the detail needed for
|
||||||
- future work: `docs/roadmap/`
|
its owned topic. User and operator documentation should not expose incidental
|
||||||
- contributor workflow: `docs/policy/development.md`
|
implementation detail. Developer documentation should link to user-facing and
|
||||||
- copyable examples: `examples/`
|
external contracts instead of restating them.
|
||||||
|
|
||||||
Other files should summarize briefly and link to the canonical source.
|
### Links
|
||||||
|
|
||||||
### 4. Keep examples real
|
Use descriptive link text and repository-relative links for repository
|
||||||
|
documents. Link to the canonical owner rather than to a duplicate summary.
|
||||||
Examples should be valid, maintained, and free of secrets.
|
Check every added or changed link, and repair or remove links when their target
|
||||||
|
moves or is retired.
|
||||||
Where practical:
|
|
||||||
- example configs should load successfully;
|
### Examples And Code Fences
|
||||||
- example commands should match real CLI syntax;
|
|
||||||
- important examples should be covered by tests.
|
Complete copyable files belong in `examples/` when maintained examples exist.
|
||||||
|
Documentation may use the smallest illustrative snippet needed for its owned
|
||||||
## Documentation Profiles
|
topic, but should link to a maintained example instead of embedding a second
|
||||||
|
complete copy.
|
||||||
All projects require:
|
|
||||||
|
Examples must be valid, secret-free, and tested where practical. Commands,
|
||||||
- `README.md`
|
flags, configuration, imports, and Go snippets must match implemented behavior.
|
||||||
- `docs/policy/architecture.md`
|
Use a language tag on fenced code blocks, and identify fragments that are
|
||||||
|
illustrative rather than directly runnable.
|
||||||
Additional docs depend on the project.
|
|
||||||
|
### Security And Privacy
|
||||||
### Small library
|
|
||||||
|
Documentation and examples must not contain real credentials, private keys,
|
||||||
Recommended:
|
private environment dumps, sensitive source material, or private
|
||||||
- `docs/policy/development.md`, if contributor conventions are non-obvious
|
infrastructure details unless intentionally public. Document secret-handling
|
||||||
|
mechanisms, not secret values.
|
||||||
### Simple CLI
|
|
||||||
|
## Canonical Ownership
|
||||||
Required:
|
|
||||||
- `docs/cli.md`
|
| Topic | Canonical owner | Owned content | Content owned elsewhere |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
Recommended:
|
| 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. |
|
||||||
- `docs/policy/development.md`
|
| 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. |
|
||||||
### Config-driven CLI
|
| 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. |
|
||||||
Required:
|
| 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. |
|
||||||
- `docs/cli.md`
|
| 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. |
|
||||||
- `docs/config.md`
|
| 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, runtime state lifecycle, and loading implementation. |
|
||||||
Recommended:
|
| Operations | `docs/operations.md` | Normal workflows, physical workspace layout, artifacts and metadata, inspection, notification behavior, recovery, cleanup, permissions, and operational caveats. | Complete CLI syntax, configuration field definitions, logical external contracts, and implementation mechanics. |
|
||||||
- `examples/`
|
| Troubleshooting | `docs/troubleshooting.md` | Recurring symptoms, likely causes, diagnostic steps, safe fixes, and links to normal-operation references. | Complete command and configuration references, routine operating procedures, and implementation detail. |
|
||||||
- `docs/policy/development.md`
|
| 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. |
|
||||||
### Stateful or operator-facing application
|
| 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. |
|
||||||
Required:
|
| 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. |
|
||||||
- `docs/cli.md`, if CLI-based
|
| Complete copyable artifacts | `examples/` | Maintained configuration and other files intended to be copied or run. | Field-by-field reference, command reference, and prose explanation. |
|
||||||
- `docs/config.md`, if config-driven
|
|
||||||
- `docs/operations.md`
|
Conditional owners do not require placeholder files or directories. If
|
||||||
|
Weatherreporter introduces a new public API, consumer interface, release
|
||||||
Recommended:
|
process, or other durable documentation responsibility, update this policy to
|
||||||
- `docs/troubleshooting.md`
|
assign its canonical owner when that responsibility is introduced.
|
||||||
- `examples/`
|
|
||||||
- `docs/policy/development.md`
|
## Boundary Rules
|
||||||
|
|
||||||
### Modular, staged, service-oriented, or orchestration application
|
### Orientation, Architecture, And Internals
|
||||||
|
|
||||||
Required:
|
The README owns product orientation. The development policy routes contributors
|
||||||
- `docs/cli.md`, if CLI-based
|
and owns the concise current package inventory. Architecture owns normative
|
||||||
- `docs/config.md`, if config-driven
|
structure and invariants. Focused internal documents own implementation
|
||||||
- `docs/operations.md`
|
behavior. These documents may link to one another but must not maintain
|
||||||
- `docs/internal/`
|
parallel package or behavior references.
|
||||||
- `docs/policy/development.md`
|
|
||||||
|
### Commands, Configuration, Operations, And Troubleshooting
|
||||||
Recommended:
|
|
||||||
- `docs/troubleshooting.md`
|
CLI documentation answers how to invoke Weatherreporter and what its command
|
||||||
- validated examples under `examples/`
|
interface does. Configuration documentation answers what settings mean.
|
||||||
|
Operations answers what happens to runtime state and how to operate or recover
|
||||||
## Required Documents
|
the application. Troubleshooting starts from a symptom and leads to diagnosis
|
||||||
|
and a safe fix.
|
||||||
### README.md
|
|
||||||
|
When a workflow crosses these topics, place the complete procedure with the
|
||||||
**Audience:** users, administrators, operators
|
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.
|
||||||
The README is the outward-facing project orientation page.
|
|
||||||
|
### Templates, Integrations, And Implementation
|
||||||
It should include, in order:
|
|
||||||
|
Template documentation defines the maintainer-facing rendering surface.
|
||||||
1. concise description;
|
Integration documentation defines externally observable shapes, logical paths,
|
||||||
2. elevator pitch;
|
protocols, and compatibility behavior. Internal documentation explains how
|
||||||
3. shortest useful command or usage example;
|
Weatherreporter produces, transforms, or consumes those contracts.
|
||||||
4. links to targeted docs.
|
|
||||||
|
Internal documents may name a command, field, template value, path, or protocol
|
||||||
The README should be short. It is not a manual.
|
to identify a dependency, but must link to its canonical documentation for the
|
||||||
|
complete definition.
|
||||||
The “shortest useful command” means the simplest command that performs the project’s core use case. (It does not mean `app --help`.)
|
|
||||||
|
### Release Procedure And Release Notes
|
||||||
### docs/policy/architecture.md
|
|
||||||
|
The release procedure owns how a maintainer prepares, publishes, verifies, and
|
||||||
**Audience:** developers, LLM coding agents
|
recovers from a Weatherreporter release. Release notes under `docs/releases/`
|
||||||
|
own the concise historical summary for one version and are the checked-in
|
||||||
`docs/policy/architecture.md` is required for every project.
|
source for its generated Gitea release body.
|
||||||
|
|
||||||
It is an inward-facing development policy document. It should describe how the project is intended to be built and changed.
|
Release notes are not current-state reference documents. They may summarize
|
||||||
|
what changed and link to durable documentation, but they must not become a
|
||||||
It should include:
|
second command, configuration, operations, integration, architecture, or
|
||||||
|
internal reference. Correct the applicable canonical owner in the same change
|
||||||
- project shape;
|
when a release changes an implemented contract.
|
||||||
- core design principles;
|
|
||||||
- package and boundary philosophy;
|
The release note at a published tag and the Gitea release generated from it are
|
||||||
- state/persistence philosophy, if applicable;
|
historical records. Later corrections on `main` do not rewrite that published
|
||||||
- external integration philosophy, if applicable;
|
record. Material release errors require the failure handling defined by the
|
||||||
- error-handling and logging principles;
|
release procedure rather than moving a published tag or overwriting its
|
||||||
- testing expectations;
|
release.
|
||||||
- documentation expectations;
|
|
||||||
- architectural invariants;
|
### Executable Authority
|
||||||
- explicit non-goals, if useful.
|
|
||||||
|
CLI parsing and help generation are the executable authority for accepted
|
||||||
For small projects, this file may be brief. It may simply state that the project is intentionally narrow, monolithic, and dependency-light.
|
commands and flags. Configuration structs, defaults, loading, and validation
|
||||||
|
are the executable authority for configuration behavior. Schemas and embedded
|
||||||
### docs/policy/development.md
|
assets are the executable authority for validated formats and template
|
||||||
|
execution. Tests protect selected contracts and invariants but do not become a
|
||||||
**Audience:** developers, LLM coding agents
|
second documentation reference merely by asserting them.
|
||||||
|
|
||||||
Required for projects maintained by humans and LLM coding agents.
|
Canonical documentation must be checked against these authorities whenever the
|
||||||
|
corresponding behavior changes.
|
||||||
It should include:
|
|
||||||
|
### Security Topics
|
||||||
- repository layout;
|
|
||||||
- build/test commands;
|
This policy owns what documentation and examples may contain. Architecture owns
|
||||||
- coding conventions;
|
application security boundaries and invariants. Configuration owns
|
||||||
- dependency policy;
|
credential-supply mechanisms. Operations owns permissions and handling of
|
||||||
- how to add config fields;
|
sensitive runtime artifacts. Integration documents own consumer-visible
|
||||||
- how to add CLI flags;
|
security contracts. Internal documents own implementation mechanisms only.
|
||||||
- how to add stages/modules/adapters, if applicable;
|
|
||||||
- how to update examples;
|
## Architecture Decision Records
|
||||||
- documentation update expectations.
|
|
||||||
|
Use sequentially numbered ADR filenames such as
|
||||||
### docs/config.md
|
`0001-record-architecture-decisions.md`. Follow the lightweight Nygard format:
|
||||||
|
|
||||||
**Audience:** administrators, operators, advanced users
|
1. title;
|
||||||
|
2. status;
|
||||||
Required for applications with configuration files.
|
3. date;
|
||||||
|
4. context;
|
||||||
It should include, in order:
|
5. decision;
|
||||||
|
6. alternatives considered;
|
||||||
1. config file locations and discovery precedence;
|
7. consequences.
|
||||||
2. minimal working config;
|
|
||||||
3. production-oriented config;
|
Use one of these statuses:
|
||||||
4. full configuration reference;
|
|
||||||
5. secrets handling, if applicable;
|
- **Proposed:** the decision is under consideration and may change;
|
||||||
6. links to maintained examples.
|
- **Accepted:** the decision is approved, whether or not implementation is
|
||||||
|
complete;
|
||||||
The full configuration reference should be canonical.
|
- **Rejected:** the proposed decision was considered and not adopted;
|
||||||
|
- **Superseded:** a later accepted ADR replaces the accepted decision.
|
||||||
### docs/cli.md
|
|
||||||
|
A proposed ADR transitions to Accepted or Rejected. An Accepted ADR transitions
|
||||||
**Audience:** users, administrators, operators
|
to Superseded only when a later Accepted ADR replaces it. An ADR may be created
|
||||||
|
as Accepted when the decision has already been made.
|
||||||
Required for CLI applications.
|
|
||||||
|
Treat the decision content of an Accepted ADR as immutable. A changed decision
|
||||||
It should include, in order:
|
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
|
||||||
1. shortest useful command;
|
architectural alternatives belong in the ADR; rejected feature ideas belong in
|
||||||
2. command overview;
|
a roadmap when they need to be retained.
|
||||||
3. complete flag reference;
|
|
||||||
4. common workflows;
|
## Document Lifecycle
|
||||||
5. diagnostic or recovery commands, if applicable.
|
|
||||||
|
Create durable current-state documentation with the implementation it
|
||||||
Explain when commands are useful, not just their syntax.
|
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
|
||||||
### docs/operations.md
|
remains useful.
|
||||||
|
|
||||||
**Audience:** administrators, operators
|
Roadmaps are temporary coordination documents. When their work is complete,
|
||||||
|
record completion, move any still-useful decisions or contracts to their
|
||||||
Required for applications that maintain state, support resume behavior, run multiple stages, write durable artifacts, use remote storage, or require recovery procedures.
|
durable owners, update incoming links, and archive or remove the roadmap
|
||||||
|
according to repository practice. Do not preserve completed roadmaps as a
|
||||||
It should cover:
|
second current-state reference.
|
||||||
|
|
||||||
- normal workflow;
|
Release notes are durable historical summaries rather than temporary roadmaps.
|
||||||
- filesystem layout;
|
Keep them concise, retain them after publication, and keep current contracts in
|
||||||
- remote storage layout, if applicable;
|
their canonical owners.
|
||||||
- logs and manifests;
|
|
||||||
- resume/retry behavior;
|
Before completing documentation work:
|
||||||
- cleanup behavior;
|
|
||||||
- archive/backup behavior;
|
- verify affected behavior and examples;
|
||||||
- safe recovery procedures;
|
- check commands, flags, fields, defaults, schemas, paths, and identifiers
|
||||||
- operational caveats.
|
against their implementation;
|
||||||
|
- keep unimplemented behavior in a roadmap, subject to the ADR exception;
|
||||||
### docs/troubleshooting.md
|
- validate links and fenced examples;
|
||||||
|
- confirm non-owning documents summarize and link rather than redefine;
|
||||||
**Audience:** administrators, operators
|
- remove stale or unsupported claims; and
|
||||||
|
- confirm that no secrets or sensitive private data were added.
|
||||||
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.
|
|
||||||
|
|||||||
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,
|
||||||
|
inspection, persistence, 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, persistence, partial success, and
|
||||||
|
failure propagation.
|
||||||
|
- State tests own path derivation, atomic artifacts, lookup, and round trips.
|
||||||
|
- 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, saving
|
||||||
|
metadata before notification, 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, workspace 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.
|
||||||
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,22 @@
|
|||||||
# Future Roadmap
|
# Future Roadmap
|
||||||
|
|
||||||
This roadmap contains project work that is not implemented. Current behavior is
|
This roadmap contains future work only. Each section identifies its planning
|
||||||
documented outside `docs/roadmap/`.
|
status; current behavior is documented outside `docs/roadmap/`.
|
||||||
|
|
||||||
## Automatic Storm Monitoring
|
## Automatic Storm Monitoring
|
||||||
|
|
||||||
Manual Storm Report generation is implemented through
|
Status: Proposed and unimplemented.
|
||||||
`weatherreporter generate storm --start TIME --end TIME`. Automatic storm-event
|
|
||||||
evaluation remains deferred.
|
|
||||||
|
|
||||||
Proposed direction:
|
Storm reporting, whether manual or automatic, is unimplemented.
|
||||||
|
|
||||||
1. detect candidate events deterministically from alerts, forecast discussion,
|
Possible direction:
|
||||||
weather story context, hourly thresholds, and material forecast changes;
|
|
||||||
2. evaluate candidates through Scriptorium or another narrow evaluator adapter;
|
1. Detect candidate storm events from alerts, forecast discussion, weather
|
||||||
3. persist storm lifecycle state;
|
story context, hourly thresholds, and material forecast changes.
|
||||||
4. generate or update Storm Reports only when a meaningful event is present;
|
2. Evaluate candidates through Scriptorium or another narrow evaluator adapter.
|
||||||
5. suppress ordinary low-impact thunder or rain chances.
|
3. Persist storm lifecycle state.
|
||||||
|
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:
|
Possible lifecycle states:
|
||||||
|
|
||||||
@@ -27,121 +27,116 @@ Possible lifecycle states:
|
|||||||
- `deescalating`
|
- `deescalating`
|
||||||
- `resolved`
|
- `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;
|
## Future Report Types
|
||||||
- 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 And Modules
|
Status: Proposed and unimplemented.
|
||||||
|
|
||||||
The module-based prompt package architecture is implemented. Future work should
|
|
||||||
add only modules backed by implemented upstream facts and clear report needs.
|
|
||||||
|
|
||||||
Possible future report types:
|
Possible future report types:
|
||||||
|
|
||||||
- another short-fuse planning report distinct from the implemented Hourly
|
- a short-fuse planning report distinct from the implemented Hourly Report, if
|
||||||
Report, if a separate product is needed;
|
a separate product is needed
|
||||||
- event-specific reports with stable event IDs;
|
- event-specific reports with stable event IDs
|
||||||
- storm review or yesterday-style reports using historical observations;
|
- storm review or yesterday-style reports using historical observations
|
||||||
- archive-focused report variants if generated report history becomes a
|
- 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:
|
Possible future modules:
|
||||||
|
|
||||||
- `hourly_table` for compact valid-period hourly facts;
|
- `hourly_table` for compact valid-period hourly facts
|
||||||
- `forecast_delta` if a separate stanza is useful beyond current Recent
|
- `forecast_delta` if a separate stanza is useful beyond current Recent
|
||||||
Changes;
|
Changes
|
||||||
- `weekend_planning` if weekend-specific planning guidance needs a dedicated
|
- `weekend_planning` if weekend-specific planning guidance needs a dedicated
|
||||||
deterministic stanza;
|
deterministic stanza
|
||||||
- `storm_window_summary` if manual or automatic Storm Reports need a dedicated
|
- `storm_window_summary` if manual or automatic storm reports need a dedicated
|
||||||
prompt-facing storm-window module;
|
prompt-facing storm-window module
|
||||||
- separate AFD section aliases, such as `afd_key_messages`,
|
- separate AFD section aliases, such as `afd_key_messages`,
|
||||||
`afd_short_term_text`, and `afd_long_term_text`, if separate stanzas prove
|
`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
|
- 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
|
QPF fields such as `measurable_qpf_total_in` and `max_hourly_qpf_in` should
|
||||||
remain omitted until a real upstream quantitative precipitation source is
|
remain omitted until a real upstream quantitative precipitation source is
|
||||||
represented in `CollectedFacts`.
|
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 collection in app orchestration
|
||||||
- keep upstream fetching out of modules;
|
- keep upstream collection out of modules
|
||||||
- keep broad reusable calculations in `DerivedFacts`;
|
- keep broad reusable calculations in `DerivedFacts`
|
||||||
- keep prompt-facing field shape inside module builders;
|
- keep prompt-facing field shape inside module builders
|
||||||
- use typed options for configurable module behavior;
|
- use typed options for configurable module behavior
|
||||||
- keep module snapshots structured and deterministic for Recent Changes.
|
- keep module snapshots structured and deterministic for Recent Changes
|
||||||
|
|
||||||
## Distributor Notification Enhancements
|
## Distributor Notification Enhancements
|
||||||
|
|
||||||
Distributor notification currently uploads one managed Markdown report per
|
Status: Proposed and unimplemented.
|
||||||
successful generated report through the configured HTTP upload pipeline.
|
|
||||||
|
|
||||||
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`;
|
- `failure_policy: warn`
|
||||||
- uploading metadata, module snapshots, data packages, or preflight artifacts;
|
- uploading metadata, module snapshots, data packages, or preflight artifacts
|
||||||
- durable upload retry queues;
|
- durable upload retry queues
|
||||||
- distributor-specific CLI flags;
|
- distributor-specific CLI flags
|
||||||
- making distributor scan the weatherreporter workspace;
|
- distributor workspace scanning
|
||||||
- handling destination routing, Markdown-to-HTML transformation, public URLs,
|
- destination routing, Markdown-to-HTML transformation, public URLs, or nginx
|
||||||
or nginx layout inside weatherreporter.
|
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,
|
weatherreporter selects explicit generated files and submits source bundles,
|
||||||
while distributor owns destination routing and publication behavior.
|
while distributor owns destination routing and publication behavior.
|
||||||
|
|
||||||
## Alternate Runtime Integrations
|
## Alternate Runtime Integrations
|
||||||
|
|
||||||
These ideas are not current behavior:
|
Status: Proposed and unimplemented.
|
||||||
|
|
||||||
- native LLM client inside `weatherreporter`;
|
These ideas remain unimplemented:
|
||||||
- database-backed state;
|
|
||||||
- public HTTP API;
|
- native LLM client inside `weatherreporter`
|
||||||
- multi-location selection;
|
- database-backed state
|
||||||
- daemon mode;
|
- public HTTP API
|
||||||
- multi-user authorization;
|
- multi-location selection
|
||||||
- plugin system;
|
- daemon mode
|
||||||
- dynamic module loading;
|
- multi-user authorization
|
||||||
- user-defined module code;
|
- plugin system
|
||||||
- YAML-defined module schemas;
|
- dynamic module loading
|
||||||
- module-owned Weather API fetching.
|
- 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
|
Each item needs its own design note before implementation. Non-roadmap docs
|
||||||
must not describe these as available behavior.
|
must not describe these as available behavior.
|
||||||
|
|
||||||
## Cleanup Refactors
|
## Deferred Refactors
|
||||||
|
|
||||||
The initial cleanup pass intentionally left these refactors out because the
|
Status: Deferred.
|
||||||
current implementation does not yet make them worth the added abstraction.
|
|
||||||
|
|
||||||
Revisit these only when new source types, report types, operational
|
These refactors should remain deferred until new requirements or recurring
|
||||||
requirements, or recurring maintenance costs make the duplication materially
|
maintenance costs make the added abstraction worthwhile:
|
||||||
more expensive:
|
|
||||||
|
|
||||||
- Weather API optional-source specification/helper refactor: consider when
|
- broad briefing weather-signal consolidation
|
||||||
additional Weather API sources make per-source fan-out, policy handling, and
|
- generic workflow engine
|
||||||
provenance wiring repetitive enough to obscure adapter behavior.
|
- Cobra migration
|
||||||
- Broad briefing weather-signal consolidation: consider when multiple module
|
- manifest, resume, or progress system
|
||||||
builders repeatedly derive the same weather signals and tests begin to need
|
- global test helper package
|
||||||
coordinated fixture updates.
|
- logging subsystem
|
||||||
- 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.
|
|
||||||
|
|
||||||
Any future implementation should preserve the existing public CLI, artifact
|
Any future implementation should preserve the existing public CLI, artifact
|
||||||
paths, report identities, module boundaries, and adapter boundaries unless a
|
paths, report identities, module boundaries, and adapter boundaries unless a
|
||||||
|
|||||||
@@ -1,441 +1,555 @@
|
|||||||
# Tomorrow Report Implementation Roadmap
|
# Promptkit Migration Implementation Plan
|
||||||
|
|
||||||
## Purpose
|
Status: Completed; Stages 1–19 passed their exit gates.
|
||||||
|
|
||||||
This roadmap defines the staged implementation plan for
|
## Purpose And Authority
|
||||||
`docs/roadmap/tomorrow.md`. It is written for an LLM coding agent that will
|
|
||||||
implement each stage in order. The conceptual target state, user intent, and
|
This document records the completed implementation of the
|
||||||
locked product decisions live in `docs/roadmap/tomorrow.md`; this file defines
|
[Promptkit migration roadmap](promptkit.md) and its post-implementation audit
|
||||||
the concrete implementation sequence.
|
remediation. The feature roadmap records scope, user intent, policy choices,
|
||||||
|
and the implemented end state. This plan records implementation sequence,
|
||||||
This is future-work planning. Do not treat the behavior described here as
|
tests, and completion gates.
|
||||||
implemented until the corresponding code, tests, examples, and non-roadmap docs
|
|
||||||
are updated.
|
Stages 12–19 were completed in order. They fixed additional defects exposed by
|
||||||
|
their required tests only when those defects were within the same stated
|
||||||
## Implementation Guardrails
|
contract; they did not add new product behavior or reinterpret roadmap
|
||||||
|
decisions.
|
||||||
- Preserve public CLI syntax: `weatherreporter generate tomorrow`.
|
|
||||||
- Make a clean pre-release break from report ID `daily_tomorrow`; do not add
|
This plan follows the repository's
|
||||||
compatibility aliases.
|
[architecture](../policy/architecture.md),
|
||||||
- Keep report identity, prompt IDs, template IDs, artifact groups, output names,
|
[documentation](../policy/documentation.md), and
|
||||||
and comparison policy centralized in `internal/report`.
|
[testing](../policy/testing.md) policies.
|
||||||
- Reuse the existing `generated_text_template` workflow implemented for Hourly.
|
|
||||||
- Keep Scriptorium details behind the existing adapter boundary.
|
## Continuing Invariants
|
||||||
- Keep Go responsible for deterministic facts, valid periods, module snapshots,
|
|
||||||
structured generated-text validation, and final Markdown template rendering.
|
- Keep `gitea.maximumdirect.net/eric/promptkit` pinned at exactly `v0.4.0`.
|
||||||
- Keep templates responsible for wording and layout.
|
- Keep Promptkit types inside `internal/adapters/promptkit`, its tests, and the
|
||||||
- Keep generated JSON schemas and Markdown templates as embedded asset files,
|
external prompt-asset contract test.
|
||||||
not inline Go strings.
|
- Preserve one Promptkit engine per `generate` or `run` invocation and one
|
||||||
- Preserve current managed artifact behavior except where report identity
|
shared engine for every sequential report in a batch.
|
||||||
intentionally changes from `daily_tomorrow` to `tomorrow`.
|
- Preserve exact prompt version `1.0.0`, the exact persisted YAML data-package
|
||||||
|
bytes, prepared execution, and preparation persistence before provider work.
|
||||||
## Stage 1: Report Identity Split
|
- Do not add retries, repair attempts, concurrent batch generation, direct
|
||||||
|
Markdown generation, arbitrary backend registration, or live-provider
|
||||||
Goal: make Tomorrow an independent report ID and artifact identity while
|
tests.
|
||||||
preserving the public `generate tomorrow` command.
|
- Keep ordinary artifacts, errors, logs, and summaries free of credentials,
|
||||||
|
rendered messages, schemas, input bodies, generated bodies, endpoints, and
|
||||||
Implementation:
|
full effective parameter maps.
|
||||||
|
- Keep sensitive debug artifacts opt-in, outside normal state, owner-only,
|
||||||
- Replace `report.DailyTomorrow` with `report.Tomorrow` whose value is
|
atomic, and free of credentials.
|
||||||
`"tomorrow"`.
|
- Treat an artifact path as reached only after the corresponding write or copy
|
||||||
- Rename report-definition helpers and resolvers around the new identity:
|
succeeds. Never persist or summarize a merely derivable future path.
|
||||||
`dailyTomorrowDefinition` to `tomorrowDefinition`,
|
- Preserve every safe reached path in partial app and CLI results even when a
|
||||||
`dailyTomorrowModules` to `tomorrowModules`, and
|
later persistence, validation, rendering, copy, or notification step fails.
|
||||||
`resolveDailyTomorrow` to `resolveTomorrow`.
|
- Keep v1 metadata read compatibility and write only v2 metadata for new runs.
|
||||||
- Update `report.DefaultRegistry`, `Registry.All`, batch resolution, and tests
|
- Keep the default test suite deterministic, offline, and credential-free.
|
||||||
so the built-in report order contains `tomorrow` instead of
|
- Run `git diff --check` before completing every stage. Run the full repository
|
||||||
`daily_tomorrow`.
|
gate in Stage 19.
|
||||||
- Update `internal/app` so `app.ReportTomorrow` resolves to `report.Tomorrow`.
|
|
||||||
- Keep the valid period as the next local civil day.
|
## Completed Migration Summary
|
||||||
- Keep evening batch behavior: `run evening` should still generate the Tomorrow
|
|
||||||
report.
|
Stages 1–11 are implemented and committed. They remain summarized here to
|
||||||
- Change Tomorrow definition identity fields to:
|
preserve the history and dependencies of the follow-up work.
|
||||||
- `ID: report.Tomorrow`
|
|
||||||
- `Name: "Tomorrow Report"`
|
| Stage | Completed outcome |
|
||||||
- `ArtifactGroup: "tomorrow"`
|
| --- | --- |
|
||||||
- `BatchOutputName: "tomorrow.md"`
|
| 1 | Removed the unfinished three-day, weekend, and storm product surfaces and retained Daily, Today, Tomorrow, and Hourly with exact prompt version `1.0.0`. |
|
||||||
- `Generated: true`
|
| 2 | Promoted the four operational prompts and canonical schemas into the embedded `internal/promptassets` source used by Promptkit and generated-text validation. |
|
||||||
- `CompatiblePriorIDs: []report.ID{report.Tomorrow}`
|
| 3 | Added the project-owned `internal/promptexec` inspection, preparation, execution, validation, debug, and error contract. |
|
||||||
- `ComparisonStrategy: report.CompareSameValidDate`
|
| 4 | Added the Promptkit v0.4.0 adapter with prepared execution, explicit value mapping, safe error classification, and offline model-client tests. |
|
||||||
- Keep the current Tomorrow module composition initially, renamed to
|
| 5 | Added Promptkit-era preparation and execution artifacts, metadata v2, new paths, and v1 decoding support. |
|
||||||
`tomorrowModules`, so the prompt data package continues to include the
|
| 6 | Added explicitly rooted, permission-restricted, atomic LLM debug persistence. |
|
||||||
daypart, precipitation, alert, SPC, AFD, weather story, tomorrow planning,
|
| 7 | Added Promptkit configuration, executor composition, and pre-collection prompt/profile/credential inspection. |
|
||||||
and hourly facts already available.
|
| 8 | Cut single-report generation over to prepared Promptkit execution and v2 persistence. |
|
||||||
- Update all code and tests that assert `daily_tomorrow` paths, metadata,
|
| 9 | Added `--llm-debug-dir` and Promptkit-era single-report summary fields. |
|
||||||
RunIDs, prior compatibility, or registry IDs.
|
| 10 | Cut morning and evening batches over to one shared Promptkit executor and removed Scriptorium code, configuration, and dependency metadata. |
|
||||||
|
| 11 | Updated canonical Promptkit documentation, removed the temporary Scriptorium corpus, and ran the available repository checks. |
|
||||||
Acceptance criteria:
|
|
||||||
|
The post-implementation audit confirmed the principal dependency and package
|
||||||
- No production-code references to `report.DailyTomorrow` or report ID
|
boundaries, but found incorrect reached-path bookkeeping, incomplete execution
|
||||||
`daily_tomorrow` remain.
|
artifact updates, insufficient artifact validation, extensive loss of
|
||||||
- `weatherreporter generate tomorrow` still parses and resolves successfully.
|
behavioral tests during the final cutover, and roadmap lifecycle text that was
|
||||||
- Evening batch contains `tomorrow`.
|
not finalized. The completed remediation addressed those findings without
|
||||||
- Managed workspace artifact paths and distributor identity values now use
|
changing the intended feature scope.
|
||||||
`tomorrow`.
|
|
||||||
|
| Stage | Completed outcome |
|
||||||
Suggested validation:
|
| --- | --- |
|
||||||
|
| 12 | Corrected reached-path bookkeeping across metadata, app results, batch items, and CLI summaries. |
|
||||||
```bash
|
| 13 | Hardened Promptkit-era durable state validation and restored v1/v2 state coverage. |
|
||||||
go test ./internal/report ./internal/app ./internal/cli ./internal/state
|
| 14 | Recorded every downstream path reached after completed prompt execution. |
|
||||||
```
|
| 15 | Restored assembled single-report behavioral and failure coverage. |
|
||||||
|
| 16 | Simplified prompt-generation orchestration while preserving behavior. |
|
||||||
This stage is suitable for one implementation prompt.
|
| 17 | Restored assembled batch, planning, artifact, and notification coverage. |
|
||||||
|
| 18 | Restored supported CLI, summary, safety, and historical inspection coverage. |
|
||||||
## Stage 2: Tomorrow GeneratedText Contract
|
| 19 | Reconciled canonical documentation and passed the complete repository verification gate. |
|
||||||
|
|
||||||
Goal: add structured Tomorrow LLM output validation and schema assets.
|
## Stage 12: Correct Reached-Artifact Bookkeeping
|
||||||
|
|
||||||
Implementation:
|
Status: Completed.
|
||||||
|
|
||||||
- Add `internal/generatedtext/tomorrow.go` with:
|
### Goal
|
||||||
|
|
||||||
```go
|
Make metadata, app results, batch items, and CLI summaries truthful at every
|
||||||
type Tomorrow struct {
|
failure boundary: a nonblank path means that artifact was successfully
|
||||||
Summary string `json:"summary"`
|
created.
|
||||||
ForecastDiscussion []string `json:"forecast_discussion"`
|
|
||||||
PrecipitationTiming string `json:"precipitation_timing,omitempty"`
|
### Work
|
||||||
Confidence string `json:"confidence,omitempty"`
|
|
||||||
}
|
1. Change `state.BuildPromptMetadataFromBriefingMetadata` so it initializes
|
||||||
```
|
identity, schema, metadata destination, and only artifacts already saved at
|
||||||
|
the call site. It must not prepopulate raw-output, normalized-text,
|
||||||
- Add `generatedtext.ValidateTomorrow`.
|
render-context, managed-report, preparation, execution, notification, or
|
||||||
- Use `json.Decoder.DisallowUnknownFields`.
|
output-copy paths.
|
||||||
- Reject multiple JSON values.
|
2. In `generatePromptReport`, assign each metadata and `ReportResult` path
|
||||||
- Trim `Summary`, `PrecipitationTiming`, `Confidence`, and each
|
immediately after that artifact write succeeds and before attempting the
|
||||||
`ForecastDiscussion` paragraph.
|
next write. In particular:
|
||||||
- Drop blank discussion paragraphs after trimming, then require at least one
|
|
||||||
remaining paragraph.
|
- do not initialize `ReportResult.ReportPath` from `Store.Paths`;
|
||||||
- Reject blank `Summary`.
|
- record a saved failed-preparation receipt in the result before saving
|
||||||
- Return normalized JSON with the same public field names.
|
metadata;
|
||||||
- Add `internal/reporttemplate/schemas/tomorrow.generated_text.schema.json`.
|
- record a saved failed or completed execution receipt before saving
|
||||||
The schema should:
|
metadata;
|
||||||
- require `summary`;
|
- retain raw, normalized, context, report, copy, and notification paths
|
||||||
- require `forecast_discussion`;
|
when a later step fails; and
|
||||||
- define `forecast_discussion` as an array of strings with at least one item;
|
- keep `MetadataPath` unchanged when a metadata rewrite fails, because the
|
||||||
- allow optional `precipitation_timing` and `confidence`;
|
prior successfully written metadata record remains the reached version.
|
||||||
- reject additional properties.
|
|
||||||
- Add `internal/reporttemplate/prompts/tomorrow.generated_text.md` as the
|
3. Remove batch-item prepopulation from derived `Store.Paths` values.
|
||||||
maintained Scriptorium prompt source asset. This file is a source contract for
|
`BatchReportResult` receives paths only from the returned `ReportResult` or
|
||||||
out-of-band Scriptorium prompt registration; weatherreporter does not need to
|
from a write that the batch itself successfully completed.
|
||||||
load prompt Markdown at runtime.
|
4. Preserve current CLI field names and omission behavior. Human and JSON
|
||||||
- Add Tomorrow to `internal/reporttemplate` schema lookup.
|
summaries must omit every unreached path.
|
||||||
- Update app generated-text validation dispatch so it can return either
|
5. Do not change artifact locations, filenames, schemas, report output, or
|
||||||
`generatedtext.Hourly` or `generatedtext.Tomorrow`. Prefer a small generic
|
notification policy in this stage.
|
||||||
dispatch shape such as:
|
|
||||||
|
### Tests
|
||||||
```go
|
|
||||||
func validateGeneratedText(def report.Definition, data []byte) (any, []byte, error)
|
- Add focused app tests for one representative report using real temporary
|
||||||
```
|
state plus a narrow failure-injecting store wrapper.
|
||||||
|
- Fail the next persistence step immediately after a successful preparation
|
||||||
Then type-check the returned value in render-context dispatch.
|
receipt, execution receipt, raw output, normalized output, render context,
|
||||||
|
managed report, output copy, and notification artifact; assert that the
|
||||||
Acceptance criteria:
|
returned result contains every reached path and no future path.
|
||||||
|
- Include one preparation failure, one operational execution failure, and one
|
||||||
- Tomorrow generated text rejects unknown fields, missing required fields,
|
completed validation rejection to cover the three execution outcome shapes.
|
||||||
blank summary, and no usable forecast discussion paragraphs.
|
- Add batch and CLI summary assertions proving unreached paths are omitted.
|
||||||
- Optional `precipitation_timing` and `confidence` are trimmed and omitted from
|
- Run:
|
||||||
normalized JSON when empty.
|
|
||||||
- Hourly generated text behavior is unchanged.
|
```sh
|
||||||
|
go test ./internal/state ./internal/app ./internal/cli
|
||||||
Suggested validation:
|
git diff --check
|
||||||
|
```
|
||||||
```bash
|
|
||||||
go test ./internal/generatedtext ./internal/reporttemplate ./internal/app
|
### Exit Gate
|
||||||
```
|
|
||||||
|
Every nonblank path in newly written metadata, app results, batch items, and
|
||||||
This stage is suitable for one implementation prompt.
|
CLI summaries names an artifact that exists. Every safe artifact successfully
|
||||||
|
written before a later failure remains discoverable from the returned partial
|
||||||
## Stage 3: Daypart Presentation Fields
|
result.
|
||||||
|
|
||||||
Goal: add only the daypart fields needed to keep the Tomorrow template
|
## Stage 13: Harden Durable State Contracts And Restore State Coverage
|
||||||
composable without moving prose construction into Go.
|
|
||||||
|
Status: Completed.
|
||||||
Implementation:
|
|
||||||
|
### Goal
|
||||||
- Extend the `derived_daypart_summaries` module output with presentation
|
|
||||||
helpers that are facts, not complete sentences:
|
Make the v1/v2 wire boundary and Promptkit-era artifact validation explicit,
|
||||||
- `display_name`, for example `Morning`;
|
strict, and durably tested.
|
||||||
- `dominant_condition_lower`, for inline template text;
|
|
||||||
- `temperature_phrase_f`, such as `low 70s`, `upper 60s`, or
|
### Work
|
||||||
`upper 60s to mid-70s`;
|
|
||||||
- `mention_precipitation`, true when max PoP is at or above the existing
|
1. Strengthen `PromptPreparationArtifact.Validate`:
|
||||||
hourly forecast precipitation mention threshold, currently 20%;
|
|
||||||
- `max_pop_time_label`, using friendly local hour format such as `8:00 AM`
|
- require report ID, Weatherreporter RunID, prompt ID, exact prompt version,
|
||||||
when max PoP time exists.
|
data-package path, nonzero start/end times, nonnegative duration, and an
|
||||||
- Reuse the existing hourly forecast precipitation mention threshold constant
|
end not earlier than the start;
|
||||||
rather than adding a user config field in this change.
|
- for success, require preparation provenance, prohibit an error, and
|
||||||
- Keep existing structured numeric fields in the module output.
|
require its prompt ID/version and data-package path to match the top-level
|
||||||
- Do not add a prewritten `daypart_line` string.
|
artifact;
|
||||||
- Do not add wind prose in this stage unless tests show the initial template
|
- for failure, require a classified bounded error and prohibit fabricated
|
||||||
needs a specific structured wind fact. If wind wording is needed, add a
|
preparation provenance.
|
||||||
small structured wind field, not a full sentence.
|
|
||||||
|
2. Strengthen `PromptExecutionArtifact.Validate`:
|
||||||
Acceptance criteria:
|
|
||||||
|
- require report ID, Weatherreporter RunID, prompt ID, exact prompt version,
|
||||||
- Daypart module output has enough structured fields for a readable Tomorrow
|
nonzero start/end times, nonnegative duration, and an end not earlier than
|
||||||
template.
|
the start;
|
||||||
- The output remains useful for YAML prompt packages.
|
- for success and validation rejection, require provenance and completed
|
||||||
- No generated prose sentence is hard-coded into the module.
|
validation, prohibit an operational error, and require the provenance
|
||||||
|
prompt ID/version to match the artifact;
|
||||||
Suggested validation:
|
- do not compare the provenance RunID with the Weatherreporter RunID because
|
||||||
|
the provenance value is Promptkit's run identity;
|
||||||
```bash
|
- for operational failure, require a classified bounded error and prohibit
|
||||||
go test ./internal/briefing ./internal/promptinput
|
invented provenance or completed validation.
|
||||||
```
|
|
||||||
|
3. Validate required provenance fields for completed executions, including
|
||||||
This stage is suitable for one implementation prompt.
|
Promptkit RunID, prompt and rendered hashes, selected profile/backend/model,
|
||||||
|
and data-package path. Permit usage counters and generated hash to be zero
|
||||||
## Stage 4: Tomorrow Render Context And Template
|
when the provider legitimately reports no value.
|
||||||
|
4. Restore focused filesystem and metadata tests for:
|
||||||
Goal: render Tomorrow Markdown from structured generated text, module outputs,
|
|
||||||
and report metadata.
|
- exact v2 paths and filenames;
|
||||||
|
- preparation/execution round trips and required fields;
|
||||||
Implementation:
|
- metadata v2 round trips without legacy aliases;
|
||||||
|
- v1 decoding, normalized internal aliases, and v1-preserving re-marshaling;
|
||||||
- Add a dedicated Tomorrow render context under `internal/generatedtext`, for
|
- unknown schema rejection;
|
||||||
example:
|
- report listing, RunID lookup, source/module/data-package inspection, and
|
||||||
|
retained v1 behavior for historical report IDs;
|
||||||
```go
|
- atomic writes and unsafe workspace/path rejection; and
|
||||||
type TomorrowRenderContext struct {
|
- prior-snapshot behavior for the four supported report IDs.
|
||||||
Report TomorrowReportContext
|
|
||||||
GeneratedText Tomorrow
|
5. Adapt useful tests from the deleted filesystem suite rather than recreating
|
||||||
Modules TomorrowTemplateModules
|
redundant low-value cases. Do not restore Scriptorium writes or retired
|
||||||
Collected facts.CollectedFacts
|
report behavior.
|
||||||
Derived facts.DerivedFacts
|
|
||||||
}
|
### Tests
|
||||||
```
|
|
||||||
|
|
||||||
- Add `TomorrowReportContext` with:
|
|
||||||
- `Title`, for example `Sunday's Weather`;
|
|
||||||
- `ForecastDate`;
|
|
||||||
- `ForecastDateLabel`, for example `Sunday, June 15, 2026`;
|
|
||||||
- `ForecastDayName`, for example `Sunday`;
|
|
||||||
- `GeneratedAt`;
|
|
||||||
- `GeneratedAtLabel`, for example
|
|
||||||
`Saturday, June 14, 2026 at 9:14 AM`;
|
|
||||||
- `ValidPeriod`;
|
|
||||||
- `Timezone`.
|
|
||||||
- Construct `Title` in Go, not in the template.
|
|
||||||
- Add `TomorrowTemplateModules` with pointer fields for the module outputs used
|
|
||||||
by the template:
|
|
||||||
- `Metadata`;
|
|
||||||
- `DerivedDailySummary`;
|
|
||||||
- `DerivedDaypartSummaries`;
|
|
||||||
- `PrecipTiming`;
|
|
||||||
- `AlertDigest`;
|
|
||||||
- `SPCConvectiveOutlooks`;
|
|
||||||
- `AreaForecastDiscussion`;
|
|
||||||
- `SPCConvectiveDiscussion`;
|
|
||||||
- `WeatherStory`;
|
|
||||||
- `TomorrowPlanning`.
|
|
||||||
- Add an ordered daypart slice for the template, derived from the configured
|
|
||||||
daypart order rather than ranging directly over a map. This can live in
|
|
||||||
`TomorrowTemplateModules`, for example `Dayparts []TomorrowDaypartContext`.
|
|
||||||
- Add `BuildTomorrowRenderContext`.
|
|
||||||
- Add `internal/reporttemplate/templates/tomorrow.md.tmpl`.
|
|
||||||
- Add Tomorrow to `internal/reporttemplate` template lookup.
|
|
||||||
- Template shape:
|
|
||||||
- title;
|
|
||||||
- forecast date;
|
|
||||||
- generated timestamp;
|
|
||||||
- `GeneratedText.Summary`;
|
|
||||||
- deterministic `Daypart Forecast` bullets in configured order;
|
|
||||||
- conditional `Precipitation Timing` only when precipitation windows exist;
|
|
||||||
- deterministic precipitation window bullets before optional
|
|
||||||
`GeneratedText.PrecipitationTiming`;
|
|
||||||
- `Forecast Discussion` with one paragraph per
|
|
||||||
`GeneratedText.ForecastDiscussion` item.
|
|
||||||
- Keep current conditions and hourly forecast available through the data
|
|
||||||
package and render context, but do not render them in the initial Tomorrow
|
|
||||||
template unless the template explicitly uses them.
|
|
||||||
|
|
||||||
Acceptance criteria:
|
|
||||||
|
|
||||||
- The template renders without map-order nondeterminism.
|
|
||||||
- Precipitation Timing is omitted when no precipitation windows exist.
|
|
||||||
- Forecast Discussion supports multiple paragraphs.
|
|
||||||
- Missing optional modules produce clean omission or fallback behavior, not
|
|
||||||
template execution errors.
|
|
||||||
|
|
||||||
Suggested validation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/generatedtext ./internal/reporttemplate ./internal/briefing
|
|
||||||
```
|
|
||||||
|
|
||||||
This stage is suitable for one implementation prompt.
|
|
||||||
|
|
||||||
## Stage 5: App Workflow Integration
|
|
||||||
|
|
||||||
Goal: route Tomorrow through the generated-text-template workflow end to end.
|
|
||||||
|
|
||||||
Implementation:
|
|
||||||
|
|
||||||
- Change Tomorrow report definition to:
|
|
||||||
- `PromptID: "weather.tomorrow_generated_text"`
|
|
||||||
- `GenerationMode: report.GenerationModeGeneratedTextTemplate`
|
|
||||||
- `TemplateID: "tomorrow"`
|
|
||||||
- `GeneratedTextSchemaID: "tomorrow"`
|
|
||||||
- Update `internal/app.buildRenderContext` dispatch:
|
|
||||||
- hourly template requires `generatedtext.Hourly`;
|
|
||||||
- tomorrow template requires `generatedtext.Tomorrow`;
|
|
||||||
- unsupported type/template combinations return actionable errors.
|
|
||||||
- Ensure Scriptorium `run` writes raw generated text to a `.json` path for
|
|
||||||
Tomorrow, matching the existing generated-text-template workflow.
|
|
||||||
- Ensure normalized generated text, render context JSON, generated Markdown,
|
|
||||||
metadata, and final report artifacts are persisted through existing state
|
|
||||||
helpers.
|
|
||||||
- Ensure app errors include report ID `tomorrow` and RunID context.
|
|
||||||
|
|
||||||
Acceptance criteria:
|
|
||||||
|
|
||||||
- `weatherreporter generate tomorrow` no longer invokes Scriptorium for full
|
|
||||||
Markdown.
|
|
||||||
- The workflow validates Scriptorium JSON output, builds a Tomorrow render
|
|
||||||
context, and renders Markdown locally.
|
|
||||||
- Hourly generated-text-template behavior remains unchanged.
|
|
||||||
|
|
||||||
Suggested validation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/app ./internal/state ./internal/adapters/scriptorium
|
|
||||||
```
|
|
||||||
|
|
||||||
This stage is suitable for one implementation prompt.
|
|
||||||
|
|
||||||
## Stage 6: CLI, State, Distributor, And Batch Behavior
|
|
||||||
|
|
||||||
Goal: update cross-package behavior affected by the report ID clean break.
|
|
||||||
|
|
||||||
Implementation:
|
|
||||||
|
|
||||||
- Update CLI tests and command-output expectations for `generate tomorrow`.
|
|
||||||
- Update state/path tests so managed artifacts, snapshots, data packages,
|
|
||||||
generated-text artifacts, render contexts, and report files use artifact group
|
|
||||||
`tomorrow`.
|
|
||||||
- Update batch tests:
|
|
||||||
- evening batch emits report ID `tomorrow`;
|
|
||||||
- batch output copy name remains `tomorrow.md`;
|
|
||||||
- partial-failure behavior is unchanged.
|
|
||||||
- Update Recent Changes tests:
|
|
||||||
- Tomorrow compares only against prior `tomorrow` snapshots;
|
|
||||||
- Daily Today no longer treats Tomorrow as a compatible prior unless the
|
|
||||||
implementation explicitly keeps that relationship for Daily Today only.
|
|
||||||
- Update distributor notification tests so rendered template variables use:
|
|
||||||
- `report_id=tomorrow`;
|
|
||||||
- `artifact_group=tomorrow`;
|
|
||||||
- `batch_output_name=tomorrow.md`.
|
|
||||||
- Update config tests so report module overrides use `reports.tomorrow`.
|
|
||||||
Do not accept `reports.daily_tomorrow` unless a future explicit
|
|
||||||
compatibility decision reverses the clean break.
|
|
||||||
|
|
||||||
Acceptance criteria:
|
|
||||||
|
|
||||||
- Public CLI syntax is stable.
|
|
||||||
- Persisted artifacts and distributor request context use the new report ID.
|
|
||||||
- Batch behavior is unchanged except for the new ID.
|
|
||||||
- No tests rely on `daily_tomorrow`.
|
|
||||||
|
|
||||||
Suggested validation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/cli ./internal/app ./internal/state ./internal/config ./internal/report
|
|
||||||
```
|
|
||||||
|
|
||||||
This stage is suitable for one implementation prompt.
|
|
||||||
|
|
||||||
## Stage 7: Documentation And Examples
|
|
||||||
|
|
||||||
Goal: align implemented docs and maintained examples after the code change.
|
|
||||||
|
|
||||||
Implementation:
|
|
||||||
|
|
||||||
- Update non-roadmap docs only after the behavior is implemented.
|
|
||||||
- Inspect and update:
|
|
||||||
- `docs/cli.md`;
|
|
||||||
- `docs/config.md`;
|
|
||||||
- `docs/operations.md`;
|
|
||||||
- `docs/troubleshooting.md`, if new failure modes are introduced;
|
|
||||||
- `docs/internal/report-registry.md`;
|
|
||||||
- `docs/internal/generatedtext.md`;
|
|
||||||
- `docs/internal/reporttemplate.md`;
|
|
||||||
- `docs/internal/state.md`;
|
|
||||||
- `docs/templates.md`;
|
|
||||||
- relevant Scriptorium and distributor integration docs only if their
|
|
||||||
weatherreporter-facing contract changed.
|
|
||||||
- Update `examples/config.yml` if it references Tomorrow modules or
|
|
||||||
`reports.daily_tomorrow`.
|
|
||||||
- Keep future Today/Daily split language only under `docs/roadmap/`.
|
|
||||||
- Do not document unimplemented Today or generic Daily products as current
|
|
||||||
behavior.
|
|
||||||
|
|
||||||
Acceptance criteria:
|
|
||||||
|
|
||||||
- Non-roadmap docs describe implemented behavior only.
|
|
||||||
- Docs use report ID `tomorrow`.
|
|
||||||
- Examples load under current config validation.
|
|
||||||
- No stale user-facing references to `daily_tomorrow` remain outside historical
|
|
||||||
roadmap context.
|
|
||||||
|
|
||||||
Suggested validation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rg -n "daily_tomorrow|Daily Tomorrow|weather.daily_report" README.md docs examples internal
|
|
||||||
git diff --check
|
|
||||||
```
|
|
||||||
|
|
||||||
This stage is suitable for one implementation prompt.
|
|
||||||
|
|
||||||
## Stage 8: Final Validation
|
|
||||||
|
|
||||||
Goal: verify the completed cutover as a coherent behavior change.
|
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
```bash
|
```sh
|
||||||
go test ./internal/report ./internal/generatedtext ./internal/reporttemplate ./internal/briefing ./internal/app ./internal/cli ./internal/state
|
go test ./internal/state ./internal/app
|
||||||
go test ./...
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exit Gate
|
||||||
|
|
||||||
|
The state package rejects incomplete or contradictory Promptkit-era artifacts,
|
||||||
|
reads historical v1 records, writes only valid v2 records, and has focused
|
||||||
|
offline coverage for its durable compatibility and filesystem contracts.
|
||||||
|
|
||||||
|
## Stage 14: Complete Execution-Artifact Path Tracking
|
||||||
|
|
||||||
|
Status: Completed.
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Make `PromptExecutionArtifact.Paths` accurately record every downstream
|
||||||
|
artifact reached after a completed Promptkit run.
|
||||||
|
|
||||||
|
### Work
|
||||||
|
|
||||||
|
1. Treat the execution artifact as an atomically updated durable record of the
|
||||||
|
completed Promptkit execution and subsequent artifact destinations. Its
|
||||||
|
status, provenance, validation, usage, and timing remain the provider-run
|
||||||
|
outcome; later application failures do not change a successful Promptkit
|
||||||
|
status into an execution failure.
|
||||||
|
2. Save the initial execution artifact after raw output is persisted, with
|
||||||
|
`RawOutputPath` populated.
|
||||||
|
3. After each later successful write, update and atomically resave the same
|
||||||
|
execution artifact with the corresponding reached path:
|
||||||
|
|
||||||
|
- normalized generated text;
|
||||||
|
- render context;
|
||||||
|
- managed Markdown report;
|
||||||
|
- an explicitly requested extra output copy, only after the copy succeeds;
|
||||||
|
and
|
||||||
|
- a Distributor notification artifact, including a persisted failure or
|
||||||
|
status artifact when notification produced one.
|
||||||
|
|
||||||
|
4. Keep metadata and execution-artifact path values consistent after every
|
||||||
|
successful checkpoint. Save the execution artifact before metadata so a
|
||||||
|
metadata failure does not erase knowledge of a reached downstream artifact.
|
||||||
|
Failure to update the execution artifact is terminal and returns a partial
|
||||||
|
result containing the downstream artifact that was already written.
|
||||||
|
5. Refactor finalization return values only as needed to tell the orchestration
|
||||||
|
layer which copy and notification paths were actually written. Distributor
|
||||||
|
must continue uploading only the managed Markdown report.
|
||||||
|
6. A validation-rejected execution ends after raw output and therefore records
|
||||||
|
only the raw-output path. An operational execution failure has no completed
|
||||||
|
provenance and records only safe paths reached before that failure.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Add table-driven execution-artifact lifecycle tests for success and every
|
||||||
|
downstream failure point.
|
||||||
|
- Load the persisted execution artifact after normalized-text, context,
|
||||||
|
template, copy, metadata, and notification failures and assert its status and
|
||||||
|
exact reached paths.
|
||||||
|
- Assert that execution artifacts never contain generated bodies, rendered
|
||||||
|
prompts, schemas, endpoints, parameters, or credentials.
|
||||||
|
- Run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./internal/state ./internal/app
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exit Gate
|
||||||
|
|
||||||
|
For every completed Promptkit run, its execution artifact contains exactly the
|
||||||
|
safe downstream paths reached by the workflow and remains semantically correct
|
||||||
|
when a later application stage fails.
|
||||||
|
|
||||||
|
## Stage 15: Restore Single-Report Behavioral Coverage
|
||||||
|
|
||||||
|
Status: Completed.
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Restore the risk-based application coverage removed during final cutover and
|
||||||
|
prove the complete single-report Promptkit workflow through project-owned
|
||||||
|
boundaries.
|
||||||
|
|
||||||
|
### Work
|
||||||
|
|
||||||
|
1. Reintroduce a focused app test harness using real state, prompt-input,
|
||||||
|
generated-text validation, render contexts, and templates with deterministic
|
||||||
|
collector, executor, notifier, clock, and filesystem boundaries.
|
||||||
|
2. Add representative successful workflows for Daily, Today, Tomorrow, and
|
||||||
|
Hourly. Verify report identity, exact prompt version, one collection, exact
|
||||||
|
persisted YAML bytes passed to the executor, expected template output,
|
||||||
|
optional copy behavior, and managed-report notification source.
|
||||||
|
3. Cover the required failure matrix:
|
||||||
|
|
||||||
|
- inspection and missing credentials before collection;
|
||||||
|
- preparation failure and callback persistence failure before provider work;
|
||||||
|
- execution-time credential disappearance;
|
||||||
|
- capacity rejection without retry;
|
||||||
|
- cancellation and deadline;
|
||||||
|
- generation and operational-validation failure;
|
||||||
|
- completed Promptkit schema rejection with retained raw output;
|
||||||
|
- generated-text decode/domain rejection;
|
||||||
|
- render-context and template failure;
|
||||||
|
- output-copy failure; and
|
||||||
|
- notification failure.
|
||||||
|
|
||||||
|
4. Verify preparation persistence precedes provider execution, debug-write
|
||||||
|
failure prevents provider execution, and execution-debug failure preserves
|
||||||
|
previously reached normal and debug artifacts.
|
||||||
|
5. Verify Recent Changes, prior-snapshot selection, output naming, and
|
||||||
|
Distributor template values for all four retained reports.
|
||||||
|
6. Adapt useful tests from the deleted app suite. Omit Scriptorium mechanics,
|
||||||
|
subprocess interaction assertions, and retired report products.
|
||||||
|
7. Fix defects exposed by these tests only when the expected behavior is
|
||||||
|
already decided by the roadmap or canonical policy. Record any new product
|
||||||
|
question instead of silently choosing it.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./internal/app
|
||||||
|
go test -race ./internal/app ./internal/adapters/promptkit
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exit Gate
|
||||||
|
|
||||||
|
The single-report workflow has deterministic behavioral coverage for all four
|
||||||
|
reports, all consequential failure stages, artifact ordering, partial results,
|
||||||
|
debug isolation, output copying, and notification behavior.
|
||||||
|
|
||||||
|
## Stage 16: Refactor Prompt Generation Orchestration
|
||||||
|
|
||||||
|
Status: Completed.
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Reduce the complexity and duplicated persistence logic in
|
||||||
|
`generatePromptReport` without changing observable behavior.
|
||||||
|
|
||||||
|
### Work
|
||||||
|
|
||||||
|
1. Use the Stage 12–15 tests as the refactoring safety boundary. Do not weaken
|
||||||
|
assertions to accommodate structural changes.
|
||||||
|
2. Split the current orchestration into small app-owned operations with clear
|
||||||
|
inputs and outcomes for:
|
||||||
|
|
||||||
|
- deterministic input and initial state construction;
|
||||||
|
- preparation callback persistence;
|
||||||
|
- preparation-failure persistence;
|
||||||
|
- operational-execution-failure persistence;
|
||||||
|
- completed execution and raw-output persistence;
|
||||||
|
- normalized text and render-context persistence;
|
||||||
|
- managed report, optional copy, metadata, and notification finalization;
|
||||||
|
and
|
||||||
|
- reached-path updates shared by success and failure paths.
|
||||||
|
|
||||||
|
3. Keep workflow order visible in one coordinator. Do not introduce a generic
|
||||||
|
workflow engine, hidden retry loop, provider-specific app type, or mutable
|
||||||
|
global state.
|
||||||
|
4. Centralize the repeated rule that a successful artifact write updates the
|
||||||
|
result before any following write can fail.
|
||||||
|
5. Preserve error identities, safe text, atomic writes, exact bytes, debug
|
||||||
|
ordering, partial results, and notification behavior.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gofmt -w internal/app/*.go
|
||||||
|
go test ./internal/app ./internal/state ./internal/cli
|
||||||
|
go test -race ./internal/app
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exit Gate
|
||||||
|
|
||||||
|
The top-level coordinator communicates the workflow order without containing
|
||||||
|
the full persistence implementation, duplicate failure branches are reduced,
|
||||||
|
and every Stage 12–15 behavioral test passes unchanged.
|
||||||
|
|
||||||
|
## Stage 17: Restore Batch Behavioral Coverage
|
||||||
|
|
||||||
|
Status: Completed.
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Re-establish confidence that morning and evening batches preserve their
|
||||||
|
pre-migration behavior while sharing one Promptkit executor.
|
||||||
|
|
||||||
|
### Work
|
||||||
|
|
||||||
|
1. Add assembled batch tests proving:
|
||||||
|
|
||||||
|
- one executor factory call and one executor per CLI invocation;
|
||||||
|
- inspection of the full candidate set before collection;
|
||||||
|
- one weather collection;
|
||||||
|
- existing morning/evening planning and ordering;
|
||||||
|
- sequential execution through the shared executor;
|
||||||
|
- continuation after an independent report failure;
|
||||||
|
- no retry after capacity rejection;
|
||||||
|
- distinct identities and debug directories for multiple Daily dates; and
|
||||||
|
- exact reached paths on successful and failed batch items.
|
||||||
|
|
||||||
|
2. Restore notification coverage for disabled notification, suppressed
|
||||||
|
per-report notification, all-success batch notification, skipped
|
||||||
|
notification after report failure, and persisted notification failure/status
|
||||||
|
artifacts.
|
||||||
|
3. Restore output-directory, Today/Tomorrow naming, dynamic Daily planning,
|
||||||
|
prior-snapshot, and managed-Markdown upload-source coverage.
|
||||||
|
4. Adapt useful tests from the deleted batch portions of the app and CLI suites.
|
||||||
|
Do not restore retired report cases or Scriptorium fakes.
|
||||||
|
5. Fix only roadmap-defined batch regressions exposed by the restored tests.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./internal/app ./internal/cli
|
||||||
|
go test -race ./internal/app
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exit Gate
|
||||||
|
|
||||||
|
Morning and evening batches are covered as assembled sequential workflows and
|
||||||
|
demonstrably preserve collection, planning, continuation, output, debug,
|
||||||
|
artifact, and notification contracts with one Promptkit executor.
|
||||||
|
|
||||||
|
## Stage 18: Restore CLI And Inspection Coverage
|
||||||
|
|
||||||
|
Status: Completed.
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Restore the user-facing command, summary, and historical inspection contracts
|
||||||
|
removed with the old root test suite.
|
||||||
|
|
||||||
|
### Work
|
||||||
|
|
||||||
|
1. Add parser and resolver tests for all four generate commands, both batch
|
||||||
|
commands, shared flags, report-specific date rules, malformed input,
|
||||||
|
`--llm-debug-dir`, `--quiet`, output paths, and rejection of retired report
|
||||||
|
names.
|
||||||
|
2. Add assembled CLI tests for representative successful single and batch
|
||||||
|
invocations using injected offline boundaries. Verify exactly one executor
|
||||||
|
construction per action.
|
||||||
|
3. Cover pre-run errors with no invented run summary, successful and failed
|
||||||
|
JSON summaries, quiet-mode behavior, safe human status output, partial paths,
|
||||||
|
and omission of absent notification/debug fields.
|
||||||
|
4. Restore inspection tests for report listing and v1/v2 metadata, modules,
|
||||||
|
data packages, prior snapshots, and sources. Include failed v2 runs and v1
|
||||||
|
fixtures using historical report IDs.
|
||||||
|
5. Assert that routine output never contains rendered prompts, schema bodies,
|
||||||
|
data packages, generated bodies, endpoints, full parameters, credentials, or
|
||||||
|
secret-like dependency errors.
|
||||||
|
6. Keep tests at stable CLI/app boundaries; do not restore assertions about
|
||||||
|
private parser formatting or Scriptorium subprocess mechanics.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./internal/cli ./internal/app ./internal/state
|
||||||
go run ./cmd/weatherreporter --help
|
go run ./cmd/weatherreporter --help
|
||||||
git diff --check
|
git diff --check
|
||||||
```
|
```
|
||||||
|
|
||||||
Also run targeted stale-symbol checks:
|
### Exit Gate
|
||||||
|
|
||||||
```bash
|
The supported CLI surface, summaries, quiet mode, partial failures, executor
|
||||||
rg -n "DailyTomorrow|dailyTomorrow|daily_tomorrow" internal docs examples
|
composition, and v1/v2 inspection behavior have deterministic offline coverage.
|
||||||
rg -n "weather.tomorrow_generated_text|TemplateID:.*tomorrow|GeneratedTextSchemaID:.*tomorrow" internal docs
|
|
||||||
|
## Stage 19: Finalize Documentation And Repository Verification
|
||||||
|
|
||||||
|
Status: Completed.
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Close the audit remediation, make roadmap lifecycle state truthful, and verify
|
||||||
|
the repository against the complete target contract.
|
||||||
|
|
||||||
|
### Work
|
||||||
|
|
||||||
|
1. Update `docs/roadmap/promptkit.md` from future tense and “unimplemented”
|
||||||
|
statuses to a completed roadmap record. Describe its old seven-report and
|
||||||
|
Scriptorium material explicitly as the pre-migration baseline rather than
|
||||||
|
current behavior.
|
||||||
|
2. Mark Stages 12–19 and this implementation plan complete only after their
|
||||||
|
exit gates pass. Retain the concise completed-stage history unless the
|
||||||
|
documentation policy calls for archival in the same change.
|
||||||
|
3. Review canonical architecture, app, state, CLI, Promptkit integration,
|
||||||
|
operations, troubleshooting, configuration, and testing documentation
|
||||||
|
against the corrected implementation. Update only actual current-state
|
||||||
|
discrepancies; do not duplicate the roadmap.
|
||||||
|
4. Search current-state code, tests, examples, help, and non-roadmap
|
||||||
|
documentation for stale Scriptorium terms, retired reports, old artifact
|
||||||
|
fields, speculative-path descriptions, or claims of missing Promptkit
|
||||||
|
implementation.
|
||||||
|
5. Confirm examples contain no credentials or private infrastructure values
|
||||||
|
and load through config tests.
|
||||||
|
|
||||||
|
### Final Verification
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gofmt -w <all changed Go files>
|
||||||
|
go mod tidy
|
||||||
|
go vet ./...
|
||||||
|
go test -count=1 ./...
|
||||||
|
go test -race ./...
|
||||||
|
go run ./cmd/weatherreporter --help
|
||||||
|
git diff --check
|
||||||
```
|
```
|
||||||
|
|
||||||
Acceptance criteria:
|
Then verify explicitly:
|
||||||
|
|
||||||
- Full test suite passes.
|
- `go list -m gitea.maximumdirect.net/eric/promptkit` reports `v0.4.0`;
|
||||||
- Help output still shows `generate tomorrow`.
|
- no committed `go.work`, `replace`, secret fixture, or live-provider test
|
||||||
- No production-code stale `daily_tomorrow` symbols remain.
|
exists;
|
||||||
- Generated-text-template artifacts for Tomorrow are persisted in the same
|
- all four prompts inspect at exact version `1.0.0`;
|
||||||
categories as Hourly.
|
- no runtime prompt requests repair attempts;
|
||||||
- Existing Hourly behavior still passes tests.
|
- v1 fixtures remain inspectable and new runs write only v2;
|
||||||
|
- normal artifacts and output contain no sensitive prompt/debug content;
|
||||||
|
- failed-run metadata, execution artifacts, app results, batch items, and CLI
|
||||||
|
summaries contain exactly the paths actually reached;
|
||||||
|
- help exposes only Daily, Today, Tomorrow, Hourly, morning, and evening; and
|
||||||
|
- managed Markdown remains the only Distributor upload source.
|
||||||
|
|
||||||
This stage is suitable for one implementation prompt.
|
### Exit Gate
|
||||||
|
|
||||||
|
Every migration and audit-remediation criterion is demonstrably satisfied,
|
||||||
|
the restored tests protect the consequential contracts, canonical
|
||||||
|
documentation describes the corrected implementation, and both roadmap
|
||||||
|
documents are marked complete.
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
None block implementation. The required decisions are locked by
|
None. The roadmap and this completed plan record the decisions used for the
|
||||||
`docs/roadmap/tomorrow.md` and this implementation plan:
|
audit remediation.
|
||||||
|
|
||||||
- Tomorrow uses report ID `tomorrow`.
|
|
||||||
- Tomorrow uses prompt ID `weather.tomorrow_generated_text`.
|
|
||||||
- Tomorrow uses template ID and generated-text schema ID `tomorrow`.
|
|
||||||
- Tomorrow forecast discussion is an array of paragraph strings.
|
|
||||||
- `daily_tomorrow` compatibility aliases are intentionally not preserved.
|
|
||||||
|
|
||||||
## Global Validation Checklist
|
|
||||||
|
|
||||||
- `go test ./...`
|
|
||||||
- `go run ./cmd/weatherreporter --help`
|
|
||||||
- `git diff --check`
|
|
||||||
- `rg -n "DailyTomorrow|dailyTomorrow|daily_tomorrow" internal docs examples`
|
|
||||||
- Confirm `weatherreporter generate tomorrow` uses structured JSON from
|
|
||||||
Scriptorium and renders final Markdown locally.
|
|
||||||
- Confirm distributor notification context uses `report_id=tomorrow` and
|
|
||||||
`artifact_group=tomorrow`.
|
|
||||||
- Confirm examples contain no unimplemented fields and no secrets.
|
|
||||||
|
|||||||
516
docs/roadmap/promptkit.md
Normal file
516
docs/roadmap/promptkit.md
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
# Promptkit Migration Roadmap
|
||||||
|
|
||||||
|
Status: Completed roadmap record.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This roadmap records the scope, decisions, and completed outcome of replacing
|
||||||
|
the external Scriptorium CLI integration with Promptkit. Canonical
|
||||||
|
documentation outside `docs/roadmap/` owns the implemented behavior.
|
||||||
|
|
||||||
|
## Pre-Migration Baseline
|
||||||
|
|
||||||
|
Status: Historical migration input.
|
||||||
|
|
||||||
|
Before the migration, Weatherreporter exposed seven report definitions, but
|
||||||
|
only four had complete prompt-backed report implementations:
|
||||||
|
|
||||||
|
- Daily Report: `weather.daily_generated_text`
|
||||||
|
- Today Report: `weather.today_generated_text`
|
||||||
|
- Tomorrow Report: `weather.tomorrow_generated_text`
|
||||||
|
- Hourly Report: `weather.hourly_generated_text`
|
||||||
|
|
||||||
|
The three-day, weekend, and storm commands and registry definitions had no
|
||||||
|
corresponding Scriptorium prompt or schema and never formed complete
|
||||||
|
operational report products. The `weather.daily_report` Scriptorium prompt was
|
||||||
|
legacy source material and was not selected by the registry.
|
||||||
|
|
||||||
|
The Scriptorium source corpus was retained temporarily under
|
||||||
|
`docs/roadmap/scriptorium/` as migration input. It contained the four
|
||||||
|
operational generated-text prompt definitions, their referenced content,
|
||||||
|
private response schemas, shared instructions, and the unused legacy Daily
|
||||||
|
Markdown prompt. The temporary corpus was removed after the runtime assets
|
||||||
|
were reconciled and embedded.
|
||||||
|
|
||||||
|
## Implemented End State
|
||||||
|
|
||||||
|
Status: Completed.
|
||||||
|
|
||||||
|
Weatherreporter pins
|
||||||
|
`gitea.maximumdirect.net/eric/promptkit` at `v0.4.0` and uses it as the
|
||||||
|
in-process engine for prompt inspection, prepared execution, provider calls,
|
||||||
|
and first-pass output validation.
|
||||||
|
|
||||||
|
The `scriptorium` executable, subprocess adapter, configuration, runtime
|
||||||
|
dependency, direct-Markdown execution path, and integration documentation have
|
||||||
|
been removed. The four operational reports continue to use structured
|
||||||
|
generated text followed by weatherreporter-owned validation and Markdown
|
||||||
|
templates.
|
||||||
|
|
||||||
|
The unfinished three-day, weekend, and storm reports are not implemented as
|
||||||
|
part of this migration. Their incomplete CLI, registry, documentation, and
|
||||||
|
generation declarations are removed from the implemented surface before the
|
||||||
|
migration is considered complete. Any future implementation of those products
|
||||||
|
requires separate roadmap scope, prompt and schema design, tests, and
|
||||||
|
documentation.
|
||||||
|
|
||||||
|
Weather selection, forecast derivation, valid periods, module construction,
|
||||||
|
Recent Changes, generated-text interpretation, Markdown templates, durable
|
||||||
|
state, inspection, output copies, and Distributor notification remain owned by
|
||||||
|
weatherreporter.
|
||||||
|
|
||||||
|
The four report prompts and private response schemas are versioned embedded
|
||||||
|
application assets. Operators configure Promptkit profiles without replacing
|
||||||
|
the report-owned corpus. One Promptkit engine is constructed per CLI
|
||||||
|
invocation and shared by every report in that invocation, including all
|
||||||
|
reports in a morning or evening batch.
|
||||||
|
|
||||||
|
Promptkit is isolated behind a weatherreporter-owned execution contract.
|
||||||
|
Promptkit request, result, validation, error, profile, backend, and provider
|
||||||
|
types do not leak into application orchestration, report definitions, domain
|
||||||
|
packages, CLI summaries, durable state contracts, or Distributor behavior.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
Status: Completed migration outcomes.
|
||||||
|
|
||||||
|
- Removed the Scriptorium runtime dependency and subprocess boundary.
|
||||||
|
- Migrated the four operational report prompts to Promptkit `v0.4.0`.
|
||||||
|
- Used prepared execution to persist preparation provenance before provider work
|
||||||
|
while executing the exact frozen snapshot.
|
||||||
|
- Validated report prompt and profile selections before weather collection when
|
||||||
|
the required information is available.
|
||||||
|
- Preserved deterministic module snapshots and structured Recent Changes.
|
||||||
|
- Preserved generated-text domain validation and repository-owned Markdown
|
||||||
|
rendering.
|
||||||
|
- Preserved context cancellation, actionable errors, secret redaction, and
|
||||||
|
inspectable failures.
|
||||||
|
- Improved durable prompt provenance with prompt, input, profile, model,
|
||||||
|
validation, usage, and timing metadata.
|
||||||
|
- Kept content-rich prompt and response diagnostics separate from routine
|
||||||
|
metadata and CLI output.
|
||||||
|
- Kept tests offline and deterministic through injected Promptkit model
|
||||||
|
clients and fixtures.
|
||||||
|
- Removed incomplete report declarations from the implemented product surface
|
||||||
|
rather than creating new report products during an integration migration.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
Status: Completed migration constraints.
|
||||||
|
|
||||||
|
The completed migration did not:
|
||||||
|
|
||||||
|
- create prompts, schemas, templates, or completed products for three-day,
|
||||||
|
weekend, or storm reports;
|
||||||
|
- preserve the unused `weather.daily_report` legacy Markdown prompt as an
|
||||||
|
active runtime asset;
|
||||||
|
- preserve a direct-Markdown LLM generation mode;
|
||||||
|
- move meteorological selection, derivation, thresholds, or comparison logic
|
||||||
|
into prompts or Promptkit;
|
||||||
|
- send raw unbounded Weather API responses to the model;
|
||||||
|
- replace weatherreporter's generated-text domain validation or Markdown
|
||||||
|
template rendering;
|
||||||
|
- add a general workflow engine, provider plugin system, or arbitrary backend
|
||||||
|
registry;
|
||||||
|
- add automatic provider, validation, repair, or capacity retries;
|
||||||
|
- add concurrent report generation to the sequential batch workflow;
|
||||||
|
- expose Promptkit types as weatherreporter contracts;
|
||||||
|
- keep a production-selectable Scriptorium/Promptkit dual-run mode;
|
||||||
|
- require Promptkit eager source validation, structured generation errors, or
|
||||||
|
semantic execution-target fingerprints; or
|
||||||
|
- use an unpublished Promptkit commit, committed Go workspace, or committed
|
||||||
|
local module replacement.
|
||||||
|
|
||||||
|
## Locked Decisions
|
||||||
|
|
||||||
|
Status: Implemented migration decisions.
|
||||||
|
|
||||||
|
### Dependency And Upgrade Boundary
|
||||||
|
|
||||||
|
- The migration pins the tagged Promptkit `v0.4.0` release.
|
||||||
|
- Coordinated local development may temporarily use the sibling Promptkit
|
||||||
|
checkout, but committed module metadata must reference the tagged release.
|
||||||
|
- The adapter relies on the public root Promptkit package only.
|
||||||
|
- A future Promptkit upgrade requires explicit review of prepared-execution
|
||||||
|
lifecycle, prompt and profile inspection, prompt/profile/schema formats,
|
||||||
|
error identities, validation behavior, capacity behavior, and the outbound
|
||||||
|
provider contract.
|
||||||
|
- Promptkit's deferred eager source validation, structured generation errors,
|
||||||
|
and semantic execution-target fingerprints do not block this migration.
|
||||||
|
|
||||||
|
### Operational Report Scope
|
||||||
|
|
||||||
|
- The migration preserves these prompt IDs:
|
||||||
|
`weather.daily_generated_text`, `weather.today_generated_text`,
|
||||||
|
`weather.tomorrow_generated_text`, and `weather.hourly_generated_text`.
|
||||||
|
- Each operational report definition selects the exact embedded prompt version
|
||||||
|
`1.0.0`; execution does not rely on ambiguous single-version lookup.
|
||||||
|
- Morning and evening batch membership remains based on Today, Tomorrow, and
|
||||||
|
eligible future Daily reports.
|
||||||
|
- Three-day, weekend, and storm are removed from current CLI help, parsing,
|
||||||
|
report registry membership, tests that claim implemented generation, and
|
||||||
|
non-roadmap documentation.
|
||||||
|
- The future product concepts may remain under `docs/roadmap/`, but migration
|
||||||
|
verification does not invent outputs or compare nonexistent prompts.
|
||||||
|
|
||||||
|
### Application Boundary
|
||||||
|
|
||||||
|
- Promptkit remains an adapter boundary even though it runs in process.
|
||||||
|
- A weatherreporter-owned contract represents prompt identity, preparation,
|
||||||
|
execution, output, validation, usage, provenance, and neutral error
|
||||||
|
categories.
|
||||||
|
- The Promptkit adapter maps public Promptkit values into that contract.
|
||||||
|
- App orchestration and test fakes depend on the project-owned contract, not
|
||||||
|
Promptkit.
|
||||||
|
- Scriptorium-specific request, result, error, and generation-mode types are
|
||||||
|
removed rather than renamed and retained.
|
||||||
|
|
||||||
|
### Prompt And Schema Ownership
|
||||||
|
|
||||||
|
- Weatherreporter embeds the four operational prompt definitions, referenced
|
||||||
|
prompt content, shared prompt content, and private response schemas.
|
||||||
|
- Assets remain separate files rather than inline Go strings.
|
||||||
|
- The temporary corpus under `docs/roadmap/scriptorium/` is migration source
|
||||||
|
material, not the final runtime location.
|
||||||
|
- Weatherreporter's existing generated-text domain types, schemas, and
|
||||||
|
templates remain the canonical application contract. Imported Scriptorium
|
||||||
|
assets are reconciled with that contract rather than copied blindly or kept
|
||||||
|
as duplicate runtime schemas.
|
||||||
|
- The imported Daily schema's incorrect Today `$id` and title are corrected.
|
||||||
|
- `confidence` is handled consistently across each prompt, provider-facing
|
||||||
|
schema, generated-text domain type, and template. The existing optional
|
||||||
|
weatherreporter field remains supported unless a separate domain decision
|
||||||
|
removes it.
|
||||||
|
- Prompt input metadata identifies the serialized data package as YAML rather
|
||||||
|
than JSON.
|
||||||
|
- Imported `pipeline-weather/...` schema paths are replaced with paths valid
|
||||||
|
inside the embedded Promptkit schema source.
|
||||||
|
- Imported `repair_attempts: 2` values are removed or set to zero. The
|
||||||
|
migration does not rely on Promptkit's internal-only repair capability.
|
||||||
|
- The unused `weather.daily_report` prompt is not promoted into runtime assets.
|
||||||
|
- One centralized embedded prompt/schema source is sufficient; Weatherreporter
|
||||||
|
does not need Notarius's multi-module asset-flattening registry.
|
||||||
|
|
||||||
|
### Profiles, Backends, And Credentials
|
||||||
|
|
||||||
|
- Execution profiles remain operator-configurable rather than embedded report
|
||||||
|
policy.
|
||||||
|
- Each embedded operational prompt declares Promptkit's built-in
|
||||||
|
`gemini-flash-latest` profile as its default.
|
||||||
|
- `gemini-flash-latest` is intentionally a moving model alias. The execution
|
||||||
|
record captures the effective model identity, but operators who require a
|
||||||
|
pinned model must select an explicit external profile.
|
||||||
|
- Configuration supports at most one external profile source:
|
||||||
|
`promptkit.profile_file` or `promptkit.profile_dir`. The two fields are
|
||||||
|
mutually exclusive.
|
||||||
|
- A nonblank `promptkit.profile` is the explicit request profile for every
|
||||||
|
report in the invocation and takes precedence over each prompt's
|
||||||
|
`default_profile`. A blank value uses the prompt default.
|
||||||
|
- Promptkit's normal profile-source precedence remains intact: an external
|
||||||
|
matching profile takes precedence over an embedded built-in profile, and an
|
||||||
|
invalid matching external profile is an error rather than a reason to fall
|
||||||
|
back.
|
||||||
|
- Weatherreporter exposes Promptkit's conventional `local` backend through the
|
||||||
|
narrow `promptkit.local.endpoint` and
|
||||||
|
`promptkit.local.concurrency_limit` configuration fields. It does not expose
|
||||||
|
arbitrary backend registration.
|
||||||
|
- A configured local endpoint registers the engine-scoped `local` backend. An
|
||||||
|
operator-supplied external profile selects it with `backend: local` and owns
|
||||||
|
the model-specific settings; Weatherreporter does not invent a local model
|
||||||
|
profile.
|
||||||
|
- Local concurrency defaults to one. A value of zero means unlimited, matching
|
||||||
|
Promptkit, and a negative value is invalid. Queue capacity and general
|
||||||
|
backend parameters are not exposed.
|
||||||
|
- Credential values remain in environment variables or file-backed
|
||||||
|
environment secrets. Configuration contains only credential source names.
|
||||||
|
- Provider credentials never appear in logs, errors, CLI output, durable
|
||||||
|
metadata, preparation records, execution records, or debug summaries.
|
||||||
|
- Promptkit `InspectProfile` reports structural target and credential
|
||||||
|
requirements; Weatherreporter owns policy for checking configured
|
||||||
|
environment availability.
|
||||||
|
- Promptkit revalidates environment credentials at `RunPrepared`; a successful
|
||||||
|
preparation does not promise that execution-time credentials remain
|
||||||
|
available.
|
||||||
|
|
||||||
|
### Configuration Contract
|
||||||
|
|
||||||
|
The replacement configuration surface is:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
promptkit:
|
||||||
|
profile: ""
|
||||||
|
profile_file: ""
|
||||||
|
profile_dir: ""
|
||||||
|
timeout: 2m
|
||||||
|
|
||||||
|
local:
|
||||||
|
endpoint: ""
|
||||||
|
concurrency_limit: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
- `timeout` remains the transport-wide provider-call safety cap.
|
||||||
|
- A blank local endpoint leaves the conventional local backend unregistered.
|
||||||
|
- Scriptorium's `binary`, `config_path`, and `extra_args` settings have no
|
||||||
|
Promptkit equivalents and are removed.
|
||||||
|
- Configuration validation rejects simultaneous `profile_file` and
|
||||||
|
`profile_dir` values, invalid local endpoints, negative concurrency, and
|
||||||
|
selected profiles that cannot resolve their backend.
|
||||||
|
|
||||||
|
### Engine Construction And Inspection
|
||||||
|
|
||||||
|
- One Promptkit engine is constructed per CLI invocation at the application
|
||||||
|
composition boundary.
|
||||||
|
- Single-report generation and every report in a batch use that same engine.
|
||||||
|
- Per-report orchestration does not construct a default engine.
|
||||||
|
- Promptkit backend capacity state and HTTP transport are shared consistently
|
||||||
|
for the invocation.
|
||||||
|
- Before collection, `InspectPrompt` checks every selected report's exact ID
|
||||||
|
and version, declared `data_package` input, default-profile metadata, prompt
|
||||||
|
hash availability, and declared output contract.
|
||||||
|
- `InspectPrompt` is a point-in-time structural check. It does not load a JSON
|
||||||
|
Schema, resolve a profile, or freeze later execution.
|
||||||
|
- Explicit profile overrides and relevant prompt defaults are checked with
|
||||||
|
`InspectProfile` before collection when application policy requires them.
|
||||||
|
- `InspectProfile` is also point-in-time and does not check credential values.
|
||||||
|
- Successful `PrepareExecution`, not inspection, is the per-run authority for
|
||||||
|
loaded schema, rendered content, frozen inputs, effective settings, and
|
||||||
|
durable execution provenance.
|
||||||
|
|
||||||
|
### Prompt Input
|
||||||
|
|
||||||
|
- Promptkit receives only the curated `data_package` produced by
|
||||||
|
`internal/promptinput`.
|
||||||
|
- Weatherreporter serializes the package once, atomically persists those exact
|
||||||
|
bytes, and supplies the same bytes with a Promptkit inline artifact.
|
||||||
|
- The managed data-package path may be supplied as non-secret provenance
|
||||||
|
through the inline artifact URI.
|
||||||
|
- Weatherreporter does not delegate unrestricted path loading to Promptkit's
|
||||||
|
default file artifact reader.
|
||||||
|
- Prompt inspection and adapter tests verify that `data_package` is required
|
||||||
|
and declared with the chosen YAML media type.
|
||||||
|
|
||||||
|
### Prepared Execution
|
||||||
|
|
||||||
|
- `Engine.PrepareExecution` replaces Scriptorium render preflight.
|
||||||
|
- Weatherreporter obtains `PreparedExecution.Details`, maps a safe subset into
|
||||||
|
its own preparation record, and persists that record before calling
|
||||||
|
`Engine.RunPrepared`.
|
||||||
|
- `RunPrepared` executes the frozen prompt, profile, schema, inputs, rendered
|
||||||
|
messages, target, and validation resources retained by the handle.
|
||||||
|
- Every acquired handle is followed immediately by `defer handle.Discard()`.
|
||||||
|
Discard is safe after execution and releases unused private execution state.
|
||||||
|
- Handles remain adapter-local, engine-bound, one-shot, in-process values.
|
||||||
|
They are never serialized, persisted, copied into app contracts, or treated
|
||||||
|
as restartable jobs.
|
||||||
|
- Preparation and execution use independent contexts. Execution receives the
|
||||||
|
active report workflow context.
|
||||||
|
- Capacity is not reserved during preparation. Capacity rejection can
|
||||||
|
therefore occur after a preparation record has been persisted.
|
||||||
|
- `RunPrepared` consumes the handle on success and every operational failure.
|
||||||
|
- Preparation details remain available from the adapter after execution or
|
||||||
|
discard, but rendered messages are not copied into routine durable state.
|
||||||
|
- Promptkit execution timing excludes preparation and consumer-held delay.
|
||||||
|
Weatherreporter records preparation timing and execution timing separately.
|
||||||
|
|
||||||
|
### Execution And Validation
|
||||||
|
|
||||||
|
- All four operational reports use Promptkit JSON Schema output validation.
|
||||||
|
- A completed Promptkit validation rejection returns a `RunResult`; the
|
||||||
|
adapter retains raw output and bounded validation details before failing the
|
||||||
|
report.
|
||||||
|
- An operational generation or validation error returns no partial
|
||||||
|
`RunResult`.
|
||||||
|
- Weatherreporter's `internal/generatedtext` validation remains the final
|
||||||
|
report-specific decode and domain boundary.
|
||||||
|
- Weatherreporter's `internal/reporttemplate` remains responsible for managed
|
||||||
|
Markdown rendering.
|
||||||
|
- Weatherreporter atomically persists Promptkit raw output and later artifacts
|
||||||
|
rather than asking Promptkit to choose managed filesystem paths.
|
||||||
|
- No Promptkit output-repair behavior is assumed or requested.
|
||||||
|
|
||||||
|
## Durable Artifacts And Observability
|
||||||
|
|
||||||
|
Status: Implemented design constraints.
|
||||||
|
|
||||||
|
Routine durable state retains useful non-secret provenance without persisting
|
||||||
|
full rendered prompts.
|
||||||
|
|
||||||
|
The preparation record contains:
|
||||||
|
|
||||||
|
- prompt ID and exact version;
|
||||||
|
- prompt definition hash;
|
||||||
|
- rendered prompt hash;
|
||||||
|
- input hashes;
|
||||||
|
- selected profile and backend identity;
|
||||||
|
- effective model identity;
|
||||||
|
- output contract summary;
|
||||||
|
- preparation start, end, and duration; and
|
||||||
|
- the path of the exact persisted data package.
|
||||||
|
|
||||||
|
The execution record and run metadata contain, when available:
|
||||||
|
|
||||||
|
- Promptkit run ID;
|
||||||
|
- prompt ID, version, and hashes;
|
||||||
|
- input hashes;
|
||||||
|
- selected profile, backend, and model identity;
|
||||||
|
- generated-content hash;
|
||||||
|
- token usage;
|
||||||
|
- execution start, end, and duration;
|
||||||
|
- validation status and bounded diagnostics; and
|
||||||
|
- paths of separately persisted raw output, normalized generated text, render
|
||||||
|
context, managed Markdown, and other artifacts reached by the workflow.
|
||||||
|
|
||||||
|
Provider endpoints, full effective model parameter maps, rendered messages,
|
||||||
|
schema bodies, data-package contents, and generated content do not belong in
|
||||||
|
routine metadata or CLI summaries.
|
||||||
|
|
||||||
|
Rendered messages and other content-rich preparation or response diagnostics
|
||||||
|
are available only when the operator supplies
|
||||||
|
`--llm-debug-dir <path>` to a single-report or batch command.
|
||||||
|
|
||||||
|
- There is no persistent YAML setting for debug capture.
|
||||||
|
- The debug root is validated or created before weather collection or provider
|
||||||
|
work. A requested destination that cannot be secured or written is an error.
|
||||||
|
- Artifacts are grouped beneath
|
||||||
|
`<path>/<report-id>/<valid-date>/<run-id>/`.
|
||||||
|
- Directories and files use owner-only permissions and atomic writes.
|
||||||
|
- Debug artifacts may contain rendered messages and content-rich preparation
|
||||||
|
or response diagnostics, but never credentials.
|
||||||
|
- The debug path appears in command output only when debug capture is enabled;
|
||||||
|
it is not added to routine durable metadata.
|
||||||
|
- Debug artifacts are not cache or comparison inputs. Their retention is owned
|
||||||
|
by the operator who selected the directory.
|
||||||
|
|
||||||
|
### Artifact Identities And Versions
|
||||||
|
|
||||||
|
Weatherreporter replaces Scriptorium-specific artifact identities rather than
|
||||||
|
reusing names whose meanings have changed:
|
||||||
|
|
||||||
|
- `PromptPreparationArtifact` uses schema version
|
||||||
|
`weatherreporter.prompt_preparation.v1`, is written as
|
||||||
|
`prompt_preparation.<runID>.json`, and is referenced by
|
||||||
|
`preparationPath`.
|
||||||
|
- `PromptExecutionArtifact` uses schema version
|
||||||
|
`weatherreporter.prompt_execution.v1`, is written as
|
||||||
|
`prompt_execution.<runID>.json`, and is referenced by `executionPath`.
|
||||||
|
- Run metadata advances to `weatherreporter.metadata.v2` and uses those new
|
||||||
|
path fields.
|
||||||
|
|
||||||
|
Preparation files remain beneath the existing configurable `preflight/`
|
||||||
|
directory, and execution files remain beneath the existing `snapshots/` tree.
|
||||||
|
The stable physical grouping limits deployment disruption without preserving
|
||||||
|
misleading Scriptorium-era filenames or field names. Raw generated output,
|
||||||
|
normalized generated text, render context, managed Markdown, and other
|
||||||
|
artifacts whose meanings have not changed retain their existing names and
|
||||||
|
locations.
|
||||||
|
|
||||||
|
Run inspection remains able to read `weatherreporter.metadata.v1` and its
|
||||||
|
legacy `preflightPath` and `generatedTextResultPath` references. New runs write
|
||||||
|
only the v2 metadata and new artifact names; Weatherreporter does not
|
||||||
|
dual-write deprecated aliases. CLI summary fields adopt `preparationPath` and
|
||||||
|
`executionPath` as an explicit, documented contract change.
|
||||||
|
|
||||||
|
## Failure Contract
|
||||||
|
|
||||||
|
Status: Implemented design constraints.
|
||||||
|
|
||||||
|
- A preparation failure produces a redacted weatherreporter-owned failure
|
||||||
|
receipt with report, RunID, prompt, stage, timing, and classified error
|
||||||
|
context. It does not fabricate Promptkit preparation details.
|
||||||
|
- An operational execution failure retains the successful preparation record
|
||||||
|
and adds a redacted execution failure receipt. No partial Promptkit result or
|
||||||
|
model output is invented.
|
||||||
|
- A Promptkit validation rejection retains the returned result, raw generated
|
||||||
|
output, validation details, and safe provenance before the report fails.
|
||||||
|
- A later generated-text decode, domain-validation, or template failure
|
||||||
|
retains every raw and validated artifact reached before that stage.
|
||||||
|
- Caller cancellation takes precedence when the active workflow context is
|
||||||
|
canceled.
|
||||||
|
- `promptkit.CapacityError` is recognized with `errors.As`; its backend ID is
|
||||||
|
copied into a weatherreporter-owned capacity error while
|
||||||
|
`ErrCapacityExceeded` remains the classification.
|
||||||
|
- Capacity rejection is an operational report failure, not invalid model
|
||||||
|
output, and does not trigger an automatic retry.
|
||||||
|
- Other Promptkit public error identities are translated into the narrow
|
||||||
|
weatherreporter error categories needed by CLI, metadata, and batch
|
||||||
|
behavior. Diagnostic prose is not parsed as a contract.
|
||||||
|
- Single-report commands return the classified failure with available
|
||||||
|
inspectable paths.
|
||||||
|
- Batch runs continue independent later reports under the existing batch
|
||||||
|
failure policy.
|
||||||
|
- Any future retry policy belongs to app orchestration, not the adapter.
|
||||||
|
|
||||||
|
## Compatibility Requirements
|
||||||
|
|
||||||
|
Status: Implemented design constraints.
|
||||||
|
|
||||||
|
- Daily, Today, Tomorrow, and Hourly report IDs, prompt IDs, valid periods,
|
||||||
|
artifact grouping, output names, and Distributor bundle behavior remain
|
||||||
|
stable.
|
||||||
|
- Morning and evening batch collection, planning, ordering, and continuation
|
||||||
|
behavior remains stable.
|
||||||
|
- Module snapshot and Recent Changes behavior remains deterministic.
|
||||||
|
- Promptkit receives only the existing curated prompt-input boundary.
|
||||||
|
- Managed Markdown remains the Distributor upload source.
|
||||||
|
- RunID lookup and inspection remain available for successful and failed runs.
|
||||||
|
- Existing managed paths remain stable where their meaning is unchanged.
|
||||||
|
Scriptorium-specific artifact names or schemas change when retaining them
|
||||||
|
would misrepresent the Promptkit contract.
|
||||||
|
- Existing v1 run metadata and referenced artifacts remain inspectable after
|
||||||
|
the migration. New runs use the v2 metadata and Promptkit-era artifact
|
||||||
|
identities without dual-writing deprecated aliases.
|
||||||
|
- Artifact or metadata schema changes are explicit, documented, and covered by
|
||||||
|
state and inspection tests.
|
||||||
|
- Prompt or generated content is not added to routine logs or CLI summaries.
|
||||||
|
- Tests do not require live providers or credentials.
|
||||||
|
- Removing incomplete three-day, weekend, and storm surfaces is documented as
|
||||||
|
correction of an unfinished product boundary, not as successful Promptkit
|
||||||
|
migration of those reports.
|
||||||
|
|
||||||
|
## Verification And Completion Criteria
|
||||||
|
|
||||||
|
Status: Completed and verified.
|
||||||
|
|
||||||
|
Completion was verified by the following outcomes:
|
||||||
|
|
||||||
|
- the four operational reports inspect, prepare, and execute through Promptkit
|
||||||
|
`v0.4.0` using embedded report-owned assets;
|
||||||
|
- every report uses exact prompt version `1.0.0`, requires the YAML
|
||||||
|
`data_package`, and declares the expected JSON Schema output contract;
|
||||||
|
- prepared execution persists a safe preparation record before provider work
|
||||||
|
and executes the same frozen snapshot;
|
||||||
|
- deterministic offline adapter and app tests cover success, preparation
|
||||||
|
failure, credential revalidation, capacity rejection, cancellation, timeout,
|
||||||
|
generation failure, Promptkit validation rejection, generated-text domain
|
||||||
|
failure, template failure, and handle discard;
|
||||||
|
- morning and evening batches construct one engine and preserve current
|
||||||
|
collection, planning, ordering, continuation, output, and notification
|
||||||
|
behavior;
|
||||||
|
- the temporary corpus has been reconciled into one runtime prompt/schema
|
||||||
|
source without duplicate provider-facing schemas;
|
||||||
|
- configuration examples load and contain no Scriptorium fields;
|
||||||
|
- CLI summaries and inspection commands expose the new project-owned artifact
|
||||||
|
contract without Promptkit types;
|
||||||
|
- Scriptorium code, configuration, tests, and runtime documentation have been
|
||||||
|
removed;
|
||||||
|
- incomplete three-day, weekend, and storm commands, registry entries, tests,
|
||||||
|
and current-behavior documentation have been removed or moved to roadmap
|
||||||
|
scope;
|
||||||
|
- non-roadmap documentation describes only the implemented Promptkit
|
||||||
|
integration;
|
||||||
|
- `go test ./...`, required focused or race-enabled checks, CLI help
|
||||||
|
validation, and `git diff --check` pass; and
|
||||||
|
- no committed `go.work`, local `replace`, live-provider test, or
|
||||||
|
secret-bearing fixture remains.
|
||||||
|
|
||||||
|
Fixture-based comparison with prior Scriptorium behavior is sufficient.
|
||||||
|
Production dual-run is not required because model calls are nondeterministic,
|
||||||
|
costly, and difficult to compare meaningfully.
|
||||||
|
|
||||||
|
## Decision Status
|
||||||
|
|
||||||
|
Status: Completed.
|
||||||
|
|
||||||
|
The roadmap has no remaining open product or architecture questions. Later
|
||||||
|
changes to this completed scope require new roadmap or decision-record scope
|
||||||
|
rather than implicit changes to this historical record.
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
# Tomorrow Report Roadmap
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
This roadmap defines the target state for making Tomorrow an independent
|
|
||||||
generated-text-template report. The feature is not implemented yet, so this
|
|
||||||
document lives under `docs/roadmap/`.
|
|
||||||
|
|
||||||
## Intent
|
|
||||||
|
|
||||||
Tomorrow should become its own report product, not a variant of the Daily
|
|
||||||
Report. The current CLI command `weatherreporter generate tomorrow` should
|
|
||||||
remain, but the internal report ID, prompt ID, template, schema, workspace
|
|
||||||
paths, and distributor identity should use `tomorrow`.
|
|
||||||
|
|
||||||
The report should combine deterministic daypart and precipitation facts with
|
|
||||||
LLM prose for the high-level summary, optional precipitation context, and
|
|
||||||
forecast discussion. The resulting Markdown should be predictable and
|
|
||||||
template-driven, similar to the implemented Hourly Report.
|
|
||||||
|
|
||||||
Longer term, Today, Tomorrow, and Daily may all become separate report products
|
|
||||||
with different prompts, templates, and deterministic sections. This roadmap
|
|
||||||
starts that split with Tomorrow.
|
|
||||||
|
|
||||||
## Target Report Shape
|
|
||||||
|
|
||||||
Example structure:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# Sunday's Weather
|
|
||||||
|
|
||||||
**Forecast Date:** Sunday, June 15, 2026
|
|
||||||
**Generated:** Saturday, June 14, 2026 at 9:14 AM
|
|
||||||
|
|
||||||
<GeneratedText summary>
|
|
||||||
|
|
||||||
## Daypart Forecast
|
|
||||||
|
|
||||||
- **Overnight:** <deterministic daypart line>
|
|
||||||
- **Morning:** <deterministic daypart line>
|
|
||||||
- **Midday:** <deterministic daypart line>
|
|
||||||
- **Afternoon:** <deterministic daypart line>
|
|
||||||
- **Evening:** <deterministic daypart line>
|
|
||||||
|
|
||||||
## Precipitation Timing
|
|
||||||
|
|
||||||
- **1:00 AM** to **5:00 AM**: Precipitation is expected during this period. The peak precipitation chance is 59% at 2:00 AM.
|
|
||||||
- <optional GeneratedText precipitation_timing>
|
|
||||||
|
|
||||||
## Forecast Discussion
|
|
||||||
|
|
||||||
<GeneratedText forecast_discussion paragraphs>
|
|
||||||
```
|
|
||||||
|
|
||||||
`Precipitation Timing` should render only when at least one precipitation
|
|
||||||
window exists for the valid period. The threshold for precipitation windows
|
|
||||||
remains the existing precipitation-window threshold, currently 40%.
|
|
||||||
|
|
||||||
## Locked Decisions
|
|
||||||
|
|
||||||
- Replace report ID `daily_tomorrow` with `tomorrow`.
|
|
||||||
- Do not preserve compatibility aliases for `daily_tomorrow`; this is a
|
|
||||||
pre-release clean break.
|
|
||||||
- Keep public CLI syntax: `weatherreporter generate tomorrow`.
|
|
||||||
- Use generated-text-template generation for Tomorrow, not full Markdown
|
|
||||||
generation by Scriptorium.
|
|
||||||
- Use a dedicated Scriptorium prompt ID, template ID, and schema ID:
|
|
||||||
- prompt ID: `weather.tomorrow_generated_text`
|
|
||||||
- template ID: `tomorrow`
|
|
||||||
- generated-text schema ID: `tomorrow`
|
|
||||||
- Use `ArtifactGroup: "tomorrow"` and `BatchOutputName: "tomorrow.md"`.
|
|
||||||
- Use `CompatiblePriorIDs: []report.ID{report.Tomorrow}`.
|
|
||||||
- Keep valid-period behavior: Tomorrow covers the next local civil day.
|
|
||||||
- Keep Morning/Evening batch behavior unless explicitly changed later; evening
|
|
||||||
batch should still include Tomorrow.
|
|
||||||
- Future Today/Daily split is out of scope for this roadmap.
|
|
||||||
|
|
||||||
## GeneratedText Contract
|
|
||||||
|
|
||||||
Add a Tomorrow GeneratedText schema:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"summary": "string",
|
|
||||||
"forecast_discussion": ["string"],
|
|
||||||
"precipitation_timing": "string",
|
|
||||||
"confidence": "string"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Required:
|
|
||||||
|
|
||||||
- `summary`
|
|
||||||
- `forecast_discussion`
|
|
||||||
|
|
||||||
Optional:
|
|
||||||
|
|
||||||
- `precipitation_timing`
|
|
||||||
- `confidence`
|
|
||||||
|
|
||||||
`forecast_discussion` should be an array of paragraph strings so Scriptorium
|
|
||||||
can return multi-paragraph discussion without embedding paragraph delimiters in
|
|
||||||
one string. Empty or whitespace-only discussion paragraphs should be rejected or
|
|
||||||
trimmed out during validation; after trimming, at least one paragraph is
|
|
||||||
required.
|
|
||||||
|
|
||||||
`confidence` may be validated and persisted but does not need to render in the
|
|
||||||
initial template.
|
|
||||||
|
|
||||||
## Template Context
|
|
||||||
|
|
||||||
Add a dedicated Tomorrow render context rather than reusing Hourly context
|
|
||||||
types.
|
|
||||||
|
|
||||||
Recommended top-level shape:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type TomorrowRenderContext struct {
|
|
||||||
Report TomorrowReportContext
|
|
||||||
GeneratedText Tomorrow
|
|
||||||
Modules TomorrowTemplateModules
|
|
||||||
Collected facts.CollectedFacts
|
|
||||||
Derived facts.DerivedFacts
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`TomorrowReportContext` should include:
|
|
||||||
|
|
||||||
- `Title`: for example `Sunday's Weather`
|
|
||||||
- `ForecastDate`: canonical local forecast date if useful
|
|
||||||
- `ForecastDateLabel`: for example `Sunday, June 15, 2026`
|
|
||||||
- `ForecastDayName`: for example `Sunday`
|
|
||||||
- `GeneratedAt`
|
|
||||||
- `GeneratedAtLabel`: for example `Saturday, June 14, 2026 at 9:14 AM`
|
|
||||||
- `ValidPeriod`
|
|
||||||
- `Timezone`
|
|
||||||
|
|
||||||
Do not derive the possessive title in the template. Go should provide `Title`
|
|
||||||
so wording is consistent and easy to test.
|
|
||||||
|
|
||||||
`TomorrowTemplateModules` should expose the module outputs needed by the
|
|
||||||
template:
|
|
||||||
|
|
||||||
- `Metadata`
|
|
||||||
- `DerivedDailySummary`
|
|
||||||
- `DerivedDaypartSummaries`
|
|
||||||
- `PrecipTiming`
|
|
||||||
- `AlertDigest`
|
|
||||||
- `SPCConvectiveOutlooks`
|
|
||||||
- `AreaForecastDiscussion`
|
|
||||||
- `SPCConvectiveDiscussion`
|
|
||||||
- `WeatherStory`
|
|
||||||
- `TomorrowPlanning`, if still useful
|
|
||||||
|
|
||||||
Current conditions and hourly forecast can remain in the module snapshot and
|
|
||||||
data package if useful for Scriptorium, but they do not need to render in the
|
|
||||||
initial Tomorrow template unless a later design calls for them.
|
|
||||||
|
|
||||||
## Daypart Forecast
|
|
||||||
|
|
||||||
The Daypart Forecast should be deterministic but composable. Avoid adding a
|
|
||||||
single prewritten Go `DaypartLine` string that makes template wording rigid.
|
|
||||||
|
|
||||||
Add presentation-friendly fields to `derived_daypart_summaries` only where they
|
|
||||||
avoid awkward template logic. Likely useful fields:
|
|
||||||
|
|
||||||
- display name, such as `Overnight` or `Morning`;
|
|
||||||
- lower-case dominant condition text for inline sentences;
|
|
||||||
- rounded temperature range phrase if available;
|
|
||||||
- precipitation mention flag using the existing hourly line mention threshold
|
|
||||||
concept, currently 20%;
|
|
||||||
- max precipitation probability and friendly max time;
|
|
||||||
- optional wind phrase or wind range only if deterministic wind wording is
|
|
||||||
clearly needed.
|
|
||||||
|
|
||||||
The initial implementation may keep daypart bullet wording simple. It should be
|
|
||||||
easy to revise the template text without editing Go unless new facts are
|
|
||||||
needed.
|
|
||||||
|
|
||||||
## Implementation Plan
|
|
||||||
|
|
||||||
1. Report identity split
|
|
||||||
- Rename `report.DailyTomorrow` to `report.Tomorrow` with ID `tomorrow`.
|
|
||||||
- Update registry order, resolver references, CLI mapping, batch selection,
|
|
||||||
state metadata expectations, docs, and tests.
|
|
||||||
- Preserve `weatherreporter generate tomorrow`.
|
|
||||||
- Accept that workspace paths, RunIDs, distributor bundle IDs, and report
|
|
||||||
URLs change from `daily_tomorrow` to `tomorrow`.
|
|
||||||
|
|
||||||
2. GeneratedText contract
|
|
||||||
- Add `generatedtext.Tomorrow`, validation, normalized JSON output, and
|
|
||||||
tests.
|
|
||||||
- Add `tomorrow.generated_text.schema.json`.
|
|
||||||
- Add `internal/reporttemplate/prompts/tomorrow.generated_text.md` as the
|
|
||||||
maintained prompt source asset.
|
|
||||||
- Update app validation dispatch to use the Tomorrow schema.
|
|
||||||
|
|
||||||
3. Template and render context
|
|
||||||
- Add `internal/reporttemplate/templates/tomorrow.md.tmpl`.
|
|
||||||
- Add Tomorrow template/schema lookup entries.
|
|
||||||
- Add `BuildTomorrowRenderContext`.
|
|
||||||
- Update app render-context dispatch for template ID `tomorrow`.
|
|
||||||
- Persist render context in the existing generated-text-template workflow.
|
|
||||||
|
|
||||||
4. Module presentation fields
|
|
||||||
- Add only the daypart presentation fields needed by the template.
|
|
||||||
- Reuse the existing precipitation window hour-label fields.
|
|
||||||
- Keep deterministic weather derivation in Go and wording/layout in the
|
|
||||||
template.
|
|
||||||
|
|
||||||
5. Report definition conversion
|
|
||||||
- Change Tomorrow report definition to:
|
|
||||||
- `PromptID: "weather.tomorrow_generated_text"`
|
|
||||||
- `GenerationMode: generated_text_template`
|
|
||||||
- `TemplateID: "tomorrow"`
|
|
||||||
- `GeneratedTextSchemaID: "tomorrow"`
|
|
||||||
- `ArtifactGroup: "tomorrow"`
|
|
||||||
- `BatchOutputName: "tomorrow.md"`
|
|
||||||
- compatible prior IDs containing only `tomorrow`
|
|
||||||
- Review module composition and keep only modules used by the prompt,
|
|
||||||
template, or future inspection value.
|
|
||||||
|
|
||||||
6. Documentation and examples
|
|
||||||
- After implementation, update non-roadmap docs for implemented behavior:
|
|
||||||
`docs/cli.md`, `docs/operations.md`, `docs/internal/report-registry.md`,
|
|
||||||
`docs/internal/generatedtext.md`, `docs/internal/reporttemplate.md`, and
|
|
||||||
`docs/templates.md`.
|
|
||||||
- Update examples that refer to `reports.tomorrow` or report module
|
|
||||||
overrides if the config key changes.
|
|
||||||
|
|
||||||
## Test Plan
|
|
||||||
|
|
||||||
- Report tests:
|
|
||||||
- registry contains `tomorrow`, not `daily_tomorrow`;
|
|
||||||
- `generate tomorrow` resolves report ID `tomorrow`;
|
|
||||||
- valid period remains next local civil day;
|
|
||||||
- evening batch still includes Tomorrow;
|
|
||||||
- RunID and artifact paths use `tomorrow`.
|
|
||||||
- GeneratedText tests:
|
|
||||||
- `summary` and non-empty `forecast_discussion` are required;
|
|
||||||
- `forecast_discussion` trims paragraph strings and rejects/omits blanks;
|
|
||||||
- optional `precipitation_timing` and `confidence` normalize correctly;
|
|
||||||
- unknown fields are rejected.
|
|
||||||
- Template tests:
|
|
||||||
- title renders as `<weekday>'s Weather`;
|
|
||||||
- forecast date and generated labels render;
|
|
||||||
- daypart bullets render in configured daypart order;
|
|
||||||
- precipitation section is omitted when no precipitation windows exist;
|
|
||||||
- precipitation section includes deterministic windows and optional LLM text
|
|
||||||
when windows exist;
|
|
||||||
- forecast discussion renders multiple paragraphs.
|
|
||||||
- App/CLI workflow tests:
|
|
||||||
- `weatherreporter generate tomorrow` uses structured Scriptorium output and
|
|
||||||
the template renderer;
|
|
||||||
- raw generated text, validated generated text, render context, report, and
|
|
||||||
metadata artifacts are persisted;
|
|
||||||
- optional `--out` behavior remains unchanged;
|
|
||||||
- distributor notification uses report ID/artifact group `tomorrow`.
|
|
||||||
|
|
||||||
Validation commands:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/report ./internal/generatedtext ./internal/reporttemplate ./internal/briefing ./internal/app ./internal/cli ./internal/state
|
|
||||||
go test ./...
|
|
||||||
go run ./cmd/weatherreporter --help
|
|
||||||
git diff --check
|
|
||||||
```
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
None block implementation. Recommended defaults are:
|
|
||||||
|
|
||||||
- make `forecast_discussion` an array of strings for Tomorrow;
|
|
||||||
- keep Hourly GeneratedText unchanged for now;
|
|
||||||
- do not add Daily Today or generic Daily report splits in this change;
|
|
||||||
- do not preserve `daily_tomorrow` compatibility aliases.
|
|
||||||
@@ -1,301 +1,179 @@
|
|||||||
# Report Templates
|
# Report Templates
|
||||||
|
|
||||||
## Purpose
|
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).
|
||||||
|
|
||||||
This guide describes the implemented Markdown report template surface for
|
## Template Assets
|
||||||
`weatherreporter`. It is for maintainers editing embedded report templates,
|
|
||||||
especially generated-text-template reports.
|
|
||||||
|
|
||||||
Templates are Go `text/template` files. The current implemented templates are:
|
Only the generated-text reports use repository-native Markdown templates.
|
||||||
|
Each report has one matching template ID, generated-text schema ID, and prompt
|
||||||
|
source:
|
||||||
|
|
||||||
- `internal/reporttemplate/templates/tomorrow.md.tmpl`
|
| Report | Template | Schema | Prompt ID and source |
|
||||||
- `internal/reporttemplate/templates/hourly.md.tmpl`
|
| --- | --- | --- | --- |
|
||||||
|
| 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/` |
|
||||||
|
|
||||||
Templates are rendered from structured contexts such as `TomorrowRenderContext`
|
The matching schemas and Promptkit definitions are embedded by
|
||||||
and `HourlyRenderContext`. Weather data collection, derivation, module
|
`internal/promptassets`. The generated-text catalog pairs each schema ID with
|
||||||
execution, generated text validation, and artifact paths are handled before
|
its template ID; keep the matching prompt definition aligned with that pair.
|
||||||
template rendering.
|
|
||||||
|
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
|
## Editing Rules
|
||||||
|
|
||||||
- Use Go `text/template` syntax.
|
- Use Go `text/template` syntax and keep changes to Markdown structure,
|
||||||
- Keep templates focused on Markdown layout, headings, ordering, and simple
|
ordering, and display conditions.
|
||||||
conditional display.
|
- Templates use `missingkey=error`; reference only documented fields and guard
|
||||||
- Do not put weather derivation, source selection, or path construction logic in
|
optional module pointers with `with` or `if`.
|
||||||
templates.
|
- Prefer `.Modules` for deterministic display values. Do not add weather
|
||||||
- Missing template keys are errors. A misspelled variable will fail rendering.
|
calculations, source selection, or prompt-input shaping to a template.
|
||||||
- No custom template functions are currently registered.
|
- Keep generated prose in `.GeneratedText`; do not restate deterministic facts
|
||||||
- Optional module stanzas are pointers and should be guarded with
|
in generated prose merely to compensate for a template change.
|
||||||
`{{ with .Modules.WeatherStory }}...{{ end }}`.
|
- When changing the generated-prose contract, update the matching prompt,
|
||||||
- Slices can be rendered with `{{ range .Items }}...{{ else }}...{{ end }}`.
|
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.
|
||||||
|
|
||||||
## Hourly Context
|
Minimal optional-value pattern:
|
||||||
|
|
||||||
The hourly template receives five top-level values:
|
|
||||||
|
|
||||||
| Variable | Type | Description |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `.Report` | HourlyReportContext | Display metadata and friendly labels for the rendered report. |
|
|
||||||
| `.GeneratedText` | Hourly | Structured text returned by Scriptorium. |
|
|
||||||
| `.Modules` | HourlyTemplateModules | Preferred deterministic template surface, keyed by module purpose. |
|
|
||||||
| `.Collected` | facts.CollectedFacts | Normalized upstream facts for advanced template use. |
|
|
||||||
| `.Derived` | facts.DerivedFacts | Shared derived facts for advanced template use. |
|
|
||||||
|
|
||||||
Prefer `.Modules` for normal template edits. `.Collected` and `.Derived` are
|
|
||||||
available when a template needs lower-level facts, but templates should still
|
|
||||||
avoid nontrivial derivation.
|
|
||||||
|
|
||||||
## Report
|
|
||||||
|
|
||||||
| Variable | Type | Description |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `.Report.Title` | string | Display title. Currently `Hourly Report`. |
|
|
||||||
| `.Report.LocationName` | string | Prompt/report location label, such as `Brentwood, MO`. |
|
|
||||||
| `.Report.GeneratedAt` | time.Time | Canonical generation timestamp. |
|
|
||||||
| `.Report.GeneratedAtLabel` | string | Friendly local generation time label. |
|
|
||||||
| `.Report.ValidPeriod` | timeutil.Period | Canonical valid period. |
|
|
||||||
| `.Report.ValidPeriodLabel` | string | Friendly local valid period label, such as `2026-05-29 at 8:30 AM to 2026-05-29 at 2:30 PM`. |
|
|
||||||
| `.Report.Timezone` | string | Effective report timezone. |
|
|
||||||
|
|
||||||
## GeneratedText
|
|
||||||
|
|
||||||
These fields are written by Scriptorium as structured JSON, validated by
|
|
||||||
weatherreporter, and then inserted into the render context.
|
|
||||||
|
|
||||||
| Variable | Type | Description |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `.GeneratedText.Summary` | string | Required short prose summary. |
|
|
||||||
| `.GeneratedText.ForecastDiscussion` | string | Required prose for the Forecast Discussion section. |
|
|
||||||
| `.GeneratedText.PrecipitationTiming` | string | Optional prose rendered after deterministic precipitation windows. |
|
|
||||||
| `.GeneratedText.Confidence` | string | Optional confidence or uncertainty note. Empty when omitted by the LLM; not rendered by the current hourly template. |
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```gotemplate
|
|
||||||
{{ .GeneratedText.Summary }}
|
|
||||||
|
|
||||||
## Forecast Discussion
|
|
||||||
|
|
||||||
{{ .GeneratedText.ForecastDiscussion }}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tomorrow Context
|
|
||||||
|
|
||||||
The Tomorrow template receives five top-level values:
|
|
||||||
|
|
||||||
| Variable | Type | Description |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `.Report` | TomorrowReportContext | Display metadata and friendly labels for the rendered report. |
|
|
||||||
| `.GeneratedText` | Tomorrow | Structured text returned by Scriptorium. |
|
|
||||||
| `.Modules` | TomorrowTemplateModules | Preferred deterministic template surface, keyed by module purpose. |
|
|
||||||
| `.Collected` | facts.CollectedFacts | Normalized upstream facts for advanced template use. |
|
|
||||||
| `.Derived` | facts.DerivedFacts | Shared derived facts for advanced template use. |
|
|
||||||
|
|
||||||
Tomorrow report metadata includes `.Report.Title`, `.Report.ForecastDate`,
|
|
||||||
`.Report.ForecastDateLabel`, `.Report.ForecastDayName`,
|
|
||||||
`.Report.GeneratedAt`, `.Report.GeneratedAtLabel`, `.Report.ValidPeriod`, and
|
|
||||||
`.Report.Timezone`.
|
|
||||||
|
|
||||||
Tomorrow generated text uses the same `.GeneratedText.Summary`,
|
|
||||||
`.GeneratedText.PrecipitationTiming`, and `.GeneratedText.Confidence` fields as
|
|
||||||
Hourly. `.GeneratedText.ForecastDiscussion` is a slice of paragraphs and should
|
|
||||||
be rendered with `range`.
|
|
||||||
|
|
||||||
Tomorrow modules include the Hourly module fields plus:
|
|
||||||
|
|
||||||
| Variable | Type | Description |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `.Modules.DerivedDailySummary` | *briefing.DerivedDailySummaryModule | Daily summary facts for the forecast date. |
|
|
||||||
| `.Modules.DerivedDaypartSummaries` | *map[string]briefing.DerivedDaypartSummaryModule | Raw daypart summary map, when direct keyed access is needed. |
|
|
||||||
| `.Modules.Dayparts` | []generatedtext.TomorrowDaypartContext | Ordered daypart summaries for deterministic template rendering. |
|
|
||||||
| `.Modules.TomorrowPlanning` | *briefing.TomorrowPlanningModule | Planning facts for the next local civil day. |
|
|
||||||
|
|
||||||
Prefer `.Modules.Dayparts` over ranging through
|
|
||||||
`.Modules.DerivedDaypartSummaries`; it follows configured daypart order and
|
|
||||||
falls back to sorted keys for any unmatched entries.
|
|
||||||
|
|
||||||
## Modules
|
|
||||||
|
|
||||||
`.Modules` exposes typed outputs from the same module pipeline used for the
|
|
||||||
prompt data package. Module fields are pointers because missing-data policy may
|
|
||||||
omit a stanza.
|
|
||||||
|
|
||||||
| Variable | Type | Description |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `.Modules.Metadata` | *briefing.MetadataModule | Report metadata module output, when present. |
|
|
||||||
| `.Modules.CurrentConditions` | *briefing.CurrentConditionsModule | Current conditions from `/conditions/current`. |
|
|
||||||
| `.Modules.HourlyForecast` | *briefing.HourlyForecastModule | Hourly forecast periods overlapping the report valid period. |
|
|
||||||
| `.Modules.PrecipTiming` | *briefing.PrecipTimingModule | Derived precipitation timing facts and threshold windows. |
|
|
||||||
| `.Modules.AlertDigest` | *briefing.AlertDigestModule | Active alert status and relevant alert overlaps. |
|
|
||||||
| `.Modules.SPCConvectiveOutlooks` | *briefing.SPCConvectiveOutlooksModule | SPC outlooks that overlap the report valid period. |
|
|
||||||
| `.Modules.AreaForecastDiscussion` | *briefing.AreaForecastDiscussionModule | AFD key messages and configured discussion sections. |
|
|
||||||
| `.Modules.SPCConvectiveDiscussion` | *briefing.SPCConvectiveDiscussionModule | SPC discussions retained for qualifying overlapping categorical risk days. |
|
|
||||||
| `.Modules.WeatherStory` | *briefing.WeatherStoryModule | Latest NWS weather story, when available. |
|
|
||||||
|
|
||||||
### Current Conditions
|
|
||||||
|
|
||||||
Common fields:
|
|
||||||
|
|
||||||
| Variable | Type | Description |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `.Modules.CurrentConditions.ConditionText` | string | Current condition text. |
|
|
||||||
| `.Modules.CurrentConditions.ConditionTextLower` | string | Lower-case current condition text for inline sentences. |
|
|
||||||
| `.Modules.CurrentConditions.TemperatureF` | *int | Rounded current temperature. |
|
|
||||||
| `.Modules.CurrentConditions.ApparentTemperatureF` | *int | Rounded apparent temperature. |
|
|
||||||
| `.Modules.CurrentConditions.RelativeHumidityPercent` | *int | Rounded relative humidity. |
|
|
||||||
| `.Modules.CurrentConditions.WindDirection` | string | 16-point compass wind direction. |
|
|
||||||
| `.Modules.CurrentConditions.WindDirectionText` | string | Lower-case full wind direction text, such as `northwest`. |
|
|
||||||
| `.Modules.CurrentConditions.WindSpeedMph` | *int | Rounded wind speed. |
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```gotemplate
|
```gotemplate
|
||||||
{{ with .Modules.CurrentConditions }}
|
{{ with .Modules.CurrentConditions }}
|
||||||
{{ .ConditionText }}{{ with .TemperatureF }}; {{ . }} F{{ end }}{{ with .WindDirection }}; wind {{ . }}{{ end }}{{ with .WindSpeedMph }} {{ . }} mph{{ end }}
|
Currently, it is {{ with .TemperatureF }}{{ . }}°F{{ end }}.
|
||||||
{{ else }}
|
{{ else }}
|
||||||
No current conditions available.
|
Current conditions are unavailable.
|
||||||
{{ end }}
|
{{ end }}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Hourly Forecast
|
Minimal list pattern:
|
||||||
|
|
||||||
Common period fields:
|
|
||||||
|
|
||||||
| Variable | Type | Description |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `.Modules.HourlyForecast.Periods` | []briefing.HourlyForecastPeriod | Ordered periods for the hourly report valid period. |
|
|
||||||
| `.Modules.HourlyForecast.Periods[].HourLabel` | string | Friendly hour label such as `4:00 PM`. |
|
|
||||||
| `.Modules.HourlyForecast.Periods[].PeriodBegins` | string | Friendly local period start label. |
|
|
||||||
| `.Modules.HourlyForecast.Periods[].PeriodEnds` | string | Friendly local period end label. |
|
|
||||||
| `.Modules.HourlyForecast.Periods[].Name` | string | Source period name. |
|
|
||||||
| `.Modules.HourlyForecast.Periods[].TextDescription` | string | Hourly forecast text. |
|
|
||||||
| `.Modules.HourlyForecast.Periods[].TextDescriptionLower` | string | Lower-case hourly forecast text for inline sentences. |
|
|
||||||
| `.Modules.HourlyForecast.Periods[].TemperatureF` | *float64 | Forecast temperature. |
|
|
||||||
| `.Modules.HourlyForecast.Periods[].ProbabilityOfPrecipitationPercent` | *float64 | Forecast precipitation probability. |
|
|
||||||
| `.Modules.HourlyForecast.Periods[].MentionPrecipitation` | bool | True when precipitation probability meets the hourly mention threshold. |
|
|
||||||
| `.Modules.HourlyForecast.Periods[].WindDirection` | string | 16-point compass wind direction. |
|
|
||||||
| `.Modules.HourlyForecast.Periods[].WindSpeedMph` | *float64 | Wind speed. |
|
|
||||||
| `.Modules.HourlyForecast.Periods[].WindGustMph` | *float64 | Wind gust. |
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```gotemplate
|
```gotemplate
|
||||||
{{ with .Modules.HourlyForecast }}{{ range .Periods }}
|
{{ range .GeneratedText.ForecastDiscussion }}
|
||||||
- **{{ .HourLabel }}:**{{ with .TemperatureF }} {{ . }}°F{{ end }} and {{ .TextDescriptionLower }}.{{ if .MentionPrecipitation }}{{ with .ProbabilityOfPrecipitationPercent }} Probability of precipitation is {{ . }}%.{{ end }}{{ end }}
|
{{ . }}
|
||||||
{{ else }}
|
{{ end }}
|
||||||
- No hourly forecast rows available.
|
|
||||||
{{ end }}{{ end }}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Precipitation Timing
|
## Registered Functions
|
||||||
|
|
||||||
Common fields:
|
Templates have these helpers in addition to Go template built-ins:
|
||||||
|
|
||||||
| Variable | Type | Description |
|
| Function | Accepts | Returns true when |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `.Modules.PrecipTiming.MaxPopPercent` | *int | Highest hourly precipitation probability in the valid period. |
|
| `hasRelevantAlerts` | an alert-digest value or pointer | its `Relevant` slice is nonempty |
|
||||||
| `.Modules.PrecipTiming.MaxPopTime` | string | Friendly local time for the highest hourly precipitation probability. |
|
| `hasEnhancedOrHigherSPCRisk` | an SPC outlook value or pointer | its `RiskDigest` contains an Enhanced, Moderate, or High Risk entry |
|
||||||
| `.Modules.PrecipTiming.ProbabilityThreshold` | float64 | Threshold used to define precipitation windows. |
|
| `isEnhancedOrHigherSPCRisk` | one SPC risk-digest entry | its `LabelText`, or fallback `RiskLabel`, is Enhanced, Moderate, or High Risk |
|
||||||
| `.Modules.PrecipTiming.PrecipitationWindows` | []briefing.PrecipitationWindowModule | One or more threshold precipitation windows. |
|
|
||||||
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodBegins` | string | Friendly local window start. |
|
|
||||||
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodBeginsHourLabel` | string | Friendly window start hour, such as `4:00 PM`. |
|
|
||||||
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodEnds` | string | Friendly local window end; omitted for open windows. |
|
|
||||||
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodEndsHourLabel` | string | Friendly window end hour; omitted for open windows. |
|
|
||||||
| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopPercent` | *int | Highest precipitation probability inside the window. |
|
|
||||||
| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopTime` | string | Friendly local time for the window maximum. |
|
|
||||||
| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopHourLabel` | string | Friendly hour label for the window maximum. |
|
|
||||||
| `.Modules.PrecipTiming.ThunderMentioned` | bool | Whether thunder is mentioned in the forecast text. |
|
|
||||||
|
|
||||||
### Alert Digest
|
For example, the alert partial uses the first two functions to decide whether
|
||||||
|
to render the section:
|
||||||
|
|
||||||
| Variable | Type | Description |
|
```gotemplate
|
||||||
| --- | --- | --- |
|
{{ if hasRelevantAlerts .Modules.AlertDigest }}
|
||||||
| `.Modules.AlertDigest.Checked` | bool | Whether alert data was checked successfully. |
|
## Alert Digest
|
||||||
| `.Modules.AlertDigest.ActiveCount` | int | Active alert count from the source. |
|
{{ end }}
|
||||||
| `.Modules.AlertDigest.RelevantCount` | int | Alert count overlapping the report period. |
|
```
|
||||||
| `.Modules.AlertDigest.Missing` | bool | True when alert data is unavailable. |
|
|
||||||
| `.Modules.AlertDigest.Relevant` | []briefing.AlertSummary | Relevant alert summaries. |
|
|
||||||
| `.Modules.AlertDigest.Relevant[].Event` | string | Alert event name. |
|
|
||||||
| `.Modules.AlertDigest.Relevant[].Headline` | string | Alert headline. |
|
|
||||||
| `.Modules.AlertDigest.Relevant[].Severity` | string | Alert severity. |
|
|
||||||
|
|
||||||
### SPC Outlooks And Discussion
|
## Render Context
|
||||||
|
|
||||||
| Variable | Type | Description |
|
Every rendered template receives one typed context with these five top-level
|
||||||
| --- | --- | --- |
|
fields:
|
||||||
| `.Modules.SPCConvectiveOutlooks.Checked` | bool | Whether SPC outlook data was checked successfully. |
|
|
||||||
| `.Modules.SPCConvectiveOutlooks.AsOf` | string | Friendly source as-of time. |
|
|
||||||
| `.Modules.SPCConvectiveOutlooks.IssuedAt` | string | Friendly source issue time. |
|
|
||||||
| `.Modules.SPCConvectiveOutlooks.Outlooks` | []briefing.SPCConvectiveOutlookRecord | Overlapping outlook records. |
|
|
||||||
| `.Modules.SPCConvectiveOutlooks.Outlooks[].Day` | int | SPC day number. |
|
|
||||||
| `.Modules.SPCConvectiveOutlooks.Outlooks[].OutlookType` | string | Outlook type, such as `categorical`. |
|
|
||||||
| `.Modules.SPCConvectiveOutlooks.Outlooks[].Label` | string | Short outlook label. |
|
|
||||||
| `.Modules.SPCConvectiveOutlooks.Outlooks[].LabelText` | string | Human-readable outlook label. |
|
|
||||||
| `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodBegins` | string | Friendly outlook period start. |
|
|
||||||
| `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodEnds` | string | Friendly outlook period end. |
|
|
||||||
| `.Modules.SPCConvectiveOutlooks.Outlooks[].ImageURL` | string | Source image URL. |
|
|
||||||
| `.Modules.SPCConvectiveDiscussion.IncludedBecause` | string | Criterion used to include discussions. |
|
|
||||||
| `.Modules.SPCConvectiveDiscussion.Discussions` | []briefing.SPCConvectiveDiscussionRecord | Retained discussion records. |
|
|
||||||
| `.Modules.SPCConvectiveDiscussion.Discussions[].Headline` | string | Discussion headline. |
|
|
||||||
| `.Modules.SPCConvectiveDiscussion.Discussions[].Summary` | string | Discussion summary. |
|
|
||||||
| `.Modules.SPCConvectiveDiscussion.Discussions[].Discussion` | string | Full discussion text. |
|
|
||||||
|
|
||||||
### Area Forecast Discussion
|
| Field | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `.Report` | Display labels and canonical report timing metadata. |
|
||||||
|
| `.GeneratedText` | Validated prose supplied by Promptkit. |
|
||||||
|
| `.Modules` | Deterministic, typed values prepared for Markdown rendering. |
|
||||||
|
| `.Collected` | Normalized upstream facts for advanced use. |
|
||||||
|
| `.Derived` | Shared calculated facts for advanced use. |
|
||||||
|
|
||||||
| Variable | Type | Description |
|
`.Collected` and `.Derived` are available for an exceptional display need, but
|
||||||
| --- | --- | --- |
|
they are lower-level contracts. Keep reusable weather derivation in Go and use
|
||||||
| `.Modules.AreaForecastDiscussion.Product` | string | Source product identifier. |
|
the module surface for normal template work.
|
||||||
| `.Modules.AreaForecastDiscussion.KeyMessages` | []string | AFD key messages. |
|
|
||||||
| `.Modules.AreaForecastDiscussion.ShortTerm` | string | AFD short-term section text. |
|
|
||||||
| `.Modules.AreaForecastDiscussion.LongTerm` | string | AFD long-term section text. |
|
|
||||||
|
|
||||||
### Weather Story
|
### Report Metadata
|
||||||
|
|
||||||
| Variable | Type | Description |
|
All contexts provide `.Report.Title`, `.Report.GeneratedAt`,
|
||||||
| --- | --- | --- |
|
`.Report.GeneratedAtLabel`, `.Report.ValidPeriod`, and `.Report.Timezone`.
|
||||||
| `.Modules.WeatherStory.Available` | bool | True when a story is available. |
|
|
||||||
| `.Modules.WeatherStory.OfficeID` | string | Source office ID. |
|
|
||||||
| `.Modules.WeatherStory.PeriodBegins` | string | Friendly story period start. |
|
|
||||||
| `.Modules.WeatherStory.PeriodEnds` | string | Friendly story period end. |
|
|
||||||
| `.Modules.WeatherStory.UpdatedAt` | *time.Time | Canonical update timestamp. |
|
|
||||||
| `.Modules.WeatherStory.Title` | string | Story title. |
|
|
||||||
| `.Modules.WeatherStory.Description` | string | Story description. |
|
|
||||||
| `.Modules.WeatherStory.AltText` | string | Story image alt text. |
|
|
||||||
| `.Modules.WeatherStory.Priority` | bool | Source priority flag. |
|
|
||||||
| `.Modules.WeatherStory.Order` | int | Source order. |
|
|
||||||
| `.Modules.WeatherStory.DownloadURL` | string | Source download URL. |
|
|
||||||
|
|
||||||
## Collected And Derived Facts
|
Hourly additionally provides `.Report.LocationName` and
|
||||||
|
`.Report.ValidPeriodLabel`.
|
||||||
|
|
||||||
The template also receives the full `facts.CollectedFacts` and
|
Daily, Today, and Tomorrow additionally provide `.Report.ForecastDate`,
|
||||||
`facts.DerivedFacts` structs:
|
`.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.
|
||||||
|
|
||||||
- `.Collected` contains normalized source data and provenance from upstream
|
### Validated GeneratedText Prose
|
||||||
Weather API fetches.
|
|
||||||
- `.Derived` contains shared slices and calculations used across modules, such
|
|
||||||
as valid-period hourly periods, precipitation timing, alert overlaps, and SPC
|
|
||||||
filtering inputs.
|
|
||||||
|
|
||||||
These values are intentionally lower-level than `.Modules`. Use them when a
|
GeneratedText is prose returned by Promptkit and validated before rendering.
|
||||||
template needs a specific field that is not exposed by a module, but keep
|
It is not a source for deterministic weather facts.
|
||||||
calculation-heavy changes in Go.
|
|
||||||
|
|
||||||
## Validation
|
| Field | Hourly type | Daily, Today, and Tomorrow type | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `.GeneratedText.Summary` | `string` | `string` | Required. |
|
||||||
|
| `.GeneratedText.ForecastDiscussion` | `string` | `[]string` | Required; range over the day-style paragraph slice. |
|
||||||
|
| `.GeneratedText.PrecipitationTiming` | `string` | `string` | Optional prose used by the precipitation partial when deterministic windows exist. |
|
||||||
|
| `.GeneratedText.Confidence` | `string` | `string` | Optional validated prose; the current templates do not render it. |
|
||||||
|
|
||||||
After editing a template, run:
|
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).
|
||||||
|
|
||||||
```bash
|
### Deterministic Module Values
|
||||||
|
|
||||||
|
Module values are deterministic outputs built from collected and derived facts.
|
||||||
|
Module pointers can be nil when their source or policy permits omission.
|
||||||
|
|
||||||
|
| Module field | Available in |
|
||||||
|
| --- | --- |
|
||||||
|
| `.Modules.Metadata`, `.Modules.CurrentConditions`, `.Modules.HourlyForecast`, `.Modules.PrecipTiming`, `.Modules.AlertDigest`, `.Modules.SPCConvectiveOutlooks`, `.Modules.AreaForecastDiscussion`, `.Modules.SPCConvectiveDiscussion`, `.Modules.WeatherStory` | All four contexts |
|
||||||
|
| `.Modules.DerivedDailySummary`, `.Modules.DerivedDaypartSummaries`, `.Modules.Dayparts` | Daily, Today, Tomorrow |
|
||||||
|
| `.Modules.OutdoorWindows`, `.Modules.DailyPlanning` | Daily |
|
||||||
|
| `.Modules.TodayPlanning` | Today |
|
||||||
|
| `.Modules.TomorrowPlanning` | Tomorrow |
|
||||||
|
|
||||||
|
The repository templates currently use the following nested display values.
|
||||||
|
They are the preferred surface for comparable edits:
|
||||||
|
|
||||||
|
| Area | Values |
|
||||||
|
| --- | --- |
|
||||||
|
| Current conditions | `.TemperatureF`, `.ConditionText`, `.ConditionTextLower`, `.ApparentTemperatureF`, `.RelativeHumidityPercent`, `.WindDirectionText`, `.WindSpeedMph` |
|
||||||
|
| Hourly periods | `.Periods`, `.HourLabel`, `.Name`, `.TemperatureF`, `.TextDescription`, `.TextDescriptionLower`, `.MentionPrecipitation`, `.ProbabilityOfPrecipitationPercent` |
|
||||||
|
| Dayparts | `.Dayparts[].Key` and `.Dayparts[].Summary` fields `DisplayName`, `DominantCondition`, `DominantConditionDisplay`, `TemperatureTrend`, `TemperatureStartPhraseF`, `TemperatureEndPhraseF`, `TemperaturePeakPhraseF`, `TemperatureSteadyPhraseF`, `TemperaturePhraseF`, `MentionPrecipitation`, and `MaxPopPercent` |
|
||||||
|
| Precipitation timing | `.PrecipitationWindows`, plus each window's `PeriodBegins`, `PeriodBeginsHourLabel`, `PeriodEnds`, `PeriodEndsHourLabel`, `ExpectationPhrase`, `MaxPopPercent`, `MaxPopTime`, and `MaxPopHourLabel` |
|
||||||
|
| Alert digest | `.AlertDigest.Relevant` entries' `Event`, `Headline`, `PeriodBegins`, and `PeriodEnds` |
|
||||||
|
| SPC risk digest | `.SPCConvectiveOutlooks.RiskDigest` entries' `LabelText`, `RiskLabel`, `PeriodBegins`, and `PeriodEnds` |
|
||||||
|
|
||||||
|
Other fields on these typed modules remain available when a template has a
|
||||||
|
well-defined display need. Their module contracts and weather derivation belong
|
||||||
|
to [Module contract internals](internal/module.md), [Module builder
|
||||||
|
internals](internal/briefing.md), and [Forecast derivation
|
||||||
|
internals](internal/forecast-derivation.md).
|
||||||
|
|
||||||
|
## Validate Changes
|
||||||
|
|
||||||
|
Run the focused checks after editing templates, partials, prompts, or schemas:
|
||||||
|
|
||||||
|
```sh
|
||||||
go test ./internal/reporttemplate ./internal/generatedtext ./internal/app
|
go test ./internal/reporttemplate ./internal/generatedtext ./internal/app
|
||||||
```
|
|
||||||
|
|
||||||
For a full check, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./...
|
|
||||||
go run ./cmd/weatherreporter --help
|
|
||||||
git diff --check
|
git diff --check
|
||||||
```
|
```
|
||||||
|
|
||||||
Template render tests currently exercise the hourly template through
|
The render-context and template tests cover Daily, Today, Tomorrow, and Hourly
|
||||||
`internal/generatedtext/render_context_test.go` and
|
contexts. Run the repository-wide test suite before merging a broader change.
|
||||||
`internal/reporttemplate/reporttemplate_test.go`.
|
|
||||||
|
|||||||
@@ -1,386 +1,48 @@
|
|||||||
# Weatherreporter Troubleshooting
|
# Troubleshooting
|
||||||
|
|
||||||
This guide lists recurring failures with likely causes, diagnostics, and safe
|
Keep failed workspace artifacts in place. When a RunID is available, start
|
||||||
fixes. See [CLI reference](cli.md), [Configuration reference](config.md), and
|
with `weatherreporter inspect metadata RUN_ID` and use the paths in its result.
|
||||||
[Operations guide](operations.md) for normal usage.
|
|
||||||
|
|
||||||
## `weather_api.base_url is required`
|
## Prompt inspection or credentials fail before collection
|
||||||
|
|
||||||
Symptom: a generation command fails before fetching weather data.
|
A prompt/version, contract, selected profile, unsupported direct-key profile,
|
||||||
|
or required environment credential can fail before weather collection. Correct
|
||||||
|
the configured `promptkit` profile or profile source, confirm the exact
|
||||||
|
Promptkit asset is available, and supply any reported environment credential.
|
||||||
|
Do not add provider keys to YAML. See [configuration](config.md).
|
||||||
|
|
||||||
Likely cause: no Weather API base URL is configured.
|
## Preparation, capacity, or execution fails
|
||||||
|
|
||||||
Diagnostic:
|
A preparation failure occurs before provider work; an execution failure occurs
|
||||||
|
after preparation. Both leave safe provenance and metadata when reached. A
|
||||||
|
capacity error for one batch report does not retry that report or prevent later
|
||||||
|
independent reports. Inspect the preparation or execution path, correct the
|
||||||
|
profile/backend condition, and create a new run. See [operations](operations.md).
|
||||||
|
|
||||||
```sh
|
## Generated text fails validation
|
||||||
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
|
Raw generated output may be saved but Markdown is not rendered when the JSON
|
||||||
config path with `--config`.
|
does not match the report schema. Correct the Promptkit prompt/profile behavior
|
||||||
|
or the matching schema and validator in source control; do not edit raw output
|
||||||
|
to treat it as validated. See [templates](templates.md).
|
||||||
|
|
||||||
Relevant docs: [Configuration reference](config.md).
|
## Debug capture fails
|
||||||
|
|
||||||
## `weather_api.base_url must be an absolute URL`
|
`--llm-debug-dir` must be an absolute secure directory outside workspace state.
|
||||||
|
A debug-write failure stops the affected report to avoid continuing without the
|
||||||
|
requested diagnostic. Repair the named path's ownership or permissions, then
|
||||||
|
rerun. Treat capture files as sensitive. See [operations](operations.md).
|
||||||
|
|
||||||
Symptom: config loading fails with a base URL validation error.
|
## Weather, state, output, or notification fails
|
||||||
|
|
||||||
Likely cause: `weather_api.base_url` is missing a scheme or host.
|
Collection errors precede planning. Later filesystem, output-copy, template,
|
||||||
|
or Distributor errors retain the reached safe paths in the summary. Repair only
|
||||||
|
the reported endpoint or path, leave successful managed reports intact, and
|
||||||
|
rerun the affected report or batch. A batch notification is intentionally
|
||||||
|
skipped when any report item fails.
|
||||||
|
|
||||||
Diagnostic: inspect the configured value in the file passed to `--config`.
|
## Secrets cannot be loaded
|
||||||
|
|
||||||
Safe fix: use an absolute URL such as `https://weather.api.example.com/`.
|
Secret files must be regular non-symlink files directly beneath
|
||||||
|
`secrets.directory` with valid environment-variable basenames. Correct the
|
||||||
Relevant docs: [Configuration reference](config.md).
|
reported file or directory without placing secret values in YAML.
|
||||||
|
|
||||||
## 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).
|
|
||||||
|
|
||||||
## Generated Text Validation Fails
|
|
||||||
|
|
||||||
Symptom: Tomorrow or Hourly generation fails with generated-text decode,
|
|
||||||
unknown-field, required-field, or multiple-JSON-values context.
|
|
||||||
|
|
||||||
Likely cause: Scriptorium wrote structured JSON that does not match the
|
|
||||||
GeneratedText contract for the selected report.
|
|
||||||
|
|
||||||
Diagnostic:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
weatherreporter inspect metadata RUN_ID
|
|
||||||
```
|
|
||||||
|
|
||||||
Then inspect the generated-text raw path recorded in metadata, if present.
|
|
||||||
|
|
||||||
Safe fix: update the Scriptorium prompt or schema configuration so the prompt
|
|
||||||
writes the expected structured JSON for the report.
|
|
||||||
|
|
||||||
Relevant docs: [Operations guide](operations.md),
|
|
||||||
[Generated Text internals](internal/generatedtext.md),
|
|
||||||
[Scriptorium integration](integrations/scriptorium.md).
|
|
||||||
|
|
||||||
## Template Rendering Fails
|
|
||||||
|
|
||||||
Symptom: Tomorrow or Hourly generation fails with report template parsing or
|
|
||||||
execution context after generated text validation succeeds.
|
|
||||||
|
|
||||||
Likely cause: an embedded template references a missing context field or
|
|
||||||
receives a value shape that does not match its typed render context.
|
|
||||||
|
|
||||||
Diagnostic:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
weatherreporter inspect metadata RUN_ID
|
|
||||||
```
|
|
||||||
|
|
||||||
If metadata records generated-text and render-context paths, inspect those
|
|
||||||
artifacts along with the template named by the report definition.
|
|
||||||
|
|
||||||
Safe fix: update the embedded template or render-context builder so the
|
|
||||||
template uses the implemented typed context.
|
|
||||||
|
|
||||||
Relevant docs: [Report Templates](templates.md),
|
|
||||||
[Report Template internals](internal/reporttemplate.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:
|
weather_api:
|
||||||
base_url: https://weather.api.rakestrawhome.com/
|
base_url: https://weather.api.example.com/
|
||||||
timeout: 15s
|
timeout: 15s
|
||||||
precision: 1
|
precision: 0
|
||||||
units: us
|
units: us
|
||||||
timezone: "America/Chicago"
|
timezone: "America/Chicago"
|
||||||
format: json
|
format: json
|
||||||
@@ -24,17 +24,21 @@ notify:
|
|||||||
pipeline_id_template: "weatherreporter.{report_id}"
|
pipeline_id_template: "weatherreporter.{report_id}"
|
||||||
bundle_id_template: "weatherreporter.{location_id}.{report_id}"
|
bundle_id_template: "weatherreporter.{location_id}.{report_id}"
|
||||||
idempotency_key_template: "{bundle_id}.{run_id}"
|
idempotency_key_template: "{bundle_id}.{run_id}"
|
||||||
report_path_templates:
|
batch:
|
||||||
- "{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md"
|
enabled: true
|
||||||
|
pipeline_id_template: "weatherreporter"
|
||||||
|
bundle_id_template: "weatherreporter.{location_id}.{batch}"
|
||||||
|
idempotency_key_template: "{bundle_id}.{batch_run_id}"
|
||||||
|
|
||||||
missing_source:
|
missing_source:
|
||||||
default: warn
|
default: warn
|
||||||
sources:
|
sources:
|
||||||
alerts: none
|
alerts: none
|
||||||
|
|
||||||
scriptorium:
|
promptkit:
|
||||||
binary: scriptorium
|
|
||||||
timeout: 2m
|
timeout: 2m
|
||||||
|
local:
|
||||||
|
concurrency_limit: 1
|
||||||
|
|
||||||
workspace:
|
workspace:
|
||||||
root: workspace
|
root: workspace
|
||||||
@@ -69,6 +73,29 @@ recent_change:
|
|||||||
|
|
||||||
reports:
|
reports:
|
||||||
daily:
|
daily:
|
||||||
|
distributor:
|
||||||
|
path_templates:
|
||||||
|
- "daily/{valid_start_date}/{run_id}.md"
|
||||||
|
- "daily/{valid_start_date}/index.md"
|
||||||
|
deterministic_modules:
|
||||||
|
- metadata
|
||||||
|
- current_conditions
|
||||||
|
- narrative_forecast
|
||||||
|
- derived_daily_summary
|
||||||
|
- derived_daypart_summaries
|
||||||
|
- precip_timing
|
||||||
|
- alert_digest
|
||||||
|
- spc_convective_outlooks
|
||||||
|
- id: area_forecast_discussion
|
||||||
|
options:
|
||||||
|
sections:
|
||||||
|
- long_term
|
||||||
|
- spc_convective_discussion
|
||||||
|
- weather_story
|
||||||
|
- outdoor_windows
|
||||||
|
- daily_planning
|
||||||
|
- hourly_forecast
|
||||||
|
today:
|
||||||
deterministic_modules:
|
deterministic_modules:
|
||||||
- metadata
|
- metadata
|
||||||
- current_conditions
|
- current_conditions
|
||||||
@@ -89,6 +116,7 @@ reports:
|
|||||||
- weather_story
|
- weather_story
|
||||||
- outdoor_windows
|
- outdoor_windows
|
||||||
- hourly_forecast
|
- hourly_forecast
|
||||||
|
- today_planning
|
||||||
hourly:
|
hourly:
|
||||||
deterministic_modules:
|
deterministic_modules:
|
||||||
- metadata
|
- metadata
|
||||||
|
|||||||
10
go.mod
10
go.mod
@@ -4,4 +4,12 @@ go 1.26
|
|||||||
|
|
||||||
require gopkg.in/yaml.v3 v3.0.1
|
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.4.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||||
|
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 h1:+al7Bw+kMv6V35a3Sm5rUtCTQhwOn5b9x3RsclPMKJk=
|
||||||
gitea.maximumdirect.net/eric/distributor v0.5.0/go.mod h1:G03FCFZPHpsUKC6SeMgTdbfNRpPQBdyTtDUj04e1Tu8=
|
gitea.maximumdirect.net/eric/distributor v0.5.0/go.mod h1:G03FCFZPHpsUKC6SeMgTdbfNRpPQBdyTtDUj04e1Tu8=
|
||||||
|
gitea.maximumdirect.net/eric/promptkit v0.4.0 h1:WHRQEt3BVBAR7hQePBaGtNXpzrs59mlr/42nQzwgOz4=
|
||||||
|
gitea.maximumdirect.net/eric/promptkit v0.4.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 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 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 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/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 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
|
||||||
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
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 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
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 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
|
||||||
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
|
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 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
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 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
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 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
322
internal/adapters/promptkit/adapter.go
Normal file
322
internal/adapters/promptkit/adapter.go
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
// Package promptkitadapter implements promptexec with Promptkit.
|
||||||
|
package promptkitadapter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptassets"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config selects the Promptkit sources and optional local backend for one engine.
|
||||||
|
type Config struct {
|
||||||
|
ProfileDirectory string
|
||||||
|
ProfileFile string
|
||||||
|
LocalEndpoint string
|
||||||
|
LocalConcurrencyLimit int
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adapter owns one Promptkit engine and its opaque prepared execution handles.
|
||||||
|
type Adapter struct {
|
||||||
|
engine *promptkit.Engine
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ promptexec.Executor = (*Adapter)(nil)
|
||||||
|
|
||||||
|
// New constructs a Promptkit-backed executor from Weatherreporter-owned settings.
|
||||||
|
func New(config Config) (*Adapter, error) {
|
||||||
|
return newAdapter(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAdapter(config Config, additionalOptions ...promptkit.Option) (*Adapter, error) {
|
||||||
|
if config.ProfileDirectory != "" && config.ProfileFile != "" {
|
||||||
|
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "profile directory and profile file cannot both be configured", nil)
|
||||||
|
}
|
||||||
|
if config.LocalEndpoint == "" && config.LocalConcurrencyLimit != 0 {
|
||||||
|
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "local concurrency requires a local endpoint", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
options := []promptkit.Option{
|
||||||
|
promptkit.WithPromptFS(promptassets.PromptFS(), "."),
|
||||||
|
promptkit.WithSchemaFS(promptassets.SchemaFS(), "."),
|
||||||
|
}
|
||||||
|
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.InlineWithURI(request.DataPackagePath, string(append([]byte(nil), request.DataPackage...))),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, classifyError(err)
|
||||||
|
}
|
||||||
|
defer prepared.Discard()
|
||||||
|
|
||||||
|
details := prepared.Details()
|
||||||
|
preparation, debug := preparationValues(details, request.DataPackagePath, 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.DataPackagePath, 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, dataPackagePath string, 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,
|
||||||
|
DataPackagePath: dataPackagePath,
|
||||||
|
}
|
||||||
|
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, dataPackagePath string, captureDebug bool) *promptexec.Execution {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
validation := promptexec.NewValidation(
|
||||||
|
promptexec.ValidationStatus(value.Validation.Status),
|
||||||
|
string(value.Validation.Mode),
|
||||||
|
value.Validation.SchemaPath,
|
||||||
|
value.Validation.Errors,
|
||||||
|
)
|
||||||
|
execution := &promptexec.Execution{
|
||||||
|
RunID: value.RunID,
|
||||||
|
PromptID: value.PromptID,
|
||||||
|
PromptVersion: value.PromptVersion,
|
||||||
|
PromptHash: value.PromptHash,
|
||||||
|
RenderedPromptHash: value.RenderedPromptHash,
|
||||||
|
InputHashes: copyInputHashes(value.InputHashes),
|
||||||
|
ProfileID: value.SelectedProfileID,
|
||||||
|
BackendID: value.SelectedBackendID,
|
||||||
|
ModelName: value.ModelName,
|
||||||
|
GeneratedHash: value.Artifact.Hash,
|
||||||
|
Usage: promptexec.TokenUsage{
|
||||||
|
PromptTokens: value.Usage.PromptTokens,
|
||||||
|
CompletionTokens: value.Usage.CompletionTokens,
|
||||||
|
TotalTokens: value.Usage.TotalTokens,
|
||||||
|
CachedTokens: value.Usage.CachedTokens,
|
||||||
|
CacheWriteTokens: value.Usage.CacheWriteTokens,
|
||||||
|
},
|
||||||
|
StartedAt: value.StartTime,
|
||||||
|
EndedAt: value.EndTime,
|
||||||
|
Duration: value.Duration,
|
||||||
|
Validation: validation,
|
||||||
|
DataPackagePath: dataPackagePath,
|
||||||
|
RawOutput: []byte(value.RawOutput),
|
||||||
|
}
|
||||||
|
if captureDebug {
|
||||||
|
execution.Debug = &promptexec.ExecutionDebug{
|
||||||
|
RawOutput: append([]byte(nil), value.RawOutput...),
|
||||||
|
ValidationDiagnostics: append([]string(nil), validation.Diagnostics...),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return execution
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderedMessages(values []promptkit.RenderedMessage) []promptexec.RenderedMessage {
|
||||||
|
messages := make([]promptexec.RenderedMessage, len(values))
|
||||||
|
for index, value := range values {
|
||||||
|
messages[index] = promptexec.RenderedMessage{Role: value.Role, Content: value.Content}
|
||||||
|
}
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyInputHashes(values map[string]string) map[string]string {
|
||||||
|
if values == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
copy := make(map[string]string, len(values))
|
||||||
|
for key, value := range values {
|
||||||
|
copy[key] = value
|
||||||
|
}
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalDebugParameters(value promptkit.ExecutionTarget) []byte {
|
||||||
|
parameters := struct {
|
||||||
|
Temperature float64 `json:"temperature"`
|
||||||
|
MaxTokens int `json:"max_tokens"`
|
||||||
|
TopP float64 `json:"top_p"`
|
||||||
|
TimeoutSeconds int `json:"timeout_seconds"`
|
||||||
|
ServiceTier string `json:"service_tier"`
|
||||||
|
ReasoningEffort string `json:"reasoning_effort"`
|
||||||
|
ExtraParams map[string]any `json:"extra_params"`
|
||||||
|
}{
|
||||||
|
Temperature: value.Temperature,
|
||||||
|
MaxTokens: value.MaxTokens,
|
||||||
|
TopP: value.TopP,
|
||||||
|
TimeoutSeconds: value.TimeoutSeconds,
|
||||||
|
ServiceTier: value.ServiceTier,
|
||||||
|
ReasoningEffort: value.ReasoningEffort,
|
||||||
|
ExtraParams: value.ExtraParams,
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(parameters)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyConfigurationError(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor configuration is invalid", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyError(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
return promptexec.NewError(promptexec.Canceled, "prompt operation was canceled", err)
|
||||||
|
}
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return promptexec.NewError(promptexec.DeadlineExceeded, "prompt operation exceeded its deadline", err)
|
||||||
|
}
|
||||||
|
var capacityError *promptkit.CapacityError
|
||||||
|
if errors.As(err, &capacityError) {
|
||||||
|
return promptexec.NewCapacityError(capacityError.BackendID, "prompt backend capacity is unavailable", err)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, promptkit.ErrInvalidConfig):
|
||||||
|
return promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor configuration is invalid", err)
|
||||||
|
case errors.Is(err, promptkit.ErrPromptNotFound):
|
||||||
|
return promptexec.NewError(promptexec.PromptNotFound, "prompt definition was not found", err)
|
||||||
|
case errors.Is(err, promptkit.ErrPromptLoad):
|
||||||
|
return promptexec.NewError(promptexec.PromptLoad, "prompt definition could not be loaded", err)
|
||||||
|
case errors.Is(err, promptkit.ErrProfileNotFound):
|
||||||
|
return promptexec.NewError(promptexec.ProfileNotFound, "execution profile was not found", err)
|
||||||
|
case errors.Is(err, promptkit.ErrProfileLoad):
|
||||||
|
return promptexec.NewError(promptexec.ProfileLoad, "execution profile could not be loaded", err)
|
||||||
|
case errors.Is(err, promptkit.ErrAPIKeyEnvMissing):
|
||||||
|
return promptexec.NewError(promptexec.MissingCredential, "execution credential is unavailable", err)
|
||||||
|
case errors.Is(err, promptkit.ErrArtifactLoad):
|
||||||
|
return promptexec.NewError(promptexec.ArtifactLoad, "prompt input could not be loaded", err)
|
||||||
|
case errors.Is(err, promptkit.ErrPromptRender):
|
||||||
|
return promptexec.NewError(promptexec.PromptRender, "prompt could not be rendered", err)
|
||||||
|
case errors.Is(err, promptkit.ErrCapacityExceeded):
|
||||||
|
return promptexec.NewCapacityError("", "prompt backend capacity is unavailable", err)
|
||||||
|
case errors.Is(err, promptkit.ErrLLMGenerate):
|
||||||
|
return promptexec.NewError(promptexec.Generation, "prompt generation failed", err)
|
||||||
|
case errors.Is(err, promptkit.ErrValidation):
|
||||||
|
return promptexec.NewError(promptexec.OperationalValidation, "prompt output validation could not be completed", err)
|
||||||
|
case errors.Is(err, promptkit.ErrInvalidRequest), errors.Is(err, promptkit.ErrProfileRequired):
|
||||||
|
return promptexec.NewError(promptexec.InvalidRequest, "prompt execution request is invalid", err)
|
||||||
|
default:
|
||||||
|
return promptexec.NewError(promptexec.Generation, "prompt operation failed", fmt.Errorf("%w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
384
internal/adapters/promptkit/adapter_test.go
Normal file
384
internal/adapters/promptkit/adapter_test.go
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
package promptkitadapter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeClient struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
response *promptkit.GenerateResponse
|
||||||
|
err error
|
||||||
|
calls int
|
||||||
|
requests []promptkit.GenerateRequest
|
||||||
|
block bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordingReader struct {
|
||||||
|
ref promptkit.ArtifactRef
|
||||||
|
}
|
||||||
|
|
||||||
|
func (reader *recordingReader) Read(_ context.Context, ref promptkit.ArtifactRef) (*promptkit.Artifact, error) {
|
||||||
|
reader.ref = ref
|
||||||
|
return &promptkit.Artifact{
|
||||||
|
Name: "data_package",
|
||||||
|
ContentType: "application/yaml",
|
||||||
|
Body: []byte(ref.Body),
|
||||||
|
URI: ref.URI,
|
||||||
|
Hash: "input-hash",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *fakeClient) Generate(ctx context.Context, request promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
|
||||||
|
client.mu.Lock()
|
||||||
|
client.calls++
|
||||||
|
client.requests = append(client.requests, request)
|
||||||
|
block := client.block
|
||||||
|
response := client.response
|
||||||
|
err := client.err
|
||||||
|
client.mu.Unlock()
|
||||||
|
if block {
|
||||||
|
<-ctx.Done()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
return response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *fakeClient) callCount() int {
|
||||||
|
client.mu.Lock()
|
||||||
|
defer client.mu.Unlock()
|
||||||
|
return client.calls
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *fakeClient) request() promptkit.GenerateRequest {
|
||||||
|
client.mu.Lock()
|
||||||
|
defer client.mu.Unlock()
|
||||||
|
return client.requests[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInspectPromptAndProfile(t *testing.T) {
|
||||||
|
adapter := newTestAdapter(t, &fakeClient{})
|
||||||
|
inspection, err := adapter.InspectPrompt(context.Background(), "weather.daily_generated_text", "1.0.0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InspectPrompt() error = %v", err)
|
||||||
|
}
|
||||||
|
if inspection.PromptID != "weather.daily_generated_text" || inspection.PromptVersion != "1.0.0" || inspection.DefaultProfileID != "gemini-flash-latest" {
|
||||||
|
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 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.DataPackagePath != request.DataPackagePath || 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 || result.DataPackagePath != request.DataPackagePath {
|
||||||
|
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 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 != request.DataPackagePath || reader.ref.Body != string(request.DataPackage) {
|
||||||
|
t.Fatalf("artifact ref = %#v, want exact inline data package provenance", reader.ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteCapturesSensitiveDebugOnlyWhenRequested(t *testing.T) {
|
||||||
|
client := &fakeClient{response: validResponse()}
|
||||||
|
adapter := newTestAdapter(t, client)
|
||||||
|
request := testExecuteRequest()
|
||||||
|
request.CaptureDebug = true
|
||||||
|
var preparationDebug *promptexec.PreparationDebug
|
||||||
|
result, err := adapter.Execute(context.Background(), request, func(preparation promptexec.Preparation, debug *promptexec.PreparationDebug) error {
|
||||||
|
preparationDebug = debug
|
||||||
|
if strings.Contains(fmt.Sprintf("%#v", preparation), "https://profile.example") || strings.Contains(fmt.Sprintf("%#v", preparation), string(request.DataPackage)) {
|
||||||
|
t.Fatalf("safe preparation leaks sensitive content: %#v", preparation)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute() error = %v", err)
|
||||||
|
}
|
||||||
|
if preparationDebug == nil || preparationDebug.Endpoint != "https://profile.example/v1" || len(preparationDebug.RenderedMessages) == 0 || len(preparationDebug.StructuredSchema) == 0 || len(preparationDebug.ParametersJSON) == 0 {
|
||||||
|
t.Fatalf("preparation debug = %#v", preparationDebug)
|
||||||
|
}
|
||||||
|
if result.Debug == nil || string(result.Debug.RawOutput) != client.response.Content {
|
||||||
|
t.Fatalf("execution debug = %#v", result.Debug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteCallbackFailurePreventsGeneration(t *testing.T) {
|
||||||
|
client := &fakeClient{response: validResponse()}
|
||||||
|
adapter := newTestAdapter(t, client)
|
||||||
|
callbackError := errors.New("save preparation")
|
||||||
|
result, err := adapter.Execute(context.Background(), testExecuteRequest(), func(promptexec.Preparation, *promptexec.PreparationDebug) error {
|
||||||
|
return callbackError
|
||||||
|
})
|
||||||
|
if result != nil || !errors.Is(err, callbackError) || client.callCount() != 0 {
|
||||||
|
t.Fatalf("result/error/provider calls = %#v/%v/%d", result, err, client.callCount())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteReturnsCompletedValidationRejection(t *testing.T) {
|
||||||
|
client := &fakeClient{response: &promptkit.GenerateResponse{Content: `{"summary":42}`, Usage: promptkit.TokenUsage{TotalTokens: 5}}}
|
||||||
|
adapter := newTestAdapter(t, client)
|
||||||
|
result, err := adapter.Execute(context.Background(), testExecuteRequest(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute() error = %v", err)
|
||||||
|
}
|
||||||
|
if result == nil || result.Validation.Status != promptexec.ValidationFailed || len(result.Validation.Diagnostics) == 0 || string(result.RawOutput) != client.response.Content {
|
||||||
|
t.Fatalf("result = %#v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteClassifiesOperationalFailures(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
client *fakeClient
|
||||||
|
context func() (context.Context, context.CancelFunc)
|
||||||
|
category promptexec.ErrorCategory
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "generation",
|
||||||
|
client: &fakeClient{err: errors.New("provider response body")},
|
||||||
|
context: func() (context.Context, context.CancelFunc) {
|
||||||
|
return context.WithCancel(context.Background())
|
||||||
|
},
|
||||||
|
category: promptexec.Generation,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "canceled",
|
||||||
|
client: &fakeClient{block: true},
|
||||||
|
context: func() (context.Context, context.CancelFunc) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
return ctx, func() {}
|
||||||
|
},
|
||||||
|
category: promptexec.Canceled,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deadline",
|
||||||
|
client: &fakeClient{block: true},
|
||||||
|
context: func() (context.Context, context.CancelFunc) {
|
||||||
|
return context.WithTimeout(context.Background(), time.Nanosecond)
|
||||||
|
},
|
||||||
|
category: promptexec.DeadlineExceeded,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
adapter := newTestAdapter(t, test.client)
|
||||||
|
ctx, cancel := test.context()
|
||||||
|
defer cancel()
|
||||||
|
result, err := adapter.Execute(ctx, testExecuteRequest(), nil)
|
||||||
|
if result != nil || err == nil || promptexec.CategoryOf(err) != test.category {
|
||||||
|
t.Fatalf("result/error/category = %#v/%v/%q, want %q", result, err, promptexec.CategoryOf(err), test.category)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "provider response body") {
|
||||||
|
t.Fatalf("error leaks provider detail: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyPromptkitErrors(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
err error
|
||||||
|
category promptexec.ErrorCategory
|
||||||
|
}{
|
||||||
|
{promptkit.ErrInvalidConfig, promptexec.InvalidConfiguration},
|
||||||
|
{promptkit.ErrInvalidRequest, promptexec.InvalidRequest},
|
||||||
|
{promptkit.ErrPromptNotFound, promptexec.PromptNotFound},
|
||||||
|
{promptkit.ErrPromptLoad, promptexec.PromptLoad},
|
||||||
|
{promptkit.ErrProfileNotFound, promptexec.ProfileNotFound},
|
||||||
|
{promptkit.ErrProfileLoad, promptexec.ProfileLoad},
|
||||||
|
{promptkit.ErrAPIKeyEnvMissing, promptexec.MissingCredential},
|
||||||
|
{promptkit.ErrArtifactLoad, promptexec.ArtifactLoad},
|
||||||
|
{promptkit.ErrPromptRender, promptexec.PromptRender},
|
||||||
|
{promptkit.ErrLLMGenerate, promptexec.Generation},
|
||||||
|
{promptkit.ErrValidation, promptexec.OperationalValidation},
|
||||||
|
{&promptkit.CapacityError{BackendID: "local"}, promptexec.Capacity},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(string(test.category), func(t *testing.T) {
|
||||||
|
got := classifyError(test.err)
|
||||||
|
if promptexec.CategoryOf(got) != test.category {
|
||||||
|
t.Fatalf("category = %q, want %q", promptexec.CategoryOf(got), test.category)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewValidatesConfiguration(t *testing.T) {
|
||||||
|
if _, err := New(Config{ProfileDirectory: "profiles", ProfileFile: "profile.yml"}); promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
|
||||||
|
t.Fatalf("profile source error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := New(Config{LocalConcurrencyLimit: 1}); promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
|
||||||
|
t.Fatalf("local concurrency error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := New(Config{LocalEndpoint: "not a URL"}); promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
|
||||||
|
t.Fatalf("local endpoint error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalBackendAndMissingCredentialBehavior(t *testing.T) {
|
||||||
|
profiles := testProfileDirectory(t, `id: local-profile
|
||||||
|
backend: local
|
||||||
|
model: local-model
|
||||||
|
`)
|
||||||
|
adapter, err := newAdapterForTest(Config{
|
||||||
|
ProfileDirectory: profiles,
|
||||||
|
LocalEndpoint: "https://local.example/v1",
|
||||||
|
LocalConcurrencyLimit: 1,
|
||||||
|
}, &fakeClient{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newAdapterForTest(local) error = %v", err)
|
||||||
|
}
|
||||||
|
profile, err := adapter.InspectProfile(context.Background(), "local-profile")
|
||||||
|
if err != nil || profile.BackendID != promptkit.BackendLocal || profile.ModelName != "local-model" {
|
||||||
|
t.Fatalf("local profile/error = %#v/%v", profile, err)
|
||||||
|
}
|
||||||
|
if got := classifyError(&promptkit.CapacityError{BackendID: promptkit.BackendLocal}); promptexec.CategoryOf(got) != promptexec.Capacity {
|
||||||
|
t.Fatalf("capacity classification = %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
credentialProfiles := testProfileDirectory(t, `id: credential-profile
|
||||||
|
endpoint: https://profile.example/v1
|
||||||
|
model: test-model
|
||||||
|
api_key_env: WEATHERREPORTER_TEST_MISSING_KEY
|
||||||
|
`)
|
||||||
|
client := &fakeClient{response: validResponse()}
|
||||||
|
credentialAdapter, err := newAdapterForTest(Config{ProfileDirectory: credentialProfiles}, client)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newAdapterForTest(credential) error = %v", err)
|
||||||
|
}
|
||||||
|
credentialProfile, err := credentialAdapter.InspectProfile(context.Background(), "credential-profile")
|
||||||
|
if err != nil || credentialProfile.CredentialRequired || credentialProfile.APIKeyEnv != "WEATHERREPORTER_TEST_MISSING_KEY" {
|
||||||
|
t.Fatalf("credential profile/error = %#v/%v", credentialProfile, err)
|
||||||
|
}
|
||||||
|
request := testExecuteRequest()
|
||||||
|
request.ProfileID = "credential-profile"
|
||||||
|
result, err := credentialAdapter.Execute(context.Background(), request, nil)
|
||||||
|
if result != nil || promptexec.CategoryOf(err) != promptexec.MissingCredential || client.callCount() != 0 {
|
||||||
|
t.Fatalf("credential result/category/calls = %#v/%q/%d", result, promptexec.CategoryOf(err), client.callCount())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestAdapter(t *testing.T, client promptkit.LLMClient) *Adapter {
|
||||||
|
return newTestAdapterWithOptions(t, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
func 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 testExecuteRequest() promptexec.ExecuteRequest {
|
||||||
|
return promptexec.ExecuteRequest{
|
||||||
|
PromptID: "weather.daily_generated_text",
|
||||||
|
PromptVersion: "1.0.0",
|
||||||
|
ProfileID: "test-profile",
|
||||||
|
DataPackage: []byte("report:\n id: daily\nbriefing: {}\n"),
|
||||||
|
DataPackagePath: "data-packages/daily/data_package.yaml",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validResponse() *promptkit.GenerateResponse {
|
||||||
|
return &promptkit.GenerateResponse{
|
||||||
|
Content: `{"summary":"A quiet day is expected.","forecast_discussion":["High pressure keeps conditions settled."],"confidence":"High."}`,
|
||||||
|
Usage: promptkit.TokenUsage{PromptTokens: 12, CompletionTokens: 8, TotalTokens: 20},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,301 +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 StructuredRunRequest 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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type StructuredRunResult 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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r Runner) StructuredRun(ctx context.Context, req StructuredRunRequest) (*StructuredRunResult, 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.structuredRunArgs(req))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("run scriptorium structured output: %w", err)
|
|
||||||
}
|
|
||||||
result := &StructuredRunResult{
|
|
||||||
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 structured 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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r Runner) structuredRunArgs(req StructuredRunRequest) []string {
|
|
||||||
return r.runArgs(RunRequest{
|
|
||||||
PromptID: req.PromptID,
|
|
||||||
DataPackagePath: req.DataPackagePath,
|
|
||||||
OutputPath: req.OutputPath,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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,315 +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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStructuredRunConstructsCommandWithoutSchemaFlags(t *testing.T) {
|
|
||||||
commands := &fakeCommands{result: CommandResult{
|
|
||||||
Stdout: []byte(`{"summary":"ok"}`),
|
|
||||||
Stderr: []byte("wrote generated text"),
|
|
||||||
StdoutTruncated: true,
|
|
||||||
}}
|
|
||||||
runner := Runner{
|
|
||||||
Binary: "/usr/local/bin/scriptorium",
|
|
||||||
ConfigPath: "/etc/scriptorium.yml",
|
|
||||||
Profile: "weather",
|
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
Commands: commands,
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
|
|
||||||
PromptID: "weather.hourly_generated_text",
|
|
||||||
DataPackagePath: "/tmp/hourly.data_package.yaml",
|
|
||||||
OutputPath: "/tmp/hourly.generated_text.raw.json",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("StructuredRun() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantArgs := []string{
|
|
||||||
"run",
|
|
||||||
"--config", "/etc/scriptorium.yml",
|
|
||||||
"--profile", "weather",
|
|
||||||
"--prompt", "weather.hourly_generated_text",
|
|
||||||
"--input", "data_package=/tmp/hourly.data_package.yaml",
|
|
||||||
"--out", "/tmp/hourly.generated_text.raw.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)
|
|
||||||
}
|
|
||||||
for _, disallowed := range []string{"--format", "--schema", "--schema-path", "--json-schema"} {
|
|
||||||
if containsArg(commands.args, disallowed) {
|
|
||||||
t.Fatalf("args = %#v, should not include %q", commands.args, disallowed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if commands.timeout != 30*time.Second {
|
|
||||||
t.Fatalf("timeout = %s, want 30s", 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.Stdout != `{"summary":"ok"}` || result.Stderr != "wrote generated text" || !result.StdoutTruncated {
|
|
||||||
t.Fatalf("result = %#v, want captured output and truncation flags", result)
|
|
||||||
}
|
|
||||||
if result.OutputPath != "/tmp/hourly.generated_text.raw.json" {
|
|
||||||
t.Fatalf("OutputPath = %q, want generated text raw path", result.OutputPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStructuredRunReturnsResultForNonzeroExit(t *testing.T) {
|
|
||||||
runner := Runner{
|
|
||||||
Commands: &fakeCommands{
|
|
||||||
result: CommandResult{
|
|
||||||
Stdout: []byte(`{"summary":"partial"}`),
|
|
||||||
Stderr: []byte("structured output failed"),
|
|
||||||
ExitCode: 3,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
|
|
||||||
PromptID: "weather.hourly_generated_text",
|
|
||||||
DataPackagePath: "/tmp/hourly.data_package.yaml",
|
|
||||||
OutputPath: "/tmp/hourly.generated_text.raw.json",
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("StructuredRun() error = nil, want nonzero exit error")
|
|
||||||
}
|
|
||||||
if result == nil {
|
|
||||||
t.Fatal("StructuredRun() result = nil, want captured result")
|
|
||||||
}
|
|
||||||
if result.ExitCode != 3 {
|
|
||||||
t.Fatalf("ExitCode = %d, want 3", result.ExitCode)
|
|
||||||
}
|
|
||||||
if result.Stdout != `{"summary":"partial"}` || result.OutputPath != "/tmp/hourly.generated_text.raw.json" {
|
|
||||||
t.Fatalf("result = %#v, want captured result fields", result)
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "structured output failed") {
|
|
||||||
t.Fatalf("error = %q, want stderr context", err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStructuredRunValidatesRequiredFieldsBeforeExecution(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
req StructuredRunRequest
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "prompt id",
|
|
||||||
req: StructuredRunRequest{
|
|
||||||
DataPackagePath: "/tmp/hourly.data_package.yaml",
|
|
||||||
OutputPath: "/tmp/hourly.generated_text.raw.json",
|
|
||||||
},
|
|
||||||
want: "prompt id is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "data package path",
|
|
||||||
req: StructuredRunRequest{
|
|
||||||
PromptID: "weather.hourly_generated_text",
|
|
||||||
OutputPath: "/tmp/hourly.generated_text.raw.json",
|
|
||||||
},
|
|
||||||
want: "data package path is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "output path",
|
|
||||||
req: StructuredRunRequest{
|
|
||||||
PromptID: "weather.hourly_generated_text",
|
|
||||||
DataPackagePath: "/tmp/hourly.data_package.yaml",
|
|
||||||
},
|
|
||||||
want: "output path is required",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
commands := &fakeCommands{}
|
|
||||||
runner := Runner{Commands: commands}
|
|
||||||
result, err := runner.StructuredRun(context.Background(), test.req)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("StructuredRun() error = nil, want validation error")
|
|
||||||
}
|
|
||||||
if result != nil {
|
|
||||||
t.Fatalf("StructuredRun() result = %#v, want nil", result)
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), test.want) {
|
|
||||||
t.Fatalf("StructuredRun() error = %v, want %q", err, test.want)
|
|
||||||
}
|
|
||||||
if commands.calls != 0 {
|
|
||||||
t.Fatalf("commands calls = %d, want no subprocess execution", commands.calls)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakeCommands struct {
|
|
||||||
name string
|
|
||||||
args []string
|
|
||||||
timeout time.Duration
|
|
||||||
result CommandResult
|
|
||||||
err error
|
|
||||||
calls int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeCommands) Run(_ context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) {
|
|
||||||
f.calls++
|
|
||||||
f.name = name
|
|
||||||
f.args = append([]string{}, args...)
|
|
||||||
f.timeout = timeout
|
|
||||||
return f.result, f.err
|
|
||||||
}
|
|
||||||
|
|
||||||
func containsArg(args []string, want string) bool {
|
|
||||||
for _, arg := range args {
|
|
||||||
if arg == want {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -24,6 +24,12 @@ import (
|
|||||||
const (
|
const (
|
||||||
convectiveOutlooksEndpoint = "/outlooks/convective"
|
convectiveOutlooksEndpoint = "/outlooks/convective"
|
||||||
sourceSPCConvectiveOutlooks = "spc_convective_outlooks"
|
sourceSPCConvectiveOutlooks = "spc_convective_outlooks"
|
||||||
|
|
||||||
|
defaultWarmupEndpoint = "/conditions/current"
|
||||||
|
defaultWarmupAttempts = 3
|
||||||
|
defaultWarmupDelay = time.Second
|
||||||
|
defaultFetchAttempts = 2
|
||||||
|
defaultFetchRetryDelay = time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
@@ -35,6 +41,12 @@ type Client struct {
|
|||||||
precision int
|
precision int
|
||||||
missingSource config.MissingSourceConfig
|
missingSource config.MissingSourceConfig
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
|
|
||||||
|
warmupEndpoint string
|
||||||
|
warmupAttempts int
|
||||||
|
warmupDelay time.Duration
|
||||||
|
fetchAttempts int
|
||||||
|
fetchRetryDelay time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
type Option func(*Client)
|
type Option func(*Client)
|
||||||
@@ -80,7 +92,12 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
|
|||||||
Default: cfg.MissingSource.Default,
|
Default: cfg.MissingSource.Default,
|
||||||
Sources: cfg.MissingSource.Sources,
|
Sources: cfg.MissingSource.Sources,
|
||||||
},
|
},
|
||||||
now: time.Now,
|
now: time.Now,
|
||||||
|
warmupEndpoint: defaultWarmupEndpoint,
|
||||||
|
warmupAttempts: defaultWarmupAttempts,
|
||||||
|
warmupDelay: defaultWarmupDelay,
|
||||||
|
fetchAttempts: defaultFetchAttempts,
|
||||||
|
fetchRetryDelay: defaultFetchRetryDelay,
|
||||||
}
|
}
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
opt(client)
|
opt(client)
|
||||||
@@ -89,6 +106,10 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
|
func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
|
||||||
|
if err := c.warmup(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
fetchedAt := c.now()
|
fetchedAt := c.now()
|
||||||
builder := bundleBuilder{
|
builder := bundleBuilder{
|
||||||
client: c,
|
client: c,
|
||||||
@@ -130,18 +151,32 @@ type bundleBuilder struct {
|
|||||||
fetchedAt time.Time
|
fetchedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type sourceRequest struct {
|
||||||
|
name string
|
||||||
|
endpoint string
|
||||||
|
query queryOptions
|
||||||
|
missingMessage string
|
||||||
|
required bool
|
||||||
|
decodeLabel string
|
||||||
|
}
|
||||||
|
|
||||||
|
type fetchedSource struct {
|
||||||
|
raw json.RawMessage
|
||||||
|
source weatherdata.Source
|
||||||
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
|
func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
|
||||||
raw, source, err := b.client.fetch(ctx, "observations", "/observations", queryOptions{precision: true})
|
var observation weatherdata.Observation
|
||||||
if err != nil {
|
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||||
|
name: "observations",
|
||||||
|
endpoint: "/observations",
|
||||||
|
query: queryOptions{precision: true},
|
||||||
|
missingMessage: "observation data is missing",
|
||||||
|
}, &observation)
|
||||||
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if raw == nil {
|
source := fetched.source
|
||||||
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.IssuedAt = &observation.Timestamp
|
source.IssuedAt = &observation.Timestamp
|
||||||
b.bundle.Observation = &observation
|
b.bundle.Observation = &observation
|
||||||
b.addSource(source)
|
b.addSource(source)
|
||||||
@@ -149,34 +184,36 @@ func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
|
func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
|
||||||
raw, source, err := b.client.fetch(ctx, "current", "/conditions/current", queryOptions{precision: true})
|
var current weatherdata.Current
|
||||||
if err != nil {
|
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||||
|
name: "current",
|
||||||
|
endpoint: "/conditions/current",
|
||||||
|
query: queryOptions{precision: true},
|
||||||
|
missingMessage: "current conditions data is missing",
|
||||||
|
}, ¤t)
|
||||||
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if raw == nil {
|
source := fetched.source
|
||||||
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)
|
|
||||||
}
|
|
||||||
b.bundle.Current = ¤t
|
b.bundle.Current = ¤t
|
||||||
b.addSource(source)
|
b.addSource(source)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
|
func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
|
||||||
raw, source, err := b.client.fetch(ctx, "hourly", "/forecast/hourly", queryOptions{precision: true, timezone: true})
|
var hourly weatherdata.ForecastRun
|
||||||
if err != nil {
|
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||||
|
name: "hourly",
|
||||||
|
endpoint: "/forecast/hourly",
|
||||||
|
query: queryOptions{precision: true, timezone: true},
|
||||||
|
missingMessage: "hourly forecast data is missing",
|
||||||
|
required: true,
|
||||||
|
decodeLabel: "hourly forecast",
|
||||||
|
}, &hourly)
|
||||||
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if raw == nil {
|
source := fetched.source
|
||||||
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)
|
|
||||||
}
|
|
||||||
if len(hourly.Periods) == 0 {
|
if len(hourly.Periods) == 0 {
|
||||||
return fmt.Errorf("hourly forecast from %s contains no periods", source.Endpoint)
|
return fmt.Errorf("hourly forecast from %s contains no periods", source.Endpoint)
|
||||||
}
|
}
|
||||||
@@ -188,17 +225,17 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
|
func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
|
||||||
raw, source, err := b.client.fetch(ctx, "narrative", "/forecast/narrative", queryOptions{precision: true, timezone: true})
|
var narrative weatherdata.ForecastRun
|
||||||
if err != nil {
|
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||||
|
name: "narrative",
|
||||||
|
endpoint: "/forecast/narrative",
|
||||||
|
query: queryOptions{precision: true, timezone: true},
|
||||||
|
missingMessage: "narrative forecast data is missing",
|
||||||
|
}, &narrative)
|
||||||
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if raw == nil {
|
source := fetched.source
|
||||||
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.IssuedAt = &narrative.IssuedAt
|
source.IssuedAt = &narrative.IssuedAt
|
||||||
source.UpdatedAt = narrative.UpdatedAt
|
source.UpdatedAt = narrative.UpdatedAt
|
||||||
b.bundle.Narrative = &narrative
|
b.bundle.Narrative = &narrative
|
||||||
@@ -221,7 +258,7 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
var alerts weatherdata.AlertRun
|
var alerts weatherdata.AlertRun
|
||||||
if err := decodeSource(raw, &alerts); err != nil {
|
if err := decodeSource(raw, &alerts); err != nil {
|
||||||
return b.handleMalformed(&source, err, false)
|
return b.handleMalformed(&source, err, sourceRequest{name: "alerts"})
|
||||||
}
|
}
|
||||||
alerts.Raw = append(json.RawMessage(nil), raw...)
|
alerts.Raw = append(json.RawMessage(nil), raw...)
|
||||||
if alerts.AsOf != nil {
|
if alerts.AsOf != nil {
|
||||||
@@ -233,17 +270,17 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
||||||
raw, source, err := b.client.fetch(ctx, "discussion", "/discussion", queryOptions{timezone: true})
|
var discussion weatherdata.Discussion
|
||||||
if err != nil {
|
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||||
|
name: "discussion",
|
||||||
|
endpoint: "/discussion",
|
||||||
|
query: queryOptions{timezone: true},
|
||||||
|
missingMessage: "forecast discussion data is missing",
|
||||||
|
}, &discussion)
|
||||||
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if raw == nil {
|
source := fetched.source
|
||||||
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.IssuedAt = &discussion.IssuedAt
|
source.IssuedAt = &discussion.IssuedAt
|
||||||
source.UpdatedAt = discussion.UpdatedAt
|
source.UpdatedAt = discussion.UpdatedAt
|
||||||
b.bundle.Discussion = &discussion
|
b.bundle.Discussion = &discussion
|
||||||
@@ -252,17 +289,17 @@ func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
||||||
raw, source, err := b.client.fetch(ctx, "weather_story", "/weatherstories/latest", queryOptions{omitUnits: true})
|
var story weatherdata.WeatherStory
|
||||||
if err != nil {
|
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||||
|
name: "weather_story",
|
||||||
|
endpoint: "/weatherstories/latest",
|
||||||
|
query: queryOptions{omitUnits: true},
|
||||||
|
missingMessage: "NWS weather story data is missing",
|
||||||
|
}, &story)
|
||||||
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if raw == nil {
|
source := fetched.source
|
||||||
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)
|
|
||||||
}
|
|
||||||
if !story.StartTime.IsZero() {
|
if !story.StartTime.IsZero() {
|
||||||
source.IssuedAt = &story.StartTime
|
source.IssuedAt = &story.StartTime
|
||||||
}
|
}
|
||||||
@@ -273,17 +310,17 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchSPCConvectiveOutlooks(ctx context.Context) error {
|
func (b *bundleBuilder) fetchSPCConvectiveOutlooks(ctx context.Context) error {
|
||||||
raw, source, err := b.client.fetch(ctx, sourceSPCConvectiveOutlooks, convectiveOutlooksEndpoint, queryOptions{timezone: true, omitUnits: true})
|
var run weatherdata.ConvectiveOutlookRun
|
||||||
if err != nil {
|
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||||
|
name: sourceSPCConvectiveOutlooks,
|
||||||
|
endpoint: convectiveOutlooksEndpoint,
|
||||||
|
query: queryOptions{timezone: true, omitUnits: true},
|
||||||
|
missingMessage: "SPC convective outlook data is missing",
|
||||||
|
}, &run)
|
||||||
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if raw == nil {
|
source := fetched.source
|
||||||
return b.handleMissing(&source, "SPC convective outlook data is missing", false)
|
|
||||||
}
|
|
||||||
var run weatherdata.ConvectiveOutlookRun
|
|
||||||
if err := decodeSource(raw, &run); err != nil {
|
|
||||||
return b.handleMalformed(&source, err, false)
|
|
||||||
}
|
|
||||||
if run.IssuedAt != nil {
|
if run.IssuedAt != nil {
|
||||||
source.IssuedAt = run.IssuedAt
|
source.IssuedAt = run.IssuedAt
|
||||||
} else {
|
} else {
|
||||||
@@ -295,6 +332,28 @@ func (b *bundleBuilder) fetchSPCConvectiveOutlooks(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *bundleBuilder) fetchDecodedSource(ctx context.Context, request sourceRequest, target any) (fetchedSource, bool, error) {
|
||||||
|
fetched, ok, err := b.fetchSource(ctx, request)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return fetchedSource{}, false, err
|
||||||
|
}
|
||||||
|
if err := decodeSource(fetched.raw, target); err != nil {
|
||||||
|
return fetchedSource{}, false, b.handleMalformed(&fetched.source, err, request)
|
||||||
|
}
|
||||||
|
return fetched, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bundleBuilder) fetchSource(ctx context.Context, request sourceRequest) (fetchedSource, bool, error) {
|
||||||
|
raw, source, err := b.client.fetch(ctx, request.name, request.endpoint, request.query)
|
||||||
|
if err != nil {
|
||||||
|
return fetchedSource{}, false, err
|
||||||
|
}
|
||||||
|
if raw == nil {
|
||||||
|
return fetchedSource{}, false, b.handleMissing(&source, request.missingMessage, request.required)
|
||||||
|
}
|
||||||
|
return fetchedSource{raw: raw, source: source}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error {
|
func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error {
|
||||||
source.Missing = true
|
source.Missing = true
|
||||||
if required {
|
if required {
|
||||||
@@ -303,9 +362,13 @@ func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string
|
|||||||
return b.applyMissingPolicy(source, "missing_source", message)
|
return b.applyMissingPolicy(source, "missing_source", message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) handleMalformed(source *weatherdata.Source, err error, required bool) error {
|
func (b *bundleBuilder) handleMalformed(source *weatherdata.Source, err error, request sourceRequest) error {
|
||||||
if required {
|
if request.required {
|
||||||
return fmt.Errorf("decode %s from %s: %w", source.Name, source.Endpoint, err)
|
label := request.name
|
||||||
|
if request.decodeLabel != "" {
|
||||||
|
label = request.decodeLabel
|
||||||
|
}
|
||||||
|
return fmt.Errorf("decode %s from %s: %w", label, source.Endpoint, err)
|
||||||
}
|
}
|
||||||
source.Missing = true
|
source.Missing = true
|
||||||
return b.applyMissingPolicy(source, "malformed_source", fmt.Sprintf("malformed %s data: %v", source.Name, err))
|
return b.applyMissingPolicy(source, "malformed_source", fmt.Sprintf("malformed %s data: %v", source.Name, err))
|
||||||
@@ -355,24 +418,9 @@ type envelope struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string, opts queryOptions) (json.RawMessage, weatherdata.Source, error) {
|
func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string, opts queryOptions) (json.RawMessage, weatherdata.Source, error) {
|
||||||
reqURL := c.endpointURL(endpoint, opts)
|
reqURL, body, err := c.fetchHTTP(ctx, endpoint, opts)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, weatherdata.Source{}, fmt.Errorf("create request for %s: %w", endpoint, err)
|
return nil, weatherdata.Source{}, 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)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var env envelope
|
var env envelope
|
||||||
@@ -398,6 +446,169 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
|
|||||||
return env.Data, source, nil
|
return env.Data, source, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) warmup(ctx context.Context) error {
|
||||||
|
endpoint := c.warmupEndpoint
|
||||||
|
if strings.TrimSpace(endpoint) == "" {
|
||||||
|
endpoint = defaultWarmupEndpoint
|
||||||
|
}
|
||||||
|
attempts := positiveAttemptCount(c.warmupAttempts)
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 1; attempt <= attempts; attempt++ {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return fmt.Errorf("warm up weather API via %s: %w", endpoint, err)
|
||||||
|
}
|
||||||
|
if err := c.warmupOnce(ctx, endpoint); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if attempt == attempts {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err := waitForRetry(ctx, c.warmupDelay); err != nil {
|
||||||
|
return fmt.Errorf("warm up weather API via %s after %d attempt(s): %w", endpoint, attempt, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("warm up weather API via %s failed after %d attempts: %w", endpoint, attempts, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) warmupOnce(ctx context.Context, endpoint string) error {
|
||||||
|
reqURL := c.endpointURL(endpoint, queryOptions{precision: true})
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create request for %s: %w", endpoint, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetch %s: %w", endpoint, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read %s response: %w", endpoint, err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) fetchHTTP(ctx context.Context, endpoint string, opts queryOptions) (*url.URL, []byte, error) {
|
||||||
|
attempts := positiveAttemptCount(c.fetchAttempts)
|
||||||
|
var lastErr error
|
||||||
|
var lastRetryable bool
|
||||||
|
for attempt := 1; attempt <= attempts; attempt++ {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("fetch %s: %w", endpoint, err)
|
||||||
|
}
|
||||||
|
reqURL, body, err := c.fetchHTTPOnce(ctx, endpoint, opts)
|
||||||
|
if err == nil {
|
||||||
|
return reqURL, body, nil
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
lastRetryable = isRetryableRequestError(err)
|
||||||
|
if !lastRetryable || attempt == attempts {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err := waitForRetry(ctx, c.fetchRetryDelay); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("fetch %s retry delay after attempt %d: %w", endpoint, attempt, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastRetryable {
|
||||||
|
return nil, nil, fmt.Errorf("fetch %s failed after %d attempts: %w", endpoint, attempts, lastErr)
|
||||||
|
}
|
||||||
|
return nil, nil, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) fetchHTTPOnce(ctx context.Context, endpoint string, opts queryOptions) (*url.URL, []byte, error) {
|
||||||
|
reqURL := c.endpointURL(endpoint, opts)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("create request for %s: %w", endpoint, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("fetch %s: %w", endpoint, err)
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return reqURL, nil, err
|
||||||
|
}
|
||||||
|
return reqURL, nil, retryableRequestError{err: err}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("read %s response: %w", endpoint, err)
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return reqURL, nil, err
|
||||||
|
}
|
||||||
|
return reqURL, nil, retryableRequestError{err: err}
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
err := fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
if isRetryableHTTPStatus(resp.StatusCode) {
|
||||||
|
return reqURL, nil, retryableRequestError{err: err}
|
||||||
|
}
|
||||||
|
return reqURL, nil, err
|
||||||
|
}
|
||||||
|
return reqURL, body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type retryableRequestError struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e retryableRequestError) Error() string {
|
||||||
|
return e.err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e retryableRequestError) Unwrap() error {
|
||||||
|
return e.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRetryableRequestError(err error) bool {
|
||||||
|
_, ok := err.(retryableRequestError)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRetryableHTTPStatus(status int) bool {
|
||||||
|
switch status {
|
||||||
|
case http.StatusRequestTimeout,
|
||||||
|
http.StatusTooManyRequests,
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
http.StatusBadGateway,
|
||||||
|
http.StatusServiceUnavailable,
|
||||||
|
http.StatusGatewayTimeout:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForRetry(ctx context.Context, delay time.Duration) error {
|
||||||
|
if delay <= 0 {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(delay)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func positiveAttemptCount(attempts int) int {
|
||||||
|
if attempts < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return attempts
|
||||||
|
}
|
||||||
|
|
||||||
func isJSONNull(raw json.RawMessage) bool {
|
func isJSONNull(raw json.RawMessage) bool {
|
||||||
return bytes.Equal(bytes.TrimSpace(raw), []byte("null"))
|
return bytes.Equal(bytes.TrimSpace(raw), []byte("null"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,27 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
|||||||
if len(bundle.Warnings) != 0 {
|
if len(bundle.Warnings) != 0 {
|
||||||
t.Fatalf("Warnings length = %d, want no warnings", len(bundle.Warnings))
|
t.Fatalf("Warnings length = %d, want no warnings", len(bundle.Warnings))
|
||||||
}
|
}
|
||||||
|
wantPaths := []string{
|
||||||
|
"/observations",
|
||||||
|
"/conditions/current",
|
||||||
|
"/forecast/hourly",
|
||||||
|
"/forecast/narrative",
|
||||||
|
"/alerts/active",
|
||||||
|
"/discussion",
|
||||||
|
"/weatherstories/latest",
|
||||||
|
convectiveOutlooksEndpoint,
|
||||||
|
}
|
||||||
|
if len(requested) != len(wantPaths)+1 {
|
||||||
|
t.Fatalf("requested paths = %v, want warmup plus %d source endpoints", requested, len(wantPaths))
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(requested[0], defaultWarmupEndpoint+"?") && requested[0] != defaultWarmupEndpoint {
|
||||||
|
t.Fatalf("first requested path = %q, want warmup endpoint %s", requested[0], defaultWarmupEndpoint)
|
||||||
|
}
|
||||||
|
for _, want := range wantPaths {
|
||||||
|
if !containsPath(requested, want) {
|
||||||
|
t.Fatalf("requested paths = %v, want %s", requested, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
|
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
|
||||||
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
|
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
|
||||||
}
|
}
|
||||||
@@ -114,9 +135,15 @@ func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
|||||||
t.Fatalf("request %q missing units=us", rawURL)
|
t.Fatalf("request %q missing units=us", rawURL)
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(rawURL, "/forecast/") {
|
if strings.HasPrefix(rawURL, "/forecast/") {
|
||||||
if !strings.Contains(rawURL, "precision=1") || !strings.Contains(rawURL, "tz=America%2FChicago") {
|
if !strings.Contains(rawURL, "precision=0") || !strings.Contains(rawURL, "tz=America%2FChicago") {
|
||||||
t.Fatalf("forecast request %q missing precision or tz", rawURL)
|
t.Fatalf("forecast request %q missing precision or tz", rawURL)
|
||||||
}
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,7 +195,7 @@ func TestFetchBundleRecordsSourceHash(t *testing.T) {
|
|||||||
|
|
||||||
func TestHTTPErrorIsActionable(t *testing.T) {
|
func TestHTTPErrorIsActionable(t *testing.T) {
|
||||||
server := fixtureServer(t, map[string]handlerOverride{
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
"/conditions/current": {status: http.StatusBadGateway, body: `upstream failed`},
|
"/forecast/hourly": {status: http.StatusBadGateway, body: `upstream failed`},
|
||||||
}, nil)
|
}, nil)
|
||||||
client := newTestClient(t, server.URL+"/", nil)
|
client := newTestClient(t, server.URL+"/", nil)
|
||||||
|
|
||||||
@@ -176,15 +203,140 @@ func TestHTTPErrorIsActionable(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("FetchBundle() error = nil, want HTTP error")
|
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())
|
t.Fatalf("error = %q, want endpoint and status", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWarmupRetriesBeforeFetchBundle(t *testing.T) {
|
||||||
|
var requested []string
|
||||||
|
var warmupCalls int
|
||||||
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
|
defaultWarmupEndpoint: {handler: func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
warmupCalls++
|
||||||
|
if warmupCalls == 1 {
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
_, _ = w.Write([]byte("vpn waking up"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.ServeFile(w, r, filepath.Join("testdata", "current.json"))
|
||||||
|
}},
|
||||||
|
}, &requested)
|
||||||
|
client := newTestClient(t, server.URL+"/", nil)
|
||||||
|
|
||||||
|
bundle, err := client.FetchBundle(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchBundle() error = %v", err)
|
||||||
|
}
|
||||||
|
if bundle.Current == nil {
|
||||||
|
t.Fatal("Current = nil, want successful fetch after warmup retry")
|
||||||
|
}
|
||||||
|
if warmupCalls != 3 {
|
||||||
|
t.Fatalf("conditions/current calls = %d, want failed warmup, successful warmup, and current source fetch", warmupCalls)
|
||||||
|
}
|
||||||
|
if len(requested) < 2 || !containsPath(requested[:2], defaultWarmupEndpoint) {
|
||||||
|
t.Fatalf("initial requests = %v, want warmup endpoint retries", requested)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWarmupFailureStopsBeforeSourceFetches(t *testing.T) {
|
||||||
|
var requested []string
|
||||||
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
|
defaultWarmupEndpoint: {status: http.StatusBadGateway, body: `vpn unavailable`},
|
||||||
|
}, &requested)
|
||||||
|
client := newTestClient(t, server.URL+"/", nil)
|
||||||
|
client.warmupAttempts = 2
|
||||||
|
|
||||||
|
_, err := client.FetchBundle(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("FetchBundle() error = nil, want warmup failure")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "warm up weather API") ||
|
||||||
|
!strings.Contains(err.Error(), defaultWarmupEndpoint) ||
|
||||||
|
!strings.Contains(err.Error(), "2 attempts") ||
|
||||||
|
!strings.Contains(err.Error(), "502") {
|
||||||
|
t.Fatalf("error = %q, want warmup endpoint, attempts, and status", err.Error())
|
||||||
|
}
|
||||||
|
if got := countPath(requested, defaultWarmupEndpoint); got != 2 {
|
||||||
|
t.Fatalf("warmup requests = %d, want 2; all requests = %v", got, requested)
|
||||||
|
}
|
||||||
|
if containsPath(requested, "/observations") {
|
||||||
|
t.Fatalf("requested paths = %v, want warmup failure before source fetches", requested)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchRetriesRetryableStatus(t *testing.T) {
|
||||||
|
var hourlyCalls int
|
||||||
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
|
"/forecast/hourly": {handler: func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hourlyCalls++
|
||||||
|
if hourlyCalls == 1 {
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
_, _ = w.Write([]byte("temporary upstream failure"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.ServeFile(w, r, filepath.Join("testdata", "hourly.json"))
|
||||||
|
}},
|
||||||
|
}, nil)
|
||||||
|
client := newTestClient(t, server.URL+"/", nil)
|
||||||
|
|
||||||
|
bundle, err := client.FetchBundle(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchBundle() error = %v", err)
|
||||||
|
}
|
||||||
|
if bundle.Hourly == nil {
|
||||||
|
t.Fatal("Hourly = nil, want successful fetch after retry")
|
||||||
|
}
|
||||||
|
if hourlyCalls != 2 {
|
||||||
|
t.Fatalf("hourly calls = %d, want 2", hourlyCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchDoesNotRetryNonRetryableStatus(t *testing.T) {
|
||||||
|
var hourlyCalls int
|
||||||
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
|
"/forecast/hourly": {handler: func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hourlyCalls++
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte("not found"))
|
||||||
|
}},
|
||||||
|
}, nil)
|
||||||
|
client := newTestClient(t, server.URL+"/", nil)
|
||||||
|
|
||||||
|
_, err := client.FetchBundle(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("FetchBundle() error = nil, want non-retryable status error")
|
||||||
|
}
|
||||||
|
if hourlyCalls != 1 {
|
||||||
|
t.Fatalf("hourly calls = %d, want no retry", hourlyCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchDoesNotRetryMalformedEnvelope(t *testing.T) {
|
||||||
|
var hourlyCalls int
|
||||||
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
|
"/forecast/hourly": {handler: func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hourlyCalls++
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`not-json`))
|
||||||
|
}},
|
||||||
|
}, nil)
|
||||||
|
client := newTestClient(t, server.URL+"/", nil)
|
||||||
|
|
||||||
|
_, err := client.FetchBundle(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("FetchBundle() error = nil, want envelope decode error")
|
||||||
|
}
|
||||||
|
if hourlyCalls != 1 {
|
||||||
|
t.Fatalf("hourly calls = %d, want no retry", hourlyCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRequiredHourlyForecast(t *testing.T) {
|
func TestRequiredHourlyForecast(t *testing.T) {
|
||||||
|
var requested []string
|
||||||
server := fixtureServer(t, map[string]handlerOverride{
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
"/forecast/hourly": {status: http.StatusOK, body: `{"data": null}`},
|
"/forecast/hourly": {status: http.StatusOK, body: `{"data": null}`},
|
||||||
}, nil)
|
}, &requested)
|
||||||
client := newTestClient(t, server.URL+"/", nil)
|
client := newTestClient(t, server.URL+"/", nil)
|
||||||
|
|
||||||
_, err := client.FetchBundle(context.Background())
|
_, err := client.FetchBundle(context.Background())
|
||||||
@@ -194,6 +346,9 @@ func TestRequiredHourlyForecast(t *testing.T) {
|
|||||||
if !strings.Contains(err.Error(), "hourly forecast data") {
|
if !strings.Contains(err.Error(), "hourly forecast data") {
|
||||||
t.Fatalf("error = %q, want hourly context", err.Error())
|
t.Fatalf("error = %q, want hourly context", err.Error())
|
||||||
}
|
}
|
||||||
|
if got := countPath(requested, "/forecast/hourly"); got != 1 {
|
||||||
|
t.Fatalf("hourly requests = %d, want no retry; all requests = %v", got, requested)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNullAlertsMeansNoActiveAlerts(t *testing.T) {
|
func TestNullAlertsMeansNoActiveAlerts(t *testing.T) {
|
||||||
@@ -402,6 +557,43 @@ func TestContextCancellation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRetryDelayRespectsContextCancellation(t *testing.T) {
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
var hourlyCalls int
|
||||||
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
|
"/forecast/hourly": {handler: func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hourlyCalls++
|
||||||
|
if cancel != nil {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
_, _ = w.Write([]byte("temporary upstream failure"))
|
||||||
|
}},
|
||||||
|
}, nil)
|
||||||
|
client := newTestClient(t, server.URL+"/", nil)
|
||||||
|
client.fetchRetryDelay = time.Hour
|
||||||
|
|
||||||
|
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||||
|
cancel = cancelFunc
|
||||||
|
defer cancelFunc()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_, err := client.FetchBundle(ctx)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("FetchBundle() error = nil, want cancellation during retry delay")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), context.Canceled.Error()) {
|
||||||
|
t.Fatalf("error = %q, want context cancellation", err.Error())
|
||||||
|
}
|
||||||
|
if elapsed > time.Second {
|
||||||
|
t.Fatalf("FetchBundle() elapsed = %s, want prompt cancellation", elapsed)
|
||||||
|
}
|
||||||
|
if hourlyCalls != 1 {
|
||||||
|
t.Fatalf("hourly calls = %d, want retry delay cancellation before second attempt", hourlyCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHTTPTimeout(t *testing.T) {
|
func TestHTTPTimeout(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
time.Sleep(50 * time.Millisecond)
|
time.Sleep(50 * time.Millisecond)
|
||||||
@@ -414,12 +606,14 @@ func TestHTTPTimeout(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New() error = %v", err)
|
t.Fatalf("New() error = %v", err)
|
||||||
}
|
}
|
||||||
|
client.warmupDelay = 0
|
||||||
|
client.fetchRetryDelay = 0
|
||||||
|
|
||||||
_, err = client.FetchBundle(context.Background())
|
_, err = client.FetchBundle(context.Background())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("FetchBundle() error = nil, want timeout error")
|
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())
|
t.Fatalf("error = %q, want endpoint context", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -446,8 +640,9 @@ func TestSaveBundle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type handlerOverride struct {
|
type handlerOverride struct {
|
||||||
status int
|
status int
|
||||||
body string
|
body string
|
||||||
|
handler http.HandlerFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server {
|
func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server {
|
||||||
@@ -467,6 +662,10 @@ func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested
|
|||||||
*requested = append(*requested, r.URL.String())
|
*requested = append(*requested, r.URL.String())
|
||||||
}
|
}
|
||||||
if override, ok := overrides[r.URL.Path]; ok {
|
if override, ok := overrides[r.URL.Path]; ok {
|
||||||
|
if override.handler != nil {
|
||||||
|
override.handler(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
w.WriteHeader(override.status)
|
w.WriteHeader(override.status)
|
||||||
_, _ = w.Write([]byte(override.body))
|
_, _ = w.Write([]byte(override.body))
|
||||||
return
|
return
|
||||||
@@ -492,6 +691,8 @@ func newTestClient(t *testing.T, baseURL string, sourcePolicies map[string]confi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New() error = %v", err)
|
t.Fatalf("New() error = %v", err)
|
||||||
}
|
}
|
||||||
|
client.warmupDelay = 0
|
||||||
|
client.fetchRetryDelay = 0
|
||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,6 +715,16 @@ func containsPath(requested []string, path string) bool {
|
|||||||
return false
|
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 {
|
func sourceByName(t *testing.T, sources []weatherdata.Source, name string) weatherdata.Source {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, source := range sources {
|
for _, source := range sources {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
85
internal/app/batch_execution_test.go
Normal file
85
internal/app/batch_execution_test.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunBatchDetailedInspectsEveryCandidateBeforeCollection(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
batch BatchKind
|
||||||
|
now string
|
||||||
|
wantPrompts int
|
||||||
|
}{
|
||||||
|
{name: "morning", batch: BatchMorning, now: "2026-05-29T08:00:00-05:00", wantPrompts: 3},
|
||||||
|
{name: "evening", batch: BatchEvening, now: "2026-05-29T18:00:00-05:00", wantPrompts: 2},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.Workspace.Root = t.TempDir()
|
||||||
|
now := mustParse(test.now)
|
||||||
|
req := BatchRequest{Config: cfg, Batch: test.batch, Now: now}
|
||||||
|
candidates, err := batchInspectionCandidates(req, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("batchInspectionCandidates() error = %v", err)
|
||||||
|
}
|
||||||
|
executor := &inspectionExecutor{profiles: map[string]promptexec.ProfileInspection{
|
||||||
|
"default-profile": {ProfileID: "default-profile", BackendID: "local", ModelName: "model"},
|
||||||
|
}, prompts: map[string]promptexec.PromptInspection{}}
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
executor.prompts[candidate.Definition.PromptID] = validPromptInspection(candidate.Definition)
|
||||||
|
}
|
||||||
|
collector := collectorFunc(func(context.Context, collect.Request) (*collect.Result, error) {
|
||||||
|
return nil, errors.New("collection reached")
|
||||||
|
})
|
||||||
|
req.Executor = executor
|
||||||
|
req.Collector = collector
|
||||||
|
_, err = RunBatchDetailed(context.Background(), req)
|
||||||
|
if err == nil || err.Error() != "collection reached" {
|
||||||
|
t.Fatalf("RunBatchDetailed() error = %v, want collection error", err)
|
||||||
|
}
|
||||||
|
if len(executor.promptRequests) != test.wantPrompts || len(executor.profileRequests) != 1 {
|
||||||
|
t.Fatalf("inspection calls = prompts %#v profiles %#v", executor.promptRequests, executor.profileRequests)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyBatchReportPathsLeavesUnreachedPathsEmpty(t *testing.T) {
|
||||||
|
item := BatchReportResult{}
|
||||||
|
copyBatchReportPaths(&item, &ReportResult{
|
||||||
|
DataPackagePath: "/runs/daily/data_package.yaml",
|
||||||
|
PreparationPath: "/runs/daily/preparation.json",
|
||||||
|
})
|
||||||
|
|
||||||
|
data, err := json.Marshal(item)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
for _, omitted := range []string{"executionPath", "reportPath", "outputPath", "metadataPath", "notificationPath"} {
|
||||||
|
if strings.Contains(text, omitted) {
|
||||||
|
t.Fatalf("batch item includes unreached field %q:\n%s", omitted, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, "dataPackagePath") || !strings.Contains(text, "preparationPath") {
|
||||||
|
t.Fatalf("batch item omits reached paths:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type collectorFunc func(context.Context, collect.Request) (*collect.Result, error)
|
||||||
|
|
||||||
|
func (f collectorFunc) Run(ctx context.Context, req collect.Request) (*collect.Result, error) {
|
||||||
|
return f(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Collector = collectorFunc(nil)
|
||||||
379
internal/app/batch_notification.go
Normal file
379
internal/app/batch_notification.go
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"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/state"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
const runIDTimestampLayout = "20060102T150405.000000000Z"
|
||||||
|
|
||||||
|
type batchNotificationIdentity struct {
|
||||||
|
PipelineID string
|
||||||
|
BundleID string
|
||||||
|
IdempotencyKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
type batchNotificationRequest struct {
|
||||||
|
Batch BatchKind
|
||||||
|
RunID string
|
||||||
|
PipelineID string
|
||||||
|
BundleID string
|
||||||
|
IdempotencyKey string
|
||||||
|
Files []batchNotificationFile
|
||||||
|
IncludedReports []BatchNotificationReport
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type batchNotificationFile struct {
|
||||||
|
ReportID report.ID
|
||||||
|
RunID string
|
||||||
|
SourcePath string
|
||||||
|
BundlePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type batchNotifier interface {
|
||||||
|
NotifyBatch(context.Context, batchNotificationRequest) (*NotificationResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchRunID(startedAt time.Time, batch BatchKind) string {
|
||||||
|
return startedAt.UTC().Format(runIDTimestampLayout) + "_" + string(batch)
|
||||||
|
}
|
||||||
|
|
||||||
|
func notifyBatch(ctx context.Context, cfg config.Config, batch BatchKind, runID string, startedAt time.Time, result *BatchResult, planned []plannedBatchReport, store state.Store, notifier Notifier) (*BatchNotificationResult, error) {
|
||||||
|
if !cfg.Notify.Distributor.Enabled {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if !cfg.Notify.Distributor.Batch.Enabled {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
return nil, fmt.Errorf("batch result is required")
|
||||||
|
}
|
||||||
|
if result.Failed > 0 {
|
||||||
|
return &BatchNotificationResult{
|
||||||
|
Status: "skipped",
|
||||||
|
Reason: "one or more reports failed",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := buildBatchNotificationRequest(cfg, batch, runID, startedAt, result.Reports, planned)
|
||||||
|
if err != nil {
|
||||||
|
path, saveErr := saveBatchNotificationArtifact(ctx, store, cfg, batch, runID, startedAt, batchNotificationRequest{}, nil, err)
|
||||||
|
if saveErr != nil {
|
||||||
|
return nil, saveErr
|
||||||
|
}
|
||||||
|
return failedBatchNotificationResult(batchNotificationRequest{}, path, err), err
|
||||||
|
}
|
||||||
|
|
||||||
|
batchNotifier, err := resolveBatchNotifier(cfg, notifier)
|
||||||
|
if err != nil {
|
||||||
|
path, saveErr := saveBatchNotificationArtifact(ctx, store, cfg, batch, runID, startedAt, req, nil, err)
|
||||||
|
if saveErr != nil {
|
||||||
|
return nil, saveErr
|
||||||
|
}
|
||||||
|
return failedBatchNotificationResult(req, path, err), err
|
||||||
|
}
|
||||||
|
|
||||||
|
notification, notifyErr := batchNotifier.NotifyBatch(ctx, req)
|
||||||
|
wrappedErr := notifyErr
|
||||||
|
if notifyErr != nil {
|
||||||
|
wrappedErr = fmt.Errorf("notify batch %q run %q bundle %q: %w", batch, runID, req.BundleID, notifyErr)
|
||||||
|
}
|
||||||
|
path, saveErr := saveBatchNotificationArtifact(ctx, store, cfg, batch, runID, startedAt, req, notification, wrappedErr)
|
||||||
|
if saveErr != nil {
|
||||||
|
return nil, saveErr
|
||||||
|
}
|
||||||
|
|
||||||
|
batchResult := batchNotificationResult(req, notification, path)
|
||||||
|
if wrappedErr != nil {
|
||||||
|
batchResult.Status = "failed"
|
||||||
|
batchResult.Error = wrappedErr.Error()
|
||||||
|
return batchResult, wrappedErr
|
||||||
|
}
|
||||||
|
return batchResult, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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.ReportPath == "" {
|
||||||
|
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q is missing managed report path", item.ReportID, item.RunID)
|
||||||
|
}
|
||||||
|
|
||||||
|
values, err := distributorTemplateValuesForReport(cfg, plannedReport.Resolved, item.RunID, plannedReport.OutputCopyName)
|
||||||
|
if err != nil {
|
||||||
|
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q source path %q: %w", item.ReportID, item.RunID, item.ReportPath, err)
|
||||||
|
}
|
||||||
|
bundlePaths, err := renderDistributorReportBundlePaths(cfg, plannedReport.Resolved, item.RunID, item.ReportPath, values)
|
||||||
|
if err != nil {
|
||||||
|
return batchNotificationRequest{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
included := BatchNotificationReport{
|
||||||
|
ReportID: item.ReportID,
|
||||||
|
RunID: item.RunID,
|
||||||
|
SourcePath: item.ReportPath,
|
||||||
|
BundlePaths: append([]string(nil), bundlePaths...),
|
||||||
|
}
|
||||||
|
for _, bundlePath := range bundlePaths {
|
||||||
|
file := batchNotificationFile{
|
||||||
|
ReportID: item.ReportID,
|
||||||
|
RunID: item.RunID,
|
||||||
|
SourcePath: item.ReportPath,
|
||||||
|
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.ReportPath, 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, path string) *BatchNotificationResult {
|
||||||
|
notification := &BatchNotificationResult{
|
||||||
|
Status: "unknown",
|
||||||
|
PipelineID: req.PipelineID,
|
||||||
|
BundleID: req.BundleID,
|
||||||
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
|
Path: path,
|
||||||
|
IncludedReports: append([]BatchNotificationReport(nil), req.IncludedReports...),
|
||||||
|
}
|
||||||
|
if result != nil {
|
||||||
|
notification.Status = result.Status
|
||||||
|
notification.RunID = result.RunID
|
||||||
|
if result.PipelineID != "" {
|
||||||
|
notification.PipelineID = result.PipelineID
|
||||||
|
}
|
||||||
|
if result.BundleID != "" {
|
||||||
|
notification.BundleID = result.BundleID
|
||||||
|
}
|
||||||
|
if result.IdempotencyKey != "" {
|
||||||
|
notification.IdempotencyKey = result.IdempotencyKey
|
||||||
|
}
|
||||||
|
if result.Error != "" {
|
||||||
|
notification.Error = result.Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if notification.Status == "" {
|
||||||
|
notification.Status = "unknown"
|
||||||
|
}
|
||||||
|
return notification
|
||||||
|
}
|
||||||
|
|
||||||
|
func failedBatchNotificationResult(req batchNotificationRequest, path string, err error) *BatchNotificationResult {
|
||||||
|
notification := batchNotificationResult(req, nil, path)
|
||||||
|
notification.Status = "failed"
|
||||||
|
if err != nil {
|
||||||
|
notification.Error = err.Error()
|
||||||
|
}
|
||||||
|
return notification
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveBatchNotificationArtifact(ctx context.Context, store state.Store, cfg config.Config, batch BatchKind, runID string, startedAt time.Time, req batchNotificationRequest, result *NotificationResult, notifyErr error) (string, error) {
|
||||||
|
if store == nil {
|
||||||
|
return "", fmt.Errorf("state store is required")
|
||||||
|
}
|
||||||
|
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("load batch notification timezone: %w", err)
|
||||||
|
}
|
||||||
|
artifact := state.BatchDistributorNotificationArtifact{
|
||||||
|
SchemaVersion: state.BatchDistributorNotificationSchemaVersion,
|
||||||
|
Batch: string(batch),
|
||||||
|
BatchRunID: runID,
|
||||||
|
AttemptedAt: time.Now(),
|
||||||
|
Endpoint: cfg.Notify.Distributor.Endpoint,
|
||||||
|
PipelineID: req.PipelineID,
|
||||||
|
BundleID: req.BundleID,
|
||||||
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
|
BundleCreated: req.CreatedAt,
|
||||||
|
Reports: batchNotificationReportArtifacts(req.IncludedReports),
|
||||||
|
Status: "attempted",
|
||||||
|
}
|
||||||
|
if result != nil {
|
||||||
|
artifact.Status = result.Status
|
||||||
|
artifact.Upload = &state.DistributorUploadResult{
|
||||||
|
RunID: result.RunID,
|
||||||
|
Status: result.UploadStatus,
|
||||||
|
}
|
||||||
|
if result.PipelineID != "" || !result.AcceptedAt.IsZero() || result.StartedAt != nil || result.FinishedAt != nil || len(result.Report) > 0 || result.Error != "" {
|
||||||
|
artifact.RunStatus = &state.DistributorRunStatus{
|
||||||
|
RunID: result.RunID,
|
||||||
|
PipelineID: result.PipelineID,
|
||||||
|
Status: result.Status,
|
||||||
|
AcceptedAt: result.AcceptedAt,
|
||||||
|
StartedAt: result.StartedAt,
|
||||||
|
FinishedAt: result.FinishedAt,
|
||||||
|
Report: append([]byte(nil), result.Report...),
|
||||||
|
Error: result.Error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
artifact.StatusError = result.StatusError
|
||||||
|
}
|
||||||
|
if notifyErr != nil {
|
||||||
|
artifact.Status = "failed"
|
||||||
|
artifact.Error = notifyErr.Error()
|
||||||
|
}
|
||||||
|
if artifact.Status == "" {
|
||||||
|
artifact.Status = "unknown"
|
||||||
|
}
|
||||||
|
return store.SaveBatchDistributorNotification(ctx, state.BatchDistributorNotificationRef{
|
||||||
|
Batch: string(batch),
|
||||||
|
BatchRunID: runID,
|
||||||
|
StartedAt: startedAt,
|
||||||
|
Location: location,
|
||||||
|
}, artifact)
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchNotificationReportArtifacts(reports []BatchNotificationReport) []state.BatchDistributorNotificationReportArtifact {
|
||||||
|
if len(reports) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
artifacts := make([]state.BatchDistributorNotificationReportArtifact, 0, len(reports))
|
||||||
|
for _, item := range reports {
|
||||||
|
artifacts = append(artifacts, state.BatchDistributorNotificationReportArtifact{
|
||||||
|
ReportID: item.ReportID,
|
||||||
|
RunID: item.RunID,
|
||||||
|
SourcePath: item.SourcePath,
|
||||||
|
BundlePaths: append([]string(nil), item.BundlePaths...),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return artifacts
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
139
internal/app/batch_plan.go
Normal file
139
internal/app/batch_plan.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
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
|
||||||
|
OutputCopyName 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
|
||||||
|
outputCopyName := "daily-" + date.In(location).Format(timeutil.DateLayout) + ".md"
|
||||||
|
planned, err = appendPlannedReport(planned, registry, report.Daily, dailyReq, outputCopyName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return planned, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendPlannedReport(planned []plannedBatchReport, registry report.Registry, id report.ID, req report.ResolveRequest, outputCopyName string) ([]plannedBatchReport, error) {
|
||||||
|
resolved, err := registry.Resolve(id, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return append(planned, plannedBatchReport{
|
||||||
|
Resolved: resolved,
|
||||||
|
OutputCopyName: outputCopyName,
|
||||||
|
}), 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)
|
||||||
|
}
|
||||||
339
internal/app/batch_plan_test.go
Normal file
339
internal/app/batch_plan_test.go
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
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 TestPlanBatchRunDynamicDailyOutputCopyNames(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)
|
||||||
|
}
|
||||||
|
if daily[0].OutputCopyName != "daily-2026-05-31.md" {
|
||||||
|
t.Fatalf("OutputCopyName = %q, want date-qualified Daily name", daily[0].OutputCopyName)
|
||||||
|
}
|
||||||
|
if planned[0].OutputCopyName != "" {
|
||||||
|
t.Fatalf("Tomorrow OutputCopyName = %q, want definition batch output name to apply later", planned[0].OutputCopyName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
500
internal/app/batch_workflow_test.go
Normal file
500
internal/app/batch_workflow_test.go
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type assembledBatchExecutor struct {
|
||||||
|
definitions map[string]report.Definition
|
||||||
|
promptRequests []string
|
||||||
|
profileRequests []string
|
||||||
|
executeRequests []promptexec.ExecuteRequest
|
||||||
|
failures map[int]error
|
||||||
|
active int
|
||||||
|
maxActive int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAssembledBatchExecutor() *assembledBatchExecutor {
|
||||||
|
definitions := make(map[string]report.Definition)
|
||||||
|
for _, definition := range report.DefaultRegistry().All() {
|
||||||
|
definitions[definition.PromptID] = definition
|
||||||
|
}
|
||||||
|
return &assembledBatchExecutor{definitions: definitions, failures: make(map[int]error)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *assembledBatchExecutor) InspectPrompt(_ context.Context, id, version string) (promptexec.PromptInspection, error) {
|
||||||
|
e.promptRequests = append(e.promptRequests, id+"@"+version)
|
||||||
|
definition, ok := e.definitions[id]
|
||||||
|
if !ok || definition.PromptVersion != version {
|
||||||
|
return promptexec.PromptInspection{}, errors.New("unexpected prompt inspection")
|
||||||
|
}
|
||||||
|
return validPromptInspection(definition), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *assembledBatchExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) {
|
||||||
|
e.profileRequests = append(e.profileRequests, id)
|
||||||
|
return promptexec.ProfileInspection{ProfileID: id, BackendID: "fixture", ModelName: "fixture-model"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *assembledBatchExecutor) Execute(_ context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {
|
||||||
|
call := len(e.executeRequests)
|
||||||
|
e.executeRequests = append(e.executeRequests, req)
|
||||||
|
e.active++
|
||||||
|
if e.active > e.maxActive {
|
||||||
|
e.maxActive = e.active
|
||||||
|
}
|
||||||
|
defer func() { e.active-- }()
|
||||||
|
stamp := time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
|
||||||
|
preparation := promptexec.Preparation{
|
||||||
|
PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: "prompt-hash",
|
||||||
|
RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "fixture",
|
||||||
|
ModelName: "fixture-model", DataPackagePath: req.DataPackagePath, StartedAt: stamp, EndedAt: stamp,
|
||||||
|
}
|
||||||
|
if err := callback(preparation, nil); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := e.failures[call]; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
definition := e.definitions[req.PromptID]
|
||||||
|
return &promptexec.Execution{
|
||||||
|
RunID: "provider-run", PromptID: req.PromptID, PromptVersion: req.PromptVersion,
|
||||||
|
PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID,
|
||||||
|
BackendID: "fixture", ModelName: "fixture-model", GeneratedHash: "generated-hash",
|
||||||
|
StartedAt: stamp, EndedAt: stamp, DataPackagePath: req.DataPackagePath,
|
||||||
|
RawOutput: []byte(generatedTextForPrompt(req.PromptID)),
|
||||||
|
Validation: promptexec.NewValidation(promptexec.ValidationPassed, "json_schema", definition.GeneratedTextSchemaID+".generated_text.schema.json", nil),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type assembledBatchNotifier struct {
|
||||||
|
reportRequests []NotificationRequest
|
||||||
|
batchRequests []batchNotificationRequest
|
||||||
|
batchResult *NotificationResult
|
||||||
|
batchErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *assembledBatchNotifier) Notify(_ context.Context, req NotificationRequest) (*NotificationResult, error) {
|
||||||
|
n.reportRequests = append(n.reportRequests, req)
|
||||||
|
return nil, errors.New("per-report notification must be suppressed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *assembledBatchNotifier) NotifyBatch(_ context.Context, req batchNotificationRequest) (*NotificationResult, error) {
|
||||||
|
n.batchRequests = append(n.batchRequests, req)
|
||||||
|
if n.batchErr != nil {
|
||||||
|
return nil, n.batchErr
|
||||||
|
}
|
||||||
|
if n.batchResult != nil {
|
||||||
|
result := *n.batchResult
|
||||||
|
if result.PipelineID == "" {
|
||||||
|
result.PipelineID = req.PipelineID
|
||||||
|
}
|
||||||
|
if result.BundleID == "" {
|
||||||
|
result.BundleID = req.BundleID
|
||||||
|
}
|
||||||
|
if result.IdempotencyKey == "" {
|
||||||
|
result.IdempotencyKey = req.IdempotencyKey
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
return &NotificationResult{
|
||||||
|
RunID: "batch-notification-run", PipelineID: req.PipelineID, BundleID: req.BundleID,
|
||||||
|
IdempotencyKey: req.IdempotencyKey, Status: "succeeded", UploadStatus: "accepted",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBatchDetailedExecutesRetainedReportsSequentially(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
batch BatchKind
|
||||||
|
now time.Time
|
||||||
|
wantIDs []report.ID
|
||||||
|
wantCopies []string
|
||||||
|
}{
|
||||||
|
{name: "morning", batch: BatchMorning, now: workflowTime("2026-05-29T08:00:00-05:00"), wantIDs: []report.ID{report.Today, report.Tomorrow, report.Daily}, wantCopies: []string{"today.md", "tomorrow.md", "daily-2026-05-31.md"}},
|
||||||
|
{name: "evening", batch: BatchEvening, now: workflowTime("2026-05-29T18:00:00-05:00"), wantIDs: []report.ID{report.Tomorrow, report.Daily}, wantCopies: []string{"tomorrow.md", "daily-2026-05-31.md"}},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
cfg := assembledBatchConfig(t, false)
|
||||||
|
bundle := assembledBatchBundle(t, "2026-05-31")
|
||||||
|
collector := &workflowCollector{result: &collect.Result{Bundle: &bundle}}
|
||||||
|
executor := newAssembledBatchExecutor()
|
||||||
|
outputDir := filepath.Join(t.TempDir(), "output")
|
||||||
|
debugRoot := filepath.Join(t.TempDir(), "debug")
|
||||||
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
|
Config: cfg, Batch: test.batch, Now: test.now, OutputDir: outputDir, LLMDebugDir: debugRoot,
|
||||||
|
Collector: collector, Executor: executor,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RunBatchDetailed() error = %v", err)
|
||||||
|
}
|
||||||
|
if collector.calls != 1 || result.Total != len(test.wantIDs) || result.Succeeded != len(test.wantIDs) || result.Failed != 0 {
|
||||||
|
t.Fatalf("collection/summary = %d/%d/%d/%d", collector.calls, result.Total, result.Succeeded, result.Failed)
|
||||||
|
}
|
||||||
|
if len(executor.executeRequests) != len(test.wantIDs) || executor.maxActive != 1 {
|
||||||
|
t.Fatalf("executor calls/max active = %d/%d, want %d/1", len(executor.executeRequests), executor.maxActive, len(test.wantIDs))
|
||||||
|
}
|
||||||
|
if len(executor.profileRequests) != 1 {
|
||||||
|
t.Fatalf("profile inspections = %#v, want one shared profile inspection", executor.profileRequests)
|
||||||
|
}
|
||||||
|
for index, item := range result.Reports {
|
||||||
|
if item.ReportID != test.wantIDs[index] || item.Status != "succeeded" {
|
||||||
|
t.Fatalf("report %d = %s/%s, want %s/succeeded", index, item.ReportID, item.Status, test.wantIDs[index])
|
||||||
|
}
|
||||||
|
if executor.executeRequests[index].PromptID != item.PromptID {
|
||||||
|
t.Fatalf("execution %d prompt = %q, want item prompt %q", index, executor.executeRequests[index].PromptID, item.PromptID)
|
||||||
|
}
|
||||||
|
if item.DataPackagePath == "" || item.PreparationPath == "" || item.ExecutionPath == "" || item.LLMDebugPath == "" || item.ReportPath == "" || item.OutputPath == "" || item.MetadataPath == "" {
|
||||||
|
t.Fatalf("successful report paths = %#v", item)
|
||||||
|
}
|
||||||
|
assertBatchItemMatchesMetadata(t, item)
|
||||||
|
if filepath.Base(item.OutputPath) != test.wantCopies[index] {
|
||||||
|
t.Fatalf("output copy = %q, want %q", item.OutputPath, test.wantCopies[index])
|
||||||
|
}
|
||||||
|
assertBatchPathsExist(t, item.DataPackagePath, item.PreparationPath, item.ExecutionPath, item.LLMDebugPath, item.ReportPath, item.OutputPath, item.MetadataPath)
|
||||||
|
managed, readErr := os.ReadFile(item.ReportPath)
|
||||||
|
if readErr != nil {
|
||||||
|
t.Fatalf("read managed report: %v", readErr)
|
||||||
|
}
|
||||||
|
copied, readErr := os.ReadFile(item.OutputPath)
|
||||||
|
if readErr != nil || !bytes.Equal(managed, copied) {
|
||||||
|
t.Fatalf("output copy mismatch/error = %v", readErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBatchDetailedContinuesAfterCapacityRejection(t *testing.T) {
|
||||||
|
cfg := assembledBatchConfig(t, true)
|
||||||
|
bundle := assembledBatchBundle(t, "2026-05-31")
|
||||||
|
collector := &workflowCollector{result: &collect.Result{Bundle: &bundle}}
|
||||||
|
executor := newAssembledBatchExecutor()
|
||||||
|
executor.failures[1] = promptexec.NewError(promptexec.Capacity, "capacity rejected", nil)
|
||||||
|
notifier := &assembledBatchNotifier{}
|
||||||
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
|
Config: cfg, Batch: BatchMorning, Now: workflowTime("2026-05-29T08:00:00-05:00"),
|
||||||
|
OutputDir: filepath.Join(t.TempDir(), "output"), LLMDebugDir: filepath.Join(t.TempDir(), "debug"),
|
||||||
|
Collector: collector, Executor: executor, Notifier: notifier,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RunBatchDetailed() error = %v", err)
|
||||||
|
}
|
||||||
|
if collector.calls != 1 || len(executor.executeRequests) != 3 || result.Total != 3 || result.Succeeded != 2 || result.Failed != 1 {
|
||||||
|
t.Fatalf("collection/execution/summary = %d/%d/%d/%d/%d", collector.calls, len(executor.executeRequests), result.Total, result.Succeeded, result.Failed)
|
||||||
|
}
|
||||||
|
if len(notifier.reportRequests) != 0 || len(notifier.batchRequests) != 0 || result.Notification == nil || result.Notification.Status != "skipped" {
|
||||||
|
t.Fatalf("notification state = reports %d batches %d result %#v", len(notifier.reportRequests), len(notifier.batchRequests), result.Notification)
|
||||||
|
}
|
||||||
|
for index, item := range result.Reports {
|
||||||
|
if index == 1 {
|
||||||
|
if item.ReportID != report.Tomorrow || item.Status != "failed" || !strings.Contains(item.Error, string(promptexec.Capacity)) {
|
||||||
|
t.Fatalf("failed item = %#v", item)
|
||||||
|
}
|
||||||
|
if item.DataPackagePath == "" || item.PreparationPath == "" || item.ExecutionPath == "" || item.LLMDebugPath == "" || item.MetadataPath == "" || item.ReportPath != "" || item.OutputPath != "" {
|
||||||
|
t.Fatalf("failed item reached paths = %#v", item)
|
||||||
|
}
|
||||||
|
assertBatchItemMatchesMetadata(t, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if item.Status != "succeeded" || item.ReportPath == "" || item.OutputPath == "" {
|
||||||
|
t.Fatalf("continued item %d = %#v", index, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBatchDetailedNotificationLifecycle(t *testing.T) {
|
||||||
|
t.Run("disabled", func(t *testing.T) {
|
||||||
|
cfg := assembledBatchConfig(t, false)
|
||||||
|
bundle := assembledBatchBundle(t, "2026-05-31")
|
||||||
|
notifier := &assembledBatchNotifier{}
|
||||||
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
|
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"),
|
||||||
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier,
|
||||||
|
})
|
||||||
|
if err != nil || result.Notification != nil || result.Failed != 0 || len(notifier.reportRequests) != 0 || len(notifier.batchRequests) != 0 {
|
||||||
|
t.Fatalf("result/error/requests = %#v/%v/%d/%d", result, err, len(notifier.reportRequests), len(notifier.batchRequests))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("batch disabled", func(t *testing.T) {
|
||||||
|
cfg := assembledBatchConfig(t, true)
|
||||||
|
cfg.Notify.Distributor.Batch.Enabled = false
|
||||||
|
bundle := assembledBatchBundle(t, "2026-05-31")
|
||||||
|
notifier := &assembledBatchNotifier{}
|
||||||
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
|
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"),
|
||||||
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier,
|
||||||
|
})
|
||||||
|
if err != nil || result.Notification != nil || len(notifier.reportRequests) != 0 || len(notifier.batchRequests) != 0 {
|
||||||
|
t.Fatalf("result/error/requests = %#v/%v/%d/%d", result, err, len(notifier.reportRequests), len(notifier.batchRequests))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("all success", func(t *testing.T) {
|
||||||
|
cfg := assembledBatchConfig(t, true)
|
||||||
|
bundle := assembledBatchBundle(t, "2026-05-31")
|
||||||
|
notifier := &assembledBatchNotifier{batchResult: &NotificationResult{
|
||||||
|
RunID: "batch-notification-run", Status: "succeeded", UploadStatus: "accepted",
|
||||||
|
Report: []byte(`{"actions":[{"action":"replace_older"}]}`),
|
||||||
|
}}
|
||||||
|
outputDir := filepath.Join(t.TempDir(), "output")
|
||||||
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
|
Config: cfg, Batch: BatchMorning, Now: workflowTime("2026-05-29T08:00:00-05:00"), OutputDir: outputDir,
|
||||||
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RunBatchDetailed() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Failed != 0 || result.Notification == nil || result.Notification.Status != "succeeded" || result.Notification.Path == "" || len(notifier.reportRequests) != 0 || len(notifier.batchRequests) != 1 {
|
||||||
|
t.Fatalf("notification result/requests = %#v/%d/%d", result.Notification, len(notifier.reportRequests), len(notifier.batchRequests))
|
||||||
|
}
|
||||||
|
managedPaths := make(map[string]struct{}, len(result.Reports))
|
||||||
|
for _, item := range result.Reports {
|
||||||
|
managedPaths[item.ReportPath] = struct{}{}
|
||||||
|
if item.NotificationPath != "" {
|
||||||
|
t.Fatalf("report item contains per-report notification path: %#v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
request := notifier.batchRequests[0]
|
||||||
|
if len(request.IncludedReports) != len(result.Reports) {
|
||||||
|
t.Fatalf("included reports = %d, want %d", len(request.IncludedReports), len(result.Reports))
|
||||||
|
}
|
||||||
|
for _, file := range request.Files {
|
||||||
|
if _, ok := managedPaths[file.SourcePath]; !ok || strings.HasPrefix(file.SourcePath, outputDir+string(filepath.Separator)) || file.BundlePath == "" {
|
||||||
|
t.Fatalf("notification file = %#v, want managed Markdown source", file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
artifact := readBatchNotificationArtifact(t, result.Notification.Path)
|
||||||
|
if artifact.Status != "succeeded" || artifact.Upload == nil || artifact.Upload.RunID != "batch-notification-run" || artifact.RunStatus == nil || len(artifact.Reports) != len(result.Reports) {
|
||||||
|
t.Fatalf("notification artifact = %#v", artifact)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("upload failure", func(t *testing.T) {
|
||||||
|
cfg := assembledBatchConfig(t, true)
|
||||||
|
bundle := assembledBatchBundle(t, "2026-05-31")
|
||||||
|
notifier := &assembledBatchNotifier{batchErr: errors.New("batch upload rejected")}
|
||||||
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
|
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"),
|
||||||
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RunBatchDetailed() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Succeeded != 2 || result.Failed != 1 || result.Notification == nil || result.Notification.Status != "failed" || result.Notification.Path == "" {
|
||||||
|
t.Fatalf("result = %#v, want successful reports and failed notification", result)
|
||||||
|
}
|
||||||
|
for _, item := range result.Reports {
|
||||||
|
if item.Status != "succeeded" {
|
||||||
|
t.Fatalf("report item = %#v, want success despite notification failure", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
artifact := readBatchNotificationArtifact(t, result.Notification.Path)
|
||||||
|
if artifact.Status != "failed" || !strings.Contains(artifact.Error, "batch upload rejected") {
|
||||||
|
t.Fatalf("notification artifact = %#v", artifact)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("status report", func(t *testing.T) {
|
||||||
|
cfg := assembledBatchConfig(t, true)
|
||||||
|
bundle := assembledBatchBundle(t, "2026-05-31")
|
||||||
|
notifier := &assembledBatchNotifier{batchResult: &NotificationResult{
|
||||||
|
RunID: "batch-notification-run", Status: "accepted", UploadStatus: "accepted",
|
||||||
|
StatusError: "status lookup unavailable", Report: []byte(`{"actions":[{"action":"replace_older"}]}`),
|
||||||
|
}}
|
||||||
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
|
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"),
|
||||||
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier,
|
||||||
|
})
|
||||||
|
if err != nil || result.Failed != 0 || result.Notification == nil || result.Notification.Path == "" {
|
||||||
|
t.Fatalf("result/error = %#v/%v", result, err)
|
||||||
|
}
|
||||||
|
artifact := readBatchNotificationArtifact(t, result.Notification.Path)
|
||||||
|
if artifact.StatusError != "status lookup unavailable" || artifact.RunStatus == nil || !bytes.Contains(artifact.RunStatus.Report, []byte("replace_older")) {
|
||||||
|
t.Fatalf("notification artifact = %#v", artifact)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBatchDetailedKeepsDynamicDailyArtifactsDistinct(t *testing.T) {
|
||||||
|
cfg := assembledBatchConfig(t, false)
|
||||||
|
bundle := assembledBatchBundle(t, "2026-05-31", "2026-06-01")
|
||||||
|
outputDir := filepath.Join(t.TempDir(), "output")
|
||||||
|
debugRoot := filepath.Join(t.TempDir(), "debug")
|
||||||
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
|
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"), OutputDir: outputDir, LLMDebugDir: debugRoot,
|
||||||
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(),
|
||||||
|
})
|
||||||
|
if err != nil || result.Failed != 0 || len(result.Reports) != 3 {
|
||||||
|
t.Fatalf("result/error = %#v/%v", result, err)
|
||||||
|
}
|
||||||
|
seenRuns := make(map[string]struct{})
|
||||||
|
seenDebug := make(map[string]struct{})
|
||||||
|
dailyDates := make(map[string]BatchReportResult)
|
||||||
|
for _, item := range result.Reports {
|
||||||
|
if _, exists := seenRuns[item.RunID]; exists {
|
||||||
|
t.Fatalf("duplicate run ID %q", item.RunID)
|
||||||
|
}
|
||||||
|
seenRuns[item.RunID] = struct{}{}
|
||||||
|
if _, exists := seenDebug[item.LLMDebugPath]; exists {
|
||||||
|
t.Fatalf("duplicate debug path %q", item.LLMDebugPath)
|
||||||
|
}
|
||||||
|
seenDebug[item.LLMDebugPath] = struct{}{}
|
||||||
|
if item.ReportID == report.Daily {
|
||||||
|
date := item.ValidPeriod.Start.In(mustLoadTestLocation(t, "America/Chicago")).Format("2006-01-02")
|
||||||
|
dailyDates[date] = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, date := range []string{"2026-05-31", "2026-06-01"} {
|
||||||
|
item, ok := dailyDates[date]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("daily items = %#v, want %s", dailyDates, date)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(item.RunID, "_daily_"+date) || item.OutputPath != filepath.Join(outputDir, "daily-"+date+".md") {
|
||||||
|
t.Fatalf("daily identity/output = %q/%q", item.RunID, item.OutputPath)
|
||||||
|
}
|
||||||
|
wantDebugPrefix := filepath.Join(debugRoot, "daily", date, item.RunID)
|
||||||
|
if item.LLMDebugPath != wantDebugPrefix {
|
||||||
|
t.Fatalf("daily debug path = %q, want %q", item.LLMDebugPath, wantDebugPrefix)
|
||||||
|
}
|
||||||
|
assertBatchPathsExist(t, filepath.Join(item.LLMDebugPath, "preparation.json"), filepath.Join(item.LLMDebugPath, "execution.json"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBatchDetailedUsesPriorSnapshotsForPromptPackages(t *testing.T) {
|
||||||
|
cfg := assembledBatchConfig(t, false)
|
||||||
|
firstBundle := assembledBatchBundle(t, "2026-05-31")
|
||||||
|
setWorkflowTemperatures(&firstBundle, 45)
|
||||||
|
first, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
|
Config: cfg, Batch: BatchMorning, Now: workflowTime("2026-05-29T08:00:00-05:00"),
|
||||||
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &firstBundle}}, Executor: newAssembledBatchExecutor(),
|
||||||
|
})
|
||||||
|
if err != nil || first.Failed != 0 {
|
||||||
|
t.Fatalf("first result/error = %#v/%v", first, err)
|
||||||
|
}
|
||||||
|
secondBundle := assembledBatchBundle(t, "2026-05-31")
|
||||||
|
setWorkflowTemperatures(&secondBundle, 85)
|
||||||
|
second, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||||
|
Config: cfg, Batch: BatchMorning, Now: workflowTime("2026-05-29T08:30:00-05:00"),
|
||||||
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &secondBundle}}, Executor: newAssembledBatchExecutor(),
|
||||||
|
})
|
||||||
|
if err != nil || second.Failed != 0 || len(second.Reports) != len(first.Reports) {
|
||||||
|
t.Fatalf("second result/error = %#v/%v", second, err)
|
||||||
|
}
|
||||||
|
for _, item := range second.Reports {
|
||||||
|
pkg := loadBatchDataPackage(t, item.DataPackagePath)
|
||||||
|
if len(pkg.RecentChanges.Items) == 0 {
|
||||||
|
t.Fatalf("report %s data package has no changes from prior snapshot", item.ReportID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assembledBatchConfig(t *testing.T, notify bool) config.Config {
|
||||||
|
t.Helper()
|
||||||
|
cfg := workflowConfig(t)
|
||||||
|
cfg.Notify.Distributor.Enabled = notify
|
||||||
|
cfg.Notify.Distributor.Batch.Enabled = notify
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func assembledBatchBundle(t *testing.T, dates ...string) weatherdata.Bundle {
|
||||||
|
t.Helper()
|
||||||
|
bundle := workflowBundle(t)
|
||||||
|
location := mustLoadTestLocation(t, "America/Chicago")
|
||||||
|
for _, date := range dates {
|
||||||
|
periods := fullDayPeriods(t, date, location)
|
||||||
|
for index := range periods {
|
||||||
|
temperature := float64(60 + index)
|
||||||
|
periods[index].TemperatureF = &temperature
|
||||||
|
periods[index].TextDescription = "Partly cloudy"
|
||||||
|
}
|
||||||
|
bundle.Hourly.Periods = append(bundle.Hourly.Periods, periods...)
|
||||||
|
}
|
||||||
|
return bundle
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatedTextForPrompt(promptID string) string {
|
||||||
|
switch promptID {
|
||||||
|
case "weather.today_generated_text":
|
||||||
|
return validTodayWorkflowJSON()
|
||||||
|
case "weather.tomorrow_generated_text":
|
||||||
|
return validTomorrowWorkflowJSON()
|
||||||
|
case "weather.daily_generated_text":
|
||||||
|
return validDailyWorkflowJSON()
|
||||||
|
case "weather.hourly_generated_text":
|
||||||
|
return validHourlyWorkflowJSON()
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertBatchPathsExist(t *testing.T, paths ...string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, path := range paths {
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
t.Fatalf("expected path %q: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertBatchItemMatchesMetadata(t *testing.T, item BatchReportResult) {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(item.MetadataPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read metadata %q: %v", item.MetadataPath, err)
|
||||||
|
}
|
||||||
|
var metadata state.Metadata
|
||||||
|
if err := json.Unmarshal(data, &metadata); err != nil {
|
||||||
|
t.Fatalf("decode metadata %q: %v", item.MetadataPath, err)
|
||||||
|
}
|
||||||
|
if item.ReportID != metadata.ReportID || item.RunID != metadata.RunID ||
|
||||||
|
item.DataPackagePath != metadata.DataPackagePath || item.PreparationPath != metadata.PreparationPath ||
|
||||||
|
item.ExecutionPath != metadata.ExecutionPath || item.ReportPath != metadata.RenderedReportPath ||
|
||||||
|
item.NotificationPath != metadata.NotificationPath {
|
||||||
|
t.Fatalf("batch item paths do not exactly match metadata: item=%#v metadata=%#v", item, metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readBatchNotificationArtifact(t *testing.T, path string) state.BatchDistributorNotificationArtifact {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read batch notification artifact: %v", err)
|
||||||
|
}
|
||||||
|
var artifact state.BatchDistributorNotificationArtifact
|
||||||
|
if err := json.Unmarshal(data, &artifact); err != nil {
|
||||||
|
t.Fatalf("decode batch notification artifact: %v", err)
|
||||||
|
}
|
||||||
|
return artifact
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadBatchDataPackage(t *testing.T, path string) promptinput.Package {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read data package: %v", err)
|
||||||
|
}
|
||||||
|
pkg, err := promptinput.LoadYAML(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadYAML() error = %v", err)
|
||||||
|
}
|
||||||
|
return pkg
|
||||||
|
}
|
||||||
552
internal/app/prompt_artifact_paths_test.go
Normal file
552
internal/app/prompt_artifact_paths_test.go
Normal file
@@ -0,0 +1,552 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
failPromptExecution = "prompt execution"
|
||||||
|
failMetadata = "metadata"
|
||||||
|
failGeneratedText = "generated text"
|
||||||
|
failRenderContext = "render context"
|
||||||
|
failRenderedReportPath = "rendered report path"
|
||||||
|
failDistributorNotification = "distributor notification"
|
||||||
|
)
|
||||||
|
|
||||||
|
type failingPersistenceStore struct {
|
||||||
|
state.Store
|
||||||
|
failOperation string
|
||||||
|
failExecutionCall int
|
||||||
|
failMetadataCall int
|
||||||
|
executionCalls int
|
||||||
|
metadataCalls int
|
||||||
|
renderedReportPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *failingPersistenceStore) SavePromptExecution(ctx context.Context, resolved report.Resolved, artifact state.PromptExecutionArtifact) (string, error) {
|
||||||
|
s.executionCalls++
|
||||||
|
if s.failOperation == failPromptExecution && (s.failExecutionCall == 0 || s.executionCalls == s.failExecutionCall) {
|
||||||
|
return "", errors.New("injected prompt execution persistence failure")
|
||||||
|
}
|
||||||
|
return s.Store.SavePromptExecution(ctx, resolved, artifact)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *failingPersistenceStore) SaveGeneratedText(ctx context.Context, resolved report.Resolved, data []byte) (string, error) {
|
||||||
|
if s.failOperation == failGeneratedText {
|
||||||
|
return "", errors.New("injected generated text persistence failure")
|
||||||
|
}
|
||||||
|
return s.Store.SaveGeneratedText(ctx, resolved, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *failingPersistenceStore) SaveRenderContext(ctx context.Context, resolved report.Resolved, value any) (string, error) {
|
||||||
|
if s.failOperation == failRenderContext {
|
||||||
|
return "", errors.New("injected render context persistence failure")
|
||||||
|
}
|
||||||
|
return s.Store.SaveRenderContext(ctx, resolved, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *failingPersistenceStore) PrepareRenderedReport(ctx context.Context, resolved report.Resolved) (string, error) {
|
||||||
|
if s.failOperation == failRenderedReportPath {
|
||||||
|
return s.renderedReportPath, nil
|
||||||
|
}
|
||||||
|
return s.Store.PrepareRenderedReport(ctx, resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *failingPersistenceStore) SaveDistributorNotification(ctx context.Context, resolved report.Resolved, artifact state.DistributorNotificationArtifact) (string, error) {
|
||||||
|
if s.failOperation == failDistributorNotification {
|
||||||
|
return "", errors.New("injected notification persistence failure")
|
||||||
|
}
|
||||||
|
return s.Store.SaveDistributorNotification(ctx, resolved, artifact)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *failingPersistenceStore) SaveMetadata(ctx context.Context, metadata state.Metadata) (string, error) {
|
||||||
|
s.metadataCalls++
|
||||||
|
if s.failOperation == failMetadata && s.metadataCalls == s.failMetadataCall {
|
||||||
|
return "", errors.New("injected metadata persistence failure")
|
||||||
|
}
|
||||||
|
return s.Store.SaveMetadata(ctx, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
type artifactPathExecutor struct {
|
||||||
|
beforePreparationErr error
|
||||||
|
afterPreparationErr error
|
||||||
|
validation promptexec.ValidationStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e artifactPathExecutor) InspectPrompt(context.Context, string, string) (promptexec.PromptInspection, error) {
|
||||||
|
return promptexec.PromptInspection{}, errors.New("unexpected inspection")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e artifactPathExecutor) InspectProfile(context.Context, string) (promptexec.ProfileInspection, error) {
|
||||||
|
return promptexec.ProfileInspection{}, errors.New("unexpected inspection")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e artifactPathExecutor) Execute(_ context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {
|
||||||
|
if e.beforePreparationErr != nil {
|
||||||
|
return nil, e.beforePreparationErr
|
||||||
|
}
|
||||||
|
now := time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
|
||||||
|
if err := callback(promptexec.Preparation{
|
||||||
|
PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: "prompt-hash",
|
||||||
|
RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "test",
|
||||||
|
ModelName: "test-model", DataPackagePath: req.DataPackagePath, StartedAt: now, EndedAt: now,
|
||||||
|
}, nil); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if e.afterPreparationErr != nil {
|
||||||
|
return nil, e.afterPreparationErr
|
||||||
|
}
|
||||||
|
validation := e.validation
|
||||||
|
if validation == "" {
|
||||||
|
validation = promptexec.ValidationPassed
|
||||||
|
}
|
||||||
|
return &promptexec.Execution{
|
||||||
|
RunID: "provider-run", PromptID: req.PromptID, PromptVersion: req.PromptVersion,
|
||||||
|
PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID,
|
||||||
|
BackendID: "test", ModelName: "test-model", GeneratedHash: "generated-hash",
|
||||||
|
StartedAt: now, EndedAt: now, DataPackagePath: req.DataPackagePath,
|
||||||
|
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.","confidence":"Medium"}`),
|
||||||
|
Validation: promptexec.NewValidation(validation, "json_schema", "daily.generated_text.schema.json", nil),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type successfulNotifier struct{}
|
||||||
|
|
||||||
|
func (successfulNotifier) Notify(context.Context, NotificationRequest) (*NotificationResult, error) {
|
||||||
|
return &NotificationResult{RunID: "notification-run", Status: "succeeded", UploadStatus: "accepted"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type failingNotifier struct{}
|
||||||
|
|
||||||
|
func (failingNotifier) Notify(context.Context, NotificationRequest) (*NotificationResult, error) {
|
||||||
|
return nil, errors.New("injected notification failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratePromptReportReturnsOnlyReachedArtifactPaths(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
failOperation string
|
||||||
|
failMetadataCall int
|
||||||
|
outputCopy bool
|
||||||
|
notify bool
|
||||||
|
want reachedPromptArtifacts
|
||||||
|
}{
|
||||||
|
{name: "preparation then metadata", failOperation: failMetadata, failMetadataCall: 1, want: reachedPromptArtifacts{preparation: true}},
|
||||||
|
{name: "raw output then execution", failOperation: failPromptExecution, want: reachedPromptArtifacts{preparation: true, metadata: true, raw: true}},
|
||||||
|
{name: "execution then metadata", failOperation: failMetadata, failMetadataCall: 2, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true}},
|
||||||
|
{name: "normalized output then metadata", failOperation: failMetadata, failMetadataCall: 3, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true}},
|
||||||
|
{name: "render context then metadata", failOperation: failMetadata, failMetadataCall: 4, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true}},
|
||||||
|
{name: "managed report then metadata", failOperation: failMetadata, failMetadataCall: 5, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true}},
|
||||||
|
{name: "output copy then metadata", failOperation: failMetadata, failMetadataCall: 5, outputCopy: true, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true}},
|
||||||
|
{name: "notification then metadata", failOperation: failMetadata, failMetadataCall: 6, notify: true, want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, notification: true}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
req, paths := promptArtifactRequest(t, artifactPathExecutor{})
|
||||||
|
store := &failingPersistenceStore{Store: req.Store, failOperation: test.failOperation, failMetadataCall: test.failMetadataCall}
|
||||||
|
req.Store = store
|
||||||
|
if test.outputCopy {
|
||||||
|
req.OutputPath = filepath.Join(t.TempDir(), "daily.md")
|
||||||
|
paths.output = req.OutputPath
|
||||||
|
}
|
||||||
|
if test.notify {
|
||||||
|
req.Config.Notify.Distributor.Enabled = true
|
||||||
|
req.Config.Notify.Distributor.PipelineIDTemplate = "weatherreporter"
|
||||||
|
req.Notifier = successfulNotifier{}
|
||||||
|
req.noNotify = false
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := generatePromptReport(context.Background(), req)
|
||||||
|
if err == nil || result == nil {
|
||||||
|
t.Fatalf("generatePromptReport() result/error = %#v/%v, want partial result and failure", result, err)
|
||||||
|
}
|
||||||
|
assertReachedPromptArtifacts(t, result, paths, test.want)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratePromptReportFailureReceiptsExposeReachedPaths(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
executor artifactPathExecutor
|
||||||
|
want reachedPromptArtifacts
|
||||||
|
wantExecutionStatus state.PromptExecutionStatus
|
||||||
|
wantExecutionPaths state.PromptExecutionPaths
|
||||||
|
wantRawExecution bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "preparation failure",
|
||||||
|
executor: artifactPathExecutor{beforePreparationErr: promptexec.NewError(promptexec.Generation, "prepare failed", nil)},
|
||||||
|
want: reachedPromptArtifacts{preparation: true, metadata: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "operational execution failure",
|
||||||
|
executor: artifactPathExecutor{afterPreparationErr: promptexec.NewError(promptexec.Generation, "provider failed", nil)},
|
||||||
|
want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true},
|
||||||
|
wantExecutionStatus: state.PromptExecutionFailed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "completed validation rejection",
|
||||||
|
executor: artifactPathExecutor{validation: promptexec.ValidationFailed},
|
||||||
|
want: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true},
|
||||||
|
wantExecutionStatus: state.PromptExecutionValidationRejected,
|
||||||
|
wantRawExecution: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
req, paths := promptArtifactRequest(t, test.executor)
|
||||||
|
if test.wantRawExecution {
|
||||||
|
test.wantExecutionPaths.RawOutputPath = paths.GeneratedTextRaw
|
||||||
|
}
|
||||||
|
result, err := generatePromptReport(context.Background(), req)
|
||||||
|
if err == nil || result == nil {
|
||||||
|
t.Fatalf("generatePromptReport() result/error = %#v/%v, want partial result and failure", result, err)
|
||||||
|
}
|
||||||
|
assertReachedPromptArtifacts(t, result, paths, test.want)
|
||||||
|
if test.wantExecutionStatus != "" {
|
||||||
|
artifact, loadErr := req.Store.LoadPromptExecution(context.Background(), result.ExecutionPath)
|
||||||
|
if loadErr != nil {
|
||||||
|
t.Fatalf("LoadPromptExecution() error = %v", loadErr)
|
||||||
|
}
|
||||||
|
if artifact.Status != test.wantExecutionStatus || artifact.Paths != test.wantExecutionPaths {
|
||||||
|
t.Fatalf("execution outcome/paths = %q/%#v, want %q/%#v", artifact.Status, artifact.Paths, test.wantExecutionStatus, test.wantExecutionPaths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompletedExecutionArtifactTracksDownstreamLifecycle(t *testing.T) {
|
||||||
|
req, paths := promptArtifactRequest(t, artifactPathExecutor{})
|
||||||
|
req.OutputPath = filepath.Join(t.TempDir(), "daily.md")
|
||||||
|
paths.output = req.OutputPath
|
||||||
|
req.Config.Notify.Distributor.Enabled = true
|
||||||
|
req.Config.Notify.Distributor.PipelineIDTemplate = "weatherreporter"
|
||||||
|
req.Notifier = successfulNotifier{}
|
||||||
|
req.noNotify = false
|
||||||
|
|
||||||
|
result, err := generatePromptReport(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generatePromptReport() error = %v", err)
|
||||||
|
}
|
||||||
|
want := state.PromptExecutionPaths{
|
||||||
|
RawOutputPath: paths.GeneratedTextRaw, GeneratedTextPath: paths.GeneratedText,
|
||||||
|
RenderContextPath: paths.RenderContext, RenderedReportPath: paths.RenderedReport,
|
||||||
|
OutputPath: paths.output, NotificationPath: paths.Notification,
|
||||||
|
}
|
||||||
|
assertPersistedExecutionPaths(t, req.Store, result.ExecutionPath, want)
|
||||||
|
|
||||||
|
data, err := os.ReadFile(result.ExecutionPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read execution artifact: %v", err)
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
for _, forbidden := range []string{
|
||||||
|
"Showers are possible during the selected day", `"rawOutput":`, `"debug":`,
|
||||||
|
`"renderedMessages":`, `"structuredSchema":`, `"endpoint":`, `"parametersJSON":`,
|
||||||
|
"credential", "secret-value",
|
||||||
|
} {
|
||||||
|
if strings.Contains(text, forbidden) {
|
||||||
|
t.Fatalf("execution artifact contains unsafe generated or provider detail %q:\n%s", forbidden, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompletedExecutionArtifactRetainsLastPersistedCheckpoint(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
failOperation string
|
||||||
|
failExecutionCall int
|
||||||
|
failMetadataCall int
|
||||||
|
requestOutput bool
|
||||||
|
failOutputCopy bool
|
||||||
|
notify bool
|
||||||
|
notificationFailure bool
|
||||||
|
wantExecution reachedExecutionArtifacts
|
||||||
|
wantResult reachedPromptArtifacts
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "normalized text write", failOperation: failGeneratedText,
|
||||||
|
wantExecution: reachedExecutionArtifacts{raw: true},
|
||||||
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "normalized text checkpoint", failOperation: failPromptExecution, failExecutionCall: 2,
|
||||||
|
wantExecution: reachedExecutionArtifacts{raw: true},
|
||||||
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "normalized text metadata", failOperation: failMetadata, failMetadataCall: 3,
|
||||||
|
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true},
|
||||||
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "render context write", failOperation: failRenderContext,
|
||||||
|
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true},
|
||||||
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "render context checkpoint", failOperation: failPromptExecution, failExecutionCall: 3,
|
||||||
|
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true},
|
||||||
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "render context metadata", failOperation: failMetadata, failMetadataCall: 4,
|
||||||
|
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true},
|
||||||
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "managed report write", failOperation: failRenderedReportPath,
|
||||||
|
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true},
|
||||||
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "managed report checkpoint", failOperation: failPromptExecution, failExecutionCall: 4,
|
||||||
|
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true},
|
||||||
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "output copy write", requestOutput: true, failOutputCopy: true,
|
||||||
|
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true},
|
||||||
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "output copy checkpoint", failOperation: failPromptExecution, failExecutionCall: 5, requestOutput: true,
|
||||||
|
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true},
|
||||||
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "output copy metadata", failOperation: failMetadata, failMetadataCall: 5, requestOutput: true,
|
||||||
|
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true, output: true},
|
||||||
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "notification artifact write", failOperation: failDistributorNotification, requestOutput: true, notify: true,
|
||||||
|
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true, output: true},
|
||||||
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "notification checkpoint", failOperation: failPromptExecution, failExecutionCall: 6, requestOutput: true, notify: true,
|
||||||
|
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true, output: true},
|
||||||
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true, notification: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "notification metadata", failOperation: failMetadata, failMetadataCall: 6, requestOutput: true, notify: true,
|
||||||
|
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true, output: true, notification: true},
|
||||||
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true, notification: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "notification operation", requestOutput: true, notify: true, notificationFailure: true,
|
||||||
|
wantExecution: reachedExecutionArtifacts{raw: true, normalized: true, renderContext: true, report: true, output: true, notification: true},
|
||||||
|
wantResult: reachedPromptArtifacts{preparation: true, execution: true, metadata: true, raw: true, normalized: true, renderContext: true, report: true, output: true, notification: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
req, paths := promptArtifactRequest(t, artifactPathExecutor{})
|
||||||
|
store := &failingPersistenceStore{
|
||||||
|
Store: req.Store, failOperation: test.failOperation,
|
||||||
|
failExecutionCall: test.failExecutionCall, failMetadataCall: test.failMetadataCall,
|
||||||
|
}
|
||||||
|
if test.failOperation == failRenderedReportPath {
|
||||||
|
store.renderedReportPath = t.TempDir()
|
||||||
|
}
|
||||||
|
req.Store = store
|
||||||
|
if test.requestOutput {
|
||||||
|
req.OutputPath = filepath.Join(t.TempDir(), "daily.md")
|
||||||
|
paths.output = req.OutputPath
|
||||||
|
}
|
||||||
|
if test.failOutputCopy {
|
||||||
|
blocker := filepath.Join(t.TempDir(), "not-a-directory")
|
||||||
|
if err := os.WriteFile(blocker, []byte("block"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write output blocker: %v", err)
|
||||||
|
}
|
||||||
|
req.OutputPath = filepath.Join(blocker, "daily.md")
|
||||||
|
paths.output = req.OutputPath
|
||||||
|
}
|
||||||
|
if test.notify {
|
||||||
|
req.Config.Notify.Distributor.Enabled = true
|
||||||
|
req.Config.Notify.Distributor.PipelineIDTemplate = "weatherreporter"
|
||||||
|
req.Notifier = successfulNotifier{}
|
||||||
|
req.noNotify = false
|
||||||
|
}
|
||||||
|
if test.notificationFailure {
|
||||||
|
req.Notifier = failingNotifier{}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := generatePromptReport(context.Background(), req)
|
||||||
|
if err == nil || result == nil {
|
||||||
|
t.Fatalf("generatePromptReport() result/error = %#v/%v, want partial result and failure", result, err)
|
||||||
|
}
|
||||||
|
assertReachedPromptArtifacts(t, result, paths, test.wantResult)
|
||||||
|
assertPersistedExecutionPaths(t, store, result.ExecutionPath, executionPathsFor(paths, test.wantExecution))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type reachedExecutionArtifacts struct {
|
||||||
|
raw bool
|
||||||
|
normalized bool
|
||||||
|
renderContext bool
|
||||||
|
report bool
|
||||||
|
output bool
|
||||||
|
notification bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func executionPathsFor(paths promptArtifactPaths, reached reachedExecutionArtifacts) state.PromptExecutionPaths {
|
||||||
|
result := state.PromptExecutionPaths{}
|
||||||
|
if reached.raw {
|
||||||
|
result.RawOutputPath = paths.GeneratedTextRaw
|
||||||
|
}
|
||||||
|
if reached.normalized {
|
||||||
|
result.GeneratedTextPath = paths.GeneratedText
|
||||||
|
}
|
||||||
|
if reached.renderContext {
|
||||||
|
result.RenderContextPath = paths.RenderContext
|
||||||
|
}
|
||||||
|
if reached.report {
|
||||||
|
result.RenderedReportPath = paths.RenderedReport
|
||||||
|
}
|
||||||
|
if reached.output {
|
||||||
|
result.OutputPath = paths.output
|
||||||
|
}
|
||||||
|
if reached.notification {
|
||||||
|
result.NotificationPath = paths.Notification
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertPersistedExecutionPaths(t *testing.T, store state.Store, path string, want state.PromptExecutionPaths) {
|
||||||
|
t.Helper()
|
||||||
|
artifact, err := store.LoadPromptExecution(context.Background(), path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadPromptExecution() error = %v", err)
|
||||||
|
}
|
||||||
|
if artifact.Status != state.PromptExecutionSucceeded || artifact.Validation == nil || artifact.Validation.Status != promptexec.ValidationPassed {
|
||||||
|
t.Fatalf("execution outcome changed after downstream write: %#v", artifact)
|
||||||
|
}
|
||||||
|
if artifact.Provenance == nil || artifact.Provenance.RunID != "provider-run" || artifact.Provenance.PromptHash != "prompt-hash" {
|
||||||
|
t.Fatalf("execution provenance changed after downstream write: %#v", artifact.Provenance)
|
||||||
|
}
|
||||||
|
if artifact.Paths != want {
|
||||||
|
t.Fatalf("execution paths = %#v, want %#v", artifact.Paths, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type promptArtifactPaths struct {
|
||||||
|
state.ArtifactPaths
|
||||||
|
output string
|
||||||
|
}
|
||||||
|
|
||||||
|
type reachedPromptArtifacts struct {
|
||||||
|
preparation bool
|
||||||
|
execution bool
|
||||||
|
metadata bool
|
||||||
|
raw bool
|
||||||
|
normalized bool
|
||||||
|
renderContext bool
|
||||||
|
report bool
|
||||||
|
output bool
|
||||||
|
notification bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptArtifactRequest(t *testing.T, executor promptexec.Executor) (promptReportRequest, promptArtifactPaths) {
|
||||||
|
t.Helper()
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.Workspace.Root = t.TempDir()
|
||||||
|
resolved, err := ResolveGenerate(GenerateRequest{
|
||||||
|
Config: cfg, Report: ReportDaily, Date: mustParse("2026-05-29T12:00:00-05:00"),
|
||||||
|
}, mustParse("2026-05-29T05:00:00-05:00"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||||
|
}
|
||||||
|
bundleData, err := os.ReadFile(filepath.Join("..", "forecast", "testdata", "daily_bundle.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read daily fixture: %v", err)
|
||||||
|
}
|
||||||
|
var bundle weatherdata.Bundle
|
||||||
|
if err := json.Unmarshal(bundleData, &bundle); err != nil {
|
||||||
|
t.Fatalf("decode daily fixture: %v", err)
|
||||||
|
}
|
||||||
|
filesystemStore, err := state.NewFilesystemStore(cfg.Workspace)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFilesystemStore() error = %v", err)
|
||||||
|
}
|
||||||
|
paths, err := filesystemStore.Paths(resolved)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Paths() error = %v", err)
|
||||||
|
}
|
||||||
|
debugWriter, err := state.NewPromptDebugWriter("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPromptDebugWriter() error = %v", err)
|
||||||
|
}
|
||||||
|
return promptReportRequest{
|
||||||
|
GenerateRequest: GenerateRequest{Config: cfg, Report: ReportDaily, Executor: executor, Store: filesystemStore},
|
||||||
|
Resolved: resolved, Collection: collect.Result{Bundle: &bundle},
|
||||||
|
Inspection: PromptInspectionResult{
|
||||||
|
PromptID: resolved.Definition.PromptID, PromptVersion: resolved.Definition.PromptVersion,
|
||||||
|
PromptHash: "prompt-hash", ProfileID: "test-profile", BackendID: "test", ModelName: "test-model",
|
||||||
|
},
|
||||||
|
DebugWriter: debugWriter, noNotify: true,
|
||||||
|
}, promptArtifactPaths{ArtifactPaths: paths}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertReachedPromptArtifacts(t *testing.T, result *ReportResult, paths promptArtifactPaths, want reachedPromptArtifacts) {
|
||||||
|
t.Helper()
|
||||||
|
if result.ModuleSnapshotPath != paths.ModuleSnapshot || result.DataPackagePath != paths.DataPackage {
|
||||||
|
t.Fatalf("base paths = module %q data %q, want %q and %q", result.ModuleSnapshotPath, result.DataPackagePath, paths.ModuleSnapshot, paths.DataPackage)
|
||||||
|
}
|
||||||
|
if result.Metadata.ModuleSnapshotPath != paths.ModuleSnapshot || result.Metadata.DataPackagePath != paths.DataPackage || result.Metadata.MetadataPath != paths.Metadata {
|
||||||
|
t.Fatalf("metadata base paths = %#v, want reached module/data paths and metadata destination", result.Metadata)
|
||||||
|
}
|
||||||
|
checks := []struct {
|
||||||
|
name string
|
||||||
|
got string
|
||||||
|
metadataGot string
|
||||||
|
inMetadata bool
|
||||||
|
path string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"preparation", result.PreparationPath, result.Metadata.PreparationPath, true, paths.Preparation, want.preparation},
|
||||||
|
{"execution", result.ExecutionPath, result.Metadata.ExecutionPath, true, paths.Execution, want.execution},
|
||||||
|
{"metadata", result.MetadataPath, "", false, paths.Metadata, want.metadata},
|
||||||
|
{"raw", result.GeneratedTextRawPath, result.Metadata.GeneratedTextRawPath, true, paths.GeneratedTextRaw, want.raw},
|
||||||
|
{"normalized", result.GeneratedTextPath, result.Metadata.GeneratedTextPath, true, paths.GeneratedText, want.normalized},
|
||||||
|
{"render context", result.RenderContextPath, result.Metadata.RenderContextPath, true, paths.RenderContext, want.renderContext},
|
||||||
|
{"report", result.ReportPath, result.Metadata.RenderedReportPath, true, paths.RenderedReport, want.report},
|
||||||
|
{"output", result.OutputPath, "", false, paths.output, want.output},
|
||||||
|
{"notification", result.NotificationPath, result.Metadata.NotificationPath, true, paths.Notification, want.notification},
|
||||||
|
}
|
||||||
|
for _, check := range checks {
|
||||||
|
if check.want && check.got != check.path {
|
||||||
|
t.Errorf("%s path = %q, want reached path %q", check.name, check.got, check.path)
|
||||||
|
}
|
||||||
|
if check.want && check.inMetadata && check.metadataGot != check.path {
|
||||||
|
t.Errorf("metadata %s path = %q, want reached path %q", check.name, check.metadataGot, check.path)
|
||||||
|
}
|
||||||
|
if !check.want && check.got != "" {
|
||||||
|
t.Errorf("%s path = %q, want empty because artifact was not reached", check.name, check.got)
|
||||||
|
}
|
||||||
|
if !check.want && check.inMetadata && check.metadataGot != "" {
|
||||||
|
t.Errorf("metadata %s path = %q, want empty because artifact was not reached", check.name, check.metadataGot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
423
internal/app/prompt_generate.go
Normal file
423
internal/app/prompt_generate.go
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
type promptReportRequest struct {
|
||||||
|
GenerateRequest
|
||||||
|
Resolved report.Resolved
|
||||||
|
Collection collect.Result
|
||||||
|
Inspection PromptInspectionResult
|
||||||
|
DebugWriter *state.PromptDebugWriter
|
||||||
|
noNotify bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatePromptReport(ctx context.Context, req promptReportRequest) (*ReportResult, error) {
|
||||||
|
workflow, err := newPromptReportWorkflow(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := workflow.buildInputs(); err != nil {
|
||||||
|
return workflow.result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
execution, executeErr := workflow.executePrompt()
|
||||||
|
if executeErr != nil {
|
||||||
|
return workflow.result, workflow.handleExecutionFailure(executeErr)
|
||||||
|
}
|
||||||
|
if execution == nil {
|
||||||
|
err := promptexec.NewError(promptexec.Generation, "prompt executor returned no execution", nil)
|
||||||
|
if saveErr := workflow.persistOperationalExecutionFailure(err); saveErr != nil {
|
||||||
|
return workflow.result, saveErr
|
||||||
|
}
|
||||||
|
return workflow.result, workflow.reportError("execute prompt", err)
|
||||||
|
}
|
||||||
|
if err := workflow.persistExecutionDebug(*execution); err != nil {
|
||||||
|
return workflow.result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if execution.Validation.Status != promptexec.ValidationPassed && execution.Validation.Status != promptexec.ValidationFailed {
|
||||||
|
err := promptexec.NewError(promptexec.OperationalValidation, "prompt execution did not complete validation", nil)
|
||||||
|
if saveErr := workflow.persistOperationalExecutionFailure(err); saveErr != nil {
|
||||||
|
return workflow.result, saveErr
|
||||||
|
}
|
||||||
|
return workflow.result, workflow.reportError("validate prompt execution", err)
|
||||||
|
}
|
||||||
|
if err := workflow.persistCompletedExecution(*execution); err != nil {
|
||||||
|
return workflow.result, err
|
||||||
|
}
|
||||||
|
if execution.Validation.Status == promptexec.ValidationFailed {
|
||||||
|
err := promptexec.NewError(promptexec.ValidationRejected, "prompt output did not satisfy its schema", nil)
|
||||||
|
return workflow.result, workflow.reportError("validate prompt execution", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered, err := workflow.persistGeneratedContent(execution.RawOutput)
|
||||||
|
if err != nil {
|
||||||
|
return workflow.result, err
|
||||||
|
}
|
||||||
|
return workflow.finalizeReport(rendered)
|
||||||
|
}
|
||||||
|
|
||||||
|
type promptReportWorkflow struct {
|
||||||
|
ctx context.Context
|
||||||
|
req promptReportRequest
|
||||||
|
store state.Store
|
||||||
|
result *ReportResult
|
||||||
|
metadata state.Metadata
|
||||||
|
briefingMetadata briefing.Metadata
|
||||||
|
reportFacts ReportFacts
|
||||||
|
moduleSnapshot module.Snapshot
|
||||||
|
dataPackageBytes []byte
|
||||||
|
handler generatedtext.Handler
|
||||||
|
executionArtifact state.PromptExecutionArtifact
|
||||||
|
debugRef state.PromptDebugRef
|
||||||
|
prepared bool
|
||||||
|
callbackFailed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPromptReportWorkflow(ctx context.Context, req promptReportRequest) (*promptReportWorkflow, error) {
|
||||||
|
if req.Collection.Bundle == nil {
|
||||||
|
return nil, fmt.Errorf("collected weather bundle is required")
|
||||||
|
}
|
||||||
|
store := req.Store
|
||||||
|
var err error
|
||||||
|
if store == nil {
|
||||||
|
store, err = defaultStore(req.Config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &promptReportWorkflow{ctx: ctx, req: req, store: store}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promptReportWorkflow) buildInputs() error {
|
||||||
|
paths, err := w.store.Paths(w.req.Resolved)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.result = &ReportResult{}
|
||||||
|
priorSnapshot, err := w.store.FindPriorSnapshot(w.ctx, w.req.Resolved)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.reportFacts, err = BuildReportFacts(ModuleSnapshotRequest{Config: w.req.Config, Resolved: w.req.Resolved}, w.req.Collection.Bundle)
|
||||||
|
if err != nil {
|
||||||
|
return generatedReportError(w.req.Resolved, w.req.Resolved.Metadata().RunID, "build report facts", err)
|
||||||
|
}
|
||||||
|
w.moduleSnapshot, err = BuildModuleSnapshotFromFacts(ModuleSnapshotRequest{Config: w.req.Config, Resolved: w.req.Resolved}, w.reportFacts)
|
||||||
|
if err != nil {
|
||||||
|
return generatedReportError(w.req.Resolved, w.req.Resolved.Metadata().RunID, "build module snapshot", err)
|
||||||
|
}
|
||||||
|
moduleSnapshotPath, err := w.store.SaveModuleSnapshot(w.ctx, w.req.Resolved, w.moduleSnapshot)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.result.ModuleSnapshot = w.moduleSnapshot
|
||||||
|
w.result.ModuleSnapshotPath = moduleSnapshotPath
|
||||||
|
w.result.PriorSnapshot = priorSnapshot
|
||||||
|
|
||||||
|
recent, err := recentChanges(w.ctx, w.store, priorSnapshot, w.req.Resolved.Definition.ID, w.moduleSnapshot, w.req.Config.RecentChange)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.result.RecentChanges = recent
|
||||||
|
w.briefingMetadata = briefing.BuildMetadata(briefingBuildContext(w.req.Config, w.req.Resolved, w.reportFacts.Collected))
|
||||||
|
w.metadata = state.BuildPromptMetadataFromBriefingMetadata(w.req.Resolved, w.briefingMetadata, state.ArtifactPaths{
|
||||||
|
ModuleSnapshot: moduleSnapshotPath,
|
||||||
|
Metadata: paths.Metadata,
|
||||||
|
})
|
||||||
|
w.result.Metadata = w.metadata
|
||||||
|
dataPackage, err := promptinput.Build(promptinput.BuildRequest{
|
||||||
|
Metadata: promptMetadata(w.metadata), Modules: w.moduleSnapshot, RecentChanges: recent,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return w.reportError("build data package", err)
|
||||||
|
}
|
||||||
|
w.dataPackageBytes, err = promptinput.MarshalYAML(dataPackage)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dataPackagePath, err := w.store.SaveDataPackageBytes(w.ctx, w.req.Resolved, w.dataPackageBytes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.metadata.DataPackagePath = dataPackagePath
|
||||||
|
w.result.DataPackage = dataPackage
|
||||||
|
w.result.DataPackagePath = dataPackagePath
|
||||||
|
w.result.Metadata = w.metadata
|
||||||
|
w.handler, err = generatedtext.LookupDefinition(w.req.Resolved.Definition)
|
||||||
|
if err != nil {
|
||||||
|
return w.reportError("lookup generated text catalog", err)
|
||||||
|
}
|
||||||
|
w.debugRef = state.PromptDebugRef{
|
||||||
|
ReportID: w.req.Resolved.Definition.ID, ValidDate: w.req.Resolved.ValidPeriod.Start.Format("2006-01-02"), RunID: w.metadata.RunID,
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promptReportWorkflow) executePrompt() (*promptexec.Execution, error) {
|
||||||
|
return w.req.Executor.Execute(w.ctx, promptexec.ExecuteRequest{
|
||||||
|
PromptID: w.req.Inspection.PromptID, PromptVersion: w.req.Inspection.PromptVersion,
|
||||||
|
ProfileID: w.req.Inspection.ProfileID, DataPackage: w.dataPackageBytes,
|
||||||
|
DataPackagePath: w.result.DataPackagePath, CaptureDebug: w.req.DebugWriter.Enabled(),
|
||||||
|
}, w.persistPreparation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promptReportWorkflow) persistPreparation(preparation promptexec.Preparation, debug *promptexec.PreparationDebug) error {
|
||||||
|
artifact := state.PromptPreparationArtifact{
|
||||||
|
SchemaVersion: state.PromptPreparationSchemaVersion, Status: state.PromptPreparationSucceeded,
|
||||||
|
ReportID: w.req.Resolved.Definition.ID, RunID: w.metadata.RunID,
|
||||||
|
PromptID: w.req.Inspection.PromptID, PromptVersion: w.req.Inspection.PromptVersion,
|
||||||
|
DataPackagePath: w.result.DataPackagePath, Preparation: &preparation,
|
||||||
|
StartedAt: preparation.StartedAt, EndedAt: preparation.EndedAt, Duration: preparation.Duration,
|
||||||
|
}
|
||||||
|
path, err := w.store.SavePromptPreparation(w.ctx, w.req.Resolved, artifact)
|
||||||
|
if err != nil {
|
||||||
|
w.callbackFailed = true
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.prepared = true
|
||||||
|
w.result.PreparationPath = path
|
||||||
|
w.metadata.PreparationPath = path
|
||||||
|
w.result.Metadata = w.metadata
|
||||||
|
debugPath, err := w.req.DebugWriter.WritePreparation(w.debugRef, preparation, debug)
|
||||||
|
if err != nil {
|
||||||
|
w.callbackFailed = true
|
||||||
|
return promptDebugWriteError(err)
|
||||||
|
}
|
||||||
|
if debugPath != "" {
|
||||||
|
w.result.LLMDebugPath = debugPath
|
||||||
|
}
|
||||||
|
if err := w.saveMetadata(); err != nil {
|
||||||
|
w.callbackFailed = true
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promptReportWorkflow) handleExecutionFailure(executeErr error) error {
|
||||||
|
if w.callbackFailed {
|
||||||
|
return executeErr
|
||||||
|
}
|
||||||
|
executeErr = classifiedPromptError("prompt execution failed", executeErr)
|
||||||
|
if !w.prepared {
|
||||||
|
if err := w.persistPreparationFailure(executeErr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return w.reportError("prepare prompt", executeErr)
|
||||||
|
}
|
||||||
|
if promptexec.CategoryOf(executeErr) != "" {
|
||||||
|
if err := w.persistOperationalExecutionFailure(executeErr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return w.reportError("execute prompt", executeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promptReportWorkflow) persistPreparationFailure(executeErr error) error {
|
||||||
|
startedAt, endedAt := time.Now(), time.Now()
|
||||||
|
artifact := state.PromptPreparationArtifact{
|
||||||
|
SchemaVersion: state.PromptPreparationSchemaVersion, Status: state.PromptPreparationFailed,
|
||||||
|
ReportID: w.req.Resolved.Definition.ID, RunID: w.metadata.RunID,
|
||||||
|
PromptID: w.req.Inspection.PromptID, PromptVersion: w.req.Inspection.PromptVersion,
|
||||||
|
DataPackagePath: w.result.DataPackagePath, StartedAt: startedAt, EndedAt: endedAt,
|
||||||
|
Error: state.NewPromptArtifactError(executeErr),
|
||||||
|
}
|
||||||
|
path, err := w.store.SavePromptPreparation(w.ctx, w.req.Resolved, artifact)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.result.PreparationPath = path
|
||||||
|
w.metadata.PreparationPath = path
|
||||||
|
w.result.Metadata = w.metadata
|
||||||
|
return w.saveMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promptReportWorkflow) persistOperationalExecutionFailure(executeErr error) error {
|
||||||
|
artifact := failedPromptExecutionArtifact(w.req.Resolved, w.metadata, w.req.Inspection, executeErr)
|
||||||
|
path, err := w.store.SavePromptExecution(w.ctx, w.req.Resolved, artifact)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.result.ExecutionPath = path
|
||||||
|
w.metadata.ExecutionPath = path
|
||||||
|
w.result.Metadata = w.metadata
|
||||||
|
return w.saveMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promptReportWorkflow) persistExecutionDebug(execution promptexec.Execution) error {
|
||||||
|
debugPath, err := w.req.DebugWriter.WriteExecution(w.debugRef, execution)
|
||||||
|
if err != nil {
|
||||||
|
return w.reportError("write prompt debug", promptDebugWriteError(err))
|
||||||
|
}
|
||||||
|
if debugPath != "" {
|
||||||
|
w.result.LLMDebugPath = debugPath
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promptReportWorkflow) persistCompletedExecution(execution promptexec.Execution) error {
|
||||||
|
rawPath, err := w.store.SaveGeneratedTextRaw(w.ctx, w.req.Resolved, execution.RawOutput)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.result.GeneratedTextRawPath = rawPath
|
||||||
|
w.metadata.GeneratedTextRawPath = rawPath
|
||||||
|
w.result.Metadata = w.metadata
|
||||||
|
w.executionArtifact = state.PromptExecutionArtifact{
|
||||||
|
SchemaVersion: state.PromptExecutionSchemaVersion,
|
||||||
|
ReportID: w.req.Resolved.Definition.ID, RunID: w.metadata.RunID,
|
||||||
|
PromptID: w.req.Inspection.PromptID, PromptVersion: w.req.Inspection.PromptVersion,
|
||||||
|
Provenance: ptr(state.PromptExecutionProvenanceFrom(execution)), Validation: &execution.Validation,
|
||||||
|
Paths: state.PromptExecutionPaths{RawOutputPath: rawPath},
|
||||||
|
StartedAt: execution.StartedAt, EndedAt: execution.EndedAt, Duration: execution.Duration,
|
||||||
|
}
|
||||||
|
if execution.Validation.Status == promptexec.ValidationPassed {
|
||||||
|
w.executionArtifact.Status = state.PromptExecutionSucceeded
|
||||||
|
} else {
|
||||||
|
w.executionArtifact.Status = state.PromptExecutionValidationRejected
|
||||||
|
}
|
||||||
|
executionPath, err := w.store.SavePromptExecution(w.ctx, w.req.Resolved, w.executionArtifact)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.result.ExecutionPath = executionPath
|
||||||
|
w.metadata.ExecutionPath = executionPath
|
||||||
|
w.result.Metadata = w.metadata
|
||||||
|
return w.saveMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promptReportWorkflow) persistGeneratedContent(raw []byte) ([]byte, error) {
|
||||||
|
generatedText, normalized, err := w.handler.Validate(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, w.reportError("validate generated text", err)
|
||||||
|
}
|
||||||
|
generatedTextPath, err := w.store.SaveGeneratedText(w.ctx, w.req.Resolved, normalized)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
w.result.GeneratedTextPath = generatedTextPath
|
||||||
|
w.metadata.GeneratedTextPath = generatedTextPath
|
||||||
|
w.result.Metadata = w.metadata
|
||||||
|
if err := w.persistReachedPathAndMetadata(func(paths *state.PromptExecutionPaths) { paths.GeneratedTextPath = generatedTextPath }); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
renderContext, err := w.handler.BuildRenderContext(w.briefingMetadata, w.moduleSnapshot, w.reportFacts.Collected, w.reportFacts.Derived, generatedText)
|
||||||
|
if err != nil {
|
||||||
|
return nil, w.reportError("build render context", err)
|
||||||
|
}
|
||||||
|
renderContextPath, err := w.store.SaveRenderContext(w.ctx, w.req.Resolved, renderContext)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
w.result.RenderContextPath = renderContextPath
|
||||||
|
w.metadata.RenderContextPath = renderContextPath
|
||||||
|
w.result.Metadata = w.metadata
|
||||||
|
if err := w.persistReachedPathAndMetadata(func(paths *state.PromptExecutionPaths) { paths.RenderContextPath = renderContextPath }); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered, err := w.handler.Render(renderContext)
|
||||||
|
if err != nil {
|
||||||
|
return nil, w.reportError("render template", err)
|
||||||
|
}
|
||||||
|
return rendered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promptReportWorkflow) finalizeReport(rendered []byte) (*ReportResult, error) {
|
||||||
|
reportPath, err := w.store.PrepareRenderedReport(w.ctx, w.req.Resolved)
|
||||||
|
if err != nil {
|
||||||
|
return w.result, err
|
||||||
|
}
|
||||||
|
if err := fileutil.WriteFileAtomic(reportPath, rendered); err != nil {
|
||||||
|
return w.result, err
|
||||||
|
}
|
||||||
|
w.result.ReportPath = reportPath
|
||||||
|
w.metadata.RenderedReportPath = reportPath
|
||||||
|
w.result.Metadata = w.metadata
|
||||||
|
if err := w.persistReachedPath(func(paths *state.PromptExecutionPaths) { paths.RenderedReportPath = reportPath }); err != nil {
|
||||||
|
return w.result, err
|
||||||
|
}
|
||||||
|
finalized, err := finalizeRenderedReport(w.ctx, finalizeRenderedReportRequest{
|
||||||
|
Config: w.req.Config, Store: w.store, Resolved: w.req.Resolved, Metadata: w.metadata, MetadataPath: w.result.MetadataPath,
|
||||||
|
ExecutionArtifact: &w.executionArtifact, ManagedReportPath: reportPath, OutputPath: w.req.OutputPath,
|
||||||
|
Notifier: w.req.Notifier, noNotify: w.req.noNotify,
|
||||||
|
})
|
||||||
|
w.result.OutputPath, w.result.NotificationPath = finalized.OutputPath, finalized.NotificationPath
|
||||||
|
w.result.Metadata, w.result.MetadataPath, w.result.Notification = finalized.Metadata, finalized.MetadataPath, finalized.Notification
|
||||||
|
return w.result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promptReportWorkflow) persistReachedPath(update func(*state.PromptExecutionPaths)) error {
|
||||||
|
return persistReachedPromptPath(w.ctx, w.store, w.req.Resolved, &w.executionArtifact, update)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promptReportWorkflow) persistReachedPathAndMetadata(update func(*state.PromptExecutionPaths)) error {
|
||||||
|
if err := w.persistReachedPath(update); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return w.saveMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promptReportWorkflow) saveMetadata() error {
|
||||||
|
path, err := w.store.SaveMetadata(w.ctx, w.metadata)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.result.Metadata = w.metadata
|
||||||
|
w.result.MetadataPath = path
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *promptReportWorkflow) reportError(operation string, err error) error {
|
||||||
|
return generatedReportError(w.req.Resolved, w.metadata.RunID, operation, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func persistReachedPromptPath(
|
||||||
|
ctx context.Context,
|
||||||
|
store state.Store,
|
||||||
|
resolved report.Resolved,
|
||||||
|
artifact *state.PromptExecutionArtifact,
|
||||||
|
update func(*state.PromptExecutionPaths),
|
||||||
|
) error {
|
||||||
|
update(&artifact.Paths)
|
||||||
|
_, err := store.SavePromptExecution(ctx, resolved, *artifact)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func failedPromptExecutionArtifact(resolved report.Resolved, metadata state.Metadata, inspection PromptInspectionResult, err error) state.PromptExecutionArtifact {
|
||||||
|
now := time.Now()
|
||||||
|
return state.PromptExecutionArtifact{
|
||||||
|
SchemaVersion: state.PromptExecutionSchemaVersion, Status: state.PromptExecutionFailed,
|
||||||
|
ReportID: resolved.Definition.ID, RunID: metadata.RunID, PromptID: inspection.PromptID,
|
||||||
|
PromptVersion: inspection.PromptVersion, StartedAt: now, EndedAt: now,
|
||||||
|
Error: state.NewPromptArtifactError(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifiedPromptError(operation string, err error) error {
|
||||||
|
if promptexec.CategoryOf(err) != "" {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return promptexec.NewError(promptexec.Generation, operation, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptDebugWriteError(err error) error {
|
||||||
|
return promptexec.NewError(promptexec.InvalidConfiguration, "write requested prompt debug artifact", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptr[T any](value T) *T { return &value }
|
||||||
142
internal/app/prompt_inspection.go
Normal file
142
internal/app/prompt_inspection.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PromptInspectionRequest contains the non-executing inputs required to
|
||||||
|
// validate one report's configured prompt and profile.
|
||||||
|
type PromptInspectionRequest struct {
|
||||||
|
Resolved report.Resolved
|
||||||
|
Executor promptexec.Executor
|
||||||
|
Promptkit config.PromptkitConfig
|
||||||
|
LookupEnv func(string) (string, bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromptInspectionResult contains only safe identity and provenance from a
|
||||||
|
// prompt/profile inspection.
|
||||||
|
type PromptInspectionResult struct {
|
||||||
|
PromptID string
|
||||||
|
PromptVersion string
|
||||||
|
PromptHash string
|
||||||
|
ProfileID string
|
||||||
|
BackendID string
|
||||||
|
ModelName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromptExecutionsInspectionRequest validates all prompt/profile combinations
|
||||||
|
// needed by a batch before collection begins.
|
||||||
|
type PromptExecutionsInspectionRequest struct {
|
||||||
|
Resolved []report.Resolved
|
||||||
|
Executor promptexec.Executor
|
||||||
|
Promptkit config.PromptkitConfig
|
||||||
|
LookupEnv func(string) (string, bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InspectPromptExecution validates the exact prompt and profile needed for a
|
||||||
|
// report before collection, execution, or durable writes begin.
|
||||||
|
func InspectPromptExecution(ctx context.Context, req PromptInspectionRequest) (PromptInspectionResult, error) {
|
||||||
|
results, err := InspectPromptExecutions(ctx, PromptExecutionsInspectionRequest{
|
||||||
|
Resolved: []report.Resolved{req.Resolved},
|
||||||
|
Executor: req.Executor,
|
||||||
|
Promptkit: req.Promptkit,
|
||||||
|
LookupEnv: req.LookupEnv,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return PromptInspectionResult{}, err
|
||||||
|
}
|
||||||
|
return results[req.Resolved.Definition.ID], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InspectPromptExecutions validates exact prompt contracts and their unique
|
||||||
|
// effective profiles. It performs no collection, execution, or durable write.
|
||||||
|
func InspectPromptExecutions(ctx context.Context, req PromptExecutionsInspectionRequest) (map[report.ID]PromptInspectionResult, error) {
|
||||||
|
if req.Executor == nil {
|
||||||
|
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is required", nil)
|
||||||
|
}
|
||||||
|
results := make(map[report.ID]PromptInspectionResult, len(req.Resolved))
|
||||||
|
profiles := map[string]promptexec.ProfileInspection{}
|
||||||
|
for _, resolved := range req.Resolved {
|
||||||
|
definition := resolved.Definition
|
||||||
|
if strings.TrimSpace(definition.PromptID) == "" || strings.TrimSpace(definition.PromptVersion) == "" {
|
||||||
|
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "report prompt identity is incomplete", nil)
|
||||||
|
}
|
||||||
|
inspection, err := req.Executor.InspectPrompt(ctx, definition.PromptID, definition.PromptVersion)
|
||||||
|
if err != nil {
|
||||||
|
return nil, promptInspectionError("prompt inspection failed", err)
|
||||||
|
}
|
||||||
|
if inspection.PromptID != definition.PromptID || inspection.PromptVersion != definition.PromptVersion {
|
||||||
|
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt inspection did not return the requested prompt version", nil)
|
||||||
|
}
|
||||||
|
if !validPromptInput(inspection.Inputs) {
|
||||||
|
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt must declare exactly one required application/yaml data_package input", nil)
|
||||||
|
}
|
||||||
|
if !validPromptOutput(definition, inspection.Output) {
|
||||||
|
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt must declare the report JSON Schema output contract", nil)
|
||||||
|
}
|
||||||
|
profileID := req.Promptkit.Profile
|
||||||
|
if profileID == "" {
|
||||||
|
profileID = inspection.DefaultProfileID
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(profileID) == "" {
|
||||||
|
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt has no execution profile", nil)
|
||||||
|
}
|
||||||
|
profile, ok := profiles[profileID]
|
||||||
|
if !ok {
|
||||||
|
profile, err = inspectPromptProfile(ctx, req.Executor, profileID, req.LookupEnv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
profiles[profileID] = profile
|
||||||
|
}
|
||||||
|
results[definition.ID] = PromptInspectionResult{
|
||||||
|
PromptID: inspection.PromptID, PromptVersion: inspection.PromptVersion, PromptHash: inspection.PromptHash,
|
||||||
|
ProfileID: profile.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspectPromptProfile(ctx context.Context, executor promptexec.Executor, profileID string, lookupEnv func(string) (string, bool)) (promptexec.ProfileInspection, error) {
|
||||||
|
profile, err := executor.InspectProfile(ctx, profileID)
|
||||||
|
if err != nil {
|
||||||
|
return promptexec.ProfileInspection{}, promptInspectionError("profile inspection failed", err)
|
||||||
|
}
|
||||||
|
if profile.ProfileID != profileID {
|
||||||
|
return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "profile inspection did not return the selected profile", nil)
|
||||||
|
}
|
||||||
|
if profile.CredentialRequired {
|
||||||
|
return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.MissingCredential, "selected profile requires an unsupported direct API key", nil)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(profile.APIKeyEnv) != "" {
|
||||||
|
if lookupEnv == nil {
|
||||||
|
lookupEnv = os.LookupEnv
|
||||||
|
}
|
||||||
|
value, present := lookupEnv(profile.APIKeyEnv)
|
||||||
|
if !present || strings.TrimSpace(value) == "" {
|
||||||
|
return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.MissingCredential, "selected profile credential is unavailable", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return profile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validPromptInput(inputs []promptexec.InputDefinition) bool {
|
||||||
|
return len(inputs) == 1 && inputs[0].Name == "data_package" && inputs[0].Required && inputs[0].ContentType == "application/yaml"
|
||||||
|
}
|
||||||
|
|
||||||
|
func validPromptOutput(definition report.Definition, output promptexec.OutputContract) bool {
|
||||||
|
return output.Format == "json" && output.ValidationMode == "json_schema" && output.SchemaPath == definition.GeneratedTextSchemaID+".generated_text.schema.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptInspectionError(operation string, err error) error {
|
||||||
|
if promptexec.CategoryOf(err) != "" {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return promptexec.NewError(promptexec.InvalidConfiguration, operation, err)
|
||||||
|
}
|
||||||
191
internal/app/prompt_inspection_test.go
Normal file
191
internal/app/prompt_inspection_test.go
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInspectPromptExecutionSelectsDefaultAndOverrideProfiles(t *testing.T) {
|
||||||
|
resolved := inspectionResolved(t)
|
||||||
|
executor := &inspectionExecutor{
|
||||||
|
prompt: validPromptInspection(resolved.Definition),
|
||||||
|
profiles: map[string]promptexec.ProfileInspection{
|
||||||
|
"default-profile": {ProfileID: "default-profile", BackendID: "local", ModelName: "default-model"},
|
||||||
|
"override-profile": {ProfileID: "override-profile", BackendID: "cloud", ModelName: "override-model"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
defaultResult, err := InspectPromptExecution(context.Background(), PromptInspectionRequest{Resolved: resolved, Executor: executor})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InspectPromptExecution(default) error = %v", err)
|
||||||
|
}
|
||||||
|
if defaultResult.ProfileID != "default-profile" || defaultResult.ModelName != "default-model" {
|
||||||
|
t.Fatalf("default result = %#v", defaultResult)
|
||||||
|
}
|
||||||
|
overrideResult, err := InspectPromptExecution(context.Background(), PromptInspectionRequest{
|
||||||
|
Resolved: resolved, Executor: executor, Promptkit: config.PromptkitConfig{Profile: "override-profile"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InspectPromptExecution(override) error = %v", err)
|
||||||
|
}
|
||||||
|
if overrideResult.ProfileID != "override-profile" || overrideResult.ModelName != "override-model" {
|
||||||
|
t.Fatalf("override result = %#v", overrideResult)
|
||||||
|
}
|
||||||
|
if len(executor.promptRequests) != 2 || executor.promptRequests[0].version != resolved.Definition.PromptVersion || executor.profileRequests[0] != "default-profile" || executor.profileRequests[1] != "override-profile" {
|
||||||
|
t.Fatalf("inspection requests = prompts %#v profiles %#v", executor.promptRequests, executor.profileRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInspectPromptExecutionRejectsInvalidContractsAndCredentials(t *testing.T) {
|
||||||
|
resolved := inspectionResolved(t)
|
||||||
|
basePrompt := validPromptInspection(resolved.Definition)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
prompt promptexec.PromptInspection
|
||||||
|
profile promptexec.ProfileInspection
|
||||||
|
lookupEnv func(string) (string, bool)
|
||||||
|
wantCategory promptexec.ErrorCategory
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "extra input",
|
||||||
|
prompt: func() promptexec.PromptInspection {
|
||||||
|
value := basePrompt
|
||||||
|
value.Inputs = append(value.Inputs, promptexec.InputDefinition{Name: "unexpected"})
|
||||||
|
return value
|
||||||
|
}(),
|
||||||
|
wantCategory: promptexec.InvalidConfiguration,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong schema",
|
||||||
|
prompt: func() promptexec.PromptInspection {
|
||||||
|
value := basePrompt
|
||||||
|
value.Output.SchemaPath = "unexpected.schema.json"
|
||||||
|
return value
|
||||||
|
}(),
|
||||||
|
wantCategory: promptexec.InvalidConfiguration,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "direct key",
|
||||||
|
prompt: basePrompt,
|
||||||
|
profile: promptexec.ProfileInspection{ProfileID: "default-profile", CredentialRequired: true},
|
||||||
|
wantCategory: promptexec.MissingCredential,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing environment credential",
|
||||||
|
prompt: basePrompt,
|
||||||
|
profile: promptexec.ProfileInspection{ProfileID: "default-profile", APIKeyEnv: "PROMPT_API_KEY"},
|
||||||
|
lookupEnv: func(string) (string, bool) { return "", false },
|
||||||
|
wantCategory: promptexec.MissingCredential,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
executor := &inspectionExecutor{prompt: test.prompt, profiles: map[string]promptexec.ProfileInspection{"default-profile": test.profile}}
|
||||||
|
_, err := InspectPromptExecution(context.Background(), PromptInspectionRequest{Resolved: resolved, Executor: executor, LookupEnv: test.lookupEnv})
|
||||||
|
if err == nil || promptexec.CategoryOf(err) != test.wantCategory {
|
||||||
|
t.Fatalf("error/category = %v/%q, want %q", err, promptexec.CategoryOf(err), test.wantCategory)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInspectPromptExecutionReturnsSafeInspectionError(t *testing.T) {
|
||||||
|
resolved := inspectionResolved(t)
|
||||||
|
executor := &inspectionExecutor{promptErr: errors.New("provider response contains resolved-secret-value")}
|
||||||
|
_, err := InspectPromptExecution(context.Background(), PromptInspectionRequest{Resolved: resolved, Executor: executor})
|
||||||
|
if err == nil || promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
|
||||||
|
t.Fatalf("error/category = %v/%q", err, promptexec.CategoryOf(err))
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "resolved-secret-value") {
|
||||||
|
t.Fatalf("inspection error leaks provider value: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInspectPromptExecutionsReusesEffectiveProfile(t *testing.T) {
|
||||||
|
first := inspectionResolved(t)
|
||||||
|
second := first
|
||||||
|
second.Definition.ID = report.Today
|
||||||
|
second.Definition.PromptID = "weather.today"
|
||||||
|
executor := &inspectionExecutor{
|
||||||
|
prompt: validPromptInspection(first.Definition),
|
||||||
|
profiles: map[string]promptexec.ProfileInspection{
|
||||||
|
"default-profile": {ProfileID: "default-profile", BackendID: "local", ModelName: "model"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
executor.prompts = map[string]promptexec.PromptInspection{
|
||||||
|
first.Definition.PromptID: validPromptInspection(first.Definition),
|
||||||
|
second.Definition.PromptID: validPromptInspection(second.Definition),
|
||||||
|
}
|
||||||
|
results, err := InspectPromptExecutions(context.Background(), PromptExecutionsInspectionRequest{Resolved: []report.Resolved{first, second}, Executor: executor})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InspectPromptExecutions() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 2 || len(executor.profileRequests) != 1 {
|
||||||
|
t.Fatalf("results/profile requests = %#v/%#v, want two results and one profile inspection", results, executor.profileRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type inspectionPromptRequest struct {
|
||||||
|
id string
|
||||||
|
version string
|
||||||
|
}
|
||||||
|
|
||||||
|
type inspectionExecutor struct {
|
||||||
|
prompt promptexec.PromptInspection
|
||||||
|
prompts map[string]promptexec.PromptInspection
|
||||||
|
profiles map[string]promptexec.ProfileInspection
|
||||||
|
promptErr error
|
||||||
|
promptRequests []inspectionPromptRequest
|
||||||
|
profileRequests []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *inspectionExecutor) InspectPrompt(_ context.Context, id string, version string) (promptexec.PromptInspection, error) {
|
||||||
|
e.promptRequests = append(e.promptRequests, inspectionPromptRequest{id: id, version: version})
|
||||||
|
if e.promptErr != nil {
|
||||||
|
return promptexec.PromptInspection{}, e.promptErr
|
||||||
|
}
|
||||||
|
if prompt, ok := e.prompts[id]; ok {
|
||||||
|
return prompt, nil
|
||||||
|
}
|
||||||
|
return e.prompt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *inspectionExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) {
|
||||||
|
e.profileRequests = append(e.profileRequests, id)
|
||||||
|
value, ok := e.profiles[id]
|
||||||
|
if !ok {
|
||||||
|
return promptexec.ProfileInspection{}, errors.New("profile missing")
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *inspectionExecutor) Execute(context.Context, promptexec.ExecuteRequest, promptexec.PreparationCallback) (*promptexec.Execution, error) {
|
||||||
|
return nil, errors.New("unexpected execution")
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspectionResolved(t *testing.T) report.Resolved {
|
||||||
|
t.Helper()
|
||||||
|
resolved, err := report.DefaultRegistry().Resolve(report.Daily, report.ResolveRequest{
|
||||||
|
Now: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC),
|
||||||
|
Date: time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC),
|
||||||
|
Location: time.UTC,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
func validPromptInspection(definition report.Definition) promptexec.PromptInspection {
|
||||||
|
return promptexec.PromptInspection{
|
||||||
|
PromptID: definition.PromptID, PromptVersion: definition.PromptVersion, PromptHash: "prompt-hash", DefaultProfileID: "default-profile",
|
||||||
|
Inputs: []promptexec.InputDefinition{{Name: "data_package", Required: true, ContentType: "application/yaml"}},
|
||||||
|
Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: definition.GeneratedTextSchemaID + ".generated_text.schema.json"},
|
||||||
|
}
|
||||||
|
}
|
||||||
659
internal/app/single_report_workflow_test.go
Normal file
659
internal/app/single_report_workflow_test.go
Normal file
@@ -0,0 +1,659 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type workflowCollector struct {
|
||||||
|
result *collect.Result
|
||||||
|
err error
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *workflowCollector) Run(context.Context, collect.Request) (*collect.Result, error) {
|
||||||
|
c.calls++
|
||||||
|
if c.err != nil {
|
||||||
|
return nil, c.err
|
||||||
|
}
|
||||||
|
return c.result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type workflowExecutor struct {
|
||||||
|
definition report.Definition
|
||||||
|
raw []byte
|
||||||
|
inspectionErr error
|
||||||
|
profile promptexec.ProfileInspection
|
||||||
|
beforePreparationErr error
|
||||||
|
afterCallbackErr error
|
||||||
|
afterPreparationErr error
|
||||||
|
validation promptexec.ValidationStatus
|
||||||
|
executeCalls int
|
||||||
|
providerCalls int
|
||||||
|
request promptexec.ExecuteRequest
|
||||||
|
beforeProvider func()
|
||||||
|
preparationDebug *promptexec.PreparationDebug
|
||||||
|
executionDebug *promptexec.ExecutionDebug
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *workflowExecutor) InspectPrompt(_ context.Context, id, version string) (promptexec.PromptInspection, error) {
|
||||||
|
if e.inspectionErr != nil {
|
||||||
|
return promptexec.PromptInspection{}, e.inspectionErr
|
||||||
|
}
|
||||||
|
if id != e.definition.PromptID || version != e.definition.PromptVersion {
|
||||||
|
return promptexec.PromptInspection{}, errors.New("unexpected prompt identity")
|
||||||
|
}
|
||||||
|
return validPromptInspection(e.definition), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *workflowExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) {
|
||||||
|
profile := e.profile
|
||||||
|
if profile.ProfileID == "" {
|
||||||
|
profile = promptexec.ProfileInspection{ProfileID: id, BackendID: "fixture", ModelName: "fixture-model"}
|
||||||
|
}
|
||||||
|
return profile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *workflowExecutor) Execute(_ context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {
|
||||||
|
e.executeCalls++
|
||||||
|
e.request = req
|
||||||
|
if e.beforePreparationErr != nil {
|
||||||
|
return nil, e.beforePreparationErr
|
||||||
|
}
|
||||||
|
stamp := time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
|
||||||
|
preparation := promptexec.Preparation{
|
||||||
|
PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: "prompt-hash",
|
||||||
|
RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "fixture",
|
||||||
|
ModelName: "fixture-model", DataPackagePath: req.DataPackagePath, StartedAt: stamp, EndedAt: stamp,
|
||||||
|
}
|
||||||
|
if err := callback(preparation, e.preparationDebug); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if e.afterCallbackErr != nil {
|
||||||
|
return nil, e.afterCallbackErr
|
||||||
|
}
|
||||||
|
if e.beforeProvider != nil {
|
||||||
|
e.beforeProvider()
|
||||||
|
}
|
||||||
|
e.providerCalls++
|
||||||
|
if e.afterPreparationErr != nil {
|
||||||
|
return nil, e.afterPreparationErr
|
||||||
|
}
|
||||||
|
validation := e.validation
|
||||||
|
if validation == "" {
|
||||||
|
validation = promptexec.ValidationPassed
|
||||||
|
}
|
||||||
|
return &promptexec.Execution{
|
||||||
|
RunID: "provider-run", PromptID: req.PromptID, PromptVersion: req.PromptVersion,
|
||||||
|
PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID,
|
||||||
|
BackendID: "fixture", ModelName: "fixture-model", GeneratedHash: "generated-hash",
|
||||||
|
StartedAt: stamp, EndedAt: stamp, DataPackagePath: req.DataPackagePath, RawOutput: e.raw,
|
||||||
|
Debug: e.executionDebug,
|
||||||
|
Validation: promptexec.NewValidation(validation, "json_schema", e.definition.GeneratedTextSchemaID+".generated_text.schema.json", nil),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type workflowNotifier struct {
|
||||||
|
requests []NotificationRequest
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *workflowNotifier) Notify(_ context.Context, req NotificationRequest) (*NotificationResult, error) {
|
||||||
|
n.requests = append(n.requests, req)
|
||||||
|
if n.err != nil {
|
||||||
|
return nil, n.err
|
||||||
|
}
|
||||||
|
return &NotificationResult{
|
||||||
|
RunID: "notification-run", PipelineID: req.PipelineID, BundleID: req.BundleID,
|
||||||
|
IdempotencyKey: req.IdempotencyKey, Status: "succeeded", UploadStatus: "accepted",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedCompletesRetainedReportWorkflows(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
kind ReportKind
|
||||||
|
id report.ID
|
||||||
|
date time.Time
|
||||||
|
raw string
|
||||||
|
wantOutput string
|
||||||
|
}{
|
||||||
|
{name: "daily", kind: ReportDaily, id: report.Daily, date: workflowTime("2026-05-29T12:00:00-05:00"), raw: validDailyWorkflowJSON(), wantOutput: "Showers are possible during the selected day."},
|
||||||
|
{name: "today", kind: ReportToday, id: report.Today, raw: validTodayWorkflowJSON(), wantOutput: "Today starts with showers before improving."},
|
||||||
|
{name: "tomorrow", kind: ReportTomorrow, id: report.Tomorrow, raw: validTomorrowWorkflowJSON(), wantOutput: "Tomorrow starts with showers before improving."},
|
||||||
|
{name: "hourly", kind: ReportHourly, id: report.Hourly, raw: validHourlyWorkflowJSON(), wantOutput: "Storm chances increase through late morning."},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
cfg := workflowConfig(t)
|
||||||
|
definition := report.DefaultRegistry().MustLookup(test.id)
|
||||||
|
bundle := workflowBundle(t)
|
||||||
|
collector := &workflowCollector{result: &collect.Result{Bundle: &bundle}}
|
||||||
|
executor := &workflowExecutor{definition: definition, raw: []byte(test.raw)}
|
||||||
|
notifier := &workflowNotifier{}
|
||||||
|
outputPath := filepath.Join(t.TempDir(), test.name+".md")
|
||||||
|
|
||||||
|
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||||
|
Config: cfg, Report: test.kind, Date: test.date, Now: workflowTime("2026-05-29T08:30:00-05:00"),
|
||||||
|
OutputPath: outputPath, Collector: collector, Executor: executor, Notifier: notifier,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateDetailed() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.Metadata.ReportID != test.id || result.Metadata.PromptID != definition.PromptID {
|
||||||
|
t.Fatalf("metadata identity = %q/%q, want %q/%q", result.Metadata.ReportID, result.Metadata.PromptID, test.id, definition.PromptID)
|
||||||
|
}
|
||||||
|
if executor.request.PromptVersion != definition.PromptVersion {
|
||||||
|
t.Fatalf("prompt version = %q, want %q", executor.request.PromptVersion, definition.PromptVersion)
|
||||||
|
}
|
||||||
|
filesystem, storeErr := state.NewFilesystemStore(cfg.Workspace)
|
||||||
|
if storeErr != nil {
|
||||||
|
t.Fatalf("NewFilesystemStore() error = %v", storeErr)
|
||||||
|
}
|
||||||
|
preparation, loadErr := filesystem.LoadPromptPreparation(context.Background(), result.PreparationPath)
|
||||||
|
if loadErr != nil || preparation.PromptVersion != definition.PromptVersion {
|
||||||
|
t.Fatalf("persisted preparation prompt version = %q, error %v, want %q", preparation.PromptVersion, loadErr, definition.PromptVersion)
|
||||||
|
}
|
||||||
|
if collector.calls != 1 || executor.executeCalls != 1 || executor.providerCalls != 1 {
|
||||||
|
t.Fatalf("calls = collect %d execute %d provider %d, want one each", collector.calls, executor.executeCalls, executor.providerCalls)
|
||||||
|
}
|
||||||
|
persisted, readErr := os.ReadFile(result.DataPackagePath)
|
||||||
|
if readErr != nil {
|
||||||
|
t.Fatalf("read data package: %v", readErr)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(executor.request.DataPackage, persisted) {
|
||||||
|
t.Fatal("executor data package differs from exact persisted YAML bytes")
|
||||||
|
}
|
||||||
|
managed, readErr := os.ReadFile(result.ReportPath)
|
||||||
|
if readErr != nil || !strings.Contains(string(managed), test.wantOutput) {
|
||||||
|
t.Fatalf("managed report = %q, error %v, want generated template output %q", managed, readErr, test.wantOutput)
|
||||||
|
}
|
||||||
|
copied, readErr := os.ReadFile(outputPath)
|
||||||
|
if readErr != nil || !bytes.Equal(copied, managed) || result.OutputPath != outputPath {
|
||||||
|
t.Fatalf("output copy mismatch/error/path = %v/%q", readErr, result.OutputPath)
|
||||||
|
}
|
||||||
|
if len(notifier.requests) != 1 || notifier.requests[0].ReportPath != result.ReportPath || notifier.requests[0].ReportPath == outputPath {
|
||||||
|
t.Fatalf("notification requests = %#v, want managed report source", notifier.requests)
|
||||||
|
}
|
||||||
|
wantPipeline := "reports." + string(test.id) + "." + definition.ArtifactGroup
|
||||||
|
if notifier.requests[0].PipelineID != wantPipeline {
|
||||||
|
t.Fatalf("pipeline = %q, want %q", notifier.requests[0].PipelineID, wantPipeline)
|
||||||
|
}
|
||||||
|
validDate := result.Metadata.ValidPeriod.Start.Format("2006-01-02")
|
||||||
|
wantBundlePaths := workflowBundlePaths(test.id, validDate, result.Metadata.RunID)
|
||||||
|
if strings.Join(notifier.requests[0].BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") {
|
||||||
|
t.Fatalf("bundle paths = %#v, want %#v", notifier.requests[0].BundlePaths, wantBundlePaths)
|
||||||
|
}
|
||||||
|
managedName := filepath.Base(result.ReportPath)
|
||||||
|
if !strings.HasPrefix(managedName, "report.") || !strings.Contains(managedName, "_"+test.name) || !strings.HasSuffix(managedName, ".md") || filepath.Base(result.OutputPath) != test.name+".md" {
|
||||||
|
t.Fatalf("output names = managed %q copy %q", result.ReportPath, result.OutputPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type preparationFailingStore struct {
|
||||||
|
state.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
type renderContextFailingStore struct {
|
||||||
|
state.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s renderContextFailingStore) SaveModuleSnapshot(ctx context.Context, resolved report.Resolved, snapshot module.Snapshot) (string, error) {
|
||||||
|
path, err := s.Store.SaveModuleSnapshot(ctx, resolved, snapshot)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for index := range snapshot.Outputs {
|
||||||
|
snapshot.Outputs[index].Value = "invalid module value"
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s preparationFailingStore) SavePromptPreparation(context.Context, report.Resolved, state.PromptPreparationArtifact) (string, error) {
|
||||||
|
return "", errors.New("injected preparation persistence failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedStopsAtConsequentialPromptFailures(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
configure func(*workflowExecutor)
|
||||||
|
wantCategory promptexec.ErrorCategory
|
||||||
|
wantPreparation bool
|
||||||
|
wantExecution bool
|
||||||
|
wantRaw bool
|
||||||
|
wantProviderCall int
|
||||||
|
}{
|
||||||
|
{name: "preparation", configure: func(e *workflowExecutor) {
|
||||||
|
e.beforePreparationErr = promptexec.NewError(promptexec.Generation, "preparation failed", nil)
|
||||||
|
}, wantCategory: promptexec.Generation, wantPreparation: true},
|
||||||
|
{name: "credential disappears", configure: func(e *workflowExecutor) {
|
||||||
|
e.afterCallbackErr = promptexec.NewError(promptexec.MissingCredential, "credential unavailable", nil)
|
||||||
|
}, wantCategory: promptexec.MissingCredential, wantPreparation: true, wantExecution: true},
|
||||||
|
{name: "capacity is not retried", configure: func(e *workflowExecutor) {
|
||||||
|
e.afterPreparationErr = promptexec.NewError(promptexec.Capacity, "capacity rejected", nil)
|
||||||
|
}, wantCategory: promptexec.Capacity, wantPreparation: true, wantExecution: true, wantProviderCall: 1},
|
||||||
|
{name: "canceled", configure: func(e *workflowExecutor) {
|
||||||
|
e.afterPreparationErr = promptexec.NewError(promptexec.Canceled, "request canceled", context.Canceled)
|
||||||
|
}, wantCategory: promptexec.Canceled, wantPreparation: true, wantExecution: true, wantProviderCall: 1},
|
||||||
|
{name: "deadline", configure: func(e *workflowExecutor) {
|
||||||
|
e.afterPreparationErr = promptexec.NewError(promptexec.DeadlineExceeded, "deadline exceeded", context.DeadlineExceeded)
|
||||||
|
}, wantCategory: promptexec.DeadlineExceeded, wantPreparation: true, wantExecution: true, wantProviderCall: 1},
|
||||||
|
{name: "generation", configure: func(e *workflowExecutor) {
|
||||||
|
e.afterPreparationErr = promptexec.NewError(promptexec.Generation, "generation failed", nil)
|
||||||
|
}, wantCategory: promptexec.Generation, wantPreparation: true, wantExecution: true, wantProviderCall: 1},
|
||||||
|
{name: "operational validation error", configure: func(e *workflowExecutor) {
|
||||||
|
e.afterPreparationErr = promptexec.NewError(promptexec.OperationalValidation, "validator failed", nil)
|
||||||
|
}, wantCategory: promptexec.OperationalValidation, wantPreparation: true, wantExecution: true, wantProviderCall: 1},
|
||||||
|
{name: "operational validation incomplete", configure: func(e *workflowExecutor) { e.validation = promptexec.ValidationSkipped }, wantCategory: promptexec.OperationalValidation, wantPreparation: true, wantExecution: true, wantProviderCall: 1},
|
||||||
|
{name: "schema rejection", configure: func(e *workflowExecutor) { e.validation = promptexec.ValidationFailed }, wantCategory: promptexec.ValidationRejected, wantPreparation: true, wantExecution: true, wantRaw: true, wantProviderCall: 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
cfg := workflowConfig(t)
|
||||||
|
cfg.Notify.Distributor.Enabled = false
|
||||||
|
definition := report.DefaultRegistry().MustLookup(report.Daily)
|
||||||
|
executor := &workflowExecutor{definition: definition, raw: []byte(validDailyWorkflowJSON())}
|
||||||
|
test.configure(executor)
|
||||||
|
bundle := workflowBundle(t)
|
||||||
|
collector := &workflowCollector{result: &collect.Result{Bundle: &bundle}}
|
||||||
|
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||||
|
Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"),
|
||||||
|
Collector: collector, Executor: executor,
|
||||||
|
})
|
||||||
|
if err == nil || result == nil || promptexec.CategoryOf(err) != test.wantCategory {
|
||||||
|
t.Fatalf("result/error/category = %#v/%v/%q, want partial result and %q", result, err, promptexec.CategoryOf(err), test.wantCategory)
|
||||||
|
}
|
||||||
|
if (result.PreparationPath != "") != test.wantPreparation || (result.ExecutionPath != "") != test.wantExecution || (result.GeneratedTextRawPath != "") != test.wantRaw {
|
||||||
|
t.Fatalf("paths = preparation %q execution %q raw %q", result.PreparationPath, result.ExecutionPath, result.GeneratedTextRawPath)
|
||||||
|
}
|
||||||
|
if executor.executeCalls != 1 || executor.providerCalls != test.wantProviderCall {
|
||||||
|
t.Fatalf("calls = execute %d provider %d, want 1/%d", executor.executeCalls, executor.providerCalls, test.wantProviderCall)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedRejectsInspectionAndCredentialsBeforeCollection(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
configure func(*workflowExecutor)
|
||||||
|
wantCategory promptexec.ErrorCategory
|
||||||
|
}{
|
||||||
|
{name: "inspection", configure: func(e *workflowExecutor) { e.inspectionErr = errors.New("inspection unavailable") }, wantCategory: promptexec.InvalidConfiguration},
|
||||||
|
{name: "credential", configure: func(e *workflowExecutor) {
|
||||||
|
e.profile = promptexec.ProfileInspection{ProfileID: "default-profile", CredentialRequired: true}
|
||||||
|
}, wantCategory: promptexec.MissingCredential},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
cfg := workflowConfig(t)
|
||||||
|
definition := report.DefaultRegistry().MustLookup(report.Daily)
|
||||||
|
executor := &workflowExecutor{definition: definition}
|
||||||
|
test.configure(executor)
|
||||||
|
collector := &workflowCollector{err: errors.New("collector must not run")}
|
||||||
|
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||||
|
Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"),
|
||||||
|
Collector: collector, Executor: executor,
|
||||||
|
})
|
||||||
|
if err == nil || result != nil || promptexec.CategoryOf(err) != test.wantCategory || collector.calls != 0 || executor.executeCalls != 0 {
|
||||||
|
t.Fatalf("result/error/category/collect/execute = %#v/%v/%q/%d/%d", result, err, promptexec.CategoryOf(err), collector.calls, executor.executeCalls)
|
||||||
|
}
|
||||||
|
entries, readErr := os.ReadDir(cfg.Workspace.Root)
|
||||||
|
if readErr != nil || len(entries) != 0 {
|
||||||
|
t.Fatalf("workspace entries/error = %#v/%v, want no writes before collection", entries, readErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedStopsProviderWhenPreparationCannotPersist(t *testing.T) {
|
||||||
|
cfg := workflowConfig(t)
|
||||||
|
cfg.Notify.Distributor.Enabled = false
|
||||||
|
filesystem, err := state.NewFilesystemStore(cfg.Workspace)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFilesystemStore() error = %v", err)
|
||||||
|
}
|
||||||
|
definition := report.DefaultRegistry().MustLookup(report.Daily)
|
||||||
|
executor := &workflowExecutor{definition: definition, raw: []byte(validDailyWorkflowJSON())}
|
||||||
|
bundle := workflowBundle(t)
|
||||||
|
result, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||||
|
Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"),
|
||||||
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: executor, Store: preparationFailingStore{Store: filesystem},
|
||||||
|
})
|
||||||
|
if err == nil || result == nil || result.PreparationPath != "" || executor.providerCalls != 0 {
|
||||||
|
t.Fatalf("result/error/preparation/provider = %#v/%v/%q/%d", result, err, result.PreparationPath, executor.providerCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedPersistsPreparationBeforeProviderExecution(t *testing.T) {
|
||||||
|
cfg := workflowConfig(t)
|
||||||
|
cfg.Notify.Distributor.Enabled = false
|
||||||
|
now := workflowTime("2026-05-29T08:30:00-05:00")
|
||||||
|
request := GenerateRequest{Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: now}
|
||||||
|
resolved, err := ResolveGenerate(request, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||||
|
}
|
||||||
|
filesystem, err := state.NewFilesystemStore(cfg.Workspace)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFilesystemStore() error = %v", err)
|
||||||
|
}
|
||||||
|
paths, err := filesystem.Paths(resolved)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Paths() error = %v", err)
|
||||||
|
}
|
||||||
|
checked := false
|
||||||
|
executor := &workflowExecutor{definition: resolved.Definition, raw: []byte(validDailyWorkflowJSON())}
|
||||||
|
executor.beforeProvider = func() {
|
||||||
|
checked = true
|
||||||
|
if _, statErr := os.Stat(paths.Preparation); statErr != nil {
|
||||||
|
t.Fatalf("preparation was not durable before provider execution: %v", statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bundle := workflowBundle(t)
|
||||||
|
request.Collector = &workflowCollector{result: &collect.Result{Bundle: &bundle}}
|
||||||
|
request.Executor = executor
|
||||||
|
request.Store = filesystem
|
||||||
|
result, err := GenerateDetailed(context.Background(), request)
|
||||||
|
if err != nil || result == nil || !checked || result.OutputPath != "" {
|
||||||
|
t.Fatalf("result/error/checked/output = %#v/%v/%t/%q", result, err, checked, result.OutputPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedRetainsInspectableArtifactsAfterApplicationFailures(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
configure func(*GenerateRequest, *workflowNotifier)
|
||||||
|
wantRaw bool
|
||||||
|
wantNormalized bool
|
||||||
|
wantContext bool
|
||||||
|
wantReport bool
|
||||||
|
wantOutput bool
|
||||||
|
wantNotify bool
|
||||||
|
}{
|
||||||
|
{name: "generated text decode", raw: `{`, wantRaw: true},
|
||||||
|
{name: "generated text domain", raw: `{}`, wantRaw: true},
|
||||||
|
{name: "render context build", raw: validDailyWorkflowJSON(), configure: func(req *GenerateRequest, _ *workflowNotifier) {
|
||||||
|
req.Store = renderContextFailingStore{Store: req.Store}
|
||||||
|
}, wantRaw: true, wantNormalized: true},
|
||||||
|
{name: "render context persistence", raw: validDailyWorkflowJSON(), configure: func(req *GenerateRequest, _ *workflowNotifier) {
|
||||||
|
req.Store = &failingPersistenceStore{Store: req.Store, failOperation: failRenderContext}
|
||||||
|
}, wantRaw: true, wantNormalized: true},
|
||||||
|
{name: "template write", raw: validDailyWorkflowJSON(), configure: func(req *GenerateRequest, _ *workflowNotifier) {
|
||||||
|
blocker := filepath.Join(t.TempDir(), "report-blocker")
|
||||||
|
if err := os.Mkdir(blocker, 0o700); err != nil {
|
||||||
|
t.Fatalf("create report blocker: %v", err)
|
||||||
|
}
|
||||||
|
req.Store = &failingPersistenceStore{Store: req.Store, failOperation: failRenderedReportPath, renderedReportPath: blocker}
|
||||||
|
}, wantRaw: true, wantNormalized: true, wantContext: true},
|
||||||
|
{name: "output copy", raw: validDailyWorkflowJSON(), configure: func(req *GenerateRequest, _ *workflowNotifier) {
|
||||||
|
req.OutputPath = t.TempDir()
|
||||||
|
}, wantRaw: true, wantNormalized: true, wantContext: true, wantReport: true},
|
||||||
|
{name: "notification", raw: validDailyWorkflowJSON(), configure: func(_ *GenerateRequest, notifier *workflowNotifier) {
|
||||||
|
notifier.err = errors.New("notification rejected")
|
||||||
|
}, wantRaw: true, wantNormalized: true, wantContext: true, wantReport: true, wantOutput: true, wantNotify: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
cfg := workflowConfig(t)
|
||||||
|
definition := report.DefaultRegistry().MustLookup(report.Daily)
|
||||||
|
executor := &workflowExecutor{definition: definition, raw: []byte(test.raw)}
|
||||||
|
notifier := &workflowNotifier{}
|
||||||
|
bundle := workflowBundle(t)
|
||||||
|
filesystem, err := state.NewFilesystemStore(cfg.Workspace)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFilesystemStore() error = %v", err)
|
||||||
|
}
|
||||||
|
req := GenerateRequest{
|
||||||
|
Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: workflowTime("2026-05-29T08:30:00-05:00"),
|
||||||
|
OutputPath: filepath.Join(t.TempDir(), "daily.md"), Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}},
|
||||||
|
Executor: executor, Notifier: notifier, Store: filesystem,
|
||||||
|
}
|
||||||
|
if test.configure != nil {
|
||||||
|
test.configure(&req, notifier)
|
||||||
|
}
|
||||||
|
result, err := GenerateDetailed(context.Background(), req)
|
||||||
|
if err == nil || result == nil {
|
||||||
|
t.Fatalf("result/error = %#v/%v, want partial result and error", result, err)
|
||||||
|
}
|
||||||
|
if (result.GeneratedTextRawPath != "") != test.wantRaw || (result.GeneratedTextPath != "") != test.wantNormalized ||
|
||||||
|
(result.RenderContextPath != "") != test.wantContext || (result.ReportPath != "") != test.wantReport ||
|
||||||
|
(result.OutputPath != "") != test.wantOutput || (result.NotificationPath != "") != test.wantNotify {
|
||||||
|
t.Fatalf("reached paths = raw %q normalized %q context %q report %q output %q notification %q", result.GeneratedTextRawPath, result.GeneratedTextPath, result.RenderContextPath, result.ReportPath, result.OutputPath, result.NotificationPath)
|
||||||
|
}
|
||||||
|
if test.wantRaw {
|
||||||
|
persisted, readErr := os.ReadFile(result.GeneratedTextRawPath)
|
||||||
|
if readErr != nil || !bytes.Equal(persisted, []byte(test.raw)) {
|
||||||
|
t.Fatalf("retained raw output = %q, error %v", persisted, readErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if test.name == "notification" && (len(notifier.requests) != 1 || notifier.requests[0].ReportPath != result.ReportPath) {
|
||||||
|
t.Fatalf("notification requests = %#v", notifier.requests)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedDebugFailuresRespectProviderBoundary(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
createCollision func(string, report.Resolved) error
|
||||||
|
wantProviderCalls int
|
||||||
|
wantPreparationFile bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "preparation debug",
|
||||||
|
createCollision: func(root string, resolved report.Resolved) error {
|
||||||
|
path := workflowDebugRunPath(root, resolved)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, []byte("not a directory"), 0o600)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "execution debug", wantProviderCalls: 1, wantPreparationFile: true,
|
||||||
|
createCollision: func(root string, resolved report.Resolved) error {
|
||||||
|
return os.MkdirAll(filepath.Join(workflowDebugRunPath(root, resolved), "execution.json"), 0o700)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
cfg := workflowConfig(t)
|
||||||
|
cfg.Notify.Distributor.Enabled = false
|
||||||
|
now := workflowTime("2026-05-29T08:30:00-05:00")
|
||||||
|
request := GenerateRequest{Config: cfg, Report: ReportDaily, Date: workflowTime("2026-05-29T12:00:00-05:00"), Now: now}
|
||||||
|
resolved, err := ResolveGenerate(request, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||||
|
}
|
||||||
|
debugRoot := filepath.Join(t.TempDir(), "prompt-debug")
|
||||||
|
if err := test.createCollision(debugRoot, resolved); err != nil {
|
||||||
|
t.Fatalf("create debug collision: %v", err)
|
||||||
|
}
|
||||||
|
bundle := workflowBundle(t)
|
||||||
|
executor := &workflowExecutor{definition: resolved.Definition, raw: []byte(validDailyWorkflowJSON())}
|
||||||
|
request.Collector = &workflowCollector{result: &collect.Result{Bundle: &bundle}}
|
||||||
|
request.Executor = executor
|
||||||
|
request.LLMDebugDir = debugRoot
|
||||||
|
result, err := GenerateDetailed(context.Background(), request)
|
||||||
|
if err == nil || result == nil || promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
|
||||||
|
t.Fatalf("result/error/category = %#v/%v/%q", result, err, promptexec.CategoryOf(err))
|
||||||
|
}
|
||||||
|
if executor.providerCalls != test.wantProviderCalls || result.PreparationPath == "" || result.ExecutionPath != "" || result.GeneratedTextRawPath != "" {
|
||||||
|
t.Fatalf("provider/preparation/metadata/execution/raw = %d/%q/%q/%q/%q", executor.providerCalls, result.PreparationPath, result.MetadataPath, result.ExecutionPath, result.GeneratedTextRawPath)
|
||||||
|
}
|
||||||
|
if test.wantPreparationFile && result.MetadataPath == "" {
|
||||||
|
t.Fatal("execution debug failure lost previously persisted metadata")
|
||||||
|
}
|
||||||
|
preparationDebug := filepath.Join(workflowDebugRunPath(debugRoot, resolved), "preparation.json")
|
||||||
|
_, statErr := os.Stat(preparationDebug)
|
||||||
|
if (statErr == nil) != test.wantPreparationFile {
|
||||||
|
t.Fatalf("preparation debug stat error = %v, want file %t", statErr, test.wantPreparationFile)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func workflowDebugRunPath(root string, resolved report.Resolved) string {
|
||||||
|
return filepath.Join(root, string(resolved.Definition.ID), resolved.ValidPeriod.Start.Format("2006-01-02"), resolved.Metadata().RunID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func workflowBundlePaths(id report.ID, validDate, runID string) []string {
|
||||||
|
switch id {
|
||||||
|
case report.Daily:
|
||||||
|
return []string{"daily/" + validDate + "/" + runID + ".md", "daily/" + validDate + "/index.md"}
|
||||||
|
case report.Today:
|
||||||
|
return []string{"daily/" + validDate + "/" + runID + ".md", "daily/" + validDate + "/index.md", "today/index.md"}
|
||||||
|
case report.Tomorrow:
|
||||||
|
return []string{"daily/" + validDate + "/" + runID + ".md", "daily/" + validDate + "/index.md", "tomorrow/index.md"}
|
||||||
|
case report.Hourly:
|
||||||
|
return []string{"hourly/index.md"}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDetailedSelectsPriorSnapshotsForRetainedReports(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
kind ReportKind
|
||||||
|
id report.ID
|
||||||
|
date time.Time
|
||||||
|
raw string
|
||||||
|
wantPrior bool
|
||||||
|
wantRecentChanges bool
|
||||||
|
}{
|
||||||
|
{name: "daily", kind: ReportDaily, id: report.Daily, date: workflowTime("2026-05-29T12:00:00-05:00"), raw: validDailyWorkflowJSON(), wantPrior: true, wantRecentChanges: true},
|
||||||
|
{name: "today", kind: ReportToday, id: report.Today, raw: validTodayWorkflowJSON(), wantPrior: true, wantRecentChanges: true},
|
||||||
|
{name: "tomorrow", kind: ReportTomorrow, id: report.Tomorrow, raw: validTomorrowWorkflowJSON(), wantPrior: true, wantRecentChanges: true},
|
||||||
|
{name: "hourly", kind: ReportHourly, id: report.Hourly, raw: validHourlyWorkflowJSON()},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
cfg := workflowConfig(t)
|
||||||
|
cfg.Notify.Distributor.Enabled = false
|
||||||
|
definition := report.DefaultRegistry().MustLookup(test.id)
|
||||||
|
firstBundle := workflowBundle(t)
|
||||||
|
setWorkflowTemperatures(&firstBundle, 45)
|
||||||
|
first, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||||
|
Config: cfg, Report: test.kind, Date: test.date, Now: workflowTime("2026-05-29T08:00:00-05:00"),
|
||||||
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &firstBundle}},
|
||||||
|
Executor: &workflowExecutor{definition: definition, raw: []byte(test.raw)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first GenerateDetailed() error = %v", err)
|
||||||
|
}
|
||||||
|
secondBundle := workflowBundle(t)
|
||||||
|
setWorkflowTemperatures(&secondBundle, 85)
|
||||||
|
second, err := GenerateDetailed(context.Background(), GenerateRequest{
|
||||||
|
Config: cfg, Report: test.kind, Date: test.date, Now: workflowTime("2026-05-29T08:30:00-05:00"),
|
||||||
|
Collector: &workflowCollector{result: &collect.Result{Bundle: &secondBundle}},
|
||||||
|
Executor: &workflowExecutor{definition: definition, raw: []byte(test.raw)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second GenerateDetailed() error = %v", err)
|
||||||
|
}
|
||||||
|
if test.wantPrior && (second.PriorSnapshot == nil || second.PriorSnapshot.Metadata.RunID != first.Metadata.RunID || second.PriorSnapshot.Metadata.ReportID != test.id) {
|
||||||
|
t.Fatalf("prior snapshot = %#v, want first %s run %q", second.PriorSnapshot, test.id, first.Metadata.RunID)
|
||||||
|
}
|
||||||
|
if !test.wantPrior && second.PriorSnapshot != nil {
|
||||||
|
t.Fatalf("prior snapshot = %#v, want none for non-overlapping rolling window", second.PriorSnapshot)
|
||||||
|
}
|
||||||
|
if (len(second.RecentChanges) > 0) != test.wantRecentChanges {
|
||||||
|
t.Fatalf("recent changes = %#v, want present %t", second.RecentChanges, test.wantRecentChanges)
|
||||||
|
}
|
||||||
|
if (len(second.DataPackage.RecentChanges.Items) > 0) != test.wantRecentChanges {
|
||||||
|
t.Fatalf("data package recent changes = %#v, want present %t", second.DataPackage.RecentChanges.Items, test.wantRecentChanges)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setWorkflowTemperatures(bundle *weatherdata.Bundle, temperature float64) {
|
||||||
|
for index := range bundle.Hourly.Periods {
|
||||||
|
value := temperature
|
||||||
|
bundle.Hourly.Periods[index].TemperatureF = &value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func workflowConfig(t *testing.T) config.Config {
|
||||||
|
t.Helper()
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.Workspace.Root = t.TempDir()
|
||||||
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||||
|
cfg.Location.ID = "home"
|
||||||
|
cfg.Location.Name = "Testville"
|
||||||
|
cfg.Location.Region = "MO"
|
||||||
|
cfg.Notify.Distributor.Enabled = true
|
||||||
|
cfg.Notify.Distributor.PipelineIDTemplate = "reports.{report_id}.{artifact_group}"
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func workflowBundle(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)
|
||||||
|
}
|
||||||
|
future := bundle.Hourly.Periods[0]
|
||||||
|
future.StartTime = workflowTime("2026-05-30T06:00:00-05:00")
|
||||||
|
future.EndTime = workflowTime("2026-05-30T07:00:00-05:00")
|
||||||
|
bundle.Hourly.Periods = append(bundle.Hourly.Periods, future)
|
||||||
|
futureNarrative := bundle.Narrative.Periods[0]
|
||||||
|
futureNarrative.StartTime = workflowTime("2026-05-30T06:00:00-05:00")
|
||||||
|
futureNarrative.EndTime = workflowTime("2026-05-30T18:00:00-05:00")
|
||||||
|
futureNarrative.Name = "Tomorrow"
|
||||||
|
bundle.Narrative.Periods = append(bundle.Narrative.Periods, futureNarrative)
|
||||||
|
return bundle
|
||||||
|
}
|
||||||
|
|
||||||
|
func workflowTime(value string) time.Time {
|
||||||
|
parsed, err := time.Parse(time.RFC3339, value)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func validHourlyWorkflowJSON() string {
|
||||||
|
return `{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region.","confidence":"Medium"}`
|
||||||
|
}
|
||||||
|
|
||||||
|
func validTomorrowWorkflowJSON() string {
|
||||||
|
return `{"summary":"Tomorrow starts with showers before improving.","forecast_discussion":["Morning showers should taper as drier air arrives.","Afternoon conditions trend quieter."],"precipitation_timing":"The best rain chance is during the morning."}`
|
||||||
|
}
|
||||||
|
|
||||||
|
func validTodayWorkflowJSON() string {
|
||||||
|
return `{"summary":"Today starts with showers before improving.","forecast_discussion":["Morning showers should taper as drier air arrives.","Afternoon conditions trend quieter."],"precipitation_timing":"The best rain chance is during the morning."}`
|
||||||
|
}
|
||||||
|
|
||||||
|
func validDailyWorkflowJSON() string {
|
||||||
|
return `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast.","Temperatures stay seasonable by afternoon."],"precipitation_timing":"Rain is most likely during the afternoon.","confidence":"Medium"}`
|
||||||
|
}
|
||||||
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 {
|
type AlertSummary struct {
|
||||||
Event string `json:"event,omitempty"`
|
Event string `json:"event,omitempty"`
|
||||||
Headline string `json:"headline,omitempty"`
|
Headline string `json:"headline,omitempty"`
|
||||||
Severity string `json:"severity,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) {
|
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 {
|
if value == nil {
|
||||||
value = &AlertDigestModule{}
|
value = &AlertDigestModule{}
|
||||||
}
|
}
|
||||||
return &module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: *value}, nil
|
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")
|
missing := sourceMissing(collected.SourceProvenance, "alerts")
|
||||||
if collected.Alerts == nil && !missing {
|
if collected.Alerts == nil && !missing {
|
||||||
return nil
|
return nil
|
||||||
@@ -42,9 +46,13 @@ func alertDigest(collected facts.CollectedFacts, overlaps []forecast.AlertOverla
|
|||||||
value.RelevantCount = len(overlaps)
|
value.RelevantCount = len(overlaps)
|
||||||
for _, overlap := range overlaps {
|
for _, overlap := range overlaps {
|
||||||
value.Relevant = append(value.Relevant, AlertSummary{
|
value.Relevant = append(value.Relevant, AlertSummary{
|
||||||
Event: overlap.Event,
|
Event: overlap.Event,
|
||||||
Headline: overlap.Headline,
|
Headline: overlap.Headline,
|
||||||
Severity: overlap.Severity,
|
Severity: overlap.Severity,
|
||||||
|
PeriodBegins: friendlyMonthDayTimeLabel(overlap.Period.Start, timezone),
|
||||||
|
PeriodEnds: friendlyMonthDayTimeLabel(overlap.Period.End, timezone),
|
||||||
|
Instruction: overlap.Instruction,
|
||||||
|
Description: overlap.Description,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"categorical:TSTM": {
|
||||||
|
"plain_language": "General or non-severe thunderstorms.",
|
||||||
|
"official_description": "No severe thunderstorms expected.",
|
||||||
|
"relative_level": "0 of 5"
|
||||||
|
},
|
||||||
|
"categorical:MRGL": {
|
||||||
|
"plain_language": "Isolated severe storms possible.",
|
||||||
|
"official_description": "Isolated severe storms may occur within the risk area, but they are expected to be limited in duration, coverage, and intensity.",
|
||||||
|
"relative_level": "1 of 5"
|
||||||
|
},
|
||||||
|
"categorical:SLGT": {
|
||||||
|
"plain_language": "Scattered severe storms possible.",
|
||||||
|
"official_description": "Isolated intense storms are possible within the risk area, but severe weather is generally expected to be short-lived and/or not widespread.",
|
||||||
|
"relative_level": "2 of 5"
|
||||||
|
},
|
||||||
|
"categorical:ENH": {
|
||||||
|
"plain_language": "Numerous severe storms possible.",
|
||||||
|
"official_description": "Numerous severe storms are possible within the risk area, some of which may be intense.",
|
||||||
|
"relative_level": "3 of 5"
|
||||||
|
},
|
||||||
|
"categorical:MDT": {
|
||||||
|
"plain_language": "Widespread severe storms likely.",
|
||||||
|
"official_description": "Widespread severe storms are likely within the risk area. Storms may be long-lived, widespread, and intense. This risk is usually reserved for days with several supercells producing intense tornadoes and/or very large hail, or an intense squall line with widespread damaging winds.",
|
||||||
|
"relative_level": "4 of 5"
|
||||||
|
},
|
||||||
|
"categorical:HIGH": {
|
||||||
|
"plain_language": "Major severe outbreak expected.",
|
||||||
|
"official_description": "A major severe weather outbreak is expected, with long-lived, very widespread, and particularly intense severe storms. This risk is reserved for when high confidence exists in widespread coverage of severe weather with embedded instances of extreme severity (i.e., violent tornadoes or very damaging convective wind events).",
|
||||||
|
"relative_level": "5 of 5"
|
||||||
|
},
|
||||||
|
"tornado:CIG1": {
|
||||||
|
"plain_language": "Conditional potential for significant tornadoes.",
|
||||||
|
"official_description": "Intensity Level 1: Reasonable Max EF2. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
|
||||||
|
"relative_level": "1 of 3"
|
||||||
|
},
|
||||||
|
"tornado:CIG2": {
|
||||||
|
"plain_language": "Conditional potential for strong tornadoes.",
|
||||||
|
"official_description": "Intensity Level 2: Reasonable Max EF3. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
|
||||||
|
"relative_level": "2 of 3"
|
||||||
|
},
|
||||||
|
"tornado:CIG3": {
|
||||||
|
"plain_language": "Conditional potential for violent tornadoes.",
|
||||||
|
"official_description": "Intensity Level 3: Reasonable Max EF4 or higher. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
|
||||||
|
"relative_level": "3 of 3"
|
||||||
|
},
|
||||||
|
"wind:CIG1": {
|
||||||
|
"plain_language": "Conditional potential for significant severe wind.",
|
||||||
|
"official_description": "Intensity Level 1: Reasonable Max wind gusts around 65 kt / 75 mph or higher. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
|
||||||
|
"relative_level": "1 of 3"
|
||||||
|
},
|
||||||
|
"wind:CIG2": {
|
||||||
|
"plain_language": "Conditional potential for intense severe wind.",
|
||||||
|
"official_description": "Intensity Level 2: Reasonable Max wind gusts around 75 kt / 85 mph or higher. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
|
||||||
|
"relative_level": "2 of 3"
|
||||||
|
},
|
||||||
|
"wind:CIG3": {
|
||||||
|
"plain_language": "Conditional potential for extreme severe wind.",
|
||||||
|
"official_description": "Intensity Level 3: Reasonable Max wind gusts around 100 kt / 115 mph or higher. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
|
||||||
|
"relative_level": "3 of 3"
|
||||||
|
},
|
||||||
|
"hail:CIG1": {
|
||||||
|
"plain_language": "Conditional potential for significant hail.",
|
||||||
|
"official_description": "Intensity Level 1: Reasonable Max hail size around 2.00 to 3.75 inches. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
|
||||||
|
"relative_level": "1 of 2"
|
||||||
|
},
|
||||||
|
"hail:CIG2": {
|
||||||
|
"plain_language": "Conditional potential for giant hail.",
|
||||||
|
"official_description": "Intensity Level 2: Reasonable Max hail size greater than 3.75 inches. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
|
||||||
|
"relative_level": "2 of 2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,6 +89,48 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestHourlyForecastPrecipMentionThreshold(t *testing.T) {
|
||||||
periods := []weatherdata.ForecastPeriod{
|
periods := []weatherdata.ForecastPeriod{
|
||||||
{StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"), ProbabilityOfPrecipitationPercent: floatPtr(19)},
|
{StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"), ProbabilityOfPrecipitationPercent: floatPtr(19)},
|
||||||
@@ -113,10 +155,10 @@ func TestHourlyForecastPrecipMentionThreshold(t *testing.T) {
|
|||||||
func TestHourlyForecastModuleRejectsUnsupportedReports(t *testing.T) {
|
func TestHourlyForecastModuleRejectsUnsupportedReports(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := testModuleContext()
|
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})
|
_, 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)
|
t.Fatalf("BuildModule() error = %v, want incompatible report", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,10 +227,10 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
|||||||
func TestNarrativeForecastModuleRejectsUnsupportedReports(t *testing.T) {
|
func TestNarrativeForecastModuleRejectsUnsupportedReports(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := testModuleContext()
|
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})
|
_, 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)
|
t.Fatalf("BuildModule() error = %v, want incompatible report", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,7 +244,7 @@ func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
|
|||||||
t.Fatalf("BuildModule() error = %v", err)
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
}
|
}
|
||||||
value := moduleValue[MetadataModule](t, output)
|
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)
|
t.Fatalf("metadata = %#v, want report identity", value)
|
||||||
}
|
}
|
||||||
if value.Location == nil || value.Location.Name != "Brentwood" {
|
if value.Location == nil || value.Location.Name != "Brentwood" {
|
||||||
@@ -211,9 +253,6 @@ func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
|
|||||||
if len(value.SourceWarnings) != 1 || value.SourceWarnings[0].CompletenessImpact != "source omitted" {
|
if len(value.SourceWarnings) != 1 || value.SourceWarnings[0].CompletenessImpact != "source omitted" {
|
||||||
t.Fatalf("SourceWarnings = %#v, want warning summary", value.SourceWarnings)
|
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)
|
data, err := json.Marshal(output.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Marshal metadata: %v", err)
|
t.Fatalf("Marshal metadata: %v", err)
|
||||||
@@ -222,6 +261,9 @@ func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
|
|||||||
if !strings.Contains(jsonText, "source_warnings") || strings.Contains(jsonText, "endpoint") || strings.Contains(jsonText, "dataSha256") {
|
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)
|
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) {
|
func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) {
|
||||||
@@ -260,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) {
|
func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := testModuleContext()
|
ctx := testModuleContext()
|
||||||
@@ -287,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) {
|
func TestBaseModulesOmitMissingOptionalOutputs(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := testModuleContext()
|
ctx := testModuleContext()
|
||||||
@@ -393,9 +506,37 @@ func TestAreaForecastDiscussionModuleUsesHourlyDefaultSections(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func testModuleContext() ModuleContext {
|
||||||
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
|
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
|
||||||
definition := report.DefaultRegistry().MustLookup(report.DailyToday)
|
definition := report.DefaultRegistry().MustLookup(report.Daily)
|
||||||
resolved := report.Resolved{
|
resolved := report.Resolved{
|
||||||
Definition: definition,
|
Definition: definition,
|
||||||
GeneratedAt: generatedAt,
|
GeneratedAt: generatedAt,
|
||||||
@@ -563,6 +704,28 @@ func moduleValue[T any](t *testing.T, output *module.Output) T {
|
|||||||
return value
|
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 {
|
func mustParseModuleTime(value string) time.Time {
|
||||||
parsed, err := time.Parse(time.RFC3339, value)
|
parsed, err := time.Parse(time.RFC3339, value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -23,6 +23,21 @@ type CurrentConditionsModule struct {
|
|||||||
WindDirectionText string `json:"wind_direction_text,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) {
|
func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
current := ctx.Collected.Current
|
current := ctx.Collected.Current
|
||||||
if current == nil {
|
if current == nil {
|
||||||
@@ -50,6 +65,27 @@ func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, err
|
|||||||
return &module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: value}, nil
|
return &module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: value}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func exportCurrentConditionsPromptValue(value any) (any, error) {
|
||||||
|
rich, ok := value.(CurrentConditionsModule)
|
||||||
|
if !ok {
|
||||||
|
return nil, unexpectedPromptExportValue(value, CurrentConditionsModule{})
|
||||||
|
}
|
||||||
|
return CurrentConditionsPromptExport{
|
||||||
|
ConditionText: rich.ConditionText,
|
||||||
|
IsDay: copyBool(rich.IsDay),
|
||||||
|
TemperatureC: copyInt(rich.TemperatureC),
|
||||||
|
TemperatureF: copyInt(rich.TemperatureF),
|
||||||
|
ApparentTemperatureC: copyInt(rich.ApparentTemperatureC),
|
||||||
|
ApparentTemperatureF: copyInt(rich.ApparentTemperatureF),
|
||||||
|
DewpointC: copyInt(rich.DewpointC),
|
||||||
|
DewpointF: copyInt(rich.DewpointF),
|
||||||
|
RelativeHumidityPercent: copyInt(rich.RelativeHumidityPercent),
|
||||||
|
WindSpeedKmh: copyInt(rich.WindSpeedKmh),
|
||||||
|
WindSpeedMph: copyInt(rich.WindSpeedMph),
|
||||||
|
WindDirection: rich.WindDirection,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (v CurrentConditionsModule) isEmpty() bool {
|
func (v CurrentConditionsModule) isEmpty() bool {
|
||||||
return v.ConditionText == "" &&
|
return v.ConditionText == "" &&
|
||||||
v.ConditionTextLower == "" &&
|
v.ConditionTextLower == "" &&
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -44,6 +44,34 @@ type DerivedDaypartSummaryModule struct {
|
|||||||
RelevantAlertCount int `json:"relevant_alert_count,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) {
|
func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
if len(ctx.Derived.DaypartSummaries) == 0 {
|
if len(ctx.Derived.DaypartSummaries) == 0 {
|
||||||
return nil, fmt.Errorf("daypart summary facts are required")
|
return nil, fmt.Errorf("daypart summary facts are required")
|
||||||
@@ -57,6 +85,52 @@ func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Outpu
|
|||||||
return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil
|
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 {
|
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
|
||||||
temperature := daypartTemperatureDisplay(daypart)
|
temperature := daypartTemperatureDisplay(daypart)
|
||||||
value := DerivedDaypartSummaryModule{
|
value := DerivedDaypartSummaryModule{
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
|
|
||||||
func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.DailyToday)
|
ctx := derivedModuleContext(report.Daily)
|
||||||
|
|
||||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -67,7 +67,7 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
|||||||
|
|
||||||
func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
|
func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.DailyToday)
|
ctx := derivedModuleContext(report.Daily)
|
||||||
ctx.Derived.DailySummaries[0].NarrativePeriods = nil
|
ctx.Derived.DailySummaries[0].NarrativePeriods = nil
|
||||||
|
|
||||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
|
||||||
@@ -89,7 +89,7 @@ func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
|
|||||||
|
|
||||||
func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.DailyToday)
|
ctx := derivedModuleContext(report.Daily)
|
||||||
|
|
||||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -105,9 +105,15 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
|||||||
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" {
|
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])
|
t.Fatalf("first precipitation window = %#v, want 8-9 AM at 60%%", rainy.PrecipitationWindows[0])
|
||||||
}
|
}
|
||||||
|
if rainy.PrecipitationWindows[0].PrecipitationType != "showers" || rainy.PrecipitationWindows[0].ExpectationPhrase != "Showers likely." {
|
||||||
|
t.Fatalf("first precipitation window phrase = %#v, want showers likely", rainy.PrecipitationWindows[0])
|
||||||
|
}
|
||||||
if rainy.PrecipitationWindows[1].PeriodBegins != "2026-05-29 at 12:00 PM" || rainy.PrecipitationWindows[1].PeriodBeginsHourLabel != "12:00 PM" || rainy.PrecipitationWindows[1].PeriodEnds != "2026-05-29 at 2:00 PM" || rainy.PrecipitationWindows[1].PeriodEndsHourLabel != "2:00 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 || rainy.PrecipitationWindows[1].MaxPopHourLabel != "12:00 PM" {
|
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])
|
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)
|
data, err := json.Marshal(output.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("marshal precip timing: %v", err)
|
t.Fatalf("marshal precip timing: %v", err)
|
||||||
@@ -115,6 +121,9 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
|||||||
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") {
|
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))
|
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"`) {
|
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))
|
t.Fatalf("precip timing json = %s, want period_begins/period_ends instead of start/end", string(data))
|
||||||
}
|
}
|
||||||
@@ -136,6 +145,100 @@ 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: forecast.DefaultPrecipWindowProbabilityThreshold,
|
||||||
|
PrecipitationWindows: []forecast.PrecipitationWindow{
|
||||||
|
{
|
||||||
|
Start: now,
|
||||||
|
MaxPrecipitationProbability: forecast.TimedValue{
|
||||||
|
Value: tt.maxPop,
|
||||||
|
Time: now,
|
||||||
|
},
|
||||||
|
ProbabilityThreshold: forecast.DefaultPrecipWindowProbabilityThreshold,
|
||||||
|
TextDescriptions: tt.descriptions,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, "America/Chicago")
|
||||||
|
if len(value.PrecipitationWindows) != 1 {
|
||||||
|
t.Fatalf("PrecipitationWindows = %#v, want one window", value.PrecipitationWindows)
|
||||||
|
}
|
||||||
|
window := value.PrecipitationWindows[0]
|
||||||
|
if window.PrecipitationType != tt.wantType || window.ExpectationPhrase != tt.wantPhrase {
|
||||||
|
t.Fatalf("window = %#v, want type %q and phrase %q", window, tt.wantType, tt.wantPhrase)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPrecipTimingModuleUsesDerivedTimingWithoutDaypartSummaries(t *testing.T) {
|
func TestPrecipTimingModuleUsesDerivedTimingWithoutDaypartSummaries(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.Hourly)
|
ctx := derivedModuleContext(report.Hourly)
|
||||||
@@ -154,7 +257,7 @@ func TestPrecipTimingModuleUsesDerivedTimingWithoutDaypartSummaries(t *testing.T
|
|||||||
|
|
||||||
func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.DailyToday)
|
ctx := derivedModuleContext(report.Daily)
|
||||||
|
|
||||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -201,6 +304,126 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDerivedDaypartSummariesPromptExportOmitsTemplateHelpers(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := derivedModuleContext(report.Daily)
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
richText := mustMarshalModuleJSON(t, output.Value)
|
||||||
|
for _, field := range []string{"temperature_phrase_f", "dominant_condition_lower", "dominant_condition_display", "max_pop_time_label"} {
|
||||||
|
if !strings.Contains(richText, field) {
|
||||||
|
t.Fatalf("rich daypart json = %s, want helper field %s", richText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt := moduleDataPackageValue[map[string]DerivedDaypartSummaryPromptExport](t, output)
|
||||||
|
morning, ok := prompt["morning"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("daypart prompt keys = %#v, want morning", prompt)
|
||||||
|
}
|
||||||
|
if morning.Date != "2026-05-29" || morning.DisplayName != "Morning" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" {
|
||||||
|
t.Fatalf("morning prompt period = %#v, want date/display/period labels", morning)
|
||||||
|
}
|
||||||
|
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 || morning.MaxPopTime != "6:00 AM" || !morning.MentionPrecipitation {
|
||||||
|
t.Fatalf("morning prompt precip/temp = %#v, want factual prompt fields with friendly max pop time", morning)
|
||||||
|
}
|
||||||
|
if morning.DominantCondition != "Showers" || morning.TemperatureTrend != "steady" || morning.TemperatureSteadyPhraseF != "upper 50s" {
|
||||||
|
t.Fatalf("morning prompt condition/trend = %#v, want condition and trend fields", morning)
|
||||||
|
}
|
||||||
|
if len(morning.NotableConditions) == 0 || morning.NotableConditions[0] != "Showers" {
|
||||||
|
t.Fatalf("morning prompt notable conditions = %#v, want copied conditions", morning.NotableConditions)
|
||||||
|
}
|
||||||
|
afternoon := prompt["afternoon"]
|
||||||
|
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 || afternoon.RelevantAlertCount != 1 {
|
||||||
|
t.Fatalf("afternoon prompt = %#v, want hazard, wind, and alert fields", afternoon)
|
||||||
|
}
|
||||||
|
|
||||||
|
promptText := mustMarshalModuleJSON(t, output.DataPackageValue())
|
||||||
|
for _, field := range []string{"date", "display_name", "period_begins", "period_ends", "temp_range_f", "max_pop_percent", "max_pop_time", "mention_precipitation", "max_wind_gust_mph", "dominant_condition", "temperature_trend", "temperature_steady_phrase_f", "notable_conditions", "relevant_alert_count"} {
|
||||||
|
if !strings.Contains(promptText, field) {
|
||||||
|
t.Fatalf("daypart prompt json = %s, want field %s", promptText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, field := range []string{"temperature_phrase_f", "dominant_condition_lower", "dominant_condition_display", "max_pop_time_label"} {
|
||||||
|
if strings.Contains(promptText, field) {
|
||||||
|
t.Fatalf("daypart prompt json = %s, want omitted helper field %s", promptText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDerivedDaypartPromptExportTemperatureTrends(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
temps []float64
|
||||||
|
wantTrend string
|
||||||
|
wantStart string
|
||||||
|
wantEnd string
|
||||||
|
wantPeak string
|
||||||
|
wantSteady string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "rising",
|
||||||
|
temps: []float64{58, 68},
|
||||||
|
wantTrend: "rising",
|
||||||
|
wantStart: "upper 50s",
|
||||||
|
wantEnd: "upper 60s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "falling",
|
||||||
|
temps: []float64{65, 58},
|
||||||
|
wantTrend: "falling",
|
||||||
|
wantStart: "mid 60s",
|
||||||
|
wantEnd: "upper 50s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "peaking",
|
||||||
|
temps: []float64{62, 78, 65},
|
||||||
|
wantTrend: "peaking",
|
||||||
|
wantPeak: "upper 70s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "steady",
|
||||||
|
temps: []float64{77, 78},
|
||||||
|
wantTrend: "steady",
|
||||||
|
wantSteady: "upper 70s",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
rich := derivedDaypartSummaryValue(derivedDaypartWithTemperatures(test.name, "2026-05-29T12:00:00-05:00", "sunny", test.temps...), "America/Chicago")
|
||||||
|
prompt := derivedDaypartSummaryPromptValue(rich)
|
||||||
|
if prompt.TemperatureTrend != test.wantTrend ||
|
||||||
|
prompt.TemperatureStartPhraseF != test.wantStart ||
|
||||||
|
prompt.TemperatureEndPhraseF != test.wantEnd ||
|
||||||
|
prompt.TemperaturePeakPhraseF != test.wantPeak ||
|
||||||
|
prompt.TemperatureSteadyPhraseF != test.wantSteady {
|
||||||
|
t.Fatalf("prompt temperature presentation = %#v", prompt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDerivedDaypartPromptExportMaxPopTimeFallback(t *testing.T) {
|
||||||
|
withLabel := derivedDaypartSummaryPromptValue(DerivedDaypartSummaryModule{
|
||||||
|
MaxPopTime: "6 AM",
|
||||||
|
MaxPopTimeLabel: "6:00 AM",
|
||||||
|
})
|
||||||
|
if withLabel.MaxPopTime != "6:00 AM" {
|
||||||
|
t.Fatalf("MaxPopTime with label = %q, want friendly label", withLabel.MaxPopTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
withoutLabel := derivedDaypartSummaryPromptValue(DerivedDaypartSummaryModule{
|
||||||
|
MaxPopTime: "6 AM",
|
||||||
|
})
|
||||||
|
if withoutLabel.MaxPopTime != "6 AM" {
|
||||||
|
t.Fatalf("MaxPopTime without label = %q, want fallback time", withoutLabel.MaxPopTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDerivedDaypartTemperaturePresentationFields(t *testing.T) {
|
func TestDerivedDaypartTemperaturePresentationFields(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -337,9 +560,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) {
|
func TestDerivedModulesHandleMissingData(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.DailyToday)
|
ctx := derivedModuleContext(report.Daily)
|
||||||
ctx.Derived.DailySummaries = nil
|
ctx.Derived.DailySummaries = nil
|
||||||
ctx.Derived.DaypartSummaries = nil
|
ctx.Derived.DaypartSummaries = nil
|
||||||
|
|
||||||
@@ -430,6 +798,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 {
|
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)
|
hour := derivedHour(start, text, precip, temperature, apparent, gust)
|
||||||
return forecast.SummarizeDaypart(name, timeutil.Period{
|
return forecast.SummarizeDaypart(name, timeutil.Period{
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ type HourlyForecastModule struct {
|
|||||||
Periods []HourlyForecastPeriod `json:"periods,omitempty"`
|
Periods []HourlyForecastPeriod `json:"periods,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HourlyForecastPromptExport struct {
|
||||||
|
Product string `json:"product,omitempty"`
|
||||||
|
IssuedAt time.Time `json:"issued_at,omitempty"`
|
||||||
|
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||||
|
SourceLocation string `json:"source_location,omitempty"`
|
||||||
|
SourceLocationID string `json:"source_location_id,omitempty"`
|
||||||
|
Periods []HourlyForecastPromptPeriod `json:"periods,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type HourlyForecastPeriod struct {
|
type HourlyForecastPeriod struct {
|
||||||
HourLabel string `json:"hour_label,omitempty"`
|
HourLabel string `json:"hour_label,omitempty"`
|
||||||
PeriodBegins string `json:"period_begins,omitempty"`
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
@@ -59,6 +68,42 @@ type HourlyForecastPeriod struct {
|
|||||||
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
|
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HourlyForecastPromptPeriod struct {
|
||||||
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
IsDay *bool `json:"is_day,omitempty"`
|
||||||
|
ConditionCode *int `json:"condition_code,omitempty"`
|
||||||
|
TextDescription string `json:"text_description,omitempty"`
|
||||||
|
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||||
|
TemperatureF *float64 `json:"temperature_f,omitempty"`
|
||||||
|
TemperatureCMin *float64 `json:"temperature_c_min,omitempty"`
|
||||||
|
TemperatureFMin *float64 `json:"temperature_f_min,omitempty"`
|
||||||
|
TemperatureCMax *float64 `json:"temperature_c_max,omitempty"`
|
||||||
|
TemperatureFMax *float64 `json:"temperature_f_max,omitempty"`
|
||||||
|
DewpointC *float64 `json:"dewpoint_c,omitempty"`
|
||||||
|
DewpointF *float64 `json:"dewpoint_f,omitempty"`
|
||||||
|
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
|
||||||
|
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
|
||||||
|
WindGustKmh *float64 `json:"wind_gust_kmh,omitempty"`
|
||||||
|
WindGustMph *float64 `json:"wind_gust_mph,omitempty"`
|
||||||
|
WindDirection string `json:"wind_direction,omitempty"`
|
||||||
|
BarometricPressurePa *float64 `json:"barometric_pressure_pa,omitempty"`
|
||||||
|
BarometricPressureInHg *float64 `json:"barometric_pressure_in_hg,omitempty"`
|
||||||
|
VisibilityMeters *float64 `json:"visibility_meters,omitempty"`
|
||||||
|
VisibilityMiles *float64 `json:"visibility_miles,omitempty"`
|
||||||
|
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"`
|
||||||
|
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"`
|
||||||
|
CloudCoverPercent *float64 `json:"cloud_cover_percent,omitempty"`
|
||||||
|
ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"`
|
||||||
|
PrecipitationAmountMm *float64 `json:"precipitation_amount_mm,omitempty"`
|
||||||
|
PrecipitationAmountIn *float64 `json:"precipitation_amount_in,omitempty"`
|
||||||
|
SnowfallDepthMM *float64 `json:"snowfall_depth_mm,omitempty"`
|
||||||
|
SnowfallDepthIn *float64 `json:"snowfall_depth_in,omitempty"`
|
||||||
|
UVIndex *float64 `json:"uv_index,omitempty"`
|
||||||
|
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
func buildHourlyForecastModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
func buildHourlyForecastModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
hourly := ctx.Collected.Hourly
|
hourly := ctx.Collected.Hourly
|
||||||
if hourly == nil || len(ctx.Derived.ValidPeriodHourlyPeriods) == 0 {
|
if hourly == nil || len(ctx.Derived.ValidPeriodHourlyPeriods) == 0 {
|
||||||
@@ -78,6 +123,66 @@ func buildHourlyForecastModule(ctx ModuleContext, _ any) (*module.Output, error)
|
|||||||
return &module.Output{ID: module.HourlyForecast, StanzaName: "hourly_forecast", Value: value}, nil
|
return &module.Output{ID: module.HourlyForecast, StanzaName: "hourly_forecast", Value: value}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func exportHourlyForecastPromptValue(value any) (any, error) {
|
||||||
|
rich, ok := value.(HourlyForecastModule)
|
||||||
|
if !ok {
|
||||||
|
return nil, unexpectedPromptExportValue(value, HourlyForecastModule{})
|
||||||
|
}
|
||||||
|
return HourlyForecastPromptExport{
|
||||||
|
Product: rich.Product,
|
||||||
|
IssuedAt: rich.IssuedAt,
|
||||||
|
UpdatedAt: copyTime(rich.UpdatedAt),
|
||||||
|
SourceLocation: rich.SourceLocation,
|
||||||
|
SourceLocationID: rich.SourceLocationID,
|
||||||
|
Periods: hourlyForecastPromptPeriods(rich.Periods),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hourlyForecastPromptPeriods(periods []HourlyForecastPeriod) []HourlyForecastPromptPeriod {
|
||||||
|
if len(periods) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]HourlyForecastPromptPeriod, 0, len(periods))
|
||||||
|
for _, period := range periods {
|
||||||
|
out = append(out, HourlyForecastPromptPeriod{
|
||||||
|
PeriodBegins: period.PeriodBegins,
|
||||||
|
PeriodEnds: period.PeriodEnds,
|
||||||
|
Name: period.Name,
|
||||||
|
IsDay: copyBool(period.IsDay),
|
||||||
|
ConditionCode: copyInt(period.ConditionCode),
|
||||||
|
TextDescription: period.TextDescription,
|
||||||
|
TemperatureC: copyFloat(period.TemperatureC),
|
||||||
|
TemperatureF: copyFloat(period.TemperatureF),
|
||||||
|
TemperatureCMin: copyFloat(period.TemperatureCMin),
|
||||||
|
TemperatureFMin: copyFloat(period.TemperatureFMin),
|
||||||
|
TemperatureCMax: copyFloat(period.TemperatureCMax),
|
||||||
|
TemperatureFMax: copyFloat(period.TemperatureFMax),
|
||||||
|
DewpointC: copyFloat(period.DewpointC),
|
||||||
|
DewpointF: copyFloat(period.DewpointF),
|
||||||
|
WindSpeedKmh: copyFloat(period.WindSpeedKmh),
|
||||||
|
WindSpeedMph: copyFloat(period.WindSpeedMph),
|
||||||
|
WindGustKmh: copyFloat(period.WindGustKmh),
|
||||||
|
WindGustMph: copyFloat(period.WindGustMph),
|
||||||
|
WindDirection: period.WindDirection,
|
||||||
|
BarometricPressurePa: copyFloat(period.BarometricPressurePa),
|
||||||
|
BarometricPressureInHg: copyFloat(period.BarometricPressureInHg),
|
||||||
|
VisibilityMeters: copyFloat(period.VisibilityMeters),
|
||||||
|
VisibilityMiles: copyFloat(period.VisibilityMiles),
|
||||||
|
ApparentTemperatureC: copyFloat(period.ApparentTemperatureC),
|
||||||
|
ApparentTemperatureF: copyFloat(period.ApparentTemperatureF),
|
||||||
|
CloudCoverPercent: copyFloat(period.CloudCoverPercent),
|
||||||
|
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
|
||||||
|
PrecipitationAmountMm: copyFloat(period.PrecipitationAmountMm),
|
||||||
|
PrecipitationAmountIn: copyFloat(period.PrecipitationAmountIn),
|
||||||
|
SnowfallDepthMM: copyFloat(period.SnowfallDepthMM),
|
||||||
|
SnowfallDepthIn: copyFloat(period.SnowfallDepthIn),
|
||||||
|
UVIndex: copyFloat(period.UVIndex),
|
||||||
|
RelativeHumidityPercent: copyFloat(period.RelativeHumidityPercent),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod {
|
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod {
|
||||||
return hourlyForecastPeriodsWithPrecipMentionThreshold(periods, timezone, DefaultHourlyForecastPrecipMentionProbabilityThreshold)
|
return hourlyForecastPeriodsWithPrecipMentionThreshold(periods, timezone, DefaultHourlyForecastPrecipMentionProbabilityThreshold)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ type MetadataModule struct {
|
|||||||
ValidPeriod timeutil.Period `json:"valid_period"`
|
ValidPeriod timeutil.Period `json:"valid_period"`
|
||||||
Location *LocationContext `json:"location,omitempty"`
|
Location *LocationContext `json:"location,omitempty"`
|
||||||
SourceWarnings []SourceWarningSummary `json:"source_warnings,omitempty"`
|
SourceWarnings []SourceWarningSummary `json:"source_warnings,omitempty"`
|
||||||
Alerts *AlertDigestModule `json:"alerts,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SourceWarningSummary struct {
|
type SourceWarningSummary struct {
|
||||||
@@ -44,7 +43,6 @@ func buildMetadataModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
|||||||
ValidPeriod: metadata.ValidPeriod,
|
ValidPeriod: metadata.ValidPeriod,
|
||||||
Location: copyLocation(ctx.Location),
|
Location: copyLocation(ctx.Location),
|
||||||
SourceWarnings: sourceWarningSummaries(ctx.Collected.SourceWarnings),
|
SourceWarnings: sourceWarningSummaries(ctx.Collected.SourceWarnings),
|
||||||
Alerts: alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps),
|
|
||||||
}
|
}
|
||||||
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil
|
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,17 @@ func friendlyDateTimeLabel(value time.Time, timezone string) string {
|
|||||||
return value.In(location).Format("2006-01-02 at 3:04 PM")
|
return value.In(location).Format("2006-01-02 at 3:04 PM")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func friendlyMonthDayTimeLabel(value time.Time, timezone string) string {
|
||||||
|
if value.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
location, err := timeutil.LoadLocation(timezone)
|
||||||
|
if err != nil {
|
||||||
|
location = time.UTC
|
||||||
|
}
|
||||||
|
return value.In(location).Format("January 2 at 3:04 PM")
|
||||||
|
}
|
||||||
|
|
||||||
func friendlyDateLabel(date string, timezone string) string {
|
func friendlyDateLabel(date string, timezone string) string {
|
||||||
location, err := timeutil.LoadLocation(timezone)
|
location, err := timeutil.LoadLocation(timezone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ type ModuleContext struct {
|
|||||||
|
|
||||||
type ModuleBuilder func(ModuleContext, any) (*module.Output, error)
|
type ModuleBuilder func(ModuleContext, any) (*module.Output, error)
|
||||||
|
|
||||||
|
type ModulePromptExporter func(value any) (any, error)
|
||||||
|
|
||||||
type ModuleDefinition struct {
|
type ModuleDefinition struct {
|
||||||
ID module.ID
|
ID module.ID
|
||||||
StanzaName string
|
StanzaName string
|
||||||
@@ -31,12 +33,17 @@ type ModuleDefinition struct {
|
|||||||
MissingData module.MissingDataBehavior
|
MissingData module.MissingDataBehavior
|
||||||
AllowDuplicate bool
|
AllowDuplicate bool
|
||||||
Builder ModuleBuilder
|
Builder ModuleBuilder
|
||||||
|
PromptExporter ModulePromptExporter
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModuleRegistry struct {
|
type ModuleRegistry struct {
|
||||||
definitions map[module.ID]ModuleDefinition
|
definitions map[module.ID]ModuleDefinition
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func unexpectedPromptExportValue(got any, want any) error {
|
||||||
|
return fmt.Errorf("value has type %T, want %T", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
func DefaultModuleRegistry() (ModuleRegistry, error) {
|
func DefaultModuleRegistry() (ModuleRegistry, error) {
|
||||||
return NewModuleRegistry(defaultModuleDefinitions())
|
return NewModuleRegistry(defaultModuleDefinitions())
|
||||||
}
|
}
|
||||||
@@ -130,6 +137,15 @@ func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (
|
|||||||
if output.StanzaName != definition.StanzaName {
|
if output.StanzaName != definition.StanzaName {
|
||||||
return nil, fmt.Errorf("module %q produced stanza %q, want %q", definition.ID, output.StanzaName, definition.StanzaName)
|
return nil, fmt.Errorf("module %q produced stanza %q, want %q", definition.ID, output.StanzaName, definition.StanzaName)
|
||||||
}
|
}
|
||||||
|
if definition.PromptExporter == nil {
|
||||||
|
output.PromptValue = output.Value
|
||||||
|
return output, nil
|
||||||
|
}
|
||||||
|
promptValue, err := definition.PromptExporter(output.Value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("module %q stanza %q prompt export: %w", definition.ID, definition.StanzaName, err)
|
||||||
|
}
|
||||||
|
output.PromptValue = promptValue
|
||||||
return output, nil
|
return output, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,8 +265,8 @@ func (d ModuleDefinition) ValidateOptions(options any) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func defaultModuleDefinitions() []ModuleDefinition {
|
func defaultModuleDefinitions() []ModuleDefinition {
|
||||||
allReports := []report.ID{report.DailyToday, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm}
|
allReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly}
|
||||||
daypartReports := []report.ID{report.DailyToday, report.Tomorrow, report.ThreeDay, report.Weekend}
|
daypartReports := []report.ID{report.Daily, report.Today, report.Tomorrow}
|
||||||
return []ModuleDefinition{
|
return []ModuleDefinition{
|
||||||
{
|
{
|
||||||
ID: module.Metadata,
|
ID: module.Metadata,
|
||||||
@@ -269,6 +285,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
SupportedReports: allReports,
|
SupportedReports: allReports,
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildCurrentConditionsModule,
|
Builder: buildCurrentConditionsModule,
|
||||||
|
PromptExporter: exportCurrentConditionsPromptValue,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: module.NarrativeForecast,
|
ID: module.NarrativeForecast,
|
||||||
@@ -276,7 +293,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
DefaultOptions: module.NarrativeForecastOptions{},
|
DefaultOptions: module.NarrativeForecastOptions{},
|
||||||
RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast},
|
RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods},
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods},
|
||||||
SupportedReports: []report.ID{report.DailyToday, report.Tomorrow},
|
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildNarrativeForecastModule,
|
Builder: buildNarrativeForecastModule,
|
||||||
},
|
},
|
||||||
@@ -286,16 +303,17 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
DefaultOptions: module.HourlyForecastOptions{},
|
DefaultOptions: module.HourlyForecastOptions{},
|
||||||
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
|
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
|
||||||
SupportedReports: []report.ID{report.DailyToday, report.Tomorrow, report.Hourly},
|
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly},
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildHourlyForecastModule,
|
Builder: buildHourlyForecastModule,
|
||||||
|
PromptExporter: exportHourlyForecastPromptValue,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: module.DerivedDailySummary,
|
ID: module.DerivedDailySummary,
|
||||||
StanzaName: "derived_daily_summary",
|
StanzaName: "derived_daily_summary",
|
||||||
DefaultOptions: module.DerivedDailySummaryOptions{},
|
DefaultOptions: module.DerivedDailySummaryOptions{},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming},
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming},
|
||||||
SupportedReports: []report.ID{report.DailyToday, report.Tomorrow},
|
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
|
||||||
MissingData: module.MissingDataError,
|
MissingData: module.MissingDataError,
|
||||||
Builder: buildDerivedDailySummaryModule,
|
Builder: buildDerivedDailySummaryModule,
|
||||||
},
|
},
|
||||||
@@ -307,6 +325,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
SupportedReports: daypartReports,
|
SupportedReports: daypartReports,
|
||||||
MissingData: module.MissingDataError,
|
MissingData: module.MissingDataError,
|
||||||
Builder: buildDerivedDaypartSummariesModule,
|
Builder: buildDerivedDaypartSummariesModule,
|
||||||
|
PromptExporter: exportDerivedDaypartSummariesPromptValue,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: module.PrecipTiming,
|
ID: module.PrecipTiming,
|
||||||
@@ -374,6 +393,15 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildOutdoorWindowsModule,
|
Builder: buildOutdoorWindowsModule,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: module.TodayPlanning,
|
||||||
|
StanzaName: "today_planning",
|
||||||
|
DefaultOptions: module.TodayPlanningOptions{},
|
||||||
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
||||||
|
SupportedReports: []report.ID{report.Today},
|
||||||
|
MissingData: module.MissingDataEmpty,
|
||||||
|
Builder: buildTodayPlanningModule,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ID: module.TomorrowPlanning,
|
ID: module.TomorrowPlanning,
|
||||||
StanzaName: "tomorrow_planning",
|
StanzaName: "tomorrow_planning",
|
||||||
@@ -383,5 +411,14 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildTomorrowPlanningModule,
|
Builder: buildTomorrowPlanningModule,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: module.DailyPlanning,
|
||||||
|
StanzaName: "daily_planning",
|
||||||
|
DefaultOptions: module.DailyPlanningOptions{},
|
||||||
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
||||||
|
SupportedReports: []report.ID{report.Daily},
|
||||||
|
MissingData: module.MissingDataEmpty,
|
||||||
|
Builder: buildDailyPlanningModule,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package briefing
|
package briefing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -40,6 +42,9 @@ func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
|
|||||||
t.Fatalf("BuildModule(%s) error = %v", item.ID, err)
|
t.Fatalf("BuildModule(%s) error = %v", item.ID, err)
|
||||||
}
|
}
|
||||||
if output != nil {
|
if output != nil {
|
||||||
|
if output.DataPackageValue() == nil {
|
||||||
|
t.Fatalf("BuildModule(%s) data package value = nil", item.ID)
|
||||||
|
}
|
||||||
outputs = append(outputs, *output)
|
outputs = append(outputs, *output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,30 +59,266 @@ func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHourlyDefaultModuleOptions(t *testing.T) {
|
func TestDefaultModuleDefinitionsDeclarePromptExportPolicy(t *testing.T) {
|
||||||
definition := report.DefaultRegistry().MustLookup(report.Hourly)
|
customExporters := map[module.ID]struct{}{
|
||||||
var found bool
|
module.CurrentConditions: {},
|
||||||
for _, item := range definition.Modules {
|
module.HourlyForecast: {},
|
||||||
if item.ID != module.AreaForecastDiscussion {
|
module.DerivedDaypartSummaries: {},
|
||||||
continue
|
}
|
||||||
|
passThroughExporters := map[module.ID]struct{}{
|
||||||
|
module.Metadata: {},
|
||||||
|
module.NarrativeForecast: {},
|
||||||
|
module.DerivedDailySummary: {},
|
||||||
|
module.PrecipTiming: {},
|
||||||
|
module.AlertDigest: {},
|
||||||
|
module.SPCConvectiveOutlooks: {},
|
||||||
|
module.AreaForecastDiscussion: {},
|
||||||
|
module.SPCConvectiveDiscussion: {},
|
||||||
|
module.WeatherStory: {},
|
||||||
|
module.OutdoorWindows: {},
|
||||||
|
module.TodayPlanning: {},
|
||||||
|
module.TomorrowPlanning: {},
|
||||||
|
module.DailyPlanning: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, definition := range defaultModuleDefinitions() {
|
||||||
|
_, custom := customExporters[definition.ID]
|
||||||
|
_, passThrough := passThroughExporters[definition.ID]
|
||||||
|
if custom == passThrough {
|
||||||
|
t.Fatalf("module %q exporter policy custom=%v passThrough=%v, want exactly one policy", definition.ID, custom, passThrough)
|
||||||
}
|
}
|
||||||
found = true
|
if custom && definition.PromptExporter == nil {
|
||||||
options, ok := item.Options.(module.AreaForecastDiscussionOptions)
|
t.Fatalf("module %q PromptExporter = nil, want custom prompt exporter", definition.ID)
|
||||||
if !ok {
|
|
||||||
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", item.Options)
|
|
||||||
}
|
}
|
||||||
if strings.Join(options.Sections, ",") != "key_messages,short_term" {
|
if passThrough && definition.PromptExporter != nil {
|
||||||
t.Fatalf("AFD sections = %#v, want key messages and short term", options.Sections)
|
t.Fatalf("module %q PromptExporter is set, want default pass-through", definition.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
}
|
||||||
t.Fatal("hourly default modules missing area_forecast_discussion")
|
|
||||||
|
func TestModuleRegistryAddsPassThroughPromptValue(t *testing.T) {
|
||||||
|
registry, err := NewModuleRegistry([]ModuleDefinition{
|
||||||
|
{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Builder: func(ModuleContext, any) (*module.Output, error) {
|
||||||
|
return &module.Output{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Value: testRegistryValue{Message: "rich"},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewModuleRegistry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
if output == nil {
|
||||||
|
t.Fatal("BuildModule() output = nil, want output")
|
||||||
|
}
|
||||||
|
if output.PromptValue != output.Value {
|
||||||
|
t.Fatalf("PromptValue = %#v, want pass-through rich value %#v", output.PromptValue, output.Value)
|
||||||
|
}
|
||||||
|
if output.DataPackageValue() != output.Value {
|
||||||
|
t.Fatalf("DataPackageValue() = %#v, want rich value", output.DataPackageValue())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryAddsCustomPromptValue(t *testing.T) {
|
||||||
|
registry, err := NewModuleRegistry([]ModuleDefinition{
|
||||||
|
{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Builder: func(ModuleContext, any) (*module.Output, error) {
|
||||||
|
return &module.Output{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Value: testRegistryValue{Message: "rich"},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
PromptExporter: func(value any) (any, error) {
|
||||||
|
rich, ok := value.(testRegistryValue)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("unexpected rich value type")
|
||||||
|
}
|
||||||
|
return testRegistryValue{Message: rich.Message + " prompt"}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewModuleRegistry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
got, ok := output.PromptValue.(testRegistryValue)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("PromptValue type = %T, want testRegistryValue", output.PromptValue)
|
||||||
|
}
|
||||||
|
if got.Message != "rich prompt" {
|
||||||
|
t.Fatalf("PromptValue = %#v, want custom prompt value", got)
|
||||||
|
}
|
||||||
|
if output.DataPackageValue() != output.PromptValue {
|
||||||
|
t.Fatalf("DataPackageValue() = %#v, want custom prompt value", output.DataPackageValue())
|
||||||
|
}
|
||||||
|
if output.Value.(testRegistryValue).Message != "rich" {
|
||||||
|
t.Fatalf("Value = %#v, want rich value unchanged", output.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryWrapsPromptExporterErrors(t *testing.T) {
|
||||||
|
registry, err := NewModuleRegistry([]ModuleDefinition{
|
||||||
|
{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Builder: func(ModuleContext, any) (*module.Output, error) {
|
||||||
|
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: testRegistryValue{Message: "rich"}}, nil
|
||||||
|
},
|
||||||
|
PromptExporter: func(any) (any, error) {
|
||||||
|
return nil, errors.New("unsupported value")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewModuleRegistry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
||||||
|
if err == nil ||
|
||||||
|
!strings.Contains(err.Error(), `module "metadata" stanza "metadata" prompt export`) ||
|
||||||
|
!strings.Contains(err.Error(), "unsupported value") {
|
||||||
|
t.Fatalf("BuildModule() error = %v, want wrapped exporter error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryValidatesOutputBeforePromptExport(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
registry, err := NewModuleRegistry([]ModuleDefinition{
|
||||||
|
{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Builder: func(ModuleContext, any) (*module.Output, error) {
|
||||||
|
return &module.Output{ID: module.CurrentConditions, StanzaName: "metadata", Value: testRegistryValue{Message: "rich"}}, nil
|
||||||
|
},
|
||||||
|
PromptExporter: func(any) (any, error) {
|
||||||
|
called = true
|
||||||
|
return testRegistryValue{Message: "prompt"}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewModuleRegistry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `module "metadata" produced output id "current_conditions"`) {
|
||||||
|
t.Fatalf("BuildModule() error = %v, want output id validation error", err)
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Fatal("PromptExporter called before output validation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryPromptValueIsNotPersistedInSnapshotJSON(t *testing.T) {
|
||||||
|
registry, err := NewModuleRegistry([]ModuleDefinition{
|
||||||
|
{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Builder: func(ModuleContext, any) (*module.Output, error) {
|
||||||
|
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: testRegistryValue{Message: "rich"}}, nil
|
||||||
|
},
|
||||||
|
PromptExporter: func(any) (any, error) {
|
||||||
|
return testRegistryValue{Message: "prompt-only"}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewModuleRegistry() error = %v", err)
|
||||||
|
}
|
||||||
|
output, err := registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
snapshot, err := module.NewSnapshot([]module.Output{*output})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSnapshot() error = %v", err)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(snapshot)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
if !strings.Contains(text, `"message":"rich"`) {
|
||||||
|
t.Fatalf("snapshot JSON missing rich value: %s", text)
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "prompt-only") || strings.Contains(text, "promptValue") || strings.Contains(text, "PromptValue") {
|
||||||
|
t.Fatalf("snapshot JSON includes runtime-only prompt value: %s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultAreaForecastDiscussionModuleOptions(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
id report.ID
|
||||||
|
wantSections string
|
||||||
|
}{
|
||||||
|
{id: report.Daily, wantSections: "long_term"},
|
||||||
|
{id: report.Hourly, wantSections: "key_messages,short_term"},
|
||||||
|
}
|
||||||
|
|
||||||
|
registry := report.DefaultRegistry()
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(string(tt.id), func(t *testing.T) {
|
||||||
|
definition := registry.MustLookup(tt.id)
|
||||||
|
var found bool
|
||||||
|
for _, item := range definition.Modules {
|
||||||
|
if item.ID != module.AreaForecastDiscussion {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
options, ok := item.Options.(module.AreaForecastDiscussionOptions)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", item.Options)
|
||||||
|
}
|
||||||
|
if strings.Join(options.Sections, ",") != tt.wantSections {
|
||||||
|
t.Fatalf("AFD sections = %#v, want %s", options.Sections, tt.wantSections)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("default modules missing area_forecast_discussion")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, id := range []report.ID{report.Today, report.Tomorrow} {
|
||||||
|
t.Run(string(id), func(t *testing.T) {
|
||||||
|
definition := registry.MustLookup(id)
|
||||||
|
var found bool
|
||||||
|
for _, item := range definition.Modules {
|
||||||
|
if item.ID != module.AreaForecastDiscussion {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
if item.Options != nil {
|
||||||
|
t.Fatalf("AFD options = %#v, want default all sections", item.Options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("default modules missing area_forecast_discussion")
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
|
func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.ID("unknown")}})
|
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.ID("unknown")}})
|
||||||
if err == nil || !strings.Contains(err.Error(), `unknown module "unknown"`) {
|
if err == nil || !strings.Contains(err.Error(), `unknown module "unknown"`) {
|
||||||
t.Fatalf("error = %v, want unknown module", err)
|
t.Fatalf("error = %v, want unknown module", err)
|
||||||
}
|
}
|
||||||
@@ -85,7 +326,7 @@ func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
|
|||||||
|
|
||||||
func TestModuleRegistryRejectsDuplicateModuleIDs(t *testing.T) {
|
func TestModuleRegistryRejectsDuplicateModuleIDs(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{
|
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
|
||||||
{ID: module.Metadata},
|
{ID: module.Metadata},
|
||||||
{ID: module.Metadata},
|
{ID: module.Metadata},
|
||||||
})
|
})
|
||||||
@@ -106,12 +347,64 @@ func TestModuleRegistryRejectsDuplicateStanzaNames(t *testing.T) {
|
|||||||
|
|
||||||
func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
|
func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.TomorrowPlanning}})
|
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.TomorrowPlanning}})
|
||||||
if err == nil || !strings.Contains(err.Error(), `module "tomorrow_planning" is not compatible with report "daily_today"`) {
|
if err == nil || !strings.Contains(err.Error(), `module "tomorrow_planning" is not compatible with report "daily"`) {
|
||||||
t.Fatalf("error = %v, want incompatible report", err)
|
t.Fatalf("error = %v, want incompatible report", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryValidatesTodayPlanningSupport(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
if err := registry.ValidateComposition(report.Today, []module.ConfigItem{{ID: module.TodayPlanning}}); err != nil {
|
||||||
|
t.Fatalf("ValidateComposition(today) error = %v", err)
|
||||||
|
}
|
||||||
|
for _, id := range []report.ID{report.Tomorrow, report.Daily} {
|
||||||
|
t.Run(string(id), func(t *testing.T) {
|
||||||
|
err := registry.ValidateComposition(id, []module.ConfigItem{{ID: module.TodayPlanning}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `module "today_planning" is not compatible with report`) {
|
||||||
|
t.Fatalf("ValidateComposition(%s) error = %v, want incompatible report", id, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryValidatesDailyPlanningSupport(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
if err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.DailyPlanning}}); err != nil {
|
||||||
|
t.Fatalf("ValidateComposition(daily) error = %v", err)
|
||||||
|
}
|
||||||
|
for _, id := range []report.ID{report.Today, report.Tomorrow, report.Hourly} {
|
||||||
|
t.Run(string(id), func(t *testing.T) {
|
||||||
|
err := registry.ValidateComposition(id, []module.ConfigItem{{ID: module.DailyPlanning}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `module "daily_planning" is not compatible with report`) {
|
||||||
|
t.Fatalf("ValidateComposition(%s) error = %v, want incompatible report", id, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistrySupportsTodayEligibleModules(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
err := registry.ValidateComposition(report.Today, []module.ConfigItem{
|
||||||
|
{ID: module.Metadata},
|
||||||
|
{ID: module.CurrentConditions},
|
||||||
|
{ID: module.NarrativeForecast},
|
||||||
|
{ID: module.HourlyForecast},
|
||||||
|
{ID: module.DerivedDailySummary},
|
||||||
|
{ID: module.DerivedDaypartSummaries},
|
||||||
|
{ID: module.PrecipTiming},
|
||||||
|
{ID: module.AlertDigest},
|
||||||
|
{ID: module.SPCConvectiveOutlooks},
|
||||||
|
{ID: module.AreaForecastDiscussion},
|
||||||
|
{ID: module.SPCConvectiveDiscussion},
|
||||||
|
{ID: module.WeatherStory},
|
||||||
|
{ID: module.OutdoorWindows},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateComposition(today eligible modules) error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestModuleRegistryRejectsHourlyIncompatibleModules(t *testing.T) {
|
func TestModuleRegistryRejectsHourlyIncompatibleModules(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
for _, id := range []module.ID{
|
for _, id := range []module.ID{
|
||||||
@@ -119,7 +412,9 @@ func TestModuleRegistryRejectsHourlyIncompatibleModules(t *testing.T) {
|
|||||||
module.DerivedDailySummary,
|
module.DerivedDailySummary,
|
||||||
module.DerivedDaypartSummaries,
|
module.DerivedDaypartSummaries,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
|
module.TodayPlanning,
|
||||||
module.TomorrowPlanning,
|
module.TomorrowPlanning,
|
||||||
|
module.DailyPlanning,
|
||||||
} {
|
} {
|
||||||
t.Run(string(id), func(t *testing.T) {
|
t.Run(string(id), func(t *testing.T) {
|
||||||
err := registry.ValidateComposition(report.Hourly, []module.ConfigItem{{ID: id}})
|
err := registry.ValidateComposition(report.Hourly, []module.ConfigItem{{ID: id}})
|
||||||
@@ -150,7 +445,7 @@ func TestModuleRegistryRejectsUnsupportedMissingDataWarn(t *testing.T) {
|
|||||||
|
|
||||||
func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) {
|
func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{
|
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
|
||||||
{ID: module.Metadata, Options: module.CurrentConditionsOptions{}},
|
{ID: module.Metadata, Options: module.CurrentConditionsOptions{}},
|
||||||
})
|
})
|
||||||
if err == nil || !strings.Contains(err.Error(), `module "metadata" options have type module.CurrentConditionsOptions, want module.MetadataOptions`) {
|
if err == nil || !strings.Contains(err.Error(), `module "metadata" options have type module.CurrentConditionsOptions, want module.MetadataOptions`) {
|
||||||
@@ -160,7 +455,7 @@ func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) {
|
|||||||
|
|
||||||
func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
|
func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{
|
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
|
||||||
{ID: module.Metadata, Options: module.MetadataOptions{}},
|
{ID: module.Metadata, Options: module.MetadataOptions{}},
|
||||||
{ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}},
|
{ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}},
|
||||||
})
|
})
|
||||||
@@ -196,3 +491,15 @@ func TestSPCConvectiveOutlookDerivedRequirementAvailability(t *testing.T) {
|
|||||||
func noopModuleBuilder(ModuleContext, any) (*module.Output, error) {
|
func noopModuleBuilder(ModuleContext, any) (*module.Output, error) {
|
||||||
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil
|
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type testRegistryValue struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRegistryModuleContext() ModuleContext {
|
||||||
|
return ModuleContext{
|
||||||
|
Resolved: report.Resolved{
|
||||||
|
Definition: report.DefaultRegistry().MustLookup(report.Daily),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ func alertStatus(bundle *weatherdata.Bundle) *AlertStatus {
|
|||||||
|
|
||||||
func variantForReport(id report.ID) string {
|
func variantForReport(id report.ID) string {
|
||||||
switch id {
|
switch id {
|
||||||
case report.DailyToday:
|
case report.Daily, report.Today:
|
||||||
return "today"
|
return "today"
|
||||||
case report.Tomorrow:
|
case report.Tomorrow:
|
||||||
return "tomorrow"
|
return "tomorrow"
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
package briefing
|
package briefing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
precipTimingChanceLowerBound = 40
|
||||||
|
precipTimingLikelyLowerBound = 50
|
||||||
|
precipTimingExpectLowerBound = 70
|
||||||
|
)
|
||||||
|
|
||||||
type PrecipTimingModule struct {
|
type PrecipTimingModule struct {
|
||||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||||
@@ -21,6 +30,8 @@ type PrecipitationWindowModule struct {
|
|||||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||||
MaxPopHourLabel string `json:"max_pop_hour_label,omitempty"`
|
MaxPopHourLabel string `json:"max_pop_hour_label,omitempty"`
|
||||||
|
PrecipitationType string `json:"precipitation_type,omitempty"`
|
||||||
|
ExpectationPhrase string `json:"expectation_phrase,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildPrecipTimingModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
func buildPrecipTimingModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
@@ -49,7 +60,50 @@ func precipTimingValue(timing forecast.PrecipTiming, timezone string) PrecipTimi
|
|||||||
item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value)
|
item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value)
|
||||||
item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone)
|
item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone)
|
||||||
item.MaxPopHourLabel = hourMinuteLabel(window.MaxPrecipitationProbability.Time, timezone)
|
item.MaxPopHourLabel = hourMinuteLabel(window.MaxPrecipitationProbability.Time, timezone)
|
||||||
|
item.PrecipitationType = precipitationWindowType(window.TextDescriptions)
|
||||||
|
if item.MaxPopPercent != nil {
|
||||||
|
item.ExpectationPhrase = precipitationWindowExpectationPhrase(*item.MaxPopPercent, item.PrecipitationType)
|
||||||
|
}
|
||||||
value.PrecipitationWindows = append(value.PrecipitationWindows, item)
|
value.PrecipitationWindows = append(value.PrecipitationWindows, item)
|
||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func precipitationWindowType(descriptions []string) string {
|
||||||
|
combined := strings.ToLower(strings.Join(descriptions, " "))
|
||||||
|
switch {
|
||||||
|
case (strings.Contains(combined, "thunderstorm") || strings.Contains(combined, "t-storm")) &&
|
||||||
|
(strings.Contains(combined, "shower") || strings.Contains(combined, "rain")):
|
||||||
|
return "showers and thunderstorms"
|
||||||
|
case strings.Contains(combined, "freezing rain"):
|
||||||
|
return "freezing rain"
|
||||||
|
case strings.Contains(combined, "thunderstorm") || strings.Contains(combined, "t-storm"):
|
||||||
|
return "thunderstorms"
|
||||||
|
case strings.Contains(combined, "shower"):
|
||||||
|
return "showers"
|
||||||
|
case strings.Contains(combined, "snow"):
|
||||||
|
return "snow"
|
||||||
|
case strings.Contains(combined, "drizzle"):
|
||||||
|
return "drizzle"
|
||||||
|
case strings.Contains(combined, "rain"):
|
||||||
|
return "rain"
|
||||||
|
default:
|
||||||
|
return "precipitation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func precipitationWindowExpectationPhrase(maxPopPercent int, precipitationType string) string {
|
||||||
|
if precipitationType == "" {
|
||||||
|
precipitationType = "precipitation"
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case maxPopPercent >= precipTimingExpectLowerBound:
|
||||||
|
return fmt.Sprintf("Expect %s.", precipitationType)
|
||||||
|
case maxPopPercent >= precipTimingLikelyLowerBound:
|
||||||
|
return fmt.Sprintf("%s likely.", sentenceCase(precipitationType))
|
||||||
|
case maxPopPercent >= precipTimingChanceLowerBound:
|
||||||
|
return fmt.Sprintf("Chance of %s.", precipitationType)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultSPCConvectiveDiscussionMinimumSeverityRank = 3
|
const defaultSPCConvectiveDiscussionMinimumSeverityRank = defaultSPCRiskDigestMinimumSeverityRank
|
||||||
const spcCategoricalOutlookType = "categorical"
|
const spcCategoricalOutlookType = defaultSPCRiskDigestOutlookType
|
||||||
|
|
||||||
type SPCConvectiveDiscussionModule struct {
|
type SPCConvectiveDiscussionModule struct {
|
||||||
IncludedBecause string `json:"included_because"`
|
IncludedBecause string `json:"included_because"`
|
||||||
|
|||||||
37
internal/briefing/spc_convective_outlook_definitions.go
Normal file
37
internal/briefing/spc_convective_outlook_definitions.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed assets/spc_convective_outlook_definitions.json
|
||||||
|
var spcConvectiveOutlookDefinitionAssets embed.FS
|
||||||
|
|
||||||
|
var spcOutlookBackgroundDefinitions = mustLoadSPCOutlookBackgroundDefinitions()
|
||||||
|
|
||||||
|
func mustLoadSPCOutlookBackgroundDefinitions() map[string]SPCOutlookBackgroundDefinition {
|
||||||
|
data, err := spcConvectiveOutlookDefinitionAssets.ReadFile("assets/spc_convective_outlook_definitions.json")
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("read embedded SPC outlook definitions: %v", err))
|
||||||
|
}
|
||||||
|
var definitions map[string]SPCOutlookBackgroundDefinition
|
||||||
|
if err := json.Unmarshal(data, &definitions); err != nil {
|
||||||
|
panic(fmt.Sprintf("decode embedded SPC outlook definitions: %v", err))
|
||||||
|
}
|
||||||
|
return definitions
|
||||||
|
}
|
||||||
|
|
||||||
|
func spcOutlookBackgroundDefinition(outlookType string, label string) *SPCOutlookBackgroundDefinition {
|
||||||
|
definition, ok := spcOutlookBackgroundDefinitions[spcOutlookDefinitionKey(outlookType, label)]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &definition
|
||||||
|
}
|
||||||
|
|
||||||
|
func spcOutlookDefinitionKey(outlookType string, label string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(outlookType)) + ":" + strings.ToUpper(strings.TrimSpace(label))
|
||||||
|
}
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
package briefing
|
package briefing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const defaultSPCRiskDigestOutlookType = "categorical"
|
||||||
|
const defaultSPCRiskDigestMinimumSeverityRank = 3
|
||||||
|
|
||||||
type SPCConvectiveOutlooksModule struct {
|
type SPCConvectiveOutlooksModule struct {
|
||||||
Checked bool `json:"checked"`
|
Checked bool `json:"checked"`
|
||||||
AsOf string `json:"as_of,omitempty"`
|
AsOf string `json:"as_of,omitempty"`
|
||||||
@@ -16,18 +21,33 @@ type SPCConvectiveOutlooksModule struct {
|
|||||||
LocationName string `json:"location_name,omitempty"`
|
LocationName string `json:"location_name,omitempty"`
|
||||||
OutlookCount int `json:"outlook_count"`
|
OutlookCount int `json:"outlook_count"`
|
||||||
Outlooks []SPCConvectiveOutlookRecord `json:"outlooks,omitempty"`
|
Outlooks []SPCConvectiveOutlookRecord `json:"outlooks,omitempty"`
|
||||||
|
RiskDigest []SPCConvectiveOutlookDigest `json:"risk_digest,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SPCConvectiveOutlookRecord struct {
|
type SPCConvectiveOutlookRecord struct {
|
||||||
Day int `json:"day,omitempty"`
|
Day int `json:"day,omitempty"`
|
||||||
OutlookType string `json:"outlook_type,omitempty"`
|
OutlookType string `json:"outlook_type,omitempty"`
|
||||||
Label string `json:"label,omitempty"`
|
Label string `json:"label,omitempty"`
|
||||||
LabelText string `json:"label_text,omitempty"`
|
LabelText string `json:"label_text,omitempty"`
|
||||||
PeriodBegins string `json:"period_begins,omitempty"`
|
BackgroundDefinition *SPCOutlookBackgroundDefinition `json:"background_definition,omitempty"`
|
||||||
PeriodEnds string `json:"period_ends,omitempty"`
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
IssuedAt string `json:"issued_at,omitempty"`
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
ContainsLocation bool `json:"contains_location"`
|
IssuedAt string `json:"issued_at,omitempty"`
|
||||||
ImageURL string `json:"image_url,omitempty"`
|
ContainsLocation bool `json:"contains_location"`
|
||||||
|
ImageURL string `json:"image_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SPCOutlookBackgroundDefinition struct {
|
||||||
|
PlainLanguage string `json:"plain_language,omitempty"`
|
||||||
|
OfficialDescription string `json:"official_description,omitempty"`
|
||||||
|
RelativeLevel string `json:"relative_level,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SPCConvectiveOutlookDigest struct {
|
||||||
|
LabelText string `json:"label_text,omitempty"`
|
||||||
|
RiskLabel string `json:"risk_label,omitempty"`
|
||||||
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildSPCConvectiveOutlooksModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
func buildSPCConvectiveOutlooksModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
@@ -49,10 +69,23 @@ func buildSPCConvectiveOutlooksModule(ctx ModuleContext, _ any) (*module.Output,
|
|||||||
}
|
}
|
||||||
|
|
||||||
value.Outlooks = spcConvectiveOutlookRecords(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod, ctx.Timezone)
|
value.Outlooks = spcConvectiveOutlookRecords(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod, ctx.Timezone)
|
||||||
|
value.RiskDigest = spcConvectiveOutlookRiskDigest(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod, ctx.Timezone, defaultSPCRiskDigestPolicy())
|
||||||
value.OutlookCount = len(value.Outlooks)
|
value.OutlookCount = len(value.Outlooks)
|
||||||
return &module.Output{ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), Value: value}, nil
|
return &module.Output{ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), Value: value}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type spcRiskDigestPolicy struct {
|
||||||
|
OutlookType string
|
||||||
|
MinimumSeverityRank int
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultSPCRiskDigestPolicy() spcRiskDigestPolicy {
|
||||||
|
return spcRiskDigestPolicy{
|
||||||
|
OutlookType: defaultSPCRiskDigestOutlookType,
|
||||||
|
MinimumSeverityRank: defaultSPCRiskDigestMinimumSeverityRank,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func spcConvectiveOutlookRecords(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period, timezone string) []SPCConvectiveOutlookRecord {
|
func spcConvectiveOutlookRecords(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period, timezone string) []SPCConvectiveOutlookRecord {
|
||||||
records := make([]SPCConvectiveOutlookRecord, 0, len(outlooks))
|
records := make([]SPCConvectiveOutlookRecord, 0, len(outlooks))
|
||||||
for _, outlook := range outlooks {
|
for _, outlook := range outlooks {
|
||||||
@@ -61,20 +94,57 @@ func spcConvectiveOutlookRecords(outlooks []weatherdata.ConvectiveOutlook, repor
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
records = append(records, SPCConvectiveOutlookRecord{
|
records = append(records, SPCConvectiveOutlookRecord{
|
||||||
Day: outlook.Day,
|
Day: outlook.Day,
|
||||||
OutlookType: outlook.OutlookType,
|
OutlookType: outlook.OutlookType,
|
||||||
Label: outlook.Label,
|
Label: outlook.Label,
|
||||||
LabelText: outlook.LabelText,
|
LabelText: outlook.LabelText,
|
||||||
PeriodBegins: friendlyPeriodBeginsLabel(outlookPeriod, timezone),
|
BackgroundDefinition: spcOutlookBackgroundDefinition(outlook.OutlookType, outlook.Label),
|
||||||
PeriodEnds: friendlyPeriodEndsLabel(outlookPeriod, timezone),
|
PeriodBegins: friendlyPeriodBeginsLabel(outlookPeriod, timezone),
|
||||||
IssuedAt: friendlyOptionalTime(outlook.IssuedAt, timezone),
|
PeriodEnds: friendlyPeriodEndsLabel(outlookPeriod, timezone),
|
||||||
ContainsLocation: outlook.ContainsLocation,
|
IssuedAt: friendlyOptionalTime(outlook.IssuedAt, timezone),
|
||||||
ImageURL: outlook.ImageURL,
|
ContainsLocation: outlook.ContainsLocation,
|
||||||
|
ImageURL: outlook.ImageURL,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return records
|
return records
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func spcConvectiveOutlookRiskDigest(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period, timezone string, policy spcRiskDigestPolicy) []SPCConvectiveOutlookDigest {
|
||||||
|
records := make([]SPCConvectiveOutlookDigest, 0, len(outlooks))
|
||||||
|
for _, outlook := range outlooks {
|
||||||
|
if outlook.OutlookType != policy.OutlookType {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if outlook.SeverityRank == nil || *outlook.SeverityRank < policy.MinimumSeverityRank {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !outlook.ContainsLocation {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
|
||||||
|
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(reportPeriod) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
records = append(records, SPCConvectiveOutlookDigest{
|
||||||
|
LabelText: outlook.LabelText,
|
||||||
|
RiskLabel: spcRiskDigestLabel(outlook.LabelText),
|
||||||
|
PeriodBegins: friendlyMonthDayTimeLabel(outlookPeriod.Start, timezone),
|
||||||
|
PeriodEnds: friendlyMonthDayTimeLabel(outlookPeriod.End, timezone),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
func spcRiskDigestLabel(labelText string) string {
|
||||||
|
label := strings.TrimSpace(labelText)
|
||||||
|
if label == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
runes := []rune(strings.ToLower(label))
|
||||||
|
runes[0] = unicode.ToUpper(runes[0])
|
||||||
|
return string(runes)
|
||||||
|
}
|
||||||
|
|
||||||
func friendlyOptionalTime(value *time.Time, timezone string) string {
|
func friendlyOptionalTime(value *time.Time, timezone string) string {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -65,18 +65,31 @@ func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) {
|
|||||||
if got.Day != 1 || got.OutlookType != "categorical" || got.Label != "SLGT" || got.LabelText != "Slight Risk" {
|
if got.Day != 1 || got.OutlookType != "categorical" || got.Label != "SLGT" || got.LabelText != "Slight Risk" {
|
||||||
t.Fatalf("outlook = %#v, want categorical slight risk fields", got)
|
t.Fatalf("outlook = %#v, want categorical slight risk fields", got)
|
||||||
}
|
}
|
||||||
|
if got.BackgroundDefinition == nil ||
|
||||||
|
got.BackgroundDefinition.PlainLanguage != "Scattered severe storms possible." ||
|
||||||
|
got.BackgroundDefinition.OfficialDescription != "Isolated intense storms are possible within the risk area, but severe weather is generally expected to be short-lived and/or not widespread." ||
|
||||||
|
got.BackgroundDefinition.RelativeLevel != "2 of 5" {
|
||||||
|
t.Fatalf("background definition = %#v, want Slight Risk helper", got.BackgroundDefinition)
|
||||||
|
}
|
||||||
if got.PeriodBegins != "2026-05-29 at 11:00 AM" || got.PeriodEnds != "2026-05-30 at 7:00 AM" || got.IssuedAt != "2026-05-29 at 8:45 AM" {
|
if got.PeriodBegins != "2026-05-29 at 11:00 AM" || got.PeriodEnds != "2026-05-30 at 7:00 AM" || got.IssuedAt != "2026-05-29 at 8:45 AM" {
|
||||||
t.Fatalf("outlook times = %#v, want friendly local labels", got)
|
t.Fatalf("outlook times = %#v, want friendly local labels", got)
|
||||||
}
|
}
|
||||||
if !got.ContainsLocation || got.ImageURL == "" {
|
if !got.ContainsLocation || got.ImageURL == "" {
|
||||||
t.Fatalf("outlook = %#v, want location flag and image URL", got)
|
t.Fatalf("outlook = %#v, want location flag and image URL", got)
|
||||||
}
|
}
|
||||||
|
if len(value.RiskDigest) != 1 {
|
||||||
|
t.Fatalf("RiskDigest length = %d, want 1", len(value.RiskDigest))
|
||||||
|
}
|
||||||
|
digest := value.RiskDigest[0]
|
||||||
|
if digest.LabelText != "Slight Risk" || digest.RiskLabel != "Slight risk" || digest.PeriodBegins != "May 29 at 11:00 AM" || digest.PeriodEnds != "May 30 at 7:00 AM" {
|
||||||
|
t.Fatalf("risk digest = %#v, want prompt-facing slight risk record", digest)
|
||||||
|
}
|
||||||
data, err := json.Marshal(output.Value)
|
data, err := json.Marshal(output.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Marshal() error = %v", err)
|
t.Fatalf("Marshal() error = %v", err)
|
||||||
}
|
}
|
||||||
text := string(data)
|
text := string(data)
|
||||||
for _, field := range []string{"checked", "as_of", "issued_at", "location_id", "location_name", "outlook_count", "outlooks", "period_begins", "period_ends", "contains_location", "image_url"} {
|
for _, field := range []string{"checked", "as_of", "issued_at", "location_id", "location_name", "outlook_count", "outlooks", "risk_digest", "background_definition", "plain_language", "official_description", "relative_level", "period_begins", "period_ends", "contains_location", "image_url"} {
|
||||||
if !strings.Contains(text, field) {
|
if !strings.Contains(text, field) {
|
||||||
t.Fatalf("json = %s, want field %s", text, field)
|
t.Fatalf("json = %s, want field %s", text, field)
|
||||||
}
|
}
|
||||||
@@ -88,6 +101,163 @@ func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSPCOutlookBackgroundDefinitionLookup(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
outlookType string
|
||||||
|
label string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "exact slight risk", outlookType: "categorical", label: "SLGT", want: true},
|
||||||
|
{name: "normalized slight risk", outlookType: " Categorical ", label: "slgt", want: true},
|
||||||
|
{name: "expanded marginal risk", outlookType: "categorical", label: "MRGL", want: true},
|
||||||
|
{name: "expanded conditional tornado risk", outlookType: "tornado", label: "CIG3", want: true},
|
||||||
|
{name: "unknown risk", outlookType: "categorical", label: "FOO"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
definition := spcOutlookBackgroundDefinition(tt.outlookType, tt.label)
|
||||||
|
if tt.want && definition == nil {
|
||||||
|
t.Fatalf("spcOutlookBackgroundDefinition(%q, %q) = nil, want definition", tt.outlookType, tt.label)
|
||||||
|
}
|
||||||
|
if !tt.want && definition != nil {
|
||||||
|
t.Fatalf("spcOutlookBackgroundDefinition(%q, %q) = %#v, want nil", tt.outlookType, tt.label, definition)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveOutlooksModuleOmitsUnknownBackgroundDefinition(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
outlook := spcRiskDigestTestOutlook("categorical", "Unknown Risk", 2, true,
|
||||||
|
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00")
|
||||||
|
outlook.Label = "FOO"
|
||||||
|
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||||
|
}
|
||||||
|
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
|
||||||
|
if len(value.Outlooks) != 1 {
|
||||||
|
t.Fatalf("Outlooks length = %d, want 1", len(value.Outlooks))
|
||||||
|
}
|
||||||
|
if value.Outlooks[0].BackgroundDefinition != nil {
|
||||||
|
t.Fatalf("BackgroundDefinition = %#v, want nil for undefined risk", value.Outlooks[0].BackgroundDefinition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCOutlookBackgroundDefinitionsAssetHasUsableEntries(t *testing.T) {
|
||||||
|
wantKeys := []string{
|
||||||
|
"categorical:TSTM",
|
||||||
|
"categorical:MRGL",
|
||||||
|
"categorical:SLGT",
|
||||||
|
"categorical:ENH",
|
||||||
|
"categorical:MDT",
|
||||||
|
"categorical:HIGH",
|
||||||
|
"tornado:CIG1",
|
||||||
|
"tornado:CIG2",
|
||||||
|
"tornado:CIG3",
|
||||||
|
"wind:CIG1",
|
||||||
|
"wind:CIG2",
|
||||||
|
"wind:CIG3",
|
||||||
|
"hail:CIG1",
|
||||||
|
"hail:CIG2",
|
||||||
|
}
|
||||||
|
if len(spcOutlookBackgroundDefinitions) != len(wantKeys) {
|
||||||
|
t.Fatalf("embedded SPC outlook background definitions length = %d, want %d", len(spcOutlookBackgroundDefinitions), len(wantKeys))
|
||||||
|
}
|
||||||
|
for _, key := range wantKeys {
|
||||||
|
if _, ok := spcOutlookBackgroundDefinitions[key]; !ok {
|
||||||
|
t.Fatalf("embedded SPC outlook background definitions missing %q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key, definition := range spcOutlookBackgroundDefinitions {
|
||||||
|
if strings.TrimSpace(key) == "" {
|
||||||
|
t.Fatal("embedded SPC outlook background definitions contain empty key")
|
||||||
|
}
|
||||||
|
if definition.PlainLanguage == "" || definition.OfficialDescription == "" || definition.RelativeLevel == "" {
|
||||||
|
t.Fatalf("embedded SPC outlook background definition %q is incomplete: %#v", key, definition)
|
||||||
|
}
|
||||||
|
if strings.Contains(definition.OfficialDescription, ".Note") || strings.Contains(definition.OfficialDescription, "higher.Note") {
|
||||||
|
t.Fatalf("embedded SPC outlook background definition %q has missing sentence spacing: %q", key, definition.OfficialDescription)
|
||||||
|
}
|
||||||
|
if strings.Contains(definition.OfficialDescription, "by themselves") {
|
||||||
|
t.Fatalf("embedded SPC outlook background definition %q has singular grammar issue: %q", key, definition.OfficialDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCRiskDigestDefaultPolicyConstants(t *testing.T) {
|
||||||
|
if defaultSPCRiskDigestOutlookType != "categorical" {
|
||||||
|
t.Fatalf("defaultSPCRiskDigestOutlookType = %q, want categorical", defaultSPCRiskDigestOutlookType)
|
||||||
|
}
|
||||||
|
if defaultSPCRiskDigestMinimumSeverityRank != 3 {
|
||||||
|
t.Fatalf("defaultSPCRiskDigestMinimumSeverityRank = %d, want 3", defaultSPCRiskDigestMinimumSeverityRank)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveOutlooksRiskDigestFilters(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
outlook weatherdata.ConvectiveOutlook
|
||||||
|
wantRisk bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "categorical slight risk included",
|
||||||
|
outlook: spcRiskDigestTestOutlook("categorical", "Slight Risk", 3, true,
|
||||||
|
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"),
|
||||||
|
wantRisk: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "marginal risk excluded",
|
||||||
|
outlook: spcRiskDigestTestOutlook("categorical", "Marginal Risk", 2, true,
|
||||||
|
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non categorical high rank excluded",
|
||||||
|
outlook: spcRiskDigestTestOutlook("wind", "30% Wind Risk", 30, true,
|
||||||
|
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non overlapping excluded",
|
||||||
|
outlook: spcRiskDigestTestOutlook("categorical", "Enhanced Risk", 4, true,
|
||||||
|
"2026-05-30T07:00:00-05:00", "2026-05-31T07:00:00-05:00"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "location miss excluded",
|
||||||
|
outlook: spcRiskDigestTestOutlook("categorical", "Moderate Risk", 5, false,
|
||||||
|
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{tt.outlook},
|
||||||
|
}
|
||||||
|
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{tt.outlook}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
|
||||||
|
gotRisk := len(value.RiskDigest) > 0
|
||||||
|
if gotRisk != tt.wantRisk {
|
||||||
|
t.Fatalf("RiskDigest = %#v, want included=%v", value.RiskDigest, tt.wantRisk)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSPCConvectiveOutlooksModuleSkipsNonOverlappingOutlooks(t *testing.T) {
|
func TestSPCConvectiveOutlooksModuleSkipsNonOverlappingOutlooks(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := testModuleContext()
|
ctx := testModuleContext()
|
||||||
@@ -117,6 +287,9 @@ func TestSPCConvectiveOutlooksModuleSkipsNonOverlappingOutlooks(t *testing.T) {
|
|||||||
if value.OutlookCount != 0 || len(value.Outlooks) != 0 {
|
if value.OutlookCount != 0 || len(value.Outlooks) != 0 {
|
||||||
t.Fatalf("value = %#v, want non-overlapping outlook omitted", value)
|
t.Fatalf("value = %#v, want non-overlapping outlook omitted", value)
|
||||||
}
|
}
|
||||||
|
if len(value.RiskDigest) != 0 {
|
||||||
|
t.Fatalf("RiskDigest = %#v, want non-overlapping outlook omitted", value.RiskDigest)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSPCConvectiveOutlooksModuleBuildsCheckedEmptyStanza(t *testing.T) {
|
func TestSPCConvectiveOutlooksModuleBuildsCheckedEmptyStanza(t *testing.T) {
|
||||||
@@ -167,3 +340,16 @@ func TestSPCConvectiveOutlooksModuleBuildsUncheckedStanzaForMissingSource(t *tes
|
|||||||
t.Fatalf("missing source value = %#v, want unchecked empty stanza", value)
|
t.Fatalf("missing source value = %#v, want unchecked empty stanza", value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func spcRiskDigestTestOutlook(outlookType string, labelText string, rank int, containsLocation bool, validFrom string, validTo string) weatherdata.ConvectiveOutlook {
|
||||||
|
return weatherdata.ConvectiveOutlook{
|
||||||
|
ID: labelText,
|
||||||
|
Day: 1,
|
||||||
|
OutlookType: outlookType,
|
||||||
|
LabelText: labelText,
|
||||||
|
SeverityRank: &rank,
|
||||||
|
ValidFrom: mustParseModuleTime(validFrom),
|
||||||
|
ValidTo: mustParseModuleTime(validTo),
|
||||||
|
ContainsLocation: containsLocation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,19 @@ type TomorrowPlanning struct {
|
|||||||
OvernightChangeWatch []string
|
OvernightChangeWatch []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type morningCommuteOvernightPlanning struct {
|
||||||
|
MorningReadiness []string
|
||||||
|
CommuteSchoolWorkdayConcerns []string
|
||||||
|
OvernightChangeWatch []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TodayPlanning struct {
|
||||||
|
MorningReadiness []string
|
||||||
|
CommuteSchoolWorkdayConcerns []string
|
||||||
|
OutdoorPlanning []string
|
||||||
|
LateDayChangeWatch []string
|
||||||
|
}
|
||||||
|
|
||||||
func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
|
func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
|
||||||
var best *OutdoorWindow
|
var best *OutdoorWindow
|
||||||
var worst *OutdoorWindow
|
var worst *OutdoorWindow
|
||||||
@@ -48,8 +61,60 @@ func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
|
|||||||
return OutdoorWindows{Best: best, Worst: worst}
|
return OutdoorWindows{Best: best, Worst: worst}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildTodayPlanning(summary *forecast.DailySummary) *TodayPlanning {
|
||||||
|
planning := &TodayPlanning{}
|
||||||
|
morning := daypartNamed(summary.Dayparts, "morning")
|
||||||
|
if morning != nil {
|
||||||
|
planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...)
|
||||||
|
}
|
||||||
|
if len(planning.MorningReadiness) == 0 {
|
||||||
|
planning.MorningReadiness = append(planning.MorningReadiness, "Morning weather looks routine based on the available hourly forecast.")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, daypart := range summary.Dayparts {
|
||||||
|
if daypart.Name == "overnight" || daypart.Name == "evening" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, concernNotes(daypart)...)
|
||||||
|
}
|
||||||
|
for _, alert := range summary.AlertOverlaps {
|
||||||
|
if alert.Event != "" {
|
||||||
|
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, "Active alert to plan around: "+alert.Event+".")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(planning.CommuteSchoolWorkdayConcerns) == 0 {
|
||||||
|
planning.CommuteSchoolWorkdayConcerns = append(planning.CommuteSchoolWorkdayConcerns, "No major commute, school, or workday weather concerns stand out in the available forecast.")
|
||||||
|
}
|
||||||
|
|
||||||
|
planning.OutdoorPlanning = append(planning.OutdoorPlanning, outdoorPlanningNotes(summary.Dayparts)...)
|
||||||
|
if len(planning.OutdoorPlanning) == 0 {
|
||||||
|
planning.OutdoorPlanning = append(planning.OutdoorPlanning, "No standout outdoor weather constraints are evident in the available forecast.")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range []string{"afternoon", "evening"} {
|
||||||
|
daypart := daypartNamed(summary.Dayparts, name)
|
||||||
|
if daypart != nil {
|
||||||
|
planning.LateDayChangeWatch = appendUnique(planning.LateDayChangeWatch, lateDayWatchNotes(*daypart)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(planning.LateDayChangeWatch) == 0 {
|
||||||
|
planning.LateDayChangeWatch = append(planning.LateDayChangeWatch, "Watch for forecast timing or intensity adjustments later today.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return planning
|
||||||
|
}
|
||||||
|
|
||||||
func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning {
|
func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning {
|
||||||
planning := &TomorrowPlanning{}
|
base := buildMorningCommuteOvernightPlanning(summary)
|
||||||
|
return &TomorrowPlanning{
|
||||||
|
MorningReadiness: append([]string(nil), base.MorningReadiness...),
|
||||||
|
CommuteSchoolWorkdayConcerns: append([]string(nil), base.CommuteSchoolWorkdayConcerns...),
|
||||||
|
OvernightChangeWatch: append([]string(nil), base.OvernightChangeWatch...),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMorningCommuteOvernightPlanning(summary *forecast.DailySummary) *morningCommuteOvernightPlanning {
|
||||||
|
planning := &morningCommuteOvernightPlanning{}
|
||||||
morning := daypartNamed(summary.Dayparts, "morning")
|
morning := daypartNamed(summary.Dayparts, "morning")
|
||||||
if morning != nil {
|
if morning != nil {
|
||||||
planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...)
|
planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...)
|
||||||
@@ -84,6 +149,18 @@ func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning {
|
|||||||
return planning
|
return planning
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func outdoorPlanningNotes(dayparts []forecast.DaypartSummary) []string {
|
||||||
|
windows := buildOutdoorWindows(dayparts)
|
||||||
|
var notes []string
|
||||||
|
if windows.Best != nil {
|
||||||
|
notes = append(notes, fmt.Sprintf("Best outdoor window: %s (%s).", titleWord(windows.Best.Daypart), strings.Join(windows.Best.Reasons, ", ")))
|
||||||
|
}
|
||||||
|
if windows.Worst != nil && (windows.Best == nil || windows.Worst.Daypart != windows.Best.Daypart) {
|
||||||
|
notes = append(notes, fmt.Sprintf("Toughest outdoor window: %s (%s).", titleWord(windows.Worst.Daypart), strings.Join(windows.Worst.Reasons, ", ")))
|
||||||
|
}
|
||||||
|
return appendUnique(nil, notes...)
|
||||||
|
}
|
||||||
|
|
||||||
func readinessNotes(daypart forecast.DaypartSummary) []string {
|
func readinessNotes(daypart forecast.DaypartSummary) []string {
|
||||||
notes := []string{}
|
notes := []string{}
|
||||||
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 50 {
|
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 50 {
|
||||||
@@ -104,6 +181,27 @@ func readinessNotes(daypart forecast.DaypartSummary) []string {
|
|||||||
return appendUnique(nil, notes...)
|
return appendUnique(nil, notes...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func lateDayWatchNotes(daypart forecast.DaypartSummary) []string {
|
||||||
|
notes := []string{}
|
||||||
|
prefix := titleWord(daypart.Name)
|
||||||
|
if prefix == "" {
|
||||||
|
prefix = "Late-day"
|
||||||
|
}
|
||||||
|
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 {
|
||||||
|
notes = append(notes, fmt.Sprintf("%s precipitation timing may shift; current peak is near %.0f%%.", prefix, daypart.MaxPrecipitationProbability.Value))
|
||||||
|
}
|
||||||
|
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
||||||
|
notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", prefix, daypart.PeakWindGust.Value))
|
||||||
|
}
|
||||||
|
if daypart.Indicators.Snow || daypart.Indicators.Ice {
|
||||||
|
notes = append(notes, prefix+" wintry weather could affect late-day travel.")
|
||||||
|
}
|
||||||
|
if len(daypart.AlertOverlaps) > 0 {
|
||||||
|
notes = append(notes, prefix+" alert timing could affect late-day plans.")
|
||||||
|
}
|
||||||
|
return appendUnique(nil, notes...)
|
||||||
|
}
|
||||||
|
|
||||||
func concernNotes(daypart forecast.DaypartSummary) []string {
|
func concernNotes(daypart forecast.DaypartSummary) []string {
|
||||||
notes := []string{}
|
notes := []string{}
|
||||||
prefix := titleWord(daypart.Name)
|
prefix := titleWord(daypart.Name)
|
||||||
|
|||||||
26
internal/briefing/today_planning_module.go
Normal file
26
internal/briefing/today_planning_module.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
|
||||||
|
type TodayPlanningModule struct {
|
||||||
|
MorningReadiness []string `json:"morning_readiness,omitempty"`
|
||||||
|
CommuteSchoolWorkdayConcerns []string `json:"commute_school_workday_concerns,omitempty"`
|
||||||
|
OutdoorPlanning []string `json:"outdoor_planning,omitempty"`
|
||||||
|
LateDayChangeWatch []string `json:"late_day_change_watch,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTodayPlanningModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
summary := ctx.Derived.FirstDailySummary()
|
||||||
|
if summary == nil {
|
||||||
|
return &module.Output{ID: module.TodayPlanning, StanzaName: "today_planning", Value: TodayPlanningModule{}}, nil
|
||||||
|
}
|
||||||
|
planning := buildTodayPlanning(summary)
|
||||||
|
value := TodayPlanningModule{}
|
||||||
|
if planning != nil {
|
||||||
|
value.MorningReadiness = append([]string(nil), planning.MorningReadiness...)
|
||||||
|
value.CommuteSchoolWorkdayConcerns = append([]string(nil), planning.CommuteSchoolWorkdayConcerns...)
|
||||||
|
value.OutdoorPlanning = append([]string(nil), planning.OutdoorPlanning...)
|
||||||
|
value.LateDayChangeWatch = append([]string(nil), planning.LateDayChangeWatch...)
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.TodayPlanning, StanzaName: "today_planning", Value: value}, nil
|
||||||
|
}
|
||||||
6
internal/buildinfo/buildinfo.go
Normal file
6
internal/buildinfo/buildinfo.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Package buildinfo exposes release metadata injected by the build pipeline.
|
||||||
|
package buildinfo
|
||||||
|
|
||||||
|
// Version identifies this Weatherreporter build. Release builds replace the
|
||||||
|
// development value with their semantic version tag through the Go linker.
|
||||||
|
var Version = "development"
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
package changes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
|
||||||
)
|
|
||||||
|
|
||||||
func CompareThreeDay(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) {
|
|
||||||
previousDayparts, err := requiredStanza[map[string]daypartSummaryStanza](previous, "derived_daypart_summaries")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("previous daypart summaries: %w", err)
|
|
||||||
}
|
|
||||||
currentDayparts, err := requiredStanza[map[string]daypartSummaryStanza](current, "derived_daypart_summaries")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("current daypart summaries: %w", err)
|
|
||||||
}
|
|
||||||
previousDays := outlookDaysFromDayparts(previousDayparts)
|
|
||||||
currentDays := outlookDaysFromDayparts(currentDayparts)
|
|
||||||
return compareOutlookDays(previousDays, currentDays, thresholds, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
type outlookDay struct {
|
|
||||||
Date string
|
|
||||||
LowTempF *int
|
|
||||||
HighTempF *int
|
|
||||||
MaxPopPercent *int
|
|
||||||
MaxPopTime string
|
|
||||||
MaxWindGustMph *int
|
|
||||||
Indicators indicators
|
|
||||||
}
|
|
||||||
|
|
||||||
func compareOutlookDays(previousDays map[string]outlookDay, currentDays map[string]outlookDay, thresholds Thresholds, prefix string) ([]Change, error) {
|
|
||||||
var changes []Change
|
|
||||||
for date, currentDay := range currentDays {
|
|
||||||
previousDay, ok := previousDays[date]
|
|
||||||
if !ok {
|
|
||||||
changes = append(changes, Change{Type: prefix + "outlook_day_added", Message: fmt.Sprintf("Outlook day added: %s.", date), Current: date})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
changes = append(changes, compareOutlookDay(date, previousDay, currentDay, thresholds, prefix)...)
|
|
||||||
}
|
|
||||||
for date := range previousDays {
|
|
||||||
if _, ok := currentDays[date]; !ok {
|
|
||||||
changes = append(changes, Change{Type: prefix + "outlook_day_removed", Message: fmt.Sprintf("Outlook day removed: %s.", date), Previous: date})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sortChanges(changes)
|
|
||||||
return changes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func compareOutlookDay(date string, previous outlookDay, current outlookDay, thresholds Thresholds, prefix string) []Change {
|
|
||||||
var changes []Change
|
|
||||||
for _, change := range compareTemperatureValues("Low", previous.LowTempF, current.LowTempF, thresholds.TemperatureDegrees) {
|
|
||||||
change.Message = date + ": " + change.Message
|
|
||||||
change.Type = prefix + "outlook_" + change.Type
|
|
||||||
changes = append(changes, change)
|
|
||||||
}
|
|
||||||
for _, change := range compareTemperatureValues("High", previous.HighTempF, current.HighTempF, thresholds.TemperatureDegrees) {
|
|
||||||
change.Message = date + ": " + change.Message
|
|
||||||
change.Type = prefix + "outlook_" + change.Type
|
|
||||||
changes = append(changes, change)
|
|
||||||
}
|
|
||||||
for _, change := range comparePrecipitationValues(previous.MaxPopPercent, current.MaxPopPercent, thresholds.PrecipProbabilityPoints, prefix+"outlook_") {
|
|
||||||
change.Message = date + ": " + change.Message
|
|
||||||
changes = append(changes, change)
|
|
||||||
}
|
|
||||||
for _, change := range comparePrecipTiming(previous.MaxPopTime, current.MaxPopTime, thresholds.PrecipTimingShiftMinutes, prefix+"outlook_") {
|
|
||||||
change.Message = date + ": " + change.Message
|
|
||||||
changes = append(changes, change)
|
|
||||||
}
|
|
||||||
for _, change := range compareWindValues(previous.MaxWindGustMph, current.MaxWindGustMph, thresholds.WindGustMilesPerHour, prefix+"outlook_") {
|
|
||||||
change.Message = date + ": " + change.Message
|
|
||||||
changes = append(changes, change)
|
|
||||||
}
|
|
||||||
for _, change := range compareIndicators(previous.Indicators, current.Indicators, prefix+"outlook_") {
|
|
||||||
change.Message = date + ": " + change.Message
|
|
||||||
changes = append(changes, change)
|
|
||||||
}
|
|
||||||
return changes
|
|
||||||
}
|
|
||||||
|
|
||||||
func outlookDaysFromDayparts(dayparts map[string]daypartSummaryStanza) map[string]outlookDay {
|
|
||||||
out := map[string]outlookDay{}
|
|
||||||
var keys []string
|
|
||||||
for key := range dayparts {
|
|
||||||
keys = append(keys, key)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
for _, key := range keys {
|
|
||||||
daypart := dayparts[key]
|
|
||||||
date := daypartDate(daypart)
|
|
||||||
if date == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
day := out[date]
|
|
||||||
day.Date = date
|
|
||||||
low, high := parseTempRange(daypart.TempRangeF)
|
|
||||||
day.LowTempF = minInt(day.LowTempF, low)
|
|
||||||
day.HighTempF = maxInt(day.HighTempF, high)
|
|
||||||
day.MaxPopPercent = maxInt(day.MaxPopPercent, daypart.MaxPopPercent)
|
|
||||||
if daypart.MaxPopPercent != nil && day.MaxPopPercent != nil && *daypart.MaxPopPercent == *day.MaxPopPercent {
|
|
||||||
day.MaxPopTime = daypart.MaxPopTime
|
|
||||||
}
|
|
||||||
day.MaxWindGustMph = maxInt(day.MaxWindGustMph, daypart.MaxWindGustMph)
|
|
||||||
day.Indicators.Snow = day.Indicators.Snow || daypart.Snow
|
|
||||||
day.Indicators.Ice = day.Indicators.Ice || daypart.Ice
|
|
||||||
out[date] = day
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func daypartDate(daypart daypartSummaryStanza) string {
|
|
||||||
return daypart.Date
|
|
||||||
}
|
|
||||||
|
|
||||||
func minInt(a *int, b *int) *int {
|
|
||||||
if a == nil {
|
|
||||||
return copyInt(b)
|
|
||||||
}
|
|
||||||
if b != nil && *b < *a {
|
|
||||||
return copyInt(b)
|
|
||||||
}
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
|
|
||||||
func maxInt(a *int, b *int) *int {
|
|
||||||
if a == nil {
|
|
||||||
return copyInt(b)
|
|
||||||
}
|
|
||||||
if b != nil && *b > *a {
|
|
||||||
return copyInt(b)
|
|
||||||
}
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyInt(value *int) *int {
|
|
||||||
if value == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
copied := *value
|
|
||||||
return &copied
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package changes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCompareThreeDayDetectsDayChanges(t *testing.T) {
|
|
||||||
previous := outlookSnapshot(t, "2026-05-29", "70", 20, "9 AM", false)
|
|
||||||
current := outlookSnapshot(t, "2026-05-29", "78", 70, "12 PM", true)
|
|
||||||
|
|
||||||
changes, err := CompareThreeDay(previous, current, Thresholds{
|
|
||||||
TemperatureDegrees: 5,
|
|
||||||
PrecipProbabilityPoints: 20,
|
|
||||||
PrecipTimingShiftMinutes: 120,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CompareThreeDay() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(changes) == 0 {
|
|
||||||
t.Fatal("changes length = 0, want detected 3-day changes")
|
|
||||||
}
|
|
||||||
if countType(changes, "outlook_precip_probability_change") == 0 || countType(changes, "outlook_snow_risk_change") == 0 {
|
|
||||||
t.Fatalf("changes = %#v, want precipitation and snow changes", changes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func outlookSnapshot(t *testing.T, date string, tempRange string, precip int, precipTime string, snow bool) module.Snapshot {
|
|
||||||
t.Helper()
|
|
||||||
return snapshot(t, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
|
|
||||||
date + "_morning": {
|
|
||||||
Date: date,
|
|
||||||
PeriodBegins: date + " at 6:00 AM",
|
|
||||||
PeriodEnds: date + " at 10:00 AM",
|
|
||||||
TempRangeF: tempRange,
|
|
||||||
MaxPopPercent: &precip,
|
|
||||||
MaxPopTime: precipTime,
|
|
||||||
Snow: snow,
|
|
||||||
},
|
|
||||||
}})
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package changes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
|
||||||
)
|
|
||||||
|
|
||||||
func CompareWeekend(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) {
|
|
||||||
previousDayparts, err := requiredStanza[map[string]daypartSummaryStanza](previous, "derived_daypart_summaries")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("previous weekend daypart summaries: %w", err)
|
|
||||||
}
|
|
||||||
currentDayparts, err := requiredStanza[map[string]daypartSummaryStanza](current, "derived_daypart_summaries")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("current weekend daypart summaries: %w", err)
|
|
||||||
}
|
|
||||||
return compareOutlookDays(outlookDaysFromDayparts(previousDayparts), outlookDaysFromDayparts(currentDayparts), thresholds, "weekend_")
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package changes
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestCompareWeekendDetectsOutlookChanges(t *testing.T) {
|
|
||||||
previous := outlookSnapshot(t, "2026-05-30", "70", 10, "9 AM", false)
|
|
||||||
current := outlookSnapshot(t, "2026-05-30", "78", 10, "9 AM", true)
|
|
||||||
|
|
||||||
changes, err := CompareWeekend(previous, current, Thresholds{TemperatureDegrees: 5})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CompareWeekend() error = %v", err)
|
|
||||||
}
|
|
||||||
if len(changes) == 0 {
|
|
||||||
t.Fatal("changes length = 0, want weekend changes")
|
|
||||||
}
|
|
||||||
if countType(changes, "weekend_outlook_snow_risk_change") == 0 {
|
|
||||||
t.Fatalf("changes = %#v, want snow risk change", changes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
59
internal/cli/executor_factory.go
Normal file
59
internal/cli/executor_factory.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
promptkitadapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/promptkit"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PromptExecutorConfig is the project-owned construction input for one prompt
|
||||||
|
// executor. It keeps adapter implementation types out of Runner's API.
|
||||||
|
type PromptExecutorConfig struct {
|
||||||
|
Profile string
|
||||||
|
ProfileFile string
|
||||||
|
ProfileDirectory string
|
||||||
|
Timeout time.Duration
|
||||||
|
LocalEndpoint string
|
||||||
|
LocalConcurrencyLimit int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecutorFactory constructs one executor for an action.
|
||||||
|
type ExecutorFactory func(PromptExecutorConfig) (promptexec.Executor, error)
|
||||||
|
|
||||||
|
func (r Runner) promptExecutor(cfg config.PromptkitConfig) (promptexec.Executor, error) {
|
||||||
|
factory := r.ExecutorFactory
|
||||||
|
if factory == nil {
|
||||||
|
factory = newPromptkitExecutor
|
||||||
|
}
|
||||||
|
return factory(promptExecutorConfig(cfg))
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptExecutorConfig(cfg config.PromptkitConfig) PromptExecutorConfig {
|
||||||
|
result := PromptExecutorConfig{
|
||||||
|
Profile: cfg.Profile,
|
||||||
|
ProfileFile: cfg.ProfileFile,
|
||||||
|
ProfileDirectory: cfg.ProfileDir,
|
||||||
|
Timeout: cfg.Timeout,
|
||||||
|
}
|
||||||
|
if cfg.Local.Endpoint != "" {
|
||||||
|
result.LocalEndpoint = cfg.Local.Endpoint
|
||||||
|
result.LocalConcurrencyLimit = cfg.Local.ConcurrencyLimit
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPromptkitExecutor(cfg PromptExecutorConfig) (promptexec.Executor, error) {
|
||||||
|
return promptkitadapter.New(promptkitAdapterConfig(cfg))
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptkitAdapterConfig(cfg PromptExecutorConfig) promptkitadapter.Config {
|
||||||
|
return promptkitadapter.Config{
|
||||||
|
ProfileDirectory: cfg.ProfileDirectory,
|
||||||
|
ProfileFile: cfg.ProfileFile,
|
||||||
|
LocalEndpoint: cfg.LocalEndpoint,
|
||||||
|
LocalConcurrencyLimit: cfg.LocalConcurrencyLimit,
|
||||||
|
Timeout: cfg.Timeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
78
internal/cli/executor_factory_test.go
Normal file
78
internal/cli/executor_factory_test.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
promptkitadapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/promptkit"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunnerPromptExecutorMapsConfigurationOnce(t *testing.T) {
|
||||||
|
var calls int
|
||||||
|
var received PromptExecutorConfig
|
||||||
|
runner := Runner{ExecutorFactory: func(value PromptExecutorConfig) (promptexec.Executor, error) {
|
||||||
|
calls++
|
||||||
|
received = value
|
||||||
|
return factoryExecutor{}, nil
|
||||||
|
}}
|
||||||
|
executor, err := runner.promptExecutor(config.PromptkitConfig{
|
||||||
|
Profile: "selected-profile",
|
||||||
|
ProfileFile: "/etc/weatherreporter/profile.yml",
|
||||||
|
Timeout: 45 * time.Second,
|
||||||
|
Local: config.PromptkitLocalConfig{
|
||||||
|
Endpoint: "http://127.0.0.1:8080",
|
||||||
|
ConcurrencyLimit: 3,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil || executor == nil || calls != 1 {
|
||||||
|
t.Fatalf("executor/error/calls = %#v/%v/%d", executor, err, calls)
|
||||||
|
}
|
||||||
|
want := PromptExecutorConfig{
|
||||||
|
Profile: "selected-profile", ProfileFile: "/etc/weatherreporter/profile.yml", Timeout: 45 * time.Second,
|
||||||
|
LocalEndpoint: "http://127.0.0.1:8080", LocalConcurrencyLimit: 3,
|
||||||
|
}
|
||||||
|
if received != want {
|
||||||
|
t.Fatalf("factory config = %#v, want %#v", received, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromptExecutorConfigLeavesBlankLocalBackendUnregistered(t *testing.T) {
|
||||||
|
value := promptExecutorConfig(config.PromptkitConfig{
|
||||||
|
Timeout: 2 * time.Minute,
|
||||||
|
Local: config.PromptkitLocalConfig{ConcurrencyLimit: 1},
|
||||||
|
})
|
||||||
|
if value.LocalEndpoint != "" || value.LocalConcurrencyLimit != 0 {
|
||||||
|
t.Fatalf("executor config = %#v, want no local backend", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromptkitAdapterConfigMapsExecutorSettings(t *testing.T) {
|
||||||
|
adapterConfig := promptkitAdapterConfig(PromptExecutorConfig{
|
||||||
|
ProfileDirectory: "/etc/weatherreporter/profiles",
|
||||||
|
Timeout: 30 * time.Second, LocalEndpoint: "http://127.0.0.1:8080", LocalConcurrencyLimit: 2,
|
||||||
|
})
|
||||||
|
want := promptkitadapter.Config{
|
||||||
|
ProfileDirectory: "/etc/weatherreporter/profiles",
|
||||||
|
Timeout: 30 * time.Second, LocalEndpoint: "http://127.0.0.1:8080", LocalConcurrencyLimit: 2,
|
||||||
|
}
|
||||||
|
if adapterConfig != want {
|
||||||
|
t.Fatalf("adapter config = %#v, want %#v", adapterConfig, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type factoryExecutor struct{}
|
||||||
|
|
||||||
|
func (factoryExecutor) InspectPrompt(context.Context, string, string) (promptexec.PromptInspection, error) {
|
||||||
|
return promptexec.PromptInspection{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (factoryExecutor) InspectProfile(context.Context, string) (promptexec.ProfileInspection, error) {
|
||||||
|
return promptexec.ProfileInspection{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (factoryExecutor) Execute(context.Context, promptexec.ExecuteRequest, promptexec.PreparationCallback) (*promptexec.Execution, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
75
internal/cli/output.go
Normal file
75
internal/cli/output.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
type outputOptions struct {
|
||||||
|
Quiet bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeActionResult(stdout, stderr io.Writer, value any, opts outputOptions, writeStatus func(io.Writer)) error {
|
||||||
|
if opts.Quiet {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if writeStatus != nil && stderr != nil {
|
||||||
|
writeStatus(stderr)
|
||||||
|
}
|
||||||
|
return writeJSON(stdout, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(stdout io.Writer, value any) error {
|
||||||
|
encoder := json.NewEncoder(stdout)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
return encoder.Encode(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeBatchStatus(stderr io.Writer, result *app.BatchResult) {
|
||||||
|
if stderr == nil || result == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, item := range result.Reports {
|
||||||
|
notificationFields := ""
|
||||||
|
if item.NotificationStatus != "" {
|
||||||
|
notificationFields += fmt.Sprintf(" notificationStatus=%q", item.NotificationStatus)
|
||||||
|
}
|
||||||
|
if item.NotificationRunID != "" {
|
||||||
|
notificationFields += fmt.Sprintf(" notificationRunId=%q", item.NotificationRunID)
|
||||||
|
}
|
||||||
|
if item.NotificationError != "" {
|
||||||
|
notificationFields += fmt.Sprintf(" notificationError=%q", item.NotificationError)
|
||||||
|
}
|
||||||
|
if item.Status == "failed" {
|
||||||
|
_, _ = fmt.Fprintf(stderr, "report=%s status=failed error=%q%s\n", item.ReportID, item.Error, notificationFields)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(stderr, "report=%s status=succeeded output=%q%s\n", item.ReportID, item.OutputPath, notificationFields)
|
||||||
|
}
|
||||||
|
if result.Notification != nil {
|
||||||
|
_, _ = fmt.Fprintf(stderr, "batchNotification status=%q", result.Notification.Status)
|
||||||
|
if result.Notification.Reason != "" {
|
||||||
|
_, _ = fmt.Fprintf(stderr, " reason=%q", result.Notification.Reason)
|
||||||
|
}
|
||||||
|
if result.Notification.RunID != "" {
|
||||||
|
_, _ = fmt.Fprintf(stderr, " runId=%q", result.Notification.RunID)
|
||||||
|
}
|
||||||
|
if result.Notification.PipelineID != "" {
|
||||||
|
_, _ = fmt.Fprintf(stderr, " pipelineId=%q", result.Notification.PipelineID)
|
||||||
|
}
|
||||||
|
if result.Notification.BundleID != "" {
|
||||||
|
_, _ = fmt.Fprintf(stderr, " bundleId=%q", result.Notification.BundleID)
|
||||||
|
}
|
||||||
|
if result.Notification.Path != "" {
|
||||||
|
_, _ = fmt.Fprintf(stderr, " path=%q", result.Notification.Path)
|
||||||
|
}
|
||||||
|
if result.Notification.Error != "" {
|
||||||
|
_, _ = fmt.Fprintf(stderr, " error=%q", result.Notification.Error)
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintln(stderr)
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(stderr, "batch=%s total=%d succeeded=%d failed=%d\n", result.Batch, result.Total, result.Succeeded, result.Failed)
|
||||||
|
}
|
||||||
59
internal/cli/output_test.go
Normal file
59
internal/cli/output_test.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWriteActionResultWritesStatusBeforeJSON(t *testing.T) {
|
||||||
|
var output bytes.Buffer
|
||||||
|
|
||||||
|
err := writeActionResult(&output, &output, map[string]string{"status": "succeeded"}, outputOptions{}, func(w io.Writer) {
|
||||||
|
_, _ = w.Write([]byte("status line\n"))
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writeActionResult() error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(output.String(), "status line\n") {
|
||||||
|
t.Fatalf("output = %q, want status before JSON", output.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(output.String(), `"status": "succeeded"`) {
|
||||||
|
t.Fatalf("output missing JSON result:\n%s", output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteActionResultQuietSuppressesOutput(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
|
err := writeActionResult(&stdout, &stderr, map[string]string{"status": "succeeded"}, outputOptions{Quiet: true}, func(w io.Writer) {
|
||||||
|
_, _ = w.Write([]byte("status line\n"))
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writeActionResult() error = %v", err)
|
||||||
|
}
|
||||||
|
if stdout.Len() != 0 || stderr.Len() != 0 {
|
||||||
|
t.Fatalf("stdout/stderr = %q/%q, want no output", stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteActionResultToleratesNilStderr(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
statusCalled := false
|
||||||
|
|
||||||
|
err := writeActionResult(&stdout, nil, map[string]string{"status": "succeeded"}, outputOptions{}, func(w io.Writer) {
|
||||||
|
statusCalled = true
|
||||||
|
_, _ = w.Write([]byte("status line\n"))
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writeActionResult() error = %v", err)
|
||||||
|
}
|
||||||
|
if statusCalled {
|
||||||
|
t.Fatal("status writer was called with nil stderr")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), `"status": "succeeded"`) {
|
||||||
|
t.Fatalf("stdout missing JSON result:\n%s", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
164
internal/cli/result.go
Normal file
164
internal/cli/result.go
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
commandGenerate = "generate"
|
||||||
|
commandRun = "run"
|
||||||
|
|
||||||
|
summaryStatusSucceeded = "succeeded"
|
||||||
|
summaryStatusFailed = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type generateSummary struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
ReportID report.ID `json:"reportId"`
|
||||||
|
ReportName string `json:"reportName"`
|
||||||
|
PromptID string `json:"promptId"`
|
||||||
|
RunID string `json:"runId"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
GeneratedAt time.Time `json:"generatedAt"`
|
||||||
|
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||||
|
ReportPath string `json:"reportPath,omitempty"`
|
||||||
|
OutputPath string `json:"outputPath,omitempty"`
|
||||||
|
MetadataPath string `json:"metadataPath,omitempty"`
|
||||||
|
DataPackagePath string `json:"dataPackagePath,omitempty"`
|
||||||
|
PreparationPath string `json:"preparationPath,omitempty"`
|
||||||
|
ExecutionPath string `json:"executionPath,omitempty"`
|
||||||
|
LLMDebugPath string `json:"llmDebugPath,omitempty"`
|
||||||
|
GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"`
|
||||||
|
GeneratedTextPath string `json:"generatedTextPath,omitempty"`
|
||||||
|
RenderContextPath string `json:"renderContextPath,omitempty"`
|
||||||
|
NotificationPath string `json:"notificationPath,omitempty"`
|
||||||
|
Notification *generateNotificationSummary `json:"notification,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type generateNotificationSummary struct {
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
UploadStatus string `json:"uploadStatus,omitempty"`
|
||||||
|
StatusError string `json:"statusError,omitempty"`
|
||||||
|
RunID string `json:"runId,omitempty"`
|
||||||
|
PipelineID string `json:"pipelineId,omitempty"`
|
||||||
|
BundleID string `json:"bundleId,omitempty"`
|
||||||
|
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
||||||
|
AcceptedAt *time.Time `json:"acceptedAt,omitempty"`
|
||||||
|
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||||
|
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type batchSummary struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
Batch app.BatchKind `json:"batch"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StartedAt time.Time `json:"startedAt"`
|
||||||
|
FinishedAt time.Time `json:"finishedAt"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Succeeded int `json:"succeeded"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
Notification *app.BatchNotificationResult `json:"notification,omitempty"`
|
||||||
|
Reports []app.BatchReportResult `json:"reports"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGenerateSummary(result *app.ReportResult, err error) generateSummary {
|
||||||
|
summary := generateSummary{Command: commandGenerate}
|
||||||
|
if result == nil {
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := result.Metadata
|
||||||
|
summary.ReportID = metadata.ReportID
|
||||||
|
summary.ReportName = reportName(metadata.ReportID)
|
||||||
|
summary.PromptID = metadata.PromptID
|
||||||
|
summary.RunID = metadata.RunID
|
||||||
|
summary.Status = summaryStatusSucceeded
|
||||||
|
summary.GeneratedAt = metadata.GeneratedAt
|
||||||
|
summary.ValidPeriod = metadata.ValidPeriod
|
||||||
|
summary.ReportPath = result.ReportPath
|
||||||
|
summary.OutputPath = result.OutputPath
|
||||||
|
summary.MetadataPath = result.MetadataPath
|
||||||
|
summary.DataPackagePath = result.DataPackagePath
|
||||||
|
summary.PreparationPath = result.PreparationPath
|
||||||
|
summary.ExecutionPath = result.ExecutionPath
|
||||||
|
summary.LLMDebugPath = result.LLMDebugPath
|
||||||
|
summary.GeneratedTextRawPath = result.GeneratedTextRawPath
|
||||||
|
summary.GeneratedTextPath = result.GeneratedTextPath
|
||||||
|
summary.RenderContextPath = result.RenderContextPath
|
||||||
|
summary.NotificationPath = result.NotificationPath
|
||||||
|
summary.Notification = newGenerateNotificationSummary(result.Notification)
|
||||||
|
if err != nil {
|
||||||
|
summary.Status = summaryStatusFailed
|
||||||
|
summary.Error = err.Error()
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGenerateNotificationSummary(result *app.NotificationResult) *generateNotificationSummary {
|
||||||
|
if result == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
summary := &generateNotificationSummary{
|
||||||
|
Status: result.Status,
|
||||||
|
UploadStatus: result.UploadStatus,
|
||||||
|
StatusError: result.StatusError,
|
||||||
|
RunID: result.RunID,
|
||||||
|
PipelineID: result.PipelineID,
|
||||||
|
BundleID: result.BundleID,
|
||||||
|
IdempotencyKey: result.IdempotencyKey,
|
||||||
|
StartedAt: result.StartedAt,
|
||||||
|
FinishedAt: result.FinishedAt,
|
||||||
|
Error: result.Error,
|
||||||
|
}
|
||||||
|
if !result.AcceptedAt.IsZero() {
|
||||||
|
acceptedAt := result.AcceptedAt
|
||||||
|
summary.AcceptedAt = &acceptedAt
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBatchSummary(result *app.BatchResult) batchSummary {
|
||||||
|
summary := batchSummary{Command: commandRun}
|
||||||
|
if result == nil {
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.Batch = result.Batch
|
||||||
|
summary.Status = batchSummaryStatus(result)
|
||||||
|
summary.StartedAt = result.StartedAt
|
||||||
|
summary.FinishedAt = result.FinishedAt
|
||||||
|
summary.Total = result.Total
|
||||||
|
summary.Succeeded = result.Succeeded
|
||||||
|
summary.Failed = result.Failed
|
||||||
|
summary.Notification = result.Notification
|
||||||
|
summary.Reports = append([]app.BatchReportResult(nil), result.Reports...)
|
||||||
|
if summary.Status == summaryStatusFailed {
|
||||||
|
summary.Error = app.BatchError{Result: result}.Error()
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchSummaryStatus(result *app.BatchResult) string {
|
||||||
|
if result == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if result.Failed > 0 || (result.Notification != nil && result.Notification.Status == summaryStatusFailed) {
|
||||||
|
return summaryStatusFailed
|
||||||
|
}
|
||||||
|
return summaryStatusSucceeded
|
||||||
|
}
|
||||||
|
|
||||||
|
func reportName(id report.ID) string {
|
||||||
|
definition, err := report.DefaultRegistry().Lookup(id)
|
||||||
|
if err != nil {
|
||||||
|
return string(id)
|
||||||
|
}
|
||||||
|
return definition.Name
|
||||||
|
}
|
||||||
260
internal/cli/result_test.go
Normal file
260
internal/cli/result_test.go
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewGenerateSummaryForGeneratedTextReport(t *testing.T) {
|
||||||
|
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
|
||||||
|
acceptedAt := generatedAt.Add(time.Minute)
|
||||||
|
startedAt := acceptedAt.Add(time.Minute)
|
||||||
|
finishedAt := startedAt.Add(time.Minute)
|
||||||
|
result := &app.ReportResult{
|
||||||
|
DataPackagePath: "/runs/hourly/data_package.yaml",
|
||||||
|
PreparationPath: "/runs/hourly/preparation.json",
|
||||||
|
ExecutionPath: "/runs/hourly/execution.json",
|
||||||
|
LLMDebugPath: "/operator-debug/hourly/2026-05-29/run-123",
|
||||||
|
ReportPath: "/runs/hourly/report.md",
|
||||||
|
OutputPath: "/copies/hourly.md",
|
||||||
|
MetadataPath: "/runs/hourly/metadata.json",
|
||||||
|
GeneratedTextRawPath: "/runs/hourly/generated_text_raw.json",
|
||||||
|
GeneratedTextPath: "/runs/hourly/generated_text.json",
|
||||||
|
RenderContextPath: "/runs/hourly/render_context.json",
|
||||||
|
NotificationPath: "/runs/hourly/notification.json",
|
||||||
|
Metadata: state.Metadata{
|
||||||
|
ReportID: report.Hourly,
|
||||||
|
PromptID: "weather.hourly_generated_text",
|
||||||
|
RunID: "20260529T133000Z_hourly",
|
||||||
|
GeneratedAt: generatedAt,
|
||||||
|
ValidPeriod: testSummaryPeriod(generatedAt),
|
||||||
|
},
|
||||||
|
Notification: &app.NotificationResult{
|
||||||
|
Status: "succeeded",
|
||||||
|
UploadStatus: "accepted",
|
||||||
|
RunID: "distributor-run",
|
||||||
|
PipelineID: "weatherreporter.hourly",
|
||||||
|
BundleID: "weatherreporter.home.hourly",
|
||||||
|
IdempotencyKey: "weatherreporter.home.hourly.20260529T133000Z_hourly",
|
||||||
|
AcceptedAt: acceptedAt,
|
||||||
|
StartedAt: &startedAt,
|
||||||
|
FinishedAt: &finishedAt,
|
||||||
|
Report: []byte(`{"actions":[{"action":"replace_older"}]}`),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := newGenerateSummary(result, nil)
|
||||||
|
|
||||||
|
if summary.Command != "generate" || summary.Status != "succeeded" {
|
||||||
|
t.Fatalf("summary command/status = %q/%q, want generate/succeeded", summary.Command, summary.Status)
|
||||||
|
}
|
||||||
|
if summary.ReportID != report.Hourly || summary.ReportName != "Hourly Report" || summary.PromptID != "weather.hourly_generated_text" || summary.RunID != "20260529T133000Z_hourly" {
|
||||||
|
t.Fatalf("summary identity = %#v, want hourly report identity", summary)
|
||||||
|
}
|
||||||
|
if summary.PreparationPath == "" || summary.ExecutionPath == "" || summary.LLMDebugPath == "" || summary.GeneratedTextRawPath == "" || summary.GeneratedTextPath == "" || summary.RenderContextPath == "" {
|
||||||
|
t.Fatalf("generated-text paths = %#v, want generated-text artifact paths", summary)
|
||||||
|
}
|
||||||
|
if summary.Notification == nil || summary.Notification.RunID != "distributor-run" || summary.Notification.AcceptedAt == nil || !summary.Notification.AcceptedAt.Equal(acceptedAt) {
|
||||||
|
t.Fatalf("notification = %#v, want summarized distributor result", summary.Notification)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(summary)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), "replace_older") || strings.Contains(string(data), "actions") {
|
||||||
|
t.Fatalf("summary JSON includes raw distributor report payload:\n%s", string(data))
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), "preflightPath") || strings.Contains(string(data), "generatedTextResultPath") || !strings.Contains(string(data), "preparationPath") || !strings.Contains(string(data), "executionPath") {
|
||||||
|
t.Fatalf("summary JSON does not use prompt artifact path names:\n%s", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewGenerateSummaryOmitsNotificationWhenNotAttempted(t *testing.T) {
|
||||||
|
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
|
||||||
|
result := &app.ReportResult{
|
||||||
|
DataPackagePath: "/runs/daily/data_package.yaml",
|
||||||
|
PreparationPath: "/runs/daily/preparation.json",
|
||||||
|
ReportPath: "/runs/daily/report.md",
|
||||||
|
OutputPath: "/copies/daily.md",
|
||||||
|
MetadataPath: "/runs/daily/metadata.json",
|
||||||
|
Metadata: state.Metadata{
|
||||||
|
ReportID: report.Daily,
|
||||||
|
PromptID: "weather.daily_generated_text",
|
||||||
|
RunID: "20260529T133000Z_daily",
|
||||||
|
GeneratedAt: generatedAt,
|
||||||
|
ValidPeriod: testSummaryPeriod(generatedAt),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := newGenerateSummary(result, nil)
|
||||||
|
|
||||||
|
if summary.ReportID != report.Daily || summary.ReportName != "Daily Report" || summary.Status != "succeeded" {
|
||||||
|
t.Fatalf("summary = %#v, want successful daily summary", summary)
|
||||||
|
}
|
||||||
|
if summary.Notification != nil || summary.NotificationPath != "" {
|
||||||
|
t.Fatalf("notification summary/path = %#v/%q, want omitted", summary.Notification, summary.NotificationPath)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(summary)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
for _, omitted := range []string{"notification"} {
|
||||||
|
if strings.Contains(string(data), omitted) {
|
||||||
|
t.Fatalf("summary JSON contains %q, want omitted:\n%s", omitted, string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewGenerateSummaryOmitsUnreachedArtifactPaths(t *testing.T) {
|
||||||
|
result := &app.ReportResult{
|
||||||
|
DataPackagePath: "/runs/daily/data_package.yaml",
|
||||||
|
PreparationPath: "/runs/daily/preparation.json",
|
||||||
|
Metadata: state.Metadata{
|
||||||
|
ReportID: report.Daily,
|
||||||
|
RunID: "20260529T133000Z_daily",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(newGenerateSummary(result, errors.New("metadata write failed")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
for _, omitted := range []string{"executionPath", "reportPath", "outputPath", "metadataPath", "generatedTextRawPath", "generatedTextPath", "renderContextPath", "notificationPath"} {
|
||||||
|
if strings.Contains(text, omitted) {
|
||||||
|
t.Fatalf("partial summary includes unreached field %q:\n%s", omitted, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, "dataPackagePath") || !strings.Contains(text, "preparationPath") {
|
||||||
|
t.Fatalf("partial summary omits reached paths:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewGenerateSummaryForNotificationFailure(t *testing.T) {
|
||||||
|
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
|
||||||
|
result := &app.ReportResult{
|
||||||
|
DataPackagePath: "/runs/hourly/data_package.yaml",
|
||||||
|
PreparationPath: "/runs/hourly/preparation.json",
|
||||||
|
ReportPath: "/runs/hourly/report.md",
|
||||||
|
OutputPath: "/copies/hourly.md",
|
||||||
|
MetadataPath: "/runs/hourly/metadata.json",
|
||||||
|
NotificationPath: "/runs/hourly/notification.json",
|
||||||
|
Metadata: state.Metadata{
|
||||||
|
ReportID: report.Hourly,
|
||||||
|
PromptID: "weather.hourly_generated_text",
|
||||||
|
RunID: "20260529T133000Z_hourly",
|
||||||
|
GeneratedAt: generatedAt,
|
||||||
|
ValidPeriod: testSummaryPeriod(generatedAt),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := errors.New(`notify report "hourly" run "20260529T133000Z_hourly": upload rejected`)
|
||||||
|
|
||||||
|
summary := newGenerateSummary(result, err)
|
||||||
|
|
||||||
|
if summary.Status != "failed" || summary.Error != err.Error() {
|
||||||
|
t.Fatalf("status/error = %q/%q, want failed notification error", summary.Status, summary.Error)
|
||||||
|
}
|
||||||
|
if summary.NotificationPath != "/runs/hourly/notification.json" || summary.ReportPath == "" || summary.MetadataPath == "" {
|
||||||
|
t.Fatalf("artifact paths = report %q metadata %q notification %q, want inspectable paths", summary.ReportPath, summary.MetadataPath, summary.NotificationPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewBatchSummaryStatusDerivation(t *testing.T) {
|
||||||
|
startedAt := time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)
|
||||||
|
finishedAt := startedAt.Add(2 * time.Minute)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
result *app.BatchResult
|
||||||
|
wantStatus string
|
||||||
|
wantError string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "success",
|
||||||
|
result: &app.BatchResult{
|
||||||
|
Batch: app.BatchMorning,
|
||||||
|
StartedAt: startedAt,
|
||||||
|
FinishedAt: finishedAt,
|
||||||
|
Total: 1,
|
||||||
|
Succeeded: 1,
|
||||||
|
Reports: []app.BatchReportResult{{ReportID: report.Today, Status: "succeeded"}},
|
||||||
|
},
|
||||||
|
wantStatus: "succeeded",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "report failure",
|
||||||
|
result: &app.BatchResult{
|
||||||
|
Batch: app.BatchMorning,
|
||||||
|
Total: 2,
|
||||||
|
Succeeded: 1,
|
||||||
|
Failed: 1,
|
||||||
|
Reports: []app.BatchReportResult{
|
||||||
|
{ReportID: report.Today, Status: "succeeded"},
|
||||||
|
{ReportID: report.Tomorrow, Status: "failed", Error: "render failed"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStatus: "failed",
|
||||||
|
wantError: "batch morning failed: 1 of 2 reports failed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "skipped notification",
|
||||||
|
result: &app.BatchResult{
|
||||||
|
Batch: app.BatchEvening,
|
||||||
|
Total: 2,
|
||||||
|
Succeeded: 1,
|
||||||
|
Failed: 1,
|
||||||
|
Reports: []app.BatchReportResult{{ReportID: report.Tomorrow, Status: "failed"}},
|
||||||
|
Notification: &app.BatchNotificationResult{
|
||||||
|
Status: "skipped",
|
||||||
|
Reason: "one or more reports failed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStatus: "failed",
|
||||||
|
wantError: "batch evening failed: 1 of 2 reports failed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "failed notification",
|
||||||
|
result: &app.BatchResult{
|
||||||
|
Batch: app.BatchEvening,
|
||||||
|
Total: 1,
|
||||||
|
Succeeded: 1,
|
||||||
|
Reports: []app.BatchReportResult{{ReportID: report.Tomorrow, Status: "succeeded"}},
|
||||||
|
Notification: &app.BatchNotificationResult{
|
||||||
|
Status: "failed",
|
||||||
|
Error: "notify batch evening: upload rejected",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStatus: "failed",
|
||||||
|
wantError: "batch evening notification failed: notify batch evening: upload rejected",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
summary := newBatchSummary(tt.result)
|
||||||
|
if summary.Command != "run" || summary.Status != tt.wantStatus {
|
||||||
|
t.Fatalf("command/status = %q/%q, want run/%s", summary.Command, summary.Status, tt.wantStatus)
|
||||||
|
}
|
||||||
|
if summary.Error != tt.wantError {
|
||||||
|
t.Fatalf("error = %q, want %q", summary.Error, tt.wantError)
|
||||||
|
}
|
||||||
|
if len(summary.Reports) != len(tt.result.Reports) {
|
||||||
|
t.Fatalf("reports = %#v, want copied report list", summary.Reports)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSummaryPeriod(start time.Time) timeutil.Period {
|
||||||
|
return timeutil.Period{
|
||||||
|
Start: start,
|
||||||
|
End: start.Add(6 * time.Hour),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,12 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/buildinfo"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
@@ -17,14 +17,13 @@ const helpText = `weatherreporter prepares weather reports from normalized forec
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
weatherreporter --help
|
weatherreporter --help
|
||||||
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
weatherreporter --version
|
||||||
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet]
|
||||||
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] [--llm-debug-dir PATH] [--quiet]
|
||||||
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet]
|
||||||
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet]
|
||||||
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] [--llm-debug-dir PATH] [--quiet]
|
||||||
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
|
weatherreporter run evening [--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]
|
|
||||||
weatherreporter inspect reports [--config PATH] [--limit N]
|
weatherreporter inspect reports [--config PATH] [--limit N]
|
||||||
weatherreporter inspect metadata [--config PATH] RUN_ID
|
weatherreporter inspect metadata [--config PATH] RUN_ID
|
||||||
weatherreporter inspect modules [--config PATH] RUN_ID
|
weatherreporter inspect modules [--config PATH] RUN_ID
|
||||||
@@ -34,15 +33,20 @@ Usage:
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
-h, --help Show this help message.
|
-h, --help Show this help message.
|
||||||
|
--version Show the Weatherreporter version.
|
||||||
--config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml.
|
--config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml.
|
||||||
--units VALUE Override weather API units.
|
--units VALUE Override weather API units.
|
||||||
--tz NAME Override weather API timezone.
|
--tz NAME Override weather API timezone.
|
||||||
--out PATH Write an extra Markdown report copy where supported by the generate command.
|
--out PATH Write an extra Markdown report copy where supported by the generate command.
|
||||||
|
--llm-debug-dir PATH Write sensitive prompt debug artifacts outside the managed workspace.
|
||||||
--out-dir PATH Write extra Markdown report copies for run commands.
|
--out-dir PATH Write extra Markdown report copies for run commands.
|
||||||
|
--quiet Suppress successful generate and run output.
|
||||||
`
|
`
|
||||||
|
|
||||||
type Runner struct {
|
type Runner struct {
|
||||||
Clock timeutil.Clock
|
Clock timeutil.Clock
|
||||||
|
ExecutorFactory ExecutorFactory
|
||||||
|
Version string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error {
|
func Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error {
|
||||||
@@ -50,7 +54,6 @@ func Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error {
|
func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error {
|
||||||
_ = stderr
|
|
||||||
if r.Clock == nil {
|
if r.Clock == nil {
|
||||||
r.Clock = timeutil.SystemClock{}
|
r.Clock = timeutil.SystemClock{}
|
||||||
}
|
}
|
||||||
@@ -58,26 +61,46 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr
|
|||||||
_, err := fmt.Fprint(stdout, helpText)
|
_, err := fmt.Fprint(stdout, helpText)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if args[0] == "--version" {
|
||||||
|
if len(args) != 1 {
|
||||||
|
return fmt.Errorf("--version does not accept arguments")
|
||||||
|
}
|
||||||
|
version := r.Version
|
||||||
|
if version == "" {
|
||||||
|
version = buildinfo.Version
|
||||||
|
}
|
||||||
|
_, err := fmt.Fprintf(stdout, "weatherreporter %s\n", version)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "generate":
|
case "generate":
|
||||||
req, err := r.resolveGenerate(args[1:])
|
req, opts, err := r.resolveGenerateAction(args[1:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return app.Generate(ctx, req)
|
result, err := app.GenerateDetailed(ctx, req)
|
||||||
|
if result != nil {
|
||||||
|
summary := newGenerateSummary(result, err)
|
||||||
|
if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, nil); encodeErr != nil {
|
||||||
|
return encodeErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
case "run":
|
case "run":
|
||||||
req, err := r.resolveRun(args[1:])
|
req, opts, err := r.resolveRunAction(args[1:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
result, err := app.RunBatchDetailed(ctx, req)
|
result, err := app.RunBatchDetailed(ctx, req)
|
||||||
if result != nil {
|
if result != nil {
|
||||||
writeRunLogs(stderr, result)
|
summary := newBatchSummary(result)
|
||||||
if encodeErr := writeJSON(stdout, result); encodeErr != nil {
|
if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, func(w io.Writer) {
|
||||||
|
writeBatchStatus(w, result)
|
||||||
|
}); encodeErr != nil {
|
||||||
return encodeErr
|
return encodeErr
|
||||||
}
|
}
|
||||||
if result.Failed > 0 {
|
if summary.Status == summaryStatusFailed {
|
||||||
return app.BatchError{Result: result}
|
return app.BatchError{Result: result}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,18 +113,18 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr
|
|||||||
}
|
}
|
||||||
|
|
||||||
type commonOptions struct {
|
type commonOptions struct {
|
||||||
ConfigPath string
|
ConfigPath string
|
||||||
Units string
|
Units string
|
||||||
Timezone string
|
Timezone string
|
||||||
Output string
|
Output string
|
||||||
OutputDir string
|
OutputDir string
|
||||||
|
LLMDebugDir string
|
||||||
|
Quiet bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type generateOptions struct {
|
type generateOptions struct {
|
||||||
commonOptions
|
commonOptions
|
||||||
Date string
|
Date string
|
||||||
Start string
|
|
||||||
End string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type inspectOptions struct {
|
type inspectOptions struct {
|
||||||
@@ -180,20 +203,25 @@ func runInspectRunCommand(ctx context.Context, stdout io.Writer, command inspect
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
||||||
|
req, _, err := r.resolveGenerateAction(args)
|
||||||
|
return req, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commonOptions, error) {
|
||||||
if r.Clock == nil {
|
if r.Clock == nil {
|
||||||
r.Clock = timeutil.SystemClock{}
|
r.Clock = timeutil.SystemClock{}
|
||||||
}
|
}
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return app.GenerateRequest{}, fmt.Errorf("generate requires a report name")
|
return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("generate requires a report name")
|
||||||
}
|
}
|
||||||
reportKind, ok := reportKind(args[0])
|
if _, err := report.IDForCommandName(args[0]); err != nil {
|
||||||
if !ok {
|
return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("unknown generate report %q", args[0])
|
||||||
return app.GenerateRequest{}, fmt.Errorf("unknown generate report %q", args[0])
|
|
||||||
}
|
}
|
||||||
|
reportKind := app.ReportKind(args[0])
|
||||||
|
|
||||||
opts, err := parseGenerateFlags(reportKind, args[1:])
|
opts, err := parseGenerateFlags(reportKind, args[1:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return app.GenerateRequest{}, err
|
return app.GenerateRequest{}, commonOptions{}, err
|
||||||
}
|
}
|
||||||
cfg, err := config.Load(config.LoadOptions{
|
cfg, err := config.Load(config.LoadOptions{
|
||||||
Path: opts.ConfigPath,
|
Path: opts.ConfigPath,
|
||||||
@@ -201,62 +229,68 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
|||||||
Timezone: opts.Timezone,
|
Timezone: opts.Timezone,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return app.GenerateRequest{}, err
|
return app.GenerateRequest{}, commonOptions{}, err
|
||||||
|
}
|
||||||
|
executor, err := r.promptExecutor(cfg.Promptkit)
|
||||||
|
if err != nil {
|
||||||
|
return app.GenerateRequest{}, commonOptions{}, err
|
||||||
}
|
}
|
||||||
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
|
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return app.GenerateRequest{}, err
|
return app.GenerateRequest{}, commonOptions{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
req := app.GenerateRequest{
|
req := app.GenerateRequest{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Report: reportKind,
|
Report: reportKind,
|
||||||
OutputPath: opts.Output,
|
OutputPath: opts.Output,
|
||||||
Now: r.Clock.Now(),
|
LLMDebugDir: opts.LLMDebugDir,
|
||||||
|
Now: r.Clock.Now(),
|
||||||
|
Executor: executor,
|
||||||
}
|
}
|
||||||
|
|
||||||
switch reportKind {
|
switch reportKind {
|
||||||
case app.ReportDaily:
|
case app.ReportDaily:
|
||||||
|
if opts.Date == "" {
|
||||||
|
return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("generate daily requires --date YYYY-MM-DD")
|
||||||
|
}
|
||||||
|
req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
|
||||||
|
if err != nil {
|
||||||
|
return app.GenerateRequest{}, commonOptions{}, err
|
||||||
|
}
|
||||||
|
case app.ReportToday:
|
||||||
if opts.Date == "" {
|
if opts.Date == "" {
|
||||||
req.Date = timeutil.LocalDate(r.Clock.Now(), location)
|
req.Date = timeutil.LocalDate(r.Clock.Now(), location)
|
||||||
} else {
|
} else {
|
||||||
req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
|
req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return app.GenerateRequest{}, err
|
return app.GenerateRequest{}, commonOptions{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case app.ReportStorm:
|
|
||||||
if opts.Start == "" {
|
|
||||||
return app.GenerateRequest{}, fmt.Errorf("generate storm requires --start")
|
|
||||||
}
|
|
||||||
if opts.End == "" {
|
|
||||||
return app.GenerateRequest{}, fmt.Errorf("generate storm requires --end")
|
|
||||||
}
|
|
||||||
period, err := report.ParseStormPeriod(opts.Start, opts.End, location)
|
|
||||||
if err != nil {
|
|
||||||
return app.GenerateRequest{}, err
|
|
||||||
}
|
|
||||||
req.StormStart = period.Start
|
|
||||||
req.StormEnd = period.End
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return req, nil
|
return req, opts.commonOptions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r Runner) resolveRun(args []string) (app.BatchRequest, error) {
|
func (r Runner) resolveRun(args []string) (app.BatchRequest, error) {
|
||||||
|
req, _, err := r.resolveRunAction(args)
|
||||||
|
return req, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Runner) resolveRunAction(args []string) (app.BatchRequest, commonOptions, error) {
|
||||||
if r.Clock == nil {
|
if r.Clock == nil {
|
||||||
r.Clock = timeutil.SystemClock{}
|
r.Clock = timeutil.SystemClock{}
|
||||||
}
|
}
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return app.BatchRequest{}, fmt.Errorf("run requires a batch name")
|
return app.BatchRequest{}, commonOptions{}, fmt.Errorf("run requires a batch name")
|
||||||
}
|
}
|
||||||
batch, ok := batchKind(args[0])
|
if _, err := report.BatchForCommandName(args[0]); err != nil {
|
||||||
if !ok {
|
return app.BatchRequest{}, commonOptions{}, fmt.Errorf("unknown run batch %q", args[0])
|
||||||
return app.BatchRequest{}, fmt.Errorf("unknown run batch %q", args[0])
|
|
||||||
}
|
}
|
||||||
|
batch := app.BatchKind(args[0])
|
||||||
opts, err := parseRunFlags(args[1:])
|
opts, err := parseRunFlags(args[1:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return app.BatchRequest{}, err
|
return app.BatchRequest{}, commonOptions{}, err
|
||||||
}
|
}
|
||||||
cfg, err := config.Load(config.LoadOptions{
|
cfg, err := config.Load(config.LoadOptions{
|
||||||
Path: opts.ConfigPath,
|
Path: opts.ConfigPath,
|
||||||
@@ -264,9 +298,13 @@ func (r Runner) resolveRun(args []string) (app.BatchRequest, error) {
|
|||||||
Timezone: opts.Timezone,
|
Timezone: opts.Timezone,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return app.BatchRequest{}, err
|
return app.BatchRequest{}, commonOptions{}, err
|
||||||
}
|
}
|
||||||
return app.BatchRequest{Config: cfg, Batch: batch, Now: r.Clock.Now(), OutputDir: opts.OutputDir}, nil
|
executor, err := r.promptExecutor(cfg.Promptkit)
|
||||||
|
if err != nil {
|
||||||
|
return app.BatchRequest{}, commonOptions{}, err
|
||||||
|
}
|
||||||
|
return app.BatchRequest{Config: cfg, Batch: batch, Now: r.Clock.Now(), OutputDir: opts.OutputDir, LLMDebugDir: opts.LLMDebugDir, Executor: executor}, opts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveRun(args []string) (app.BatchRequest, error) {
|
func resolveRun(args []string) (app.BatchRequest, error) {
|
||||||
@@ -278,13 +316,10 @@ func parseGenerateFlags(report app.ReportKind, args []string) (generateOptions,
|
|||||||
fs.SetOutput(io.Discard)
|
fs.SetOutput(io.Discard)
|
||||||
opts := generateOptions{}
|
opts := generateOptions{}
|
||||||
addCommonFlags(fs, &opts.commonOptions, true)
|
addCommonFlags(fs, &opts.commonOptions, true)
|
||||||
if report == app.ReportDaily {
|
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress successful action output")
|
||||||
|
if report == app.ReportDaily || report == app.ReportToday {
|
||||||
fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD")
|
fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD")
|
||||||
}
|
}
|
||||||
if report == app.ReportStorm {
|
|
||||||
fs.StringVar(&opts.Start, "start", "", "storm start time")
|
|
||||||
fs.StringVar(&opts.End, "end", "", "storm end time")
|
|
||||||
}
|
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return generateOptions{}, err
|
return generateOptions{}, err
|
||||||
}
|
}
|
||||||
@@ -300,6 +335,7 @@ func parseRunFlags(args []string) (commonOptions, error) {
|
|||||||
opts := commonOptions{}
|
opts := commonOptions{}
|
||||||
addCommonFlags(fs, &opts, false)
|
addCommonFlags(fs, &opts, false)
|
||||||
fs.StringVar(&opts.OutputDir, "out-dir", "", "extra Markdown report copy directory")
|
fs.StringVar(&opts.OutputDir, "out-dir", "", "extra Markdown report copy directory")
|
||||||
|
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress successful action output")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return commonOptions{}, err
|
return commonOptions{}, err
|
||||||
}
|
}
|
||||||
@@ -342,71 +378,12 @@ func parseInspectRunFlags(command string, args []string) (inspectOptions, error)
|
|||||||
return opts, nil
|
return opts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJSON(stdout io.Writer, value any) error {
|
|
||||||
encoder := json.NewEncoder(stdout)
|
|
||||||
encoder.SetIndent("", " ")
|
|
||||||
return encoder.Encode(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeRunLogs(stderr io.Writer, result *app.BatchResult) {
|
|
||||||
if stderr == nil || result == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, item := range result.Reports {
|
|
||||||
notificationFields := ""
|
|
||||||
if item.NotificationStatus != "" {
|
|
||||||
notificationFields += fmt.Sprintf(" notificationStatus=%q", item.NotificationStatus)
|
|
||||||
}
|
|
||||||
if item.NotificationRunID != "" {
|
|
||||||
notificationFields += fmt.Sprintf(" notificationRunId=%q", item.NotificationRunID)
|
|
||||||
}
|
|
||||||
if item.NotificationError != "" {
|
|
||||||
notificationFields += fmt.Sprintf(" notificationError=%q", item.NotificationError)
|
|
||||||
}
|
|
||||||
if item.Status == "failed" {
|
|
||||||
_, _ = fmt.Fprintf(stderr, "report=%s status=failed error=%q%s\n", item.ReportID, item.Error, notificationFields)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
_, _ = fmt.Fprintf(stderr, "report=%s status=succeeded output=%q%s\n", item.ReportID, item.OutputPath, notificationFields)
|
|
||||||
}
|
|
||||||
_, _ = fmt.Fprintf(stderr, "batch=%s total=%d succeeded=%d failed=%d\n", result.Batch, result.Total, result.Succeeded, result.Failed)
|
|
||||||
}
|
|
||||||
|
|
||||||
func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) {
|
func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) {
|
||||||
fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path")
|
fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path")
|
||||||
fs.StringVar(&opts.Units, "units", "", "weather API units")
|
fs.StringVar(&opts.Units, "units", "", "weather API units")
|
||||||
fs.StringVar(&opts.Timezone, "tz", "", "weather API timezone")
|
fs.StringVar(&opts.Timezone, "tz", "", "weather API timezone")
|
||||||
|
fs.StringVar(&opts.LLMDebugDir, "llm-debug-dir", "", "write sensitive prompt debug artifacts under PATH")
|
||||||
if includeOutput {
|
if includeOutput {
|
||||||
fs.StringVar(&opts.Output, "out", "", "extra Markdown report copy path")
|
fs.StringVar(&opts.Output, "out", "", "extra Markdown report copy path")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func reportKind(value string) (app.ReportKind, bool) {
|
|
||||||
switch value {
|
|
||||||
case string(app.ReportDaily):
|
|
||||||
return app.ReportDaily, true
|
|
||||||
case string(app.ReportTomorrow):
|
|
||||||
return app.ReportTomorrow, true
|
|
||||||
case string(app.ReportHourly):
|
|
||||||
return app.ReportHourly, true
|
|
||||||
case string(app.ReportThreeDay):
|
|
||||||
return app.ReportThreeDay, true
|
|
||||||
case string(app.ReportWeekend):
|
|
||||||
return app.ReportWeekend, true
|
|
||||||
case string(app.ReportStorm):
|
|
||||||
return app.ReportStorm, true
|
|
||||||
default:
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func batchKind(value string) (app.BatchKind, bool) {
|
|
||||||
switch value {
|
|
||||||
case string(app.BatchMorning):
|
|
||||||
return app.BatchMorning, true
|
|
||||||
case string(app.BatchEvening):
|
|
||||||
return app.BatchEvening, true
|
|
||||||
default:
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
42
internal/cli/run_test.go
Normal file
42
internal/cli/run_test.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseRunFlagsAcceptsPromptDebugDirectory(t *testing.T) {
|
||||||
|
opts, err := parseRunFlags([]string{"--llm-debug-dir", "/tmp/prompt-debug"})
|
||||||
|
if err != nil || opts.LLMDebugDir != "/tmp/prompt-debug" {
|
||||||
|
t.Fatalf("parseRunFlags() = %#v, %v", opts, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRunActionConstructsOneExecutor(t *testing.T) {
|
||||||
|
for _, command := range []string{"morning", "evening"} {
|
||||||
|
t.Run(command, func(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.yml")
|
||||||
|
if err := os.WriteFile(configPath, []byte("workspace:\n root: "+filepath.Join(t.TempDir(), "workspace")+"\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
calls := 0
|
||||||
|
executor := &factoryExecutor{}
|
||||||
|
runner := Runner{
|
||||||
|
Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 0, 0, 0, time.UTC)},
|
||||||
|
ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) {
|
||||||
|
calls++
|
||||||
|
return executor, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req, _, err := runner.resolveRunAction([]string{command, "--config", configPath, "--llm-debug-dir", "/tmp/debug"})
|
||||||
|
if err != nil || calls != 1 || req.Executor != executor || req.LLMDebugDir != "/tmp/debug" {
|
||||||
|
t.Fatalf("resolveRunAction() request/error/calls = %#v/%v/%d", req, err, calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
31
internal/collect/collect.go
Normal file
31
internal/collect/collect.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Package collect owns upstream weather source collection for application use.
|
||||||
|
package collect
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/weatherapi"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Request struct {
|
||||||
|
Config config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
Bundle *weatherdata.Bundle
|
||||||
|
}
|
||||||
|
|
||||||
|
func Run(ctx context.Context, req Request) (*Result, error) {
|
||||||
|
client, err := weatherapi.New(req.Config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("prepare weather collection: %w", err)
|
||||||
|
}
|
||||||
|
bundle, err := client.FetchBundle(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("collect weather bundle: %w", err)
|
||||||
|
}
|
||||||
|
return &Result{Bundle: bundle}, nil
|
||||||
|
}
|
||||||
99
internal/collect/collect_test.go
Normal file
99
internal/collect/collect_test.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package collect
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunFetchesBundle(t *testing.T) {
|
||||||
|
server := collectionTestServer(t, nil)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||||
|
|
||||||
|
result, err := Run(context.Background(), Request{Config: cfg})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if result == nil || result.Bundle == nil {
|
||||||
|
t.Fatal("Run() result bundle = nil, want fetched bundle")
|
||||||
|
}
|
||||||
|
if result.Bundle.Hourly == nil {
|
||||||
|
t.Fatal("Hourly = nil, want fetched hourly forecast")
|
||||||
|
}
|
||||||
|
if result.Bundle.WeatherStory == nil || result.Bundle.WeatherStory.Title != "Several Chances for Rain Through Monday" {
|
||||||
|
t.Fatalf("WeatherStory = %#v, want fetched weather story", result.Bundle.WeatherStory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunWrapsAdapterConstructionError(t *testing.T) {
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.WeatherAPI.BaseURL = ""
|
||||||
|
cfg.Secrets.Directory = "super-secret-directory"
|
||||||
|
|
||||||
|
_, err := Run(context.Background(), Request{Config: cfg})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Run() error = nil, want adapter construction error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "prepare weather collection") {
|
||||||
|
t.Fatalf("error = %q, want collection setup context", err.Error())
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), cfg.Secrets.Directory) {
|
||||||
|
t.Fatalf("error = %q, want no secret path leakage", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunWrapsFetchError(t *testing.T) {
|
||||||
|
server := collectionTestServer(t, map[string]int{"/observations": http.StatusBadGateway})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||||
|
|
||||||
|
_, err := Run(context.Background(), Request{Config: cfg})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Run() error = nil, want fetch error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "collect weather bundle") {
|
||||||
|
t.Fatalf("error = %q, want collection fetch context", err.Error())
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "/observations") {
|
||||||
|
t.Fatalf("error = %q, want source endpoint context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionTestServer(t *testing.T, statusByPath map[string]int) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if status := statusByPath[r.URL.Path]; status != 0 {
|
||||||
|
http.Error(w, "upstream failure", status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/observations":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`))
|
||||||
|
case "/conditions/current":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`))
|
||||||
|
case "/forecast/hourly":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T13:00:00-05:00","endTime":"2026-05-29T14:00:00-05:00"}]}}`))
|
||||||
|
case "/forecast/narrative":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[]}}`))
|
||||||
|
case "/alerts/active":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"alerts":[]}}`))
|
||||||
|
case "/discussion":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":[],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for saved bundle."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for saved bundle."}}}`))
|
||||||
|
case "/weatherstories/latest":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
|
||||||
|
case "/outlooks/convective":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user