Compare commits
222 Commits
v0.5.0
...
5e96790d85
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e96790d85 | |||
| 35f4f82e94 | |||
| b605596bcb | |||
| 9303502b32 | |||
| f9eef80233 | |||
| c6f8570474 | |||
| 1130d807dc | |||
| ff2e664c62 | |||
| 2f3558cf33 | |||
| 154d31c3e8 | |||
| 6b1ff862f3 | |||
| 0c27fab384 | |||
| ad3b788f8c | |||
| 82acb8dc1a | |||
| 3aaddda676 | |||
| 7f989839cd | |||
| 27506168f8 | |||
| dc11e08e22 | |||
| fdddb5f08d | |||
| f78186b020 | |||
| 8dd604afb4 | |||
| 52bb17c8fa | |||
| 7952e4fb25 | |||
| 0281327365 | |||
| bf76eae301 | |||
| 0d47662cf9 | |||
| f4f009b904 | |||
| 3c1b753952 | |||
| bdbab48d10 | |||
| 16cc4b3f63 | |||
| 0ef861ed8f | |||
| 6ae7eb44cf | |||
| 8f6aa8aa8b | |||
| b8e889ad13 | |||
| 15ee4af1a1 | |||
| 4c606eb39f | |||
| 8d2ac163ae | |||
| 8709b5f4d8 | |||
| fd48ebecb8 | |||
| 021e5dd8b1 | |||
| 7adf5e1b08 | |||
| 455cc67d4c | |||
| dd3133ee2a | |||
| b3637cddd6 | |||
| 662db5e511 | |||
| 2ef91cf1b1 | |||
| 1d2f176977 | |||
| 2b3bcdd4f1 | |||
| a82f03feb8 | |||
| f1d4e38414 | |||
| 32060bd370 | |||
| 133f83f4ce | |||
| 42f0e16b02 | |||
| a2f0a2fc36 | |||
| 9f552cff6b | |||
| b913194fb4 | |||
| 3eccafad6b | |||
| a9d87bdbaa | |||
| 6f9255105d | |||
| c04e3c5599 | |||
| f15315f1b9 | |||
| 0ef6cd567e | |||
| b308ff4d6b | |||
| 5ecbc06c85 | |||
| 21e97f5d4e | |||
| 3900b3313b | |||
| 5d416cfc4a | |||
| 6532e8824a | |||
| d321492995 | |||
| b57110e5c8 | |||
| 482e83903c | |||
| 21a7748b2c | |||
| b36e198bfe | |||
| f9d6d42b1b | |||
| d9ab1e47ec | |||
| 4f755704d9 | |||
| a13f04fce5 | |||
| 1f5b347cd2 | |||
| 3639636813 | |||
| ca27d81163 | |||
| d74ba0f259 | |||
| 121f28fd29 | |||
| e3bcecc5c1 | |||
| 0884eb0ce5 | |||
| a27e870522 | |||
| d90801cff5 | |||
| 0f63159482 | |||
| 2792933833 | |||
| 9261431329 | |||
| 1bfd865333 | |||
| e5af7477af | |||
| 92fcbfcc05 | |||
| ff6aade42c | |||
| 4fac69c9f0 | |||
| 90ab6973e6 | |||
| 90a502f50e | |||
| 273f462e09 | |||
| 5361d5647b | |||
| d2e90da148 | |||
| 047ce32ac6 | |||
| 5896168a93 | |||
| fe9c40741a | |||
| 88004a1827 | |||
| a515b7e7d9 | |||
| 696454cf34 | |||
| 8c97788682 | |||
| 5203440ba0 | |||
| 3d452a120a | |||
| 4eece7cc8a | |||
| d0d0b698f9 | |||
| 4c4b01f265 | |||
| 58fe794227 | |||
| 63dfc0b55a | |||
| 1ddc33eb17 | |||
| 4a0238909b | |||
| 3d5f71e72d | |||
| 8ff5c44324 | |||
| 4e704e4f51 | |||
| 7b4c73d1e6 | |||
| 7efd8b5855 | |||
| 7cbc59d8a7 | |||
| 67b30dbad6 | |||
| cd8d77b37c | |||
| bb79232e3e | |||
| b4e0aadbef | |||
| 4fe0f40cef | |||
| 40b42f4bf3 | |||
| e8f1aa5caf | |||
| a02af0bce0 | |||
| ff2f8c16a3 | |||
| ace5577402 | |||
| 473f252aee | |||
| 74bd69834c | |||
| 98cab70b53 | |||
| dc0172ff82 | |||
| 0b41773017 | |||
| ddda42453f | |||
| 295f06915f | |||
| 120bce3391 | |||
| 386263784c | |||
| afe6803a63 | |||
| 5344c7880a | |||
| 74e32eb18e | |||
| ceb00ad45e | |||
| 7cd07c429b | |||
| 28b8391d53 | |||
| bb8de054dc | |||
| 9d6502460e | |||
| fcf1108641 | |||
| 185605fbf0 | |||
| 662d906997 | |||
| f6e20d1412 | |||
| 9e14a9b7c7 | |||
| 422430613c | |||
| 2a4ce64d6f | |||
| 316ab8f3fc | |||
| f6a68426e1 | |||
| bed2b84100 | |||
| b39ec1e4d3 | |||
| dd92e9b061 | |||
| 8d737395dc | |||
| b3f7c9c1f2 | |||
| d425132ae7 | |||
| 925d351341 | |||
| c4107490df | |||
| b38230bc35 | |||
| 1f5f9964e0 | |||
| b0d8c6983d | |||
| 55f0180599 | |||
| 5284033fb8 | |||
| 0b1423c90d | |||
| e6a4bb2d16 | |||
| c3a051d372 | |||
| 3482551360 | |||
| d7a72f8581 | |||
| c09b7410ce | |||
| 96ce0edd46 | |||
| 7cd68ff222 | |||
| 16680e3f61 | |||
| 3389d4fa93 | |||
| 0041845935 | |||
| 3bcccb4a7b | |||
| 2aba52f552 | |||
| f149563c68 | |||
| 276e4f1189 | |||
| cb42cad6a6 | |||
| ef044327c6 | |||
| d1d0df11a8 | |||
| c3da3af2f4 | |||
| 7b760a0823 | |||
| 1e9c29aa55 | |||
| 1ddd88231a | |||
| d665049f05 | |||
| 1af6169999 | |||
| 468197f7e0 | |||
| 816cfb24aa | |||
| 479d144592 | |||
| 0b516d9762 | |||
| 2483c2362d | |||
| 40639309b1 | |||
| eefb0681dc | |||
| 9501dad1dc | |||
| 24dba3bd60 | |||
| e9508089ab | |||
| 454f47b2b5 | |||
| d8b417458b | |||
| 195a130124 | |||
| 8577fc29e4 | |||
| d71c7e4d28 | |||
| 9bc8156615 | |||
| 183b23cf5a | |||
| 138bc4e7e4 | |||
| e2529cfdf2 | |||
| b982b27f84 | |||
| c573cd5b4d | |||
| c2758d7a91 | |||
| 64cae8c4d9 | |||
| a2ba6f5382 | |||
| 7c8d9191c1 | |||
| 9677835d84 | |||
| 63749a9572 | |||
| 9ff90d33fc |
50
.woodpecker/release.yml
Normal file
50
.woodpecker/release.yml
Normal file
@@ -0,0 +1,50 @@
|
||||
when:
|
||||
- event: tag
|
||||
|
||||
steps:
|
||||
- name: build-release-assets
|
||||
image: golang:1.25
|
||||
commands:
|
||||
- |
|
||||
set -eu
|
||||
|
||||
version="$CI_COMMIT_TAG"
|
||||
dist="dist"
|
||||
pkg="gitea.maximumdirect.net/eric/weatherreporter/cmd/weatherreporter"
|
||||
|
||||
rm -rf "$dist"
|
||||
mkdir -p "$dist"
|
||||
|
||||
build_binary() {
|
||||
goos="$1"
|
||||
goarch="$2"
|
||||
suffix="$3"
|
||||
output="$dist/weatherreporter-$version-$goos-$goarch$suffix"
|
||||
|
||||
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \
|
||||
go build -trimpath -ldflags "-s -w -X gitea.maximumdirect.net/eric/weatherreporter/internal/buildinfo.Version=$version" \
|
||||
-o "$output" "$pkg"
|
||||
}
|
||||
|
||||
build_binary linux amd64 ""
|
||||
build_binary linux arm64 ""
|
||||
build_binary darwin amd64 ""
|
||||
build_binary darwin arm64 ""
|
||||
build_binary windows amd64 ".exe"
|
||||
build_binary windows arm64 ".exe"
|
||||
|
||||
- name: publish-release
|
||||
image: woodpeckerci/plugin-release
|
||||
depends_on:
|
||||
- build-release-assets
|
||||
settings:
|
||||
api_key:
|
||||
from_secret: GITEA_RELEASE_TOKEN
|
||||
files:
|
||||
- dist/weatherreporter-*
|
||||
checksum: sha256
|
||||
checksum-file: SHA256SUMS
|
||||
checksum-flatten: true
|
||||
file-exists: skip
|
||||
overwrite: false
|
||||
prerelease: false
|
||||
@@ -1,4 +1 @@
|
||||
Please carefully review the documents in `docs/policy` before making any changes to this repository.
|
||||
- `architecture.md` provides the canonical high-level architecture policy for this repository.
|
||||
- `development.md` provides more granular development policy for this repository.
|
||||
- `documentation.md` provides the canonical documentation policy for this repository.
|
||||
Please review `docs/development.md` for initial orientation in this repository and follow its task-specific reading guide.
|
||||
|
||||
16
README.md
16
README.md
@@ -1,21 +1,25 @@
|
||||
# weatherreporter
|
||||
|
||||
`weatherreporter` is a Go application for preparing human-facing weather
|
||||
reports from normalized forecast data. It builds structured briefing packages,
|
||||
runs them through `scriptorium`, and keeps inspectable artifacts under a local
|
||||
workspace.
|
||||
Weatherreporter is a Go CLI that turns normalized weather data into managed,
|
||||
human-facing Markdown reports.
|
||||
|
||||
It provides repeatable reports with inspectable local artifacts, so operators
|
||||
can review what was collected and generated for every run.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```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
|
||||
|
||||
- [CLI reference](docs/cli.md)
|
||||
- [Configuration reference](docs/config.md)
|
||||
- [Operations guide](docs/operations.md)
|
||||
- [Troubleshooting](docs/troubleshooting.md)
|
||||
- [Development guide](docs/development.md)
|
||||
- [Architecture policy](docs/policy/architecture.md)
|
||||
- [Development policy](docs/policy/development.md)
|
||||
|
||||
181
docs/cli.md
181
docs/cli.md
@@ -1,90 +1,157 @@
|
||||
# Weatherreporter CLI
|
||||
|
||||
`weatherreporter` generates Markdown weather reports, runs scheduled report
|
||||
batches, and inspects previously generated artifacts.
|
||||
`weatherreporter` generates weather reports, runs report batches, and inspects
|
||||
artifacts already stored in its workspace.
|
||||
|
||||
## Shortest Useful Command
|
||||
|
||||
```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
|
||||
artifacts, runs `scriptorium render` as a preflight check, runs
|
||||
`scriptorium run`, and writes an extra Markdown copy to `./daily.md`.
|
||||
The command uses the configured Weather API and writes an extra Markdown copy
|
||||
at `./today.md`. See the [configuration reference](config.md) to supply the
|
||||
required Weather API endpoint.
|
||||
|
||||
## Commands
|
||||
## Commands And Usage
|
||||
|
||||
```text
|
||||
weatherreporter --help
|
||||
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
||||
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
|
||||
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
|
||||
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
|
||||
weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
|
||||
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] [--quiet]
|
||||
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
|
||||
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
|
||||
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
|
||||
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
|
||||
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet] --start TIME --end TIME
|
||||
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--quiet]
|
||||
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--quiet]
|
||||
weatherreporter inspect reports [--config PATH] [--limit N]
|
||||
weatherreporter inspect metadata [--config PATH] RUN_ID
|
||||
weatherreporter inspect briefing [--config PATH] RUN_ID
|
||||
weatherreporter inspect modules [--config PATH] RUN_ID
|
||||
weatherreporter inspect data-package [--config PATH] RUN_ID
|
||||
weatherreporter inspect prior [--config PATH] RUN_ID
|
||||
weatherreporter inspect sources [--config PATH] RUN_ID
|
||||
```
|
||||
|
||||
`generate` commands write briefing, data package, preflight, report, and
|
||||
metadata artifacts under the configured workspace. `generate storm` requires
|
||||
explicit event-window bounds with `--start` and `--end`.
|
||||
| Command | Contract |
|
||||
| --- | --- |
|
||||
| `generate daily` | Requires `--date YYYY-MM-DD`; the date is interpreted in the effective report timezone. |
|
||||
| `generate today` | Accepts an optional `--date YYYY-MM-DD`; without it, the current local date in the effective report timezone is used. |
|
||||
| `generate tomorrow`, `three-day`, `weekend` | Use their report-defined valid period and accept the common generate flags. |
|
||||
| `generate hourly` | Covers the next six hours in the effective report timezone. It does not accept `--date`, `--start`, `--end`, `--hours`, or `--duration`. |
|
||||
| `generate storm` | Requires both `--start TIME` and `--end TIME`. Each time may be `YYYY-MM-DDTHH:MM` in the effective timezone or an RFC3339 timestamp with an explicit offset. |
|
||||
| `run morning` and `run evening` | Run their defined report batches. `--out-dir` writes extra Markdown copies; `--out` is not accepted. |
|
||||
|
||||
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
||||
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch
|
||||
runs continue independent reports after a failure, print a JSON summary to
|
||||
stdout, write compact status lines to stderr, and return nonzero when any report
|
||||
failed.
|
||||
`generate` accepts all seven report command names shown above. `run` accepts
|
||||
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.
|
||||
They do not fetch weather data or invoke `scriptorium`.
|
||||
## Output, Errors, And Quiet Mode
|
||||
|
||||
## 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.
|
||||
- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`.
|
||||
- `--units VALUE`: override configured Weather API units for `generate` and `run`.
|
||||
- `--tz NAME`: override configured Weather API timezone for `generate` and `run`.
|
||||
- `--out PATH`: write an extra Markdown report copy for `generate` commands.
|
||||
- `--out-dir PATH`: write extra Markdown report copies for `run morning` and `run evening`.
|
||||
- `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone.
|
||||
- `--start TIME`: required start time for `generate storm`.
|
||||
- `--end TIME`: required end time for `generate storm`.
|
||||
- `--limit N`: maximum records for `inspect reports`; defaults to `20`, and `0` means no limit.
|
||||
`--quiet` is supported by action commands only. It suppresses action summaries
|
||||
and routine batch status output; it does not suppress command errors.
|
||||
|
||||
Storm times accept `YYYY-MM-DDTHH:MM` in the configured timezone or RFC3339
|
||||
timestamps with explicit offsets.
|
||||
Inspection commands always write their requested JSON value to stdout and do
|
||||
not accept `--quiet`.
|
||||
|
||||
## Common Workflows
|
||||
### Generate Summary
|
||||
|
||||
```sh
|
||||
weatherreporter generate tomorrow --out ./tomorrow.md
|
||||
weatherreporter generate three-day --out ./three-day.md
|
||||
weatherreporter generate weekend --out ./weekend.md
|
||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
|
||||
weatherreporter run morning --out-dir ./reports
|
||||
weatherreporter run evening --out-dir ./reports
|
||||
A generate summary always identifies the command, report, run, generation
|
||||
time, valid period, and status:
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "generate",
|
||||
"reportId": "today",
|
||||
"reportName": "Today Report",
|
||||
"promptId": "weather.today_generated_text",
|
||||
"runId": "20260529T120000.000000000Z_today",
|
||||
"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`, and `preflightPath`. Generated-text reports additionally
|
||||
include `generatedTextRawPath`, `generatedTextResultPath`,
|
||||
`generatedTextPath`, and `renderContextPath`. `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. |
|
||||
| `--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. |
|
||||
| `--start TIME`, `--end TIME` | `generate storm` | Required storm-event bounds. |
|
||||
| `--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 storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
|
||||
weatherreporter run morning --out-dir ./reports
|
||||
```
|
||||
|
||||
## Inspection Commands
|
||||
|
||||
```sh
|
||||
weatherreporter inspect reports --limit 10
|
||||
weatherreporter inspect metadata 20260529T100000.000000000Z_daily_today
|
||||
weatherreporter inspect briefing 20260529T100000.000000000Z_daily_today
|
||||
weatherreporter inspect data-package 20260529T100000.000000000Z_daily_today
|
||||
weatherreporter inspect prior 20260529T100000.000000000Z_daily_today
|
||||
weatherreporter inspect sources 20260529T100000.000000000Z_daily_today
|
||||
weatherreporter inspect metadata 20260529T100000.000000000Z_today
|
||||
weatherreporter inspect modules 20260529T100000.000000000Z_today
|
||||
weatherreporter inspect data-package 20260529T100000.000000000Z_today
|
||||
weatherreporter inspect prior 20260529T100000.000000000Z_today
|
||||
weatherreporter inspect sources 20260529T100000.000000000Z_today
|
||||
```
|
||||
|
||||
`inspect reports` lists recent generated runs with artifact paths and source
|
||||
warning counts. The other inspect commands require a RunID. `inspect prior`
|
||||
returns the prior comparable snapshot metadata selected from stored metadata, or
|
||||
`null` when none exists. `inspect sources` shows source provenance and source
|
||||
warnings without dumping full weather payloads.
|
||||
| Command | JSON returned |
|
||||
| --- | --- |
|
||||
| `inspect reports` | Recent generated runs, including artifact paths and source-warning counts. |
|
||||
| `inspect metadata RUN_ID` | Persisted metadata for the run. |
|
||||
| `inspect modules RUN_ID` | The run's persisted ordered module snapshot. |
|
||||
| `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
|
||||
`scriptorium`. See the [operations guide](operations.md) for artifact lifecycle
|
||||
and recovery.
|
||||
|
||||
228
docs/config.md
228
docs/config.md
@@ -1,128 +1,206 @@
|
||||
# Weatherreporter Configuration
|
||||
|
||||
Configuration is YAML. By default, `weatherreporter` reads:
|
||||
Weatherreporter reads YAML configuration. The default path is:
|
||||
|
||||
```text
|
||||
/usr/local/etc/weatherreporter/config.yml
|
||||
```
|
||||
|
||||
Use `--config PATH` to load a different file. If the default file is absent,
|
||||
built-in defaults are used. If `--config PATH` points to a missing file, loading
|
||||
fails.
|
||||
If the default file is absent, Weatherreporter uses built-in defaults. An
|
||||
explicit `--config PATH` must exist. Values are applied in this order:
|
||||
|
||||
Precedence is:
|
||||
1. built-in defaults;
|
||||
2. the configuration file, when present; and
|
||||
3. the `--units` and `--tz` command-line overrides.
|
||||
|
||||
1. CLI flags
|
||||
2. configuration file
|
||||
3. built-in defaults
|
||||
Environment variables do not override configuration fields. Output flags write
|
||||
extra report copies for a command and do not change configuration.
|
||||
|
||||
The implemented configuration overrides are `--units` and `--tz`. Output flags
|
||||
control report copies for the current command but do not change configuration
|
||||
files. Environment-variable configuration is not implemented.
|
||||
## Maintained Examples
|
||||
|
||||
## Minimal Config
|
||||
- [minimal-config.yml](../examples/minimal-config.yml) is the smallest useful
|
||||
collection and generation configuration.
|
||||
- [config.yml](../examples/config.yml) is a representative production-oriented
|
||||
configuration using synthetic endpoints and no credentials.
|
||||
|
||||
See [examples/minimal-config.yml](../examples/minimal-config.yml).
|
||||
Both files are loaded by the configuration test suite.
|
||||
|
||||
## Minimal Configuration
|
||||
|
||||
```yaml
|
||||
weather_api:
|
||||
base_url: https://weather.api.example.com/
|
||||
```
|
||||
|
||||
`weather_api.base_url` is required for commands that fetch weather data. Other
|
||||
fields fall back to defaults.
|
||||
|
||||
## Production-Oriented Config
|
||||
|
||||
See [examples/config.yml](../examples/config.yml). The example is loaded by the
|
||||
config test suite.
|
||||
`weather_api.base_url` is required for workflows that collect weather data.
|
||||
All omitted fields use their built-in defaults.
|
||||
|
||||
## Field Reference
|
||||
|
||||
### `weather_api`
|
||||
|
||||
- `base_url`: absolute base URL for the Weather API. Required for generation and fetch workflows.
|
||||
- `timeout`: HTTP timeout duration. Default: `10s`.
|
||||
- `precision`: numeric precision query value. Default: `1`.
|
||||
- `units`: Weather API units query value. Default: `us`.
|
||||
- `timezone`: report timezone and Weather API timezone query value where supported. Default: `America/Chicago`.
|
||||
- `format`: Weather API response format. Must be `json`. Default: `json`.
|
||||
| Field | Default | Rules |
|
||||
| --- | --- | --- |
|
||||
| `base_url` | empty | Absolute Weather API URL. Required for collection and generation. |
|
||||
| `timeout` | `10s` | Must be greater than zero. |
|
||||
| `precision` | `0` | Must be zero or greater. Sent as the Weather API precision query value. |
|
||||
| `units` | `us` | Required Weather API units query value; `--units` overrides it for one command. |
|
||||
| `timezone` | `America/Chicago` | Required report and Weather API timezone; `--tz` overrides it for one command. |
|
||||
| `format` | `json` | Required and must be `json`. |
|
||||
|
||||
Timezone values may be IANA names, configured aliases such as `Chicago` and
|
||||
`Stl`, US timezone abbreviations, or UTC offsets such as `-5` and `+09:30`.
|
||||
|
||||
### `location`
|
||||
|
||||
`location` is descriptive prompt context included in briefing metadata and
|
||||
Scriptorium data packages. It does not select a Weather API endpoint or enable
|
||||
multiple configured forecast locations.
|
||||
`location` supplies descriptive prompt context; it does not choose a Weather
|
||||
API endpoint or configure multiple forecast locations.
|
||||
|
||||
- `id`: short local identifier. Default: `home`.
|
||||
- `name`: human-readable location name. Default: `Brentwood`.
|
||||
- `region`: broader forecast area context. Default: `St. Louis Metro`.
|
||||
| Field | Default |
|
||||
| --- | --- |
|
||||
| `id` | `home` |
|
||||
| `name` | `Brentwood` |
|
||||
| `region` | `St. Louis Metro` |
|
||||
|
||||
The prompt-facing location object also includes `timezone`, derived from the
|
||||
effective `weather_api.timezone` after CLI overrides such as `--tz`.
|
||||
The prompt-facing location timezone is derived from the effective
|
||||
`weather_api.timezone` after command-line overrides.
|
||||
|
||||
### `secrets`
|
||||
|
||||
`secrets.directory` defaults to empty, which disables secret loading. When it
|
||||
is set, every regular file directly in that directory is loaded after the file
|
||||
and command-line overrides. A file basename must match
|
||||
`[A-Za-z_][A-Za-z0-9_]*`; it becomes an environment variable name, and the
|
||||
file contents replace any existing value. One trailing LF or CRLF is removed.
|
||||
|
||||
Missing directories, unreadable files, subdirectories, symlinks, non-regular
|
||||
files, and invalid names fail configuration loading. Put only secret values in
|
||||
this directory, never in the YAML file.
|
||||
|
||||
### `notify.distributor`
|
||||
|
||||
Distributor notification is disabled by default. Its fields are:
|
||||
|
||||
| Field | Default | Rules when notification is enabled |
|
||||
| --- | --- | --- |
|
||||
| `enabled` | `false` | Activates Distributor notification validation. |
|
||||
| `endpoint` | `https://distributor.example.com` | Must be an absolute URL. |
|
||||
| `token_env` | `DISTRIBUTOR_UPLOAD_TOKEN` | Must name a valid environment variable. |
|
||||
| `timeout` | `30s` | Must be greater than zero. |
|
||||
| `failure_policy` | `error` | Must be `error`. |
|
||||
| `pipeline_id_template` | empty | Required single-report pipeline ID template. |
|
||||
| `bundle_id_template` | `weatherreporter.{location_id}.{report_id}` | Required single-report bundle ID template. |
|
||||
| `idempotency_key_template` | `{bundle_id}.{run_id}` | Required single-report idempotency-key template. |
|
||||
| `batch.enabled` | `true` | Activates batch notification validation when Distributor notification is enabled. |
|
||||
| `batch.pipeline_id_template` | `weatherreporter` | Required when batch notification is enabled. |
|
||||
| `batch.bundle_id_template` | `weatherreporter.{location_id}.{batch}` | Required when batch notification is enabled. |
|
||||
| `batch.idempotency_key_template` | `{bundle_id}.{batch_run_id}` | Required when batch notification is enabled. |
|
||||
|
||||
The upload token is read from the environment variable named by `token_env`.
|
||||
Use `secrets.directory` when a file-backed secret is appropriate.
|
||||
|
||||
Single-report bundle templates accept `location_id`, `report_id`, `run_id`,
|
||||
`artifact_group`, `batch_output_name`, `valid_start_date`, `valid_end_date`,
|
||||
`valid_start_time`, `valid_end_time`, `valid_start_stamp`, `valid_end_stamp`,
|
||||
and `storm_id`. Pipeline and idempotency-key templates may also use
|
||||
`bundle_id`. Dates use `YYYY-MM-DD`; times use `HHMM`; and stamps use
|
||||
`YYYY-MM-DDTHHMM` in the effective report timezone. `storm_id` is
|
||||
`{valid_start_stamp}-{valid_end_stamp}` for Storm Report and empty otherwise.
|
||||
|
||||
Batch bundle and pipeline templates accept `location_id`, `batch`,
|
||||
`batch_run_id`, and `batch_started_date`; batch idempotency-key templates may
|
||||
also use `bundle_id`. `batch_started_date` is the batch start date in the
|
||||
effective report timezone.
|
||||
|
||||
`reports.<report>.distributor.path_templates` overrides the default ordered
|
||||
Distributor paths for that report. Each rendered path must be a unique relative
|
||||
path with `/` separators. Backslashes, empty segments, `.` and `..` segments,
|
||||
`manifest.json`, and the reserved Distributor sidecar basename are rejected.
|
||||
The default paths are:
|
||||
|
||||
| Report | Paths |
|
||||
| --- | --- |
|
||||
| `hourly` | `hourly/index.md` |
|
||||
| `daily` | `daily/{valid_start_date}/{run_id}.md`, `daily/{valid_start_date}/index.md` |
|
||||
| `today` | `daily/{valid_start_date}/{run_id}.md`, `daily/{valid_start_date}/index.md`, `today/index.md` |
|
||||
| `tomorrow` | `daily/{valid_start_date}/{run_id}.md`, `daily/{valid_start_date}/index.md`, `tomorrow/index.md` |
|
||||
| `three_day` | `three-day/{valid_start_date}/{run_id}.md`, `three-day/{valid_start_date}/index.md` |
|
||||
| `weekend` | `weekend/{valid_start_date}/{run_id}.md`, `weekend/{valid_start_date}/index.md` |
|
||||
| `storm` | `storm/{storm_id}/{run_id}.md`, `storm/{storm_id}/index.md` |
|
||||
|
||||
See the [operations guide](operations.md) for notification timing, uploaded
|
||||
artifact selection, and failure handling.
|
||||
|
||||
### `missing_source`
|
||||
|
||||
- `default`: missing-source behavior for optional sources. One of `error`, `warn`, or `none`. Default: `warn`.
|
||||
- `sources`: optional map of source-specific overrides, using the same policy values.
|
||||
|
||||
Hourly forecast data is required for generated reports. Optional sources and
|
||||
stub source slots use the missing-source policy.
|
||||
`missing_source.default` defaults to `warn` and accepts `error`, `warn`, or
|
||||
`none`. `missing_source.sources` optionally overrides that policy by source.
|
||||
Hourly forecast data is required for generated reports. Supported optional
|
||||
source keys are `observations`, `current`, `narrative`, `alerts`, `discussion`,
|
||||
`weather_story`, and `spc_convective_outlooks`.
|
||||
|
||||
### `scriptorium`
|
||||
|
||||
- `binary`: `scriptorium` executable name or path. Default: `scriptorium`.
|
||||
- `config_path`: optional Scriptorium config path passed to the adapter.
|
||||
- `profile`: optional Scriptorium profile passed to the adapter.
|
||||
- `timeout`: subprocess timeout. Default: `2m`.
|
||||
- `extra_args`: optional additional arguments passed to Scriptorium commands.
|
||||
| Field | Default | Rules |
|
||||
| --- | --- | --- |
|
||||
| `binary` | `scriptorium` | Required executable name or path. |
|
||||
| `config_path` | empty | Optional Scriptorium configuration path. |
|
||||
| `profile` | empty | Optional Scriptorium profile. |
|
||||
| `timeout` | `2m` | Must be greater than zero. |
|
||||
| `extra_args` | empty | Optional extra arguments passed to Scriptorium commands. |
|
||||
|
||||
### `workspace`
|
||||
|
||||
- `root`: workspace root for managed artifacts. Default: `workspace`.
|
||||
- `snapshots_dir`: briefing and metadata directory under `workspace.root`. Default: `snapshots`.
|
||||
- `reports_dir`: managed Markdown report directory under `workspace.root`. Default: `reports`.
|
||||
- `data_packages_dir`: prompt input package directory under `workspace.root`. Default: `data-packages`.
|
||||
- `preflight_dir`: Scriptorium render output directory under `workspace.root`. Default: `preflight`.
|
||||
| Field | Default |
|
||||
| --- | --- |
|
||||
| `root` | `workspace` |
|
||||
| `snapshots_dir` | `snapshots` |
|
||||
| `reports_dir` | `reports` |
|
||||
| `data_packages_dir` | `data-packages` |
|
||||
| `preflight_dir` | `preflight` |
|
||||
| `notifications_dir` | `notifications` |
|
||||
|
||||
Workspace subdirectories must be relative paths that stay inside
|
||||
`workspace.root`.
|
||||
`workspace.root` is required. Each workspace subdirectory must be a relative
|
||||
path that stays within the root. See the [operations guide](operations.md) for
|
||||
the managed workspace layout and lifecycle.
|
||||
|
||||
### `dayparts`
|
||||
|
||||
`dayparts` is a list of named local-time windows used by forecast derivation.
|
||||
Each entry has:
|
||||
|
||||
- `name`
|
||||
- `start`
|
||||
- `end`
|
||||
|
||||
`start` and `end` use `HH:MM`. The default entries are overnight, morning,
|
||||
midday, afternoon, and evening.
|
||||
`dayparts` is a non-empty list of named local-time windows used in forecast
|
||||
derivation. Every item needs `name`, `start`, and `end`; start and end use
|
||||
`HH:MM`. Defaults are `overnight` (`00:00`–`06:00`), `morning`
|
||||
(`06:00`–`10:00`), `midday` (`10:00`–`15:00`), `afternoon`
|
||||
(`15:00`–`17:00`), and `evening` (`17:00`–`24:00`).
|
||||
|
||||
### `recent_change`
|
||||
|
||||
- `temperature_degrees`: temperature change threshold. Default: `5`.
|
||||
- `precip_probability_points`: precipitation probability threshold. Default: `20`.
|
||||
- `wind_gust_miles_per_hour`: wind gust change threshold. Default: `10`.
|
||||
- `precip_timing_shift_minutes`: precipitation timing shift threshold. Default: `120`.
|
||||
| Field | Default |
|
||||
| --- | --- |
|
||||
| `temperature_degrees` | `5` |
|
||||
| `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 briefing
|
||||
snapshot exists and a threshold is crossed.
|
||||
These thresholds control when Recent Changes are included in prompt input for a
|
||||
prior comparable module snapshot.
|
||||
|
||||
## Secrets
|
||||
### `reports`
|
||||
|
||||
Configuration files should not contain secrets. The current Weather API and
|
||||
Scriptorium integration settings do not require secret fields.
|
||||
`reports` optionally overrides a report's ordered deterministic modules and
|
||||
Distributor path templates. Omit a report entry to retain its defaults.
|
||||
|
||||
## Maintained Examples
|
||||
Supported report keys are `daily`, `today`, `tomorrow`, `hourly`, `three_day`,
|
||||
`weekend`, and `storm`. Configuration also accepts `three_day_outlook`,
|
||||
`weekend_outlook`, and `storm_report`; hyphens and underscores are equivalent.
|
||||
|
||||
- [examples/minimal-config.yml](../examples/minimal-config.yml): smallest
|
||||
useful config for generation and fetching.
|
||||
- [examples/config.yml](../examples/config.yml): production-oriented config
|
||||
covering implemented fields.
|
||||
Each report entry can contain:
|
||||
|
||||
Both example files are loaded by the config test suite.
|
||||
- `deterministic_modules`: an ordered list of module IDs, or objects with `id`
|
||||
and optional `options`.
|
||||
- `distributor.path_templates`: an optional, non-empty ordered list of
|
||||
Distributor paths for that report.
|
||||
|
||||
Unknown reports and modules, duplicate modules, incompatible report-module
|
||||
combinations, duplicate stanza names, invalid path templates, and invalid
|
||||
module options fail configuration loading. The accepted module IDs and module
|
||||
option contracts are documented in the [module contract internals](internal/module.md).
|
||||
|
||||
86
docs/development.md
Normal file
86
docs/development.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Development
|
||||
|
||||
This is the first-read guide for people and coding agents working on
|
||||
Weatherreporter. It provides a concise repository orientation and routes each
|
||||
kind of change to its canonical documentation.
|
||||
|
||||
Weatherreporter is a Go CLI that collects normalized weather data, derives
|
||||
deterministic report facts and module snapshots, invokes Scriptorium for
|
||||
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. |
|
||||
| Scriptorium commands, subprocess execution, prompt inputs, or result handling | [Scriptorium integration](integrations/scriptorium.md), [Scriptorium adapter internals](internal/scriptorium-adapter.md), and [prompt-input internals](internal/prompt-input.md) | These separate the external CLI contract, subprocess boundary, 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. |
|
||||
| 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, Scriptorium, 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, Scriptorium, or Distributor
|
||||
services. The architecture and testing policies own the detailed rules.
|
||||
|
||||
## Baseline Validation
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
go run ./cmd/weatherreporter --help
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Use focused package tests during development and add broader or race-enabled
|
||||
checks when required by the [testing policy](policy/testing.md) and the risks of
|
||||
the change.
|
||||
70
docs/integrations/distributor/api.md
Normal file
70
docs/integrations/distributor/api.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Distributor HTTP Upload Contract
|
||||
|
||||
Weatherreporter integrates with the HTTP upload API provided by
|
||||
`gitea.maximumdirect.net/eric/distributor v0.5.0`. It submits source bundles to
|
||||
a configured pipeline and reads the resulting run status. Configuration fields
|
||||
and notification lifecycle are documented in the [configuration reference](../../config.md)
|
||||
and [operations guide](../../operations.md).
|
||||
|
||||
## Upload Admission
|
||||
|
||||
Weatherreporter uses an absolute HTTP(S) endpoint as a base URL. The client
|
||||
posts a gzip-compressed source bundle to:
|
||||
|
||||
```text
|
||||
POST /v1/pipelines/<pipeline_id>/upload
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/gzip
|
||||
Idempotency-Key: <key>
|
||||
```
|
||||
|
||||
The authenticated token must be allowed to use the selected upload pipeline.
|
||||
A successful response is `202 Accepted` with JSON containing `run_id` and
|
||||
`status`. Acceptance means Distributor staged and validated the source bundle;
|
||||
it does not mean downstream destinations have published it.
|
||||
|
||||
The adapter requires a pipeline ID, bundle ID, idempotency key, and at least one
|
||||
source-file mapping before calling Distributor. It reads the bearer token from
|
||||
the configured environment variable and redacts that value from errors. Request
|
||||
construction and timeout handling belong to the [Distributor adapter](../../internal/distributor-adapter.md).
|
||||
|
||||
## Idempotency
|
||||
|
||||
Distributor scopes idempotency to the token, pipeline ID, and key. Keys must be
|
||||
non-empty ASCII values of at most 128 bytes using letters, digits, `.`, `_`,
|
||||
`-`, and `:`. Weatherreporter always supplies a rendered key; it does not rely
|
||||
on the client library's generated-key fallback.
|
||||
|
||||
Reusing a key for the same normalized source manifest returns the original
|
||||
accepted run. Reusing it for different content returns `409 Conflict`, which
|
||||
the adapter exposes as a Weatherreporter idempotency-conflict error. A distinct
|
||||
report or batch run therefore needs a distinct key; reuse a key only when
|
||||
retrying that same upload.
|
||||
|
||||
## Run Status And Retention
|
||||
|
||||
After acceptance, Weatherreporter reads:
|
||||
|
||||
```text
|
||||
GET /runs/<run_id>
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
The status record provides `run_id`, `pipeline_id`, status timestamps, optional
|
||||
JSON `report`, and an `error` for failures. Statuses are `accepted`, `queued`,
|
||||
`running`, `succeeded`, and `failed`. A terminal `failed` status makes the
|
||||
notification fail; the adapter preserves the returned status details for the
|
||||
application to record.
|
||||
|
||||
Run and idempotency records are in-memory. Completed records expire according
|
||||
to Distributor's `server.http.retention`, and a Distributor restart removes
|
||||
retained status and idempotency state. Status polling decisions and persistence
|
||||
of notification artifacts are internal orchestration behavior; see the
|
||||
[Distributor adapter](../../internal/distributor-adapter.md) and
|
||||
[application orchestration](../../internal/app-orchestration.md).
|
||||
|
||||
## Compatibility Reference
|
||||
|
||||
The upstream canonical HTTP wire contract is
|
||||
`docs/integrations/http-upload.md` in the Distributor repository. This page
|
||||
documents only the portion exercised by Weatherreporter.
|
||||
36
docs/integrations/distributor/pkg-bundle.md
Normal file
36
docs/integrations/distributor/pkg-bundle.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Distributor Source Bundle Mapping
|
||||
|
||||
Weatherreporter uses the source-bundle format through Distributor's
|
||||
`pkg/upload.UploadFiles` helper. It does not create bundle directories or call
|
||||
`pkg/bundle` directly. The helper creates a temporary bundle, writes and
|
||||
validates `manifest.json`, archives it, and removes the temporary bundle when
|
||||
the upload call returns.
|
||||
|
||||
## File Mappings
|
||||
|
||||
Every mapping pairs a managed Markdown report source with one bundle-relative
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Bundle paths must be clean, relative, slash-separated paths. They cannot be
|
||||
empty or absolute, contain backslashes, empty segments, `.` or `..`, or use
|
||||
`manifest.json` or `.distributor.json` as a basename. The mapped source must be
|
||||
a regular file. File mapping order is preserved and affects the bundle digest.
|
||||
|
||||
The bundle manifest uses schema version `1`, carries the rendered bundle ID and
|
||||
creation time, and records each mapped file's path, SHA-256 digest, and size.
|
||||
Destination routing, publication, and Distributor-managed destination state are
|
||||
not source-bundle fields.
|
||||
|
||||
## Compatibility Reference
|
||||
|
||||
The upstream canonical file-format contract is
|
||||
`docs/integrations/source-bundle.md` in the Distributor repository. It defines
|
||||
the complete manifest and archive format; this page records only the mapping and
|
||||
path constraints Weatherreporter relies on.
|
||||
51
docs/integrations/distributor/pkg-upload.md
Normal file
51
docs/integrations/distributor/pkg-upload.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Distributor Upload Client Contract
|
||||
|
||||
Weatherreporter uses `gitea.maximumdirect.net/eric/distributor/pkg/upload` at
|
||||
the pinned module version `v0.5.0`. It constructs one client per notification
|
||||
attempt and calls `UploadFiles`, followed by `Status` for the accepted run.
|
||||
|
||||
## Client And Upload
|
||||
|
||||
The adapter constructs the client with the configured endpoint, bearer token,
|
||||
and an HTTP client whose timeout is the configured Distributor timeout. It
|
||||
passes no custom retry options, so the pinned client's defaults apply: three
|
||||
attempts, 100 ms base delay, and one-second maximum delay.
|
||||
|
||||
For each notification, Weatherreporter calls `UploadFiles` with:
|
||||
|
||||
- the rendered pipeline ID;
|
||||
- the rendered bundle ID as the source manifest ID;
|
||||
- the report or batch generation time as `Created`;
|
||||
- the managed-report-to-bundle-path mappings described in the
|
||||
[bundle mapping contract](pkg-bundle.md); and
|
||||
- a rendered idempotency key.
|
||||
|
||||
It leaves bundle validation enabled. `UploadFiles` creates the temporary source
|
||||
bundle and sends it as a gzip-compressed tar archive; Weatherreporter does not
|
||||
call `UploadBundle` or submit prebuilt bundle roots.
|
||||
|
||||
## Retry, Conflict, And Status
|
||||
|
||||
The pinned upload client retries only `503 Service Unavailable` and retryable
|
||||
network failures. It does not retry successful `202` responses or other HTTP
|
||||
errors. Because every Weatherreporter request supplies an idempotency key, a
|
||||
retry keeps the same upload identity.
|
||||
|
||||
The client decodes the accepted upload result (`run_id`, `status`) and the run
|
||||
status record. A `409` response is an upstream idempotency conflict; the
|
||||
adapter translates it to its own conflict error without exposing the token.
|
||||
|
||||
The adapter then calls `Status` for the accepted run. A terminal `failed`
|
||||
status is a notification failure. A status lookup failure or a timeout before a
|
||||
terminal status remains attached to the otherwise accepted upload as diagnostic
|
||||
status information. Polling cadence, final failure handling, redaction, and
|
||||
notification artifact persistence are internal behavior documented in the
|
||||
[Distributor adapter](../../internal/distributor-adapter.md) and
|
||||
[application orchestration](../../internal/app-orchestration.md).
|
||||
|
||||
## Compatibility Reference
|
||||
|
||||
The upstream package workflow is documented in
|
||||
`docs/consumers/pkg-upload.md` in the Distributor repository. Weatherreporter
|
||||
uses only the client construction, `UploadFiles`, retry/conflict behavior, and
|
||||
`Status` operations described here.
|
||||
@@ -1,97 +1,91 @@
|
||||
# Scriptorium Integration
|
||||
|
||||
This document describes the external Scriptorium CLI contract used by
|
||||
`weatherreporter`.
|
||||
`weatherreporter` invokes the Scriptorium executable as a subprocess to
|
||||
preflight prompt input and produce report artifacts. This is the limited CLI
|
||||
contract Weatherreporter uses, not general Scriptorium documentation.
|
||||
|
||||
## Purpose
|
||||
## Invocation
|
||||
|
||||
`weatherreporter` invokes Scriptorium as a subprocess to preflight prompt input
|
||||
and generate Markdown reports. This page documents the CLI surface the adapter
|
||||
uses, not the full Scriptorium product.
|
||||
The configured `scriptorium.binary` is the executable name or path. When it is
|
||||
empty, the adapter invokes `scriptorium`. Arguments are passed directly to the
|
||||
process, without a shell.
|
||||
|
||||
## Commands Used
|
||||
For every command, arguments occur in this order:
|
||||
|
||||
Render preflight:
|
||||
1. The subcommand.
|
||||
2. `--config <path>` when `scriptorium.config_path` is set.
|
||||
3. `--profile <profile>` when `scriptorium.profile` is set.
|
||||
4. The command-specific arguments below.
|
||||
5. Each configured `scriptorium.extra_args` item.
|
||||
|
||||
```bash
|
||||
scriptorium render \
|
||||
--prompt <prompt_id> \
|
||||
--input data_package=<path> \
|
||||
--format json
|
||||
The adapter uses these exact command shapes:
|
||||
|
||||
```text
|
||||
scriptorium render [--config <path>] [--profile <profile>] \
|
||||
--prompt <prompt_id> --input data_package=<data_package_path> --format json \
|
||||
[<extra_arg> ...]
|
||||
|
||||
scriptorium run [--config <path>] [--profile <profile>] \
|
||||
--prompt <prompt_id> --input data_package=<data_package_path> --out <output_path> \
|
||||
[<extra_arg> ...]
|
||||
```
|
||||
|
||||
Report generation:
|
||||
`render` is the preflight command. `run` writes either a Markdown report or a
|
||||
raw generated-text artifact to the supplied `--out` path. The structured
|
||||
generated-text use of `run` has the same argv as Markdown generation; it does
|
||||
not add `--format`, `--schema`, `--schema-path`, or `--json-schema` flags.
|
||||
Prompt configuration selected by `<prompt_id>` controls that output.
|
||||
|
||||
```bash
|
||||
scriptorium run \
|
||||
--prompt <prompt_id> \
|
||||
--input data_package=<path> \
|
||||
--out <artifact_path>
|
||||
```
|
||||
## Inputs and Outputs
|
||||
|
||||
`weatherreporter` always passes prompt input as
|
||||
`--input data_package=<path>`. The data package is structured JSON created by
|
||||
`internal/promptinput`.
|
||||
Weatherreporter always supplies exactly one prompt input:
|
||||
`--input data_package=<data_package_path>`. The path identifies the YAML data
|
||||
package produced by the [prompt-input builder](../internal/prompt-input.md).
|
||||
Its schema and the separate JSON module snapshots are internal artifacts, not
|
||||
part of this CLI contract.
|
||||
|
||||
## Configured Arguments
|
||||
The application supplies an already-managed output path to every `run` call.
|
||||
For direct reports it is the Markdown artifact path. For generated-text
|
||||
reports it is the raw JSON artifact path; subsequent validation and Markdown
|
||||
rendering are owned by [generated-text processing](../internal/generatedtext.md).
|
||||
|
||||
The adapter can prepend configured flags before prompt-specific arguments:
|
||||
`render` has no output-path argument. Its JSON-formatted stdout remains
|
||||
captured output: the adapter records it and does not parse it into a separate
|
||||
CLI result type. Likewise, the adapter records `run` output metadata without
|
||||
decoding the artifact written at `--out`.
|
||||
|
||||
- `--config <path>` from `scriptorium.config_path`
|
||||
- `--profile <profile>` from `scriptorium.profile`
|
||||
## Execution and Results
|
||||
|
||||
It appends `scriptorium.extra_args` after the built-in arguments. Extra
|
||||
arguments are passed directly as argv items.
|
||||
`scriptorium.timeout`, when greater than zero, creates a timeout for each
|
||||
subprocess invocation. Parent-context cancellation and that timeout stop the
|
||||
command through the process context.
|
||||
|
||||
`scriptorium.binary` selects the executable name or path. If unset inside the
|
||||
adapter, it falls back to `scriptorium`.
|
||||
Stdout and stderr are captured independently, each up to 1 MiB. Every returned
|
||||
result records the complete argv as `command`, the captured `stdout` and
|
||||
`stderr`, `exitCode`, and `stdoutTruncated` and `stderrTruncated` when a stream
|
||||
was capped. Results from both forms of `run` also record `outputPath`, the
|
||||
requested `--out` value.
|
||||
|
||||
## Execution Behavior
|
||||
|
||||
The adapter runs Scriptorium without shell interpolation. Arguments are passed
|
||||
through `exec.CommandContext`.
|
||||
|
||||
`scriptorium.timeout` limits each subprocess call when configured. Context
|
||||
cancellation or timeout returns an execution error.
|
||||
|
||||
Stdout and stderr are captured separately. Each stream is capped at 1 MiB and
|
||||
the result records whether truncation occurred.
|
||||
|
||||
## Results
|
||||
|
||||
Render results include:
|
||||
|
||||
- full argv recorded as `command`
|
||||
- stdout
|
||||
- stderr
|
||||
- exit code
|
||||
- truncation flags when applicable
|
||||
|
||||
Run results include the same fields plus the requested output path.
|
||||
|
||||
`weatherreporter` persists render preflight JSON when orchestration reaches the
|
||||
preflight save point. The final Markdown artifact is written by Scriptorium to
|
||||
the `--out` path.
|
||||
The [Scriptorium adapter](../internal/scriptorium-adapter.md) owns process
|
||||
execution and result capture. [Application orchestration](../internal/app-orchestration.md)
|
||||
owns when preflight output, report artifacts, and generated-text artifacts are
|
||||
persisted.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
The adapter validates required request fields before starting Scriptorium:
|
||||
Before starting Scriptorium, the adapter requires a prompt ID and data-package
|
||||
path for every command, plus an output path for `run`. Missing fields fail
|
||||
without executing a subprocess.
|
||||
|
||||
- prompt ID
|
||||
- data package path
|
||||
- output path for `run`
|
||||
A nonzero process exit returns the captured result and an error that includes
|
||||
the exit code and stderr. An output file written before such an exit does not
|
||||
make the request successful. Failures to start the command, context
|
||||
cancellation, and timeout return an error rather than a successful result.
|
||||
|
||||
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.
|
||||
## Operational Notes
|
||||
|
||||
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.
|
||||
- Extra arguments are argv items; they are not shell-interpreted.
|
||||
- Prompt input, generated artifacts, stdout, and stderr can contain
|
||||
operationally sensitive weather data.
|
||||
- Provide API keys through the Scriptorium environment or its configuration,
|
||||
not through Weatherreporter CLI arguments.
|
||||
|
||||
@@ -1,26 +1,51 @@
|
||||
# Weather API Integration
|
||||
|
||||
This document describes the external Weather API contract used by
|
||||
`weatherreporter`.
|
||||
Weatherreporter fetches normalized weather inputs from a configured Weather API
|
||||
base URL. This guide defines the HTTP contract the service must satisfy; it is
|
||||
not a general Weather API reference. Configuration values are defined in the
|
||||
[configuration reference](../config.md). Normalization and collection behavior
|
||||
are documented in [Weather data internals](../internal/weather-data.md) and
|
||||
[Collection internals](../internal/collect.md).
|
||||
|
||||
## Purpose
|
||||
## Base URL And Requests
|
||||
|
||||
`weatherreporter` uses a configured Weather API base URL to fetch normalized
|
||||
weather source data and assemble a `forecast.Bundle`. This is an integration
|
||||
contract for the project adapter, not a complete public API reference for the
|
||||
upstream service.
|
||||
`weather_api.base_url` must be an absolute URL. Weatherreporter joins each
|
||||
endpoint path to the configured base URL path, so a service hosted under a path
|
||||
prefix must keep that prefix available. Requests use `GET` and carry the
|
||||
configured timeout on every HTTP attempt.
|
||||
|
||||
## Base URL
|
||||
Every request sends `format` and, except where noted below, `units`. The
|
||||
configured format must be `json`.
|
||||
|
||||
`weather_api.base_url` must be an absolute URL. Adapter requests join this base
|
||||
URL with the endpoint paths listed below. Generation and explicit bundle fetches
|
||||
fail before any HTTP request when the base URL is empty or not absolute.
|
||||
Before retrieving sources, Weatherreporter warms up
|
||||
`/conditions/current` with the same `format`, `units`, and `precision` query
|
||||
parameters used for current conditions. The warmup only requires a readable
|
||||
2xx response; its body is not decoded. Failure after its internal retry budget
|
||||
stops the fetch before source requests begin.
|
||||
|
||||
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
|
||||
|
||||
Every response used by the adapter must be JSON with a top-level `data` field:
|
||||
Each endpoint response must be JSON with a top-level `data` member:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -28,105 +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
|
||||
sources follow the configured missing-source policy. Missing hourly forecast
|
||||
data fails bundle fetching because hourly periods are required for report
|
||||
generation.
|
||||
An absent `data` member is treated as a missing source. For ordinary sources,
|
||||
`data: null` is also missing. The active-alert exception is listed above: its
|
||||
explicit `null` payload represents an empty alert result.
|
||||
|
||||
`/alerts/active` is the exception: a successful response with `data: null`
|
||||
means the endpoint was checked and there are no current active alerts. The
|
||||
adapter records a non-missing alerts source and an empty alert run.
|
||||
Hourly forecast data must be present and contain at least one `period`; a
|
||||
missing, malformed, or empty hourly product fails collection. The remaining
|
||||
sources follow the configured missing-source policy. Under `error`, collection
|
||||
fails; under `warn`, the source is omitted and an inspectable warning is
|
||||
recorded; under `none`, the source is omitted without a warning. A per-source
|
||||
policy overrides the default. See [Configuration](../config.md) for policy
|
||||
settings and [Weather data internals](../internal/weather-data.md) for recorded
|
||||
source metadata.
|
||||
|
||||
Malformed JSON envelopes, non-2xx statuses, and response read failures include
|
||||
endpoint context in returned errors. Decode errors include source context when
|
||||
they fail the fetch; optional malformed sources follow the missing-source policy.
|
||||
Malformed top-level JSON envelopes and HTTP failures are direct request errors.
|
||||
Malformed `data` for an optional source follows its missing-source policy.
|
||||
|
||||
## Query Parameters
|
||||
## Payload Fields Used
|
||||
|
||||
The adapter sends these query parameters:
|
||||
Weatherreporter decodes only the fields below; additional upstream fields are
|
||||
ignored. Timestamps must be JSON values accepted by Go's `time.Time` decoder.
|
||||
|
||||
- `format`: from `weather_api.format`; the implemented configuration requires
|
||||
`json`
|
||||
- `units`: from `weather_api.units`
|
||||
- `precision`: from `weather_api.precision` on observations, current
|
||||
conditions, hourly forecast, and narrative forecast requests
|
||||
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast, and
|
||||
discussion requests
|
||||
### Observations And Current Conditions
|
||||
|
||||
Alerts do not receive `precision` or `tz`.
|
||||
`/observations` uses `stationId`, `stationName`, `timestamp`, `conditionCode`,
|
||||
`isDay`, `textDescription`, `temperatureC`, `temperatureF`, `dewpointC`,
|
||||
`dewpointF`, `windSpeedKmh`, `windSpeedMph`, `windGustKmh`, `windGustMph`,
|
||||
`windDirectionDegrees`, `barometricPressurePa`, `barometricPressureInHg`,
|
||||
`visibilityMeters`, `visibilityMiles`, `relativeHumidityPercent`,
|
||||
`apparentTemperatureC`, `apparentTemperatureF`, and `presentWeather`.
|
||||
|
||||
## Endpoints Used
|
||||
`/conditions/current` uses `conditionText`, `isDay`,
|
||||
`relativeHumidityPercent`, `windDirectionDegrees`, `temperatureC`,
|
||||
`temperatureF`, `apparentTemperatureC`, `apparentTemperatureF`, `dewpointC`,
|
||||
`dewpointF`, `windSpeedKmh`, and `windSpeedMph`.
|
||||
|
||||
The adapter fetches these endpoints once per bundle:
|
||||
### Hourly And Narrative Forecasts
|
||||
|
||||
- `/observations`
|
||||
- `/conditions/current`
|
||||
- `/forecast/hourly`
|
||||
- `/forecast/narrative`
|
||||
- `/alerts/active`
|
||||
- `/discussion`
|
||||
Both forecast endpoints use run-level `locationId`, `locationName`, `issuedAt`,
|
||||
`updatedAt`, `product`, `latitude`, `longitude`, `elevationMeters`,
|
||||
`elevationFeet`, and `periods`.
|
||||
|
||||
`weatherreporter` does not call day-slice forecast endpoints or discussion
|
||||
subsection endpoints. Report-period selection and daypart summarization happen
|
||||
inside Go after the full hourly and narrative products are fetched.
|
||||
Each `periods` item uses `startTime`, `endTime`, `name`, `isDay`,
|
||||
`conditionCode`, `textDescription`, `temperatureC`, `temperatureF`,
|
||||
`temperatureCMin`, `temperatureFMin`, `temperatureCMax`, `temperatureFMax`,
|
||||
`dewpointC`, `dewpointF`, `windSpeedKmh`, `windSpeedMph`, `windGustKmh`,
|
||||
`windGustMph`, `windDirectionDegrees`, `barometricPressurePa`,
|
||||
`barometricPressureInHg`, `visibilityMeters`, `visibilityMiles`,
|
||||
`apparentTemperatureC`, `apparentTemperatureF`, `cloudCoverPercent`,
|
||||
`probabilityOfPrecipitationPercent`, `precipitationAmountMm`,
|
||||
`precipitationAmountIn`, `snowfallDepthMM`, `snowfallDepthIn`, `uvIndex`, and
|
||||
`relativeHumidityPercent`.
|
||||
|
||||
## Required And Optional Sources
|
||||
### Alerts, Discussion, And Weather Story
|
||||
|
||||
Hourly forecast is required:
|
||||
`/alerts/active` uses the `asOf` timestamp and keeps each item in `alerts` as
|
||||
an alert payload. Weatherreporter does not require a separate alert-item schema
|
||||
at this integration boundary.
|
||||
|
||||
- `data: null` for `/forecast/hourly` fails the fetch.
|
||||
- an hourly forecast with no `periods` fails the fetch.
|
||||
- malformed hourly data fails the fetch.
|
||||
`/discussion` uses `officeId`, `officeName`, `product`, `issuedAt`,
|
||||
`updatedAt`, `keyMessages`, and the `shortTerm` and `longTerm` sections. Each
|
||||
section uses `qualifier`, `text`, and `issuedAt`.
|
||||
|
||||
Other fetched sources are optional and follow `missing_source.default` or a
|
||||
source-specific `missing_source.sources` policy:
|
||||
`/weatherstories/latest` uses `officeId`, `startTime`, `endTime`, `updatedAt`,
|
||||
`title`, `description`, `altText`, `priority`, `order`, and `downloadUrl`.
|
||||
|
||||
- `observations` for `/observations`
|
||||
- `current` for `/conditions/current`
|
||||
- `narrative` for `/forecast/narrative`
|
||||
- `alerts` for `/alerts/active`
|
||||
- `discussion` for `/discussion`
|
||||
### SPC Convective Outlooks
|
||||
|
||||
The adapter also creates missing stub source records for `daily` and
|
||||
`weather_story` because those source slots exist in the internal bundle but are
|
||||
not fetched from the Weather API.
|
||||
`/outlooks/convective` uses run-level `locationId`, `locationName`, `asOf`,
|
||||
`issuedAt`, `updatedAt`, `product`, `outlooks`, and `discussions`.
|
||||
|
||||
Policy behavior:
|
||||
Each outlook uses `id`, `provider`, `product`, `day`, `outlookType`, `label`,
|
||||
`labelText`, `forecaster`, `severityRank`, `validFrom`, `validTo`, `issuedAt`,
|
||||
`expiresAt`, `sourceUrl`, `imageUrl`, `containsLocation`, and GeoJSON
|
||||
`geometry`. Each discussion uses `day`, `headline`, `summary`, `discussion`,
|
||||
and `updatedAt`.
|
||||
|
||||
- `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
|
||||
## Timeouts, Retries, And Failures
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Source Identity
|
||||
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.
|
||||
|
||||
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 forecast bundle
|
||||
types in `internal/forecast/bundle.go`, including:
|
||||
|
||||
- observation timestamps and observation values
|
||||
- current condition values
|
||||
- forecast run metadata and `periods`
|
||||
- active alert run data
|
||||
- discussion metadata, key messages, and short/long-term section text
|
||||
|
||||
The adapter intentionally keeps upstream transport and envelope details inside
|
||||
`internal/adapters/weatherapi`; downstream packages consume the normalized
|
||||
bundle.
|
||||
Retry counts and delays are adapter behavior rather than Weather API request
|
||||
parameters. Do not depend on a particular attempt count when implementing the
|
||||
service.
|
||||
|
||||
@@ -1,123 +1,110 @@
|
||||
# App Orchestration Internals
|
||||
# Application Orchestration Internals
|
||||
|
||||
This document describes the implemented workflow coordinator in `internal/app`.
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/app` coordinates the top-level use cases after CLI parsing and config
|
||||
loading are complete. It resolves report definitions, fetches weather data,
|
||||
builds briefing and prompt-input artifacts, invokes Scriptorium through the
|
||||
adapter boundary, persists managed state, runs batches, and reads existing
|
||||
artifacts for inspection.
|
||||
`internal/app` composes top-level generation, batch, collection-save, and
|
||||
inspection workflows after CLI parsing and configuration loading. It owns
|
||||
workflow ordering, request composition, partial-result handling, and the
|
||||
application-facing interfaces used for tests.
|
||||
|
||||
## Inputs And Outputs
|
||||
|
||||
Inputs:
|
||||
The package accepts generate, resolved-report, batch, explicit-collection, and
|
||||
inspection requests. Generation and batch requests may supply collector,
|
||||
renderer, store, and notifier implementations for tests; production defaults
|
||||
use the focused packages.
|
||||
|
||||
- `GenerateRequest` for one report command
|
||||
- `BatchRequest` for morning or evening batch commands
|
||||
- `FetchBundleRequest` for explicit bundle fetch and save workflows
|
||||
- `BriefingRequest` and `ReportRequest` for package-level orchestration tests
|
||||
and internal composition
|
||||
- resolved report definitions from `internal/report`
|
||||
- forecast bundles from `internal/adapters/weatherapi`
|
||||
- prior snapshots loaded from `internal/state`
|
||||
- optional renderer and state-store fakes for tests
|
||||
A report result contains the module snapshot, prompt package, available
|
||||
Scriptorium results, generated-text artifacts when used, report and metadata
|
||||
paths, prior snapshot, Recent Changes, and notification information. A batch
|
||||
result contains aggregate counts, per-report outcomes, and an optional batch
|
||||
notification. Inspection returns persisted values only.
|
||||
|
||||
Outputs:
|
||||
Exact public command syntax, configuration fields, workspace layout, external
|
||||
protocols, and report definitions belong in [the CLI reference](../cli.md),
|
||||
[the configuration reference](../config.md), [operations](../operations.md),
|
||||
and their focused integration and internal documents.
|
||||
|
||||
- generated report results with briefing, data package, preflight, report,
|
||||
metadata, prior snapshot, Recent Changes, and Scriptorium result details
|
||||
- batch summaries with per-report status, artifact paths, and error text
|
||||
- saved Weather API bundle JSON for fetch workflows
|
||||
- inspection JSON values for reports, metadata, briefings, data packages, prior
|
||||
snapshots, and source provenance
|
||||
## Single-Report Workflow
|
||||
|
||||
## Boundaries
|
||||
`GenerateDetailed` first collects weather data, then resolves the requested
|
||||
report using the configured registry and current time, and finally calls
|
||||
`GenerateReport` with that explicit collection. It returns no result when
|
||||
collection or resolution fails.
|
||||
|
||||
`internal/app` owns workflow order and request composition. It does not parse
|
||||
CLI flags, load YAML files directly, implement HTTP transport, derive forecast
|
||||
facts, define report periods, compare rendered Markdown, or construct
|
||||
Scriptorium argv.
|
||||
`GenerateReport` requires a non-nil normalized bundle and then performs this
|
||||
ordered work:
|
||||
|
||||
Report selection and report identity policy come from `internal/report`.
|
||||
Weather API transport stays in `internal/adapters/weatherapi`. Scriptorium
|
||||
subprocess behavior stays in `internal/adapters/scriptorium`. Filesystem layout
|
||||
and persisted metadata stay in `internal/state`.
|
||||
1. Select a state store, determine artifact destinations, and locate a prior
|
||||
compatible snapshot.
|
||||
2. Build report facts and deterministic module snapshots, then save the module
|
||||
snapshot and calculate Recent Changes.
|
||||
3. Build and save the prompt data package, run Scriptorium render preflight,
|
||||
save any preflight result, and save initial metadata.
|
||||
4. Produce managed Markdown according to the report generation mode.
|
||||
5. Optionally make an output copy, save final metadata, optionally notify
|
||||
Distributor from the managed report path, and save metadata again when a
|
||||
notification path is produced.
|
||||
|
||||
## Config Fields Used
|
||||
Direct-Markdown reports prepare the managed report and invoke the Scriptorium
|
||||
run boundary. Generated-text-template reports look up their catalog definition,
|
||||
run structured Scriptorium output to the raw artifact, preserve any structured
|
||||
run result, validate and save generated text, build and save a render context,
|
||||
then render the embedded Markdown template. Schema, template, and subprocess
|
||||
details remain in their [generated-text](generatedtext.md),
|
||||
[report-template](reporttemplate.md), and [Scriptorium adapter](scriptorium-adapter.md)
|
||||
owners.
|
||||
|
||||
- `weather_api.*` for Weather API client construction and briefing metadata
|
||||
- `scriptorium.*` for renderer construction
|
||||
- `workspace.*` for filesystem state
|
||||
- `dayparts` for daily and outlook summarization
|
||||
- `recent_change.*` for structured Recent Changes thresholds
|
||||
If preflight returns a result with an error, the result and initial metadata are
|
||||
saved before the error returns. If report generation fails after a managed path
|
||||
is prepared, metadata still records that path; output copies and notification
|
||||
are skipped. Generated-text failures preserve the latest artifact reached
|
||||
before failure when it was saved.
|
||||
|
||||
Output copy flags are command request fields. They are not configuration
|
||||
defaults.
|
||||
## Batch And Inspection Workflows
|
||||
|
||||
## Generation Workflow
|
||||
`RunBatchDetailed` collects once, asks the report registry to plan the batch
|
||||
from that collection, and invokes `GenerateReport` independently for every
|
||||
planned report using the same collection and state store. Per-report
|
||||
notification is suppressed. A failed report is recorded and does not prevent
|
||||
later planned reports from running.
|
||||
|
||||
Single-report generation follows this order:
|
||||
After report generation, the batch notifier is considered once. It is omitted
|
||||
when Distributor or batch notification is disabled, skipped when any report
|
||||
failed, and otherwise receives one multi-file request. A batch notification
|
||||
failure increments the aggregate failure count but does not rewrite successful
|
||||
report items. Notification identities, path mappings, polling, and redaction
|
||||
are owned by the [Distributor adapter](distributor-adapter.md).
|
||||
|
||||
1. Resolve the command report to a `report.Resolved` value.
|
||||
2. Create or use a filesystem store.
|
||||
3. Locate any prior compatible snapshot through `internal/state`.
|
||||
4. Fetch a Weather API bundle.
|
||||
5. Build a report-specific briefing package.
|
||||
6. Save the briefing snapshot.
|
||||
7. Compute Recent Changes from structured prior and current briefings.
|
||||
8. Build and save the Scriptorium `data_package`.
|
||||
9. Run Scriptorium render preflight.
|
||||
10. Save preflight JSON when a render result is available.
|
||||
11. Save metadata for inspection.
|
||||
12. Run Scriptorium report generation to the managed report path.
|
||||
13. Copy the managed report to the requested `--out` path when provided.
|
||||
14. Save metadata with the managed report path.
|
||||
Inspection methods create a state store and load existing report records,
|
||||
metadata, module snapshots, prompt packages, prior snapshots, or source
|
||||
provenance. They neither collect data nor invoke Scriptorium or Distributor.
|
||||
|
||||
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.
|
||||
## Boundaries And Failure Propagation
|
||||
|
||||
## Batch Workflow
|
||||
The app layer does not parse flags, load configuration files, implement Weather
|
||||
API transport, construct Scriptorium argv, or define report registry policy. It
|
||||
coordinates the relevant collaborators and preserves their error context.
|
||||
|
||||
`run morning` resolves Daily Today, 3-Day Outlook, and Weekend Outlook except
|
||||
on Sunday. `run evening` resolves Daily Tomorrow. Batch output copy names come
|
||||
from report definitions. Batch generation continues independent reports after a
|
||||
failure, records each result, writes compact status lines to stderr, emits a
|
||||
JSON summary to stdout, and returns an aggregate error when any report failed.
|
||||
- Collection failure stops a single report or batch before resolution or
|
||||
planning completes.
|
||||
- State, fact, module, prompt-input, or preflight failures stop that report
|
||||
before report generation.
|
||||
- A terminal Distributor failure is returned with the saved notification
|
||||
information when available.
|
||||
- Batch failures are represented per report and through aggregate batch status.
|
||||
- Persisted artifact paths are carried in results so callers can inspect work
|
||||
completed before a later failure.
|
||||
|
||||
## Inspection Workflow
|
||||
## Tests And Invariants
|
||||
|
||||
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.
|
||||
Focused tests are in `internal/app/app_test.go` and
|
||||
`internal/app/batch_plan_test.go`, with collection coverage in
|
||||
`internal/collect/collect_test.go`.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- Resolve errors stop the requested workflow before fetching weather data.
|
||||
- Weather API and briefing errors stop that report before Scriptorium runs.
|
||||
- Prompt input validation fails before render preflight.
|
||||
- Render and run errors preserve Scriptorium stderr and exit-code context.
|
||||
- Metadata and artifact path errors include filesystem context.
|
||||
- Batch failures are recorded per report and surfaced through an aggregate
|
||||
batch error.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/app/app_test.go`
|
||||
- `internal/cli/root_test.go`
|
||||
- `internal/state/filesystem_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Report behavior is resolved through `internal/report`.
|
||||
- Generated reports use the same app request and result types regardless of
|
||||
report ID.
|
||||
- Render preflight precedes Scriptorium report generation.
|
||||
- Recent Changes are computed from structured briefing snapshots.
|
||||
- Metadata links artifacts produced for a run.
|
||||
- Production workflows collect through `internal/collect`.
|
||||
- A report uses one explicit normalized collection throughout its generation.
|
||||
- Render preflight precedes report generation.
|
||||
- Recent Changes compare structured module snapshots.
|
||||
- Generated-text reports render from a validated typed context, never directly
|
||||
from a raw prompt package.
|
||||
- Only managed Markdown reports are notification sources; output copies are
|
||||
never uploaded.
|
||||
|
||||
@@ -1,85 +1,69 @@
|
||||
# Briefing Internals
|
||||
# Module Builder Internals
|
||||
|
||||
This document describes the implemented briefing package boundary.
|
||||
`internal/briefing` builds typed module outputs from resolved report context,
|
||||
collected facts, and derived facts. It owns the module registry, including
|
||||
module support, fact requirements, option types, missing-data policy, builders,
|
||||
and prompt-export hooks. It does not collect data, derive periods, write a
|
||||
snapshot, construct YAML, invoke Scriptorium, or render a report.
|
||||
|
||||
## Purpose
|
||||
## Registry and construction
|
||||
|
||||
`internal/briefing` builds structured report-specific briefing packages from
|
||||
resolved report metadata, forecast bundles, and derived forecast summaries.
|
||||
Briefings are curated inputs for prompt data packages, not rendered report
|
||||
prose.
|
||||
Every `ModuleDefinition` declares an ID, stanza name, default option value,
|
||||
required collected and derived facts, supported report IDs, missing-data
|
||||
behavior, duplicate policy, builder, and optional prompt exporter.
|
||||
|
||||
## Inputs And Outputs
|
||||
`BuildModule` first verifies the requested module, report compatibility, and
|
||||
option shape. It then applies the declared missing-data behavior:
|
||||
|
||||
Inputs:
|
||||
- `omit` returns no output for unavailable optional facts;
|
||||
- `error` returns the missing fact requirements; and
|
||||
- `empty` allows the builder to emit an explicit checked-empty value.
|
||||
|
||||
- resolved report definition, generation time, timezone, and valid period
|
||||
- forecast bundle with source provenance and warnings
|
||||
- derived daily or period summaries where required
|
||||
- configured units, timezone, and descriptive location context
|
||||
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.
|
||||
|
||||
Outputs:
|
||||
## Built value families
|
||||
|
||||
- `briefing.Package` with common metadata and one report-specific content
|
||||
object for Daily, 3-Day, Weekend, or Storm Report
|
||||
- optional `currentConditions` prompt context from normalized
|
||||
`/conditions/current` data when available
|
||||
- optional JSON file written by `briefing.Save`
|
||||
Source-oriented builders shape report metadata, current conditions, narrative
|
||||
and hourly forecasts, alert digest, SPC outlooks and discussion, area forecast
|
||||
discussion, and weather story. Derived builders shape daily and daypart
|
||||
summaries, precipitation timing, outdoor windows, and the report-specific
|
||||
Daily, Today, and Tomorrow planning values.
|
||||
|
||||
## Boundaries
|
||||
The module registry preserves rich values for templates and snapshots while
|
||||
curating prompt exports where needed. In particular, source warnings are a
|
||||
metadata summary, checked-empty alerts and SPC outlooks remain distinct from
|
||||
missing sources, and prompt-safe SPC values omit geometry and other
|
||||
template-only or source details. The complete module composition is in
|
||||
[module internals](module.md); fact derivation is in [fact contracts](facts.md).
|
||||
|
||||
- This package selects and shapes weather facts for prompts.
|
||||
- It does not fetch weather data, compare prior snapshots, build
|
||||
`data_package` files, invoke Scriptorium, or write workflow metadata.
|
||||
`area_forecast_discussion` accepts an optional typed section filter. Planning
|
||||
modules are report-specific: `daily_planning` supports Daily,
|
||||
`today_planning` supports Today, and `tomorrow_planning` supports Tomorrow.
|
||||
|
||||
## Config Fields Used
|
||||
## Missing data and boundaries
|
||||
|
||||
The package receives configured units and timezone from the app layer. Daypart
|
||||
configuration is consumed by `internal/forecast` before briefing builders run.
|
||||
Configured `location` values are prompt context only; Weather API
|
||||
`sourceLocationId` and `sourceLocation` remain source provenance.
|
||||
Current conditions are copied from the normalized `/conditions/current` bundle
|
||||
source only; observation station and timestamp fields remain provenance.
|
||||
Optional current conditions, narrative products, discussions, and weather
|
||||
stories may be omitted. Required derived modules fail when their declared facts
|
||||
are unavailable. Empty alert and outlook runs can still produce checked-empty
|
||||
modules. SPC discussion is omitted unless a retained categorical outlook meets
|
||||
the package's severity criterion and matching discussion text exists.
|
||||
|
||||
## External Adapters Used
|
||||
Effective units, timezone, and location context arrive in `ModuleContext` from
|
||||
configuration and resolved report metadata. Field defaults are owned by
|
||||
[configuration](../config.md), and prompt-package layout is owned by
|
||||
[prompt input](prompt-input.md).
|
||||
|
||||
None directly.
|
||||
## Verification and invariants
|
||||
|
||||
## State Or Manifest Behavior
|
||||
Focused tests cover source and derived values, registry validation, option
|
||||
handling, prompt exporters, support rules, and missing-data behavior:
|
||||
|
||||
`briefing.Save` writes briefing JSON atomically. Managed workspace placement is
|
||||
owned by `internal/state`.
|
||||
```sh
|
||||
go test ./internal/briefing
|
||||
```
|
||||
|
||||
## Skip And Resume Behavior
|
||||
|
||||
None. Builders either return a complete briefing package or an error.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- Daily briefing construction requires a Daily report definition and derived
|
||||
daily summary.
|
||||
- 3-Day briefing construction requires a 3-Day report definition and at least
|
||||
one derived summary.
|
||||
- Weekend briefing construction requires a Weekend report definition and at
|
||||
least one derived summary.
|
||||
- Storm briefing construction requires a Storm Report definition and forecast
|
||||
bundle.
|
||||
- Save failures include path and operation context.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/briefing/daily_test.go`
|
||||
- `internal/briefing/three_day_test.go`
|
||||
- `internal/briefing/weekend_test.go`
|
||||
- `internal/briefing/storm_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Briefings contain structured weather facts and source context.
|
||||
- Common metadata includes RunID, report ID, prompt ID, valid period, source
|
||||
provenance, source hashes, source warnings, and configured prompt location.
|
||||
- LLM prompt input packaging and Scriptorium execution remain outside this
|
||||
boundary.
|
||||
Builders emit structured facts, never report prose. The app collects their
|
||||
outputs into a module snapshot, and state persists that snapshot.
|
||||
|
||||
@@ -1,74 +1,61 @@
|
||||
# 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 briefing packages and emits
|
||||
compact change records for prompt input data packages.
|
||||
Each comparator receives a prior snapshot, a current snapshot, and
|
||||
`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 briefing package
|
||||
- current briefing package
|
||||
- comparison thresholds from configuration
|
||||
| Comparator | Required snapshot data | Compared values |
|
||||
| --- | --- | --- |
|
||||
| `CompareDaily` | `derived_daily_summary`, `derived_daypart_summaries` | Low and high temperature, daily precipitation probability and timing, peak gust, alerts, and aggregate indicators |
|
||||
| `CompareThreeDay` | `derived_daypart_summaries` | Per-day temperatures, precipitation probability and timing, peak gust, indicators, and added or removed outlook days |
|
||||
| `CompareWeekend` | `derived_daypart_summaries` | The three-day values with weekend-prefixed change types |
|
||||
|
||||
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. The multi-day comparators build their day map from daypart
|
||||
summaries. A missing or added day becomes a dedicated change rather than a
|
||||
comparison against invented data.
|
||||
|
||||
- ordered `changes.Change` items with type, message, previous value, and current
|
||||
value where useful
|
||||
The application selects a comparator only after state lookup establishes a
|
||||
compatible prior snapshot. Daily, Today, and Tomorrow use the daily comparator;
|
||||
Three-day and Weekend use their named comparators. Other report types, such as
|
||||
Storm, produce no Recent Changes list.
|
||||
|
||||
## Boundaries
|
||||
## Missing data and failures
|
||||
|
||||
- This package compares structured briefing data only.
|
||||
- It does not read filesystem state, find prior snapshots, render Markdown,
|
||||
invoke Scriptorium, or compare generated report text.
|
||||
Required stanzas that are absent or cannot be decoded return an error with the
|
||||
snapshot and stanza context. Optional stanzas may be absent. A snapshot with no
|
||||
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`
|
||||
- `recent_change.precip_probability_points`
|
||||
- `recent_change.wind_gust_miles_per_hour`
|
||||
- `recent_change.precip_timing_shift_minutes`
|
||||
Focused tests cover the daily, three-day, and weekend strategies, threshold
|
||||
boundaries, indicator and alert changes, and missing required stanzas:
|
||||
|
||||
## External Adapters Used
|
||||
```sh
|
||||
go test ./internal/changes
|
||||
```
|
||||
|
||||
None.
|
||||
|
||||
## State Or Manifest Behavior
|
||||
|
||||
None directly. The app loads prior briefing snapshots through `internal/state`
|
||||
before calling comparison functions.
|
||||
|
||||
## Skip And Resume Behavior
|
||||
|
||||
No resume behavior. When the app has no prior comparable snapshot, it sends an
|
||||
empty Recent Changes list without calling a comparison function.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- Daily comparison requires both inputs to contain Daily briefing content.
|
||||
- 3-Day comparison requires both inputs to contain 3-Day briefing content.
|
||||
- Weekend comparison requires both inputs to contain Weekend briefing content.
|
||||
- Storm Report currently has no comparison implementation, so the app leaves
|
||||
Recent Changes empty for Storm reports.
|
||||
|
||||
## 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.
|
||||
Recent Changes always compare structured snapshot values, never report prose.
|
||||
|
||||
65
docs/internal/cli.md
Normal file
65
docs/internal/cli.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# CLI Internals
|
||||
|
||||
`internal/cli` turns process arguments into application requests and translates
|
||||
application results into terminal output. The user-facing command, flag, and
|
||||
output contract belongs in the [CLI reference](../cli.md).
|
||||
|
||||
## Responsibilities
|
||||
|
||||
`Runner.Run` dispatches the top-level action or inspection request. For actions,
|
||||
the package parses command-specific and common flags, loads configuration with
|
||||
CLI overrides, obtains the current time, and constructs either an
|
||||
`app.GenerateRequest` or an `app.BatchRequest`. It delegates generation and
|
||||
batch execution to `internal/app`.
|
||||
|
||||
For inspection, it loads configuration, builds the appropriate app inspection
|
||||
request, and writes the returned value. Inspection is read-only; the inspected
|
||||
artifact types and user invocation remain owned by the [CLI reference](../cli.md)
|
||||
and [operations guide](../operations.md).
|
||||
|
||||
## Result Translation
|
||||
|
||||
Action results become CLI-safe JSON summaries in `result.go`. Generate summaries
|
||||
carry report identity, status, relevant artifact paths, and notification
|
||||
summary data. Batch summaries carry aggregate counts, per-report outcomes, and
|
||||
the optional batch notification result. The translation deliberately excludes
|
||||
full module snapshots, prompt packages, raw generated text, Scriptorium output,
|
||||
and complete Distributor payloads.
|
||||
|
||||
When an action returns both a result and an error, the CLI writes the failed
|
||||
summary before returning that error. Parse, configuration-load, and other
|
||||
failures that produce no application result return without a summary.
|
||||
|
||||
`writeActionResult` writes action status information to stderr first, then JSON
|
||||
to stdout. Batch execution supplies the status writer; single-report generation
|
||||
does not emit routine stderr output. Quiet action requests suppress both normal
|
||||
streams but still return errors. Inspection writes its JSON value to stdout and
|
||||
does not accept quiet mode because stdout is the inspection result.
|
||||
|
||||
## Boundaries
|
||||
|
||||
The package owns argument parsing, request adaptation, help text, and terminal
|
||||
presentation. It does not implement report selection, collection, state
|
||||
persistence, external transport, subprocess execution, or notification policy.
|
||||
Those concerns remain in [application orchestration](app-orchestration.md) and
|
||||
their focused owners.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- Invalid command names, flags, dates, and configuration fail before an app
|
||||
request is executed.
|
||||
- Application errors retain their application context; output helpers do not
|
||||
hide or replace them.
|
||||
- JSON-encoding errors are returned directly.
|
||||
- A failed batch summary causes the CLI to return an aggregate batch error even
|
||||
when the detailed batch call has already returned its result.
|
||||
|
||||
## Tests And Invariants
|
||||
|
||||
Focused tests are in `internal/cli/root_test.go`, `internal/cli/output_test.go`,
|
||||
and `internal/cli/result_test.go`.
|
||||
|
||||
- CLI summaries are stable, bounded views of app results.
|
||||
- Routine batch status lines precede the batch JSON summary.
|
||||
- A quiet action produces no successful or failure summary output.
|
||||
- Inspection never invokes action-output helpers.
|
||||
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 Scriptorium, or
|
||||
notify Distributor.
|
||||
|
||||
## Application Composition
|
||||
|
||||
`internal/app` owns the narrow `Collector` interface used by workflow tests;
|
||||
the production implementation delegates to `collect.Run`. Generation, batch
|
||||
execution, and explicit bundle fetching all use this boundary. Application
|
||||
orchestration rejects a nil collector result or a nil bundle before report work
|
||||
can continue.
|
||||
|
||||
Single-report generation and a batch each collect once. A batch passes the same
|
||||
normalized collection to planning and to every report it generates. Collection
|
||||
failure prevents later workflow work for that request.
|
||||
|
||||
## Boundaries And Invariants
|
||||
|
||||
Collection owns adapter creation and retrieval of one normalized bundle. It
|
||||
must not make report, period, batch, prompt, module, filesystem, or notification
|
||||
decisions.
|
||||
|
||||
- App-facing Weather API collection always passes through this package.
|
||||
- The returned value is normalized source data, not facts or prompt input.
|
||||
- Context cancellation is passed to the Weather API adapter.
|
||||
- Errors retain whether setup or fetching failed.
|
||||
|
||||
Focused tests are in `internal/collect/collect_test.go`; orchestration use is
|
||||
also covered by `internal/app/app_test.go`.
|
||||
63
docs/internal/distributor-adapter.md
Normal file
63
docs/internal/distributor-adapter.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Distributor Adapter Internals
|
||||
|
||||
`internal/adapters/distributor` translates a local delivery request into the
|
||||
Distributor Go client's upload and status calls, then returns a local delivery
|
||||
result. The external API, authentication, and idempotency contract is owned by
|
||||
the [Distributor API guide](../integrations/distributor/api.md) and
|
||||
[Distributor bundle guide](../integrations/distributor/pkg-bundle.md).
|
||||
|
||||
## Client construction
|
||||
|
||||
`Client` holds the endpoint, the name of the environment variable containing
|
||||
the token, an optional timeout, and an injectable upstream-client factory.
|
||||
`New` validates its configuration before creating the adapter. For each upload,
|
||||
the adapter reads the token from the configured environment variable and builds
|
||||
the upstream client with that endpoint, token, and an HTTP client whose timeout
|
||||
matches the local positive timeout.
|
||||
|
||||
The upstream client is an implementation dependency, not a source of
|
||||
application configuration: retry ownership, pipeline selection, path
|
||||
templates, and report rendering are defined by
|
||||
[configuration](../config.md) and [application orchestration](app-orchestration.md).
|
||||
|
||||
## Upload translation
|
||||
|
||||
Before calling the dependency, `Upload` validates the endpoint and token
|
||||
configuration plus the local pipeline ID, bundle ID, idempotency key, and every
|
||||
file's source and bundle paths. It maps the request as follows:
|
||||
|
||||
| Local request | Distributor client value |
|
||||
| --- | --- |
|
||||
| Pipeline ID | Upload pipeline identifier |
|
||||
| Bundle ID | Bundle identifier |
|
||||
| Idempotency key | Upload idempotency key |
|
||||
| File source and bundle paths | Bundle file entries |
|
||||
| Creation timestamp | Bundle creation time |
|
||||
|
||||
The call inherits the caller's context and applies the configured positive
|
||||
timeout. The adapter does not read report files, construct bundle layouts, or
|
||||
persist notification artifacts.
|
||||
|
||||
## Status and errors
|
||||
|
||||
An accepted upload is followed by one status request. When a timeout is
|
||||
configured, a nonterminal result is polled until `succeeded` or `failed`, or
|
||||
until the context ends. The translated `UploadResult` contains the run ID,
|
||||
status, and `RunStatus`, including pipeline ID, lifecycle timestamps, report,
|
||||
and remote error details.
|
||||
|
||||
Status lookup or polling errors are preserved in `UploadResult.StatusError` so
|
||||
the caller can record an accepted-but-unconfirmed delivery. A terminal failed
|
||||
run returns that result and an error. Upload failures return no result. Upstream
|
||||
idempotency conflicts become the local `IdempotencyConflictError`, which adds
|
||||
endpoint, pipeline, bundle, idempotency, and file-path context while redacting
|
||||
the token.
|
||||
|
||||
## Verification
|
||||
|
||||
Focused tests cover configuration validation, request mapping, timeouts and
|
||||
polling, status translation, conflict handling, and token redaction:
|
||||
|
||||
```sh
|
||||
go test ./internal/adapters/distributor
|
||||
```
|
||||
67
docs/internal/facts.md
Normal file
67
docs/internal/facts.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Fact Contracts Internals
|
||||
|
||||
`internal/facts` is the deterministic boundary between a collected weather
|
||||
bundle and report-scoped facts. It preserves normalized source values and then
|
||||
selects and summarizes the values needed for one resolved report. Provider
|
||||
transport and normalized bundle semantics belong to
|
||||
[weather-data internals](weather-data.md); report identity and valid-period
|
||||
selection belong to [report registry internals](report-registry.md).
|
||||
|
||||
## Collected facts
|
||||
|
||||
`BuildCollected` projects a `weatherdata.Bundle` into `CollectedFacts`. It
|
||||
retains the fetched timestamp and every normalized product: observations,
|
||||
current conditions, hourly, narrative, alerts, discussion, daily, weather
|
||||
story, and convective outlook data. Source provenance and warnings are copied
|
||||
into their own slices so downstream consumers can inspect data completeness
|
||||
without treating it as an ordinary weather fact.
|
||||
|
||||
A nil bundle produces an empty collected value. Collection itself, missing
|
||||
source policy, and source hashes are outside this package.
|
||||
|
||||
## Report-scoped derivation
|
||||
|
||||
`BuildDerived` requires a valid resolved period and a valid report timezone. It
|
||||
uses half-open period overlap to select hourly, narrative, daily, and alert
|
||||
data; it also derives precipitation timing. Convective outlooks are retained
|
||||
only when their valid interval overlaps the report period, with discussions
|
||||
kept for represented outlook days. Both collections are sorted deterministically.
|
||||
|
||||
Report identity controls the summary shape:
|
||||
|
||||
| Report family | Derived summary |
|
||||
| --- | --- |
|
||||
| Hourly | Rolling-period selections and precipitation timing; no daily or daypart summary |
|
||||
| Daily, Today, Tomorrow | One local civil-day summary and its dayparts |
|
||||
| Three-day, Weekend | One clipped daily summary for each overlapping local day |
|
||||
| Storm | One summary for the explicit report window |
|
||||
|
||||
`DaypartSummaries` is collected from the resulting daily or storm summaries.
|
||||
The detailed grouping, daypart-window, and alert rules are owned by
|
||||
[forecast derivation](forecast-derivation.md).
|
||||
|
||||
## Missing data and failures
|
||||
|
||||
Optional normalized products remain nil or yield empty selections; the package
|
||||
does not create substitute values. A present convective-outlook run with no
|
||||
matching outlooks produces non-nil empty outlook and discussion slices, while
|
||||
a missing run produces nil slices.
|
||||
|
||||
Derivation fails for an invalid report period, invalid timezone, unsupported
|
||||
report ID, or when a requested daily summary has no hourly forecast data.
|
||||
Invalid daypart definitions surface from forecast derivation. The package does
|
||||
not access the CLI, filesystem, subprocesses, or network.
|
||||
|
||||
## Verification and invariants
|
||||
|
||||
Focused tests cover collected-fact separation, report-period selection,
|
||||
hourly and storm behavior, daily and partial-day summaries, and convective
|
||||
outlook selection:
|
||||
|
||||
```sh
|
||||
go test ./internal/facts
|
||||
```
|
||||
|
||||
Facts are derived once for a resolved report from already collected data.
|
||||
They remain reusable structured values: prompt wording, state persistence,
|
||||
prior-report comparison, and template presentation are owned elsewhere.
|
||||
@@ -1,76 +1,63 @@
|
||||
# Forecast Derivation Internals
|
||||
|
||||
This document describes deterministic forecast summarization in
|
||||
`internal/forecast`.
|
||||
`internal/forecast` deterministically selects and summarizes normalized
|
||||
forecast data. It has no transport, filesystem, CLI, subprocess, or report
|
||||
registry dependency. Its summaries are consumed by
|
||||
[fact contracts](facts.md) and later module builders.
|
||||
|
||||
## Purpose
|
||||
## Period and daypart semantics
|
||||
|
||||
`internal/forecast` converts normalized bundle data into daily and period
|
||||
summaries used by briefing builders.
|
||||
Selections use `timeutil.Period` half-open overlap: a value is selected only
|
||||
when both intervals share time. `BuildDailySummary` creates one local civil
|
||||
day; `BuildPeriodDailySummaries` intersects every local civil day with the
|
||||
requested period, preserving partial first and last days.
|
||||
|
||||
## Inputs And Outputs
|
||||
`ResolveDayparts` converts each configured name, start clock, and end clock
|
||||
into a local window. An end clock at or before its start clock wraps into the
|
||||
next civil day. The daypart and timezone defaults are defined in the
|
||||
[configuration reference](../config.md), not here.
|
||||
|
||||
Inputs:
|
||||
## Deterministic summaries
|
||||
|
||||
- `forecast.Bundle`
|
||||
- local date or resolved report period
|
||||
- timezone
|
||||
- configured daypart definitions
|
||||
`BuildDailySummary` requires an hourly run with at least one period. It adds
|
||||
the selected narrative periods, discussion, alert overlaps, source provenance,
|
||||
source warnings, and one `DaypartSummary` per resolved window. A daypart keeps
|
||||
its selected hourly periods and derives temperature and apparent-temperature
|
||||
ranges, timed precipitation and wind maxima, dominant and notable conditions,
|
||||
and weather indicators.
|
||||
|
||||
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
|
||||
- one clipped daily summary per local day or partial day from
|
||||
`BuildPeriodDailySummaries`
|
||||
- daypart summaries with selected hourly periods, ranges, timed maximums,
|
||||
conditions, indicators, and alert overlaps
|
||||
Alert overlap parsing supports the normalized alert payload's available timing
|
||||
fields. Unparseable alerts and invalid intervals are ignored; valid overlaps
|
||||
are clipped to the requested period and ordered by alert start time.
|
||||
|
||||
## Boundaries
|
||||
## Missing data and failures
|
||||
|
||||
- This package groups, selects, and summarizes already-normalized forecast
|
||||
data.
|
||||
- It does not perform HTTP calls, parse CLI flags, resolve report definitions,
|
||||
compare prior snapshots, build prompt input packages, or invoke Scriptorium.
|
||||
Empty selections yield empty summary fields rather than generated prose.
|
||||
Direct daily or period-summary calls fail when their required bundle, valid
|
||||
period, hourly data, or daypart definitions are invalid. A nil location uses
|
||||
UTC when these APIs are called directly. Optional narrative, discussion, and
|
||||
alerts remain absent when their normalized products are absent.
|
||||
|
||||
## 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`
|
||||
- `dayparts[].start`
|
||||
- `dayparts[].end`
|
||||
## Verification and invariants
|
||||
|
||||
Threshold constants for basic indicators live in forecast code rather than
|
||||
configuration.
|
||||
Focused tests cover local civil days, clipped periods, daypart resolution,
|
||||
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 `forecast.Bundle`.
|
||||
|
||||
## State Or Manifest Behavior
|
||||
|
||||
None. Source warnings and provenance from the bundle are carried into summaries
|
||||
for later metadata and briefing output.
|
||||
|
||||
## Skip And Resume Behavior
|
||||
|
||||
None. Missing optional source context can produce empty selections, but missing
|
||||
required hourly data fails summarization.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- 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.
|
||||
The package preserves normalized inputs as inspectable structured values and
|
||||
never decides report identity, delivery, or presentation wording.
|
||||
|
||||
51
docs/internal/generatedtext.md
Normal file
51
docs/internal/generatedtext.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Generated Text Internals
|
||||
|
||||
`internal/generatedtext` validates the structured prose produced for generated-
|
||||
text reports and turns validated prose plus rich module values into typed render
|
||||
contexts. It owns the catalog that pairs a generated-text report definition
|
||||
with its validator, schema ID, template ID, and context builder. The complete
|
||||
maintainer-facing context fields belong to [report templates](../templates.md).
|
||||
|
||||
## Catalog and validation
|
||||
|
||||
Only the Daily, Today, Tomorrow, and Hourly report definitions use the
|
||||
generated-text-template mode. `LookupDefinition` rejects a direct-Markdown
|
||||
definition, unknown schema or template IDs, and unsupported schema/template
|
||||
pairs before the run begins. A handler validates raw JSON, returns a typed
|
||||
value and canonical normalized JSON, loads its schema, builds a render context,
|
||||
and renders through `internal/reporttemplate`.
|
||||
|
||||
Daily, Today, and Tomorrow use a day-style value with required trimmed summary
|
||||
and one or more nonblank discussion paragraphs. Hourly requires trimmed summary
|
||||
and a single trimmed discussion string. 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.
|
||||
|
||||
## Render contexts
|
||||
|
||||
The catalog's report-specific builders receive briefing metadata, a rich module
|
||||
snapshot, collected facts, derived facts, and the matching validated generated
|
||||
text. They decode the module stanzas needed by the template and build typed
|
||||
Daily, Today, Tomorrow, or Hourly contexts. Context construction validates
|
||||
metadata and periods, preserves rich module values, and uses ordered slices for
|
||||
template iteration rather than maps.
|
||||
|
||||
Optional source stanzas become nil or fallback context fields. Missing required
|
||||
stanzas, type-decoding failures, invalid metadata, or a generated-text type
|
||||
that does not match the chosen handler fail before template execution. Prompt
|
||||
packages, raw Scriptorium output, state persistence, and template asset lookup
|
||||
remain outside this package.
|
||||
|
||||
## Verification and invariants
|
||||
|
||||
Focused tests cover the catalog, each report-specific validator, normalization,
|
||||
schema/template mismatches, context construction, optional modules, and typed
|
||||
stanza errors:
|
||||
|
||||
```sh
|
||||
go test ./internal/generatedtext
|
||||
```
|
||||
|
||||
Generated text supplies prose slots only; deterministic weather facts remain in
|
||||
module and fact values. Every generated-text definition must resolve to exactly
|
||||
one supported catalog pair.
|
||||
68
docs/internal/module.md
Normal file
68
docs/internal/module.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Module Contract Internals
|
||||
|
||||
`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).
|
||||
|
||||
## Outputs and snapshots
|
||||
|
||||
Each `Output` has a module ID, stanza name, rich `Value`, and runtime-only
|
||||
`PromptValue`. `DataPackageValue` returns the prompt value when present and
|
||||
otherwise the rich value. This permits custom prompt exports without shrinking
|
||||
the template and inspection value.
|
||||
|
||||
`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.
|
||||
|
||||
Snapshots reject missing schema versions, empty IDs or stanza names, and
|
||||
duplicate IDs or stanza names. Output order is caller-owned and preserved.
|
||||
|
||||
## Registered IDs and default composition
|
||||
|
||||
The registered IDs are `metadata`, `current_conditions`,
|
||||
`narrative_forecast`, `hourly_forecast`, `derived_daily_summary`,
|
||||
`derived_daypart_summaries`, `precip_timing`, `alert_digest`,
|
||||
`spc_convective_outlooks`, `area_forecast_discussion`,
|
||||
`spc_convective_discussion`, `weather_story`, `outdoor_windows`,
|
||||
`today_planning`, `tomorrow_planning`, and `daily_planning`.
|
||||
|
||||
The registry declares these ordered default compositions:
|
||||
|
||||
| Report | Ordered modules |
|
||||
| --- | --- |
|
||||
| Daily | metadata, current conditions, narrative forecast, daily summary, daypart summaries, precipitation timing, alert digest, SPC outlooks, AFD (long term), SPC discussion, weather story, outdoor windows, daily planning, hourly forecast |
|
||||
| Today | metadata, current conditions, narrative forecast, daily summary, daypart summaries, precipitation timing, alert digest, SPC outlooks, AFD, SPC discussion, weather story, outdoor windows, hourly forecast, today planning |
|
||||
| Tomorrow | metadata, current conditions, narrative forecast, daily summary, daypart summaries, precipitation timing, alert digest, SPC outlooks, AFD, SPC discussion, weather story, outdoor windows, tomorrow planning, hourly forecast |
|
||||
| Hourly | metadata, current conditions, hourly forecast, precipitation timing, alert digest, SPC outlooks, AFD (key messages and short term), SPC discussion, weather story |
|
||||
| Three-day and Weekend | metadata, current conditions, daypart summaries, precipitation timing, alert digest, SPC outlooks, AFD, SPC discussion, weather story, outdoor windows |
|
||||
| Storm | metadata, current conditions, precipitation timing, alert digest, SPC outlooks, AFD, SPC discussion, weather story |
|
||||
|
||||
The only non-empty default option is the AFD section selection. It accepts a
|
||||
`sections` list; omitted or empty selects all available sections. Report
|
||||
definitions may narrow it as shown above. Option shape and report compatibility
|
||||
are validated by the briefing registry.
|
||||
|
||||
## Rich and prompt-facing values
|
||||
|
||||
Rich values remain available to snapshots, comparisons, and render contexts.
|
||||
Briefing attaches custom prompt exports only for current conditions, hourly
|
||||
forecast, and derived daypart summaries; all other current builders use
|
||||
pass-through values. The prompt package owns how exported stanzas are grouped
|
||||
and serialized; see [prompt input](prompt-input.md).
|
||||
|
||||
## Verification and invariants
|
||||
|
||||
Focused tests cover snapshot validation and order, typed stanza lookup, and
|
||||
prompt-value fallback:
|
||||
|
||||
```sh
|
||||
go test ./internal/module
|
||||
```
|
||||
|
||||
Module IDs and stanza names are stable, every emitted output has one of each,
|
||||
and this package never imports the report registry.
|
||||
@@ -1,69 +1,61 @@
|
||||
# Prompt Input Internals
|
||||
|
||||
This document describes prompt input data package construction.
|
||||
`internal/promptinput` converts report metadata, an ordered module snapshot,
|
||||
Recent Changes, and source warnings into the YAML `data_package` consumed by
|
||||
Scriptorium. It owns this package's schema, grouping, serialization, loading,
|
||||
and validation—not weather collection, module construction, path choice, or
|
||||
subprocess execution.
|
||||
|
||||
## Purpose
|
||||
## Package construction
|
||||
|
||||
`internal/promptinput` converts a structured briefing package and optional
|
||||
Recent Changes into the `data_package` JSON passed to Scriptorium prompts.
|
||||
`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 And Outputs
|
||||
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).
|
||||
|
||||
Inputs:
|
||||
## YAML ordering and grouping
|
||||
|
||||
- `briefing.Package`
|
||||
- optional `[]changes.Change`
|
||||
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:
|
||||
|
||||
Outputs:
|
||||
| Category | Current stanzas |
|
||||
| --- | --- |
|
||||
| `applicable_risk_products` | alert digest, SPC convective outlooks |
|
||||
| `derived_summaries` | deterministic summaries, precipitation timing, outdoor windows, and planning values |
|
||||
| `narrative_products` | narrative forecast, discussions, and weather story |
|
||||
| `raw_data` | current conditions and hourly forecast |
|
||||
|
||||
- `promptinput.Package` containing schema version, RunID, report metadata,
|
||||
briefing content, Recent Changes, and source warnings. Briefing content
|
||||
includes configured location context, current conditions when available,
|
||||
discussion key messages, and short/long-term AFD narratives when the Weather
|
||||
API provides them.
|
||||
- report metadata includes `currentLocalDate`, the generation date formatted as
|
||||
`YYYY-MM-DD` in the effective report timezone.
|
||||
- optional JSON file written by `promptinput.Save`
|
||||
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.
|
||||
|
||||
## Boundaries
|
||||
## Validation and persistence
|
||||
|
||||
- This package owns the prompt input schema and validation.
|
||||
- It does not fetch weather data, derive forecast summaries, find prior
|
||||
snapshots, compare changes, or invoke Scriptorium.
|
||||
`Validate` requires the current schema version, run and report identifiers,
|
||||
prompt ID, generation timestamp, timezone, current local date, valid period,
|
||||
and at least one ordered briefing stanza. It rejects duplicate stanza names,
|
||||
missing values, and a missing category for every non-metadata stanza.
|
||||
|
||||
## Config Fields Used
|
||||
`MarshalYAML` and `LoadYAML` validate their result. `Save` writes the serialized
|
||||
YAML atomically; managed workspace paths are owned by [state internals](state.md).
|
||||
Generated-text artifacts and template render contexts are later workflow
|
||||
artifacts, not members of this package.
|
||||
|
||||
None directly. Config-derived values, including timezone and prompt location
|
||||
context, are already present in briefing metadata before this package runs.
|
||||
## Verification and invariants
|
||||
|
||||
## External Adapters Used
|
||||
Focused tests cover construction, curated exports, category ordering, YAML
|
||||
round trips, invalid layout, validation, and atomic saves:
|
||||
|
||||
None.
|
||||
```sh
|
||||
go test ./internal/promptinput
|
||||
```
|
||||
|
||||
## State Or Manifest Behavior
|
||||
|
||||
`promptinput.Save` writes JSON atomically. Managed workspace paths are owned by
|
||||
`internal/state`.
|
||||
|
||||
## Skip And Resume Behavior
|
||||
|
||||
None. Recent Changes is always present as an `items` list and may be empty.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
Validation fails before render preflight when required top-level or briefing
|
||||
metadata fields are missing or inconsistent, or when no report content is
|
||||
present. Save failures include filesystem operation and path context.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/promptinput/package_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Scriptorium receives structured `data_package` JSON.
|
||||
- Briefing metadata and top-level report metadata must agree.
|
||||
- Recent Changes are not inferred from rendered report text.
|
||||
The package is narrower than a template render context and never infers changes
|
||||
from report prose.
|
||||
|
||||
@@ -1,97 +1,76 @@
|
||||
# Report Registry Internals
|
||||
|
||||
This document describes report identity, valid-period resolution, batch
|
||||
membership, output naming, artifact grouping, and comparison declarations in
|
||||
`internal/report`.
|
||||
`internal/report` owns the registry of report identities and the data declared
|
||||
for each one: resolution, generation mode, prompt identity, comparison policy,
|
||||
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,
|
||||
briefing, and CLI wiring consume resolved definitions instead of owning report
|
||||
identity policy themselves.
|
||||
Each `Definition` declares a stable ID and display name, prompt ID, generation
|
||||
mode, optional template and generated-text schema IDs, valid-period resolver,
|
||||
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
|
||||
|
||||
Each report definition declares:
|
||||
|
||||
- report ID and display name
|
||||
- Scriptorium prompt ID
|
||||
- valid-period resolver
|
||||
- comparison strategy
|
||||
- managed artifact group
|
||||
- batch output copy filename
|
||||
- generated-report eligibility
|
||||
- prior-report compatibility list
|
||||
- morning or evening batch membership
|
||||
|
||||
## Implemented Reports
|
||||
|
||||
| Report | ID | Prompt | Artifact group | Batch copy | Prior compatibility |
|
||||
| Report ID | Mode | Period policy | Comparison | Registry batch flag | Output copy |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Daily Today | `daily_today` | `weather.daily_report` | `daily` | `daily.md` | Daily Today, Daily Tomorrow |
|
||||
| Daily Tomorrow | `daily_tomorrow` | `weather.daily_report` | `daily` | `tomorrow.md` | Daily Today, Daily Tomorrow |
|
||||
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `three-day` | `three-day.md` | 3-Day Outlook |
|
||||
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `weekend` | `weekend.md` | Weekend Outlook |
|
||||
| Storm Report | `storm` | `weather.storm_report` | `storm` | `storm.md` | Storm Report |
|
||||
| `daily` | Generated text + template | Explicit local civil day | Same valid date | Dynamic Daily inclusion is app-owned | `daily.md` |
|
||||
| `today` | Generated text + template | Selected or current local civil day | Same valid date | Morning | `today.md` |
|
||||
| `tomorrow` | Generated text + template | Next local civil day | Same valid date | Evening | `tomorrow.md` |
|
||||
| `hourly` | Generated text + template | Rolling six-hour interval | Rolling window | — | `hourly.md` |
|
||||
| `three_day` | Scriptorium Markdown | Generation time through the third following local midnight | Same valid date | Morning | `three-day.md` |
|
||||
| `weekend` | Scriptorium Markdown | Upcoming weekend window | Weekend window | Morning | `weekend.md` |
|
||||
| `storm` | Scriptorium Markdown | Caller-supplied event window | Explicit window | — | `storm.md` |
|
||||
|
||||
All implemented report definitions are eligible for generation.
|
||||
The four generated-text reports pair their report ID with matching template and
|
||||
schema IDs. The three direct-Markdown reports leave both IDs empty. Exact
|
||||
template fields and schema assets belong to [report templates](../templates.md)
|
||||
and [generated-text internals](generatedtext.md).
|
||||
|
||||
## Valid Periods
|
||||
All valid periods are half-open. Storm accepts local `YYYY-MM-DDTHH:MM` values
|
||||
in the effective report timezone or offset-bearing RFC3339 values; its end
|
||||
must follow its start. Resolving Weekend directly on Sunday is rejected.
|
||||
|
||||
- Daily Today covers the selected local civil day, or the current local civil
|
||||
day when no date override is supplied.
|
||||
- Daily Tomorrow covers the next local civil day from generation time.
|
||||
- 3-Day Outlook covers the interval from generation time through local midnight
|
||||
three days later.
|
||||
- Weekend Outlook covers the upcoming weekend window and is not scheduled for
|
||||
Sunday morning batch resolution.
|
||||
- Storm Report covers an explicit event window supplied by the caller.
|
||||
## Registry collaborators
|
||||
|
||||
Storm event windows can be parsed from local `YYYY-MM-DDTHH:MM` timestamps in
|
||||
the configured timezone or RFC3339 timestamps with explicit offsets. End time
|
||||
must be after start time.
|
||||
`DefaultRegistry` is the only source of the seven 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.
|
||||
|
||||
## Boundaries
|
||||
The definition's `DistributorPathTemplates` are internal declarations consumed
|
||||
by app orchestration. Their rendered external bundle paths and compatibility
|
||||
contract are documented in the [Distributor bundle guide](../integrations/distributor/pkg-bundle.md), not repeated here.
|
||||
|
||||
`internal/report` defines report metadata and time coverage. It does not fetch
|
||||
weather data, build briefings, compare briefing contents, write state, parse CLI
|
||||
flags, or invoke Scriptorium.
|
||||
`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.
|
||||
|
||||
The CLI owns public command names. The app maps those command names to report
|
||||
IDs, then uses the registry for report policy.
|
||||
## Module composition and failures
|
||||
|
||||
## Config Fields Used
|
||||
Each definition supplies an ordered `[]module.ConfigItem`; the complete
|
||||
report-to-module mapping is maintained in [module internals](module.md).
|
||||
`ArtifactGroup`, `BatchOutputName`, `Generated`, and comparison compatibility
|
||||
are likewise consumed by state and orchestration rather than recomputed there.
|
||||
|
||||
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
|
||||
output path copying uses batch output names from report definitions.
|
||||
Unknown report IDs or batch names, an invalid weekend resolution, and invalid
|
||||
storm windows return errors. The registry never collects weather data, builds
|
||||
modules, parses CLI flags, writes state, executes Scriptorium, or delivers a
|
||||
report.
|
||||
|
||||
## State And App Usage
|
||||
## Verification and invariants
|
||||
|
||||
- State paths use `ArtifactGroup`.
|
||||
- Batch output copies use `BatchOutputName`.
|
||||
- Generation checks `Generated`.
|
||||
- Prior lookup checks `CompatiblePriorIDs` and the comparison strategy.
|
||||
- RunIDs include the resolved report ID.
|
||||
Focused tests cover definition completeness, command and alias lookup, period
|
||||
resolution, run IDs, path declarations, composition defaults, and override
|
||||
validation:
|
||||
|
||||
## Failure Behavior
|
||||
```sh
|
||||
go test ./internal/report
|
||||
```
|
||||
|
||||
- Unknown report IDs and batch names return actionable errors.
|
||||
- Weekend Outlook resolution returns an error when resolved directly on Sunday.
|
||||
- Storm Report resolution requires start and end, with end after start.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/report/period_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
- `internal/cli/root_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Report selection goes through the registry.
|
||||
- Daily Today and Daily Tomorrow both use `weather.daily_report`.
|
||||
- Valid periods are half-open intervals independent of rendered report text.
|
||||
- Artifact grouping, batch output filenames, generated-report eligibility,
|
||||
comparison compatibility, and comparison strategy are declared by report
|
||||
definition.
|
||||
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.
|
||||
|
||||
51
docs/internal/reporttemplate.md
Normal file
51
docs/internal/reporttemplate.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Report Template Internals
|
||||
|
||||
`internal/reporttemplate` embeds and renders the repository's native Markdown
|
||||
templates and exposes their companion generated-text schemas. The current asset
|
||||
IDs are `daily`, `today`, `tomorrow`, and `hourly`. The template files, partials,
|
||||
and complete render-context field reference are maintained in
|
||||
[report templates](../templates.md).
|
||||
|
||||
## Assets and lookup
|
||||
|
||||
The package embeds top-level templates, shared partials, and JSON schemas from
|
||||
its asset directories. `Template` and `Schema` return the requested embedded
|
||||
asset and fail with the requested ID when it is unknown or unreadable.
|
||||
|
||||
Generated-text catalog handlers obtain schema bytes and template source through
|
||||
these APIs. Prompt source files are repository assets for prompt registration;
|
||||
they are not reporttemplate lookup assets. Report definitions select IDs, while
|
||||
[generated-text internals](generatedtext.md) verifies the supported
|
||||
schema/template pairing.
|
||||
|
||||
## Rendering
|
||||
|
||||
`Render` loads the top-level template, creates a `text/template` with helper
|
||||
functions and `missingkey=error`, parses the template, parses every shared
|
||||
partial, and executes the result against the typed render context. This makes
|
||||
missing context fields, bad template syntax, unreadable partials, and execution
|
||||
failures actionable with template or partial context.
|
||||
|
||||
Top-level templates decide which shared partials they invoke. The current
|
||||
partials cover daypart forecast variants, alert digest, and precipitation
|
||||
timing. Template code receives curated typed contexts rather than raw data
|
||||
packages, and it must not reimplement weather selection or generated-text
|
||||
validation.
|
||||
|
||||
## Boundaries and verification
|
||||
|
||||
This package does not collect weather data, build modules, validate generated
|
||||
text, construct contexts, resolve report definitions, write state, execute
|
||||
Scriptorium, or upload reports. It produces Markdown bytes for application
|
||||
orchestration to persist.
|
||||
|
||||
Focused tests cover asset lookup, schema availability, rendering, partial
|
||||
behavior, missing keys, and malformed context:
|
||||
|
||||
```sh
|
||||
go test ./internal/reporttemplate
|
||||
```
|
||||
|
||||
Embedded assets stay as separate files, shared fragments stay under the partial
|
||||
directory, and generated-text schemas describe prose slots rather than
|
||||
deterministic weather facts.
|
||||
@@ -1,100 +1,51 @@
|
||||
# Scriptorium Adapter Internals
|
||||
|
||||
This document describes the subprocess adapter in
|
||||
`internal/adapters/scriptorium`.
|
||||
`internal/adapters/scriptorium` translates Weatherreporter render requests to
|
||||
Scriptorium process arguments and translates process results back to local
|
||||
types. The external CLI and output contract belongs to the
|
||||
[Scriptorium integration guide](../integrations/scriptorium.md); prompts,
|
||||
template inputs, and report ownership remain outside this adapter.
|
||||
|
||||
## Purpose
|
||||
## Request-to-command translation
|
||||
|
||||
The adapter runs `scriptorium render` for prompt preflight and `scriptorium run`
|
||||
for Markdown report generation. It isolates subprocess execution, argv
|
||||
construction, timeout handling, output capture, and exit-code interpretation
|
||||
from app and domain packages.
|
||||
`Runner` accepts a binary, config path, profile, timeout, extra arguments, and
|
||||
an injectable command executor. Its defaults are the `scriptorium` binary and
|
||||
the real `ExecRunner`. Optional configuration flags are placed before the
|
||||
operation-specific arguments, and extra arguments are appended last.
|
||||
|
||||
## Inputs And Outputs
|
||||
| Local operation | Required values | Translated arguments |
|
||||
| --- | --- | --- |
|
||||
| `Render` | prompt ID, data-package path | `render [--config …] [--profile …] --prompt <id> --input data_package=<path> --format json [extra …]` |
|
||||
| `Run` | prompt ID, data-package path, output path | `run [--config …] [--profile …] --prompt <id> --input data_package=<path> --out <path> [extra …]` |
|
||||
| `StructuredRun` | prompt ID, data-package path, output path | Same translation as `Run` |
|
||||
|
||||
Inputs:
|
||||
Blank required values fail before a command starts. The adapter does not add
|
||||
schema flags or interpret a prompt's payload; it only gives Scriptorium the
|
||||
named `data_package` input.
|
||||
|
||||
- prompt ID
|
||||
- prompt input data package path
|
||||
- report output path for `run`
|
||||
- configured binary, config path, profile, timeout, and extra arguments
|
||||
- context for cancellation
|
||||
## Command execution and result translation
|
||||
|
||||
Outputs:
|
||||
`ExecRunner` uses `exec.CommandContext`, never a shell. A positive configured
|
||||
timeout creates a child context. Standard output and standard error are
|
||||
captured independently, each with a 1 MiB limit, and the executed command is
|
||||
retained for diagnostics.
|
||||
|
||||
- argv used for execution
|
||||
- captured stdout and stderr
|
||||
- truncation flags for captured output
|
||||
- exit code
|
||||
- report output path for `run`
|
||||
`RenderResult`, `RunResult`, and `StructuredRunResult` expose the command,
|
||||
captured output, truncation markers, and exit code. Run results also retain the
|
||||
requested output path. Exit status zero is successful. A nonzero process exit
|
||||
returns its result and an error, while a start failure, cancellation, or
|
||||
deadline failure returns no result and the execution error.
|
||||
|
||||
## Boundaries
|
||||
The adapter does not parse rendered JSON, validate a generated report, write
|
||||
state, or upload a report. Those responsibilities sit with
|
||||
[application orchestration](app-orchestration.md), [state internals](state.md), and the
|
||||
relevant delivery adapter.
|
||||
|
||||
`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.
|
||||
## Verification
|
||||
|
||||
The adapter exposes request and result structs for render and run operations.
|
||||
State persistence uses a state-owned preflight artifact shape; app
|
||||
orchestration converts render results before saving.
|
||||
Focused tests cover argument order, validation, bounded capture, timeout and
|
||||
cancellation handling, and exit-status translation:
|
||||
|
||||
## 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
|
||||
```sh
|
||||
go test ./internal/adapters/scriptorium
|
||||
```
|
||||
|
||||
Report generation argv starts with:
|
||||
|
||||
```text
|
||||
scriptorium run --prompt <prompt_id> --input data_package=<path> --out <path>
|
||||
```
|
||||
|
||||
Configured `--config` and `--profile` flags are inserted after the subcommand
|
||||
and before prompt-specific arguments. Extra arguments are appended after the
|
||||
built-in arguments.
|
||||
|
||||
## Execution Behavior
|
||||
|
||||
The adapter runs commands without shell interpolation. The same private
|
||||
execution path is used by render and run after command-specific request
|
||||
validation and argv construction.
|
||||
|
||||
When `scriptorium.timeout` is greater than zero, each subprocess call uses a
|
||||
context with that timeout. Stdout and stderr are captured separately, capped at
|
||||
1 MiB each, and marked as truncated when the cap is reached.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- Missing prompt ID or data package path returns an error before subprocess
|
||||
execution.
|
||||
- Missing run output path returns an error before subprocess execution.
|
||||
- Subprocess start errors, context cancellation, and timeouts are wrapped with
|
||||
operation context by the caller-facing method.
|
||||
- Nonzero render and run exits return the captured result plus an error
|
||||
containing the exit code and stderr.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/adapters/scriptorium/runner_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
- `internal/cli/root_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- No shell interpolation is used.
|
||||
- The Scriptorium input name is `data_package`.
|
||||
- Render and run preserve command-specific result structs.
|
||||
- Scriptorium-specific flags stay inside adapter and config boundaries.
|
||||
|
||||
@@ -1,117 +1,91 @@
|
||||
# State Internals
|
||||
|
||||
This document describes filesystem state in `internal/state`.
|
||||
The `internal/state` package owns filesystem-backed run state: safe path
|
||||
derivation, metadata persistence, prior-report lookup, and read-only report
|
||||
inspection. It does not decide which reports to generate or deliver. For the
|
||||
operator-facing layout and retention procedures, see the
|
||||
[operations guide](../operations.md).
|
||||
|
||||
## Purpose
|
||||
## Store construction and artifact paths
|
||||
|
||||
`internal/state` owns managed workspace paths, atomic JSON writes, persisted
|
||||
metadata, prior snapshot lookup, and read-only artifact inspection helpers.
|
||||
`NewFilesystemStore` requires a workspace root and rejects absolute or
|
||||
escaping values for every configured state directory. `Paths` then validates a
|
||||
run ID and artifact group before deriving all paths from the report's valid
|
||||
start date (`YYYY-MM-DD`). This keeps a run's artifacts together while making
|
||||
the paths safe to use below the configured workspace.
|
||||
|
||||
## Inputs And Outputs
|
||||
| Artifact | Derived 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` |
|
||||
| Render preflight | `preflight/<group>/<date>/render.<run-id>.json` |
|
||||
| Notification record | `notifications/<group>/<date>/distributor.<run-id>.json` |
|
||||
| Managed report | `reports/<group>/<date>/report.<run-id>.md` |
|
||||
| Generated text | `snapshots/<group>/<date>/generated_text.<run-id>.json` |
|
||||
| Generated-text source and result | `snapshots/<group>/<date>/generated_text_raw.<run-id>.json` and `generated_text_result.<run-id>.json` |
|
||||
| Generated-text render context | `snapshots/<group>/<date>/render_context.<run-id>.json` |
|
||||
|
||||
Inputs:
|
||||
The configured notification root separates notification artifacts from report
|
||||
artifacts; single-report notification paths use the report's valid date. Report
|
||||
producers create parent directories as needed and write the report body; state
|
||||
is responsible for the surrounding paths and saved run artifacts.
|
||||
|
||||
- workspace configuration
|
||||
- resolved report definition and valid period
|
||||
- briefing package
|
||||
- prompt input data package
|
||||
- preflight artifact
|
||||
- rendered report path preparation request
|
||||
- RunID for inspection lookups
|
||||
Batch Distributor notifications are derived separately as
|
||||
`notifications/batches/<batch>/<local-date>/distributor.<batch-run-id>.json`.
|
||||
Their date is calculated from the batch start in its configured location, and
|
||||
the batch identity and run ID receive the same path-segment validation as
|
||||
single-report artifact identifiers.
|
||||
|
||||
Outputs:
|
||||
## Metadata and durable writes
|
||||
|
||||
- briefing snapshot JSON path
|
||||
- prompt input data package JSON path
|
||||
- render preflight JSON path
|
||||
- managed Markdown report path
|
||||
- metadata JSON path
|
||||
- prior comparable snapshot metadata
|
||||
- loaded briefing or data package
|
||||
- recent report records for inspection
|
||||
`Metadata` is the durable inventory for a run. It records its schema version,
|
||||
run identity, generated and valid timestamps, artifact group and mode, source
|
||||
content and provenance, and the module snapshot, data-package, preflight,
|
||||
report, generated-artifact, and notification locations when present.
|
||||
|
||||
## Boundaries
|
||||
`BuildMetadataFromBriefingMetadata` establishes the common fields; the
|
||||
application adds locations as artifacts are produced. `SaveMetadata` requires
|
||||
the run ID and the module snapshot, data-package, preflight, and metadata
|
||||
paths. The package also saves module snapshots, data packages, preflight
|
||||
records, generated-text artifacts, render contexts, and notifications. JSON
|
||||
writes use `fileutil.WriteJSONAtomic`, so readers do not observe a partially
|
||||
written state file.
|
||||
|
||||
`internal/state` owns local filesystem layout, path validation, durable writes,
|
||||
metadata reads, prior lookup, and report listing. It does not fetch weather
|
||||
data, derive forecasts, build prompt input content, compare briefing contents,
|
||||
invoke Scriptorium, import adapter result types, or parse CLI flags.
|
||||
The data package itself follows the shared
|
||||
[prompt-input contract](prompt-input.md). Report text, templates, and external
|
||||
delivery payloads remain owned by their respective packages and integration
|
||||
references.
|
||||
|
||||
Preflight persistence uses the state-owned `PreflightArtifact` shape. The app
|
||||
converts adapter render results into that shape before saving.
|
||||
## Prior reports and inspection
|
||||
|
||||
## Config Fields Used
|
||||
`FindPriorSnapshot` searches metadata rather than guessing from filenames. It
|
||||
only considers an earlier compatible report in the same artifact group and
|
||||
supports the comparison strategies defined by the report request:
|
||||
|
||||
- `workspace.root`
|
||||
- `workspace.snapshots_dir`
|
||||
- `workspace.reports_dir`
|
||||
- `workspace.data_packages_dir`
|
||||
- `workspace.preflight_dir`
|
||||
- `same_valid_date` finds an earlier generated report for the same valid day.
|
||||
- `weekend_window` finds a prior comparable weekend window.
|
||||
|
||||
Workspace subdirectories must be relative paths that stay under
|
||||
`workspace.root`.
|
||||
The newest eligible metadata record wins; the current run is excluded.
|
||||
Unreadable or malformed candidate metadata is ignored so a damaged historical
|
||||
record does not block a new run.
|
||||
|
||||
## Managed Layout
|
||||
`ListReports` walks saved metadata, returns results ordered newest-first by
|
||||
generation time, and treats a missing snapshots directory as an empty history.
|
||||
`LoadMetadataByRunID` builds on that inspection path. These APIs are read-only;
|
||||
repairing or pruning stored state is an operational concern.
|
||||
|
||||
Paths are derived from the resolved report definition's artifact group, the
|
||||
valid-period start date for JSON artifacts, and the RunID.
|
||||
## Boundaries and verification
|
||||
|
||||
```text
|
||||
<workspace.root>/
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.briefing.json
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.metadata.json
|
||||
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.json
|
||||
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json
|
||||
reports/<artifact_group>/<run_id>.md
|
||||
The package rejects unsafe path components and incomplete metadata before
|
||||
writing. Callers must provide a valid report request, artifact group, and
|
||||
store configuration. Its focused tests cover path derivation, atomic
|
||||
persistence, metadata validation, comparison eligibility, and report listing:
|
||||
|
||||
```sh
|
||||
go test ./internal/state
|
||||
```
|
||||
|
||||
Metadata is stored beside briefing snapshots and links the briefing, data
|
||||
package, preflight, report paths, and configured prompt location. Report
|
||||
listing walks metadata files under the snapshots directory.
|
||||
|
||||
## Prior Lookup
|
||||
|
||||
Prior snapshot lookup reads stored metadata through the shared lookup path and
|
||||
selects the latest earlier snapshot whose report ID is compatible with the
|
||||
current report definition.
|
||||
|
||||
- Daily Today and Daily Tomorrow are compatible with each other for the same
|
||||
valid local date.
|
||||
- 3-Day Outlook compares with prior 3-Day snapshots for the same valid local
|
||||
date.
|
||||
- Weekend Outlook compares with prior Weekend snapshots for the same weekend
|
||||
window.
|
||||
- Storm Report currently has no prior lookup because explicit event-window
|
||||
comparison is not searched by the filesystem store.
|
||||
|
||||
## Writes And Inspection
|
||||
|
||||
Durable JSON writes use shared atomic file helpers. Managed Markdown reports are
|
||||
prepared by creating their parent directory; Scriptorium writes the report body
|
||||
to the prepared path. Extra Markdown copies are handled by app orchestration.
|
||||
|
||||
Inspection helpers read existing metadata, briefing, and data package files.
|
||||
Missing metadata directories return no inspection records or no prior snapshot
|
||||
rather than creating state.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- Invalid workspace paths return validation errors.
|
||||
- Missing required metadata fields prevent metadata writes.
|
||||
- JSON writes use a temporary file followed by rename where practical.
|
||||
- Read and decode failures include path context.
|
||||
- Unknown RunIDs produce an actionable lookup error.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/state/filesystem_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Managed paths stay under the configured workspace root.
|
||||
- Artifact grouping comes from report definitions.
|
||||
- Metadata links artifacts produced for a run.
|
||||
- Prior lookup is based on structured metadata, not rendered report text.
|
||||
See [application orchestration](app-orchestration.md) for the order in which
|
||||
these artifacts are created and [report templates](../templates.md) for the
|
||||
user-facing report contract.
|
||||
|
||||
@@ -1,89 +1,69 @@
|
||||
# Weather Data Internals
|
||||
|
||||
This document describes Weather API ingestion into `forecast.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 one
|
||||
configured Weather API endpoint and assembles the bundle consumed by forecast
|
||||
derivation and briefing builders. Briefing builders expose normalized current
|
||||
conditions as prompt context when `/conditions/current` is available.
|
||||
`Bundle` has a collection timestamp (`FetchedAt`), source provenance
|
||||
(`Sources`), and collection-level warnings (`Warnings`). Its product fields are
|
||||
optional so an allowed missing source can be represented without manufacturing
|
||||
weather data.
|
||||
|
||||
## Inputs And Outputs
|
||||
| Field | Normalized product |
|
||||
| --- | --- |
|
||||
| `Observation` | Station observation |
|
||||
| `Current` | Current conditions |
|
||||
| `Hourly` | Hourly forecast periods |
|
||||
| `Narrative` | Narrative forecast |
|
||||
| `Alerts` | Active-alert check, including an explicitly empty result |
|
||||
| `Discussion` | Forecast discussion and its time-range sections |
|
||||
| `Daily` | Daily forecast periods when supplied |
|
||||
| `WeatherStory` | Latest weather story |
|
||||
| `SPCConvectiveOutlooks` | Convective outlook run, discussions, and GeoJSON geometry |
|
||||
|
||||
Inputs:
|
||||
The bundle carries values rather than provider request details. Consumers use
|
||||
it to construct report facts and data packages; they should not infer a
|
||||
provider endpoint or retry policy from the normalized types. See
|
||||
[collection](collect.md) for assembly and
|
||||
[report templates](../templates.md) for the values exposed to authors.
|
||||
|
||||
- `config.Config` with Weather API URL, timeout, format, units, timezone,
|
||||
precision, and missing-source policy
|
||||
- HTTP responses using the Weather API `data` envelope
|
||||
## Source provenance
|
||||
|
||||
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.
|
||||
|
||||
- `forecast.Bundle` with observation, current conditions, hourly forecast,
|
||||
narrative forecast, active alerts, discussion, source records, and source
|
||||
warnings
|
||||
- stub source records for daily forecast and weather story source slots
|
||||
- optional saved bundle JSON through app fetch helpers
|
||||
An empty product can be meaningful checked data. For example, an explicit
|
||||
empty alerts result is not missing and retains its source hash. A source is
|
||||
marked missing only when the adapter's missing-source policy treats the
|
||||
response or parsing failure as unavailable. The policy itself belongs to the
|
||||
[configuration reference](../config.md).
|
||||
|
||||
## Boundaries
|
||||
## Warning semantics
|
||||
|
||||
- The adapter owns HTTP calls, response-envelope handling, source hashing, and
|
||||
decoding into internal bundle types.
|
||||
- It does not derive dayparts, resolve report periods, build briefings, compare
|
||||
snapshots, write report state, or invoke Scriptorium.
|
||||
`SourceWarning` has a source name, stable code, severity, explanatory message,
|
||||
endpoint, and `CompletenessImpact`. When collection proceeds with a warning,
|
||||
the same warning appears in `Source.Warnings` and `Bundle.Warnings` so both
|
||||
local provenance and whole-run consumers see it. A policy that treats a missing
|
||||
source as an error returns no partial bundle.
|
||||
|
||||
## 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`
|
||||
- `weather_api.timeout`
|
||||
- `weather_api.format`
|
||||
- `weather_api.units`
|
||||
- `weather_api.timezone`
|
||||
- `weather_api.precision`
|
||||
- `missing_source.default`
|
||||
- `missing_source.sources`
|
||||
## Boundaries and verification
|
||||
|
||||
## 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
|
||||
|
||||
See [Weather API integration](../integrations/weatherapi.md) for the external
|
||||
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.
|
||||
`app.FetchAndSaveBundle` can write bundle JSON atomically for inspection.
|
||||
|
||||
## Skip And Resume Behavior
|
||||
|
||||
No resume behavior. Optional missing or malformed sources may be omitted,
|
||||
warned, or treated as errors according to missing-source policy. Hourly forecast
|
||||
data is required and cannot be skipped.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- Missing or invalid `weather_api.base_url` prevents client construction.
|
||||
- HTTP errors, response read failures, and envelope decode failures include
|
||||
endpoint context.
|
||||
- Missing hourly data or hourly forecasts with no periods fail bundle fetch.
|
||||
- Optional and stub sources follow missing-source policy.
|
||||
- Explicit `data: null` from `/alerts/active` produces an empty, non-missing
|
||||
alert run.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/adapters/weatherapi/client_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Weather facts come from normalized source data.
|
||||
- Full hourly and narrative products are fetched; Go owns report-period
|
||||
selection.
|
||||
- Source provenance and warnings remain inspectable downstream.
|
||||
```sh
|
||||
go test ./internal/weatherdata
|
||||
go test ./internal/adapters/weatherapi
|
||||
```
|
||||
|
||||
@@ -1,187 +1,156 @@
|
||||
# Weatherreporter Operations
|
||||
|
||||
This guide covers normal operation, generated artifacts, inspection, recovery,
|
||||
and current operational caveats. For symptom-specific diagnosis, see
|
||||
This guide covers normal operation, managed workspace state, inspection,
|
||||
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).
|
||||
|
||||
## Normal Workflow
|
||||
## Normal Operation
|
||||
|
||||
Implemented generation commands:
|
||||
After configuring a Weather API endpoint, generate one report:
|
||||
|
||||
```text
|
||||
weatherreporter generate daily --date 2026-05-29
|
||||
weatherreporter generate tomorrow
|
||||
weatherreporter generate three-day
|
||||
weatherreporter generate weekend
|
||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
|
||||
```sh
|
||||
weatherreporter generate today --out ./today.md
|
||||
```
|
||||
|
||||
Each command resolves a report period, fetches a Weather API bundle, builds a
|
||||
briefing, builds a prompt input data package, runs `scriptorium render`, runs
|
||||
`scriptorium run`, and writes managed artifacts under the configured workspace.
|
||||
`--out PATH` writes an extra Markdown copy for the current generated report.
|
||||
A generation collects weather data, resolves the report period, builds and
|
||||
persists the module snapshot and prompt data package, runs Scriptorium
|
||||
preflight, then produces the managed Markdown report. Daily, Today, Tomorrow,
|
||||
and Hourly reports additionally persist generated-text artifacts, validate the
|
||||
structured generated text, and render Markdown from the validated text and
|
||||
deterministic values.
|
||||
|
||||
Implemented batch commands:
|
||||
The managed report and its final metadata are saved before single-report
|
||||
Distributor notification is attempted. `--out` writes an extra operator copy;
|
||||
it never changes the managed report or upload source. A successful generate
|
||||
command prints its summary to stdout unless `--quiet` is used.
|
||||
|
||||
```text
|
||||
weatherreporter run morning
|
||||
weatherreporter run evening
|
||||
Run a scheduled batch with the same configured collection:
|
||||
|
||||
```sh
|
||||
weatherreporter run morning --out-dir ./reports
|
||||
```
|
||||
|
||||
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
||||
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch
|
||||
commands print a JSON summary to stdout, write compact per-report status lines
|
||||
to stderr, continue independent reports after one report fails, and return
|
||||
nonzero when any report failed. `--out-dir PATH` writes extra Markdown copies
|
||||
using report default filenames such as `daily.md`, `three-day.md`,
|
||||
`weekend.md`, and `tomorrow.md`.
|
||||
Each batch 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.
|
||||
|
||||
## Filesystem Layout
|
||||
`--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.
|
||||
|
||||
The default workspace root is `workspace`.
|
||||
## 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
|
||||
workspace/
|
||||
snapshots/
|
||||
daily/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.briefing.json
|
||||
<run_id>.metadata.json
|
||||
three-day/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.briefing.json
|
||||
<run_id>.metadata.json
|
||||
weekend/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.briefing.json
|
||||
<run_id>.metadata.json
|
||||
storm/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.briefing.json
|
||||
<run_id>.metadata.json
|
||||
data-packages/
|
||||
daily/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.json
|
||||
three-day/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.json
|
||||
weekend/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.json
|
||||
storm/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.json
|
||||
preflight/
|
||||
daily/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.render.json
|
||||
three-day/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.render.json
|
||||
weekend/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.render.json
|
||||
storm/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.render.json
|
||||
reports/
|
||||
daily/
|
||||
<run_id>.md
|
||||
three-day/
|
||||
<run_id>.md
|
||||
weekend/
|
||||
<run_id>.md
|
||||
storm/
|
||||
<run_id>.md
|
||||
reports/<artifact_group>/<YYYY-MM-DD>/report.<run_id>.md
|
||||
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/modules.<run_id>.json
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/metadata.<run_id>.json
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_raw.<run_id>.json
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_result.<run_id>.json
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text.<run_id>.json
|
||||
snapshots/<artifact_group>/<YYYY-MM-DD>/render_context.<run_id>.json
|
||||
|
||||
data-packages/<artifact_group>/<YYYY-MM-DD>/data_package.<run_id>.yaml
|
||||
preflight/<artifact_group>/<YYYY-MM-DD>/render.<run_id>.json
|
||||
|
||||
notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json
|
||||
notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json
|
||||
```
|
||||
|
||||
Managed artifact filenames use the RunID, so repeated runs for the same valid
|
||||
period do not overwrite each other.
|
||||
The generated-text and render-context artifacts are written only by Daily,
|
||||
Today, Tomorrow, and Hourly reports. A report's metadata links the module
|
||||
snapshot, data package, preflight artifact, managed report, and any available
|
||||
generated-text or 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
|
||||
different managed paths. Batch notification RunIDs contain the UTC batch start
|
||||
timestamp and batch name.
|
||||
|
||||
RunIDs are based on generation time plus report ID:
|
||||
## Distributor Notification
|
||||
|
||||
```text
|
||||
20260529T100000.123456789Z_daily_today
|
||||
```
|
||||
When `notify.distributor.enabled` is enabled, a successful `generate`
|
||||
uploads only the managed Markdown report after final metadata has been saved.
|
||||
The extra copy from `--out` is never uploaded. A notification attempt writes
|
||||
a redacted debug artifact at
|
||||
`notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json`; its
|
||||
path is then recorded in report metadata.
|
||||
|
||||
Each generated report writes metadata that links:
|
||||
Batches suppress per-report notification. When both Distributor and its batch
|
||||
notification are enabled, Weatherreporter submits one multi-report upload after
|
||||
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.
|
||||
|
||||
- RunID, report ID, variant, and prompt ID
|
||||
- generation time, timezone, and valid period
|
||||
- source location, source hashes, and source warnings
|
||||
- briefing snapshot path
|
||||
- prompt input data package path
|
||||
- preflight output path
|
||||
- managed Markdown report path
|
||||
A batch notification attempt writes
|
||||
`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.
|
||||
|
||||
Batch summaries include report status, error text when applicable, valid
|
||||
period, and known artifact paths for each attempted report.
|
||||
## Inspecting Stored Runs
|
||||
|
||||
## Inspection
|
||||
Inspection is read-only: it neither collects weather data nor invokes
|
||||
Scriptorium or Distributor. Start by finding a RunID:
|
||||
|
||||
Inspection commands read existing workspace artifacts and emit JSON to stdout.
|
||||
They do not fetch weather data or run `scriptorium`.
|
||||
|
||||
```text
|
||||
```sh
|
||||
weatherreporter inspect reports --limit 10
|
||||
weatherreporter inspect metadata RUN_ID
|
||||
weatherreporter inspect briefing RUN_ID
|
||||
weatherreporter inspect data-package RUN_ID
|
||||
weatherreporter inspect prior RUN_ID
|
||||
weatherreporter inspect sources RUN_ID
|
||||
```
|
||||
|
||||
Use `inspect reports` to find recent RunIDs and artifact paths. Use
|
||||
`inspect metadata` to see the artifact links recorded for a run. Use
|
||||
`inspect briefing` and `inspect data-package` to review the exact structured
|
||||
inputs used for rendering. Use `inspect prior` to see the prior comparable
|
||||
snapshot selected for Recent Changes, or `null` when none exists. Use
|
||||
`inspect sources` to review source provenance and warnings without dumping full
|
||||
weather payloads.
|
||||
| Command | Reads |
|
||||
| --- | --- |
|
||||
| `inspect reports` | Metadata files under the workspace snapshots tree. |
|
||||
| `inspect metadata RUN_ID` | Metadata located by RunID. |
|
||||
| `inspect modules RUN_ID` | The module snapshot path recorded in metadata. |
|
||||
| `inspect data-package RUN_ID` | The data-package path recorded in metadata. |
|
||||
| `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
|
||||
|
||||
Recent Changes are computed from structured briefing snapshots, not rendered
|
||||
Markdown text.
|
||||
|
||||
Daily Today and Daily Tomorrow can compare with each other when they cover the
|
||||
same valid local date. 3-Day Outlook compares with prior compatible 3-Day
|
||||
snapshots for the same valid local date. Weekend Outlook compares with prior
|
||||
compatible Weekend snapshots for the same weekend window. Storm Report currently
|
||||
leaves Recent Changes empty.
|
||||
|
||||
When no prior comparable snapshot exists, or no configured threshold is crossed,
|
||||
`recentChanges.items` is empty.
|
||||
A missing snapshots directory produces no listed reports. An unknown or empty
|
||||
RunID is an error; use `inspect reports` to obtain a valid value.
|
||||
|
||||
## 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
|
||||
preflight JSON and metadata are written for inspection.
|
||||
- If `scriptorium run` exits nonzero after writing a report, the managed report
|
||||
and metadata remain available.
|
||||
- For batch commands, inspect the stdout JSON summary first, then inspect the
|
||||
artifact paths for each failed report.
|
||||
- A preflight failure can leave the preflight artifact and metadata.
|
||||
- A report-generation failure can leave the managed report, module snapshot,
|
||||
data package, and metadata.
|
||||
- A generated-text failure can leave raw text, the structured run result, or a
|
||||
validated generated-text and render-context artifact, depending on where it
|
||||
stopped.
|
||||
- A single-report notification failure preserves the report and final metadata,
|
||||
including its notification artifact when it was written.
|
||||
- A batch notification failure preserves each report's artifacts and adds the
|
||||
top-level batch notification artifact.
|
||||
|
||||
For a bad report, start with:
|
||||
|
||||
```text
|
||||
weatherreporter inspect metadata RUN_ID
|
||||
weatherreporter inspect sources RUN_ID
|
||||
weatherreporter inspect briefing RUN_ID
|
||||
weatherreporter inspect data-package RUN_ID
|
||||
weatherreporter inspect prior RUN_ID
|
||||
```
|
||||
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
|
||||
RunIDs or the batch notification path. Do not remove the whole workspace as a
|
||||
first response; retain it until the failure is understood.
|
||||
|
||||
## Operational Caveats
|
||||
|
||||
- The application uses one configured Weather API endpoint.
|
||||
- The application writes local filesystem state only.
|
||||
- The application does not implement resume, cleanup, archive, remote storage,
|
||||
daemon operation, or automatic storm monitoring.
|
||||
- Generated reports and Scriptorium stderr can contain sensitive operational
|
||||
context. Store workspace artifacts with appropriate filesystem permissions.
|
||||
- Workspace files, generated reports, and Scriptorium stderr can contain
|
||||
sensitive operational context. Set appropriate filesystem permissions and do
|
||||
not publish them unintentionally.
|
||||
- Weatherreporter uses one configured Weather API endpoint and local workspace
|
||||
state.
|
||||
- It does not provide automatic resume, cleanup, archival, remote state, daemon
|
||||
operation, or automatic storm monitoring.
|
||||
|
||||
@@ -1,119 +1,218 @@
|
||||
# Architecture
|
||||
# Architecture Policy
|
||||
|
||||
This document defines the development principles for this Go project. It is inward-facing: developers and LLM coding agents should use it to preserve the project’s shape, boundaries, and invariants as the code evolves.
|
||||
## Purpose
|
||||
|
||||
## weatherreporter
|
||||
`weatherreporter` is a deterministic weather briefing and report-preparation application. It consumes normalized weather data from the internal weatherfeeder-backed API, derives report-specific briefing packages, compares those packages against prior snapshots, and invokes an external prompt runner to produce human-facing reports.
|
||||
This policy defines Weatherreporter's system shape, normative ownership,
|
||||
dependency direction, architectural invariants, safety properties, and
|
||||
non-goals. Developers and coding agents should use it to preserve the
|
||||
application's boundaries as the implementation evolves.
|
||||
|
||||
The application should keep meteorological data selection, daypart grouping, threshold detection, forecast-period resolution, and recent-change comparison inside Go domain packages. LLM prompts should receive curated briefing packages rather than raw unbounded source payloads wherever practical.
|
||||
The [development guide](../development.md) owns the current package inventory
|
||||
and contributor workflow. Focused documents under `docs/internal/` own
|
||||
implemented subsystem mechanics. This policy owns the rules those packages and
|
||||
mechanics must preserve.
|
||||
|
||||
Report types must be defined through a registry or equivalent mechanism. Each report definition should declare its report ID, prompt ID, valid-period resolver, briefing builder, comparison strategy, and output naming behavior. Avoid scattering report-type conditionals across CLI and orchestration code.
|
||||
## System Shape
|
||||
|
||||
Generated reports must be associated with explicit metadata, including report type, location, generation time, valid period, source product timestamps or hashes, briefing snapshot path, and output path. Recent Changes must be based on structured snapshot comparison rather than comparison of rendered Markdown report text.
|
||||
Weatherreporter is a deterministic weather briefing and report-preparation CLI.
|
||||
It consumes normalized weather data, derives report facts and module snapshots,
|
||||
builds curated prompt packages, compares structured snapshots with prior runs,
|
||||
and invokes Scriptorium either to produce managed Markdown directly or to
|
||||
produce bounded generated-text prose for repository-owned templates. It
|
||||
persists inspectable artifacts and can upload completed reports through
|
||||
Distributor.
|
||||
|
||||
`scriptorium` is an external adapter, not domain logic. Subprocess execution must be isolated under `internal/adapters/scriptorium`, use context-aware execution, avoid shell interpolation, capture actionable stderr, and keep scriptorium-specific flags from leaking into domain packages.
|
||||
The application is intentionally a small, explicit, dependency-light Go
|
||||
program. Add abstraction only when it protects a real boundary, makes an
|
||||
important invariant testable, or supports an implemented extension point.
|
||||
|
||||
## Project Shape
|
||||
The primary flow is:
|
||||
|
||||
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.
|
||||
1. CLI parsing and configuration resolution;
|
||||
2. report or batch resolution;
|
||||
3. normalized weather collection;
|
||||
4. deterministic fact derivation and module construction;
|
||||
5. structured prior-snapshot comparison;
|
||||
6. curated prompt input and report-mode-specific Scriptorium processing;
|
||||
7. generated-text validation when applicable, managed Markdown production,
|
||||
and metadata persistence; and
|
||||
8. optional notification using managed report artifacts.
|
||||
|
||||
Business/domain logic should live outside CLI, transport, and external-adapter packages.
|
||||
Inspection is a separate read-only flow over persisted state. It must not
|
||||
collect weather data, invoke Scriptorium, or upload reports.
|
||||
|
||||
## Ownership And Dependency Direction
|
||||
|
||||
### Entry Point And CLI
|
||||
|
||||
The binary entry point should do no business work beyond constructing and
|
||||
running the CLI. CLI code owns commands, arguments, flags, help, output
|
||||
formatting, and conversion into application requests.
|
||||
|
||||
CLI packages must not own meteorological decisions, report composition,
|
||||
artifact layout, Recent Changes comparison, external transport, or subprocess
|
||||
construction.
|
||||
|
||||
### Configuration
|
||||
|
||||
Configuration loading, built-in defaults, overrides, secret loading, and
|
||||
validation belong to `internal/config`. Operational values shared across
|
||||
packages must be explicit configuration or constants owned by the responsible
|
||||
package, not hidden in CLI or adapter code.
|
||||
|
||||
The exact configuration contract belongs in the
|
||||
[configuration reference](../config.md). Other architecture documents should
|
||||
state ownership and safety rules rather than repeat fields, defaults, or
|
||||
precedence.
|
||||
|
||||
### Application Orchestration
|
||||
|
||||
`internal/app` owns top-level use cases and workflow order. It composes report
|
||||
resolution, collection, domain transformations, state, rendering, and optional
|
||||
notification through narrow project-owned contracts.
|
||||
|
||||
The application layer may coordinate components and convert between their
|
||||
contracts. It must not absorb CLI parsing, HTTP transport, subprocess argument
|
||||
construction, filesystem layout, weather derivation algorithms, template
|
||||
execution, or adapter-specific dependency types.
|
||||
|
||||
### Domain And Report Logic
|
||||
|
||||
Meteorological selection, forecast-period resolution, daypart grouping,
|
||||
threshold detection, fact derivation, report composition, module construction,
|
||||
generated-text validation, and Recent Changes comparison belong in deterministic
|
||||
Go domain packages.
|
||||
|
||||
Domain packages must not depend on CLI parsing, process execution, remote
|
||||
transport, or concrete external-library types. Given the same normalized
|
||||
inputs, configuration, valid period, prior snapshot, and clock, domain behavior
|
||||
should be reproducible.
|
||||
|
||||
Report selection must go through the report registry or an equivalent
|
||||
centralized mechanism. A report definition owns its identity, prompt and
|
||||
rendering mode, valid-period resolver, module composition, comparison strategy,
|
||||
artifact grouping, and output naming. Do not scatter report-ID conditionals
|
||||
through CLI, orchestration, or adapters.
|
||||
|
||||
### External Adapters
|
||||
|
||||
External integrations use adapter boundaries under `internal/adapters`.
|
||||
Adapters own transport and protocol mechanics; application and domain packages
|
||||
own decisions.
|
||||
|
||||
- The Weather API adapter owns HTTP request construction, timeouts, retries,
|
||||
response-envelope handling, decoding, and endpoint compatibility.
|
||||
- The Scriptorium adapter owns argument construction, context-aware subprocess
|
||||
execution, stdout and stderr capture, exit interpretation, and result
|
||||
decoding. It must avoid shell interpolation.
|
||||
- The Distributor adapter owns dependency-specific bundle and upload types,
|
||||
client construction, request execution, status handling, and redaction.
|
||||
|
||||
External dependency types must not leak beyond the adapter that integrates
|
||||
them. Adapters should expose narrow project-owned inputs and outputs so an
|
||||
integration can be tested or replaced without changing domain logic.
|
||||
|
||||
### State And Embedded Assets
|
||||
|
||||
`internal/state` owns managed workspace paths, durable metadata, atomic
|
||||
artifact persistence, prior lookup, and inspection reads. Other packages should
|
||||
request state operations rather than reconstruct managed paths independently.
|
||||
|
||||
Schemas, prompts, Markdown templates, and partials should live as separate
|
||||
repository assets and be embedded by the package that owns their execution or
|
||||
lookup. Keep weather derivation and path construction out of templates.
|
||||
|
||||
## Architectural Invariants
|
||||
|
||||
### Weather Truth And Generated Text
|
||||
|
||||
- Normalized source data and deterministic Go derivation are authoritative for
|
||||
weather facts.
|
||||
- LLM prompts receive curated module-based packages rather than raw,
|
||||
unbounded source payloads.
|
||||
- For generated-text-template reports, generated text is limited to defined
|
||||
prose slots, validated before use, and rendered through typed or otherwise
|
||||
explicit contexts.
|
||||
- Direct-Markdown reports receive the same curated prompt-package boundary but
|
||||
produce managed Markdown directly through Scriptorium rather than the
|
||||
generated-text schema and repository-template workflow.
|
||||
- Repository-owned templates arrange validated prose and deterministic facts;
|
||||
they do not perform meteorological derivation.
|
||||
|
||||
### Reports And Comparison
|
||||
|
||||
- Report behavior is resolved through centralized definitions.
|
||||
- Recent Changes is computed from structured module snapshots, never by
|
||||
comparing rendered Markdown.
|
||||
- Batch workflows collect normalized weather data once and reuse that
|
||||
collection for planning and report generation.
|
||||
- Report metadata links identity, generation time, valid period, source
|
||||
provenance, and the managed artifacts produced for the run.
|
||||
|
||||
### Managed State And Notification
|
||||
|
||||
- Durable structured writes are atomic where practical.
|
||||
- Managed paths remain beneath the configured workspace root.
|
||||
- Operations that delete, move, overwrite, or copy files use narrow, explicit
|
||||
paths; destructive cleanup is opt-in.
|
||||
- Intermediate artifacts reached before a later failure remain inspectable
|
||||
where practical.
|
||||
- Distributor uploads use managed Markdown reports, never optional output
|
||||
copies or broad workspace scans.
|
||||
- Notification occurs only after the managed report and required metadata have
|
||||
been successfully produced.
|
||||
|
||||
### Security, Errors, And Cancellation
|
||||
|
||||
- Secrets must not appear in logs, errors, persisted artifacts, examples, or
|
||||
user-facing output.
|
||||
- Errors preserve actionable operation, report, RunID, path, endpoint, or
|
||||
subprocess context without exposing secrets or unnecessarily large payloads.
|
||||
- External calls, subprocesses, storage operations, and multi-step workflows
|
||||
accept or propagate `context.Context` where cancellation or timeout is
|
||||
meaningful.
|
||||
- Adapter failures preserve useful status, stderr, or response context at the
|
||||
boundary and are translated into project-owned errors before crossing into
|
||||
unrelated packages.
|
||||
|
||||
## Dependency Policy
|
||||
|
||||
Prefer the Go standard library where practical.
|
||||
Prefer the Go standard library. Add an external dependency only when it
|
||||
materially improves correctness, security, interoperability, or
|
||||
maintainability. A dependency used for a small convenience does not justify its
|
||||
lifetime upgrade and compatibility cost.
|
||||
|
||||
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.
|
||||
Keep dependency-specific types inside the package that intentionally adopts
|
||||
the dependency. The application should remain understandable and testable
|
||||
without requiring framework-wide abstractions or live external services.
|
||||
|
||||
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.
|
||||
## Verification And Documentation
|
||||
|
||||
## Package Layout
|
||||
Core behavior must be testable without live Weather API, Scriptorium, or
|
||||
Distributor services. The [testing policy](testing.md) owns test philosophy,
|
||||
sufficiency, boundaries, and test-double guidance.
|
||||
|
||||
Use this layout unless the project has a documented reason to differ:
|
||||
Documentation must follow the
|
||||
[documentation policy](documentation.md). Update the canonical user,
|
||||
operator, integration, internal, and example documentation in the same change
|
||||
as the behavior it describes. Future or proposed behavior belongs under
|
||||
`docs/roadmap/`; significant durable decisions may be recorded as ADRs.
|
||||
|
||||
- `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.
|
||||
## Non-Goals
|
||||
|
||||
Package-private implementation constants may live near the package that owns them, preferably in `constants.go` when useful.
|
||||
Weatherreporter is not:
|
||||
|
||||
## Configuration
|
||||
- a source weather-data ingestion or normalization service;
|
||||
- a general-purpose LLM orchestration framework;
|
||||
- an application in which an LLM selects authoritative weather facts or report
|
||||
policy;
|
||||
- a plugin framework with dynamically discovered report or module behavior;
|
||||
- an HTTP service or multi-user distributed job system;
|
||||
- a replacement for Scriptorium or Distributor protocol ownership; or
|
||||
- a system that hides operational state exclusively inside opaque logs or
|
||||
remote services.
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
New requirements may justify revisiting a non-goal. A change that alters system
|
||||
shape, dependency direction, a safety property, or another architectural
|
||||
invariant should be recorded deliberately in this policy or an ADR rather than
|
||||
introduced implicitly.
|
||||
|
||||
@@ -1,193 +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/weatherapi`: Weather API HTTP adapter.
|
||||
- `internal/adapters/scriptorium`: Scriptorium subprocess adapter.
|
||||
- `internal/forecast`: normalized bundle types and deterministic forecast
|
||||
derivation.
|
||||
- `internal/report`: report definitions, valid periods, batches, output names,
|
||||
and comparison declarations.
|
||||
- `internal/briefing`: report-specific briefing package builders.
|
||||
- `internal/changes`: structured Recent Changes comparison.
|
||||
- `internal/promptinput`: Scriptorium `data_package` construction and
|
||||
validation.
|
||||
- `internal/state`: filesystem paths, atomic JSON writes, metadata, lookup, and
|
||||
inspection support.
|
||||
- `internal/timeutil`: clock, date, timezone, and period helpers.
|
||||
- `docs`: user, operator, developer, integration, internal, policy, and roadmap
|
||||
documentation.
|
||||
- `examples`: maintained copyable examples.
|
||||
|
||||
## 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/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 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 dependency:
|
||||
|
||||
- `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 or Scriptorium services.
|
||||
|
||||
Preferred test patterns:
|
||||
|
||||
- fake command runners for subprocess behavior;
|
||||
- `httptest.Server` for Weather API behavior;
|
||||
- filesystem temp directories for state behavior;
|
||||
- deterministic clocks for report periods and RunIDs;
|
||||
- table tests for config validation, CLI parsing, period resolution, and
|
||||
threshold behavior.
|
||||
|
||||
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 or Scriptorium contract changes;
|
||||
- `docs/roadmap/` only for unimplemented or deferred work.
|
||||
|
||||
Non-roadmap docs must describe implemented behavior only.
|
||||
@@ -1,356 +1,207 @@
|
||||
# Go Project Documentation Policy
|
||||
# Documentation Policy
|
||||
|
||||
## Purpose
|
||||
|
||||
Project documentation must help four audiences:
|
||||
|
||||
1. users who need to run the application;
|
||||
2. administrators/operators who need to configure and operate it;
|
||||
3. developers who need to understand and change it safely;
|
||||
4. LLM coding agents that need clear scope, boundaries, and invariants.
|
||||
|
||||
Docs should be accurate, concise, task-oriented, and organized by audience. Prefer links to canonical docs over repetition.
|
||||
This policy assigns each Weatherreporter documentation topic to one canonical
|
||||
owner. Its goal is to keep documentation accurate, concise, discoverable, and
|
||||
resistant to drift for users, operators, developers, integrators, maintainers,
|
||||
and coding agents.
|
||||
|
||||
## Core Rules
|
||||
|
||||
### 1. Keep docs concise
|
||||
|
||||
Each document should cover a defined scope and only the essentials for that scope.
|
||||
|
||||
Avoid:
|
||||
- long background explanations;
|
||||
- repeated reference material;
|
||||
- implementation detail in user-facing docs;
|
||||
- aspirational language outside roadmap docs;
|
||||
- verbose examples where one minimal example is clearer.
|
||||
|
||||
### 2. Document only implemented behavior outside roadmap files
|
||||
|
||||
Unimplemented, planned, aspirational, experimental, or future work may be described only under:
|
||||
|
||||
- `docs/roadmap/`
|
||||
|
||||
No other documentation file, including `README.md`, should describe code, features, modules, stages, commands, config fields, or behaviors that do not currently exist.
|
||||
|
||||
If a feature is partial, non-roadmap docs may describe only the implemented portion and its current boundary.
|
||||
|
||||
### 3. Use canonical homes
|
||||
|
||||
Each type of information should have one canonical location.
|
||||
|
||||
Canonical homes:
|
||||
|
||||
- project purpose and quickstart: `README.md`
|
||||
- development principles: `docs/policy/architecture.md`
|
||||
- configuration reference: `docs/config.md`
|
||||
- CLI reference: `docs/cli.md`
|
||||
- operations and recovery: `docs/operations.md`
|
||||
- troubleshooting: `docs/troubleshooting.md`
|
||||
- implemented internals: `docs/internal/`
|
||||
- future work: `docs/roadmap/`
|
||||
- contributor workflow: `docs/policy/development.md`
|
||||
- copyable examples: `examples/`
|
||||
|
||||
Other files should summarize briefly and link to the canonical source.
|
||||
|
||||
### 4. Keep examples real
|
||||
|
||||
Examples should be valid, maintained, and free of secrets.
|
||||
|
||||
Where practical:
|
||||
- example configs should load successfully;
|
||||
- example commands should match real CLI syntax;
|
||||
- important examples should be covered by tests.
|
||||
|
||||
## Documentation Profiles
|
||||
|
||||
All projects require:
|
||||
|
||||
- `README.md`
|
||||
- `docs/policy/architecture.md`
|
||||
|
||||
Additional docs depend on the project.
|
||||
|
||||
### Small library
|
||||
|
||||
Recommended:
|
||||
- `docs/policy/development.md`, if contributor conventions are non-obvious
|
||||
|
||||
### Simple CLI
|
||||
|
||||
Required:
|
||||
- `docs/cli.md`
|
||||
|
||||
Recommended:
|
||||
- `docs/policy/development.md`
|
||||
|
||||
### Config-driven CLI
|
||||
|
||||
Required:
|
||||
- `docs/cli.md`
|
||||
- `docs/config.md`
|
||||
|
||||
Recommended:
|
||||
- `examples/`
|
||||
- `docs/policy/development.md`
|
||||
|
||||
### Stateful or operator-facing application
|
||||
|
||||
Required:
|
||||
- `docs/cli.md`, if CLI-based
|
||||
- `docs/config.md`, if config-driven
|
||||
- `docs/operations.md`
|
||||
|
||||
Recommended:
|
||||
- `docs/troubleshooting.md`
|
||||
- `examples/`
|
||||
- `docs/policy/development.md`
|
||||
|
||||
### Modular, staged, service-oriented, or orchestration application
|
||||
|
||||
Required:
|
||||
- `docs/cli.md`, if CLI-based
|
||||
- `docs/config.md`, if config-driven
|
||||
- `docs/operations.md`
|
||||
- `docs/internal/`
|
||||
- `docs/policy/development.md`
|
||||
|
||||
Recommended:
|
||||
- `docs/troubleshooting.md`
|
||||
- validated examples under `examples/`
|
||||
|
||||
## Required Documents
|
||||
|
||||
### README.md
|
||||
|
||||
**Audience:** users, administrators, operators
|
||||
|
||||
The README is the outward-facing project orientation page.
|
||||
|
||||
It should include, in order:
|
||||
|
||||
1. concise description;
|
||||
2. elevator pitch;
|
||||
3. shortest useful command or usage example;
|
||||
4. links to targeted docs.
|
||||
|
||||
The README should be short. It is not a manual.
|
||||
|
||||
The “shortest useful command” means the simplest command that performs the project’s core use case. (It does not mean `app --help`.)
|
||||
|
||||
### docs/policy/architecture.md
|
||||
|
||||
**Audience:** developers, LLM coding agents
|
||||
|
||||
`docs/policy/architecture.md` is required for every project.
|
||||
|
||||
It is an inward-facing development policy document. It should describe how the project is intended to be built and changed.
|
||||
|
||||
It should include:
|
||||
|
||||
- project shape;
|
||||
- core design principles;
|
||||
- package and boundary philosophy;
|
||||
- state/persistence philosophy, if applicable;
|
||||
- external integration philosophy, if applicable;
|
||||
- error-handling and logging principles;
|
||||
- testing expectations;
|
||||
- documentation expectations;
|
||||
- architectural invariants;
|
||||
- explicit non-goals, if useful.
|
||||
|
||||
For small projects, this file may be brief. It may simply state that the project is intentionally narrow, monolithic, and dependency-light.
|
||||
|
||||
### docs/policy/development.md
|
||||
|
||||
**Audience:** developers, LLM coding agents
|
||||
|
||||
Required for projects maintained by humans and LLM coding agents.
|
||||
|
||||
It should include:
|
||||
|
||||
- repository layout;
|
||||
- build/test commands;
|
||||
- coding conventions;
|
||||
- dependency policy;
|
||||
- how to add config fields;
|
||||
- how to add CLI flags;
|
||||
- how to add stages/modules/adapters, if applicable;
|
||||
- how to update examples;
|
||||
- documentation update expectations.
|
||||
|
||||
### docs/config.md
|
||||
|
||||
**Audience:** administrators, operators, advanced users
|
||||
|
||||
Required for applications with configuration files.
|
||||
|
||||
It should include, in order:
|
||||
|
||||
1. config file locations and discovery precedence;
|
||||
2. minimal working config;
|
||||
3. production-oriented config;
|
||||
4. full configuration reference;
|
||||
5. secrets handling, if applicable;
|
||||
6. links to maintained examples.
|
||||
|
||||
The full configuration reference should be canonical.
|
||||
|
||||
### docs/cli.md
|
||||
|
||||
**Audience:** users, administrators, operators
|
||||
|
||||
Required for CLI applications.
|
||||
|
||||
It should include, in order:
|
||||
|
||||
1. shortest useful command;
|
||||
2. command overview;
|
||||
3. complete flag reference;
|
||||
4. common workflows;
|
||||
5. diagnostic or recovery commands, if applicable.
|
||||
|
||||
Explain when commands are useful, not just their syntax.
|
||||
|
||||
### docs/operations.md
|
||||
|
||||
**Audience:** administrators, operators
|
||||
|
||||
Required for applications that maintain state, support resume behavior, run multiple stages, write durable artifacts, use remote storage, or require recovery procedures.
|
||||
|
||||
It should cover:
|
||||
|
||||
- normal workflow;
|
||||
- filesystem layout;
|
||||
- remote storage layout, if applicable;
|
||||
- logs and manifests;
|
||||
- resume/retry behavior;
|
||||
- cleanup behavior;
|
||||
- archive/backup behavior;
|
||||
- safe recovery procedures;
|
||||
- operational caveats.
|
||||
|
||||
### docs/troubleshooting.md
|
||||
|
||||
**Audience:** administrators, operators
|
||||
|
||||
Recommended once recurring failure modes exist.
|
||||
|
||||
Each entry should include:
|
||||
|
||||
- symptom;
|
||||
- likely cause;
|
||||
- diagnostic command or inspection step;
|
||||
- safe fix;
|
||||
- relevant links.
|
||||
|
||||
### docs/internal/
|
||||
|
||||
**Audience:** developers, LLM coding agents
|
||||
|
||||
Required for modular, staged, service-oriented, or orchestration projects.
|
||||
|
||||
This directory describes implemented internal components. It is not the roadmap.
|
||||
|
||||
Use one file per major component where useful.
|
||||
|
||||
Each component doc should include:
|
||||
|
||||
1. purpose;
|
||||
2. inputs and outputs;
|
||||
3. boundaries;
|
||||
4. config fields used;
|
||||
5. external adapters used;
|
||||
6. state or manifest behavior, if applicable;
|
||||
7. skip/resume behavior, if applicable;
|
||||
8. failure behavior;
|
||||
9. tests to inspect before changing;
|
||||
10. architectural invariants.
|
||||
|
||||
### docs/roadmap/
|
||||
|
||||
**Audience:** maintainers, developers, LLM coding agents
|
||||
|
||||
This is the only place for planned, future, aspirational, experimental, or unimplemented work.
|
||||
|
||||
Roadmap docs should clearly distinguish:
|
||||
|
||||
- proposed work;
|
||||
- accepted plans;
|
||||
- deferred ideas;
|
||||
- rejected ideas;
|
||||
- implementation prompts or task breakdowns, if useful.
|
||||
|
||||
Roadmap docs should not be confused with current behavior.
|
||||
|
||||
### docs/integrations/
|
||||
|
||||
**Audience:** developers, LLM coding agents
|
||||
|
||||
Required for projects that depend on external CLIs, APIs, services, protocols, or file formats where the integration contract is important to maintain.
|
||||
|
||||
This directory contains concise, versioned reference notes for external integration contracts. It should document only the parts of the external system that this project actually uses.
|
||||
|
||||
Use one file per integration where useful.
|
||||
|
||||
## Examples Directory
|
||||
|
||||
Projects with non-trivial configuration or workflows should include `examples/`.
|
||||
|
||||
Useful examples include:
|
||||
|
||||
- minimal working config;
|
||||
- production-oriented config;
|
||||
- full annotated config;
|
||||
- local development config;
|
||||
- remote/object-storage config;
|
||||
- minimal session/input file.
|
||||
|
||||
Examples should be valid, maintained, tested when practical, and linked from relevant docs.
|
||||
|
||||
## Security and Privacy
|
||||
|
||||
Docs and examples must not include:
|
||||
|
||||
- real API keys;
|
||||
- tokens;
|
||||
- passwords;
|
||||
- private keys;
|
||||
- private environment dumps;
|
||||
- sensitive user data;
|
||||
- raw private transcripts;
|
||||
- private infrastructure details unless intentionally public.
|
||||
|
||||
Document secret-handling mechanisms, not actual secret values.
|
||||
|
||||
## Maintenance Rules
|
||||
|
||||
When docs change, verify the affected behavior.
|
||||
|
||||
Where practical:
|
||||
|
||||
- load example config files in tests;
|
||||
- test CLI examples or command parser behavior;
|
||||
- validate documented flags against real flags;
|
||||
- remove stale references;
|
||||
- update links after renames;
|
||||
- keep roadmap content out of non-roadmap docs.
|
||||
|
||||
If documentation and code disagree, fix the documentation and/or open a roadmap item; do not leave aspirational behavior in current-behavior docs.
|
||||
|
||||
Documentation is complete only when it matches the current code.
|
||||
|
||||
## Documentation Change Checklist
|
||||
|
||||
Before merging documentation changes, verify:
|
||||
|
||||
- README is concise and orientation-focused.
|
||||
- `docs/policy/architecture.md` describes development principles.
|
||||
- Future work appears only under `docs/roadmap/`.
|
||||
- User-facing docs avoid unnecessary internals.
|
||||
- Developer-facing docs preserve boundaries and invariants.
|
||||
- Config examples match the schema.
|
||||
- CLI examples match real commands and flags.
|
||||
- Defaults appear in the canonical config reference.
|
||||
- No secrets or private data are included.
|
||||
- Links are accurate.
|
||||
### One Canonical Documentation Owner
|
||||
|
||||
Each authoritative fact belongs in one canonical document or documentation
|
||||
area. A non-owning document may give a short, stable summary for orientation,
|
||||
but it must link to the canonical owner instead of maintaining a second
|
||||
definition.
|
||||
|
||||
Volatile details include commands, flags, configuration fields and defaults,
|
||||
report and module IDs, schemas, file names, paths, status and exit behavior,
|
||||
retry behavior, and runtime guarantees. If readers could reasonably treat a
|
||||
statement as a contract, its exact documentation belongs with the owner named
|
||||
in this policy.
|
||||
|
||||
Executable sources of truth and documentation owners serve different purposes.
|
||||
Code, schemas, and embedded assets determine runtime behavior. The canonical
|
||||
document owns the corresponding explanation or reference for readers. Both may
|
||||
necessarily express the same contract, but other documentation should summarize
|
||||
and link rather than create another complete reference. When implementation and
|
||||
documentation disagree, verify the intended behavior and update them together.
|
||||
|
||||
### Current State, Decisions, And Future Work
|
||||
|
||||
Outside `docs/roadmap/`, documentation describes implemented behavior only.
|
||||
Partial features may be described only to their implemented boundary.
|
||||
|
||||
An accepted architecture decision may describe an approved direction before it
|
||||
is implemented, but acceptance is not evidence that the behavior exists.
|
||||
Current-state documents change when the implementation lands. Temporary
|
||||
roadmaps own future work, sequencing, and implementation status; they do not
|
||||
replace durable policies, decisions, or current contracts.
|
||||
|
||||
### Audience And Detail
|
||||
|
||||
Write for the document's stated audience and include only the detail needed for
|
||||
its owned topic. User and operator documentation should not expose incidental
|
||||
implementation detail. Developer documentation should link to user-facing and
|
||||
external contracts instead of restating them.
|
||||
|
||||
### Links
|
||||
|
||||
Use descriptive link text and repository-relative links for repository
|
||||
documents. Link to the canonical owner rather than to a duplicate summary.
|
||||
Check every added or changed link, and repair or remove links when their target
|
||||
moves or is retired.
|
||||
|
||||
### Examples And Code Fences
|
||||
|
||||
Complete copyable files belong in `examples/` when maintained examples exist.
|
||||
Documentation may use the smallest illustrative snippet needed for its owned
|
||||
topic, but should link to a maintained example instead of embedding a second
|
||||
complete copy.
|
||||
|
||||
Examples must be valid, secret-free, and tested where practical. Commands,
|
||||
flags, configuration, imports, and Go snippets must match implemented behavior.
|
||||
Use a language tag on fenced code blocks, and identify fragments that are
|
||||
illustrative rather than directly runnable.
|
||||
|
||||
### Security And Privacy
|
||||
|
||||
Documentation and examples must not contain real credentials, private keys,
|
||||
private environment dumps, sensitive source material, or private
|
||||
infrastructure details unless intentionally public. Document secret-handling
|
||||
mechanisms, not secret values.
|
||||
|
||||
## Canonical Ownership
|
||||
|
||||
| Topic | Canonical owner | Owned content | Content owned elsewhere |
|
||||
| --- | --- | --- | --- |
|
||||
| Product orientation and minimal quickstart | `README.md` | What Weatherreporter is, why it is useful, one shortest successful invocation, and links onward. | Complete command reference, configuration reference, operational procedures, architecture, and implementation detail. |
|
||||
| Contributor workflow and package inventory | `docs/development.md` | Repository layout, local workflow, validation commands, coding conventions, task-specific change guidance, dependency workflow, and repository hygiene. | Architectural invariants, user-facing contracts, detailed subsystem behavior, and future work. |
|
||||
| Current application architecture | `docs/policy/architecture.md` | System shape, normative ownership, dependency direction, package boundaries, invariants, safety properties, and non-goals. | Concrete implementation mechanics, contributor procedures, decision history, and future work. |
|
||||
| Documentation organization | `docs/policy/documentation.md` | Documentation ownership, audience boundaries, maintenance rules, and document lifecycle. | Application architecture and runtime behavior. |
|
||||
| Testing policy | `docs/policy/testing.md` | Test philosophy, risk-based sufficiency, stable test boundaries, doubles, coverage guidance, regression policy, and criteria for adding, rewriting, or deleting tests. | Subsystem behavior, application contracts, subsystem-specific test inventories, and implementation plans. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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, Scriptorium, Distributor, external formats and protocols, durable logical paths and schemas, compatibility behavior, and upstream or downstream responsibilities. | Physical runtime placement and lifecycle, internal transformations, CLI syntax, and configuration defaults. |
|
||||
| Internal subsystem behavior | `docs/internal/` | Implementation flow, internal collaborators and state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, user-facing contracts, external schemas, operator procedures, and future package plans. |
|
||||
| Architectural decision history | `docs/adr/`, when repository-local decisions require records | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, and task sequencing. |
|
||||
| Temporary feature roadmaps | `docs/roadmap/`, while planned work needs coordination | Proposed, accepted, deferred, or rejected work; sequencing; gates; implementation status; and task breakdowns. | Implemented behavior reference and durable decision rationale. |
|
||||
| Complete copyable artifacts | `examples/` | Maintained configuration and other files intended to be copied or run. | Field-by-field reference, command reference, and prose explanation. |
|
||||
|
||||
Conditional owners do not require placeholder files or directories. If
|
||||
Weatherreporter introduces a new public API, consumer interface, release
|
||||
process, or other durable documentation responsibility, update this policy to
|
||||
assign its canonical owner when that responsibility is introduced.
|
||||
|
||||
## Boundary Rules
|
||||
|
||||
### Orientation, Architecture, And Internals
|
||||
|
||||
The README owns product orientation. The development policy routes contributors
|
||||
and owns the concise current package inventory. Architecture owns normative
|
||||
structure and invariants. Focused internal documents own implementation
|
||||
behavior. These documents may link to one another but must not maintain
|
||||
parallel package or behavior references.
|
||||
|
||||
### Commands, Configuration, Operations, And Troubleshooting
|
||||
|
||||
CLI documentation answers how to invoke Weatherreporter and what its command
|
||||
interface does. Configuration documentation answers what settings mean.
|
||||
Operations answers what happens to runtime state and how to operate or recover
|
||||
the application. Troubleshooting starts from a symptom and leads to diagnosis
|
||||
and a safe fix.
|
||||
|
||||
When a workflow crosses these topics, place the complete procedure with the
|
||||
document that owns the task and link to the other contracts. Do not duplicate
|
||||
complete flag, field, or path references to make a workflow self-contained.
|
||||
|
||||
### Templates, Integrations, And Implementation
|
||||
|
||||
Template documentation defines the maintainer-facing rendering surface.
|
||||
Integration documentation defines externally observable shapes, logical paths,
|
||||
protocols, and compatibility behavior. Internal documentation explains how
|
||||
Weatherreporter produces, transforms, or consumes those contracts.
|
||||
|
||||
Internal documents may name a command, field, template value, path, or protocol
|
||||
to identify a dependency, but must link to its canonical documentation for the
|
||||
complete definition.
|
||||
|
||||
### Executable Authority
|
||||
|
||||
CLI parsing and help generation are the executable authority for accepted
|
||||
commands and flags. Configuration structs, defaults, loading, and validation
|
||||
are the executable authority for configuration behavior. Schemas and embedded
|
||||
assets are the executable authority for validated formats and template
|
||||
execution. Tests protect selected contracts and invariants but do not become a
|
||||
second documentation reference merely by asserting them.
|
||||
|
||||
Canonical documentation must be checked against these authorities whenever the
|
||||
corresponding behavior changes.
|
||||
|
||||
### Security Topics
|
||||
|
||||
This policy owns what documentation and examples may contain. Architecture owns
|
||||
application security boundaries and invariants. Configuration owns
|
||||
credential-supply mechanisms. Operations owns permissions and handling of
|
||||
sensitive runtime artifacts. Integration documents own consumer-visible
|
||||
security contracts. Internal documents own implementation mechanisms only.
|
||||
|
||||
## Architecture Decision Records
|
||||
|
||||
Use sequentially numbered ADR filenames such as
|
||||
`0001-record-architecture-decisions.md`. Follow the lightweight Nygard format:
|
||||
|
||||
1. title;
|
||||
2. status;
|
||||
3. date;
|
||||
4. context;
|
||||
5. decision;
|
||||
6. alternatives considered;
|
||||
7. consequences.
|
||||
|
||||
Use one of these statuses:
|
||||
|
||||
- **Proposed:** the decision is under consideration and may change;
|
||||
- **Accepted:** the decision is approved, whether or not implementation is
|
||||
complete;
|
||||
- **Rejected:** the proposed decision was considered and not adopted;
|
||||
- **Superseded:** a later accepted ADR replaces the accepted decision.
|
||||
|
||||
A proposed ADR transitions to Accepted or Rejected. An Accepted ADR transitions
|
||||
to Superseded only when a later Accepted ADR replaces it. An ADR may be created
|
||||
as Accepted when the decision has already been made.
|
||||
|
||||
Treat the decision content of an Accepted ADR as immutable. A changed decision
|
||||
requires a later ADR rather than a rewrite of the accepted record. A Superseded
|
||||
ADR must link to its replacement, and the replacement must link back. Rejected
|
||||
architectural alternatives belong in the ADR; rejected feature ideas belong in
|
||||
a roadmap when they need to be retained.
|
||||
|
||||
## Document Lifecycle
|
||||
|
||||
Create durable current-state documentation with the implementation it
|
||||
describes. Update its canonical owner in the same change when behavior changes.
|
||||
If ownership moves, remove the old definition and leave a link where navigation
|
||||
remains useful.
|
||||
|
||||
Roadmaps are temporary coordination documents. When their work is complete,
|
||||
record completion, move any still-useful decisions or contracts to their
|
||||
durable owners, update incoming links, and archive or remove the roadmap
|
||||
according to repository practice. Do not preserve completed roadmaps as a
|
||||
second current-state reference.
|
||||
|
||||
Before completing documentation work:
|
||||
|
||||
- verify affected behavior and examples;
|
||||
- check commands, flags, fields, defaults, schemas, paths, and identifiers
|
||||
against their implementation;
|
||||
- keep unimplemented behavior in a roadmap, subject to the ADR exception;
|
||||
- validate links and fenced examples;
|
||||
- confirm non-owning documents summarize and link rather than redefine;
|
||||
- remove stale or unsupported claims; and
|
||||
- confirm that no secrets or sensitive private data were added.
|
||||
|
||||
337
docs/policy/testing.md
Normal file
337
docs/policy/testing.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# Testing Policy
|
||||
|
||||
## Purpose
|
||||
|
||||
Our tests exist to make **incorrect changes expensive and correct changes
|
||||
cheap**.
|
||||
|
||||
We do not optimize for test count, line coverage, exhaustive isolation, or the
|
||||
fewest possible tests. We optimize for sufficient confidence in important
|
||||
behavior while imposing as little unnecessary friction as possible on future
|
||||
development.
|
||||
|
||||
## Every Test Has A Cost
|
||||
|
||||
Every test has an immediate cost and a continuing lifetime cost. It must be
|
||||
written, reviewed, executed, understood, diagnosed when it fails, updated when
|
||||
legitimate behavior changes, and maintained as fixtures and dependencies
|
||||
evolve.
|
||||
|
||||
Tests also create cognitive and architectural friction. They can constrain
|
||||
refactoring, duplicate policy, slow feedback, add noise to failures, and cause
|
||||
harmless implementation changes to require unrelated suite edits.
|
||||
|
||||
A test is warranted when the confidence it provides justifies those costs.
|
||||
Apply that judgment at two levels:
|
||||
|
||||
1. **Per test:** What realistic defect does this test detect, how consequential
|
||||
would it be, and is that protection worth the test's lifetime cost?
|
||||
2. **Across the suite:** Does this collection provide materially more
|
||||
confidence than a smaller, simpler suite would?
|
||||
|
||||
Prefer a lean suite that provides sufficient confidence in the risks that
|
||||
matter without redundant or low-value tests. Some friction is intentional:
|
||||
tests should make dangerous changes, such as corrupting state, breaking
|
||||
compatibility, violating security boundaries, or reintroducing subtle defects,
|
||||
require deliberate review. They should not make ordinary internal changes
|
||||
needlessly expensive.
|
||||
|
||||
Maintenance cost is not a reason to omit testing by default. When omitting a
|
||||
plausible test, be able to explain why the protected failure is low-risk,
|
||||
already covered, obvious, reversible, or cheaper to detect elsewhere. Favor
|
||||
testing when failure would be consequential, subtle, or difficult to observe.
|
||||
|
||||
## Default Testing Style
|
||||
|
||||
Use a classical or Detroit-style approach:
|
||||
|
||||
- Test observable behavior, resulting state, contracts, and invariants.
|
||||
- Use real internal collaborators when they are fast and deterministic.
|
||||
- Use fakes, stubs, or mocks primarily at expensive, nondeterministic,
|
||||
destructive, or external boundaries.
|
||||
- Prefer package-level behavioral tests over tests coupled to private helpers
|
||||
or internal call sequences.
|
||||
- Test exact collaborator interactions only when the interaction itself is a
|
||||
requirement.
|
||||
|
||||
Weatherreporter's important seams include clocks, subprocesses, 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, Scriptorium, 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, subprocess, 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 Scriptorium, 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 command runners for Scriptorium 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.
|
||||
@@ -1,23 +1,23 @@
|
||||
# Future Roadmap
|
||||
|
||||
This roadmap contains project work that is not implemented. Current behavior is
|
||||
documented outside `docs/roadmap/`.
|
||||
This roadmap contains future work only. Each section identifies its planning
|
||||
status; current behavior is documented outside `docs/roadmap/`.
|
||||
|
||||
## Deferred: Automatic Storm Monitoring
|
||||
## Automatic Storm Monitoring
|
||||
|
||||
Manual Storm Report generation is implemented through
|
||||
`weatherreporter generate storm --start TIME --end TIME`. Automatic storm-event
|
||||
evaluation remains deferred.
|
||||
Status: Proposed and unimplemented.
|
||||
|
||||
Proposed direction:
|
||||
Manual Storm Report generation is implemented; see the [CLI reference](../cli.md).
|
||||
Automatic storm-event evaluation remains unimplemented.
|
||||
|
||||
1. detect candidate events deterministically from alerts, forecast discussion,
|
||||
weather story context when available, hourly thresholds, and material
|
||||
forecast changes;
|
||||
2. evaluate candidates through Scriptorium or another narrow evaluator adapter;
|
||||
3. persist storm lifecycle state;
|
||||
4. generate or update Storm Reports only when a meaningful event is present;
|
||||
5. suppress ordinary low-impact thunder or rain chances.
|
||||
Possible direction:
|
||||
|
||||
1. Detect candidate storm events from alerts, forecast discussion, weather
|
||||
story context, hourly thresholds, and material forecast changes.
|
||||
2. Evaluate candidates through Scriptorium or another narrow evaluator adapter.
|
||||
3. Persist storm lifecycle state.
|
||||
4. Generate or update Storm Reports only when a meaningful event is present.
|
||||
5. Suppress ordinary low-impact thunder or rain chances.
|
||||
|
||||
Possible lifecycle states:
|
||||
|
||||
@@ -28,59 +28,117 @@ Possible lifecycle states:
|
||||
- `deescalating`
|
||||
- `resolved`
|
||||
|
||||
Acceptance criteria before implementation:
|
||||
Before implementation, the design must preserve scheduled report behavior,
|
||||
manual Storm Report generation, inspectable evaluator failures, and fixture
|
||||
coverage for deterministic candidate detection.
|
||||
|
||||
- scheduled reports and manual Storm Reports remain stable;
|
||||
- candidate detection has fixture coverage;
|
||||
- evaluator failures are inspectable and do not create noisy report output;
|
||||
- manual Storm Report generation remains available.
|
||||
## Future Report Types
|
||||
|
||||
## Deferred: Alternate Runtime Integrations
|
||||
Status: Proposed and unimplemented.
|
||||
|
||||
These ideas are not current behavior:
|
||||
Possible future report types:
|
||||
|
||||
- native LLM client inside `weatherreporter`;
|
||||
- database-backed state;
|
||||
- public HTTP API;
|
||||
- multi-location selection;
|
||||
- daemon mode;
|
||||
- multi-user authorization;
|
||||
- plugin system.
|
||||
- a short-fuse planning report distinct from the implemented Hourly Report, if
|
||||
a separate product is needed
|
||||
- event-specific reports with stable event IDs
|
||||
- storm review or yesterday-style reports using historical observations
|
||||
- archive-focused report variants if generated report history becomes a
|
||||
first-class product
|
||||
|
||||
New reports should preserve the boundaries documented in the [report registry
|
||||
internals](../internal/report-registry.md).
|
||||
|
||||
## Future Modules
|
||||
|
||||
Status: Proposed and unimplemented.
|
||||
|
||||
Possible future modules:
|
||||
|
||||
- `hourly_table` for compact valid-period hourly facts
|
||||
- `forecast_delta` if a separate stanza is useful beyond current Recent
|
||||
Changes
|
||||
- `weekend_planning` if weekend-specific planning guidance needs a dedicated
|
||||
deterministic stanza
|
||||
- `storm_window_summary` if manual or automatic Storm Reports need a dedicated
|
||||
prompt-facing storm-window module
|
||||
- separate AFD section aliases, such as `afd_key_messages`,
|
||||
`afd_short_term_text`, and `afd_long_term_text`, if separate stanzas prove
|
||||
more useful than `area_forecast_discussion.options.sections`
|
||||
- SPC, radar, QPF, snow/rain total, or historical-observation modules once
|
||||
upstream sources and report requirements exist
|
||||
|
||||
QPF fields such as `measurable_qpf_total_in` and `max_hourly_qpf_in` should
|
||||
remain omitted until a real upstream quantitative precipitation source is
|
||||
represented in `CollectedFacts`.
|
||||
|
||||
Future module work should preserve the boundaries documented in [fact
|
||||
contracts](../internal/facts.md), [module internals](../internal/module.md), and
|
||||
[briefing internals](../internal/briefing.md):
|
||||
|
||||
- keep upstream collection in app orchestration
|
||||
- keep upstream collection out of modules
|
||||
- keep broad reusable calculations in `DerivedFacts`
|
||||
- keep prompt-facing field shape inside module builders
|
||||
- use typed options for configurable module behavior
|
||||
- keep module snapshots structured and deterministic for Recent Changes
|
||||
|
||||
## Distributor Notification Enhancements
|
||||
|
||||
Status: Proposed and unimplemented.
|
||||
|
||||
Single-report and batch Distributor notification are implemented. Current
|
||||
behavior is documented in the [Distributor adapter guide](../internal/distributor-adapter.md),
|
||||
[Distributor integration guides](../integrations/distributor/), and
|
||||
[operations guide](../operations.md). The following enhancements remain
|
||||
unimplemented:
|
||||
|
||||
- `failure_policy: warn`
|
||||
- uploading metadata, module snapshots, data packages, or preflight artifacts
|
||||
- durable upload retry queues
|
||||
- distributor-specific CLI flags
|
||||
- distributor workspace scanning
|
||||
- destination routing, Markdown-to-HTML transformation, public URLs, or nginx
|
||||
layout inside weatherreporter
|
||||
|
||||
Any distributor enhancement should preserve the adapter boundary:
|
||||
weatherreporter selects explicit generated files and submits source bundles,
|
||||
while distributor owns destination routing and publication behavior.
|
||||
|
||||
## Alternate Runtime Integrations
|
||||
|
||||
Status: Proposed and unimplemented.
|
||||
|
||||
These ideas remain unimplemented:
|
||||
|
||||
- native LLM client inside `weatherreporter`
|
||||
- database-backed state
|
||||
- public HTTP API
|
||||
- multi-location selection
|
||||
- daemon mode
|
||||
- multi-user authorization
|
||||
- plugin system
|
||||
- dynamic module loading
|
||||
- user-defined module code
|
||||
- YAML-defined module schemas
|
||||
- module-owned Weather API fetching
|
||||
|
||||
Each item needs its own design note before implementation. Non-roadmap docs
|
||||
must not describe these as available behavior.
|
||||
|
||||
## Deferred: Cleanup Refactors
|
||||
## Deferred Refactors
|
||||
|
||||
The initial cleanup pass intentionally left these refactors out because the
|
||||
current implementation does not yet make them worth the added abstraction.
|
||||
Status: Deferred.
|
||||
|
||||
Revisit these only when new source types, report types, operational
|
||||
requirements, or recurring maintenance costs make the duplication materially
|
||||
more expensive:
|
||||
These refactors should remain deferred until new requirements or recurring
|
||||
maintenance costs make the added abstraction worthwhile:
|
||||
|
||||
- Weather API optional-source specification/helper refactor: consider when
|
||||
additional Weather API sources make per-source fan-out, policy handling, and
|
||||
provenance wiring repetitive enough to obscure adapter behavior.
|
||||
- Broad briefing weather-signal consolidation: consider when multiple briefing
|
||||
builders repeatedly derive the same weather signals and tests begin to need
|
||||
coordinated fixture updates.
|
||||
- Generic workflow engine: defer unless generation, inspection, recovery, or
|
||||
future background workflows gain enough shared step semantics to justify a
|
||||
declared execution model.
|
||||
- Plugin architecture: defer until there is a concrete external extension
|
||||
contract and at least one implemented extension point.
|
||||
- Cobra migration: defer while the standard-library CLI remains small,
|
||||
explicit, and covered by parser tests.
|
||||
- Manifest, resume, or progress system: defer until operators need resumable
|
||||
runs, checkpoint recovery, or richer audit trails than the current durable
|
||||
artifacts and metadata provide.
|
||||
- Global test helper package: defer while package-local helpers keep tests
|
||||
clear; revisit only if setup duplication starts to hide behavior.
|
||||
- Logging subsystem: defer until there are recurring operator diagnostics that
|
||||
cannot be handled with current errors, metadata, inspection commands, and
|
||||
artifact output.
|
||||
- broad briefing weather-signal consolidation
|
||||
- generic workflow engine
|
||||
- Cobra migration
|
||||
- manifest, resume, or progress system
|
||||
- global test helper package
|
||||
- logging subsystem
|
||||
|
||||
Any future implementation should preserve the existing public CLI, artifact
|
||||
paths, report identities, and adapter boundaries unless a separate roadmap
|
||||
explicitly changes them.
|
||||
paths, report identities, module boundaries, and adapter boundaries unless a
|
||||
separate roadmap explicitly changes them.
|
||||
|
||||
327
docs/roadmap/promptkit.md
Normal file
327
docs/roadmap/promptkit.md
Normal file
@@ -0,0 +1,327 @@
|
||||
# Promptkit Migration Roadmap
|
||||
|
||||
Status: Accepted migration policy; the migration itself is unimplemented.
|
||||
|
||||
## Purpose
|
||||
|
||||
This roadmap defines the scope and desired end state for replacing the
|
||||
external Scriptorium CLI integration with the Promptkit Go library. The
|
||||
migration is not yet implemented. Current Scriptorium behavior remains
|
||||
documented in the [Scriptorium integration guide](../integrations/scriptorium.md)
|
||||
until the replacement is complete.
|
||||
|
||||
A separate staged implementation plan will describe how to move from the
|
||||
current code to this target state. That plan should reference this roadmap
|
||||
rather than redefine its architectural decisions or scope.
|
||||
|
||||
## Desired End State
|
||||
|
||||
Status: Accepted target state; unimplemented.
|
||||
|
||||
Weatherreporter uses a pinned released version of
|
||||
`gitea.maximumdirect.net/eric/promptkit` as its in-process prompt preparation
|
||||
and LLM execution engine. The `scriptorium` executable, subprocess adapter,
|
||||
configuration, runtime dependency, and integration documentation have been
|
||||
removed.
|
||||
|
||||
The migration does not change weatherreporter's fundamental product behavior.
|
||||
Weather selection, forecast derivation, report periods, module construction,
|
||||
Recent Changes, generated-text interpretation, Markdown templates, durable
|
||||
state, inspection, output copies, and distributor notification remain owned by
|
||||
weatherreporter.
|
||||
|
||||
All report prompts and private response schemas are versioned application
|
||||
assets. Operators may configure Promptkit execution profiles without replacing
|
||||
the report-owned prompt and schema 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 prompt execution contract.
|
||||
Promptkit request, result, validation, error, profile, backend, and provider
|
||||
types do not leak into application orchestration, report definitions, domain
|
||||
packages, CLI summaries, state contracts, or distributor behavior.
|
||||
|
||||
## Goals
|
||||
|
||||
Status: Accepted migration scope; unimplemented.
|
||||
|
||||
- Remove the runtime dependency on the `scriptorium` executable.
|
||||
- Replace shell-free subprocess orchestration with typed in-process Promptkit
|
||||
preparation and execution.
|
||||
- Preserve the seven report definitions and their existing prompt IDs.
|
||||
- Preserve both direct-Markdown and generated-text-template report workflows.
|
||||
- Preserve deterministic module snapshots and structured Recent Changes.
|
||||
- Preserve context cancellation, actionable errors, secret redaction, and
|
||||
inspectable failures.
|
||||
- Improve durable prompt provenance with prompt, input, profile, model,
|
||||
validation, usage, and timing metadata.
|
||||
- Keep content-rich prompt and response diagnostics separate from routine
|
||||
metadata and CLI output.
|
||||
- Keep tests offline and deterministic through injected Promptkit model
|
||||
clients and fixtures.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
Status: Accepted migration scope; unimplemented.
|
||||
|
||||
The migration will not:
|
||||
|
||||
- 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 to weatherreporter;
|
||||
- add automatic provider, validation, or capacity retries;
|
||||
- add concurrent report generation to the existing sequential batch workflow;
|
||||
- expose Promptkit types as a weatherreporter component contract;
|
||||
- keep a production-selectable Scriptorium/Promptkit dual-run mode; or
|
||||
- use an unpublished Promptkit commit, committed Go workspace, or committed
|
||||
local module replacement.
|
||||
|
||||
## Locked Decisions
|
||||
|
||||
Status: Accepted decisions for the unimplemented migration.
|
||||
|
||||
### Dependency And Versioning
|
||||
|
||||
- The initial integration will pin Promptkit `v0.3.0`.
|
||||
- Coordinated local development may temporarily use the sibling Promptkit
|
||||
checkout, but committed module metadata must reference the tagged release.
|
||||
- A future Promptkit upgrade requires an explicit review of the public engine,
|
||||
prompt/profile/schema formats, error identities, validation behavior, and
|
||||
outbound provider contract used by weatherreporter.
|
||||
|
||||
### Application Boundary
|
||||
|
||||
- Promptkit remains an adapter boundary even though it runs in process.
|
||||
- A weatherreporter-owned contract will represent preparation, execution,
|
||||
output formats, validation, usage, provenance, and neutral error categories.
|
||||
- The Promptkit adapter will map public Promptkit values into that contract at
|
||||
the boundary.
|
||||
- App orchestration and test fakes will depend on the weatherreporter contract,
|
||||
not on Promptkit.
|
||||
- Existing Scriptorium-specific generation mode names will be replaced with
|
||||
provider-neutral names.
|
||||
|
||||
### Prompt And Schema Ownership
|
||||
|
||||
- Weatherreporter will embed all report prompt definitions, prompt content,
|
||||
and private response schemas.
|
||||
- Prompt assets will remain separate files rather than inline Go strings.
|
||||
- The current Scriptorium prompt corpus will be retrieved before the
|
||||
implementation stage that establishes the embedded Promptkit assets.
|
||||
- The retrieved corpus will be reviewed and converted to the pinned Promptkit
|
||||
format without changing report intent or prompt IDs.
|
||||
- The four existing generated-text prompt fragments and schemas under
|
||||
`internal/reporttemplate` will be reconciled with that corpus rather than
|
||||
duplicated.
|
||||
- Direct-Markdown prompt assets for the three-day, weekend, and storm reports
|
||||
will become weatherreporter-owned assets.
|
||||
- Weatherreporter needs one centralized embedded prompt/schema source; it does
|
||||
not need Notarius's multi-module asset-flattening registry.
|
||||
|
||||
### Profiles, Backends, And Credentials
|
||||
|
||||
- Execution profiles remain operator-configurable rather than embedded report
|
||||
policy.
|
||||
- Configuration will support at most one external profile source: a profile
|
||||
directory or a single profile file.
|
||||
- Prompt definitions may provide their normal default profile, while
|
||||
weatherreporter may support an explicit configured profile selection.
|
||||
- Credential values remain in environment variables or file-backed
|
||||
environment secrets. Configuration contains only credential source names.
|
||||
- Provider credentials must not appear in logs, errors, CLI output, durable
|
||||
metadata, preparation artifacts, execution artifacts, or debug summaries.
|
||||
- Weatherreporter will not expose Promptkit's general backend registry as
|
||||
arbitrary application configuration.
|
||||
|
||||
### Engine Lifetime
|
||||
|
||||
- One Promptkit engine will be constructed per CLI invocation at the
|
||||
application composition boundary.
|
||||
- Single-report generation will use that engine for preparation and execution.
|
||||
- Morning and evening batches will share the same engine across every planned
|
||||
report.
|
||||
- Per-report orchestration will not construct its own default Promptkit engine.
|
||||
- Promptkit backend capacity state and HTTP transport will therefore be shared
|
||||
consistently for the invocation.
|
||||
|
||||
### Prompt Input
|
||||
|
||||
- Promptkit will continue to receive the curated `data_package` produced by
|
||||
`internal/promptinput`.
|
||||
- Weatherreporter will serialize the data package once, atomically persist
|
||||
those exact bytes, and supply the same bytes as a Promptkit inline artifact.
|
||||
- The managed data-package path may be supplied as non-secret artifact
|
||||
provenance.
|
||||
- Weatherreporter will not delegate unrestricted path loading to Promptkit's
|
||||
default file artifact reader.
|
||||
- The same immutable Promptkit request will be used for preparation and
|
||||
execution so the preflight and run inputs cannot diverge.
|
||||
|
||||
### Preparation And Execution
|
||||
|
||||
- Promptkit `Prepare` replaces the current Scriptorium render preflight.
|
||||
- Promptkit `Run` performs both Markdown and structured generated-text
|
||||
execution.
|
||||
- Promptkit basic validation will be used where appropriate for direct
|
||||
Markdown output.
|
||||
- Promptkit JSON Schema validation provides the provider-facing and first
|
||||
structured-output check for generated-text reports.
|
||||
- Weatherreporter's `internal/generatedtext` validation remains the final
|
||||
report-specific domain boundary.
|
||||
- Weatherreporter's `internal/reporttemplate` remains responsible for
|
||||
generated-text Markdown rendering.
|
||||
- Weatherreporter will atomically persist Promptkit output rather than asking
|
||||
the dependency to write managed report files.
|
||||
- The migration will not rely on Promptkit output repair. Promptkit v0.3.0's
|
||||
public engine validates in a single pass even when a prompt declares repair
|
||||
attempts.
|
||||
|
||||
## Durable Artifacts And Observability
|
||||
|
||||
Status: Accepted design constraints; unimplemented.
|
||||
|
||||
Routine durable artifacts should retain useful non-secret provenance without
|
||||
persisting full rendered prompts by default.
|
||||
|
||||
The preparation record should contain:
|
||||
|
||||
- prompt ID and version;
|
||||
- prompt definition hash;
|
||||
- rendered prompt hash;
|
||||
- input hashes;
|
||||
- selected profile and backend identity;
|
||||
- effective model identity;
|
||||
- output contract summary; and
|
||||
- preparation timing.
|
||||
|
||||
The execution record and run metadata should contain, when available:
|
||||
|
||||
- Promptkit run ID;
|
||||
- prompt ID, version, and hashes;
|
||||
- input hashes;
|
||||
- selected profile, backend, and model identity;
|
||||
- generated-content hash;
|
||||
- token usage;
|
||||
- start, end, and duration;
|
||||
- validation status and bounded diagnostics; and
|
||||
- the path of any separately persisted raw generated output.
|
||||
|
||||
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
|
||||
will be available only through an explicitly enabled debug mechanism. Debug
|
||||
artifacts must be documented as potentially sensitive, must not contain
|
||||
credentials, and must have a clear operator-owned retention policy.
|
||||
|
||||
## Failure Contract
|
||||
|
||||
Status: Accepted design constraints; unimplemented.
|
||||
|
||||
Promptkit returns a completed `RunResult` for output-validation failure but no
|
||||
partial result for operational preparation or execution errors. Weatherreporter
|
||||
will preserve that distinction.
|
||||
|
||||
- A preparation failure produces a redacted weatherreporter-owned failure
|
||||
receipt with report, RunID, prompt, stage, timing, and classified error
|
||||
context. It does not fabricate a Promptkit preparation result.
|
||||
- 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 failure retains the returned result, raw generated
|
||||
output, validation details, and safe provenance before the report fails.
|
||||
- A later weatherreporter generated-text decode, domain-validation, or template
|
||||
failure retains every raw and validated artifact reached before that stage.
|
||||
- Context cancellation takes precedence when the caller context is canceled.
|
||||
- Promptkit capacity rejection maps to a weatherreporter-owned error category.
|
||||
It is an operational report failure, not invalid model output.
|
||||
- Single-report commands return the classified failure with available
|
||||
inspectable paths.
|
||||
- Batch runs continue independent later reports under the existing batch
|
||||
failure policy.
|
||||
- The migration adds no automatic retries. Any future retry policy belongs to
|
||||
app orchestration, not the Promptkit adapter.
|
||||
|
||||
## Compatibility Requirements
|
||||
|
||||
Status: Accepted design constraints; unimplemented.
|
||||
|
||||
- Report IDs, prompt IDs, report selection, valid periods, artifact grouping,
|
||||
output names, and distributor bundle behavior remain stable.
|
||||
- Module snapshot and Recent Changes behavior remains deterministic.
|
||||
- Promptkit receives only the existing curated prompt-input boundary.
|
||||
- Generated reports continue to use the managed Markdown path as the
|
||||
distributor upload source.
|
||||
- RunID lookup and inspection remain available for successful and failed runs.
|
||||
- Existing managed state paths remain stable where their meaning is unchanged.
|
||||
Scriptorium-specific artifact names or schemas may change when retaining
|
||||
them would misrepresent the new contract.
|
||||
- Any artifact or metadata schema change is 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 Promptkit providers or credentials.
|
||||
|
||||
## Verification And Completion Criteria
|
||||
|
||||
Status: Proposed completion criteria for the unimplemented migration.
|
||||
|
||||
The migration is complete when:
|
||||
|
||||
- all seven reports prepare and execute through Promptkit using embedded
|
||||
report-owned assets;
|
||||
- direct-Markdown and generated-text-template paths have deterministic offline
|
||||
adapter and app-level coverage;
|
||||
- preparation, provider failure, capacity rejection, cancellation, timeout,
|
||||
Promptkit validation failure, generated-text validation failure, template
|
||||
failure, and successful generation preserve their specified artifacts;
|
||||
- morning and evening batches construct one shared engine and preserve current
|
||||
collection, planning, ordering, continuation, output, and notification
|
||||
behavior;
|
||||
- configuration examples load and contain no Scriptorium fields;
|
||||
- CLI summaries and inspection commands expose the new artifact contract
|
||||
without Promptkit dependency types;
|
||||
- Scriptorium code, configuration, tests, and runtime documentation have been
|
||||
removed;
|
||||
- non-roadmap documentation describes only the implemented Promptkit
|
||||
integration;
|
||||
- `go test ./...`, 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 the current Scriptorium behavior is sufficient
|
||||
for migration verification. A production-selectable dual-run period is not
|
||||
required because model calls are nondeterministic, costly, and difficult to
|
||||
compare meaningfully.
|
||||
|
||||
## External Prerequisite
|
||||
|
||||
Status: Required and unimplemented.
|
||||
|
||||
Before implementing the embedded asset stage, the current Scriptorium prompt
|
||||
corpus must be made available in this repository. It should include the seven
|
||||
prompt definitions, referenced content files, private response schemas,
|
||||
relevant default-profile declarations, and any shared prompt fragments needed
|
||||
to reproduce current report behavior.
|
||||
|
||||
## Open Questions
|
||||
|
||||
Status: Open; these require decisions before implementation.
|
||||
|
||||
- What exact `promptkit.*` configuration fields should replace the current
|
||||
Scriptorium fields, including the name and precedence of an optional explicit
|
||||
profile override?
|
||||
- Should weatherreporter expose Promptkit's conventional `local` backend
|
||||
registration as a narrow configuration feature, or rely initially on
|
||||
built-in and endpoint-only profiles?
|
||||
- Should report definitions store an explicit Promptkit prompt version, or
|
||||
should each embedded prompt ID be required to have exactly one version?
|
||||
- What CLI or configuration control enables sensitive prompt/response debug
|
||||
artifacts, and where should those artifacts live?
|
||||
- What final names and schema versions should replace the
|
||||
Scriptorium-specific preflight and run-result artifacts while balancing
|
||||
semantic clarity with existing state-path compatibility?
|
||||
179
docs/templates.md
Normal file
179
docs/templates.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Report Templates
|
||||
|
||||
This guide is for maintainers editing Weatherreporter's embedded Markdown
|
||||
templates. Templates format already validated report inputs; they do not select
|
||||
sources, derive weather facts, or validate generated prose. For those details,
|
||||
see [Generated Text internals](internal/generatedtext.md) and [Report Template
|
||||
internals](internal/reporttemplate.md).
|
||||
|
||||
## Template Assets
|
||||
|
||||
Only the generated-text reports use repository-native Markdown templates.
|
||||
Each report has one matching template ID, generated-text schema ID, and prompt
|
||||
source:
|
||||
|
||||
| Report | Template | Schema | Prompt ID and source |
|
||||
| --- | --- | --- | --- |
|
||||
| Daily | `templates/daily.md.tmpl` (`daily`) | `daily` | `weather.daily_generated_text`; `prompts/daily.generated_text.md` |
|
||||
| Today | `templates/today.md.tmpl` (`today`) | `today` | `weather.today_generated_text`; `prompts/today.generated_text.md` |
|
||||
| Tomorrow | `templates/tomorrow.md.tmpl` (`tomorrow`) | `tomorrow` | `weather.tomorrow_generated_text`; `prompts/tomorrow.generated_text.md` |
|
||||
| Hourly | `templates/hourly.md.tmpl` (`hourly`) | `hourly` | `weather.hourly_generated_text`; `prompts/hourly.generated_text.md` |
|
||||
|
||||
The matching schema files are under `internal/reporttemplate/schemas/`. The
|
||||
generated-text catalog pairs each schema ID with its template ID; keep the
|
||||
matching report prompt source aligned with that pair.
|
||||
|
||||
Shared partials are under `internal/reporttemplate/templates/partials/`:
|
||||
|
||||
| Partial | Used by |
|
||||
| --- | --- |
|
||||
| `alert_digest.md.tmpl` | Daily, Today, Tomorrow, and Hourly |
|
||||
| `precipitation_timing.md.tmpl` | Daily, Today, Tomorrow, and Hourly |
|
||||
| `daypart_forecast.md.tmpl` | Daily and Tomorrow |
|
||||
| `today_daypart_forecast.md.tmpl` | Today |
|
||||
|
||||
All shared partials are parsed whenever any top-level template is rendered. A
|
||||
syntax error in a partial can therefore prevent every generated-text report
|
||||
from rendering.
|
||||
|
||||
## Editing Rules
|
||||
|
||||
- Use Go `text/template` syntax and keep changes to Markdown structure,
|
||||
ordering, and display conditions.
|
||||
- Templates use `missingkey=error`; reference only documented fields and guard
|
||||
optional module pointers with `with` or `if`.
|
||||
- Prefer `.Modules` for deterministic display values. Do not add weather
|
||||
calculations, source selection, or prompt-input shaping to a template.
|
||||
- Keep generated prose in `.GeneratedText`; do not restate deterministic facts
|
||||
in generated prose merely to compensate for a template change.
|
||||
- When changing the generated-prose contract, update the matching prompt,
|
||||
schema, validator, render context, and template together. The validation and
|
||||
catalog rules are owned by [Generated Text internals](internal/generatedtext.md).
|
||||
- Use `.Modules.Dayparts` for ordered daypart output. Do not range over
|
||||
`.Modules.DerivedDaypartSummaries`, which is a map.
|
||||
|
||||
Minimal optional-value pattern:
|
||||
|
||||
```gotemplate
|
||||
{{ with .Modules.CurrentConditions }}
|
||||
Currently, it is {{ with .TemperatureF }}{{ . }}°F{{ end }}.
|
||||
{{ else }}
|
||||
Current conditions are unavailable.
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
Minimal list pattern:
|
||||
|
||||
```gotemplate
|
||||
{{ range .GeneratedText.ForecastDiscussion }}
|
||||
{{ . }}
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
## Registered Functions
|
||||
|
||||
Templates have these helpers in addition to Go template built-ins:
|
||||
|
||||
| Function | Accepts | Returns true when |
|
||||
| --- | --- | --- |
|
||||
| `hasRelevantAlerts` | an alert-digest value or pointer | its `Relevant` slice is nonempty |
|
||||
| `hasEnhancedOrHigherSPCRisk` | an SPC outlook value or pointer | its `RiskDigest` contains an Enhanced, Moderate, or High Risk entry |
|
||||
| `isEnhancedOrHigherSPCRisk` | one SPC risk-digest entry | its `LabelText`, or fallback `RiskLabel`, is Enhanced, Moderate, or High Risk |
|
||||
|
||||
For example, the alert partial uses the first two functions to decide whether
|
||||
to render the section:
|
||||
|
||||
```gotemplate
|
||||
{{ if hasRelevantAlerts .Modules.AlertDigest }}
|
||||
## Alert Digest
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
## Render Context
|
||||
|
||||
Every rendered template receives one typed context with these five top-level
|
||||
fields:
|
||||
|
||||
| Field | Purpose |
|
||||
| --- | --- |
|
||||
| `.Report` | Display labels and canonical report timing metadata. |
|
||||
| `.GeneratedText` | Validated prose supplied by Scriptorium. |
|
||||
| `.Modules` | Deterministic, typed values prepared for Markdown rendering. |
|
||||
| `.Collected` | Normalized upstream facts for advanced use. |
|
||||
| `.Derived` | Shared calculated facts for advanced use. |
|
||||
|
||||
`.Collected` and `.Derived` are available for an exceptional display need, but
|
||||
they are lower-level contracts. Keep reusable weather derivation in Go and use
|
||||
the module surface for normal template work.
|
||||
|
||||
### Report Metadata
|
||||
|
||||
All contexts provide `.Report.Title`, `.Report.GeneratedAt`,
|
||||
`.Report.GeneratedAtLabel`, `.Report.ValidPeriod`, and `.Report.Timezone`.
|
||||
|
||||
Hourly additionally provides `.Report.LocationName` and
|
||||
`.Report.ValidPeriodLabel`.
|
||||
|
||||
Daily, Today, and Tomorrow additionally provide `.Report.ForecastDate`,
|
||||
`.Report.ForecastDateLabel`, and `.Report.ForecastDayName`. Their valid-period
|
||||
field remains canonical timing data; use the supplied display labels instead
|
||||
of formatting timestamps in a template.
|
||||
|
||||
### Validated GeneratedText Prose
|
||||
|
||||
GeneratedText is prose returned by Scriptorium and validated before rendering.
|
||||
It is not a source for deterministic weather facts.
|
||||
|
||||
| Field | Hourly type | Daily, Today, and Tomorrow type | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `.GeneratedText.Summary` | `string` | `string` | Required. |
|
||||
| `.GeneratedText.ForecastDiscussion` | `string` | `[]string` | Required; range over the day-style paragraph slice. |
|
||||
| `.GeneratedText.PrecipitationTiming` | `string` | `string` | 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. |
|
||||
|
||||
The JSON schema rejects unknown properties and defines the required fields, but
|
||||
the schema body and validation behavior are documented in [Generated Text
|
||||
internals](internal/generatedtext.md).
|
||||
|
||||
### Deterministic Module Values
|
||||
|
||||
Module values are deterministic outputs built from collected and derived facts.
|
||||
Module pointers can be nil when their source or policy permits omission.
|
||||
|
||||
| Module field | Available in |
|
||||
| --- | --- |
|
||||
| `.Modules.Metadata`, `.Modules.CurrentConditions`, `.Modules.HourlyForecast`, `.Modules.PrecipTiming`, `.Modules.AlertDigest`, `.Modules.SPCConvectiveOutlooks`, `.Modules.AreaForecastDiscussion`, `.Modules.SPCConvectiveDiscussion`, `.Modules.WeatherStory` | All four contexts |
|
||||
| `.Modules.DerivedDailySummary`, `.Modules.DerivedDaypartSummaries`, `.Modules.Dayparts` | Daily, Today, Tomorrow |
|
||||
| `.Modules.OutdoorWindows`, `.Modules.DailyPlanning` | Daily |
|
||||
| `.Modules.TodayPlanning` | Today |
|
||||
| `.Modules.TomorrowPlanning` | Tomorrow |
|
||||
|
||||
The repository templates currently use the following nested display values.
|
||||
They are the preferred surface for comparable edits:
|
||||
|
||||
| Area | Values |
|
||||
| --- | --- |
|
||||
| Current conditions | `.TemperatureF`, `.ConditionText`, `.ConditionTextLower`, `.ApparentTemperatureF`, `.RelativeHumidityPercent`, `.WindDirectionText`, `.WindSpeedMph` |
|
||||
| Hourly periods | `.Periods`, `.HourLabel`, `.Name`, `.TemperatureF`, `.TextDescription`, `.TextDescriptionLower`, `.MentionPrecipitation`, `.ProbabilityOfPrecipitationPercent` |
|
||||
| Dayparts | `.Dayparts[].Key` and `.Dayparts[].Summary` fields `DisplayName`, `DominantCondition`, `DominantConditionDisplay`, `TemperatureTrend`, `TemperatureStartPhraseF`, `TemperatureEndPhraseF`, `TemperaturePeakPhraseF`, `TemperatureSteadyPhraseF`, `TemperaturePhraseF`, `MentionPrecipitation`, and `MaxPopPercent` |
|
||||
| Precipitation timing | `.PrecipitationWindows`, plus each window's `PeriodBegins`, `PeriodBeginsHourLabel`, `PeriodEnds`, `PeriodEndsHourLabel`, `ExpectationPhrase`, `MaxPopPercent`, `MaxPopTime`, and `MaxPopHourLabel` |
|
||||
| Alert digest | `.AlertDigest.Relevant` entries' `Event`, `Headline`, `PeriodBegins`, and `PeriodEnds` |
|
||||
| SPC risk digest | `.SPCConvectiveOutlooks.RiskDigest` entries' `LabelText`, `RiskLabel`, `PeriodBegins`, and `PeriodEnds` |
|
||||
|
||||
Other fields on these typed modules remain available when a template has a
|
||||
well-defined display need. Their module contracts and weather derivation belong
|
||||
to [Module contract internals](internal/module.md), [Module builder
|
||||
internals](internal/briefing.md), and [Forecast derivation
|
||||
internals](internal/forecast-derivation.md).
|
||||
|
||||
## Validate Changes
|
||||
|
||||
Run the focused checks after editing templates, partials, prompts, or schemas:
|
||||
|
||||
```sh
|
||||
go test ./internal/reporttemplate ./internal/generatedtext ./internal/app
|
||||
git diff --check
|
||||
```
|
||||
|
||||
The render-context and template tests cover Daily, Today, Tomorrow, and Hourly
|
||||
contexts. Run the repository-wide test suite before merging a broader change.
|
||||
@@ -1,235 +1,271 @@
|
||||
# Weatherreporter Troubleshooting
|
||||
# Troubleshooting
|
||||
|
||||
This guide lists recurring failures with likely causes, diagnostics, and safe
|
||||
fixes. See [CLI reference](cli.md), [Configuration reference](config.md), and
|
||||
[Operations guide](operations.md) for normal usage.
|
||||
Use the error from the command together with the run artifacts when a run ID is
|
||||
available. Start with [`inspect metadata`](cli.md#inspection-commands) to identify the
|
||||
report and artifact paths, then use the narrower inspection command named
|
||||
below. Do not remove a workspace to diagnose a failure: it contains the
|
||||
evidence needed to correct it safely.
|
||||
|
||||
## `weather_api.base_url is required`
|
||||
## A command or configuration is rejected before work starts
|
||||
|
||||
Symptom: a generation command fails before fetching weather data.
|
||||
Symptom: The command exits before it creates a run, with an unknown-flag,
|
||||
missing-argument, invalid date or time bound, invalid timezone, or
|
||||
`weather_api.base_url` message.
|
||||
|
||||
Likely cause: no Weather API base URL is configured.
|
||||
Likely cause: The command does not accept that option for the requested report,
|
||||
or required command and configuration values are absent or malformed.
|
||||
|
||||
Diagnostic:
|
||||
Diagnostic: Compare the command with [`generate` and `run`](cli.md#commands-and-usage)
|
||||
and review the configured value named in the error. `generate daily` requires
|
||||
`--date`; `generate storm` requires both `--start` and `--end`.
|
||||
|
||||
```sh
|
||||
weatherreporter generate daily --config ./config.yml --date 2026-05-29
|
||||
```
|
||||
Safe fix: Correct only the reported option or configuration value. Use an
|
||||
absolute Weather API URL and a valid IANA timezone; do not change unrelated
|
||||
workspace data.
|
||||
|
||||
Safe fix: add `weather_api.base_url` to the config file, or pass the intended
|
||||
config path with `--config`.
|
||||
See also: [Configuration](config.md) and [Weather API integration](integrations/weatherapi.md).
|
||||
|
||||
Relevant docs: [Configuration reference](config.md).
|
||||
## Weather data cannot be collected
|
||||
|
||||
## `weather_api.base_url must be an absolute URL`
|
||||
Symptom: A generation command fails while fetching weather data, or reports
|
||||
`hourly forecast data is missing` or `contains no periods`.
|
||||
|
||||
Symptom: config loading fails with a base URL validation error.
|
||||
Likely cause: The Weather API is unavailable, its configured endpoint or
|
||||
credentials are unsuitable, or the response lacks the hourly forecast required
|
||||
by the selected report.
|
||||
|
||||
Likely cause: `weather_api.base_url` is missing a scheme or host.
|
||||
Diagnostic: Check the service status and the configured base URL, then retry
|
||||
the same report. If a run ID was produced, run `weatherreporter inspect sources
|
||||
RUN_ID` to see the recorded source result.
|
||||
|
||||
Diagnostic: inspect the configured value in the file passed to `--config`.
|
||||
Safe fix: Restore access to the configured Weather API or choose a reporting
|
||||
period supported by the returned forecast. Do not invent missing hourly values
|
||||
in local artifacts.
|
||||
|
||||
Safe fix: use an absolute URL such as `https://weather.api.example.com/`.
|
||||
See also: [Configuration](config.md) and [Weather API integration](integrations/weatherapi.md).
|
||||
|
||||
Relevant docs: [Configuration reference](config.md).
|
||||
## Optional source warnings appear
|
||||
|
||||
## Invalid Timezone
|
||||
Symptom: The report succeeds but its output says that a source supplied a
|
||||
warning or degraded result.
|
||||
|
||||
Symptom: config loading fails with `weather_api.timezone` context, or a CLI
|
||||
timezone override fails.
|
||||
Likely cause: An optional source did not return usable data; mandatory weather
|
||||
collection still completed.
|
||||
|
||||
Likely cause: `weather_api.timezone` or `--tz` is not recognized.
|
||||
Diagnostic: Run `weatherreporter inspect sources RUN_ID` and identify the
|
||||
source and warning recorded for that run.
|
||||
|
||||
Diagnostic:
|
||||
Safe fix: Correct the affected source configuration or service issue, then
|
||||
generate a new report if the missing optional information is needed. Keep the
|
||||
existing run for comparison.
|
||||
|
||||
```sh
|
||||
weatherreporter generate daily --tz America/Chicago --date 2026-05-29
|
||||
```
|
||||
See also: [Inspecting a run](cli.md#inspection-commands) and [Operations](operations.md).
|
||||
|
||||
Safe fix: use an accepted timezone value, such as an IANA timezone name,
|
||||
`Chicago`, `Stl`, a US timezone abbreviation, or a UTC offset.
|
||||
## Scriptorium cannot be prepared
|
||||
|
||||
Relevant docs: [Configuration reference](config.md).
|
||||
Symptom: The report fails with a fragment such as `run scriptorium render`, or
|
||||
the Scriptorium executable cannot be started.
|
||||
|
||||
## Storm Command Rejects Time Bounds
|
||||
Likely cause: The configured executable, profile, prompt, or its local runtime
|
||||
environment is unavailable to Weatherreporter.
|
||||
|
||||
Symptom: `generate storm` fails with `requires --start`, `requires --end`, or
|
||||
`requires --end after --start`.
|
||||
Diagnostic: Confirm that the configured executable can be run by the same user
|
||||
and inspect `weatherreporter inspect metadata RUN_ID` when a run ID is shown.
|
||||
|
||||
Likely cause: the manual event window is missing or invalid.
|
||||
Safe fix: Repair the executable path or the Scriptorium configuration and retry
|
||||
the report. Do not edit generated artifacts to bypass preparation.
|
||||
|
||||
Diagnostic:
|
||||
See also: [Configuration](config.md) and [Operations](operations.md).
|
||||
|
||||
```sh
|
||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
|
||||
```
|
||||
## Scriptorium preflight fails
|
||||
|
||||
Safe fix: provide both bounds. Use `YYYY-MM-DDTHH:MM` in the configured
|
||||
timezone, or RFC3339 timestamps with explicit offsets.
|
||||
Symptom: A Scriptorium-backed report stops before text generation, often with
|
||||
a `scriptorium render exited with code` fragment.
|
||||
|
||||
Relevant docs: [CLI reference](cli.md).
|
||||
Likely cause: Scriptorium rejected the render request, prompt, profile, or data
|
||||
package before it could run the report.
|
||||
|
||||
## Weather API Fetch Fails
|
||||
Diagnostic: Inspect the run metadata and the saved preflight artifact path it
|
||||
references. Compare the reported Scriptorium diagnostic with its configuration.
|
||||
|
||||
Symptom: generation fails with `fetch /...`, an HTTP status, or request context.
|
||||
Safe fix: Correct the reported Scriptorium input or configuration, then create
|
||||
a new run. Preserve the failed preflight artifact for support or comparison.
|
||||
|
||||
Likely cause: the configured Weather API endpoint is unreachable, returned a
|
||||
non-2xx response, or returned an invalid response envelope.
|
||||
See also: [Inspecting a run](cli.md#inspection-commands) and [Operations](operations.md).
|
||||
|
||||
Diagnostic:
|
||||
## Scriptorium report execution fails
|
||||
|
||||
```sh
|
||||
weatherreporter generate daily --config ./config.yml --date 2026-05-29
|
||||
```
|
||||
Symptom: Preparation succeeded, but generation stops with a
|
||||
`scriptorium run exited with code` fragment.
|
||||
|
||||
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`.
|
||||
Likely cause: The Scriptorium run failed after preflight, for example because
|
||||
its prompt execution or runtime dependency failed.
|
||||
|
||||
Relevant docs: [Configuration reference](config.md).
|
||||
Diagnostic: Inspect the run metadata and preflight artifact, then review the
|
||||
exit diagnostic from the command. This distinguishes a run failure from a
|
||||
preflight failure.
|
||||
|
||||
## Hourly Forecast Is Missing
|
||||
Safe fix: Correct the Scriptorium issue identified by that diagnostic and run
|
||||
the report again; leave the failed run artifacts in place.
|
||||
|
||||
Symptom: generation fails with hourly forecast context, such as missing hourly
|
||||
data or an hourly forecast containing no periods.
|
||||
See also: [Operations](operations.md).
|
||||
|
||||
Likely cause: hourly forecast data is required for generated reports.
|
||||
## Generated text fails validation
|
||||
|
||||
Diagnostic: check the Weather API response for `/forecast/hourly`.
|
||||
Symptom: A generated-text report fails after Scriptorium returns text, with a
|
||||
message about generated text or required report content.
|
||||
|
||||
Safe fix: restore hourly forecast data at the Weather API. Missing-source
|
||||
policy cannot make hourly optional.
|
||||
Likely cause: Returned text does not meet the report's validation rules.
|
||||
|
||||
Relevant docs: [Configuration reference](config.md), [Operations guide](operations.md).
|
||||
Diagnostic: Use `weatherreporter inspect metadata RUN_ID` to find the saved raw
|
||||
generated-text artifact, and inspect it alongside the reported validation
|
||||
message.
|
||||
|
||||
## Source Warnings Appear
|
||||
Safe fix: Correct the upstream prompt or generation configuration that caused
|
||||
the invalid output, then create a new run. Do not hand-edit saved raw text and
|
||||
present it as a validated report.
|
||||
|
||||
Symptom: generation succeeds, but metadata or `inspect sources` shows source
|
||||
warnings.
|
||||
See also: [Operations](operations.md).
|
||||
|
||||
Likely cause: an optional source was missing or malformed under a warning
|
||||
missing-source policy.
|
||||
## Report template rendering fails
|
||||
|
||||
Diagnostic:
|
||||
Symptom: Scriptorium output is available, but the report fails while building
|
||||
the final Markdown document.
|
||||
|
||||
```sh
|
||||
weatherreporter inspect sources RUN_ID
|
||||
weatherreporter inspect metadata RUN_ID
|
||||
```
|
||||
Likely cause: The selected report template or the render context is
|
||||
incompatible with the generated or collected data.
|
||||
|
||||
Safe fix: inspect the warning `source`, `code`, `message`, and `endpoint`. Fix
|
||||
the upstream optional source, or intentionally change the relevant
|
||||
`missing_source` policy.
|
||||
Diagnostic: Inspect the metadata, generated-text result, and render-context
|
||||
artifacts for the run. Note the template or missing-field fragment in the
|
||||
error rather than relying on a complete error string.
|
||||
|
||||
Relevant docs: [Configuration reference](config.md), [Operations guide](operations.md).
|
||||
Safe fix: Correct the template or its supported inputs in source control, test
|
||||
the change, and create a new report. Do not alter the saved context merely to
|
||||
make one historical run render.
|
||||
|
||||
## `scriptorium` Is Not Found Or Cannot Start
|
||||
See also: [Operations](operations.md).
|
||||
|
||||
Symptom: generation fails with `run scriptorium render` or `run scriptorium`
|
||||
and an executable or OS error.
|
||||
## A report fails after artifacts are saved
|
||||
|
||||
Likely cause: the configured Scriptorium binary is unavailable or not
|
||||
executable.
|
||||
Symptom: A generation command reports an error after showing a run ID, such as
|
||||
an error writing the managed report, copying `--out`, saving metadata, or
|
||||
notifying Distributor.
|
||||
|
||||
Diagnostic: check `scriptorium.binary` in config and run the same binary outside
|
||||
`weatherreporter`.
|
||||
Likely cause: A local filesystem permission or path problem, an unavailable
|
||||
destination for `--out`, or a later report-delivery failure occurred after
|
||||
earlier steps succeeded.
|
||||
|
||||
Safe fix: install Scriptorium, update `scriptorium.binary`, or fix executable
|
||||
Diagnostic: Run `weatherreporter inspect metadata RUN_ID` and check the exact
|
||||
path and operation named in the error. For an `--out` failure, verify only the
|
||||
specified destination directory and filename.
|
||||
|
||||
Safe fix: Repair access to that exact path or disable the optional delivery
|
||||
step only when appropriate, then generate a new report. Keep the existing
|
||||
managed artifacts untouched.
|
||||
|
||||
See also: [Operations](operations.md) and [Distributor integration](integrations/distributor/pkg-upload.md).
|
||||
|
||||
## A batch has partial report failures
|
||||
|
||||
Symptom: `run morning` or `run evening` returns nonzero and reports both
|
||||
succeeded and failed report items.
|
||||
|
||||
Likely cause: A report-level collection, generation, rendering, or local
|
||||
output failure affected one or more planned reports; the remaining reports
|
||||
continue independently.
|
||||
|
||||
Diagnostic: Read the per-report status lines, then inspect the run ID for each
|
||||
failed item with `weatherreporter inspect metadata RUN_ID`.
|
||||
|
||||
Safe fix: Correct the specific failure and rerun the batch or affected report.
|
||||
Do not delete successful reports simply because another item failed.
|
||||
|
||||
See also: [Batch commands](cli.md#commands-and-usage) and [Operations](operations.md).
|
||||
|
||||
## A batch upload is skipped
|
||||
|
||||
Symptom: The batch result says Distributor notification was skipped because
|
||||
one or more reports failed.
|
||||
|
||||
Likely cause: Batch notification intentionally runs only after every planned
|
||||
report succeeds.
|
||||
|
||||
Diagnostic: Review the failed report items and their metadata; a skipped batch
|
||||
notification is expected while any item is failed.
|
||||
|
||||
Safe fix: Resolve the report failures and rerun the batch. Do not upload a
|
||||
partial bundle by manually reusing batch artifacts.
|
||||
|
||||
See also: [Batch commands](cli.md#commands-and-usage) and [Operations](operations.md).
|
||||
|
||||
## Distributor notification fails
|
||||
|
||||
Symptom: A completed report or otherwise successful batch reports a Distributor
|
||||
error, including a rejected upload, source or idempotency conflict, or service
|
||||
unavailability.
|
||||
|
||||
Likely cause: Distributor rejected the request identity or bundle, required
|
||||
credentials are unavailable, or the remote service cannot be reached.
|
||||
|
||||
Diagnostic: Inspect the report metadata or batch result for the notification
|
||||
artifact and the error fragment. Verify the configured Distributor endpoint and
|
||||
request identity without exposing credentials.
|
||||
|
||||
Safe fix: Resolve the reported remote conflict, configuration, or availability
|
||||
issue and create a new report or rerun the batch. Do not modify recorded bundle
|
||||
or idempotency artifacts to force an upload.
|
||||
|
||||
See also: [Configuration](config.md), [Distributor integration](integrations/distributor/pkg-upload.md), and [Operations](operations.md).
|
||||
|
||||
## Secrets cannot be loaded
|
||||
|
||||
Symptom: Startup reports `read secrets directory`, `secret file`, or a token
|
||||
environment-variable error before the affected service can be used.
|
||||
|
||||
Likely cause: The configured secrets directory cannot be read, contains a
|
||||
non-regular file, or does not supply the environment variable required by an
|
||||
enabled integration.
|
||||
|
||||
Diagnostic: Check the configured secrets directory path, ownership, and that
|
||||
each intended secret is a regular file. Confirm the variable name from
|
||||
configuration only; never print or paste its value.
|
||||
|
||||
Safe fix: Correct permissions, file type, or the missing secret file, then
|
||||
retry. Keep secret values out of commands, logs, tickets, and artifacts.
|
||||
|
||||
See also: [Configuration](config.md) and [Operations](operations.md).
|
||||
|
||||
## A run ID or saved state cannot be found
|
||||
|
||||
Symptom: An inspection command reports that metadata for a run ID was not
|
||||
found, or a report cannot use a prior snapshot.
|
||||
|
||||
Likely cause: The run ID is wrong, the configured workspace is different from
|
||||
the one that created the run, or no compatible prior snapshot exists.
|
||||
|
||||
Diagnostic: Use `weatherreporter inspect reports` to list available reports in
|
||||
the current workspace, then copy the run ID from that output. Confirm the
|
||||
workspace configuration before retrying a prior-snapshot operation.
|
||||
|
||||
Safe fix: Use an existing run ID and its original workspace, or generate a new
|
||||
compatible report when no prior snapshot is available. Do not fabricate state
|
||||
files or run IDs.
|
||||
|
||||
See also: [Inspecting a run](cli.md#inspection-commands) and [Operations](operations.md).
|
||||
|
||||
## Workspace paths cannot be read or written
|
||||
|
||||
Symptom: Startup or report persistence reports a workspace-path, permission,
|
||||
or "must be relative to workspace root" error.
|
||||
|
||||
Likely cause: A configured artifact directory escapes the workspace, or the
|
||||
current user lacks access to the specific workspace location.
|
||||
|
||||
Diagnostic: Check the named configuration path against the configured workspace
|
||||
root and inspect ownership and permissions of that exact directory.
|
||||
|
||||
Safe fix: Set the path to a location within the workspace or repair access to
|
||||
the named directory, then rerun. Do not remove the workspace or broadly relax
|
||||
permissions.
|
||||
|
||||
Relevant docs: [Configuration reference](config.md),
|
||||
[Scriptorium integration](integrations/scriptorium.md).
|
||||
|
||||
## Render Preflight Fails
|
||||
|
||||
Symptom: generation fails with `scriptorium render exited with code ...`.
|
||||
|
||||
Likely cause: Scriptorium rejected the prompt, config, profile, or
|
||||
`data_package` input before report generation.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
weatherreporter inspect metadata RUN_ID
|
||||
weatherreporter inspect data-package RUN_ID
|
||||
```
|
||||
|
||||
Then read the preflight path from metadata. It contains captured stdout, stderr,
|
||||
exit code, and command.
|
||||
|
||||
Safe fix: fix the Scriptorium configuration, prompt ID, profile, or data package
|
||||
input indicated by stderr.
|
||||
|
||||
Relevant docs: [Operations guide](operations.md),
|
||||
[Scriptorium integration](integrations/scriptorium.md).
|
||||
|
||||
## Scriptorium Run Fails
|
||||
|
||||
Symptom: generation fails with `scriptorium run exited with code ...`.
|
||||
|
||||
Likely cause: Scriptorium failed during report generation or validation.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
weatherreporter inspect metadata RUN_ID
|
||||
weatherreporter inspect data-package RUN_ID
|
||||
```
|
||||
|
||||
If metadata includes a rendered report path, inspect that report as well. A
|
||||
nonzero run can still leave a managed report artifact.
|
||||
|
||||
Safe fix: use the captured stderr and data package to fix the Scriptorium
|
||||
prompt, profile, model configuration, or validation issue.
|
||||
|
||||
Relevant docs: [Operations guide](operations.md),
|
||||
[Scriptorium integration](integrations/scriptorium.md).
|
||||
|
||||
## Batch Command Returns Nonzero
|
||||
|
||||
Symptom: `run morning` or `run evening` returns nonzero.
|
||||
|
||||
Likely cause: at least one report in the batch failed.
|
||||
|
||||
Diagnostic: inspect stdout for the JSON summary and stderr for compact status
|
||||
lines.
|
||||
|
||||
Safe fix: use the failed report's artifact paths from the summary, then inspect
|
||||
metadata, sources, briefing, and data package for that RunID.
|
||||
|
||||
Relevant docs: [CLI reference](cli.md), [Operations guide](operations.md).
|
||||
|
||||
## Unknown RunID
|
||||
|
||||
Symptom: an inspect command fails with `metadata for run id ... was not found`.
|
||||
|
||||
Likely cause: the RunID is mistyped or the command is reading a different
|
||||
workspace.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
weatherreporter inspect reports --config ./config.yml --limit 20
|
||||
```
|
||||
|
||||
Safe fix: copy a RunID from `inspect reports`, or use the same `--config` and
|
||||
workspace that generated the report.
|
||||
|
||||
Relevant docs: [Operations guide](operations.md).
|
||||
|
||||
## Workspace Path Error
|
||||
|
||||
Symptom: startup or inspection fails with workspace path validation or
|
||||
filesystem read/write context.
|
||||
|
||||
Likely cause: a workspace subdirectory is absolute, escapes `workspace.root`, or
|
||||
the process cannot read or write the configured path.
|
||||
|
||||
Diagnostic: review `workspace.root`, `workspace.snapshots_dir`,
|
||||
`workspace.reports_dir`, `workspace.data_packages_dir`, and
|
||||
`workspace.preflight_dir`.
|
||||
|
||||
Safe fix: keep workspace subdirectories relative to `workspace.root`, and grant
|
||||
the process appropriate filesystem permissions.
|
||||
|
||||
Relevant docs: [Configuration reference](config.md), [Operations guide](operations.md).
|
||||
See also: [Configuration](config.md) and [Operations](operations.md).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
weather_api:
|
||||
base_url: https://weather.api.rakestrawhome.com/
|
||||
base_url: https://weather.api.example.com/
|
||||
timeout: 15s
|
||||
precision: 1
|
||||
precision: 0
|
||||
units: us
|
||||
timezone: "America/Chicago"
|
||||
format: json
|
||||
@@ -11,6 +11,25 @@ location:
|
||||
name: Brentwood
|
||||
region: St. Louis Metro
|
||||
|
||||
secrets:
|
||||
directory: ""
|
||||
|
||||
notify:
|
||||
distributor:
|
||||
enabled: false
|
||||
endpoint: https://distributor.example.com
|
||||
token_env: DISTRIBUTOR_UPLOAD_TOKEN
|
||||
timeout: 30s
|
||||
failure_policy: error
|
||||
pipeline_id_template: "weatherreporter.{report_id}"
|
||||
bundle_id_template: "weatherreporter.{location_id}.{report_id}"
|
||||
idempotency_key_template: "{bundle_id}.{run_id}"
|
||||
batch:
|
||||
enabled: true
|
||||
pipeline_id_template: "weatherreporter"
|
||||
bundle_id_template: "weatherreporter.{location_id}.{batch}"
|
||||
idempotency_key_template: "{bundle_id}.{batch_run_id}"
|
||||
|
||||
missing_source:
|
||||
default: warn
|
||||
sources:
|
||||
@@ -26,6 +45,7 @@ workspace:
|
||||
reports_dir: reports
|
||||
data_packages_dir: data-packages
|
||||
preflight_dir: preflight
|
||||
notifications_dir: notifications
|
||||
|
||||
dayparts:
|
||||
- name: overnight
|
||||
@@ -49,3 +69,65 @@ recent_change:
|
||||
precip_probability_points: 20
|
||||
wind_gust_miles_per_hour: 10
|
||||
precip_timing_shift_minutes: 120
|
||||
|
||||
reports:
|
||||
daily:
|
||||
distributor:
|
||||
path_templates:
|
||||
- "daily/{valid_start_date}/{run_id}.md"
|
||||
- "daily/{valid_start_date}/index.md"
|
||||
deterministic_modules:
|
||||
- metadata
|
||||
- current_conditions
|
||||
- narrative_forecast
|
||||
- derived_daily_summary
|
||||
- derived_daypart_summaries
|
||||
- precip_timing
|
||||
- alert_digest
|
||||
- spc_convective_outlooks
|
||||
- id: area_forecast_discussion
|
||||
options:
|
||||
sections:
|
||||
- long_term
|
||||
- spc_convective_discussion
|
||||
- weather_story
|
||||
- outdoor_windows
|
||||
- daily_planning
|
||||
- hourly_forecast
|
||||
today:
|
||||
deterministic_modules:
|
||||
- metadata
|
||||
- current_conditions
|
||||
- narrative_forecast
|
||||
- derived_daily_summary
|
||||
- derived_daypart_summaries
|
||||
- precip_timing
|
||||
- alert_digest
|
||||
- spc_convective_outlooks
|
||||
- id: area_forecast_discussion
|
||||
options:
|
||||
sections:
|
||||
- product
|
||||
- key_messages
|
||||
- short_term
|
||||
- long_term
|
||||
- spc_convective_discussion
|
||||
- weather_story
|
||||
- outdoor_windows
|
||||
- hourly_forecast
|
||||
- today_planning
|
||||
hourly:
|
||||
deterministic_modules:
|
||||
- metadata
|
||||
- current_conditions
|
||||
- hourly_forecast
|
||||
- precip_timing
|
||||
- alert_digest
|
||||
- spc_convective_outlooks
|
||||
- id: area_forecast_discussion
|
||||
options:
|
||||
sections:
|
||||
- key_messages
|
||||
- short_term
|
||||
- spc_convective_discussion
|
||||
- weather_story
|
||||
|
||||
2
go.mod
2
go.mod
@@ -3,3 +3,5 @@ module gitea.maximumdirect.net/eric/weatherreporter
|
||||
go 1.26
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
|
||||
require gitea.maximumdirect.net/eric/distributor v0.5.0
|
||||
|
||||
48
go.sum
48
go.sum
@@ -1,3 +1,51 @@
|
||||
gitea.maximumdirect.net/eric/distributor v0.5.0 h1:+al7Bw+kMv6V35a3Sm5rUtCTQhwOn5b9x3RsclPMKJk=
|
||||
gitea.maximumdirect.net/eric/distributor v0.5.0/go.mod h1:G03FCFZPHpsUKC6SeMgTdbfNRpPQBdyTtDUj04e1Tu8=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11/go.mod h1:dnakxebH6UwFvcvujL0LVggYQ8nEvBGjU4G/V79Nv94=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.20/go.mod h1:PuwEpciweIXGULWeOeSTXtSbH4CW9mWdWrhdCKQI1sM=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 h1:0w6dCiO8iez+YKwRhRBlL1CH/E3GTfdkuzrwj1by8vo=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25/go.mod h1:9FDWUothyr5RCRAHc45XOiVCzUR8n/IhCYX+uVqw6vk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 h1:A1PmWU2zfkIm9EyFlJncFXL4W4phML+h8KjltUsCvNQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26/go.mod h1:dY4MRzXEizrD4hqtpKvWVGPX7QleSGGVY+EBolo1RmM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 h1:W/EyPFl9A5rXrtoilfwHYEvzHER+K4SpBPtMXi24Mos=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18/go.mod h1:UG50K+pvd/uy6xExbobg0rjqFBFZe6I3l75EPDZw4tg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 h1:dD3dhHNglpd98gs72my22Ndqi1hqQGllFFg1F+twfxg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25/go.mod h1:0yAbjPfd64gG7mj85RW+fMEYdfBgCRZw8g/oWcL1pjc=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 h1:2pQEbwf+/6EDbiit/GcBE2K4IUpMZymaA0kOz3xK978=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25/go.mod h1:KvT6NCcQ0EZ+ZkVRrlBMt04Po3ok23YELEp7WimhLhM=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 h1:ie4ElCmUKS26pzrZcIk/lmt4yWjAqLLcawstyQCh298=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2/go.mod h1:zjsomFeX5duj+4PlMB+o4JoWTIx+G0XMyzjYrUbQkN0=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 h1:1VwbP3qMNfxUDEXWki4rCE5iA+44VA1lokTz9HasGzw=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1/go.mod h1:vUtyoSj0OPji3kjIVSc/GlKuWEiL33f/WFxl6dmpy/A=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 h1:N6pIsdFOW1Kd9S4KyFKXdGRBojPPxkP32+uHFWLv4Hc=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19/go.mod h1:3gt5WJArFooNmyLONS+h/R4J+o86II8du38IgCwj9dE=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 h1:hc+lBYiiTr8Zk4MTzIsQ92MeDWCIDvWGmzKUWOaBcOg=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2/go.mod h1:hU6fqB3OJA6/ePheD47LQnxvjYk6br6PtQxs+Q9ojvk=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3SJXQNEgksORW3Js=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8=
|
||||
github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
|
||||
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
|
||||
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
371
internal/adapters/distributor/client.go
Normal file
371
internal/adapters/distributor/client.go
Normal file
@@ -0,0 +1,371 @@
|
||||
// Package distributor adapts weatherreporter report artifacts to distributor uploads.
|
||||
package distributor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
distributorbundle "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
|
||||
distributorupload "gitea.maximumdirect.net/eric/distributor/pkg/upload"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
Endpoint string
|
||||
TokenEnv string
|
||||
Timeout time.Duration
|
||||
newUploadClient uploadClientFactory
|
||||
}
|
||||
|
||||
type UploadRequest struct {
|
||||
PipelineID string
|
||||
BundleID string
|
||||
IdempotencyKey string
|
||||
Files []UploadFile
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type UploadFile struct {
|
||||
SourcePath string
|
||||
BundlePath string
|
||||
}
|
||||
|
||||
type UploadResult struct {
|
||||
RunID string
|
||||
Status string
|
||||
UploadStatus string
|
||||
StatusError string
|
||||
RunStatus *RunStatus
|
||||
}
|
||||
|
||||
type RunStatus struct {
|
||||
RunID string
|
||||
PipelineID string
|
||||
Status string
|
||||
AcceptedAt time.Time
|
||||
StartedAt *time.Time
|
||||
FinishedAt *time.Time
|
||||
Report json.RawMessage
|
||||
Error string
|
||||
}
|
||||
|
||||
type IdempotencyConflictError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *IdempotencyConflictError) Error() string {
|
||||
if e == nil || e.Err == nil {
|
||||
return "distributor idempotency conflict"
|
||||
}
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *IdempotencyConflictError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Err
|
||||
}
|
||||
|
||||
type uploadClientFactory func(endpoint, token string, timeout time.Duration) (uploadClient, error)
|
||||
|
||||
type uploadClient interface {
|
||||
UploadFiles(ctx context.Context, opts uploadFilesOptions) (uploadFilesResult, error)
|
||||
Status(ctx context.Context, runID string) (runStatus, error)
|
||||
}
|
||||
|
||||
type uploadFilesOptions struct {
|
||||
PipelineID string
|
||||
BundleID string
|
||||
IdempotencyKey string
|
||||
Files []UploadFile
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type uploadFilesResult struct {
|
||||
RunID string
|
||||
Status string
|
||||
}
|
||||
|
||||
type runStatus struct {
|
||||
RunID string
|
||||
PipelineID string
|
||||
Status string
|
||||
AcceptedAt time.Time
|
||||
StartedAt *time.Time
|
||||
FinishedAt *time.Time
|
||||
Report json.RawMessage
|
||||
Error string
|
||||
}
|
||||
|
||||
const statusPollInterval = 250 * time.Millisecond
|
||||
|
||||
func New(cfg config.DistributorNotifyConfig) *Client {
|
||||
return newClient(cfg, newDistributorUploadClient)
|
||||
}
|
||||
|
||||
func newClient(cfg config.DistributorNotifyConfig, factory uploadClientFactory) *Client {
|
||||
if factory == nil {
|
||||
factory = newDistributorUploadClient
|
||||
}
|
||||
return &Client{
|
||||
Endpoint: cfg.Endpoint,
|
||||
TokenEnv: cfg.TokenEnv,
|
||||
Timeout: cfg.Timeout,
|
||||
newUploadClient: factory,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Upload(ctx context.Context, req UploadRequest) (UploadResult, error) {
|
||||
if c == nil {
|
||||
return UploadResult{}, fmt.Errorf("distributor client is nil")
|
||||
}
|
||||
if c.Endpoint == "" {
|
||||
return UploadResult{}, fmt.Errorf("distributor endpoint is required")
|
||||
}
|
||||
if c.TokenEnv == "" {
|
||||
return UploadResult{}, fmt.Errorf("distributor token environment variable is required")
|
||||
}
|
||||
if req.PipelineID == "" {
|
||||
return UploadResult{}, fmt.Errorf("distributor pipeline id is required")
|
||||
}
|
||||
if req.BundleID == "" {
|
||||
return UploadResult{}, fmt.Errorf("distributor bundle id is required")
|
||||
}
|
||||
if req.IdempotencyKey == "" {
|
||||
return UploadResult{}, fmt.Errorf("distributor idempotency key is required for bundle %q", req.BundleID)
|
||||
}
|
||||
if len(req.Files) == 0 {
|
||||
return UploadResult{}, fmt.Errorf("distributor upload files are required for bundle %q", req.BundleID)
|
||||
}
|
||||
for i, file := range req.Files {
|
||||
if file.SourcePath == "" {
|
||||
return UploadResult{}, fmt.Errorf("distributor source path is required for bundle %q file %d", req.BundleID, i)
|
||||
}
|
||||
if file.BundlePath == "" {
|
||||
return UploadResult{}, fmt.Errorf("distributor bundle path is required for bundle %q file %d", req.BundleID, i)
|
||||
}
|
||||
}
|
||||
if c.newUploadClient == nil {
|
||||
return UploadResult{}, fmt.Errorf("distributor upload client factory is required for endpoint %q", c.Endpoint)
|
||||
}
|
||||
|
||||
token := os.Getenv(c.TokenEnv)
|
||||
if token == "" {
|
||||
return UploadResult{}, fmt.Errorf("distributor token environment variable %q is not set", c.TokenEnv)
|
||||
}
|
||||
|
||||
uploadClient, err := c.newUploadClient(c.Endpoint, token, c.Timeout)
|
||||
if err != nil {
|
||||
return UploadResult{}, fmt.Errorf("create distributor upload client for endpoint %q: %w", c.Endpoint, redactToken(err, token))
|
||||
}
|
||||
|
||||
runCtx := ctx
|
||||
if runCtx == nil {
|
||||
runCtx = context.Background()
|
||||
}
|
||||
cancel := func() {}
|
||||
if c.Timeout > 0 {
|
||||
runCtx, cancel = context.WithTimeout(runCtx, c.Timeout)
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
result, err := uploadClient.UploadFiles(runCtx, uploadFilesOptions{
|
||||
PipelineID: req.PipelineID,
|
||||
BundleID: req.BundleID,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
Files: append([]UploadFile(nil), req.Files...),
|
||||
CreatedAt: req.CreatedAt,
|
||||
})
|
||||
if err != nil {
|
||||
return UploadResult{}, wrapUploadError(err, uploadErrorContext{
|
||||
Endpoint: c.Endpoint,
|
||||
PipelineID: req.PipelineID,
|
||||
BundleID: req.BundleID,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
SourcePaths: uploadSourcePaths(req.Files),
|
||||
BundlePaths: uploadBundlePaths(req.Files),
|
||||
Token: token,
|
||||
})
|
||||
}
|
||||
|
||||
uploadResult := UploadResult{
|
||||
RunID: result.RunID,
|
||||
Status: result.Status,
|
||||
UploadStatus: result.Status,
|
||||
}
|
||||
status, statusErr := waitForRunStatus(runCtx, uploadClient, result.RunID, c.Timeout > 0)
|
||||
if status.RunID != "" || status.Status != "" {
|
||||
uploadResult.RunStatus = &RunStatus{
|
||||
RunID: status.RunID,
|
||||
PipelineID: status.PipelineID,
|
||||
Status: status.Status,
|
||||
AcceptedAt: status.AcceptedAt,
|
||||
StartedAt: status.StartedAt,
|
||||
FinishedAt: status.FinishedAt,
|
||||
Report: append(json.RawMessage(nil), status.Report...),
|
||||
Error: redactTokenString(status.Error, token),
|
||||
}
|
||||
if status.Status != "" {
|
||||
uploadResult.Status = status.Status
|
||||
}
|
||||
}
|
||||
if statusErr != nil {
|
||||
uploadResult.StatusError = redactTokenString(statusErr.Error(), token)
|
||||
return uploadResult, nil
|
||||
}
|
||||
if status.Status == "failed" {
|
||||
return uploadResult, fmt.Errorf("distributor run %q failed: %s", status.RunID, uploadResult.RunStatus.Error)
|
||||
}
|
||||
return uploadResult, nil
|
||||
}
|
||||
|
||||
func waitForRunStatus(ctx context.Context, client uploadClient, runID string, poll bool) (runStatus, error) {
|
||||
status, err := client.Status(ctx, runID)
|
||||
if err != nil || terminalRunStatus(status.Status) || !poll {
|
||||
return status, err
|
||||
}
|
||||
|
||||
for {
|
||||
timer := time.NewTimer(statusPollInterval)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return status, fmt.Errorf("distributor run %q did not reach terminal status before timeout: %w", runID, ctx.Err())
|
||||
case <-timer.C:
|
||||
}
|
||||
|
||||
next, err := client.Status(ctx, runID)
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
status = next
|
||||
if terminalRunStatus(status.Status) {
|
||||
return status, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func terminalRunStatus(status string) bool {
|
||||
return status == "succeeded" || status == "failed"
|
||||
}
|
||||
|
||||
type distributorUploadClient struct {
|
||||
client *distributorupload.Client
|
||||
}
|
||||
|
||||
func newDistributorUploadClient(endpoint, token string, timeout time.Duration) (uploadClient, error) {
|
||||
httpClient := (*http.Client)(nil)
|
||||
if timeout > 0 {
|
||||
httpClient = &http.Client{Timeout: timeout}
|
||||
}
|
||||
client, err := distributorupload.NewClient(distributorupload.ClientOptions{
|
||||
Endpoint: endpoint,
|
||||
Token: token,
|
||||
HTTPClient: httpClient,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return distributorUploadClient{client: client}, nil
|
||||
}
|
||||
|
||||
func (c distributorUploadClient) UploadFiles(ctx context.Context, opts uploadFilesOptions) (uploadFilesResult, error) {
|
||||
files := make([]distributorbundle.BundleFile, 0, len(opts.Files))
|
||||
for _, file := range opts.Files {
|
||||
files = append(files, distributorbundle.BundleFile{
|
||||
SourcePath: file.SourcePath,
|
||||
Path: file.BundlePath,
|
||||
})
|
||||
}
|
||||
result, err := c.client.UploadFiles(ctx, distributorupload.UploadFilesOptions{
|
||||
PipelineID: opts.PipelineID,
|
||||
ID: opts.BundleID,
|
||||
Created: opts.CreatedAt,
|
||||
IdempotencyKey: opts.IdempotencyKey,
|
||||
Files: files,
|
||||
})
|
||||
if err != nil {
|
||||
return uploadFilesResult{}, err
|
||||
}
|
||||
return uploadFilesResult{
|
||||
RunID: result.RunID,
|
||||
Status: result.Status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c distributorUploadClient) Status(ctx context.Context, runID string) (runStatus, error) {
|
||||
status, err := c.client.Status(ctx, runID)
|
||||
if err != nil {
|
||||
return runStatus{}, err
|
||||
}
|
||||
return runStatus{
|
||||
RunID: status.RunID,
|
||||
PipelineID: status.PipelineID,
|
||||
Status: status.Status,
|
||||
AcceptedAt: status.AcceptedAt,
|
||||
StartedAt: status.StartedAt,
|
||||
FinishedAt: status.FinishedAt,
|
||||
Report: append(json.RawMessage(nil), status.Report...),
|
||||
Error: status.Error,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type uploadErrorContext struct {
|
||||
Endpoint string
|
||||
PipelineID string
|
||||
BundleID string
|
||||
IdempotencyKey string
|
||||
SourcePaths []string
|
||||
BundlePaths []string
|
||||
Token string
|
||||
}
|
||||
|
||||
func wrapUploadError(err error, ctx uploadErrorContext) error {
|
||||
var conflict *distributorupload.IdempotencyConflictError
|
||||
isConflict := errors.As(err, &conflict)
|
||||
err = redactToken(err, ctx.Token)
|
||||
if isConflict {
|
||||
return &IdempotencyConflictError{
|
||||
Err: fmt.Errorf("upload distributor bundle %q to pipeline %q at endpoint %q with idempotency key %q from sources %q as bundle paths %q: idempotency conflict: %w", ctx.BundleID, ctx.PipelineID, ctx.Endpoint, ctx.IdempotencyKey, ctx.SourcePaths, ctx.BundlePaths, err),
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("upload distributor bundle %q to pipeline %q at endpoint %q with idempotency key %q from sources %q as bundle paths %q: %w", ctx.BundleID, ctx.PipelineID, ctx.Endpoint, ctx.IdempotencyKey, ctx.SourcePaths, ctx.BundlePaths, err)
|
||||
}
|
||||
|
||||
func uploadSourcePaths(files []UploadFile) []string {
|
||||
paths := make([]string, 0, len(files))
|
||||
for _, file := range files {
|
||||
paths = append(paths, file.SourcePath)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func uploadBundlePaths(files []UploadFile) []string {
|
||||
paths := make([]string, 0, len(files))
|
||||
for _, file := range files {
|
||||
paths = append(paths, file.BundlePath)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func redactToken(err error, token string) error {
|
||||
if err == nil || token == "" {
|
||||
return err
|
||||
}
|
||||
return errors.New(redactTokenString(err.Error(), token))
|
||||
}
|
||||
|
||||
func redactTokenString(value, token string) string {
|
||||
if token == "" {
|
||||
return value
|
||||
}
|
||||
return strings.ReplaceAll(value, token, "[redacted]")
|
||||
}
|
||||
406
internal/adapters/distributor/client_test.go
Normal file
406
internal/adapters/distributor/client_test.go
Normal file
@@ -0,0 +1,406 @@
|
||||
package distributor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
distributorupload "gitea.maximumdirect.net/eric/distributor/pkg/upload"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
)
|
||||
|
||||
func TestUploadUsesConfiguredClientAndFiles(t *testing.T) {
|
||||
cfg := config.Defaults().Notify.Distributor
|
||||
cfg.Endpoint = "https://distributor.example.test"
|
||||
cfg.TokenEnv = "DISTRIBUTOR_UPLOAD_TOKEN"
|
||||
cfg.Timeout = 15 * time.Second
|
||||
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||
|
||||
factory := &fakeUploadFactory{
|
||||
client: &fakeUploadClient{
|
||||
result: uploadFilesResult{RunID: "run-123", Status: "accepted"},
|
||||
status: runStatus{RunID: "run-123", PipelineID: "reports", Status: "succeeded", Report: json.RawMessage(`{"actions":[{"action":"replace_older"}]}`)},
|
||||
},
|
||||
}
|
||||
client := newClient(cfg, factory.newClient)
|
||||
|
||||
result, err := client.Upload(context.Background(), UploadRequest{
|
||||
PipelineID: "weatherreporter.daily",
|
||||
BundleID: "weatherreporter.home.daily.run",
|
||||
IdempotencyKey: "weatherreporter.home.daily.run",
|
||||
Files: []UploadFile{
|
||||
{SourcePath: "/tmp/report.md", BundlePath: "2026-06-07/daily/report.md"},
|
||||
{SourcePath: "/tmp/report.md", BundlePath: "2026-06-07/daily/latest.md"},
|
||||
},
|
||||
CreatedAt: time.Date(2026, 6, 7, 12, 0, 0, 123, time.UTC),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Upload() error = %v", err)
|
||||
}
|
||||
if result.RunID != "run-123" || result.Status != "succeeded" || result.UploadStatus != "accepted" {
|
||||
t.Fatalf("result = %#v, want accepted run", result)
|
||||
}
|
||||
if result.RunStatus == nil || result.RunStatus.PipelineID != "reports" || !strings.Contains(string(result.RunStatus.Report), "replace_older") {
|
||||
t.Fatalf("RunStatus = %#v, want parsed run report", result.RunStatus)
|
||||
}
|
||||
if factory.endpoint != cfg.Endpoint {
|
||||
t.Fatalf("factory endpoint = %q, want %q", factory.endpoint, cfg.Endpoint)
|
||||
}
|
||||
if factory.token != "secret-token" {
|
||||
t.Fatalf("factory token = %q, want secret-token", factory.token)
|
||||
}
|
||||
if factory.timeout != 15*time.Second {
|
||||
t.Fatalf("factory timeout = %s, want 15s", factory.timeout)
|
||||
}
|
||||
got := factory.client.opts
|
||||
if got.PipelineID != "weatherreporter.daily" {
|
||||
t.Fatalf("PipelineID = %q, want weatherreporter.daily", got.PipelineID)
|
||||
}
|
||||
if got.BundleID != "weatherreporter.home.daily.run" {
|
||||
t.Fatalf("BundleID = %q, want weatherreporter.home.daily.run", got.BundleID)
|
||||
}
|
||||
if got.IdempotencyKey != "weatherreporter.home.daily.run" {
|
||||
t.Fatalf("IdempotencyKey = %q, want weatherreporter.home.daily.run", got.IdempotencyKey)
|
||||
}
|
||||
if len(got.Files) != 2 {
|
||||
t.Fatalf("files = %#v, want two mappings", got.Files)
|
||||
}
|
||||
if got.Files[0].SourcePath != "/tmp/report.md" || got.Files[0].BundlePath != "2026-06-07/daily/report.md" {
|
||||
t.Fatalf("first file = %#v, want archive mapping", got.Files[0])
|
||||
}
|
||||
if got.Files[1].SourcePath != "/tmp/report.md" || got.Files[1].BundlePath != "2026-06-07/daily/latest.md" {
|
||||
t.Fatalf("second file = %#v, want latest mapping", got.Files[1])
|
||||
}
|
||||
if got.CreatedAt.IsZero() {
|
||||
t.Fatal("CreatedAt is zero, want generated report timestamp")
|
||||
}
|
||||
if factory.client.statusRunID != "run-123" {
|
||||
t.Fatalf("Status runID = %q, want run-123", factory.client.statusRunID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadRejectsMissingInputs(t *testing.T) {
|
||||
cfg := config.Defaults().Notify.Distributor
|
||||
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Client, *UploadRequest)
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "Token",
|
||||
mutate: func(c *Client, req *UploadRequest) {
|
||||
t.Setenv(c.TokenEnv, "")
|
||||
},
|
||||
wantErr: "token environment variable",
|
||||
},
|
||||
{
|
||||
name: "PipelineID",
|
||||
mutate: func(c *Client, req *UploadRequest) {
|
||||
req.PipelineID = ""
|
||||
},
|
||||
wantErr: "pipeline id is required",
|
||||
},
|
||||
{
|
||||
name: "Files",
|
||||
mutate: func(c *Client, req *UploadRequest) {
|
||||
req.Files = nil
|
||||
},
|
||||
wantErr: "upload files are required",
|
||||
},
|
||||
{
|
||||
name: "SourcePath",
|
||||
mutate: func(c *Client, req *UploadRequest) {
|
||||
req.Files[0].SourcePath = ""
|
||||
},
|
||||
wantErr: "source path is required",
|
||||
},
|
||||
{
|
||||
name: "BundlePath",
|
||||
mutate: func(c *Client, req *UploadRequest) {
|
||||
req.Files[0].BundlePath = ""
|
||||
},
|
||||
wantErr: "bundle path is required",
|
||||
},
|
||||
{
|
||||
name: "UploadClientFactory",
|
||||
mutate: func(c *Client, req *UploadRequest) {
|
||||
c.newUploadClient = nil
|
||||
},
|
||||
wantErr: "upload client factory is required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||
client := newClient(cfg, (&fakeUploadFactory{client: &fakeUploadClient{}}).newClient)
|
||||
req := validUploadRequest()
|
||||
tt.mutate(client, &req)
|
||||
|
||||
_, err := client.Upload(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Fatal("Upload() error = nil, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
if strings.Contains(err.Error(), "secret-token") {
|
||||
t.Fatalf("error = %q, want no token value", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadWrapsFactoryErrorWithoutToken(t *testing.T) {
|
||||
cfg := config.Defaults().Notify.Distributor
|
||||
cfg.Endpoint = "https://distributor.example.test"
|
||||
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||
factory := &fakeUploadFactory{
|
||||
err: fmt.Errorf("factory failed with secret-token"),
|
||||
}
|
||||
client := newClient(cfg, factory.newClient)
|
||||
|
||||
_, err := client.Upload(context.Background(), validUploadRequest())
|
||||
if err == nil {
|
||||
t.Fatal("Upload() error = nil, want error")
|
||||
}
|
||||
if strings.Contains(err.Error(), "secret-token") {
|
||||
t.Fatalf("error = %q, want no token value", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), cfg.Endpoint) {
|
||||
t.Fatalf("error = %q, want endpoint context", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadWrapsUploadFailureWithContextWithoutToken(t *testing.T) {
|
||||
cfg := config.Defaults().Notify.Distributor
|
||||
cfg.Endpoint = "https://distributor.example.test"
|
||||
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||
factory := &fakeUploadFactory{
|
||||
client: &fakeUploadClient{err: fmt.Errorf("server rejected secret-token")},
|
||||
}
|
||||
client := newClient(cfg, factory.newClient)
|
||||
req := validUploadRequest()
|
||||
|
||||
_, err := client.Upload(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Fatal("Upload() error = nil, want error")
|
||||
}
|
||||
for _, want := range []string{cfg.Endpoint, req.PipelineID, req.BundleID, req.IdempotencyKey, req.Files[0].SourcePath, req.Files[0].BundlePath, req.Files[1].BundlePath} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %q, want context %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(err.Error(), "secret-token") {
|
||||
t.Fatalf("error = %q, want no token value", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadReturnsAcceptedWhenStatusLookupFails(t *testing.T) {
|
||||
cfg := config.Defaults().Notify.Distributor
|
||||
cfg.Endpoint = "https://distributor.example.test"
|
||||
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||
factory := &fakeUploadFactory{
|
||||
client: &fakeUploadClient{
|
||||
result: uploadFilesResult{RunID: "run-123", Status: "accepted"},
|
||||
statusErr: fmt.Errorf("status rejected secret-token"),
|
||||
},
|
||||
}
|
||||
client := newClient(cfg, factory.newClient)
|
||||
|
||||
result, err := client.Upload(context.Background(), validUploadRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("Upload() error = %v, want accepted upload despite status lookup failure", err)
|
||||
}
|
||||
if result.Status != "accepted" || result.StatusError == "" {
|
||||
t.Fatalf("result = %#v, want accepted status with status error", result)
|
||||
}
|
||||
if strings.Contains(result.StatusError, "secret-token") {
|
||||
t.Fatalf("StatusError = %q, want token redacted", result.StatusError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadPollsUntilTerminalStatus(t *testing.T) {
|
||||
cfg := config.Defaults().Notify.Distributor
|
||||
cfg.Endpoint = "https://distributor.example.test"
|
||||
cfg.Timeout = 2 * time.Second
|
||||
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||
factory := &fakeUploadFactory{
|
||||
client: &fakeUploadClient{
|
||||
result: uploadFilesResult{RunID: "run-123", Status: "accepted"},
|
||||
statuses: []runStatus{
|
||||
{RunID: "run-123", Status: "accepted"},
|
||||
{RunID: "run-123", Status: "succeeded", Report: json.RawMessage(`{"actions":[{"action":"replace_older"}]}`)},
|
||||
},
|
||||
},
|
||||
}
|
||||
client := newClient(cfg, factory.newClient)
|
||||
|
||||
result, err := client.Upload(context.Background(), validUploadRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("Upload() error = %v", err)
|
||||
}
|
||||
if result.Status != "succeeded" || result.RunStatus == nil || !strings.Contains(string(result.RunStatus.Report), "replace_older") {
|
||||
t.Fatalf("result = %#v, want terminal succeeded status with run report", result)
|
||||
}
|
||||
if factory.client.statusCalls != 2 {
|
||||
t.Fatalf("status calls = %d, want 2", factory.client.statusCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadReturnsLatestStatusWhenPollingTimesOut(t *testing.T) {
|
||||
cfg := config.Defaults().Notify.Distributor
|
||||
cfg.Endpoint = "https://distributor.example.test"
|
||||
cfg.Timeout = time.Millisecond
|
||||
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||
factory := &fakeUploadFactory{
|
||||
client: &fakeUploadClient{
|
||||
result: uploadFilesResult{RunID: "run-123", Status: "accepted"},
|
||||
status: runStatus{RunID: "run-123", Status: "running"},
|
||||
},
|
||||
}
|
||||
client := newClient(cfg, factory.newClient)
|
||||
|
||||
result, err := client.Upload(context.Background(), validUploadRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("Upload() error = %v, want accepted upload with status timeout recorded", err)
|
||||
}
|
||||
if result.Status != "running" || result.StatusError == "" {
|
||||
t.Fatalf("result = %#v, want latest status and status timeout", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadFailsWhenDistributorRunFailed(t *testing.T) {
|
||||
cfg := config.Defaults().Notify.Distributor
|
||||
cfg.Endpoint = "https://distributor.example.test"
|
||||
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||
factory := &fakeUploadFactory{
|
||||
client: &fakeUploadClient{
|
||||
result: uploadFilesResult{RunID: "run-123", Status: "accepted"},
|
||||
status: runStatus{
|
||||
RunID: "run-123",
|
||||
Status: "failed",
|
||||
Error: "destination rejected secret-token",
|
||||
Report: json.RawMessage(`{"actions":[{"action":"failed"}]}`),
|
||||
},
|
||||
},
|
||||
}
|
||||
client := newClient(cfg, factory.newClient)
|
||||
|
||||
result, err := client.Upload(context.Background(), validUploadRequest())
|
||||
if err == nil {
|
||||
t.Fatal("Upload() error = nil, want failed distributor run error")
|
||||
}
|
||||
if result.RunStatus == nil || result.RunStatus.Status != "failed" || !strings.Contains(string(result.RunStatus.Report), "failed") {
|
||||
t.Fatalf("result = %#v, want failed run status report", result)
|
||||
}
|
||||
if strings.Contains(err.Error(), "secret-token") || strings.Contains(result.RunStatus.Error, "secret-token") {
|
||||
t.Fatalf("error/result leaked token: err=%q result=%#v", err.Error(), result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadPreservesIdempotencyConflictDiagnosis(t *testing.T) {
|
||||
cfg := config.Defaults().Notify.Distributor
|
||||
cfg.Endpoint = "https://distributor.example.test"
|
||||
t.Setenv(cfg.TokenEnv, "secret-token")
|
||||
factory := &fakeUploadFactory{
|
||||
client: &fakeUploadClient{
|
||||
err: &distributorupload.IdempotencyConflictError{
|
||||
HTTPError: distributorupload.HTTPError{
|
||||
StatusCode: 409,
|
||||
Status: "409 Conflict",
|
||||
Message: "conflicting upload for secret-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
client := newClient(cfg, factory.newClient)
|
||||
|
||||
_, err := client.Upload(context.Background(), validUploadRequest())
|
||||
if err == nil {
|
||||
t.Fatal("Upload() error = nil, want error")
|
||||
}
|
||||
var conflict *IdempotencyConflictError
|
||||
if !errors.As(err, &conflict) {
|
||||
t.Fatalf("Upload() error = %T %v, want IdempotencyConflictError", err, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "idempotency conflict") {
|
||||
t.Fatalf("error = %q, want idempotency conflict diagnosis", err.Error())
|
||||
}
|
||||
if strings.Contains(err.Error(), "secret-token") {
|
||||
t.Fatalf("error = %q, want no token value", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func validUploadRequest() UploadRequest {
|
||||
return UploadRequest{
|
||||
PipelineID: "weatherreporter.daily",
|
||||
BundleID: "weatherreporter.home.daily.run",
|
||||
IdempotencyKey: "weatherreporter.home.daily.run",
|
||||
Files: []UploadFile{
|
||||
{SourcePath: "/tmp/report.md", BundlePath: "2026-06-07/daily/report.md"},
|
||||
{SourcePath: "/tmp/report.md", BundlePath: "2026-06-07/daily/latest.md"},
|
||||
},
|
||||
CreatedAt: time.Date(2026, 6, 7, 12, 0, 0, 123, time.UTC),
|
||||
}
|
||||
}
|
||||
|
||||
type fakeUploadFactory struct {
|
||||
endpoint string
|
||||
token string
|
||||
timeout time.Duration
|
||||
client *fakeUploadClient
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeUploadFactory) newClient(endpoint, token string, timeout time.Duration) (uploadClient, error) {
|
||||
f.endpoint = endpoint
|
||||
f.token = token
|
||||
f.timeout = timeout
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.client, nil
|
||||
}
|
||||
|
||||
type fakeUploadClient struct {
|
||||
opts uploadFilesOptions
|
||||
statusRunID string
|
||||
statusCalls int
|
||||
result uploadFilesResult
|
||||
status runStatus
|
||||
statuses []runStatus
|
||||
err error
|
||||
statusErr error
|
||||
}
|
||||
|
||||
func (c *fakeUploadClient) UploadFiles(ctx context.Context, opts uploadFilesOptions) (uploadFilesResult, error) {
|
||||
c.opts = opts
|
||||
if c.err != nil {
|
||||
return uploadFilesResult{}, c.err
|
||||
}
|
||||
return c.result, nil
|
||||
}
|
||||
|
||||
func (c *fakeUploadClient) Status(ctx context.Context, runID string) (runStatus, error) {
|
||||
c.statusRunID = runID
|
||||
c.statusCalls++
|
||||
if c.statusErr != nil {
|
||||
return runStatus{}, c.statusErr
|
||||
}
|
||||
if len(c.statuses) > 0 {
|
||||
index := c.statusCalls - 1
|
||||
if index >= len(c.statuses) {
|
||||
index = len(c.statuses) - 1
|
||||
}
|
||||
return c.statuses[index], nil
|
||||
}
|
||||
return c.status, nil
|
||||
}
|
||||
@@ -79,6 +79,12 @@ type RunRequest struct {
|
||||
OutputPath string
|
||||
}
|
||||
|
||||
type StructuredRunRequest struct {
|
||||
PromptID string
|
||||
DataPackagePath string
|
||||
OutputPath string
|
||||
}
|
||||
|
||||
type RenderResult struct {
|
||||
Command []string `json:"command"`
|
||||
Stdout string `json:"stdout"`
|
||||
@@ -98,6 +104,16 @@ type RunResult struct {
|
||||
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")
|
||||
@@ -124,6 +140,82 @@ func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, e
|
||||
}
|
||||
|
||||
func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
||||
result, err := r.executeRun(ctx, outputRunRequest{
|
||||
PromptID: req.PromptID,
|
||||
DataPackagePath: req.DataPackagePath,
|
||||
OutputPath: req.OutputPath,
|
||||
}, "run scriptorium", "scriptorium run")
|
||||
if err != nil {
|
||||
if result == nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.runResult(), err
|
||||
}
|
||||
return result.runResult(), nil
|
||||
}
|
||||
|
||||
func (r Runner) StructuredRun(ctx context.Context, req StructuredRunRequest) (*StructuredRunResult, error) {
|
||||
result, err := r.executeRun(ctx, outputRunRequest{
|
||||
PromptID: req.PromptID,
|
||||
DataPackagePath: req.DataPackagePath,
|
||||
OutputPath: req.OutputPath,
|
||||
}, "run scriptorium structured output", "scriptorium structured run")
|
||||
if err != nil {
|
||||
if result == nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.structuredRunResult(), err
|
||||
}
|
||||
return result.structuredRunResult(), nil
|
||||
}
|
||||
|
||||
func (result outputRunResult) runResult() *RunResult {
|
||||
return &RunResult{
|
||||
Command: result.Command,
|
||||
Stdout: result.Stdout,
|
||||
Stderr: result.Stderr,
|
||||
StdoutTruncated: result.StdoutTruncated,
|
||||
StderrTruncated: result.StderrTruncated,
|
||||
ExitCode: result.ExitCode,
|
||||
OutputPath: result.OutputPath,
|
||||
}
|
||||
}
|
||||
|
||||
func (result outputRunResult) structuredRunResult() *StructuredRunResult {
|
||||
return &StructuredRunResult{
|
||||
Command: result.Command,
|
||||
Stdout: result.Stdout,
|
||||
Stderr: result.Stderr,
|
||||
StdoutTruncated: result.StdoutTruncated,
|
||||
StderrTruncated: result.StderrTruncated,
|
||||
ExitCode: result.ExitCode,
|
||||
OutputPath: result.OutputPath,
|
||||
}
|
||||
}
|
||||
|
||||
type execution struct {
|
||||
binary string
|
||||
args []string
|
||||
result CommandResult
|
||||
}
|
||||
|
||||
type outputRunRequest struct {
|
||||
PromptID string
|
||||
DataPackagePath string
|
||||
OutputPath string
|
||||
}
|
||||
|
||||
type outputRunResult struct {
|
||||
Command []string
|
||||
Stdout string
|
||||
Stderr string
|
||||
StdoutTruncated bool
|
||||
StderrTruncated bool
|
||||
ExitCode int
|
||||
OutputPath string
|
||||
}
|
||||
|
||||
func (r Runner) executeRun(ctx context.Context, req outputRunRequest, executeContext string, exitContext string) (*outputRunResult, error) {
|
||||
if req.PromptID == "" {
|
||||
return nil, fmt.Errorf("prompt id is required")
|
||||
}
|
||||
@@ -133,11 +225,15 @@ func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
||||
if req.OutputPath == "" {
|
||||
return nil, fmt.Errorf("output path is required")
|
||||
}
|
||||
execution, err := r.execute(ctx, r.runArgs(req))
|
||||
execution, err := r.execute(ctx, r.runArgs(RunRequest{
|
||||
PromptID: req.PromptID,
|
||||
DataPackagePath: req.DataPackagePath,
|
||||
OutputPath: req.OutputPath,
|
||||
}))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("run scriptorium: %w", err)
|
||||
return nil, fmt.Errorf("%s: %w", executeContext, err)
|
||||
}
|
||||
result := &RunResult{
|
||||
result := &outputRunResult{
|
||||
Command: execution.argv(),
|
||||
Stdout: string(execution.result.Stdout),
|
||||
Stderr: string(execution.result.Stderr),
|
||||
@@ -147,17 +243,11 @@ func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
||||
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, fmt.Errorf("%s exited with code %d: %s", exitContext, 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 == "" {
|
||||
|
||||
@@ -2,6 +2,7 @@ package scriptorium
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -19,8 +20,8 @@ func TestRenderConstructsCommand(t *testing.T) {
|
||||
}
|
||||
|
||||
result, err := runner.Render(context.Background(), RenderRequest{
|
||||
PromptID: "weather.daily_report",
|
||||
DataPackagePath: "/tmp/data_package.json",
|
||||
PromptID: "weather.markdown_report",
|
||||
DataPackagePath: "/tmp/data_package.yaml",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Render() error = %v", err)
|
||||
@@ -30,8 +31,8 @@ func TestRenderConstructsCommand(t *testing.T) {
|
||||
"render",
|
||||
"--config", "/etc/scriptorium.yml",
|
||||
"--profile", "weather",
|
||||
"--prompt", "weather.daily_report",
|
||||
"--input", "data_package=/tmp/data_package.json",
|
||||
"--prompt", "weather.markdown_report",
|
||||
"--input", "data_package=/tmp/data_package.yaml",
|
||||
"--format", "json",
|
||||
}
|
||||
if commands.name != "/usr/local/bin/scriptorium" {
|
||||
@@ -56,8 +57,8 @@ func TestRenderReturnsResultForNonzeroExit(t *testing.T) {
|
||||
}
|
||||
|
||||
result, err := runner.Render(context.Background(), RenderRequest{
|
||||
PromptID: "weather.daily_report",
|
||||
DataPackagePath: "/tmp/data_package.json",
|
||||
PromptID: "weather.markdown_report",
|
||||
DataPackagePath: "/tmp/data_package.yaml",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Render() error = nil, want nonzero exit error")
|
||||
@@ -84,8 +85,8 @@ func TestRunConstructsCommand(t *testing.T) {
|
||||
}
|
||||
|
||||
result, err := runner.Run(context.Background(), RunRequest{
|
||||
PromptID: "weather.daily_report",
|
||||
DataPackagePath: "/tmp/data_package.json",
|
||||
PromptID: "weather.markdown_report",
|
||||
DataPackagePath: "/tmp/data_package.yaml",
|
||||
OutputPath: "/tmp/daily.md",
|
||||
})
|
||||
if err != nil {
|
||||
@@ -96,8 +97,8 @@ func TestRunConstructsCommand(t *testing.T) {
|
||||
"run",
|
||||
"--config", "/etc/scriptorium.yml",
|
||||
"--profile", "weather",
|
||||
"--prompt", "weather.daily_report",
|
||||
"--input", "data_package=/tmp/data_package.json",
|
||||
"--prompt", "weather.markdown_report",
|
||||
"--input", "data_package=/tmp/data_package.yaml",
|
||||
"--out", "/tmp/daily.md",
|
||||
}
|
||||
if commands.name != "/usr/local/bin/scriptorium" {
|
||||
@@ -129,8 +130,8 @@ func TestRunReturnsResultForValidationExit(t *testing.T) {
|
||||
}
|
||||
|
||||
result, err := runner.Run(context.Background(), RunRequest{
|
||||
PromptID: "weather.daily_report",
|
||||
DataPackagePath: "/tmp/data_package.json",
|
||||
PromptID: "weather.markdown_report",
|
||||
DataPackagePath: "/tmp/data_package.yaml",
|
||||
OutputPath: "/tmp/daily.md",
|
||||
})
|
||||
if err == nil {
|
||||
@@ -147,17 +148,397 @@ func TestRunReturnsResultForValidationExit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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/data_package.hourly.yaml",
|
||||
OutputPath: "/tmp/generated_text_raw.hourly.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/data_package.hourly.yaml",
|
||||
"--out", "/tmp/generated_text_raw.hourly.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/generated_text_raw.hourly.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/data_package.hourly.yaml",
|
||||
OutputPath: "/tmp/generated_text_raw.hourly.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/generated_text_raw.hourly.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 TestOutputRunsPreserveCapturedResultFields(t *testing.T) {
|
||||
type commonResult struct {
|
||||
Command []string
|
||||
Stdout string
|
||||
Stderr string
|
||||
StdoutTruncated bool
|
||||
StderrTruncated bool
|
||||
ExitCode int
|
||||
OutputPath string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
run func(Runner) (*commonResult, error)
|
||||
}{
|
||||
{
|
||||
name: "Run",
|
||||
run: func(runner Runner) (*commonResult, error) {
|
||||
result, err := runner.Run(context.Background(), RunRequest{
|
||||
PromptID: "weather.markdown_report",
|
||||
DataPackagePath: "/tmp/data_package.yaml",
|
||||
OutputPath: "/tmp/report.md",
|
||||
})
|
||||
if result == nil {
|
||||
return nil, err
|
||||
}
|
||||
return &commonResult{
|
||||
Command: result.Command,
|
||||
Stdout: result.Stdout,
|
||||
Stderr: result.Stderr,
|
||||
StdoutTruncated: result.StdoutTruncated,
|
||||
StderrTruncated: result.StderrTruncated,
|
||||
ExitCode: result.ExitCode,
|
||||
OutputPath: result.OutputPath,
|
||||
}, err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "StructuredRun",
|
||||
run: func(runner Runner) (*commonResult, error) {
|
||||
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
|
||||
PromptID: "weather.markdown_report",
|
||||
DataPackagePath: "/tmp/data_package.yaml",
|
||||
OutputPath: "/tmp/report.md",
|
||||
})
|
||||
if result == nil {
|
||||
return nil, err
|
||||
}
|
||||
return &commonResult{
|
||||
Command: result.Command,
|
||||
Stdout: result.Stdout,
|
||||
Stderr: result.Stderr,
|
||||
StdoutTruncated: result.StdoutTruncated,
|
||||
StderrTruncated: result.StderrTruncated,
|
||||
ExitCode: result.ExitCode,
|
||||
OutputPath: result.OutputPath,
|
||||
}, err
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
commands := &fakeCommands{result: CommandResult{
|
||||
Stdout: []byte("captured stdout"),
|
||||
Stderr: []byte("captured stderr"),
|
||||
StdoutTruncated: true,
|
||||
StderrTruncated: true,
|
||||
}}
|
||||
runner := Runner{
|
||||
Binary: "/usr/local/bin/scriptorium",
|
||||
ConfigPath: "/etc/scriptorium.yml",
|
||||
Profile: "weather",
|
||||
Timeout: 15 * time.Second,
|
||||
Commands: commands,
|
||||
}
|
||||
|
||||
result, err := test.run(runner)
|
||||
if err != nil {
|
||||
t.Fatalf("%s error = %v", test.name, err)
|
||||
}
|
||||
wantArgs := []string{
|
||||
"run",
|
||||
"--config", "/etc/scriptorium.yml",
|
||||
"--profile", "weather",
|
||||
"--prompt", "weather.markdown_report",
|
||||
"--input", "data_package=/tmp/data_package.yaml",
|
||||
"--out", "/tmp/report.md",
|
||||
}
|
||||
if !reflect.DeepEqual(commands.args, wantArgs) {
|
||||
t.Fatalf("args = %#v, want %#v", commands.args, wantArgs)
|
||||
}
|
||||
if commands.timeout != 15*time.Second {
|
||||
t.Fatalf("timeout = %s, want 15s", commands.timeout)
|
||||
}
|
||||
if !reflect.DeepEqual(result.Command, append([]string{"/usr/local/bin/scriptorium"}, wantArgs...)) {
|
||||
t.Fatalf("Command = %#v, want full argv", result.Command)
|
||||
}
|
||||
if result.Stdout != "captured stdout" || result.Stderr != "captured stderr" {
|
||||
t.Fatalf("captured output = %q/%q, want stdout/stderr", result.Stdout, result.Stderr)
|
||||
}
|
||||
if !result.StdoutTruncated || !result.StderrTruncated {
|
||||
t.Fatalf("truncation flags = %t/%t, want both true", result.StdoutTruncated, result.StderrTruncated)
|
||||
}
|
||||
if result.ExitCode != 0 || result.OutputPath != "/tmp/report.md" {
|
||||
t.Fatalf("result = %#v, want exit 0 and output path", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputRunsReturnCapturedResultForNonzeroExit(t *testing.T) {
|
||||
type commonResult struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
StderrTruncated bool
|
||||
ExitCode int
|
||||
OutputPath string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
run func(Runner) (*commonResult, error)
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "Run",
|
||||
run: func(runner Runner) (*commonResult, error) {
|
||||
result, err := runner.Run(context.Background(), RunRequest{
|
||||
PromptID: "weather.markdown_report",
|
||||
DataPackagePath: "/tmp/data_package.yaml",
|
||||
OutputPath: "/tmp/report.md",
|
||||
})
|
||||
if result == nil {
|
||||
return nil, err
|
||||
}
|
||||
return &commonResult{
|
||||
Stdout: result.Stdout,
|
||||
Stderr: result.Stderr,
|
||||
StderrTruncated: result.StderrTruncated,
|
||||
ExitCode: result.ExitCode,
|
||||
OutputPath: result.OutputPath,
|
||||
}, err
|
||||
},
|
||||
wantErr: "scriptorium run exited with code 7: captured stderr",
|
||||
},
|
||||
{
|
||||
name: "StructuredRun",
|
||||
run: func(runner Runner) (*commonResult, error) {
|
||||
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
|
||||
PromptID: "weather.markdown_report",
|
||||
DataPackagePath: "/tmp/data_package.yaml",
|
||||
OutputPath: "/tmp/report.md",
|
||||
})
|
||||
if result == nil {
|
||||
return nil, err
|
||||
}
|
||||
return &commonResult{
|
||||
Stdout: result.Stdout,
|
||||
Stderr: result.Stderr,
|
||||
StderrTruncated: result.StderrTruncated,
|
||||
ExitCode: result.ExitCode,
|
||||
OutputPath: result.OutputPath,
|
||||
}, err
|
||||
},
|
||||
wantErr: "scriptorium structured run exited with code 7: captured stderr",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
runner := Runner{
|
||||
Commands: &fakeCommands{result: CommandResult{
|
||||
Stdout: []byte("captured stdout"),
|
||||
Stderr: []byte("captured stderr"),
|
||||
StderrTruncated: true,
|
||||
ExitCode: 7,
|
||||
}},
|
||||
}
|
||||
|
||||
result, err := test.run(runner)
|
||||
if err == nil {
|
||||
t.Fatalf("%s error = nil, want nonzero exit error", test.name)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatalf("%s result = nil, want captured result", test.name)
|
||||
}
|
||||
if err.Error() != test.wantErr {
|
||||
t.Fatalf("%s error = %q, want %q", test.name, err.Error(), test.wantErr)
|
||||
}
|
||||
if result.Stdout != "captured stdout" || result.Stderr != "captured stderr" || !result.StderrTruncated {
|
||||
t.Fatalf("captured result = %#v, want stdout/stderr/truncation", result)
|
||||
}
|
||||
if result.ExitCode != 7 || result.OutputPath != "/tmp/report.md" {
|
||||
t.Fatalf("result = %#v, want exit 7 and output path", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputRunsValidateRequiredFieldsBeforeExecution(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
run func(Runner, string, string, string) error
|
||||
}{
|
||||
{
|
||||
name: "Run",
|
||||
run: func(runner Runner, promptID string, dataPackagePath string, outputPath string) error {
|
||||
result, err := runner.Run(context.Background(), RunRequest{
|
||||
PromptID: promptID,
|
||||
DataPackagePath: dataPackagePath,
|
||||
OutputPath: outputPath,
|
||||
})
|
||||
if result != nil {
|
||||
return fmt.Errorf("result = %#v, want nil", result)
|
||||
}
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "StructuredRun",
|
||||
run: func(runner Runner, promptID string, dataPackagePath string, outputPath string) error {
|
||||
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
|
||||
PromptID: promptID,
|
||||
DataPackagePath: dataPackagePath,
|
||||
OutputPath: outputPath,
|
||||
})
|
||||
if result != nil {
|
||||
return fmt.Errorf("result = %#v, want nil", result)
|
||||
}
|
||||
return err
|
||||
},
|
||||
},
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
promptID string
|
||||
dataPackagePath string
|
||||
outputPath string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "prompt id",
|
||||
dataPackagePath: "/tmp/data_package.yaml",
|
||||
outputPath: "/tmp/report.md",
|
||||
want: "prompt id is required",
|
||||
},
|
||||
{
|
||||
name: "data package path",
|
||||
promptID: "weather.markdown_report",
|
||||
outputPath: "/tmp/report.md",
|
||||
want: "data package path is required",
|
||||
},
|
||||
{
|
||||
name: "output path",
|
||||
promptID: "weather.markdown_report",
|
||||
dataPackagePath: "/tmp/data_package.yaml",
|
||||
want: "output path is required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
commands := &fakeCommands{}
|
||||
err := test.run(Runner{Commands: commands}, tc.promptID, tc.dataPackagePath, tc.outputPath)
|
||||
if err == nil {
|
||||
t.Fatalf("%s error = nil, want validation error", test.name)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("%s error = %v, want %q", test.name, err, tc.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
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package weatherapi adapts the internal weather API to forecast bundles.
|
||||
// Package weatherapi adapts the internal weather API to weather data bundles.
|
||||
package weatherapi
|
||||
|
||||
import (
|
||||
@@ -18,7 +18,18 @@ import (
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
const (
|
||||
convectiveOutlooksEndpoint = "/outlooks/convective"
|
||||
sourceSPCConvectiveOutlooks = "spc_convective_outlooks"
|
||||
|
||||
defaultWarmupEndpoint = "/conditions/current"
|
||||
defaultWarmupAttempts = 3
|
||||
defaultWarmupDelay = time.Second
|
||||
defaultFetchAttempts = 2
|
||||
defaultFetchRetryDelay = time.Second
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
@@ -30,6 +41,12 @@ type Client struct {
|
||||
precision int
|
||||
missingSource config.MissingSourceConfig
|
||||
now func() time.Time
|
||||
|
||||
warmupEndpoint string
|
||||
warmupAttempts int
|
||||
warmupDelay time.Duration
|
||||
fetchAttempts int
|
||||
fetchRetryDelay time.Duration
|
||||
}
|
||||
|
||||
type Option func(*Client)
|
||||
@@ -76,6 +93,11 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
|
||||
Sources: cfg.MissingSource.Sources,
|
||||
},
|
||||
now: time.Now,
|
||||
warmupEndpoint: defaultWarmupEndpoint,
|
||||
warmupAttempts: defaultWarmupAttempts,
|
||||
warmupDelay: defaultWarmupDelay,
|
||||
fetchAttempts: defaultFetchAttempts,
|
||||
fetchRetryDelay: defaultFetchRetryDelay,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(client)
|
||||
@@ -83,11 +105,15 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *Client) FetchBundle(ctx context.Context) (*forecast.Bundle, error) {
|
||||
func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
|
||||
if err := c.warmup(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fetchedAt := c.now()
|
||||
builder := bundleBuilder{
|
||||
client: c,
|
||||
bundle: &forecast.Bundle{FetchedAt: fetchedAt},
|
||||
bundle: &weatherdata.Bundle{FetchedAt: fetchedAt},
|
||||
fetchedAt: fetchedAt,
|
||||
}
|
||||
|
||||
@@ -109,10 +135,10 @@ func (c *Client) FetchBundle(ctx context.Context) (*forecast.Bundle, error) {
|
||||
if err := builder.fetchDiscussion(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := builder.addStub("daily", "daily forecast data is not available from the weather API yet"); err != nil {
|
||||
if err := builder.fetchWeatherStory(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := builder.addStub("weather_story", "NWS weather story is not available from the weather API yet"); err != nil {
|
||||
if err := builder.fetchSPCConvectiveOutlooks(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -121,22 +147,36 @@ func (c *Client) FetchBundle(ctx context.Context) (*forecast.Bundle, error) {
|
||||
|
||||
type bundleBuilder struct {
|
||||
client *Client
|
||||
bundle *forecast.Bundle
|
||||
bundle *weatherdata.Bundle
|
||||
fetchedAt time.Time
|
||||
}
|
||||
|
||||
type sourceRequest struct {
|
||||
name string
|
||||
endpoint string
|
||||
query queryOptions
|
||||
missingMessage string
|
||||
required bool
|
||||
decodeLabel string
|
||||
}
|
||||
|
||||
type fetchedSource struct {
|
||||
raw json.RawMessage
|
||||
source weatherdata.Source
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, "observations", "/observations", queryOptions{precision: true})
|
||||
if err != nil {
|
||||
var observation weatherdata.Observation
|
||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||
name: "observations",
|
||||
endpoint: "/observations",
|
||||
query: queryOptions{precision: true},
|
||||
missingMessage: "observation data is missing",
|
||||
}, &observation)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "observation data is missing", false)
|
||||
}
|
||||
var observation forecast.Observation
|
||||
if err := decodeSource(raw, &observation); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
}
|
||||
source := fetched.source
|
||||
source.IssuedAt = &observation.Timestamp
|
||||
b.bundle.Observation = &observation
|
||||
b.addSource(source)
|
||||
@@ -144,34 +184,36 @@ func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, "current", "/conditions/current", queryOptions{precision: true})
|
||||
if err != nil {
|
||||
var current weatherdata.Current
|
||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||
name: "current",
|
||||
endpoint: "/conditions/current",
|
||||
query: queryOptions{precision: true},
|
||||
missingMessage: "current conditions data is missing",
|
||||
}, ¤t)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "current conditions data is missing", false)
|
||||
}
|
||||
var current forecast.Current
|
||||
if err := decodeSource(raw, ¤t); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
}
|
||||
source := fetched.source
|
||||
b.bundle.Current = ¤t
|
||||
b.addSource(source)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, "hourly", "/forecast/hourly", queryOptions{precision: true, timezone: true})
|
||||
if err != nil {
|
||||
var hourly weatherdata.ForecastRun
|
||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||
name: "hourly",
|
||||
endpoint: "/forecast/hourly",
|
||||
query: queryOptions{precision: true, timezone: true},
|
||||
missingMessage: "hourly forecast data is missing",
|
||||
required: true,
|
||||
decodeLabel: "hourly forecast",
|
||||
}, &hourly)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "hourly forecast data is missing", true)
|
||||
}
|
||||
var hourly forecast.ForecastRun
|
||||
if err := decodeSource(raw, &hourly); err != nil {
|
||||
return fmt.Errorf("decode hourly forecast from %s: %w", source.Endpoint, err)
|
||||
}
|
||||
source := fetched.source
|
||||
if len(hourly.Periods) == 0 {
|
||||
return fmt.Errorf("hourly forecast from %s contains no periods", source.Endpoint)
|
||||
}
|
||||
@@ -183,17 +225,17 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, "narrative", "/forecast/narrative", queryOptions{precision: true, timezone: true})
|
||||
if err != nil {
|
||||
var narrative weatherdata.ForecastRun
|
||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||
name: "narrative",
|
||||
endpoint: "/forecast/narrative",
|
||||
query: queryOptions{precision: true, timezone: true},
|
||||
missingMessage: "narrative forecast data is missing",
|
||||
}, &narrative)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "narrative forecast data is missing", false)
|
||||
}
|
||||
var narrative forecast.ForecastRun
|
||||
if err := decodeSource(raw, &narrative); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
}
|
||||
source := fetched.source
|
||||
source.IssuedAt = &narrative.IssuedAt
|
||||
source.UpdatedAt = narrative.UpdatedAt
|
||||
b.bundle.Narrative = &narrative
|
||||
@@ -210,13 +252,13 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
||||
return b.handleMissing(&source, "active alerts data is missing", false)
|
||||
}
|
||||
if isJSONNull(raw) {
|
||||
b.bundle.Alerts = &forecast.AlertRun{Raw: append(json.RawMessage(nil), raw...)}
|
||||
b.bundle.Alerts = &weatherdata.AlertRun{Raw: append(json.RawMessage(nil), raw...)}
|
||||
b.addSource(source)
|
||||
return nil
|
||||
}
|
||||
var alerts forecast.AlertRun
|
||||
var alerts weatherdata.AlertRun
|
||||
if err := decodeSource(raw, &alerts); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
return b.handleMalformed(&source, err, sourceRequest{name: "alerts"})
|
||||
}
|
||||
alerts.Raw = append(json.RawMessage(nil), raw...)
|
||||
if alerts.AsOf != nil {
|
||||
@@ -228,17 +270,17 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, "discussion", "/discussion", queryOptions{timezone: true})
|
||||
if err != nil {
|
||||
var discussion weatherdata.Discussion
|
||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||
name: "discussion",
|
||||
endpoint: "/discussion",
|
||||
query: queryOptions{timezone: true},
|
||||
missingMessage: "forecast discussion data is missing",
|
||||
}, &discussion)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "forecast discussion data is missing", false)
|
||||
}
|
||||
var discussion forecast.Discussion
|
||||
if err := decodeSource(raw, &discussion); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
}
|
||||
source := fetched.source
|
||||
source.IssuedAt = &discussion.IssuedAt
|
||||
source.UpdatedAt = discussion.UpdatedAt
|
||||
b.bundle.Discussion = &discussion
|
||||
@@ -246,16 +288,73 @@ func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) addStub(sourceName string, message string) error {
|
||||
source := forecast.Source{
|
||||
Name: sourceName,
|
||||
FetchedAt: b.fetchedAt,
|
||||
Missing: true,
|
||||
func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
||||
var story weatherdata.WeatherStory
|
||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||
name: "weather_story",
|
||||
endpoint: "/weatherstories/latest",
|
||||
query: queryOptions{omitUnits: true},
|
||||
missingMessage: "NWS weather story data is missing",
|
||||
}, &story)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
return b.applyMissingPolicy(&source, "missing_source", message)
|
||||
source := fetched.source
|
||||
if !story.StartTime.IsZero() {
|
||||
source.IssuedAt = &story.StartTime
|
||||
}
|
||||
source.UpdatedAt = story.UpdatedAt
|
||||
b.bundle.WeatherStory = &story
|
||||
b.addSource(source)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) handleMissing(source *forecast.Source, message string, required bool) error {
|
||||
func (b *bundleBuilder) fetchSPCConvectiveOutlooks(ctx context.Context) error {
|
||||
var run weatherdata.ConvectiveOutlookRun
|
||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||
name: sourceSPCConvectiveOutlooks,
|
||||
endpoint: convectiveOutlooksEndpoint,
|
||||
query: queryOptions{timezone: true, omitUnits: true},
|
||||
missingMessage: "SPC convective outlook data is missing",
|
||||
}, &run)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
source := fetched.source
|
||||
if run.IssuedAt != nil {
|
||||
source.IssuedAt = run.IssuedAt
|
||||
} else {
|
||||
source.IssuedAt = run.AsOf
|
||||
}
|
||||
source.UpdatedAt = run.UpdatedAt
|
||||
b.bundle.SPCConvectiveOutlooks = &run
|
||||
b.addSource(source)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchDecodedSource(ctx context.Context, request sourceRequest, target any) (fetchedSource, bool, error) {
|
||||
fetched, ok, err := b.fetchSource(ctx, request)
|
||||
if err != nil || !ok {
|
||||
return fetchedSource{}, false, err
|
||||
}
|
||||
if err := decodeSource(fetched.raw, target); err != nil {
|
||||
return fetchedSource{}, false, b.handleMalformed(&fetched.source, err, request)
|
||||
}
|
||||
return fetched, true, nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchSource(ctx context.Context, request sourceRequest) (fetchedSource, bool, error) {
|
||||
raw, source, err := b.client.fetch(ctx, request.name, request.endpoint, request.query)
|
||||
if err != nil {
|
||||
return fetchedSource{}, false, err
|
||||
}
|
||||
if raw == nil {
|
||||
return fetchedSource{}, false, b.handleMissing(&source, request.missingMessage, request.required)
|
||||
}
|
||||
return fetchedSource{raw: raw, source: source}, true, nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error {
|
||||
source.Missing = true
|
||||
if required {
|
||||
return fmt.Errorf("%s from %s is required", message, source.Endpoint)
|
||||
@@ -263,21 +362,25 @@ func (b *bundleBuilder) handleMissing(source *forecast.Source, message string, r
|
||||
return b.applyMissingPolicy(source, "missing_source", message)
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) handleMalformed(source *forecast.Source, err error, required bool) error {
|
||||
if required {
|
||||
return fmt.Errorf("decode %s from %s: %w", source.Name, source.Endpoint, err)
|
||||
func (b *bundleBuilder) handleMalformed(source *weatherdata.Source, err error, request sourceRequest) error {
|
||||
if request.required {
|
||||
label := request.name
|
||||
if request.decodeLabel != "" {
|
||||
label = request.decodeLabel
|
||||
}
|
||||
return fmt.Errorf("decode %s from %s: %w", label, source.Endpoint, err)
|
||||
}
|
||||
source.Missing = true
|
||||
return b.applyMissingPolicy(source, "malformed_source", fmt.Sprintf("malformed %s data: %v", source.Name, err))
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) applyMissingPolicy(source *forecast.Source, code string, message string) error {
|
||||
func (b *bundleBuilder) applyMissingPolicy(source *weatherdata.Source, code string, message string) error {
|
||||
policy := b.client.policyFor(source.Name)
|
||||
if policy == config.MissingSourceError {
|
||||
return fmt.Errorf("%s: %s", source.Name, message)
|
||||
}
|
||||
if policy == config.MissingSourceWarn {
|
||||
warning := forecast.SourceWarning{
|
||||
warning := weatherdata.SourceWarning{
|
||||
Source: source.Name,
|
||||
Code: code,
|
||||
Severity: "warning",
|
||||
@@ -292,7 +395,7 @@ func (b *bundleBuilder) applyMissingPolicy(source *forecast.Source, code string,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) addSource(source forecast.Source) {
|
||||
func (b *bundleBuilder) addSource(source weatherdata.Source) {
|
||||
b.bundle.Sources = append(b.bundle.Sources, source)
|
||||
}
|
||||
|
||||
@@ -307,39 +410,25 @@ type queryOptions struct {
|
||||
precision bool
|
||||
timezone bool
|
||||
allowNull bool
|
||||
omitUnits bool
|
||||
}
|
||||
|
||||
type envelope struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string, opts queryOptions) (json.RawMessage, forecast.Source, error) {
|
||||
reqURL := c.endpointURL(endpoint, opts)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
|
||||
func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string, opts queryOptions) (json.RawMessage, weatherdata.Source, error) {
|
||||
reqURL, body, err := c.fetchHTTP(ctx, endpoint, opts)
|
||||
if err != nil {
|
||||
return nil, forecast.Source{}, fmt.Errorf("create request for %s: %w", endpoint, err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, forecast.Source{}, fmt.Errorf("fetch %s: %w", endpoint, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
||||
if err != nil {
|
||||
return nil, forecast.Source{}, fmt.Errorf("read %s response: %w", endpoint, err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, forecast.Source{}, fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
return nil, weatherdata.Source{}, err
|
||||
}
|
||||
|
||||
var env envelope
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
return nil, forecast.Source{}, fmt.Errorf("decode %s envelope: %w", endpoint, err)
|
||||
return nil, weatherdata.Source{}, fmt.Errorf("decode %s envelope: %w", endpoint, err)
|
||||
}
|
||||
|
||||
source := forecast.Source{
|
||||
source := weatherdata.Source{
|
||||
Name: sourceName,
|
||||
Endpoint: endpoint,
|
||||
Query: queryMap(reqURL.Query()),
|
||||
@@ -357,6 +446,169 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
|
||||
return env.Data, source, nil
|
||||
}
|
||||
|
||||
func (c *Client) warmup(ctx context.Context) error {
|
||||
endpoint := c.warmupEndpoint
|
||||
if strings.TrimSpace(endpoint) == "" {
|
||||
endpoint = defaultWarmupEndpoint
|
||||
}
|
||||
attempts := positiveAttemptCount(c.warmupAttempts)
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= attempts; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fmt.Errorf("warm up weather API via %s: %w", endpoint, err)
|
||||
}
|
||||
if err := c.warmupOnce(ctx, endpoint); err != nil {
|
||||
lastErr = err
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
if attempt == attempts {
|
||||
break
|
||||
}
|
||||
if err := waitForRetry(ctx, c.warmupDelay); err != nil {
|
||||
return fmt.Errorf("warm up weather API via %s after %d attempt(s): %w", endpoint, attempt, err)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("warm up weather API via %s failed after %d attempts: %w", endpoint, attempts, lastErr)
|
||||
}
|
||||
|
||||
func (c *Client) warmupOnce(ctx context.Context, endpoint string) error {
|
||||
reqURL := c.endpointURL(endpoint, queryOptions{precision: true})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request for %s: %w", endpoint, err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch %s: %w", endpoint, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s response: %w", endpoint, err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) fetchHTTP(ctx context.Context, endpoint string, opts queryOptions) (*url.URL, []byte, error) {
|
||||
attempts := positiveAttemptCount(c.fetchAttempts)
|
||||
var lastErr error
|
||||
var lastRetryable bool
|
||||
for attempt := 1; attempt <= attempts; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, nil, fmt.Errorf("fetch %s: %w", endpoint, err)
|
||||
}
|
||||
reqURL, body, err := c.fetchHTTPOnce(ctx, endpoint, opts)
|
||||
if err == nil {
|
||||
return reqURL, body, nil
|
||||
}
|
||||
lastErr = err
|
||||
lastRetryable = isRetryableRequestError(err)
|
||||
if !lastRetryable || attempt == attempts {
|
||||
break
|
||||
}
|
||||
if err := waitForRetry(ctx, c.fetchRetryDelay); err != nil {
|
||||
return nil, nil, fmt.Errorf("fetch %s retry delay after attempt %d: %w", endpoint, attempt, err)
|
||||
}
|
||||
}
|
||||
if lastRetryable {
|
||||
return nil, nil, fmt.Errorf("fetch %s failed after %d attempts: %w", endpoint, attempts, lastErr)
|
||||
}
|
||||
return nil, nil, lastErr
|
||||
}
|
||||
|
||||
func (c *Client) fetchHTTPOnce(ctx context.Context, endpoint string, opts queryOptions) (*url.URL, []byte, error) {
|
||||
reqURL := c.endpointURL(endpoint, opts)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create request for %s: %w", endpoint, err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("fetch %s: %w", endpoint, err)
|
||||
if ctx.Err() != nil {
|
||||
return reqURL, nil, err
|
||||
}
|
||||
return reqURL, nil, retryableRequestError{err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
||||
if err != nil {
|
||||
err = fmt.Errorf("read %s response: %w", endpoint, err)
|
||||
if ctx.Err() != nil {
|
||||
return reqURL, nil, err
|
||||
}
|
||||
return reqURL, nil, retryableRequestError{err: err}
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
err := fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
if isRetryableHTTPStatus(resp.StatusCode) {
|
||||
return reqURL, nil, retryableRequestError{err: err}
|
||||
}
|
||||
return reqURL, nil, err
|
||||
}
|
||||
return reqURL, body, nil
|
||||
}
|
||||
|
||||
type retryableRequestError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e retryableRequestError) Error() string {
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
func (e retryableRequestError) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
func isRetryableRequestError(err error) bool {
|
||||
_, ok := err.(retryableRequestError)
|
||||
return ok
|
||||
}
|
||||
|
||||
func isRetryableHTTPStatus(status int) bool {
|
||||
switch status {
|
||||
case http.StatusRequestTimeout,
|
||||
http.StatusTooManyRequests,
|
||||
http.StatusInternalServerError,
|
||||
http.StatusBadGateway,
|
||||
http.StatusServiceUnavailable,
|
||||
http.StatusGatewayTimeout:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func waitForRetry(ctx context.Context, delay time.Duration) error {
|
||||
if delay <= 0 {
|
||||
return ctx.Err()
|
||||
}
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func positiveAttemptCount(attempts int) int {
|
||||
if attempts < 1 {
|
||||
return 1
|
||||
}
|
||||
return attempts
|
||||
}
|
||||
|
||||
func isJSONNull(raw json.RawMessage) bool {
|
||||
return bytes.Equal(bytes.TrimSpace(raw), []byte("null"))
|
||||
}
|
||||
@@ -366,7 +618,9 @@ func (c *Client) endpointURL(endpoint string, opts queryOptions) *url.URL {
|
||||
reqURL.Path = path.Join(c.baseURL.Path, endpoint)
|
||||
query := reqURL.Query()
|
||||
query.Set("format", c.format)
|
||||
if !opts.omitUnits {
|
||||
query.Set("units", c.units)
|
||||
}
|
||||
if opts.precision {
|
||||
query.Set("precision", strconv.Itoa(c.precision))
|
||||
}
|
||||
@@ -406,7 +660,7 @@ func sourceHash(raw json.RawMessage) (string, error) {
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func SaveBundle(path string, bundle *forecast.Bundle) error {
|
||||
func SaveBundle(path string, bundle *weatherdata.Bundle) error {
|
||||
if err := fileutil.WriteJSONAtomic(path, bundle); err != nil {
|
||||
return fmt.Errorf("save bundle: %w", err)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
func TestFetchBundleFromFixtures(t *testing.T) {
|
||||
@@ -49,11 +49,47 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
||||
if bundle.Discussion.LongTerm == nil || bundle.Discussion.LongTerm.Text != "Warmer temperatures and periodic rain chances continue into the weekend." {
|
||||
t.Fatalf("Discussion.LongTerm = %#v, want long-term AFD text", bundle.Discussion.LongTerm)
|
||||
}
|
||||
if bundle.WeatherStory == nil || bundle.WeatherStory.Title != "Several Chances for Rain Through Monday" {
|
||||
t.Fatalf("WeatherStory = %#v, want latest weather story", bundle.WeatherStory)
|
||||
}
|
||||
if bundle.WeatherStory.UpdatedAt == nil {
|
||||
t.Fatalf("WeatherStory.UpdatedAt = nil, want update timestamp")
|
||||
}
|
||||
if bundle.SPCConvectiveOutlooks == nil || len(bundle.SPCConvectiveOutlooks.Outlooks) != 1 {
|
||||
t.Fatalf("SPCConvectiveOutlooks = %#v, want one outlook", bundle.SPCConvectiveOutlooks)
|
||||
}
|
||||
if len(bundle.SPCConvectiveOutlooks.Outlooks[0].Geometry) == 0 {
|
||||
t.Fatalf("SPCConvectiveOutlooks.Outlooks[0].Geometry is empty, want GeoJSON")
|
||||
}
|
||||
if len(bundle.SPCConvectiveOutlooks.Discussions) != 1 || bundle.SPCConvectiveOutlooks.Discussions[0].Headline != "Severe storms possible" {
|
||||
t.Fatalf("SPCConvectiveOutlooks.Discussions = %#v, want one discussion", bundle.SPCConvectiveOutlooks.Discussions)
|
||||
}
|
||||
if len(bundle.Sources) != 8 {
|
||||
t.Fatalf("Sources length = %d, want 8", len(bundle.Sources))
|
||||
}
|
||||
if len(bundle.Warnings) != 2 {
|
||||
t.Fatalf("Warnings length = %d, want daily and weather story warnings", len(bundle.Warnings))
|
||||
if len(bundle.Warnings) != 0 {
|
||||
t.Fatalf("Warnings length = %d, want no warnings", len(bundle.Warnings))
|
||||
}
|
||||
wantPaths := []string{
|
||||
"/observations",
|
||||
"/conditions/current",
|
||||
"/forecast/hourly",
|
||||
"/forecast/narrative",
|
||||
"/alerts/active",
|
||||
"/discussion",
|
||||
"/weatherstories/latest",
|
||||
convectiveOutlooksEndpoint,
|
||||
}
|
||||
if len(requested) != len(wantPaths)+1 {
|
||||
t.Fatalf("requested paths = %v, want warmup plus %d source endpoints", requested, len(wantPaths))
|
||||
}
|
||||
if !strings.HasPrefix(requested[0], defaultWarmupEndpoint+"?") && requested[0] != defaultWarmupEndpoint {
|
||||
t.Fatalf("first requested path = %q, want warmup endpoint %s", requested[0], defaultWarmupEndpoint)
|
||||
}
|
||||
for _, want := range wantPaths {
|
||||
if !containsPath(requested, want) {
|
||||
t.Fatalf("requested paths = %v, want %s", requested, want)
|
||||
}
|
||||
}
|
||||
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
|
||||
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
|
||||
@@ -61,6 +97,12 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
||||
if !containsPath(requested, "/forecast/narrative") || containsPath(requested, "/forecast/narrative/today") {
|
||||
t.Fatalf("requested paths = %v, want full narrative endpoint only", requested)
|
||||
}
|
||||
if !containsPath(requested, "/weatherstories/latest") {
|
||||
t.Fatalf("requested paths = %v, want weather story endpoint", requested)
|
||||
}
|
||||
if !containsPath(requested, convectiveOutlooksEndpoint) {
|
||||
t.Fatalf("requested paths = %v, want convective outlook endpoint", requested)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
||||
@@ -74,13 +116,34 @@ func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, rawURL := range requested {
|
||||
if !strings.Contains(rawURL, "format=json") || !strings.Contains(rawURL, "units=us") {
|
||||
t.Fatalf("request %q missing format=json or units=us", rawURL)
|
||||
if !strings.Contains(rawURL, "format=json") {
|
||||
t.Fatalf("request %q missing format=json", rawURL)
|
||||
}
|
||||
if strings.HasPrefix(rawURL, "/weatherstories/") {
|
||||
if strings.Contains(rawURL, "units=") || strings.Contains(rawURL, "precision=") || strings.Contains(rawURL, "tz=") {
|
||||
t.Fatalf("weather story request %q should use format only", rawURL)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(rawURL, convectiveOutlooksEndpoint) {
|
||||
if strings.Contains(rawURL, "units=") || strings.Contains(rawURL, "precision=") || !strings.Contains(rawURL, "tz=America%2FChicago") {
|
||||
t.Fatalf("convective outlook request %q should use format and tz only", rawURL)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(rawURL, "units=us") {
|
||||
t.Fatalf("request %q missing units=us", rawURL)
|
||||
}
|
||||
if strings.HasPrefix(rawURL, "/forecast/") {
|
||||
if !strings.Contains(rawURL, "precision=1") || !strings.Contains(rawURL, "tz=America%2FChicago") {
|
||||
if !strings.Contains(rawURL, "precision=0") || !strings.Contains(rawURL, "tz=America%2FChicago") {
|
||||
t.Fatalf("forecast request %q missing precision or tz", rawURL)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if rawURL == defaultWarmupEndpoint || strings.HasPrefix(rawURL, defaultWarmupEndpoint+"?") || strings.HasPrefix(rawURL, "/observations?") {
|
||||
if !strings.Contains(rawURL, "precision=0") {
|
||||
t.Fatalf("request %q missing precision=0", rawURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,11 +162,40 @@ func TestFetchBundleRecordsSourceHash(t *testing.T) {
|
||||
if observation.DataSHA256 != want {
|
||||
t.Fatalf("DataSHA256 = %q, want %q", observation.DataSHA256, want)
|
||||
}
|
||||
story := sourceByName(t, bundle.Sources, "weather_story")
|
||||
if story.Endpoint != "/weatherstories/latest" {
|
||||
t.Fatalf("weather story endpoint = %q, want /weatherstories/latest", story.Endpoint)
|
||||
}
|
||||
if story.DataSHA256 != hashFixtureData(t, "weather_story.json") {
|
||||
t.Fatalf("weather story DataSHA256 = %q, want fixture hash", story.DataSHA256)
|
||||
}
|
||||
if story.IssuedAt == nil || story.UpdatedAt == nil {
|
||||
t.Fatalf("weather story source timestamps = issued %#v updated %#v, want both", story.IssuedAt, story.UpdatedAt)
|
||||
}
|
||||
outlooks := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
|
||||
if outlooks.Endpoint != convectiveOutlooksEndpoint {
|
||||
t.Fatalf("convective outlook endpoint = %q, want %s", outlooks.Endpoint, convectiveOutlooksEndpoint)
|
||||
}
|
||||
if outlooks.Query["format"] != "json" || outlooks.Query["tz"] != "America/Chicago" || outlooks.Query["units"] != "" || outlooks.Query["precision"] != "" {
|
||||
t.Fatalf("convective outlook query = %#v, want format and tz only", outlooks.Query)
|
||||
}
|
||||
if outlooks.DataSHA256 != hashFixtureData(t, "convective_outlooks.json") {
|
||||
t.Fatalf("convective outlook DataSHA256 = %q, want fixture hash", outlooks.DataSHA256)
|
||||
}
|
||||
if outlooks.Missing {
|
||||
t.Fatal("convective outlook source Missing = true, want false")
|
||||
}
|
||||
if outlooks.IssuedAt == nil || outlooks.IssuedAt.Format(time.RFC3339) != "2026-05-29T15:45:00Z" {
|
||||
t.Fatalf("convective outlook IssuedAt = %#v, want run issuedAt", outlooks.IssuedAt)
|
||||
}
|
||||
if outlooks.UpdatedAt == nil || outlooks.UpdatedAt.Format(time.RFC3339) != "2026-05-29T16:05:00Z" {
|
||||
t.Fatalf("convective outlook UpdatedAt = %#v, want run updatedAt", outlooks.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPErrorIsActionable(t *testing.T) {
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
"/conditions/current": {status: http.StatusBadGateway, body: `upstream failed`},
|
||||
"/forecast/hourly": {status: http.StatusBadGateway, body: `upstream failed`},
|
||||
}, nil)
|
||||
client := newTestClient(t, server.URL+"/", nil)
|
||||
|
||||
@@ -111,15 +203,140 @@ func TestHTTPErrorIsActionable(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("FetchBundle() error = nil, want HTTP error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "/conditions/current") || !strings.Contains(err.Error(), "502") {
|
||||
if !strings.Contains(err.Error(), "/forecast/hourly") || !strings.Contains(err.Error(), "502") {
|
||||
t.Fatalf("error = %q, want endpoint and status", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarmupRetriesBeforeFetchBundle(t *testing.T) {
|
||||
var requested []string
|
||||
var warmupCalls int
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
defaultWarmupEndpoint: {handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
warmupCalls++
|
||||
if warmupCalls == 1 {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte("vpn waking up"))
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, filepath.Join("testdata", "current.json"))
|
||||
}},
|
||||
}, &requested)
|
||||
client := newTestClient(t, server.URL+"/", nil)
|
||||
|
||||
bundle, err := client.FetchBundle(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("FetchBundle() error = %v", err)
|
||||
}
|
||||
if bundle.Current == nil {
|
||||
t.Fatal("Current = nil, want successful fetch after warmup retry")
|
||||
}
|
||||
if warmupCalls != 3 {
|
||||
t.Fatalf("conditions/current calls = %d, want failed warmup, successful warmup, and current source fetch", warmupCalls)
|
||||
}
|
||||
if len(requested) < 2 || !containsPath(requested[:2], defaultWarmupEndpoint) {
|
||||
t.Fatalf("initial requests = %v, want warmup endpoint retries", requested)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarmupFailureStopsBeforeSourceFetches(t *testing.T) {
|
||||
var requested []string
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
defaultWarmupEndpoint: {status: http.StatusBadGateway, body: `vpn unavailable`},
|
||||
}, &requested)
|
||||
client := newTestClient(t, server.URL+"/", nil)
|
||||
client.warmupAttempts = 2
|
||||
|
||||
_, err := client.FetchBundle(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("FetchBundle() error = nil, want warmup failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "warm up weather API") ||
|
||||
!strings.Contains(err.Error(), defaultWarmupEndpoint) ||
|
||||
!strings.Contains(err.Error(), "2 attempts") ||
|
||||
!strings.Contains(err.Error(), "502") {
|
||||
t.Fatalf("error = %q, want warmup endpoint, attempts, and status", err.Error())
|
||||
}
|
||||
if got := countPath(requested, defaultWarmupEndpoint); got != 2 {
|
||||
t.Fatalf("warmup requests = %d, want 2; all requests = %v", got, requested)
|
||||
}
|
||||
if containsPath(requested, "/observations") {
|
||||
t.Fatalf("requested paths = %v, want warmup failure before source fetches", requested)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRetriesRetryableStatus(t *testing.T) {
|
||||
var hourlyCalls int
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
"/forecast/hourly": {handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
hourlyCalls++
|
||||
if hourlyCalls == 1 {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte("temporary upstream failure"))
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, filepath.Join("testdata", "hourly.json"))
|
||||
}},
|
||||
}, nil)
|
||||
client := newTestClient(t, server.URL+"/", nil)
|
||||
|
||||
bundle, err := client.FetchBundle(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("FetchBundle() error = %v", err)
|
||||
}
|
||||
if bundle.Hourly == nil {
|
||||
t.Fatal("Hourly = nil, want successful fetch after retry")
|
||||
}
|
||||
if hourlyCalls != 2 {
|
||||
t.Fatalf("hourly calls = %d, want 2", hourlyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchDoesNotRetryNonRetryableStatus(t *testing.T) {
|
||||
var hourlyCalls int
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
"/forecast/hourly": {handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
hourlyCalls++
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte("not found"))
|
||||
}},
|
||||
}, nil)
|
||||
client := newTestClient(t, server.URL+"/", nil)
|
||||
|
||||
_, err := client.FetchBundle(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("FetchBundle() error = nil, want non-retryable status error")
|
||||
}
|
||||
if hourlyCalls != 1 {
|
||||
t.Fatalf("hourly calls = %d, want no retry", hourlyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchDoesNotRetryMalformedEnvelope(t *testing.T) {
|
||||
var hourlyCalls int
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
"/forecast/hourly": {handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
hourlyCalls++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`not-json`))
|
||||
}},
|
||||
}, nil)
|
||||
client := newTestClient(t, server.URL+"/", nil)
|
||||
|
||||
_, err := client.FetchBundle(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("FetchBundle() error = nil, want envelope decode error")
|
||||
}
|
||||
if hourlyCalls != 1 {
|
||||
t.Fatalf("hourly calls = %d, want no retry", hourlyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiredHourlyForecast(t *testing.T) {
|
||||
var requested []string
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
"/forecast/hourly": {status: http.StatusOK, body: `{"data": null}`},
|
||||
}, nil)
|
||||
}, &requested)
|
||||
client := newTestClient(t, server.URL+"/", nil)
|
||||
|
||||
_, err := client.FetchBundle(context.Background())
|
||||
@@ -129,6 +346,9 @@ func TestRequiredHourlyForecast(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "hourly forecast data") {
|
||||
t.Fatalf("error = %q, want hourly context", err.Error())
|
||||
}
|
||||
if got := countPath(requested, "/forecast/hourly"); got != 1 {
|
||||
t.Fatalf("hourly requests = %d, want no retry; all requests = %v", got, requested)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNullAlertsMeansNoActiveAlerts(t *testing.T) {
|
||||
@@ -161,6 +381,59 @@ func TestNullAlertsMeansNoActiveAlerts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingSPCConvectiveOutlooksUsesPolicy(t *testing.T) {
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
convectiveOutlooksEndpoint: {status: http.StatusOK, body: `{"data": null}`},
|
||||
}, nil)
|
||||
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
|
||||
sourceSPCConvectiveOutlooks: config.MissingSourceWarn,
|
||||
})
|
||||
|
||||
bundle, err := client.FetchBundle(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("FetchBundle() error = %v", err)
|
||||
}
|
||||
if bundle.SPCConvectiveOutlooks != nil {
|
||||
t.Fatalf("SPCConvectiveOutlooks = %#v, want nil for missing source", bundle.SPCConvectiveOutlooks)
|
||||
}
|
||||
source := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
|
||||
if !source.Missing || len(source.Warnings) != 1 {
|
||||
t.Fatalf("convective outlook source = %#v, want missing source warning", source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptySPCConvectiveOutlooksAreCheckedData(t *testing.T) {
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
convectiveOutlooksEndpoint: {status: http.StatusOK, body: `{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`},
|
||||
}, nil)
|
||||
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
|
||||
sourceSPCConvectiveOutlooks: config.MissingSourceWarn,
|
||||
})
|
||||
|
||||
bundle, err := client.FetchBundle(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("FetchBundle() error = %v", err)
|
||||
}
|
||||
if bundle.SPCConvectiveOutlooks == nil {
|
||||
t.Fatal("SPCConvectiveOutlooks = nil, want checked empty run")
|
||||
}
|
||||
if len(bundle.SPCConvectiveOutlooks.Outlooks) != 0 || len(bundle.SPCConvectiveOutlooks.Discussions) != 0 {
|
||||
t.Fatalf("SPCConvectiveOutlooks = %#v, want empty arrays", bundle.SPCConvectiveOutlooks)
|
||||
}
|
||||
source := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
|
||||
if source.Missing || len(source.Warnings) != 0 {
|
||||
t.Fatalf("convective outlook source = %#v, want non-missing source without warnings", source)
|
||||
}
|
||||
if source.IssuedAt == nil || source.IssuedAt.Format(time.RFC3339) != "2026-05-29T16:00:00Z" {
|
||||
t.Fatalf("convective outlook IssuedAt = %#v, want fallback to asOf", source.IssuedAt)
|
||||
}
|
||||
for _, warning := range bundle.Warnings {
|
||||
if warning.Source == sourceSPCConvectiveOutlooks {
|
||||
t.Fatalf("warnings = %#v, want no convective outlook warning", bundle.Warnings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -169,7 +442,7 @@ func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
|
||||
wantWarns int
|
||||
wantSource bool
|
||||
}{
|
||||
{name: "warn", policy: config.MissingSourceWarn, wantWarns: 3, wantSource: true},
|
||||
{name: "warn", policy: config.MissingSourceWarn, wantWarns: 1, wantSource: true},
|
||||
{name: "none", policy: config.MissingSourceNone, wantWarns: 0, wantSource: true},
|
||||
{name: "error", policy: config.MissingSourceError, wantErr: true},
|
||||
}
|
||||
@@ -230,6 +503,45 @@ func TestMalformedNonRequiredSourceUsesPolicy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingWeatherStoryUsesPolicy(t *testing.T) {
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
"/weatherstories/latest": {status: http.StatusOK, body: `{"data": null}`},
|
||||
}, nil)
|
||||
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
|
||||
"weather_story": config.MissingSourceWarn,
|
||||
})
|
||||
|
||||
bundle, err := client.FetchBundle(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("FetchBundle() error = %v", err)
|
||||
}
|
||||
if bundle.WeatherStory != nil {
|
||||
t.Fatalf("WeatherStory = %#v, want nil for missing source", bundle.WeatherStory)
|
||||
}
|
||||
source := sourceByName(t, bundle.Sources, "weather_story")
|
||||
if !source.Missing || len(source.Warnings) != 1 {
|
||||
t.Fatalf("weather_story source = %#v, want missing source warning", source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedWeatherStoryUsesPolicy(t *testing.T) {
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
"/weatherstories/latest": {status: http.StatusOK, body: `{"data": {"startTime": 123}}`},
|
||||
}, nil)
|
||||
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
|
||||
"weather_story": config.MissingSourceWarn,
|
||||
})
|
||||
|
||||
bundle, err := client.FetchBundle(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("FetchBundle() error = %v", err)
|
||||
}
|
||||
source := sourceByName(t, bundle.Sources, "weather_story")
|
||||
if !source.Missing || len(source.Warnings) != 1 || source.Warnings[0].Code != "malformed_source" {
|
||||
t.Fatalf("weather_story source = %#v, want malformed source warning", source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextCancellation(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done()
|
||||
@@ -245,6 +557,43 @@ func TestContextCancellation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryDelayRespectsContextCancellation(t *testing.T) {
|
||||
var cancel context.CancelFunc
|
||||
var hourlyCalls int
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
"/forecast/hourly": {handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
hourlyCalls++
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte("temporary upstream failure"))
|
||||
}},
|
||||
}, nil)
|
||||
client := newTestClient(t, server.URL+"/", nil)
|
||||
client.fetchRetryDelay = time.Hour
|
||||
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
cancel = cancelFunc
|
||||
defer cancelFunc()
|
||||
|
||||
start := time.Now()
|
||||
_, err := client.FetchBundle(ctx)
|
||||
elapsed := time.Since(start)
|
||||
if err == nil {
|
||||
t.Fatal("FetchBundle() error = nil, want cancellation during retry delay")
|
||||
}
|
||||
if !strings.Contains(err.Error(), context.Canceled.Error()) {
|
||||
t.Fatalf("error = %q, want context cancellation", err.Error())
|
||||
}
|
||||
if elapsed > time.Second {
|
||||
t.Fatalf("FetchBundle() elapsed = %s, want prompt cancellation", elapsed)
|
||||
}
|
||||
if hourlyCalls != 1 {
|
||||
t.Fatalf("hourly calls = %d, want retry delay cancellation before second attempt", hourlyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPTimeout(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
@@ -257,12 +606,14 @@ func TestHTTPTimeout(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
client.warmupDelay = 0
|
||||
client.fetchRetryDelay = 0
|
||||
|
||||
_, err = client.FetchBundle(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("FetchBundle() error = nil, want timeout error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "/observations") {
|
||||
if !strings.Contains(err.Error(), defaultWarmupEndpoint) {
|
||||
t.Fatalf("error = %q, want endpoint context", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -291,6 +642,7 @@ func TestSaveBundle(t *testing.T) {
|
||||
type handlerOverride struct {
|
||||
status int
|
||||
body string
|
||||
handler http.HandlerFunc
|
||||
}
|
||||
|
||||
func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server {
|
||||
@@ -302,12 +654,18 @@ func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested
|
||||
"/forecast/narrative": "narrative.json",
|
||||
"/alerts/active": "alerts.json",
|
||||
"/discussion": "discussion.json",
|
||||
"/weatherstories/latest": "weather_story.json",
|
||||
convectiveOutlooksEndpoint: "convective_outlooks.json",
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if requested != nil {
|
||||
*requested = append(*requested, r.URL.String())
|
||||
}
|
||||
if override, ok := overrides[r.URL.Path]; ok {
|
||||
if override.handler != nil {
|
||||
override.handler(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(override.status)
|
||||
_, _ = w.Write([]byte(override.body))
|
||||
return
|
||||
@@ -333,6 +691,8 @@ func newTestClient(t *testing.T, baseURL string, sourcePolicies map[string]confi
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
client.warmupDelay = 0
|
||||
client.fetchRetryDelay = 0
|
||||
return client
|
||||
}
|
||||
|
||||
@@ -355,7 +715,17 @@ func containsPath(requested []string, path string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func sourceByName(t *testing.T, sources []forecast.Source, name string) forecast.Source {
|
||||
func countPath(requested []string, path string) int {
|
||||
var count int
|
||||
for _, rawURL := range requested {
|
||||
if strings.HasPrefix(rawURL, path+"?") || rawURL == path {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func sourceByName(t *testing.T, sources []weatherdata.Source, name string) weatherdata.Source {
|
||||
t.Helper()
|
||||
for _, source := range sources {
|
||||
if source.Name == name {
|
||||
@@ -363,7 +733,7 @@ func sourceByName(t *testing.T, sources []forecast.Source, name string) forecast
|
||||
}
|
||||
}
|
||||
t.Fatalf("source %q not found in %#v", name, sources)
|
||||
return forecast.Source{}
|
||||
return weatherdata.Source{}
|
||||
}
|
||||
|
||||
func hashFixtureData(t *testing.T, fixture string) string {
|
||||
|
||||
50
internal/adapters/weatherapi/testdata/convective_outlooks.json
vendored
Normal file
50
internal/adapters/weatherapi/testdata/convective_outlooks.json
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"data": {
|
||||
"locationId": "nws-lsx-grid-90-74",
|
||||
"locationName": "St. Louis, MO",
|
||||
"asOf": "2026-05-29T16:00:00Z",
|
||||
"issuedAt": "2026-05-29T15:45:00Z",
|
||||
"updatedAt": "2026-05-29T16:05:00Z",
|
||||
"product": "convective_outlook",
|
||||
"outlooks": [
|
||||
{
|
||||
"id": "day1-categorical-slight",
|
||||
"provider": "spc",
|
||||
"product": "convective_outlook",
|
||||
"day": 1,
|
||||
"outlookType": "categorical",
|
||||
"label": "SLGT",
|
||||
"labelText": "Slight Risk",
|
||||
"forecaster": "Smith",
|
||||
"severityRank": 3,
|
||||
"validFrom": "2026-05-29T13:00:00-05:00",
|
||||
"validTo": "2026-05-30T07:00:00-05:00",
|
||||
"issuedAt": "2026-05-29T15:45:00Z",
|
||||
"expiresAt": "2026-05-30T07:00:00-05:00",
|
||||
"sourceUrl": "https://www.spc.noaa.gov/products/outlook/day1otlk.html",
|
||||
"imageUrl": "https://www.spc.noaa.gov/products/outlook/day1probotlk_2000_torn.gif",
|
||||
"containsLocation": true,
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [
|
||||
[
|
||||
[-91.0, 38.0],
|
||||
[-90.0, 38.5],
|
||||
[-89.5, 37.8],
|
||||
[-91.0, 38.0]
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"discussions": [
|
||||
{
|
||||
"day": 1,
|
||||
"headline": "Severe storms possible",
|
||||
"summary": "Scattered severe storms are possible.",
|
||||
"discussion": "A few storms may become severe during the afternoon.",
|
||||
"updatedAt": "2026-05-29T16:05:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
14
internal/adapters/weatherapi/testdata/weather_story.json
vendored
Normal file
14
internal/adapters/weatherapi/testdata/weather_story.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"data": {
|
||||
"officeId": "LSX",
|
||||
"startTime": "2026-05-30T08:46:00Z",
|
||||
"endTime": "2026-05-31T11:00:00Z",
|
||||
"updatedAt": "2026-05-30T09:00:34Z",
|
||||
"title": "Several Chances for Rain Through Monday",
|
||||
"description": "A stagnant weather pattern with low pressure over the Great Plains and high pressure over the Great Lakes will continue to produce scattered showers and thunderstorms, for areas mainly along and west of the Mississippi River today and Sunday.",
|
||||
"altText": "This slide shows the forecast for today through Tuesday with icons for showers and thunderstorms and a picture of a cumulonimbus cloud on the right side.",
|
||||
"priority": false,
|
||||
"order": 1,
|
||||
"downloadUrl": "https://api.weather.gov/offices/LSX/weatherstories/download/3228e499-2aae-45a8-9ff9-1c060311026f"
|
||||
}
|
||||
}
|
||||
1180
internal/app/app.go
1180
internal/app/app.go
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
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)
|
||||
}
|
||||
352
internal/app/batch_plan_test.go
Normal file
352
internal/app/batch_plan_test.go
Normal file
@@ -0,0 +1,352 @@
|
||||
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 TestPlanBatchRunMorningExcludesLegacyStaticReports(t *testing.T) {
|
||||
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchMorning}, mustParse("2026-05-29T08:00:00-05:00"), collect.Result{Bundle: &weatherdata.Bundle{}})
|
||||
if err != nil {
|
||||
t.Fatalf("planBatchRun() error = %v", err)
|
||||
}
|
||||
|
||||
for _, item := range planned {
|
||||
if item.Resolved.Definition.ID == report.ThreeDay || item.Resolved.Definition.ID == report.Weekend {
|
||||
t.Fatalf("morning plan includes %s, want no 3-Day or Weekend", item.Resolved.Definition.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -6,11 +6,12 @@ import (
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type InspectReportsRequest struct {
|
||||
@@ -28,7 +29,7 @@ type SourceInspection struct {
|
||||
ReportID report.ID `json:"reportId"`
|
||||
SourceLocation string `json:"sourceLocation,omitempty"`
|
||||
Sources []briefing.SourceMetadata `json:"sources,omitempty"`
|
||||
Warnings []forecast.SourceWarning `json:"warnings,omitempty"`
|
||||
Warnings []weatherdata.SourceWarning `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
func InspectReports(ctx context.Context, req InspectReportsRequest) ([]state.ReportRecord, error) {
|
||||
@@ -44,12 +45,12 @@ func InspectMetadata(ctx context.Context, req InspectRunRequest) (state.Metadata
|
||||
return inspection.metadata, err
|
||||
}
|
||||
|
||||
func InspectBriefing(ctx context.Context, req InspectRunRequest) (briefing.Package, error) {
|
||||
func InspectModules(ctx context.Context, req InspectRunRequest) (module.Snapshot, error) {
|
||||
inspection, err := inspectRun(ctx, req)
|
||||
if err != nil {
|
||||
return briefing.Package{}, err
|
||||
return module.Snapshot{}, err
|
||||
}
|
||||
return inspection.store.LoadBriefing(ctx, inspection.metadata.BriefingPath)
|
||||
return inspection.store.LoadModuleSnapshot(ctx, inspection.metadata.ModuleSnapshotPath)
|
||||
}
|
||||
|
||||
func InspectDataPackage(ctx context.Context, req InspectRunRequest) (promptinput.Package, error) {
|
||||
|
||||
68
internal/briefing/alert_digest_module.go
Normal file
68
internal/briefing/alert_digest_module.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type AlertDigestModule struct {
|
||||
Checked bool `json:"checked"`
|
||||
ActiveCount int `json:"active_count"`
|
||||
RelevantCount int `json:"relevant_count"`
|
||||
Missing bool `json:"missing,omitempty"`
|
||||
Relevant []AlertSummary `json:"relevant,omitempty"`
|
||||
}
|
||||
|
||||
type AlertSummary struct {
|
||||
Event string `json:"event,omitempty"`
|
||||
Headline string `json:"headline,omitempty"`
|
||||
Severity string `json:"severity,omitempty"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
Instruction string `json:"instruction,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
func buildAlertDigestModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
value := alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps, ctx.Timezone)
|
||||
if value == nil {
|
||||
value = &AlertDigestModule{}
|
||||
}
|
||||
return &module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: *value}, nil
|
||||
}
|
||||
|
||||
func alertDigest(collected facts.CollectedFacts, overlaps []forecast.AlertOverlap, timezone string) *AlertDigestModule {
|
||||
missing := sourceMissing(collected.SourceProvenance, "alerts")
|
||||
if collected.Alerts == nil && !missing {
|
||||
return nil
|
||||
}
|
||||
value := &AlertDigestModule{Missing: missing}
|
||||
if collected.Alerts != nil {
|
||||
value.Checked = true
|
||||
value.ActiveCount = len(collected.Alerts.Alerts)
|
||||
}
|
||||
value.RelevantCount = len(overlaps)
|
||||
for _, overlap := range overlaps {
|
||||
value.Relevant = append(value.Relevant, AlertSummary{
|
||||
Event: overlap.Event,
|
||||
Headline: overlap.Headline,
|
||||
Severity: overlap.Severity,
|
||||
PeriodBegins: friendlyMonthDayTimeLabel(overlap.Period.Start, timezone),
|
||||
PeriodEnds: friendlyMonthDayTimeLabel(overlap.Period.End, timezone),
|
||||
Instruction: overlap.Instruction,
|
||||
Description: overlap.Description,
|
||||
})
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func sourceMissing(sources []weatherdata.Source, name string) bool {
|
||||
for _, source := range sources {
|
||||
if source.Name == name && source.Missing {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
67
internal/briefing/area_forecast_discussion_module.go
Normal file
67
internal/briefing/area_forecast_discussion_module.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
)
|
||||
|
||||
type AreaForecastDiscussionModule struct {
|
||||
Product string `json:"product,omitempty"`
|
||||
KeyMessages []string `json:"key_messages,omitempty"`
|
||||
ShortTerm string `json:"short_term,omitempty"`
|
||||
LongTerm string `json:"long_term,omitempty"`
|
||||
}
|
||||
|
||||
func buildAreaForecastDiscussionModule(ctx ModuleContext, options any) (*module.Output, error) {
|
||||
discussion := ctx.Collected.Discussion
|
||||
if discussion == nil {
|
||||
return nil, nil
|
||||
}
|
||||
opts, ok := options.(module.AreaForecastDiscussionOptions)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("area forecast discussion options have type %T", options)
|
||||
}
|
||||
sections, err := areaForecastDiscussionSections(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value := AreaForecastDiscussionModule{}
|
||||
if sections["product"] {
|
||||
value.Product = discussion.Product
|
||||
}
|
||||
if sections["key_messages"] {
|
||||
value.KeyMessages = append([]string(nil), discussion.KeyMessages...)
|
||||
}
|
||||
if sections["short_term"] && discussion.ShortTerm != nil {
|
||||
value.ShortTerm = discussion.ShortTerm.Text
|
||||
}
|
||||
if sections["long_term"] && discussion.LongTerm != nil {
|
||||
value.LongTerm = discussion.LongTerm.Text
|
||||
}
|
||||
if value.Product == "" && len(value.KeyMessages) == 0 && value.ShortTerm == "" && value.LongTerm == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return &module.Output{ID: module.AreaForecastDiscussion, StanzaName: "area_forecast_discussion", Value: value}, nil
|
||||
}
|
||||
|
||||
func areaForecastDiscussionSections(options module.AreaForecastDiscussionOptions) (map[string]bool, error) {
|
||||
if len(options.Sections) == 0 {
|
||||
return map[string]bool{
|
||||
"product": true,
|
||||
"key_messages": true,
|
||||
"short_term": true,
|
||||
"long_term": true,
|
||||
}, nil
|
||||
}
|
||||
sections := map[string]bool{}
|
||||
for _, section := range options.Sections {
|
||||
switch section {
|
||||
case "product", "key_messages", "short_term", "long_term":
|
||||
sections[section] = true
|
||||
default:
|
||||
return nil, fmt.Errorf("area forecast discussion section %q is not supported", section)
|
||||
}
|
||||
}
|
||||
return sections, nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"categorical:TSTM": {
|
||||
"plain_language": "General or non-severe thunderstorms.",
|
||||
"official_description": "No severe thunderstorms expected.",
|
||||
"relative_level": "0 of 5"
|
||||
},
|
||||
"categorical:MRGL": {
|
||||
"plain_language": "Isolated severe storms possible.",
|
||||
"official_description": "Isolated severe storms may occur within the risk area, but they are expected to be limited in duration, coverage, and intensity.",
|
||||
"relative_level": "1 of 5"
|
||||
},
|
||||
"categorical:SLGT": {
|
||||
"plain_language": "Scattered severe storms possible.",
|
||||
"official_description": "Isolated intense storms are possible within the risk area, but severe weather is generally expected to be short-lived and/or not widespread.",
|
||||
"relative_level": "2 of 5"
|
||||
},
|
||||
"categorical:ENH": {
|
||||
"plain_language": "Numerous severe storms possible.",
|
||||
"official_description": "Numerous severe storms are possible within the risk area, some of which may be intense.",
|
||||
"relative_level": "3 of 5"
|
||||
},
|
||||
"categorical:MDT": {
|
||||
"plain_language": "Widespread severe storms likely.",
|
||||
"official_description": "Widespread severe storms are likely within the risk area. Storms may be long-lived, widespread, and intense. This risk is usually reserved for days with several supercells producing intense tornadoes and/or very large hail, or an intense squall line with widespread damaging winds.",
|
||||
"relative_level": "4 of 5"
|
||||
},
|
||||
"categorical:HIGH": {
|
||||
"plain_language": "Major severe outbreak expected.",
|
||||
"official_description": "A major severe weather outbreak is expected, with long-lived, very widespread, and particularly intense severe storms. This risk is reserved for when high confidence exists in widespread coverage of severe weather with embedded instances of extreme severity (i.e., violent tornadoes or very damaging convective wind events).",
|
||||
"relative_level": "5 of 5"
|
||||
},
|
||||
"tornado:CIG1": {
|
||||
"plain_language": "Conditional potential for significant tornadoes.",
|
||||
"official_description": "Intensity Level 1: Reasonable Max EF2. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
|
||||
"relative_level": "1 of 3"
|
||||
},
|
||||
"tornado:CIG2": {
|
||||
"plain_language": "Conditional potential for strong tornadoes.",
|
||||
"official_description": "Intensity Level 2: Reasonable Max EF3. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
|
||||
"relative_level": "2 of 3"
|
||||
},
|
||||
"tornado:CIG3": {
|
||||
"plain_language": "Conditional potential for violent tornadoes.",
|
||||
"official_description": "Intensity Level 3: Reasonable Max EF4 or higher. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
|
||||
"relative_level": "3 of 3"
|
||||
},
|
||||
"wind:CIG1": {
|
||||
"plain_language": "Conditional potential for significant severe wind.",
|
||||
"official_description": "Intensity Level 1: Reasonable Max wind gusts around 65 kt / 75 mph or higher. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
|
||||
"relative_level": "1 of 3"
|
||||
},
|
||||
"wind:CIG2": {
|
||||
"plain_language": "Conditional potential for intense severe wind.",
|
||||
"official_description": "Intensity Level 2: Reasonable Max wind gusts around 75 kt / 85 mph or higher. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
|
||||
"relative_level": "2 of 3"
|
||||
},
|
||||
"wind:CIG3": {
|
||||
"plain_language": "Conditional potential for extreme severe wind.",
|
||||
"official_description": "Intensity Level 3: Reasonable Max wind gusts around 100 kt / 115 mph or higher. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
|
||||
"relative_level": "3 of 3"
|
||||
},
|
||||
"hail:CIG1": {
|
||||
"plain_language": "Conditional potential for significant hail.",
|
||||
"official_description": "Intensity Level 1: Reasonable Max hail size around 2.00 to 3.75 inches. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
|
||||
"relative_level": "1 of 2"
|
||||
},
|
||||
"hail:CIG2": {
|
||||
"plain_language": "Conditional potential for giant hail.",
|
||||
"official_description": "Intensity Level 2: Reasonable Max hail size greater than 3.75 inches. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
|
||||
"relative_level": "2 of 2"
|
||||
}
|
||||
}
|
||||
735
internal/briefing/base_modules_test.go
Normal file
735
internal/briefing/base_modules_test.go
Normal file
@@ -0,0 +1,735 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
func TestBaseModulesBuildAvailableSourceOutputs(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
|
||||
tests := []struct {
|
||||
id module.ID
|
||||
stanza string
|
||||
}{
|
||||
{id: module.Metadata, stanza: "metadata"},
|
||||
{id: module.CurrentConditions, stanza: "current_conditions"},
|
||||
{id: module.NarrativeForecast, stanza: "narrative_forecast"},
|
||||
{id: module.HourlyForecast, stanza: "hourly_forecast"},
|
||||
{id: module.AlertDigest, stanza: "alert_digest"},
|
||||
{id: module.AreaForecastDiscussion, stanza: "area_forecast_discussion"},
|
||||
{id: module.WeatherStory, stanza: "weather_story"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.id), func(t *testing.T) {
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: tt.id})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
if output == nil {
|
||||
t.Fatal("BuildModule() output = nil, want stanza")
|
||||
}
|
||||
if output.ID != tt.id || output.StanzaName != tt.stanza {
|
||||
t.Fatalf("output = %#v, want id %q stanza %q", output, tt.id, tt.stanza)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[HourlyForecastModule](t, output)
|
||||
if value.Product != "hourly" || value.SourceLocationID != "test-grid" || len(value.Periods) != 1 {
|
||||
t.Fatalf("HourlyForecast = %#v, want hourly metadata and one valid-period period", value)
|
||||
}
|
||||
period := value.Periods[0]
|
||||
if period.HourLabel != "8:00 AM" || period.TextDescription != "Showers likely." || period.TextDescriptionLower != "showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 {
|
||||
t.Fatalf("HourlyForecast period = %#v, want hourly period facts", period)
|
||||
}
|
||||
if period.PeriodBegins != "2026-05-29 at 8:00 AM" || period.PeriodEnds != "2026-05-29 at 9:00 AM" {
|
||||
t.Fatalf("HourlyForecast period times = %q/%q, want friendly local time labels", period.PeriodBegins, period.PeriodEnds)
|
||||
}
|
||||
if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
|
||||
t.Fatalf("HourlyForecast period = %#v, want compass wind and precip chance", period)
|
||||
}
|
||||
if !period.MentionPrecipitation {
|
||||
t.Fatalf("MentionPrecipitation = false, want true for default threshold")
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal hourly forecast: %v", err)
|
||||
}
|
||||
jsonText := string(data)
|
||||
for _, field := range []string{"source_location_id", "hour_label", "period_begins", "period_ends", "text_description", "text_description_lower", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "mention_precipitation", "relative_humidity_percent"} {
|
||||
if !strings.Contains(jsonText, field) {
|
||||
t.Fatalf("hourly json = %s, want field %s", jsonText, field)
|
||||
}
|
||||
}
|
||||
if strings.Contains(jsonText, "wind_direction_degrees") || strings.Contains(jsonText, "Tomorrow") {
|
||||
t.Fatalf("hourly json = %s, want valid-period prompt fields only", jsonText)
|
||||
}
|
||||
if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) {
|
||||
t.Fatalf("hourly json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHourlyForecastPromptExportOmitsTemplateHelpers(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
richText := mustMarshalModuleJSON(t, output.Value)
|
||||
for _, field := range []string{"hour_label", "text_description_lower", "mention_precipitation"} {
|
||||
if !strings.Contains(richText, field) {
|
||||
t.Fatalf("rich hourly json = %s, want helper field %s", richText, field)
|
||||
}
|
||||
}
|
||||
|
||||
prompt := moduleDataPackageValue[HourlyForecastPromptExport](t, output)
|
||||
if prompt.Product != "hourly" || prompt.SourceLocationID != "test-grid" || len(prompt.Periods) != 1 {
|
||||
t.Fatalf("hourly prompt export = %#v, want hourly metadata and one period", prompt)
|
||||
}
|
||||
period := prompt.Periods[0]
|
||||
if period.PeriodBegins != "2026-05-29 at 8:00 AM" || period.PeriodEnds != "2026-05-29 at 9:00 AM" || period.TextDescription != "Showers likely." {
|
||||
t.Fatalf("hourly prompt period = %#v, want factual period fields", period)
|
||||
}
|
||||
if period.TemperatureF == nil || *period.TemperatureF != 76 || period.WindSpeedMph == nil || *period.WindSpeedMph != 14 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
|
||||
t.Fatalf("hourly prompt period = %#v, want temperature, wind, and precip fields", period)
|
||||
}
|
||||
if period.WindDirection != "S" || period.RelativeHumidityPercent == nil || *period.RelativeHumidityPercent != 66 {
|
||||
t.Fatalf("hourly prompt period = %#v, want wind direction and humidity", period)
|
||||
}
|
||||
promptText := mustMarshalModuleJSON(t, output.DataPackageValue())
|
||||
for _, field := range []string{"period_begins", "period_ends", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} {
|
||||
if !strings.Contains(promptText, field) {
|
||||
t.Fatalf("hourly prompt json = %s, want field %s", promptText, field)
|
||||
}
|
||||
}
|
||||
for _, field := range []string{"hour_label", "text_description_lower", "mention_precipitation"} {
|
||||
if strings.Contains(promptText, field) {
|
||||
t.Fatalf("hourly prompt json = %s, want omitted helper field %s", promptText, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHourlyForecastPrecipMentionThreshold(t *testing.T) {
|
||||
periods := []weatherdata.ForecastPeriod{
|
||||
{StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"), ProbabilityOfPrecipitationPercent: floatPtr(19)},
|
||||
{StartTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"), ProbabilityOfPrecipitationPercent: floatPtr(20)},
|
||||
{StartTime: mustParseModuleTime("2026-05-29T10:00:00-05:00")},
|
||||
}
|
||||
value := hourlyForecastPeriodsWithPrecipMentionThreshold(periods, "America/Chicago", DefaultHourlyForecastPrecipMentionProbabilityThreshold)
|
||||
if len(value) != 3 {
|
||||
t.Fatalf("periods length = %d, want 3", len(value))
|
||||
}
|
||||
if value[0].MentionPrecipitation {
|
||||
t.Fatalf("19%% MentionPrecipitation = true, want false")
|
||||
}
|
||||
if !value[1].MentionPrecipitation {
|
||||
t.Fatalf("20%% MentionPrecipitation = false, want true")
|
||||
}
|
||||
if value[2].MentionPrecipitation {
|
||||
t.Fatalf("nil MentionPrecipitation = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHourlyForecastModuleRejectsUnsupportedReports(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Weekend)
|
||||
|
||||
_, 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"`) {
|
||||
t.Fatalf("BuildModule() error = %v, want incompatible report", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHourlyForecastModuleBuildsForHourly(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Hourly)
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[HourlyForecastModule](t, output)
|
||||
if len(value.Periods) != 1 || value.Periods[0].TextDescription != "Showers likely." {
|
||||
t.Fatalf("HourlyForecast = %#v, want hourly report period", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.NarrativeForecast})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[NarrativeForecastModule](t, output)
|
||||
if value.Product != "narrative" || value.SourceLocationID != "test-grid" || len(value.Periods) != 1 {
|
||||
t.Fatalf("NarrativeForecast = %#v, want narrative metadata and one valid-period period", value)
|
||||
}
|
||||
period := value.Periods[0]
|
||||
if period.Name != "Today" || period.TextDescription != "Morning storms, then partly sunny." {
|
||||
t.Fatalf("NarrativeForecast period = %#v, want Today narrative", period)
|
||||
}
|
||||
if period.PeriodBegins != "2026-05-29 at 6:00 AM" || period.PeriodEnds != "2026-05-29 at 6:00 PM" {
|
||||
t.Fatalf("NarrativeForecast period times = %q/%q, want friendly local time labels", period.PeriodBegins, period.PeriodEnds)
|
||||
}
|
||||
if period.IsDay == nil || !*period.IsDay || period.TemperatureF == nil || *period.TemperatureF != 81 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 60 {
|
||||
t.Fatalf("NarrativeForecast period = %#v, want day, temperature, and precip values", period)
|
||||
}
|
||||
if period.WindDirection != "NE" {
|
||||
t.Fatalf("NarrativeForecast period wind direction = %q, want NE", period.WindDirection)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal narrative forecast: %v", err)
|
||||
}
|
||||
jsonText := string(data)
|
||||
for _, field := range []string{"source_location_id", "period_begins", "period_ends", "text_description", "temperature_f", "wind_speed_mph", "wind_direction", "probability_of_precipitation_percent"} {
|
||||
if !strings.Contains(jsonText, field) {
|
||||
t.Fatalf("narrative json = %s, want field %s", jsonText, field)
|
||||
}
|
||||
}
|
||||
if strings.Contains(jsonText, "wind_direction_degrees") {
|
||||
t.Fatalf("narrative json = %s, want compass wind_direction without degrees field", jsonText)
|
||||
}
|
||||
if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) {
|
||||
t.Fatalf("narrative json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText)
|
||||
}
|
||||
if strings.Contains(jsonText, "Tomorrow night") {
|
||||
t.Fatalf("narrative json = %s, want only valid-period narrative periods", jsonText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNarrativeForecastModuleRejectsUnsupportedReports(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Weekend)
|
||||
|
||||
_, 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"`) {
|
||||
t.Fatalf("BuildModule() error = %v, want incompatible report", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.Metadata})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[MetadataModule](t, output)
|
||||
if value.RunID == "" || value.ReportID != report.Daily || value.PromptID != "weather.daily_generated_text" {
|
||||
t.Fatalf("metadata = %#v, want report identity", value)
|
||||
}
|
||||
if value.Location == nil || value.Location.Name != "Brentwood" {
|
||||
t.Fatalf("Location = %#v, want configured location", value.Location)
|
||||
}
|
||||
if len(value.SourceWarnings) != 1 || value.SourceWarnings[0].CompletenessImpact != "source omitted" {
|
||||
t.Fatalf("SourceWarnings = %#v, want warning summary", value.SourceWarnings)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal metadata: %v", err)
|
||||
}
|
||||
jsonText := string(data)
|
||||
if !strings.Contains(jsonText, "source_warnings") || strings.Contains(jsonText, "endpoint") || strings.Contains(jsonText, "dataSha256") {
|
||||
t.Fatalf("metadata json = %s, want source warning summary without transport provenance", jsonText)
|
||||
}
|
||||
if strings.Contains(jsonText, `"alerts"`) {
|
||||
t.Fatalf("metadata json = %s, want alert details only in alert_digest", jsonText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.CurrentConditions})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[CurrentConditionsModule](t, output)
|
||||
if value.ConditionText != "Partly cloudy" || value.ConditionTextLower != "partly cloudy" || value.TemperatureF == nil || *value.TemperatureF != 74 {
|
||||
t.Fatalf("CurrentConditions = %#v, want rounded current condition facts", value)
|
||||
}
|
||||
if value.ApparentTemperatureF == nil || *value.ApparentTemperatureF != 76 || value.RelativeHumidityPercent == nil || *value.RelativeHumidityPercent != 71 || value.WindSpeedMph == nil || *value.WindSpeedMph != 8 {
|
||||
t.Fatalf("CurrentConditions rounded values = %#v, want apparent 76, humidity 71, wind 8", value)
|
||||
}
|
||||
if value.WindDirection != "S" {
|
||||
t.Fatalf("WindDirection = %q, want S", value.WindDirection)
|
||||
}
|
||||
if value.WindDirectionText != "south" {
|
||||
t.Fatalf("WindDirectionText = %q, want south", value.WindDirectionText)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal current conditions: %v", err)
|
||||
}
|
||||
jsonText := string(data)
|
||||
for _, field := range []string{"condition_text", "condition_text_lower", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction", "wind_direction_text"} {
|
||||
if !strings.Contains(jsonText, field) {
|
||||
t.Fatalf("current json = %s, want field %s", jsonText, field)
|
||||
}
|
||||
}
|
||||
if strings.Contains(jsonText, "wind_direction_degrees") {
|
||||
t.Fatalf("current json = %s, want compass wind_direction without degrees field", jsonText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentConditionsPromptExportOmitsTemplateHelpers(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.CurrentConditions})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
richText := mustMarshalModuleJSON(t, output.Value)
|
||||
for _, field := range []string{"condition_text_lower", "wind_direction_text"} {
|
||||
if !strings.Contains(richText, field) {
|
||||
t.Fatalf("rich current conditions json = %s, want helper field %s", richText, field)
|
||||
}
|
||||
}
|
||||
|
||||
prompt := moduleDataPackageValue[CurrentConditionsPromptExport](t, output)
|
||||
if prompt.ConditionText != "Partly cloudy" || prompt.TemperatureF == nil || *prompt.TemperatureF != 74 {
|
||||
t.Fatalf("current prompt export = %#v, want condition text and temperature", prompt)
|
||||
}
|
||||
if prompt.ApparentTemperatureF == nil || *prompt.ApparentTemperatureF != 76 || prompt.RelativeHumidityPercent == nil || *prompt.RelativeHumidityPercent != 71 || prompt.WindSpeedMph == nil || *prompt.WindSpeedMph != 8 {
|
||||
t.Fatalf("current prompt export = %#v, want apparent temperature, humidity, and wind speed", prompt)
|
||||
}
|
||||
if prompt.WindDirection != "S" {
|
||||
t.Fatalf("current prompt wind direction = %q, want S", prompt.WindDirection)
|
||||
}
|
||||
promptText := mustMarshalModuleJSON(t, output.DataPackageValue())
|
||||
for _, field := range []string{"condition_text", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction"} {
|
||||
if !strings.Contains(promptText, field) {
|
||||
t.Fatalf("current prompt json = %s, want field %s", promptText, field)
|
||||
}
|
||||
}
|
||||
for _, field := range []string{"condition_text_lower", "wind_direction_text"} {
|
||||
if strings.Contains(promptText, field) {
|
||||
t.Fatalf("current prompt json = %s, want omitted helper field %s", promptText, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Collected.Alerts = &weatherdata.AlertRun{}
|
||||
ctx.Derived.AlertOverlaps = nil
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.AlertDigest})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(checked empty) error = %v", err)
|
||||
}
|
||||
checked := moduleValue[AlertDigestModule](t, output)
|
||||
if !checked.Checked || checked.ActiveCount != 0 || checked.RelevantCount != 0 || checked.Missing {
|
||||
t.Fatalf("checked empty alert digest = %#v, want checked/no active", checked)
|
||||
}
|
||||
|
||||
ctx.Collected.Alerts = nil
|
||||
ctx.Collected.SourceProvenance = []weatherdata.Source{{Name: "alerts", Missing: true}}
|
||||
output, err = registry.BuildModule(ctx, module.ConfigItem{ID: module.AlertDigest})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(missing) error = %v", err)
|
||||
}
|
||||
missing := moduleValue[AlertDigestModule](t, output)
|
||||
if missing.Checked || !missing.Missing {
|
||||
t.Fatalf("missing alert digest = %#v, want missing unchecked source", missing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertDigestIncludesPeriodAndGuidance(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Collected.Alerts = &weatherdata.AlertRun{Alerts: []json.RawMessage{json.RawMessage(`{"event":"Wind Advisory"}`)}}
|
||||
ctx.Derived.AlertOverlaps = []forecast.AlertOverlap{{
|
||||
Event: "Wind Advisory",
|
||||
Headline: "Wind Advisory until 8 PM",
|
||||
Severity: "Moderate",
|
||||
Period: timeutil.Period{Start: mustParseModuleTime("2026-06-17T18:00:00Z"), End: mustParseModuleTime("2026-06-18T01:00:00Z")},
|
||||
Instruction: "Secure outdoor objects.",
|
||||
Description: "Gusty winds may blow around unsecured objects.",
|
||||
}}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.AlertDigest})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(alert digest) error = %v", err)
|
||||
}
|
||||
value := moduleValue[AlertDigestModule](t, output)
|
||||
if len(value.Relevant) != 1 {
|
||||
t.Fatalf("Relevant length = %d, want 1", len(value.Relevant))
|
||||
}
|
||||
alert := value.Relevant[0]
|
||||
if alert.Event != "Wind Advisory" || alert.Headline != "Wind Advisory until 8 PM" || alert.Severity != "Moderate" {
|
||||
t.Fatalf("alert identity = %#v, want preserved event/headline/severity", alert)
|
||||
}
|
||||
if alert.PeriodBegins != "June 17 at 1:00 PM" || alert.PeriodEnds != "June 17 at 8:00 PM" {
|
||||
t.Fatalf("alert period = %q/%q, want friendly local labels", alert.PeriodBegins, alert.PeriodEnds)
|
||||
}
|
||||
if alert.Instruction != "Secure outdoor objects." || alert.Description != "Gusty winds may blow around unsecured objects." {
|
||||
t.Fatalf("alert guidance = %#v, want instruction and description preserved", alert)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseModulesOmitMissingOptionalOutputs(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Collected.Current = nil
|
||||
ctx.Collected.Narrative = nil
|
||||
ctx.Collected.Hourly = nil
|
||||
ctx.Derived.ValidPeriodNarrativePeriods = nil
|
||||
ctx.Derived.ValidPeriodHourlyPeriods = nil
|
||||
ctx.Collected.Discussion = nil
|
||||
ctx.Collected.WeatherStory = nil
|
||||
|
||||
for _, id := range []module.ID{module.CurrentConditions, module.NarrativeForecast, module.HourlyForecast, module.AreaForecastDiscussion, module.WeatherStory} {
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: id})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(%s) error = %v", id, err)
|
||||
}
|
||||
if output != nil {
|
||||
t.Fatalf("BuildModule(%s) output = %#v, want omitted", id, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAreaForecastDiscussionAndWeatherStoryModules(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
|
||||
afdOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.AreaForecastDiscussion})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(afd) error = %v", err)
|
||||
}
|
||||
afd := moduleValue[AreaForecastDiscussionModule](t, afdOutput)
|
||||
if len(afd.KeyMessages) != 1 || afd.ShortTerm != "Showers increase this afternoon." || afd.LongTerm != "Periodic rain chances continue." {
|
||||
t.Fatalf("AFD = %#v, want discussion sections", afd)
|
||||
}
|
||||
|
||||
storyOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.WeatherStory})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(weather story) error = %v", err)
|
||||
}
|
||||
story := moduleValue[WeatherStoryModule](t, storyOutput)
|
||||
if !story.Available || story.Title != "Rain Chances" || story.Description != "Scattered showers are possible." {
|
||||
t.Fatalf("WeatherStory = %#v, want structured story fields", story)
|
||||
}
|
||||
if story.PeriodBegins != "2026-05-29 at 6:00 AM" || story.PeriodEnds != "2026-05-29 at 6:00 PM" {
|
||||
t.Fatalf("WeatherStory period = %q/%q, want friendly local period labels", story.PeriodBegins, story.PeriodEnds)
|
||||
}
|
||||
data, err := json.Marshal(storyOutput.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal weather story: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "download_url") || !strings.Contains(string(data), "period_begins") || !strings.Contains(string(data), "period_ends") {
|
||||
t.Fatalf("weather story json = %s, want snake_case download_url", string(data))
|
||||
}
|
||||
if strings.Contains(string(data), "start_time") || strings.Contains(string(data), "end_time") {
|
||||
t.Fatalf("weather story json = %s, want period_begins/period_ends instead of start_time/end_time", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{
|
||||
ID: module.AreaForecastDiscussion,
|
||||
Options: module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
afd := moduleValue[AreaForecastDiscussionModule](t, output)
|
||||
if afd.ShortTerm != "Showers increase this afternoon." {
|
||||
t.Fatalf("ShortTerm = %q, want selected short term section", afd.ShortTerm)
|
||||
}
|
||||
if afd.Product != "" || len(afd.KeyMessages) != 0 || afd.LongTerm != "" {
|
||||
t.Fatalf("AFD = %#v, want only short_term section", afd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAreaForecastDiscussionModuleUsesHourlyDefaultSections(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Hourly)
|
||||
var item module.ConfigItem
|
||||
for _, candidate := range ctx.Resolved.Definition.Modules {
|
||||
if candidate.ID == module.AreaForecastDiscussion {
|
||||
item = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if item.ID == "" {
|
||||
t.Fatal("hourly default modules missing area_forecast_discussion")
|
||||
}
|
||||
|
||||
output, err := registry.BuildModule(ctx, item)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
afd := moduleValue[AreaForecastDiscussionModule](t, output)
|
||||
if len(afd.KeyMessages) != 1 || afd.ShortTerm != "Showers increase this afternoon." {
|
||||
t.Fatalf("AFD = %#v, want key messages and short term", afd)
|
||||
}
|
||||
if afd.Product != "" || afd.LongTerm != "" {
|
||||
t.Fatalf("AFD = %#v, want product and long term omitted", afd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAreaForecastDiscussionModuleUsesDailyDefaultSections(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Daily)
|
||||
var item module.ConfigItem
|
||||
for _, candidate := range ctx.Resolved.Definition.Modules {
|
||||
if candidate.ID == module.AreaForecastDiscussion {
|
||||
item = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if item.ID == "" {
|
||||
t.Fatal("daily default modules missing area_forecast_discussion")
|
||||
}
|
||||
|
||||
output, err := registry.BuildModule(ctx, item)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
afd := moduleValue[AreaForecastDiscussionModule](t, output)
|
||||
if afd.LongTerm != "Periodic rain chances continue." {
|
||||
t.Fatalf("LongTerm = %q, want selected long term section", afd.LongTerm)
|
||||
}
|
||||
if afd.Product != "" || len(afd.KeyMessages) != 0 || afd.ShortTerm != "" {
|
||||
t.Fatalf("AFD = %#v, want only long term section", afd)
|
||||
}
|
||||
}
|
||||
|
||||
func testModuleContext() ModuleContext {
|
||||
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
|
||||
definition := report.DefaultRegistry().MustLookup(report.Daily)
|
||||
resolved := report.Resolved{
|
||||
Definition: definition,
|
||||
GeneratedAt: generatedAt,
|
||||
Timezone: "America/Chicago",
|
||||
ValidPeriod: timeutil.Period{
|
||||
Start: mustParseModuleTime("2026-05-29T00:00:00-05:00"),
|
||||
End: mustParseModuleTime("2026-05-30T00:00:00-05:00"),
|
||||
},
|
||||
}
|
||||
isDay := true
|
||||
tempF := 74.4
|
||||
apparentF := 75.6
|
||||
humidity := 70.6
|
||||
windMph := 8.4
|
||||
windDirection := 190.0
|
||||
narrativeTempF := 81.0
|
||||
narrativePop := 60.0
|
||||
narrativeWind := 12.0
|
||||
narrativeWindDirection := 45.0
|
||||
hourlyTempF := 76.0
|
||||
hourlyPop := 70.0
|
||||
hourlyHumidity := 66.0
|
||||
hourlyWindMph := 14.0
|
||||
updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00")
|
||||
return ModuleContext{
|
||||
Resolved: resolved,
|
||||
Collected: facts.CollectedFacts{
|
||||
Current: &weatherdata.Current{
|
||||
ConditionText: "Partly cloudy",
|
||||
IsDay: &isDay,
|
||||
TemperatureF: &tempF,
|
||||
ApparentTemperatureF: &apparentF,
|
||||
RelativeHumidityPercent: &humidity,
|
||||
WindSpeedMph: &windMph,
|
||||
WindDirectionDegrees: &windDirection,
|
||||
},
|
||||
Narrative: &weatherdata.ForecastRun{
|
||||
LocationID: "test-grid",
|
||||
LocationName: "Testville",
|
||||
IssuedAt: mustParseModuleTime("2026-05-29T10:30:00-05:00"),
|
||||
UpdatedAt: &updatedAt,
|
||||
Product: "narrative",
|
||||
Periods: []weatherdata.ForecastPeriod{
|
||||
{
|
||||
Name: "Today",
|
||||
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
|
||||
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
|
||||
IsDay: &isDay,
|
||||
TextDescription: "Morning storms, then partly sunny.",
|
||||
TemperatureF: floatPtr(narrativeTempF),
|
||||
WindSpeedMph: &narrativeWind,
|
||||
WindDirectionDegrees: &narrativeWindDirection,
|
||||
ProbabilityOfPrecipitationPercent: &narrativePop,
|
||||
},
|
||||
},
|
||||
},
|
||||
Hourly: &weatherdata.ForecastRun{
|
||||
LocationID: "test-grid",
|
||||
LocationName: "Testville",
|
||||
IssuedAt: mustParseModuleTime("2026-05-29T10:30:00-05:00"),
|
||||
UpdatedAt: &updatedAt,
|
||||
Product: "hourly",
|
||||
Periods: []weatherdata.ForecastPeriod{
|
||||
{
|
||||
StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"),
|
||||
EndTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"),
|
||||
TextDescription: "Showers likely.",
|
||||
TemperatureF: &hourlyTempF,
|
||||
WindSpeedMph: &hourlyWindMph,
|
||||
WindDirectionDegrees: &windDirection,
|
||||
ProbabilityOfPrecipitationPercent: &hourlyPop,
|
||||
RelativeHumidityPercent: &hourlyHumidity,
|
||||
},
|
||||
{
|
||||
StartTime: mustParseModuleTime("2026-05-30T08:00:00-05:00"),
|
||||
EndTime: mustParseModuleTime("2026-05-30T09:00:00-05:00"),
|
||||
TextDescription: "Tomorrow showers.",
|
||||
},
|
||||
},
|
||||
},
|
||||
Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{
|
||||
json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate"}`),
|
||||
}},
|
||||
Discussion: &weatherdata.Discussion{
|
||||
Product: "discussion",
|
||||
KeyMessages: []string{"Scattered showers are possible."},
|
||||
ShortTerm: &weatherdata.DiscussionSection{Text: "Showers increase this afternoon."},
|
||||
LongTerm: &weatherdata.DiscussionSection{Text: "Periodic rain chances continue."},
|
||||
},
|
||||
WeatherStory: &weatherdata.WeatherStory{
|
||||
OfficeID: "LSX",
|
||||
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
|
||||
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
|
||||
UpdatedAt: &updatedAt,
|
||||
Title: "Rain Chances",
|
||||
Description: "Scattered showers are possible.",
|
||||
AltText: "Weather story graphic with rain chances.",
|
||||
Priority: true,
|
||||
Order: 1,
|
||||
DownloadURL: "https://example.invalid/story.png",
|
||||
},
|
||||
SourceProvenance: []weatherdata.Source{{Name: "alerts", FetchedAt: generatedAt}},
|
||||
SourceWarnings: []weatherdata.SourceWarning{{
|
||||
Source: "daily",
|
||||
Code: "missing_source",
|
||||
Severity: "warning",
|
||||
Message: "daily source is missing",
|
||||
Endpoint: "/forecast/daily",
|
||||
CompletenessImpact: "source omitted",
|
||||
}},
|
||||
},
|
||||
Derived: facts.DerivedFacts{
|
||||
ValidPeriodHourlyPeriods: []weatherdata.ForecastPeriod{
|
||||
{
|
||||
StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"),
|
||||
EndTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"),
|
||||
TextDescription: "Showers likely.",
|
||||
TemperatureF: &hourlyTempF,
|
||||
WindSpeedMph: &hourlyWindMph,
|
||||
WindDirectionDegrees: &windDirection,
|
||||
ProbabilityOfPrecipitationPercent: &hourlyPop,
|
||||
RelativeHumidityPercent: &hourlyHumidity,
|
||||
},
|
||||
},
|
||||
ValidPeriodNarrativePeriods: []weatherdata.ForecastPeriod{
|
||||
{
|
||||
Name: "Today",
|
||||
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
|
||||
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
|
||||
IsDay: &isDay,
|
||||
TextDescription: "Morning storms, then partly sunny.",
|
||||
TemperatureF: floatPtr(narrativeTempF),
|
||||
WindSpeedMph: &narrativeWind,
|
||||
WindDirectionDegrees: &narrativeWindDirection,
|
||||
ProbabilityOfPrecipitationPercent: &narrativePop,
|
||||
},
|
||||
},
|
||||
AlertOverlaps: []forecast.AlertOverlap{{
|
||||
Event: "Flood Watch",
|
||||
Headline: "Flooding possible",
|
||||
Severity: "Moderate",
|
||||
}},
|
||||
},
|
||||
Units: "us",
|
||||
Timezone: "America/Chicago",
|
||||
Location: &LocationContext{
|
||||
ID: "home",
|
||||
Name: "Brentwood",
|
||||
Region: "St. Louis Metro",
|
||||
Timezone: "America/Chicago",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func moduleValue[T any](t *testing.T, output *module.Output) T {
|
||||
t.Helper()
|
||||
var value T
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal module value: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
t.Fatalf("decode module value: %v", err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func moduleDataPackageValue[T any](t *testing.T, output *module.Output) T {
|
||||
t.Helper()
|
||||
var value T
|
||||
data, err := json.Marshal(output.DataPackageValue())
|
||||
if err != nil {
|
||||
t.Fatalf("marshal module data package value: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
t.Fatalf("decode module data package value: %v", err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func mustMarshalModuleJSON(t *testing.T, value any) string {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal module value: %v", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func mustParseModuleTime(value string) time.Time {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
104
internal/briefing/current_conditions_module.go
Normal file
104
internal/briefing/current_conditions_module.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
)
|
||||
|
||||
type CurrentConditionsModule struct {
|
||||
ConditionText string `json:"condition_text,omitempty"`
|
||||
ConditionTextLower string `json:"condition_text_lower,omitempty"`
|
||||
IsDay *bool `json:"is_day,omitempty"`
|
||||
TemperatureC *int `json:"temperature_c,omitempty"`
|
||||
TemperatureF *int `json:"temperature_f,omitempty"`
|
||||
ApparentTemperatureC *int `json:"apparent_temperature_c,omitempty"`
|
||||
ApparentTemperatureF *int `json:"apparent_temperature_f,omitempty"`
|
||||
DewpointC *int `json:"dewpoint_c,omitempty"`
|
||||
DewpointF *int `json:"dewpoint_f,omitempty"`
|
||||
RelativeHumidityPercent *int `json:"relative_humidity_percent,omitempty"`
|
||||
WindSpeedKmh *int `json:"wind_speed_kmh,omitempty"`
|
||||
WindSpeedMph *int `json:"wind_speed_mph,omitempty"`
|
||||
WindDirection string `json:"wind_direction,omitempty"`
|
||||
WindDirectionText string `json:"wind_direction_text,omitempty"`
|
||||
}
|
||||
|
||||
type CurrentConditionsPromptExport struct {
|
||||
ConditionText string `json:"condition_text,omitempty"`
|
||||
IsDay *bool `json:"is_day,omitempty"`
|
||||
TemperatureC *int `json:"temperature_c,omitempty"`
|
||||
TemperatureF *int `json:"temperature_f,omitempty"`
|
||||
ApparentTemperatureC *int `json:"apparent_temperature_c,omitempty"`
|
||||
ApparentTemperatureF *int `json:"apparent_temperature_f,omitempty"`
|
||||
DewpointC *int `json:"dewpoint_c,omitempty"`
|
||||
DewpointF *int `json:"dewpoint_f,omitempty"`
|
||||
RelativeHumidityPercent *int `json:"relative_humidity_percent,omitempty"`
|
||||
WindSpeedKmh *int `json:"wind_speed_kmh,omitempty"`
|
||||
WindSpeedMph *int `json:"wind_speed_mph,omitempty"`
|
||||
WindDirection string `json:"wind_direction,omitempty"`
|
||||
}
|
||||
|
||||
func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
current := ctx.Collected.Current
|
||||
if current == nil {
|
||||
return nil, nil
|
||||
}
|
||||
value := CurrentConditionsModule{
|
||||
ConditionText: current.ConditionText,
|
||||
ConditionTextLower: strings.ToLower(current.ConditionText),
|
||||
IsDay: copyBool(current.IsDay),
|
||||
TemperatureC: roundedInt(current.TemperatureC),
|
||||
TemperatureF: roundedInt(current.TemperatureF),
|
||||
ApparentTemperatureC: roundedInt(current.ApparentTemperatureC),
|
||||
ApparentTemperatureF: roundedInt(current.ApparentTemperatureF),
|
||||
DewpointC: roundedInt(current.DewpointC),
|
||||
DewpointF: roundedInt(current.DewpointF),
|
||||
RelativeHumidityPercent: roundedInt(current.RelativeHumidityPercent),
|
||||
WindSpeedKmh: roundedInt(current.WindSpeedKmh),
|
||||
WindSpeedMph: roundedInt(current.WindSpeedMph),
|
||||
WindDirection: windDirectionLabel(current.WindDirectionDegrees),
|
||||
WindDirectionText: windDirectionTextLabel(current.WindDirectionDegrees),
|
||||
}
|
||||
if value.isEmpty() {
|
||||
return nil, nil
|
||||
}
|
||||
return &module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: value}, nil
|
||||
}
|
||||
|
||||
func exportCurrentConditionsPromptValue(value any) (any, error) {
|
||||
rich, ok := value.(CurrentConditionsModule)
|
||||
if !ok {
|
||||
return nil, unexpectedPromptExportValue(value, CurrentConditionsModule{})
|
||||
}
|
||||
return CurrentConditionsPromptExport{
|
||||
ConditionText: rich.ConditionText,
|
||||
IsDay: copyBool(rich.IsDay),
|
||||
TemperatureC: copyInt(rich.TemperatureC),
|
||||
TemperatureF: copyInt(rich.TemperatureF),
|
||||
ApparentTemperatureC: copyInt(rich.ApparentTemperatureC),
|
||||
ApparentTemperatureF: copyInt(rich.ApparentTemperatureF),
|
||||
DewpointC: copyInt(rich.DewpointC),
|
||||
DewpointF: copyInt(rich.DewpointF),
|
||||
RelativeHumidityPercent: copyInt(rich.RelativeHumidityPercent),
|
||||
WindSpeedKmh: copyInt(rich.WindSpeedKmh),
|
||||
WindSpeedMph: copyInt(rich.WindSpeedMph),
|
||||
WindDirection: rich.WindDirection,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (v CurrentConditionsModule) isEmpty() bool {
|
||||
return v.ConditionText == "" &&
|
||||
v.ConditionTextLower == "" &&
|
||||
v.IsDay == nil &&
|
||||
v.TemperatureC == nil &&
|
||||
v.TemperatureF == nil &&
|
||||
v.ApparentTemperatureC == nil &&
|
||||
v.ApparentTemperatureF == nil &&
|
||||
v.DewpointC == nil &&
|
||||
v.DewpointF == nil &&
|
||||
v.RelativeHumidityPercent == nil &&
|
||||
v.WindSpeedKmh == nil &&
|
||||
v.WindSpeedMph == nil &&
|
||||
v.WindDirection == "" &&
|
||||
v.WindDirectionText == ""
|
||||
}
|
||||
24
internal/briefing/daily_planning_module.go
Normal file
24
internal/briefing/daily_planning_module.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package briefing
|
||||
|
||||
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
|
||||
type DailyPlanningModule struct {
|
||||
MorningReadiness []string `json:"morning_readiness,omitempty"`
|
||||
CommuteSchoolWorkdayConcerns []string `json:"commute_school_workday_concerns,omitempty"`
|
||||
OvernightChangeWatch []string `json:"overnight_change_watch,omitempty"`
|
||||
}
|
||||
|
||||
func buildDailyPlanningModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
summary := ctx.Derived.FirstDailySummary()
|
||||
if summary == nil {
|
||||
return &module.Output{ID: module.DailyPlanning, StanzaName: "daily_planning", Value: DailyPlanningModule{}}, nil
|
||||
}
|
||||
planning := buildMorningCommuteOvernightPlanning(summary)
|
||||
value := DailyPlanningModule{}
|
||||
if planning != nil {
|
||||
value.MorningReadiness = append([]string(nil), planning.MorningReadiness...)
|
||||
value.CommuteSchoolWorkdayConcerns = append([]string(nil), planning.CommuteSchoolWorkdayConcerns...)
|
||||
value.OvernightChangeWatch = append([]string(nil), planning.OvernightChangeWatch...)
|
||||
}
|
||||
return &module.Output{ID: module.DailyPlanning, StanzaName: "daily_planning", Value: value}, nil
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
||||
bundle := loadBundleFixture(t)
|
||||
currentIsDay := true
|
||||
currentTemp := 75.9
|
||||
currentFeelsLike := 76.1
|
||||
currentHumidity := 56.0
|
||||
currentWind := 10.7
|
||||
bundle.Current = &forecast.Current{
|
||||
ConditionText: "Partly cloudy",
|
||||
IsDay: ¤tIsDay,
|
||||
TemperatureF: ¤tTemp,
|
||||
ApparentTemperatureF: ¤tFeelsLike,
|
||||
RelativeHumidityPercent: ¤tHumidity,
|
||||
WindSpeedMph: ¤tWind,
|
||||
}
|
||||
bundle.Sources[0].DataSHA256 = "abc123"
|
||||
bundle.Warnings = []forecast.SourceWarning{{Source: "daily", Code: "missing_source", Severity: "warning"}}
|
||||
location := mustLocation(t)
|
||||
resolved := mustResolveDaily(t, location)
|
||||
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDailySummary() error = %v", err)
|
||||
}
|
||||
|
||||
pkg, err := BuildDaily(BuildContext{
|
||||
Resolved: resolved,
|
||||
Bundle: bundle,
|
||||
Units: "us",
|
||||
Timezone: "America/Chicago",
|
||||
Location: &LocationContext{
|
||||
ID: "home",
|
||||
Name: "Brentwood",
|
||||
Region: "St. Louis Metro",
|
||||
Timezone: "America/Chicago",
|
||||
},
|
||||
}, summary)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDaily() error = %v", err)
|
||||
}
|
||||
|
||||
if pkg.Metadata.SchemaVersion != SchemaVersion {
|
||||
t.Fatalf("SchemaVersion = %q, want %q", pkg.Metadata.SchemaVersion, SchemaVersion)
|
||||
}
|
||||
if !strings.Contains(pkg.Metadata.RunID, "daily_today") {
|
||||
t.Fatalf("RunID = %q, want report id", pkg.Metadata.RunID)
|
||||
}
|
||||
if pkg.Metadata.ReportID != report.DailyToday {
|
||||
t.Fatalf("ReportID = %q, want daily_today", pkg.Metadata.ReportID)
|
||||
}
|
||||
if pkg.Metadata.Units != "us" || pkg.Metadata.Timezone != "America/Chicago" {
|
||||
t.Fatalf("metadata units/timezone = %q/%q", pkg.Metadata.Units, pkg.Metadata.Timezone)
|
||||
}
|
||||
if pkg.Metadata.Location == nil || pkg.Metadata.Location.ID != "home" || pkg.Metadata.Location.Name != "Brentwood" || pkg.Metadata.Location.Region != "St. Louis Metro" || pkg.Metadata.Location.Timezone != "America/Chicago" {
|
||||
t.Fatalf("metadata location = %#v, want configured prompt location", pkg.Metadata.Location)
|
||||
}
|
||||
if pkg.CurrentConditions == nil || pkg.CurrentConditions.ConditionText != "Partly cloudy" || pkg.CurrentConditions.TemperatureF == nil || *pkg.CurrentConditions.TemperatureF != currentTemp || pkg.CurrentConditions.RelativeHumidityPercent == nil || *pkg.CurrentConditions.RelativeHumidityPercent != currentHumidity {
|
||||
t.Fatalf("CurrentConditions = %#v, want current conditions from bundle", pkg.CurrentConditions)
|
||||
}
|
||||
if len(pkg.Metadata.Sources) != 1 || pkg.Metadata.Sources[0].DataSHA256 != "abc123" {
|
||||
t.Fatalf("Sources = %#v, want source hash", pkg.Metadata.Sources)
|
||||
}
|
||||
if len(pkg.Metadata.SourceWarnings) != 1 {
|
||||
t.Fatalf("SourceWarnings length = %d, want 1", len(pkg.Metadata.SourceWarnings))
|
||||
}
|
||||
if pkg.Daily == nil {
|
||||
t.Fatal("Daily = nil")
|
||||
}
|
||||
if len(pkg.Daily.Dayparts) != 5 {
|
||||
t.Fatalf("Dayparts length = %d, want 5", len(pkg.Daily.Dayparts))
|
||||
}
|
||||
if len(pkg.Daily.RelevantAlerts) != 1 {
|
||||
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.Daily.RelevantAlerts))
|
||||
}
|
||||
if len(pkg.Daily.NarrativePeriods) != 1 {
|
||||
t.Fatalf("NarrativePeriods length = %d, want 1", len(pkg.Daily.NarrativePeriods))
|
||||
}
|
||||
if len(pkg.Daily.Discussion.KeyMessages) != 1 {
|
||||
t.Fatalf("Discussion key messages length = %d, want 1", len(pkg.Daily.Discussion.KeyMessages))
|
||||
}
|
||||
if pkg.Daily.Discussion.ShortTerm != "Morning showers taper as a weak boundary shifts east." {
|
||||
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Daily.Discussion.ShortTerm)
|
||||
}
|
||||
if pkg.Daily.Discussion.LongTerm != "Warmer and more humid conditions return with periodic rain chances." {
|
||||
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Daily.Discussion.LongTerm)
|
||||
}
|
||||
if pkg.Daily.OutdoorWindows.Best == nil || pkg.Daily.OutdoorWindows.Worst == nil {
|
||||
t.Fatalf("OutdoorWindows = %#v, want best and worst", pkg.Daily.OutdoorWindows)
|
||||
}
|
||||
if pkg.Daily.BottomLine.Summary == "" {
|
||||
t.Fatal("BottomLine summary is empty")
|
||||
}
|
||||
if _, err := json.Marshal(pkg); err != nil {
|
||||
t.Fatalf("briefing package is not JSON inspectable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyBriefingQuietWeather(t *testing.T) {
|
||||
location := mustLocation(t)
|
||||
resolved := mustResolveDaily(t, location)
|
||||
bundle := &forecast.Bundle{
|
||||
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{
|
||||
quietHour("2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", 72),
|
||||
}},
|
||||
Alerts: &forecast.AlertRun{},
|
||||
Sources: []forecast.Source{
|
||||
{Name: "hourly", FetchedAt: time.Now()},
|
||||
{Name: "alerts", Endpoint: "/alerts/active", FetchedAt: time.Now()},
|
||||
{Name: "current", Endpoint: "/conditions/current", FetchedAt: time.Now(), Missing: true},
|
||||
},
|
||||
Warnings: []forecast.SourceWarning{{Source: "current", Code: "missing_source", Severity: "warning"}},
|
||||
}
|
||||
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDailySummary() error = %v", err)
|
||||
}
|
||||
pkg, err := BuildDaily(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}, summary)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDaily() error = %v", err)
|
||||
}
|
||||
if pkg.Daily.BottomLine.Summary != "Conditions: Clear." {
|
||||
t.Fatalf("BottomLine summary = %q, want clear conditions", pkg.Daily.BottomLine.Summary)
|
||||
}
|
||||
if pkg.CurrentConditions != nil {
|
||||
t.Fatalf("CurrentConditions = %#v, want nil when current conditions are missing", pkg.CurrentConditions)
|
||||
}
|
||||
if len(pkg.Metadata.SourceWarnings) != 1 || pkg.Metadata.SourceWarnings[0].Source != "current" {
|
||||
t.Fatalf("SourceWarnings = %#v, want current missing-source warning", pkg.Metadata.SourceWarnings)
|
||||
}
|
||||
if len(pkg.Daily.RelevantAlerts) != 0 {
|
||||
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts))
|
||||
}
|
||||
if pkg.Metadata.Alerts == nil {
|
||||
t.Fatal("Metadata.Alerts = nil, want checked no-active-alerts status")
|
||||
}
|
||||
if !pkg.Metadata.Alerts.Checked || pkg.Metadata.Alerts.ActiveCount != 0 || pkg.Metadata.Alerts.RelevantCount != 0 || pkg.Metadata.Alerts.Missing {
|
||||
t.Fatalf("Metadata.Alerts = %#v, want checked no-active-alerts status", pkg.Metadata.Alerts)
|
||||
}
|
||||
data, err := json.Marshal(pkg.Metadata.Alerts)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal alert metadata: %v", err)
|
||||
}
|
||||
if strings.Contains(string(data), `"missing"`) {
|
||||
t.Fatalf("alert metadata includes missing for checked empty alerts:\n%s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyBriefingAlertExclusion(t *testing.T) {
|
||||
location := mustLocation(t)
|
||||
resolved := mustResolveDaily(t, location)
|
||||
bundle := loadBundleFixture(t)
|
||||
bundle.Alerts = &forecast.AlertRun{Alerts: []json.RawMessage{
|
||||
json.RawMessage(`{"event":"Future Watch","effective":"2026-06-01T00:00:00-05:00","expires":"2026-06-01T06:00:00-05:00"}`),
|
||||
}}
|
||||
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDailySummary() error = %v", err)
|
||||
}
|
||||
pkg, err := BuildDaily(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}, summary)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDaily() error = %v", err)
|
||||
}
|
||||
if len(pkg.Daily.RelevantAlerts) != 0 {
|
||||
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTomorrowBriefingIncludesPlanningInputs(t *testing.T) {
|
||||
location := mustLocation(t)
|
||||
resolved, err := report.Resolve(report.DailyTomorrow, report.ResolveRequest{
|
||||
Now: mustParse("2026-05-29T18:00:00-05:00"),
|
||||
Location: location,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve tomorrow: %v", err)
|
||||
}
|
||||
precip := 70.0
|
||||
wind := 34.0
|
||||
summary := &forecast.DailySummary{
|
||||
Date: "2026-05-30",
|
||||
Period: resolved.ValidPeriod,
|
||||
Dayparts: []forecast.DaypartSummary{
|
||||
{
|
||||
Name: "overnight",
|
||||
Period: timeutil.Period{
|
||||
Start: mustParse("2026-05-30T00:00:00-05:00"),
|
||||
End: mustParse("2026-05-30T06:00:00-05:00"),
|
||||
},
|
||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
||||
Value: 40,
|
||||
Time: mustParse("2026-05-30T03:00:00-05:00"),
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "morning",
|
||||
Period: timeutil.Period{
|
||||
Start: mustParse("2026-05-30T06:00:00-05:00"),
|
||||
End: mustParse("2026-05-30T12:00:00-05:00"),
|
||||
},
|
||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
||||
Value: precip,
|
||||
Time: mustParse("2026-05-30T08:00:00-05:00"),
|
||||
},
|
||||
PeakWindGust: &forecast.TimedValue{
|
||||
Value: wind,
|
||||
Time: mustParse("2026-05-30T09:00:00-05:00"),
|
||||
},
|
||||
Indicators: forecast.Indicators{Snow: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
pkg, err := BuildDaily(BuildContext{
|
||||
Resolved: resolved,
|
||||
Units: "us",
|
||||
Timezone: "America/Chicago",
|
||||
}, summary)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDaily() error = %v", err)
|
||||
}
|
||||
|
||||
if pkg.Metadata.ReportID != report.DailyTomorrow || pkg.Metadata.Variant != "tomorrow" {
|
||||
t.Fatalf("metadata report/variant = %q/%q, want tomorrow", pkg.Metadata.ReportID, pkg.Metadata.Variant)
|
||||
}
|
||||
if pkg.Daily.ForecastSummaryDate != "2026-05-30" {
|
||||
t.Fatalf("ForecastSummaryDate = %q, want 2026-05-30", pkg.Daily.ForecastSummaryDate)
|
||||
}
|
||||
if pkg.Daily.Planning == nil {
|
||||
t.Fatal("Planning = nil, want tomorrow planning inputs")
|
||||
}
|
||||
if len(pkg.Daily.Planning.MorningReadiness) == 0 || len(pkg.Daily.Planning.CommuteSchoolWorkdayConcerns) == 0 || len(pkg.Daily.Planning.OvernightChangeWatch) == 0 {
|
||||
t.Fatalf("Planning = %#v, want populated planning inputs", pkg.Daily.Planning)
|
||||
}
|
||||
if !strings.Contains(strings.Join(pkg.Daily.Planning.MorningReadiness, " "), "precipitation") {
|
||||
t.Fatalf("MorningReadiness = %#v, want precipitation note", pkg.Daily.Planning.MorningReadiness)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveBriefingPackage(t *testing.T) {
|
||||
pkg := Package{Metadata: Metadata{SchemaVersion: SchemaVersion}}
|
||||
path := filepath.Join(t.TempDir(), "nested", "briefing.json")
|
||||
if err := Save(path, pkg); err != nil {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read briefing: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), SchemaVersion) {
|
||||
t.Fatalf("saved briefing missing schema version:\n%s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func loadBundleFixture(t *testing.T) *forecast.Bundle {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join("..", "forecast", "testdata", "daily_bundle.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read bundle fixture: %v", err)
|
||||
}
|
||||
var bundle forecast.Bundle
|
||||
if err := json.Unmarshal(data, &bundle); err != nil {
|
||||
t.Fatalf("decode bundle fixture: %v", err)
|
||||
}
|
||||
return &bundle
|
||||
}
|
||||
|
||||
func mustResolveDaily(t *testing.T, location *time.Location) report.Resolved {
|
||||
t.Helper()
|
||||
resolved, err := report.Resolve(report.DailyToday, report.ResolveRequest{
|
||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
||||
Location: location,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve daily: %v", err)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func defaultDayparts() []forecast.DaypartDefinition {
|
||||
return []forecast.DaypartDefinition{
|
||||
{Name: "overnight", Start: "00:00", End: "06:00"},
|
||||
{Name: "morning", Start: "06:00", End: "10:00"},
|
||||
{Name: "midday", Start: "10:00", End: "15:00"},
|
||||
{Name: "afternoon", Start: "15:00", End: "17:00"},
|
||||
{Name: "evening", Start: "17:00", End: "24:00"},
|
||||
}
|
||||
}
|
||||
|
||||
func quietHour(start string, end string, temperature float64) forecast.ForecastPeriod {
|
||||
return forecast.ForecastPeriod{
|
||||
StartTime: mustParse(start),
|
||||
EndTime: mustParse(end),
|
||||
TextDescription: "Clear",
|
||||
TemperatureF: &temperature,
|
||||
}
|
||||
}
|
||||
|
||||
func mustLocation(t *testing.T) *time.Location {
|
||||
t.Helper()
|
||||
location, err := time.LoadLocation("America/Chicago")
|
||||
if err != nil {
|
||||
t.Fatalf("load location: %v", err)
|
||||
}
|
||||
return location
|
||||
}
|
||||
|
||||
func mustParse(value string) time.Time {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
153
internal/briefing/derived_daily_summary_module.go
Normal file
153
internal/briefing/derived_daily_summary_module.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type DerivedDailySummaryModule struct {
|
||||
Date string `json:"date,omitempty"`
|
||||
HighTempF *int `json:"high_temp_f,omitempty"`
|
||||
LowTempF *int `json:"low_temp_f,omitempty"`
|
||||
DailyPrecipitationProbability *int `json:"daily_precipitation_probability,omitempty"`
|
||||
MostLikelyPrecipitationHour string `json:"most_likely_precipitation_hour,omitempty"`
|
||||
ThunderMentioned bool `json:"thunder_mentioned"`
|
||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||
HeatIndexMaxF *int `json:"heat_index_max_f,omitempty"`
|
||||
DominantConditions []string `json:"dominant_conditions,omitempty"`
|
||||
Hazards []string `json:"hazards,omitempty"`
|
||||
}
|
||||
|
||||
func buildDerivedDailySummaryModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
summary := ctx.Derived.FirstDailySummary()
|
||||
if summary == nil {
|
||||
return nil, fmt.Errorf("daily summary facts are required")
|
||||
}
|
||||
value, err := derivedDailySummaryValue(*summary, ctx.Derived.PrecipTiming, ctx.Timezone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: value}, nil
|
||||
}
|
||||
|
||||
func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.PrecipTiming, timezone string) (DerivedDailySummaryModule, error) {
|
||||
value := DerivedDailySummaryModule{
|
||||
Date: friendlyDateLabel(summary.Date, timezone),
|
||||
ThunderMentioned: timing.ThunderMentioned,
|
||||
}
|
||||
conditions := map[string]struct{}{}
|
||||
hazards := map[string]struct{}{}
|
||||
var temperature forecast.Range
|
||||
var apparent forecast.Range
|
||||
var maxPop *forecast.TimedValue
|
||||
var maxGust *forecast.TimedValue
|
||||
for _, daypart := range summary.Dayparts {
|
||||
addRange(&temperature, daypart.Temperature)
|
||||
addRange(&apparent, daypart.ApparentTemperature)
|
||||
maxTimedValue(&maxPop, daypart.MaxPrecipitationProbability)
|
||||
maxTimedValue(&maxGust, daypart.PeakWindGust)
|
||||
if daypart.DominantCondition != "" {
|
||||
conditions[daypart.DominantCondition] = struct{}{}
|
||||
}
|
||||
for _, hazard := range hazardsForIndicators(daypart.Indicators) {
|
||||
hazards[hazard] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, alert := range summary.AlertOverlaps {
|
||||
if alert.Event != "" {
|
||||
hazards[alert.Event] = struct{}{}
|
||||
}
|
||||
}
|
||||
narrativeTemperature := narrativeTemperatureRange(summary.NarrativePeriods)
|
||||
if narrativeTemperature.Max != nil {
|
||||
value.HighTempF = roundedInt(narrativeTemperature.Max)
|
||||
} else {
|
||||
value.HighTempF = roundedInt(temperature.Max)
|
||||
}
|
||||
if narrativeTemperature.Min != nil {
|
||||
value.LowTempF = roundedInt(narrativeTemperature.Min)
|
||||
} else {
|
||||
value.LowTempF = roundedInt(temperature.Min)
|
||||
}
|
||||
value.HeatIndexMaxF = roundedInt(apparent.Max)
|
||||
narrativePrecipitation := narrativeMaxPrecipitation(summary.NarrativePeriods)
|
||||
if narrativePrecipitation != nil {
|
||||
value.DailyPrecipitationProbability = roundedInt(&narrativePrecipitation.Value)
|
||||
} else if maxPop != nil {
|
||||
value.DailyPrecipitationProbability = roundedInt(&maxPop.Value)
|
||||
}
|
||||
if maxPop != nil {
|
||||
value.MostLikelyPrecipitationHour = mostLikelyPrecipitationHour(maxPop, timezone)
|
||||
}
|
||||
if maxGust != nil {
|
||||
value.MaxWindGustMph = roundedInt(&maxGust.Value)
|
||||
}
|
||||
value.DominantConditions = sortedSet(conditions)
|
||||
value.Hazards = sortedSet(hazards)
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func narrativeTemperatureRange(periods []weatherdata.ForecastPeriod) forecast.Range {
|
||||
var out forecast.Range
|
||||
for _, period := range periods {
|
||||
addNarrativeHigh(&out, period.TemperatureFMax)
|
||||
addNarrativeLow(&out, period.TemperatureFMin)
|
||||
if period.TemperatureF != nil && period.IsDay != nil {
|
||||
if *period.IsDay {
|
||||
addNarrativeHigh(&out, period.TemperatureF)
|
||||
} else {
|
||||
addNarrativeLow(&out, period.TemperatureF)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func addNarrativeHigh(target *forecast.Range, value *float64) {
|
||||
if value == nil {
|
||||
return
|
||||
}
|
||||
if target.Max == nil || *value > *target.Max {
|
||||
copied := *value
|
||||
target.Max = &copied
|
||||
}
|
||||
}
|
||||
|
||||
func addNarrativeLow(target *forecast.Range, value *float64) {
|
||||
if value == nil {
|
||||
return
|
||||
}
|
||||
if target.Min == nil || *value < *target.Min {
|
||||
copied := *value
|
||||
target.Min = &copied
|
||||
}
|
||||
}
|
||||
|
||||
func narrativeMaxPrecipitation(periods []weatherdata.ForecastPeriod) *forecast.TimedValue {
|
||||
var maxPop *forecast.TimedValue
|
||||
for _, period := range periods {
|
||||
if period.ProbabilityOfPrecipitationPercent == nil {
|
||||
continue
|
||||
}
|
||||
value := forecast.TimedValue{
|
||||
Value: *period.ProbabilityOfPrecipitationPercent,
|
||||
Time: period.StartTime,
|
||||
}
|
||||
maxTimedValue(&maxPop, &value)
|
||||
}
|
||||
return maxPop
|
||||
}
|
||||
|
||||
func mostLikelyPrecipitationHour(maxPop *forecast.TimedValue, timezone string) string {
|
||||
if maxPop == nil || maxPop.Value <= 0 {
|
||||
return ""
|
||||
}
|
||||
percent := roundedInt(&maxPop.Value)
|
||||
if percent == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d%% at %s", *percent, clockLabel(maxPop.Time, timezone))
|
||||
}
|
||||
411
internal/briefing/derived_daypart_summaries_module.go
Normal file
411
internal/briefing/derived_daypart_summaries_module.go
Normal file
@@ -0,0 +1,411 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type DerivedDaypartSummaryModule struct {
|
||||
Date string `json:"date,omitempty"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||
TemperaturePhraseF string `json:"temperature_phrase_f,omitempty"`
|
||||
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||
MaxPopTimeLabel string `json:"max_pop_time_label,omitempty"`
|
||||
MentionPrecipitation bool `json:"mention_precipitation,omitempty"`
|
||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
|
||||
DominantCondition string `json:"dominant_condition,omitempty"`
|
||||
DominantConditionLower string `json:"dominant_condition_lower,omitempty"`
|
||||
DominantConditionDisplay string `json:"dominant_condition_display,omitempty"`
|
||||
TemperatureTrend string `json:"temperature_trend,omitempty"`
|
||||
TemperatureStartPhraseF string `json:"temperature_start_phrase_f,omitempty"`
|
||||
TemperatureEndPhraseF string `json:"temperature_end_phrase_f,omitempty"`
|
||||
TemperaturePeakPhraseF string `json:"temperature_peak_phrase_f,omitempty"`
|
||||
TemperatureSteadyPhraseF string `json:"temperature_steady_phrase_f,omitempty"`
|
||||
NotableConditions []string `json:"notable_conditions,omitempty"`
|
||||
Snow bool `json:"snow,omitempty"`
|
||||
Ice bool `json:"ice,omitempty"`
|
||||
Fog bool `json:"fog,omitempty"`
|
||||
Heat bool `json:"heat,omitempty"`
|
||||
Cold bool `json:"cold,omitempty"`
|
||||
Wind bool `json:"wind,omitempty"`
|
||||
RelevantAlertCount int `json:"relevant_alert_count,omitempty"`
|
||||
}
|
||||
|
||||
type DerivedDaypartSummaryPromptExport struct {
|
||||
Date string `json:"date,omitempty"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||
MentionPrecipitation bool `json:"mention_precipitation,omitempty"`
|
||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
|
||||
DominantCondition string `json:"dominant_condition,omitempty"`
|
||||
TemperatureTrend string `json:"temperature_trend,omitempty"`
|
||||
TemperatureStartPhraseF string `json:"temperature_start_phrase_f,omitempty"`
|
||||
TemperatureEndPhraseF string `json:"temperature_end_phrase_f,omitempty"`
|
||||
TemperaturePeakPhraseF string `json:"temperature_peak_phrase_f,omitempty"`
|
||||
TemperatureSteadyPhraseF string `json:"temperature_steady_phrase_f,omitempty"`
|
||||
NotableConditions []string `json:"notable_conditions,omitempty"`
|
||||
Snow bool `json:"snow,omitempty"`
|
||||
Ice bool `json:"ice,omitempty"`
|
||||
Fog bool `json:"fog,omitempty"`
|
||||
Heat bool `json:"heat,omitempty"`
|
||||
Cold bool `json:"cold,omitempty"`
|
||||
Wind bool `json:"wind,omitempty"`
|
||||
RelevantAlertCount int `json:"relevant_alert_count,omitempty"`
|
||||
}
|
||||
|
||||
func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
if len(ctx.Derived.DaypartSummaries) == 0 {
|
||||
return nil, fmt.Errorf("daypart summary facts are required")
|
||||
}
|
||||
value := map[string]DerivedDaypartSummaryModule{}
|
||||
prefixDates := multipleSummaryDates(ctx.Derived.DailySummaries)
|
||||
for _, daypart := range ctx.Derived.DaypartSummaries {
|
||||
key := daypartKey(daypart, prefixDates)
|
||||
value[key] = derivedDaypartSummaryValue(daypart, ctx.Timezone)
|
||||
}
|
||||
return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil
|
||||
}
|
||||
|
||||
func exportDerivedDaypartSummariesPromptValue(value any) (any, error) {
|
||||
rich, ok := value.(map[string]DerivedDaypartSummaryModule)
|
||||
if !ok {
|
||||
return nil, unexpectedPromptExportValue(value, map[string]DerivedDaypartSummaryModule{})
|
||||
}
|
||||
out := make(map[string]DerivedDaypartSummaryPromptExport, len(rich))
|
||||
for key, daypart := range rich {
|
||||
out[key] = derivedDaypartSummaryPromptValue(daypart)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func derivedDaypartSummaryPromptValue(rich DerivedDaypartSummaryModule) DerivedDaypartSummaryPromptExport {
|
||||
maxPopTime := rich.MaxPopTime
|
||||
if rich.MaxPopTimeLabel != "" {
|
||||
maxPopTime = rich.MaxPopTimeLabel
|
||||
}
|
||||
return DerivedDaypartSummaryPromptExport{
|
||||
Date: rich.Date,
|
||||
DisplayName: rich.DisplayName,
|
||||
PeriodBegins: rich.PeriodBegins,
|
||||
PeriodEnds: rich.PeriodEnds,
|
||||
TempRangeF: rich.TempRangeF,
|
||||
ApparentTempRangeF: rich.ApparentTempRangeF,
|
||||
MaxPopPercent: copyInt(rich.MaxPopPercent),
|
||||
MaxPopTime: maxPopTime,
|
||||
MentionPrecipitation: rich.MentionPrecipitation,
|
||||
MaxWindGustMph: copyInt(rich.MaxWindGustMph),
|
||||
MaxWindGustTime: rich.MaxWindGustTime,
|
||||
DominantCondition: rich.DominantCondition,
|
||||
TemperatureTrend: rich.TemperatureTrend,
|
||||
TemperatureStartPhraseF: rich.TemperatureStartPhraseF,
|
||||
TemperatureEndPhraseF: rich.TemperatureEndPhraseF,
|
||||
TemperaturePeakPhraseF: rich.TemperaturePeakPhraseF,
|
||||
TemperatureSteadyPhraseF: rich.TemperatureSteadyPhraseF,
|
||||
NotableConditions: append([]string(nil), rich.NotableConditions...),
|
||||
Snow: rich.Snow,
|
||||
Ice: rich.Ice,
|
||||
Fog: rich.Fog,
|
||||
Heat: rich.Heat,
|
||||
Cold: rich.Cold,
|
||||
Wind: rich.Wind,
|
||||
RelevantAlertCount: rich.RelevantAlertCount,
|
||||
}
|
||||
}
|
||||
|
||||
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
|
||||
temperature := daypartTemperatureDisplay(daypart)
|
||||
value := DerivedDaypartSummaryModule{
|
||||
Date: localDateLabel(daypart.Period.Start, timezone),
|
||||
DisplayName: titleWord(strings.TrimSpace(daypart.Name)),
|
||||
PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone),
|
||||
PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone),
|
||||
TempRangeF: rangeLabel(daypart.Temperature),
|
||||
TemperaturePhraseF: temperaturePhraseF(daypart.Temperature),
|
||||
TemperatureTrend: temperature.Trend,
|
||||
TemperatureStartPhraseF: temperature.StartPhrase,
|
||||
TemperatureEndPhraseF: temperature.EndPhrase,
|
||||
TemperaturePeakPhraseF: temperature.PeakPhrase,
|
||||
TemperatureSteadyPhraseF: temperature.SteadyPhrase,
|
||||
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
|
||||
DominantCondition: daypart.DominantCondition,
|
||||
DominantConditionLower: strings.ToLower(daypart.DominantCondition),
|
||||
DominantConditionDisplay: sentenceCase(daypart.DominantCondition),
|
||||
NotableConditions: append([]string(nil), daypart.NotableConditions...),
|
||||
Snow: daypart.Indicators.Snow,
|
||||
Ice: daypart.Indicators.Ice,
|
||||
Fog: daypart.Indicators.Fog,
|
||||
Heat: daypart.Indicators.Heat,
|
||||
Cold: daypart.Indicators.Cold,
|
||||
Wind: daypart.Indicators.Wind,
|
||||
RelevantAlertCount: len(daypart.AlertOverlaps),
|
||||
}
|
||||
if daypart.MaxPrecipitationProbability != nil {
|
||||
value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value)
|
||||
value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone)
|
||||
value.MaxPopTimeLabel = hourMinuteLabel(daypart.MaxPrecipitationProbability.Time, timezone)
|
||||
value.MentionPrecipitation = mentionHourlyForecastPrecipitation(&daypart.MaxPrecipitationProbability.Value, DefaultHourlyForecastPrecipMentionProbabilityThreshold)
|
||||
}
|
||||
if daypart.PeakWindGust != nil {
|
||||
value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value)
|
||||
value.MaxWindGustTime = clockLabel(daypart.PeakWindGust.Time, timezone)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type daypartTemperaturePresentation struct {
|
||||
Trend string
|
||||
StartPhrase string
|
||||
EndPhrase string
|
||||
PeakPhrase string
|
||||
SteadyPhrase string
|
||||
}
|
||||
|
||||
type temperaturePoint struct {
|
||||
value int
|
||||
bandIndex int
|
||||
phrase string
|
||||
}
|
||||
|
||||
const (
|
||||
temperatureTrendRising = "rising"
|
||||
temperatureTrendFalling = "falling"
|
||||
temperatureTrendPeaking = "peaking"
|
||||
temperatureTrendSteady = "steady"
|
||||
)
|
||||
|
||||
func daypartTemperatureDisplay(daypart forecast.DaypartSummary) daypartTemperaturePresentation {
|
||||
points := daypartTemperaturePoints(daypart.HourlyPeriods)
|
||||
if len(points) == 0 {
|
||||
return daypartSteadyTemperatureDisplay(temperaturePhraseF(daypart.Temperature))
|
||||
}
|
||||
if len(points) == 1 {
|
||||
return daypartSteadyTemperatureDisplay(points[0].phrase)
|
||||
}
|
||||
|
||||
first := points[0]
|
||||
last := points[len(points)-1]
|
||||
peak, peakIndex := peakTemperaturePoint(points)
|
||||
if peakIndex > 0 && peakIndex < len(points)-1 && peak.bandIndex > first.bandIndex && peak.bandIndex > last.bandIndex {
|
||||
return daypartTemperaturePresentation{
|
||||
Trend: temperatureTrendPeaking,
|
||||
PeakPhrase: peak.phrase,
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case first.bandIndex < last.bandIndex:
|
||||
return daypartTemperaturePresentation{
|
||||
Trend: temperatureTrendRising,
|
||||
StartPhrase: first.phrase,
|
||||
EndPhrase: last.phrase,
|
||||
}
|
||||
case first.bandIndex > last.bandIndex:
|
||||
return daypartTemperaturePresentation{
|
||||
Trend: temperatureTrendFalling,
|
||||
StartPhrase: first.phrase,
|
||||
EndPhrase: last.phrase,
|
||||
}
|
||||
default:
|
||||
return daypartSteadyTemperatureDisplay(temperaturePhraseF(daypart.Temperature))
|
||||
}
|
||||
}
|
||||
|
||||
func daypartSteadyTemperatureDisplay(phrase string) daypartTemperaturePresentation {
|
||||
if phrase == "" {
|
||||
return daypartTemperaturePresentation{}
|
||||
}
|
||||
return daypartTemperaturePresentation{
|
||||
Trend: temperatureTrendSteady,
|
||||
SteadyPhrase: phrase,
|
||||
}
|
||||
}
|
||||
|
||||
func daypartTemperaturePoints(periods []weatherdata.ForecastPeriod) []temperaturePoint {
|
||||
sorted := append([]weatherdata.ForecastPeriod(nil), periods...)
|
||||
sort.SliceStable(sorted, func(i int, j int) bool {
|
||||
return sorted[i].StartTime.Before(sorted[j].StartTime)
|
||||
})
|
||||
points := make([]temperaturePoint, 0, len(sorted))
|
||||
for _, period := range sorted {
|
||||
temperature := forecastPeriodTemperatureF(period)
|
||||
if temperature == nil {
|
||||
continue
|
||||
}
|
||||
rounded := roundedInt(temperature)
|
||||
if rounded == nil {
|
||||
continue
|
||||
}
|
||||
points = append(points, temperaturePoint{
|
||||
value: *rounded,
|
||||
bandIndex: temperatureBandIndex(*rounded),
|
||||
phrase: temperatureBandPhrase(*rounded),
|
||||
})
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
func peakTemperaturePoint(points []temperaturePoint) (temperaturePoint, int) {
|
||||
peak := points[0]
|
||||
peakIndex := 0
|
||||
for index, point := range points[1:] {
|
||||
if point.value > peak.value {
|
||||
peak = point
|
||||
peakIndex = index + 1
|
||||
}
|
||||
}
|
||||
return peak, peakIndex
|
||||
}
|
||||
|
||||
func forecastPeriodTemperatureF(period weatherdata.ForecastPeriod) *float64 {
|
||||
switch {
|
||||
case period.TemperatureF != nil:
|
||||
return period.TemperatureF
|
||||
case period.TemperatureFMax != nil:
|
||||
return period.TemperatureFMax
|
||||
case period.TemperatureFMin != nil:
|
||||
return period.TemperatureFMin
|
||||
case period.TemperatureC != nil:
|
||||
value := celsiusToFahrenheit(*period.TemperatureC)
|
||||
return &value
|
||||
case period.TemperatureCMax != nil:
|
||||
value := celsiusToFahrenheit(*period.TemperatureCMax)
|
||||
return &value
|
||||
case period.TemperatureCMin != nil:
|
||||
value := celsiusToFahrenheit(*period.TemperatureCMin)
|
||||
return &value
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func celsiusToFahrenheit(value float64) float64 {
|
||||
return value*9/5 + 32
|
||||
}
|
||||
|
||||
func temperatureBandIndex(value int) int {
|
||||
decade := (value / 10) * 10
|
||||
remainder := value - decade
|
||||
if remainder < 0 {
|
||||
remainder = -remainder
|
||||
}
|
||||
band := 1
|
||||
switch {
|
||||
case remainder <= 3:
|
||||
band = 0
|
||||
case remainder >= 7:
|
||||
band = 2
|
||||
}
|
||||
return decade*3 + band
|
||||
}
|
||||
|
||||
func temperaturePhraseF(value forecast.Range) string {
|
||||
if value.Min == nil && value.Max == nil {
|
||||
return ""
|
||||
}
|
||||
if value.Min != nil && value.Max != nil {
|
||||
low := roundedInt(value.Min)
|
||||
high := roundedInt(value.Max)
|
||||
if low == nil || high == nil {
|
||||
return ""
|
||||
}
|
||||
lowPhrase := temperatureBandPhrase(*low)
|
||||
highPhrase := temperatureBandPhrase(*high)
|
||||
if lowPhrase == highPhrase {
|
||||
return lowPhrase
|
||||
}
|
||||
return lowPhrase + " to " + highPhrase
|
||||
}
|
||||
if value.Min != nil {
|
||||
low := roundedInt(value.Min)
|
||||
if low == nil {
|
||||
return ""
|
||||
}
|
||||
return temperatureBandPhrase(*low)
|
||||
}
|
||||
high := roundedInt(value.Max)
|
||||
if high == nil {
|
||||
return ""
|
||||
}
|
||||
return temperatureBandPhrase(*high)
|
||||
}
|
||||
|
||||
func temperatureBandPhrase(value int) string {
|
||||
decade := (value / 10) * 10
|
||||
remainder := value - decade
|
||||
if remainder < 0 {
|
||||
remainder = -remainder
|
||||
}
|
||||
qualifier := "mid"
|
||||
switch {
|
||||
case remainder <= 3:
|
||||
qualifier = "low"
|
||||
case remainder >= 7:
|
||||
qualifier = "upper"
|
||||
}
|
||||
return fmt.Sprintf("%s %ds", qualifier, decade)
|
||||
}
|
||||
|
||||
func sentenceCase(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(trimmed)
|
||||
runes[0] = unicode.ToUpper(runes[0])
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
func multipleSummaryDates(summaries []forecast.DailySummary) bool {
|
||||
seen := map[string]struct{}{}
|
||||
for _, summary := range summaries {
|
||||
seen[summary.Date] = struct{}{}
|
||||
}
|
||||
return len(seen) > 1
|
||||
}
|
||||
|
||||
func daypartKey(daypart forecast.DaypartSummary, prefixDate bool) string {
|
||||
key := normalizedKey(daypart.Name)
|
||||
if key == "" {
|
||||
key = "unnamed"
|
||||
}
|
||||
if !prefixDate {
|
||||
return key
|
||||
}
|
||||
return daypart.Period.Start.Format(timeutil.DateLayout) + "_" + key
|
||||
}
|
||||
|
||||
func normalizedKey(value string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(value))
|
||||
var out strings.Builder
|
||||
lastUnderscore := false
|
||||
for _, r := range lower {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
out.WriteRune(r)
|
||||
lastUnderscore = false
|
||||
continue
|
||||
}
|
||||
if !lastUnderscore {
|
||||
out.WriteByte('_')
|
||||
lastUnderscore = true
|
||||
}
|
||||
}
|
||||
return strings.Trim(out.String(), "_")
|
||||
}
|
||||
876
internal/briefing/derived_modules_test.go
Normal file
876
internal/briefing/derived_modules_test.go
Normal file
@@ -0,0 +1,876 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.Daily)
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[DerivedDailySummaryModule](t, output)
|
||||
|
||||
if value.Date != "Friday, May 29, 2026" {
|
||||
t.Fatalf("Date = %q, want friendly local date", value.Date)
|
||||
}
|
||||
if value.HighTempF == nil || *value.HighTempF != 88 || value.LowTempF == nil || *value.LowTempF != 64 {
|
||||
t.Fatalf("daily temperatures = %#v/%#v, want narrative 88/64", value.HighTempF, value.LowTempF)
|
||||
}
|
||||
if value.DailyPrecipitationProbability == nil || *value.DailyPrecipitationProbability != 55 {
|
||||
t.Fatalf("DailyPrecipitationProbability = %#v, want narrative 55", value.DailyPrecipitationProbability)
|
||||
}
|
||||
if value.MostLikelyPrecipitationHour != "80% at 12 PM" || !value.ThunderMentioned {
|
||||
t.Fatalf("precip timing = %#v, want most likely hour and thunder", value)
|
||||
}
|
||||
if !containsString(value.DominantConditions, "Thunderstorms with gusty wind") || containsString(value.DominantConditions, "Morning storms, then partly sunny.") {
|
||||
t.Fatalf("DominantConditions = %#v, want daypart conditions rather than narrative conditions", value.DominantConditions)
|
||||
}
|
||||
if value.MaxWindGustMph == nil || *value.MaxWindGustMph != 42 {
|
||||
t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph)
|
||||
}
|
||||
if value.HeatIndexMaxF == nil || *value.HeatIndexMaxF != 101 {
|
||||
t.Fatalf("HeatIndexMaxF = %#v, want 101", value.HeatIndexMaxF)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal daily summary: %v", err)
|
||||
}
|
||||
jsonText := string(data)
|
||||
for _, field := range []string{"high_temp_f", "low_temp_f", "daily_precipitation_probability", "most_likely_precipitation_hour", "heat_index_max_f"} {
|
||||
if !strings.Contains(jsonText, field) {
|
||||
t.Fatalf("daily json = %s, want field %s", jsonText, field)
|
||||
}
|
||||
}
|
||||
for _, removed := range []string{"max_pop_percent", "max_pop_window", "first_precip_hour", "last_precip_hour"} {
|
||||
if strings.Contains(jsonText, removed) {
|
||||
t.Fatalf("daily json = %s, want removed field %s omitted", jsonText, removed)
|
||||
}
|
||||
}
|
||||
if strings.Contains(jsonText, "qpf") {
|
||||
t.Fatalf("daily json = %s, want no QPF fields without upstream QPF facts", jsonText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.Daily)
|
||||
ctx.Derived.DailySummaries[0].NarrativePeriods = nil
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[DerivedDailySummaryModule](t, output)
|
||||
|
||||
if value.HighTempF == nil || *value.HighTempF != 96 || value.LowTempF == nil || *value.LowTempF != 31 {
|
||||
t.Fatalf("daily temperatures = %#v/%#v, want fallback 96/31", value.HighTempF, value.LowTempF)
|
||||
}
|
||||
if value.DailyPrecipitationProbability == nil || *value.DailyPrecipitationProbability != 80 {
|
||||
t.Fatalf("DailyPrecipitationProbability = %#v, want hourly fallback 80", value.DailyPrecipitationProbability)
|
||||
}
|
||||
if len(value.DominantConditions) == 0 || !containsString(value.DominantConditions, "Thunderstorms with gusty wind") {
|
||||
t.Fatalf("DominantConditions = %#v, want fallback daypart conditions", value.DominantConditions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.Daily)
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(rainy) error = %v", err)
|
||||
}
|
||||
rainy := moduleValue[PrecipTimingModule](t, output)
|
||||
if rainy.MaxPopPercent == nil || *rainy.MaxPopPercent != 80 || rainy.MaxPopTime != "12 PM" || rainy.ProbabilityThreshold != forecast.DefaultPrecipWindowProbabilityThreshold || !rainy.ThunderMentioned {
|
||||
t.Fatalf("rainy precip timing = %#v, want peak, threshold, and thunder", rainy)
|
||||
}
|
||||
if len(rainy.PrecipitationWindows) != 2 {
|
||||
t.Fatalf("rainy precipitation windows = %#v, want two windows", rainy.PrecipitationWindows)
|
||||
}
|
||||
if rainy.PrecipitationWindows[0].PeriodBegins != "2026-05-29 at 8:00 AM" || rainy.PrecipitationWindows[0].PeriodBeginsHourLabel != "8:00 AM" || rainy.PrecipitationWindows[0].PeriodEnds != "2026-05-29 at 9:00 AM" || rainy.PrecipitationWindows[0].PeriodEndsHourLabel != "9:00 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 || rainy.PrecipitationWindows[0].MaxPopHourLabel != "8:00 AM" {
|
||||
t.Fatalf("first precipitation window = %#v, want 8-9 AM at 60%%", rainy.PrecipitationWindows[0])
|
||||
}
|
||||
if rainy.PrecipitationWindows[0].PrecipitationType != "showers" || rainy.PrecipitationWindows[0].ExpectationPhrase != "Showers likely." {
|
||||
t.Fatalf("first precipitation window phrase = %#v, want showers likely", rainy.PrecipitationWindows[0])
|
||||
}
|
||||
if rainy.PrecipitationWindows[1].PeriodBegins != "2026-05-29 at 12:00 PM" || rainy.PrecipitationWindows[1].PeriodBeginsHourLabel != "12:00 PM" || rainy.PrecipitationWindows[1].PeriodEnds != "2026-05-29 at 2:00 PM" || rainy.PrecipitationWindows[1].PeriodEndsHourLabel != "2:00 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 || rainy.PrecipitationWindows[1].MaxPopHourLabel != "12:00 PM" {
|
||||
t.Fatalf("second precipitation window = %#v, want noon-2 PM at 80%%", rainy.PrecipitationWindows[1])
|
||||
}
|
||||
if rainy.PrecipitationWindows[1].PrecipitationType != "showers and thunderstorms" || rainy.PrecipitationWindows[1].ExpectationPhrase != "Expect showers and thunderstorms." {
|
||||
t.Fatalf("second precipitation window phrase = %#v, want expect showers and thunderstorms", rainy.PrecipitationWindows[1])
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal precip timing: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "precipitation_windows") || !strings.Contains(string(data), "probability_threshold") || !strings.Contains(string(data), "period_begins_hour_label") || !strings.Contains(string(data), "max_pop_hour_label") {
|
||||
t.Fatalf("precip timing json = %s, want threshold and windows", string(data))
|
||||
}
|
||||
if !strings.Contains(string(data), "precipitation_type") || !strings.Contains(string(data), "expectation_phrase") {
|
||||
t.Fatalf("precip timing json = %s, want precipitation type and expectation phrase", string(data))
|
||||
}
|
||||
if strings.Contains(string(data), `"start"`) || strings.Contains(string(data), `"end"`) {
|
||||
t.Fatalf("precip timing json = %s, want period_begins/period_ends instead of start/end", string(data))
|
||||
}
|
||||
if strings.Contains(string(data), "first_precip_hour") || strings.Contains(string(data), "last_precip_hour") {
|
||||
t.Fatalf("precip timing json = %s, want no ambiguous first/last fields", string(data))
|
||||
}
|
||||
|
||||
ctx.Derived.PrecipTiming = forecast.BuildPrecipTiming([]weatherdata.ForecastPeriod{derivedHour("2026-05-29T10:00:00-05:00", "Sunny", 0, 70, nil, 5)})
|
||||
output, err = registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(dry) error = %v", err)
|
||||
}
|
||||
dry := moduleValue[PrecipTimingModule](t, output)
|
||||
if len(dry.PrecipitationWindows) != 0 || dry.ThunderMentioned {
|
||||
t.Fatalf("dry precip timing = %#v, want no precip windows and no thunder", dry)
|
||||
}
|
||||
if dry.MaxPopPercent == nil || *dry.MaxPopPercent != 0 {
|
||||
t.Fatalf("dry MaxPopPercent = %#v, want checked zero", dry.MaxPopPercent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrecipTimingModuleBuildsExpectationPhrases(t *testing.T) {
|
||||
now := mustParseModuleTime("2026-05-29T08:00:00-05:00")
|
||||
tests := []struct {
|
||||
name string
|
||||
maxPop float64
|
||||
descriptions []string
|
||||
wantType string
|
||||
wantPhrase string
|
||||
}{
|
||||
{
|
||||
name: "chance lower bound",
|
||||
maxPop: 40,
|
||||
descriptions: []string{"Scattered showers"},
|
||||
wantType: "showers",
|
||||
wantPhrase: "Chance of showers.",
|
||||
},
|
||||
{
|
||||
name: "chance upper bound",
|
||||
maxPop: 49,
|
||||
descriptions: []string{"Rain possible"},
|
||||
wantType: "rain",
|
||||
wantPhrase: "Chance of rain.",
|
||||
},
|
||||
{
|
||||
name: "likely lower bound",
|
||||
maxPop: 50,
|
||||
descriptions: []string{"Drizzle"},
|
||||
wantType: "drizzle",
|
||||
wantPhrase: "Drizzle likely.",
|
||||
},
|
||||
{
|
||||
name: "likely upper bound",
|
||||
maxPop: 69,
|
||||
descriptions: []string{"Freezing rain"},
|
||||
wantType: "freezing rain",
|
||||
wantPhrase: "Freezing rain likely.",
|
||||
},
|
||||
{
|
||||
name: "expect lower bound",
|
||||
maxPop: 70,
|
||||
descriptions: []string{"Snow"},
|
||||
wantType: "snow",
|
||||
wantPhrase: "Expect snow.",
|
||||
},
|
||||
{
|
||||
name: "showers and thunderstorms preferred",
|
||||
maxPop: 100,
|
||||
descriptions: []string{"Showers likely", "Thunderstorms possible"},
|
||||
wantType: "showers and thunderstorms",
|
||||
wantPhrase: "Expect showers and thunderstorms.",
|
||||
},
|
||||
{
|
||||
name: "thunderstorms only",
|
||||
maxPop: 80,
|
||||
descriptions: []string{"Thunderstorms"},
|
||||
wantType: "thunderstorms",
|
||||
wantPhrase: "Expect thunderstorms.",
|
||||
},
|
||||
{
|
||||
name: "unknown fallback",
|
||||
maxPop: 95,
|
||||
descriptions: []string{"Unsettled conditions"},
|
||||
wantType: "precipitation",
|
||||
wantPhrase: "Expect precipitation.",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
value := precipTimingValue(forecast.PrecipTiming{
|
||||
ProbabilityThreshold: forecast.DefaultPrecipWindowProbabilityThreshold,
|
||||
PrecipitationWindows: []forecast.PrecipitationWindow{
|
||||
{
|
||||
Start: now,
|
||||
MaxPrecipitationProbability: forecast.TimedValue{
|
||||
Value: tt.maxPop,
|
||||
Time: now,
|
||||
},
|
||||
ProbabilityThreshold: forecast.DefaultPrecipWindowProbabilityThreshold,
|
||||
TextDescriptions: tt.descriptions,
|
||||
},
|
||||
},
|
||||
}, "America/Chicago")
|
||||
if len(value.PrecipitationWindows) != 1 {
|
||||
t.Fatalf("PrecipitationWindows = %#v, want one window", value.PrecipitationWindows)
|
||||
}
|
||||
window := value.PrecipitationWindows[0]
|
||||
if window.PrecipitationType != tt.wantType || window.ExpectationPhrase != tt.wantPhrase {
|
||||
t.Fatalf("window = %#v, want type %q and phrase %q", window, tt.wantType, tt.wantPhrase)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrecipTimingModuleUsesDerivedTimingWithoutDaypartSummaries(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.Hourly)
|
||||
ctx.Derived.DailySummaries = nil
|
||||
ctx.Derived.DaypartSummaries = nil
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[PrecipTimingModule](t, output)
|
||||
if value.MaxPopPercent == nil || *value.MaxPopPercent != 80 || len(value.PrecipitationWindows) != 2 {
|
||||
t.Fatalf("precip timing = %#v, want derived timing without daypart summaries", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.Daily)
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[map[string]DerivedDaypartSummaryModule](t, output)
|
||||
|
||||
morning, ok := value["morning"]
|
||||
if !ok {
|
||||
t.Fatalf("daypart keys = %#v, want configured morning key", value)
|
||||
}
|
||||
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 {
|
||||
t.Fatalf("morning = %#v, want temp range and precip peak", morning)
|
||||
}
|
||||
if morning.DisplayName != "Morning" || morning.DominantConditionLower != "showers" || morning.DominantConditionDisplay != "Showers" || morning.TemperaturePhraseF != "upper 50s" || morning.TemperatureTrend != "steady" || morning.TemperatureSteadyPhraseF != "upper 50s" || !morning.MentionPrecipitation || morning.MaxPopTimeLabel != "6:00 AM" {
|
||||
t.Fatalf("morning presentation fields = %#v, want display facts for template composition", morning)
|
||||
}
|
||||
if morning.Date != "2026-05-29" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" {
|
||||
t.Fatalf("morning period = %q/%q/%q, want friendly local date and period labels", morning.Date, morning.PeriodBegins, morning.PeriodEnds)
|
||||
}
|
||||
overnight := value["overnight"]
|
||||
if overnight.MentionPrecipitation {
|
||||
t.Fatalf("overnight MentionPrecipitation = true, want false below threshold")
|
||||
}
|
||||
afternoon := value["afternoon"]
|
||||
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 {
|
||||
t.Fatalf("afternoon = %#v, want heat and wind hazard values", afternoon)
|
||||
}
|
||||
if !overnight.Cold {
|
||||
t.Fatalf("overnight = %#v, want cold hazard", overnight)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal daypart summaries: %v", err)
|
||||
}
|
||||
jsonText := string(data)
|
||||
for _, field := range []string{"date", "display_name", "period_begins", "period_ends", "temp_range_f", "temperature_phrase_f", "temperature_trend", "temperature_steady_phrase_f", "max_pop_percent", "max_pop_time_label", "mention_precipitation", "max_wind_gust_mph", "dominant_condition", "dominant_condition_lower", "dominant_condition_display"} {
|
||||
if !strings.Contains(jsonText, field) {
|
||||
t.Fatalf("daypart json = %s, want field %s", jsonText, field)
|
||||
}
|
||||
}
|
||||
if strings.Contains(jsonText, `"period":`) || strings.Contains(jsonText, `T06:00:00`) {
|
||||
t.Fatalf("daypart json = %s, want friendly period label instead of raw timestamps", jsonText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedDaypartSummariesPromptExportOmitsTemplateHelpers(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.Daily)
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
richText := mustMarshalModuleJSON(t, output.Value)
|
||||
for _, field := range []string{"temperature_phrase_f", "dominant_condition_lower", "dominant_condition_display", "max_pop_time_label"} {
|
||||
if !strings.Contains(richText, field) {
|
||||
t.Fatalf("rich daypart json = %s, want helper field %s", richText, field)
|
||||
}
|
||||
}
|
||||
|
||||
prompt := moduleDataPackageValue[map[string]DerivedDaypartSummaryPromptExport](t, output)
|
||||
morning, ok := prompt["morning"]
|
||||
if !ok {
|
||||
t.Fatalf("daypart prompt keys = %#v, want morning", prompt)
|
||||
}
|
||||
if morning.Date != "2026-05-29" || morning.DisplayName != "Morning" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" {
|
||||
t.Fatalf("morning prompt period = %#v, want date/display/period labels", morning)
|
||||
}
|
||||
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 || morning.MaxPopTime != "6:00 AM" || !morning.MentionPrecipitation {
|
||||
t.Fatalf("morning prompt precip/temp = %#v, want factual prompt fields with friendly max pop time", morning)
|
||||
}
|
||||
if morning.DominantCondition != "Showers" || morning.TemperatureTrend != "steady" || morning.TemperatureSteadyPhraseF != "upper 50s" {
|
||||
t.Fatalf("morning prompt condition/trend = %#v, want condition and trend fields", morning)
|
||||
}
|
||||
if len(morning.NotableConditions) == 0 || morning.NotableConditions[0] != "Showers" {
|
||||
t.Fatalf("morning prompt notable conditions = %#v, want copied conditions", morning.NotableConditions)
|
||||
}
|
||||
afternoon := prompt["afternoon"]
|
||||
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 || afternoon.RelevantAlertCount != 1 {
|
||||
t.Fatalf("afternoon prompt = %#v, want hazard, wind, and alert fields", afternoon)
|
||||
}
|
||||
|
||||
promptText := mustMarshalModuleJSON(t, output.DataPackageValue())
|
||||
for _, field := range []string{"date", "display_name", "period_begins", "period_ends", "temp_range_f", "max_pop_percent", "max_pop_time", "mention_precipitation", "max_wind_gust_mph", "dominant_condition", "temperature_trend", "temperature_steady_phrase_f", "notable_conditions", "relevant_alert_count"} {
|
||||
if !strings.Contains(promptText, field) {
|
||||
t.Fatalf("daypart prompt json = %s, want field %s", promptText, field)
|
||||
}
|
||||
}
|
||||
for _, field := range []string{"temperature_phrase_f", "dominant_condition_lower", "dominant_condition_display", "max_pop_time_label"} {
|
||||
if strings.Contains(promptText, field) {
|
||||
t.Fatalf("daypart prompt json = %s, want omitted helper field %s", promptText, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedDaypartPromptExportTemperatureTrends(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
temps []float64
|
||||
wantTrend string
|
||||
wantStart string
|
||||
wantEnd string
|
||||
wantPeak string
|
||||
wantSteady string
|
||||
}{
|
||||
{
|
||||
name: "rising",
|
||||
temps: []float64{58, 68},
|
||||
wantTrend: "rising",
|
||||
wantStart: "upper 50s",
|
||||
wantEnd: "upper 60s",
|
||||
},
|
||||
{
|
||||
name: "falling",
|
||||
temps: []float64{65, 58},
|
||||
wantTrend: "falling",
|
||||
wantStart: "mid 60s",
|
||||
wantEnd: "upper 50s",
|
||||
},
|
||||
{
|
||||
name: "peaking",
|
||||
temps: []float64{62, 78, 65},
|
||||
wantTrend: "peaking",
|
||||
wantPeak: "upper 70s",
|
||||
},
|
||||
{
|
||||
name: "steady",
|
||||
temps: []float64{77, 78},
|
||||
wantTrend: "steady",
|
||||
wantSteady: "upper 70s",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
rich := derivedDaypartSummaryValue(derivedDaypartWithTemperatures(test.name, "2026-05-29T12:00:00-05:00", "sunny", test.temps...), "America/Chicago")
|
||||
prompt := derivedDaypartSummaryPromptValue(rich)
|
||||
if prompt.TemperatureTrend != test.wantTrend ||
|
||||
prompt.TemperatureStartPhraseF != test.wantStart ||
|
||||
prompt.TemperatureEndPhraseF != test.wantEnd ||
|
||||
prompt.TemperaturePeakPhraseF != test.wantPeak ||
|
||||
prompt.TemperatureSteadyPhraseF != test.wantSteady {
|
||||
t.Fatalf("prompt temperature presentation = %#v", prompt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedDaypartPromptExportMaxPopTimeFallback(t *testing.T) {
|
||||
withLabel := derivedDaypartSummaryPromptValue(DerivedDaypartSummaryModule{
|
||||
MaxPopTime: "6 AM",
|
||||
MaxPopTimeLabel: "6:00 AM",
|
||||
})
|
||||
if withLabel.MaxPopTime != "6:00 AM" {
|
||||
t.Fatalf("MaxPopTime with label = %q, want friendly label", withLabel.MaxPopTime)
|
||||
}
|
||||
|
||||
withoutLabel := derivedDaypartSummaryPromptValue(DerivedDaypartSummaryModule{
|
||||
MaxPopTime: "6 AM",
|
||||
})
|
||||
if withoutLabel.MaxPopTime != "6 AM" {
|
||||
t.Fatalf("MaxPopTime without label = %q, want fallback time", withoutLabel.MaxPopTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedDaypartTemperaturePresentationFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
temps []float64
|
||||
wantTrend string
|
||||
wantStart string
|
||||
wantEnd string
|
||||
wantPeak string
|
||||
wantSteady string
|
||||
}{
|
||||
{
|
||||
name: "rising",
|
||||
temps: []float64{58, 68},
|
||||
wantTrend: "rising",
|
||||
wantStart: "upper 50s",
|
||||
wantEnd: "upper 60s",
|
||||
},
|
||||
{
|
||||
name: "falling",
|
||||
temps: []float64{65, 58},
|
||||
wantTrend: "falling",
|
||||
wantStart: "mid 60s",
|
||||
wantEnd: "upper 50s",
|
||||
},
|
||||
{
|
||||
name: "peaking",
|
||||
temps: []float64{62, 78, 65},
|
||||
wantTrend: "peaking",
|
||||
wantPeak: "upper 70s",
|
||||
},
|
||||
{
|
||||
name: "steady same band",
|
||||
temps: []float64{77, 78},
|
||||
wantTrend: "steady",
|
||||
wantSteady: "upper 70s",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
summary := derivedDaypartWithTemperatures("afternoon", "2026-05-29T12:00:00-05:00", "sunny", test.temps...)
|
||||
value := derivedDaypartSummaryValue(summary, "America/Chicago")
|
||||
if value.DominantConditionDisplay != "Sunny" {
|
||||
t.Fatalf("DominantConditionDisplay = %q, want Sunny", value.DominantConditionDisplay)
|
||||
}
|
||||
if value.TemperatureTrend != test.wantTrend ||
|
||||
value.TemperatureStartPhraseF != test.wantStart ||
|
||||
value.TemperatureEndPhraseF != test.wantEnd ||
|
||||
value.TemperaturePeakPhraseF != test.wantPeak ||
|
||||
value.TemperatureSteadyPhraseF != test.wantSteady {
|
||||
t.Fatalf("temperature presentation = %#v", value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemperaturePhraseF(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value forecast.Range
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "single low band",
|
||||
value: forecast.Range{Min: floatPtr(71), Max: floatPtr(73)},
|
||||
want: "low 70s",
|
||||
},
|
||||
{
|
||||
name: "single upper value",
|
||||
value: forecast.Range{Min: floatPtr(68), Max: floatPtr(68)},
|
||||
want: "upper 60s",
|
||||
},
|
||||
{
|
||||
name: "range across bands",
|
||||
value: forecast.Range{Min: floatPtr(68), Max: floatPtr(75)},
|
||||
want: "upper 60s to mid 70s",
|
||||
},
|
||||
{
|
||||
name: "max only",
|
||||
value: forecast.Range{Max: floatPtr(84)},
|
||||
want: "mid 80s",
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
value: forecast.Range{},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := temperaturePhraseF(test.value); got != test.want {
|
||||
t.Fatalf("temperaturePhraseF() = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.Tomorrow)
|
||||
|
||||
outdoorOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(outdoor windows) error = %v", err)
|
||||
}
|
||||
outdoor := moduleValue[OutdoorWindowsModule](t, outdoorOutput)
|
||||
if outdoor.Best == nil || outdoor.Worst == nil {
|
||||
t.Fatalf("outdoor windows = %#v, want best and worst", outdoor)
|
||||
}
|
||||
if outdoor.Best.Daypart != "overnight" || outdoor.Worst.Daypart != "afternoon" {
|
||||
t.Fatalf("outdoor windows = %#v, want quiet overnight and stormy afternoon", outdoor)
|
||||
}
|
||||
if outdoor.Best.PeriodBegins != "2026-05-29 at 12:00 AM" || outdoor.Best.PeriodEnds != "2026-05-29 at 6:00 AM" {
|
||||
t.Fatalf("best outdoor period = %#v, want overnight period labels", outdoor.Best)
|
||||
}
|
||||
if outdoor.Worst.PeriodBegins != "2026-05-29 at 12:00 PM" || outdoor.Worst.PeriodEnds != "2026-05-29 at 6:00 PM" {
|
||||
t.Fatalf("worst outdoor period = %#v, want afternoon period labels", outdoor.Worst)
|
||||
}
|
||||
|
||||
planningOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TomorrowPlanning})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(tomorrow planning) error = %v", err)
|
||||
}
|
||||
planning := moduleValue[TomorrowPlanningModule](t, planningOutput)
|
||||
if len(planning.MorningReadiness) == 0 || len(planning.CommuteSchoolWorkdayConcerns) == 0 || len(planning.OvernightChangeWatch) == 0 {
|
||||
t.Fatalf("tomorrow planning = %#v, want daily planning notes", planning)
|
||||
}
|
||||
data, err := json.Marshal(planningOutput.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal tomorrow planning: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "morning_readiness") || strings.Contains(string(data), "morningReadiness") {
|
||||
t.Fatalf("tomorrow planning json = %s, want snake_case fields", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyPlanningModulePackagesPlanningFields(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := dailyModuleContext()
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(daily planning) error = %v", err)
|
||||
}
|
||||
if output.ID != module.DailyPlanning || output.StanzaName != "daily_planning" {
|
||||
t.Fatalf("output = %#v, want daily planning stanza", output)
|
||||
}
|
||||
planning := moduleValue[DailyPlanningModule](t, output)
|
||||
if len(planning.MorningReadiness) == 0 ||
|
||||
len(planning.CommuteSchoolWorkdayConcerns) == 0 ||
|
||||
len(planning.OvernightChangeWatch) == 0 {
|
||||
t.Fatalf("daily planning = %#v, want populated planning fields", planning)
|
||||
}
|
||||
if !containsString(planning.MorningReadiness, "Morning precipitation chance peaks near 60%.") {
|
||||
t.Fatalf("MorningReadiness = %#v, want precipitation readiness note", planning.MorningReadiness)
|
||||
}
|
||||
if !containsString(planning.CommuteSchoolWorkdayConcerns, "Afternoon alert overlap needs attention.") {
|
||||
t.Fatalf("CommuteSchoolWorkdayConcerns = %#v, want alert-overlap concern", planning.CommuteSchoolWorkdayConcerns)
|
||||
}
|
||||
if !containsString(planning.OvernightChangeWatch, "Watch for forecast timing or intensity adjustments overnight.") {
|
||||
t.Fatalf("OvernightChangeWatch = %#v, want overnight fallback note", planning.OvernightChangeWatch)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal daily planning: %v", err)
|
||||
}
|
||||
jsonText := string(data)
|
||||
for _, field := range []string{"morning_readiness", "commute_school_workday_concerns", "overnight_change_watch"} {
|
||||
if !strings.Contains(jsonText, field) {
|
||||
t.Fatalf("daily planning json = %s, want field %s", jsonText, field)
|
||||
}
|
||||
}
|
||||
if strings.Contains(jsonText, "tomorrow_planning") || strings.Contains(jsonText, "morningReadiness") {
|
||||
t.Fatalf("daily planning json = %s, want daily snake_case fields only", jsonText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyPlanningModuleRejectsUnsupportedReports(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
for _, id := range []report.ID{report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm} {
|
||||
t.Run(string(id), func(t *testing.T) {
|
||||
ctx := derivedModuleContext(id)
|
||||
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning})
|
||||
if err == nil || !strings.Contains(err.Error(), `module "daily_planning" is not compatible with report`) {
|
||||
t.Fatalf("BuildModule(%s) error = %v, want incompatible report", id, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyPlanningModuleHandlesMissingDailySummary(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := dailyModuleContext()
|
||||
ctx.Derived.DailySummaries = nil
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(daily planning) error = %v", err)
|
||||
}
|
||||
planning := moduleValue[DailyPlanningModule](t, output)
|
||||
if len(planning.MorningReadiness) != 0 ||
|
||||
len(planning.CommuteSchoolWorkdayConcerns) != 0 ||
|
||||
len(planning.OvernightChangeWatch) != 0 {
|
||||
t.Fatalf("daily planning = %#v, want empty output without daily summary", planning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodayPlanningModulePackagesPlanningFields(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := todayModuleContext()
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(today planning) error = %v", err)
|
||||
}
|
||||
if output.ID != module.TodayPlanning || output.StanzaName != "today_planning" {
|
||||
t.Fatalf("output = %#v, want today planning stanza", output)
|
||||
}
|
||||
planning := moduleValue[TodayPlanningModule](t, output)
|
||||
if len(planning.MorningReadiness) == 0 ||
|
||||
len(planning.CommuteSchoolWorkdayConcerns) == 0 ||
|
||||
len(planning.OutdoorPlanning) == 0 ||
|
||||
len(planning.LateDayChangeWatch) == 0 {
|
||||
t.Fatalf("today planning = %#v, want populated planning fields", planning)
|
||||
}
|
||||
if !containsString(planning.MorningReadiness, "Morning precipitation chance peaks near 60%.") {
|
||||
t.Fatalf("MorningReadiness = %#v, want precipitation readiness note", planning.MorningReadiness)
|
||||
}
|
||||
if !containsString(planning.OutdoorPlanning, "Best outdoor window: Overnight (cold risk).") ||
|
||||
!containsString(planning.OutdoorPlanning, "Toughest outdoor window: Afternoon (high precipitation chance, gusty wind, alert overlap, heat risk).") {
|
||||
t.Fatalf("OutdoorPlanning = %#v, want deterministic best and toughest windows", planning.OutdoorPlanning)
|
||||
}
|
||||
if !containsString(planning.LateDayChangeWatch, "Afternoon precipitation timing may shift; current peak is near 80%.") {
|
||||
t.Fatalf("LateDayChangeWatch = %#v, want late-day change note", planning.LateDayChangeWatch)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal today planning: %v", err)
|
||||
}
|
||||
jsonText := string(data)
|
||||
for _, field := range []string{"morning_readiness", "commute_school_workday_concerns", "outdoor_planning", "late_day_change_watch"} {
|
||||
if !strings.Contains(jsonText, field) {
|
||||
t.Fatalf("today planning json = %s, want field %s", jsonText, field)
|
||||
}
|
||||
}
|
||||
if strings.Contains(jsonText, "morningReadiness") || strings.Contains(jsonText, "lateDayChangeWatch") {
|
||||
t.Fatalf("today planning json = %s, want snake_case fields", jsonText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodayPlanningModuleRejectsUnsupportedReports(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
for _, id := range []report.ID{report.Tomorrow, report.Daily} {
|
||||
t.Run(string(id), func(t *testing.T) {
|
||||
ctx := derivedModuleContext(id)
|
||||
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning})
|
||||
if err == nil || !strings.Contains(err.Error(), `module "today_planning" is not compatible with report`) {
|
||||
t.Fatalf("BuildModule(%s) error = %v, want incompatible report", id, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodayPlanningModuleHandlesMissingDailySummary(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := todayModuleContext()
|
||||
ctx.Derived.DailySummaries = nil
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(today planning) error = %v", err)
|
||||
}
|
||||
planning := moduleValue[TodayPlanningModule](t, output)
|
||||
if len(planning.MorningReadiness) != 0 ||
|
||||
len(planning.CommuteSchoolWorkdayConcerns) != 0 ||
|
||||
len(planning.OutdoorPlanning) != 0 ||
|
||||
len(planning.LateDayChangeWatch) != 0 {
|
||||
t.Fatalf("today planning = %#v, want empty output without daily summary", planning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedModulesHandleMissingData(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.Daily)
|
||||
ctx.Derived.DailySummaries = nil
|
||||
ctx.Derived.DaypartSummaries = nil
|
||||
|
||||
if _, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary}); err == nil {
|
||||
t.Fatal("BuildModule(derived daily summary) error = nil, want required facts error")
|
||||
}
|
||||
if _, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries}); err == nil {
|
||||
t.Fatal("BuildModule(daypart summaries) error = nil, want required facts error")
|
||||
}
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(outdoor windows) error = %v", err)
|
||||
}
|
||||
windows := moduleValue[OutdoorWindowsModule](t, output)
|
||||
if windows.Best != nil || windows.Worst != nil {
|
||||
t.Fatalf("outdoor windows = %#v, want empty output with missing dayparts", windows)
|
||||
}
|
||||
}
|
||||
|
||||
func derivedModuleContext(id report.ID) ModuleContext {
|
||||
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
|
||||
definition := report.DefaultRegistry().MustLookup(id)
|
||||
summary := forecast.DailySummary{
|
||||
Date: "2026-05-29",
|
||||
Period: timeutil.Period{
|
||||
Start: mustParseModuleTime("2026-05-29T00:00:00-05:00"),
|
||||
End: mustParseModuleTime("2026-05-30T00:00:00-05:00"),
|
||||
},
|
||||
Dayparts: []forecast.DaypartSummary{
|
||||
derivedDaypart("overnight", "2026-05-29T00:00:00-05:00", "2026-05-29T06:00:00-05:00", "Clear and cold", 31, nil, 0, 5),
|
||||
derivedDaypart("morning", "2026-05-29T06:00:00-05:00", "2026-05-29T12:00:00-05:00", "Showers", 58, nil, 60, 15),
|
||||
derivedDaypart("afternoon", "2026-05-29T12:00:00-05:00", "2026-05-29T18:00:00-05:00", "Thunderstorms with gusty wind", 96, floatPtr(101), 80, 42),
|
||||
},
|
||||
}
|
||||
hours := []weatherdata.ForecastPeriod{
|
||||
derivedHour("2026-05-29T00:00:00-05:00", "Clear and cold", 0, 31, nil, 5),
|
||||
derivedHour("2026-05-29T08:00:00-05:00", "Showers", 60, 58, nil, 15),
|
||||
derivedHour("2026-05-29T09:00:00-05:00", "Dry break", 20, 62, nil, 10),
|
||||
derivedHour("2026-05-29T12:00:00-05:00", "Thunderstorms with gusty wind", 80, 96, floatPtr(101), 42),
|
||||
derivedHour("2026-05-29T13:00:00-05:00", "Heavy rain", 70, 82, nil, 30),
|
||||
derivedHour("2026-05-29T14:00:00-05:00", "Drying out", 20, 78, nil, 12),
|
||||
}
|
||||
narrative := []weatherdata.ForecastPeriod{
|
||||
{
|
||||
Name: "Today",
|
||||
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
|
||||
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
|
||||
IsDay: boolPtr(true),
|
||||
TextDescription: "Morning storms, then partly sunny.",
|
||||
TemperatureFMax: floatPtr(88),
|
||||
ProbabilityOfPrecipitationPercent: floatPtr(55),
|
||||
},
|
||||
{
|
||||
Name: "Tonight",
|
||||
StartTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
|
||||
EndTime: mustParseModuleTime("2026-05-30T00:00:00-05:00"),
|
||||
IsDay: boolPtr(false),
|
||||
TextDescription: "Clouds linger tonight.",
|
||||
TemperatureFMin: floatPtr(64),
|
||||
ProbabilityOfPrecipitationPercent: floatPtr(30),
|
||||
},
|
||||
}
|
||||
summary.Dayparts[2].AlertOverlaps = []forecast.AlertOverlap{{Event: "Severe Thunderstorm Watch"}}
|
||||
summary.NarrativePeriods = append([]weatherdata.ForecastPeriod(nil), narrative...)
|
||||
return ModuleContext{
|
||||
Resolved: report.Resolved{
|
||||
Definition: definition,
|
||||
GeneratedAt: generatedAt,
|
||||
Timezone: "America/Chicago",
|
||||
ValidPeriod: summary.Period,
|
||||
},
|
||||
Collected: facts.CollectedFacts{
|
||||
Narrative: &weatherdata.ForecastRun{
|
||||
IssuedAt: mustParseModuleTime("2026-05-29T10:30:00-05:00"),
|
||||
Product: "narrative",
|
||||
Periods: append([]weatherdata.ForecastPeriod(nil), narrative...),
|
||||
},
|
||||
},
|
||||
Derived: facts.DerivedFacts{
|
||||
ValidPeriodHourlyPeriods: hours,
|
||||
ValidPeriodNarrativePeriods: narrative,
|
||||
DailySummaries: []forecast.DailySummary{summary},
|
||||
DaypartSummaries: append([]forecast.DaypartSummary(nil), summary.Dayparts...),
|
||||
PrecipTiming: forecast.BuildPrecipTiming(hours),
|
||||
},
|
||||
Units: "us",
|
||||
Timezone: "America/Chicago",
|
||||
}
|
||||
}
|
||||
|
||||
func todayModuleContext() ModuleContext {
|
||||
ctx := derivedModuleContext(report.Daily)
|
||||
ctx.Resolved.Definition = report.Definition{
|
||||
ID: report.Today,
|
||||
Name: "Today Report",
|
||||
PromptID: "weather.today_generated_text",
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func dailyModuleContext() ModuleContext {
|
||||
ctx := derivedModuleContext(report.Tomorrow)
|
||||
ctx.Resolved.Definition = report.Definition{
|
||||
ID: report.Daily,
|
||||
Name: "Daily Report",
|
||||
PromptID: "weather.daily_generated_text",
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func derivedDaypart(name string, start string, end string, text string, temperature float64, apparent *float64, precip float64, gust float64) forecast.DaypartSummary {
|
||||
hour := derivedHour(start, text, precip, temperature, apparent, gust)
|
||||
return forecast.SummarizeDaypart(name, timeutil.Period{
|
||||
Start: mustParseModuleTime(start),
|
||||
End: mustParseModuleTime(end),
|
||||
}, []weatherdata.ForecastPeriod{hour})
|
||||
}
|
||||
|
||||
func derivedDaypartWithTemperatures(name string, start string, text string, temperatures ...float64) forecast.DaypartSummary {
|
||||
startTime := mustParseModuleTime(start)
|
||||
periods := make([]weatherdata.ForecastPeriod, 0, len(temperatures))
|
||||
for index, temperature := range temperatures {
|
||||
periodStart := startTime.Add(time.Duration(index) * time.Hour)
|
||||
periods = append(periods, weatherdata.ForecastPeriod{
|
||||
StartTime: periodStart,
|
||||
EndTime: periodStart.Add(time.Hour),
|
||||
TextDescription: text,
|
||||
TemperatureF: floatPtr(temperature),
|
||||
})
|
||||
}
|
||||
return forecast.SummarizeDaypart(name, timeutil.Period{
|
||||
Start: startTime,
|
||||
End: startTime.Add(time.Duration(len(temperatures)) * time.Hour),
|
||||
}, periods)
|
||||
}
|
||||
|
||||
func derivedHour(start string, text string, precip float64, temperature float64, apparent *float64, gust float64) weatherdata.ForecastPeriod {
|
||||
startTime := mustParseModuleTime(start)
|
||||
endTime := startTime.Add(time.Hour)
|
||||
return weatherdata.ForecastPeriod{
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
TextDescription: text,
|
||||
TemperatureF: floatPtr(temperature),
|
||||
ApparentTemperatureF: apparent,
|
||||
ProbabilityOfPrecipitationPercent: floatPtr(precip),
|
||||
WindGustMph: floatPtr(gust),
|
||||
}
|
||||
}
|
||||
|
||||
func floatPtr(value float64) *float64 {
|
||||
return &value
|
||||
}
|
||||
|
||||
func boolPtr(value bool) *bool {
|
||||
return &value
|
||||
}
|
||||
|
||||
func containsString(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
247
internal/briefing/hourly_forecast_module.go
Normal file
247
internal/briefing/hourly_forecast_module.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
const DefaultHourlyForecastPrecipMentionProbabilityThreshold = 20
|
||||
|
||||
type HourlyForecastModule struct {
|
||||
Product string `json:"product,omitempty"`
|
||||
IssuedAt time.Time `json:"issued_at,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
SourceLocation string `json:"source_location,omitempty"`
|
||||
SourceLocationID string `json:"source_location_id,omitempty"`
|
||||
Periods []HourlyForecastPeriod `json:"periods,omitempty"`
|
||||
}
|
||||
|
||||
type HourlyForecastPromptExport struct {
|
||||
Product string `json:"product,omitempty"`
|
||||
IssuedAt time.Time `json:"issued_at,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
SourceLocation string `json:"source_location,omitempty"`
|
||||
SourceLocationID string `json:"source_location_id,omitempty"`
|
||||
Periods []HourlyForecastPromptPeriod `json:"periods,omitempty"`
|
||||
}
|
||||
|
||||
type HourlyForecastPeriod struct {
|
||||
HourLabel string `json:"hour_label,omitempty"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
IsDay *bool `json:"is_day,omitempty"`
|
||||
ConditionCode *int `json:"condition_code,omitempty"`
|
||||
TextDescription string `json:"text_description,omitempty"`
|
||||
TextDescriptionLower string `json:"text_description_lower,omitempty"`
|
||||
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||
TemperatureF *float64 `json:"temperature_f,omitempty"`
|
||||
TemperatureCMin *float64 `json:"temperature_c_min,omitempty"`
|
||||
TemperatureFMin *float64 `json:"temperature_f_min,omitempty"`
|
||||
TemperatureCMax *float64 `json:"temperature_c_max,omitempty"`
|
||||
TemperatureFMax *float64 `json:"temperature_f_max,omitempty"`
|
||||
DewpointC *float64 `json:"dewpoint_c,omitempty"`
|
||||
DewpointF *float64 `json:"dewpoint_f,omitempty"`
|
||||
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
|
||||
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
|
||||
WindGustKmh *float64 `json:"wind_gust_kmh,omitempty"`
|
||||
WindGustMph *float64 `json:"wind_gust_mph,omitempty"`
|
||||
WindDirection string `json:"wind_direction,omitempty"`
|
||||
BarometricPressurePa *float64 `json:"barometric_pressure_pa,omitempty"`
|
||||
BarometricPressureInHg *float64 `json:"barometric_pressure_in_hg,omitempty"`
|
||||
VisibilityMeters *float64 `json:"visibility_meters,omitempty"`
|
||||
VisibilityMiles *float64 `json:"visibility_miles,omitempty"`
|
||||
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"`
|
||||
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"`
|
||||
CloudCoverPercent *float64 `json:"cloud_cover_percent,omitempty"`
|
||||
ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"`
|
||||
MentionPrecipitation bool `json:"mention_precipitation,omitempty"`
|
||||
PrecipitationAmountMm *float64 `json:"precipitation_amount_mm,omitempty"`
|
||||
PrecipitationAmountIn *float64 `json:"precipitation_amount_in,omitempty"`
|
||||
SnowfallDepthMM *float64 `json:"snowfall_depth_mm,omitempty"`
|
||||
SnowfallDepthIn *float64 `json:"snowfall_depth_in,omitempty"`
|
||||
UVIndex *float64 `json:"uv_index,omitempty"`
|
||||
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
|
||||
}
|
||||
|
||||
type HourlyForecastPromptPeriod struct {
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
IsDay *bool `json:"is_day,omitempty"`
|
||||
ConditionCode *int `json:"condition_code,omitempty"`
|
||||
TextDescription string `json:"text_description,omitempty"`
|
||||
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||
TemperatureF *float64 `json:"temperature_f,omitempty"`
|
||||
TemperatureCMin *float64 `json:"temperature_c_min,omitempty"`
|
||||
TemperatureFMin *float64 `json:"temperature_f_min,omitempty"`
|
||||
TemperatureCMax *float64 `json:"temperature_c_max,omitempty"`
|
||||
TemperatureFMax *float64 `json:"temperature_f_max,omitempty"`
|
||||
DewpointC *float64 `json:"dewpoint_c,omitempty"`
|
||||
DewpointF *float64 `json:"dewpoint_f,omitempty"`
|
||||
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
|
||||
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
|
||||
WindGustKmh *float64 `json:"wind_gust_kmh,omitempty"`
|
||||
WindGustMph *float64 `json:"wind_gust_mph,omitempty"`
|
||||
WindDirection string `json:"wind_direction,omitempty"`
|
||||
BarometricPressurePa *float64 `json:"barometric_pressure_pa,omitempty"`
|
||||
BarometricPressureInHg *float64 `json:"barometric_pressure_in_hg,omitempty"`
|
||||
VisibilityMeters *float64 `json:"visibility_meters,omitempty"`
|
||||
VisibilityMiles *float64 `json:"visibility_miles,omitempty"`
|
||||
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"`
|
||||
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"`
|
||||
CloudCoverPercent *float64 `json:"cloud_cover_percent,omitempty"`
|
||||
ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"`
|
||||
PrecipitationAmountMm *float64 `json:"precipitation_amount_mm,omitempty"`
|
||||
PrecipitationAmountIn *float64 `json:"precipitation_amount_in,omitempty"`
|
||||
SnowfallDepthMM *float64 `json:"snowfall_depth_mm,omitempty"`
|
||||
SnowfallDepthIn *float64 `json:"snowfall_depth_in,omitempty"`
|
||||
UVIndex *float64 `json:"uv_index,omitempty"`
|
||||
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
|
||||
}
|
||||
|
||||
func buildHourlyForecastModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
hourly := ctx.Collected.Hourly
|
||||
if hourly == nil || len(ctx.Derived.ValidPeriodHourlyPeriods) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
value := HourlyForecastModule{
|
||||
Product: hourly.Product,
|
||||
IssuedAt: hourly.IssuedAt,
|
||||
UpdatedAt: copyTime(hourly.UpdatedAt),
|
||||
SourceLocation: hourly.LocationName,
|
||||
SourceLocationID: hourly.LocationID,
|
||||
Periods: hourlyForecastPeriods(ctx.Derived.ValidPeriodHourlyPeriods, ctx.Timezone),
|
||||
}
|
||||
if value.isEmpty() {
|
||||
return nil, nil
|
||||
}
|
||||
return &module.Output{ID: module.HourlyForecast, StanzaName: "hourly_forecast", Value: value}, nil
|
||||
}
|
||||
|
||||
func exportHourlyForecastPromptValue(value any) (any, error) {
|
||||
rich, ok := value.(HourlyForecastModule)
|
||||
if !ok {
|
||||
return nil, unexpectedPromptExportValue(value, HourlyForecastModule{})
|
||||
}
|
||||
return HourlyForecastPromptExport{
|
||||
Product: rich.Product,
|
||||
IssuedAt: rich.IssuedAt,
|
||||
UpdatedAt: copyTime(rich.UpdatedAt),
|
||||
SourceLocation: rich.SourceLocation,
|
||||
SourceLocationID: rich.SourceLocationID,
|
||||
Periods: hourlyForecastPromptPeriods(rich.Periods),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func hourlyForecastPromptPeriods(periods []HourlyForecastPeriod) []HourlyForecastPromptPeriod {
|
||||
if len(periods) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]HourlyForecastPromptPeriod, 0, len(periods))
|
||||
for _, period := range periods {
|
||||
out = append(out, HourlyForecastPromptPeriod{
|
||||
PeriodBegins: period.PeriodBegins,
|
||||
PeriodEnds: period.PeriodEnds,
|
||||
Name: period.Name,
|
||||
IsDay: copyBool(period.IsDay),
|
||||
ConditionCode: copyInt(period.ConditionCode),
|
||||
TextDescription: period.TextDescription,
|
||||
TemperatureC: copyFloat(period.TemperatureC),
|
||||
TemperatureF: copyFloat(period.TemperatureF),
|
||||
TemperatureCMin: copyFloat(period.TemperatureCMin),
|
||||
TemperatureFMin: copyFloat(period.TemperatureFMin),
|
||||
TemperatureCMax: copyFloat(period.TemperatureCMax),
|
||||
TemperatureFMax: copyFloat(period.TemperatureFMax),
|
||||
DewpointC: copyFloat(period.DewpointC),
|
||||
DewpointF: copyFloat(period.DewpointF),
|
||||
WindSpeedKmh: copyFloat(period.WindSpeedKmh),
|
||||
WindSpeedMph: copyFloat(period.WindSpeedMph),
|
||||
WindGustKmh: copyFloat(period.WindGustKmh),
|
||||
WindGustMph: copyFloat(period.WindGustMph),
|
||||
WindDirection: period.WindDirection,
|
||||
BarometricPressurePa: copyFloat(period.BarometricPressurePa),
|
||||
BarometricPressureInHg: copyFloat(period.BarometricPressureInHg),
|
||||
VisibilityMeters: copyFloat(period.VisibilityMeters),
|
||||
VisibilityMiles: copyFloat(period.VisibilityMiles),
|
||||
ApparentTemperatureC: copyFloat(period.ApparentTemperatureC),
|
||||
ApparentTemperatureF: copyFloat(period.ApparentTemperatureF),
|
||||
CloudCoverPercent: copyFloat(period.CloudCoverPercent),
|
||||
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
|
||||
PrecipitationAmountMm: copyFloat(period.PrecipitationAmountMm),
|
||||
PrecipitationAmountIn: copyFloat(period.PrecipitationAmountIn),
|
||||
SnowfallDepthMM: copyFloat(period.SnowfallDepthMM),
|
||||
SnowfallDepthIn: copyFloat(period.SnowfallDepthIn),
|
||||
UVIndex: copyFloat(period.UVIndex),
|
||||
RelativeHumidityPercent: copyFloat(period.RelativeHumidityPercent),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod {
|
||||
return hourlyForecastPeriodsWithPrecipMentionThreshold(periods, timezone, DefaultHourlyForecastPrecipMentionProbabilityThreshold)
|
||||
}
|
||||
|
||||
func hourlyForecastPeriodsWithPrecipMentionThreshold(periods []weatherdata.ForecastPeriod, timezone string, threshold float64) []HourlyForecastPeriod {
|
||||
out := make([]HourlyForecastPeriod, 0, len(periods))
|
||||
for _, period := range periods {
|
||||
validPeriod := timeutil.Period{Start: period.StartTime, End: period.EndTime}
|
||||
out = append(out, HourlyForecastPeriod{
|
||||
HourLabel: hourMinuteLabel(period.StartTime, timezone),
|
||||
PeriodBegins: friendlyPeriodBeginsLabel(validPeriod, timezone),
|
||||
PeriodEnds: friendlyPeriodEndsLabel(validPeriod, timezone),
|
||||
Name: period.Name,
|
||||
IsDay: copyBool(period.IsDay),
|
||||
ConditionCode: copyInt(period.ConditionCode),
|
||||
TextDescription: period.TextDescription,
|
||||
TextDescriptionLower: strings.ToLower(period.TextDescription),
|
||||
TemperatureC: copyFloat(period.TemperatureC),
|
||||
TemperatureF: copyFloat(period.TemperatureF),
|
||||
TemperatureCMin: copyFloat(period.TemperatureCMin),
|
||||
TemperatureFMin: copyFloat(period.TemperatureFMin),
|
||||
TemperatureCMax: copyFloat(period.TemperatureCMax),
|
||||
TemperatureFMax: copyFloat(period.TemperatureFMax),
|
||||
DewpointC: copyFloat(period.DewpointC),
|
||||
DewpointF: copyFloat(period.DewpointF),
|
||||
WindSpeedKmh: copyFloat(period.WindSpeedKmh),
|
||||
WindSpeedMph: copyFloat(period.WindSpeedMph),
|
||||
WindGustKmh: copyFloat(period.WindGustKmh),
|
||||
WindGustMph: copyFloat(period.WindGustMph),
|
||||
WindDirection: windDirectionLabel(period.WindDirectionDegrees),
|
||||
BarometricPressurePa: copyFloat(period.BarometricPressurePa),
|
||||
BarometricPressureInHg: copyFloat(period.BarometricPressureInHg),
|
||||
VisibilityMeters: copyFloat(period.VisibilityMeters),
|
||||
VisibilityMiles: copyFloat(period.VisibilityMiles),
|
||||
ApparentTemperatureC: copyFloat(period.ApparentTemperatureC),
|
||||
ApparentTemperatureF: copyFloat(period.ApparentTemperatureF),
|
||||
CloudCoverPercent: copyFloat(period.CloudCoverPercent),
|
||||
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
|
||||
MentionPrecipitation: mentionHourlyForecastPrecipitation(period.ProbabilityOfPrecipitationPercent, threshold),
|
||||
PrecipitationAmountMm: copyFloat(period.PrecipitationAmountMm),
|
||||
PrecipitationAmountIn: copyFloat(period.PrecipitationAmountIn),
|
||||
SnowfallDepthMM: copyFloat(period.SnowfallDepthMM),
|
||||
SnowfallDepthIn: copyFloat(period.SnowfallDepthIn),
|
||||
UVIndex: copyFloat(period.UVIndex),
|
||||
RelativeHumidityPercent: copyFloat(period.RelativeHumidityPercent),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mentionHourlyForecastPrecipitation(probability *float64, threshold float64) bool {
|
||||
return probability != nil && *probability >= threshold
|
||||
}
|
||||
|
||||
func (v HourlyForecastModule) isEmpty() bool {
|
||||
return v.Product == "" &&
|
||||
v.IssuedAt.IsZero() &&
|
||||
v.UpdatedAt == nil &&
|
||||
v.SourceLocation == "" &&
|
||||
v.SourceLocationID == "" &&
|
||||
len(v.Periods) == 0
|
||||
}
|
||||
62
internal/briefing/metadata_module.go
Normal file
62
internal/briefing/metadata_module.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type MetadataModule struct {
|
||||
RunID string `json:"run_id"`
|
||||
ReportID report.ID `json:"report_id"`
|
||||
Variant string `json:"variant,omitempty"`
|
||||
PromptID string `json:"prompt_id"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
Units string `json:"units"`
|
||||
Timezone string `json:"timezone"`
|
||||
ValidPeriod timeutil.Period `json:"valid_period"`
|
||||
Location *LocationContext `json:"location,omitempty"`
|
||||
SourceWarnings []SourceWarningSummary `json:"source_warnings,omitempty"`
|
||||
}
|
||||
|
||||
type SourceWarningSummary struct {
|
||||
Source string `json:"source"`
|
||||
Code string `json:"code"`
|
||||
Severity string `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
CompletenessImpact string `json:"completeness_impact,omitempty"`
|
||||
}
|
||||
|
||||
func buildMetadataModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
metadata := ctx.Resolved.Metadata()
|
||||
value := MetadataModule{
|
||||
RunID: metadata.RunID,
|
||||
ReportID: metadata.ReportID,
|
||||
Variant: variantForReport(metadata.ReportID),
|
||||
PromptID: metadata.PromptID,
|
||||
GeneratedAt: metadata.GeneratedAt,
|
||||
Units: ctx.Units,
|
||||
Timezone: ctx.Timezone,
|
||||
ValidPeriod: metadata.ValidPeriod,
|
||||
Location: copyLocation(ctx.Location),
|
||||
SourceWarnings: sourceWarningSummaries(ctx.Collected.SourceWarnings),
|
||||
}
|
||||
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil
|
||||
}
|
||||
|
||||
func sourceWarningSummaries(warnings []weatherdata.SourceWarning) []SourceWarningSummary {
|
||||
out := make([]SourceWarningSummary, 0, len(warnings))
|
||||
for _, warning := range warnings {
|
||||
out = append(out, SourceWarningSummary{
|
||||
Source: warning.Source,
|
||||
Code: warning.Code,
|
||||
Severity: warning.Severity,
|
||||
Message: warning.Message,
|
||||
CompletenessImpact: warning.CompletenessImpact,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
177
internal/briefing/module_format_helpers.go
Normal file
177
internal/briefing/module_format_helpers.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
func rangeLabel(value forecast.Range) string {
|
||||
if value.Min == nil && value.Max == nil {
|
||||
return ""
|
||||
}
|
||||
if value.Min != nil && value.Max != nil {
|
||||
low := roundedInt(value.Min)
|
||||
high := roundedInt(value.Max)
|
||||
if low != nil && high != nil && *low == *high {
|
||||
return fmt.Sprintf("%d", *low)
|
||||
}
|
||||
return fmt.Sprintf("%d-%d", *low, *high)
|
||||
}
|
||||
if value.Min != nil {
|
||||
low := roundedInt(value.Min)
|
||||
return fmt.Sprintf("%d", *low)
|
||||
}
|
||||
high := roundedInt(value.Max)
|
||||
return fmt.Sprintf("%d", *high)
|
||||
}
|
||||
|
||||
func daypartApparentRangeLabel(value forecast.Range) string {
|
||||
if value.Min == nil && value.Max == nil {
|
||||
return ""
|
||||
}
|
||||
return rangeLabel(value)
|
||||
}
|
||||
|
||||
func roundedInt(value *float64) *int {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
rounded := int(*value + 0.5)
|
||||
if *value < 0 {
|
||||
rounded = int(*value - 0.5)
|
||||
}
|
||||
return &rounded
|
||||
}
|
||||
|
||||
func windDirectionLabel(degrees *float64) string {
|
||||
if degrees == nil {
|
||||
return ""
|
||||
}
|
||||
labels := []string{"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"}
|
||||
normalized := math.Mod(*degrees, 360)
|
||||
if normalized < 0 {
|
||||
normalized += 360
|
||||
}
|
||||
sector := int(math.Floor((normalized+11.25)/22.5)) % len(labels)
|
||||
return labels[sector]
|
||||
}
|
||||
|
||||
func windDirectionTextLabel(degrees *float64) string {
|
||||
if degrees == nil {
|
||||
return ""
|
||||
}
|
||||
labels := []string{
|
||||
"north",
|
||||
"north-northeast",
|
||||
"northeast",
|
||||
"east-northeast",
|
||||
"east",
|
||||
"east-southeast",
|
||||
"southeast",
|
||||
"south-southeast",
|
||||
"south",
|
||||
"south-southwest",
|
||||
"southwest",
|
||||
"west-southwest",
|
||||
"west",
|
||||
"west-northwest",
|
||||
"northwest",
|
||||
"north-northwest",
|
||||
}
|
||||
normalized := math.Mod(*degrees, 360)
|
||||
if normalized < 0 {
|
||||
normalized += 360
|
||||
}
|
||||
sector := int(math.Floor((normalized+11.25)/22.5)) % len(labels)
|
||||
return labels[sector]
|
||||
}
|
||||
|
||||
func timedClockLabel(value *forecast.TimedValue, timezone string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return clockLabel(value.Time, timezone)
|
||||
}
|
||||
|
||||
func friendlyPeriodBeginsLabel(period timeutil.Period, timezone string) string {
|
||||
if !period.IsValid() {
|
||||
return ""
|
||||
}
|
||||
return friendlyDateTimeLabel(period.Start, timezone)
|
||||
}
|
||||
|
||||
func friendlyPeriodEndsLabel(period timeutil.Period, timezone string) string {
|
||||
if !period.IsValid() {
|
||||
return ""
|
||||
}
|
||||
return friendlyDateTimeLabel(period.End, timezone)
|
||||
}
|
||||
|
||||
func friendlyDateTimeLabel(value time.Time, timezone string) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
location, err := timeutil.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
location = time.UTC
|
||||
}
|
||||
return value.In(location).Format("2006-01-02 at 3:04 PM")
|
||||
}
|
||||
|
||||
func friendlyMonthDayTimeLabel(value time.Time, timezone string) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
location, err := timeutil.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
location = time.UTC
|
||||
}
|
||||
return value.In(location).Format("January 2 at 3:04 PM")
|
||||
}
|
||||
|
||||
func friendlyDateLabel(date string, timezone string) string {
|
||||
location, err := timeutil.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
location = time.UTC
|
||||
}
|
||||
parsed, err := time.ParseInLocation(timeutil.DateLayout, date, location)
|
||||
if err != nil {
|
||||
return date
|
||||
}
|
||||
return parsed.Format("Monday, January 2, 2006")
|
||||
}
|
||||
|
||||
func localDateLabel(value time.Time, timezone string) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
location, err := timeutil.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
location = time.UTC
|
||||
}
|
||||
return value.In(location).Format(timeutil.DateLayout)
|
||||
}
|
||||
|
||||
func clockLabel(value time.Time, timezone string) string {
|
||||
location, err := timeutil.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
location = time.UTC
|
||||
}
|
||||
label := value.In(location).Format("3 PM")
|
||||
if label == "12 AM" && value.In(location).Minute() == 0 {
|
||||
return "12 AM"
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
func hourMinuteLabel(value time.Time, timezone string) string {
|
||||
location, err := timeutil.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
location = time.UTC
|
||||
}
|
||||
return value.In(location).Format("3:04 PM")
|
||||
}
|
||||
51
internal/briefing/module_format_helpers_test.go
Normal file
51
internal/briefing/module_format_helpers_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package briefing
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestWindDirectionLabelUsesSixteenPointCompass(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
degrees *float64
|
||||
want string
|
||||
}{
|
||||
{name: "nil", degrees: nil, want: ""},
|
||||
{name: "north", degrees: floatPtr(0), want: "N"},
|
||||
{name: "below first boundary", degrees: floatPtr(11.24), want: "N"},
|
||||
{name: "at first boundary", degrees: floatPtr(11.25), want: "NNE"},
|
||||
{name: "northeast", degrees: floatPtr(45), want: "NE"},
|
||||
{name: "south", degrees: floatPtr(180), want: "S"},
|
||||
{name: "wrap to north", degrees: floatPtr(348.75), want: "N"},
|
||||
{name: "full rotation", degrees: floatPtr(360), want: "N"},
|
||||
{name: "negative normalizes", degrees: floatPtr(-45), want: "NW"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := windDirectionLabel(tt.degrees); got != tt.want {
|
||||
t.Fatalf("windDirectionLabel(%v) = %q, want %q", tt.degrees, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindDirectionTextLabelUsesLowercaseCompassText(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
degrees *float64
|
||||
want string
|
||||
}{
|
||||
{name: "nil", degrees: nil, want: ""},
|
||||
{name: "north", degrees: floatPtr(0), want: "north"},
|
||||
{name: "north northeast", degrees: floatPtr(11.25), want: "north-northeast"},
|
||||
{name: "northwest", degrees: floatPtr(315), want: "northwest"},
|
||||
{name: "negative normalizes", degrees: floatPtr(-45), want: "northwest"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := windDirectionTextLabel(tt.degrees); got != tt.want {
|
||||
t.Fatalf("windDirectionTextLabel(%v) = %q, want %q", tt.degrees, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
424
internal/briefing/modules.go
Normal file
424
internal/briefing/modules.go
Normal file
@@ -0,0 +1,424 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
|
||||
type ModuleContext struct {
|
||||
Resolved report.Resolved
|
||||
Collected facts.CollectedFacts
|
||||
Derived facts.DerivedFacts
|
||||
Units string
|
||||
Timezone string
|
||||
Location *LocationContext
|
||||
}
|
||||
|
||||
type ModuleBuilder func(ModuleContext, any) (*module.Output, error)
|
||||
|
||||
type ModulePromptExporter func(value any) (any, error)
|
||||
|
||||
type ModuleDefinition struct {
|
||||
ID module.ID
|
||||
StanzaName string
|
||||
DefaultOptions any
|
||||
RequiredCollected []module.FactRequirement
|
||||
RequiredDerived []module.FactRequirement
|
||||
SupportedReports []report.ID
|
||||
MissingData module.MissingDataBehavior
|
||||
AllowDuplicate bool
|
||||
Builder ModuleBuilder
|
||||
PromptExporter ModulePromptExporter
|
||||
}
|
||||
|
||||
type ModuleRegistry struct {
|
||||
definitions map[module.ID]ModuleDefinition
|
||||
}
|
||||
|
||||
func unexpectedPromptExportValue(got any, want any) error {
|
||||
return fmt.Errorf("value has type %T, want %T", got, want)
|
||||
}
|
||||
|
||||
func DefaultModuleRegistry() (ModuleRegistry, error) {
|
||||
return NewModuleRegistry(defaultModuleDefinitions())
|
||||
}
|
||||
|
||||
func MustDefaultModuleRegistry() ModuleRegistry {
|
||||
registry, err := DefaultModuleRegistry()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return registry
|
||||
}
|
||||
|
||||
func NewModuleRegistry(definitions []ModuleDefinition) (ModuleRegistry, error) {
|
||||
registry := ModuleRegistry{definitions: map[module.ID]ModuleDefinition{}}
|
||||
seenStanzas := map[string]module.ID{}
|
||||
for i, definition := range definitions {
|
||||
if definition.ID == "" {
|
||||
return ModuleRegistry{}, fmt.Errorf("module definition[%d].id is required", i)
|
||||
}
|
||||
if definition.StanzaName == "" {
|
||||
return ModuleRegistry{}, fmt.Errorf("module %q stanza name is required", definition.ID)
|
||||
}
|
||||
if _, ok := registry.definitions[definition.ID]; ok {
|
||||
return ModuleRegistry{}, fmt.Errorf("duplicate module definition %q", definition.ID)
|
||||
}
|
||||
if definition.Builder == nil {
|
||||
return ModuleRegistry{}, fmt.Errorf("module %q has no builder", definition.ID)
|
||||
}
|
||||
if definition.MissingData == module.MissingDataWarn {
|
||||
return ModuleRegistry{}, fmt.Errorf("module %q uses unsupported missing data behavior %q", definition.ID, definition.MissingData)
|
||||
}
|
||||
if existingID, ok := seenStanzas[definition.StanzaName]; ok {
|
||||
return ModuleRegistry{}, fmt.Errorf("duplicate stanza name %q for modules %q and %q", definition.StanzaName, existingID, definition.ID)
|
||||
}
|
||||
seenStanzas[definition.StanzaName] = definition.ID
|
||||
registry.definitions[definition.ID] = definition
|
||||
}
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
func (r ModuleRegistry) Lookup(id module.ID) (ModuleDefinition, error) {
|
||||
definition, ok := r.definitions[id]
|
||||
if !ok {
|
||||
return ModuleDefinition{}, fmt.Errorf("unknown module %q", id)
|
||||
}
|
||||
return definition, nil
|
||||
}
|
||||
|
||||
func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (*module.Output, error) {
|
||||
definition, err := r.Lookup(item.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !definition.SupportsReport(ctx.Resolved.Definition.ID) {
|
||||
return nil, fmt.Errorf("module %q is not compatible with report %q", item.ID, ctx.Resolved.Definition.ID)
|
||||
}
|
||||
if err := definition.ValidateOptions(item.Options); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if definition.Builder == nil {
|
||||
return nil, fmt.Errorf("module %q has no builder", item.ID)
|
||||
}
|
||||
missing := missingRequirements(definition, ctx)
|
||||
if len(missing) > 0 {
|
||||
switch definition.MissingData {
|
||||
case module.MissingDataOmit:
|
||||
return nil, nil
|
||||
case module.MissingDataError:
|
||||
return nil, fmt.Errorf("module %q missing required facts: %s", item.ID, strings.Join(missing, ", "))
|
||||
case module.MissingDataEmpty:
|
||||
case module.MissingDataWarn:
|
||||
return nil, fmt.Errorf("module %q uses unsupported missing data behavior %q", item.ID, definition.MissingData)
|
||||
default:
|
||||
return nil, fmt.Errorf("module %q has unknown missing data behavior %q", item.ID, definition.MissingData)
|
||||
}
|
||||
}
|
||||
options := item.Options
|
||||
if options == nil {
|
||||
options = definition.DefaultOptions
|
||||
}
|
||||
output, err := definition.Builder(ctx, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if output == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if output.ID != definition.ID {
|
||||
return nil, fmt.Errorf("module %q produced output id %q", definition.ID, output.ID)
|
||||
}
|
||||
if output.StanzaName != definition.StanzaName {
|
||||
return nil, fmt.Errorf("module %q produced stanza %q, want %q", definition.ID, output.StanzaName, definition.StanzaName)
|
||||
}
|
||||
if definition.PromptExporter == nil {
|
||||
output.PromptValue = output.Value
|
||||
return output, nil
|
||||
}
|
||||
promptValue, err := definition.PromptExporter(output.Value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("module %q stanza %q prompt export: %w", definition.ID, definition.StanzaName, err)
|
||||
}
|
||||
output.PromptValue = promptValue
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func missingRequirements(definition ModuleDefinition, ctx ModuleContext) []string {
|
||||
var missing []string
|
||||
for _, requirement := range definition.RequiredCollected {
|
||||
if !collectedFactAvailable(requirement, ctx) {
|
||||
missing = append(missing, string(requirement))
|
||||
}
|
||||
}
|
||||
for _, requirement := range definition.RequiredDerived {
|
||||
if !derivedFactAvailable(requirement, ctx) {
|
||||
missing = append(missing, string(requirement))
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContext) bool {
|
||||
switch requirement {
|
||||
case module.CollectedCurrentConditions:
|
||||
return ctx.Collected.Current != nil
|
||||
case module.CollectedNarrativeForecast:
|
||||
return ctx.Collected.Narrative != nil
|
||||
case module.CollectedHourlyForecast:
|
||||
return ctx.Collected.Hourly != nil
|
||||
case module.CollectedAlerts:
|
||||
return ctx.Collected.Alerts != nil
|
||||
case module.CollectedDiscussion:
|
||||
return ctx.Collected.Discussion != nil
|
||||
case module.CollectedWeatherStory:
|
||||
return ctx.Collected.WeatherStory != nil
|
||||
case module.CollectedSPCConvectiveOutlooks:
|
||||
return ctx.Collected.SPCConvectiveOutlooks != nil
|
||||
case module.CollectedSourceMetadata:
|
||||
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func derivedFactAvailable(requirement module.FactRequirement, ctx ModuleContext) bool {
|
||||
switch requirement {
|
||||
case module.RequiresDerivedHourlyPeriods:
|
||||
return len(ctx.Derived.ValidPeriodHourlyPeriods) > 0
|
||||
case module.RequiresDerivedNarrativePeriods:
|
||||
return len(ctx.Derived.ValidPeriodNarrativePeriods) > 0
|
||||
case module.RequiresDerivedAlertOverlaps:
|
||||
return true
|
||||
case module.RequiresDerivedDailySummaries:
|
||||
return len(ctx.Derived.DailySummaries) > 0
|
||||
case module.RequiresDerivedDaypartSummaries:
|
||||
return len(ctx.Derived.DaypartSummaries) > 0
|
||||
case module.RequiresDerivedPrecipTiming:
|
||||
return true
|
||||
case module.RequiresDerivedSPCConvectiveOutlooks:
|
||||
return ctx.Derived.SPCConvectiveOutlooks != nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (r ModuleRegistry) ValidateComposition(reportID report.ID, items []module.ConfigItem) error {
|
||||
seenModules := map[module.ID]struct{}{}
|
||||
seenStanzas := map[string]module.ID{}
|
||||
for i, item := range items {
|
||||
definition, err := r.Lookup(item.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("modules[%d]: %w", i, err)
|
||||
}
|
||||
if _, ok := seenModules[item.ID]; ok && !definition.AllowDuplicate {
|
||||
return fmt.Errorf("modules[%d]: duplicate module %q", i, item.ID)
|
||||
}
|
||||
seenModules[item.ID] = struct{}{}
|
||||
if existingID, ok := seenStanzas[definition.StanzaName]; ok {
|
||||
return fmt.Errorf("modules[%d]: duplicate stanza name %q for modules %q and %q", i, definition.StanzaName, existingID, item.ID)
|
||||
}
|
||||
seenStanzas[definition.StanzaName] = item.ID
|
||||
if !definition.SupportsReport(reportID) {
|
||||
return fmt.Errorf("modules[%d]: module %q is not compatible with report %q", i, item.ID, reportID)
|
||||
}
|
||||
if err := definition.ValidateOptions(item.Options); err != nil {
|
||||
return fmt.Errorf("modules[%d]: %w", i, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d ModuleDefinition) SupportsReport(id report.ID) bool {
|
||||
if len(d.SupportedReports) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, supported := range d.SupportedReports {
|
||||
if supported == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (d ModuleDefinition) ValidateOptions(options any) error {
|
||||
if options == nil {
|
||||
return nil
|
||||
}
|
||||
if d.DefaultOptions == nil {
|
||||
return fmt.Errorf("module %q does not accept options", d.ID)
|
||||
}
|
||||
want := reflect.TypeOf(d.DefaultOptions)
|
||||
got := reflect.TypeOf(options)
|
||||
if got == want {
|
||||
return nil
|
||||
}
|
||||
if got.Kind() == reflect.Pointer && got.Elem() == want {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("module %q options have type %s, want %s", d.ID, got, want)
|
||||
}
|
||||
|
||||
func defaultModuleDefinitions() []ModuleDefinition {
|
||||
allReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm}
|
||||
daypartReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.ThreeDay, report.Weekend}
|
||||
return []ModuleDefinition{
|
||||
{
|
||||
ID: module.Metadata,
|
||||
StanzaName: "metadata",
|
||||
DefaultOptions: module.MetadataOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedSourceMetadata},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildMetadataModule,
|
||||
},
|
||||
{
|
||||
ID: module.CurrentConditions,
|
||||
StanzaName: "current_conditions",
|
||||
DefaultOptions: module.CurrentConditionsOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedCurrentConditions},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildCurrentConditionsModule,
|
||||
PromptExporter: exportCurrentConditionsPromptValue,
|
||||
},
|
||||
{
|
||||
ID: module.NarrativeForecast,
|
||||
StanzaName: "narrative_forecast",
|
||||
DefaultOptions: module.NarrativeForecastOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods},
|
||||
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildNarrativeForecastModule,
|
||||
},
|
||||
{
|
||||
ID: module.HourlyForecast,
|
||||
StanzaName: "hourly_forecast",
|
||||
DefaultOptions: module.HourlyForecastOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
|
||||
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly},
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildHourlyForecastModule,
|
||||
PromptExporter: exportHourlyForecastPromptValue,
|
||||
},
|
||||
{
|
||||
ID: module.DerivedDailySummary,
|
||||
StanzaName: "derived_daily_summary",
|
||||
DefaultOptions: module.DerivedDailySummaryOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming},
|
||||
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
|
||||
MissingData: module.MissingDataError,
|
||||
Builder: buildDerivedDailySummaryModule,
|
||||
},
|
||||
{
|
||||
ID: module.DerivedDaypartSummaries,
|
||||
StanzaName: "derived_daypart_summaries",
|
||||
DefaultOptions: module.DerivedDaypartSummariesOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
|
||||
SupportedReports: daypartReports,
|
||||
MissingData: module.MissingDataError,
|
||||
Builder: buildDerivedDaypartSummariesModule,
|
||||
PromptExporter: exportDerivedDaypartSummariesPromptValue,
|
||||
},
|
||||
{
|
||||
ID: module.PrecipTiming,
|
||||
StanzaName: "precip_timing",
|
||||
DefaultOptions: module.PrecipTimingOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedPrecipTiming},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildPrecipTimingModule,
|
||||
},
|
||||
{
|
||||
ID: module.AlertDigest,
|
||||
StanzaName: "alert_digest",
|
||||
DefaultOptions: module.AlertDigestOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedAlerts},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedAlertOverlaps},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildAlertDigestModule,
|
||||
},
|
||||
{
|
||||
ID: module.SPCConvectiveOutlooks,
|
||||
StanzaName: string(module.SPCConvectiveOutlooks),
|
||||
DefaultOptions: module.SPCConvectiveOutlooksOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildSPCConvectiveOutlooksModule,
|
||||
},
|
||||
{
|
||||
ID: module.AreaForecastDiscussion,
|
||||
StanzaName: "area_forecast_discussion",
|
||||
DefaultOptions: module.AreaForecastDiscussionOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedDiscussion},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildAreaForecastDiscussionModule,
|
||||
},
|
||||
{
|
||||
ID: module.SPCConvectiveDiscussion,
|
||||
StanzaName: string(module.SPCConvectiveDiscussion),
|
||||
DefaultOptions: module.SPCConvectiveDiscussionOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildSPCConvectiveDiscussionModule,
|
||||
},
|
||||
{
|
||||
ID: module.WeatherStory,
|
||||
StanzaName: "weather_story",
|
||||
DefaultOptions: module.WeatherStoryOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedWeatherStory},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildWeatherStoryModule,
|
||||
},
|
||||
{
|
||||
ID: module.OutdoorWindows,
|
||||
StanzaName: "outdoor_windows",
|
||||
DefaultOptions: module.OutdoorWindowsOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
|
||||
SupportedReports: daypartReports,
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildOutdoorWindowsModule,
|
||||
},
|
||||
{
|
||||
ID: module.TodayPlanning,
|
||||
StanzaName: "today_planning",
|
||||
DefaultOptions: module.TodayPlanningOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
||||
SupportedReports: []report.ID{report.Today},
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildTodayPlanningModule,
|
||||
},
|
||||
{
|
||||
ID: module.TomorrowPlanning,
|
||||
StanzaName: "tomorrow_planning",
|
||||
DefaultOptions: module.TomorrowPlanningOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
||||
SupportedReports: []report.ID{report.Tomorrow},
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildTomorrowPlanningModule,
|
||||
},
|
||||
{
|
||||
ID: module.DailyPlanning,
|
||||
StanzaName: "daily_planning",
|
||||
DefaultOptions: module.DailyPlanningOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
||||
SupportedReports: []report.ID{report.Daily},
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildDailyPlanningModule,
|
||||
},
|
||||
}
|
||||
}
|
||||
505
internal/briefing/modules_test.go
Normal file
505
internal/briefing/modules_test.go
Normal file
@@ -0,0 +1,505 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
for _, definition := range report.DefaultRegistry().All() {
|
||||
if err := registry.ValidateComposition(definition.ID, definition.Modules); err != nil {
|
||||
t.Fatalf("ValidateComposition(%s) error = %v", definition.ID, err)
|
||||
}
|
||||
for _, item := range definition.Modules {
|
||||
moduleDefinition, err := registry.Lookup(item.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup(%s) error = %v", item.ID, err)
|
||||
}
|
||||
if moduleDefinition.Builder == nil {
|
||||
t.Fatalf("report %s module %s has no builder", definition.ID, item.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
for _, definition := range report.DefaultRegistry().All() {
|
||||
t.Run(string(definition.ID), func(t *testing.T) {
|
||||
ctx := derivedModuleContext(definition.ID)
|
||||
var outputs []module.Output
|
||||
for _, item := range definition.Modules {
|
||||
output, err := registry.BuildModule(ctx, item)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(%s) error = %v", item.ID, err)
|
||||
}
|
||||
if output != nil {
|
||||
if output.DataPackageValue() == nil {
|
||||
t.Fatalf("BuildModule(%s) data package value = nil", item.ID)
|
||||
}
|
||||
outputs = append(outputs, *output)
|
||||
}
|
||||
}
|
||||
snapshot, err := module.NewSnapshot(outputs)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSnapshot() error = %v", err)
|
||||
}
|
||||
if len(snapshot.Outputs) == 0 {
|
||||
t.Fatal("snapshot outputs = 0, want default report modules")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultModuleDefinitionsDeclarePromptExportPolicy(t *testing.T) {
|
||||
customExporters := map[module.ID]struct{}{
|
||||
module.CurrentConditions: {},
|
||||
module.HourlyForecast: {},
|
||||
module.DerivedDaypartSummaries: {},
|
||||
}
|
||||
passThroughExporters := map[module.ID]struct{}{
|
||||
module.Metadata: {},
|
||||
module.NarrativeForecast: {},
|
||||
module.DerivedDailySummary: {},
|
||||
module.PrecipTiming: {},
|
||||
module.AlertDigest: {},
|
||||
module.SPCConvectiveOutlooks: {},
|
||||
module.AreaForecastDiscussion: {},
|
||||
module.SPCConvectiveDiscussion: {},
|
||||
module.WeatherStory: {},
|
||||
module.OutdoorWindows: {},
|
||||
module.TodayPlanning: {},
|
||||
module.TomorrowPlanning: {},
|
||||
module.DailyPlanning: {},
|
||||
}
|
||||
|
||||
for _, definition := range defaultModuleDefinitions() {
|
||||
_, custom := customExporters[definition.ID]
|
||||
_, passThrough := passThroughExporters[definition.ID]
|
||||
if custom == passThrough {
|
||||
t.Fatalf("module %q exporter policy custom=%v passThrough=%v, want exactly one policy", definition.ID, custom, passThrough)
|
||||
}
|
||||
if custom && definition.PromptExporter == nil {
|
||||
t.Fatalf("module %q PromptExporter = nil, want custom prompt exporter", definition.ID)
|
||||
}
|
||||
if passThrough && definition.PromptExporter != nil {
|
||||
t.Fatalf("module %q PromptExporter is set, want default pass-through", definition.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryAddsPassThroughPromptValue(t *testing.T) {
|
||||
registry, err := NewModuleRegistry([]ModuleDefinition{
|
||||
{
|
||||
ID: module.Metadata,
|
||||
StanzaName: "metadata",
|
||||
Builder: func(ModuleContext, any) (*module.Output, error) {
|
||||
return &module.Output{
|
||||
ID: module.Metadata,
|
||||
StanzaName: "metadata",
|
||||
Value: testRegistryValue{Message: "rich"},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewModuleRegistry() error = %v", err)
|
||||
}
|
||||
|
||||
output, err := registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
if output == nil {
|
||||
t.Fatal("BuildModule() output = nil, want output")
|
||||
}
|
||||
if output.PromptValue != output.Value {
|
||||
t.Fatalf("PromptValue = %#v, want pass-through rich value %#v", output.PromptValue, output.Value)
|
||||
}
|
||||
if output.DataPackageValue() != output.Value {
|
||||
t.Fatalf("DataPackageValue() = %#v, want rich value", output.DataPackageValue())
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryAddsCustomPromptValue(t *testing.T) {
|
||||
registry, err := NewModuleRegistry([]ModuleDefinition{
|
||||
{
|
||||
ID: module.Metadata,
|
||||
StanzaName: "metadata",
|
||||
Builder: func(ModuleContext, any) (*module.Output, error) {
|
||||
return &module.Output{
|
||||
ID: module.Metadata,
|
||||
StanzaName: "metadata",
|
||||
Value: testRegistryValue{Message: "rich"},
|
||||
}, nil
|
||||
},
|
||||
PromptExporter: func(value any) (any, error) {
|
||||
rich, ok := value.(testRegistryValue)
|
||||
if !ok {
|
||||
return nil, errors.New("unexpected rich value type")
|
||||
}
|
||||
return testRegistryValue{Message: rich.Message + " prompt"}, nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewModuleRegistry() error = %v", err)
|
||||
}
|
||||
|
||||
output, err := registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
got, ok := output.PromptValue.(testRegistryValue)
|
||||
if !ok {
|
||||
t.Fatalf("PromptValue type = %T, want testRegistryValue", output.PromptValue)
|
||||
}
|
||||
if got.Message != "rich prompt" {
|
||||
t.Fatalf("PromptValue = %#v, want custom prompt value", got)
|
||||
}
|
||||
if output.DataPackageValue() != output.PromptValue {
|
||||
t.Fatalf("DataPackageValue() = %#v, want custom prompt value", output.DataPackageValue())
|
||||
}
|
||||
if output.Value.(testRegistryValue).Message != "rich" {
|
||||
t.Fatalf("Value = %#v, want rich value unchanged", output.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryWrapsPromptExporterErrors(t *testing.T) {
|
||||
registry, err := NewModuleRegistry([]ModuleDefinition{
|
||||
{
|
||||
ID: module.Metadata,
|
||||
StanzaName: "metadata",
|
||||
Builder: func(ModuleContext, any) (*module.Output, error) {
|
||||
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: testRegistryValue{Message: "rich"}}, nil
|
||||
},
|
||||
PromptExporter: func(any) (any, error) {
|
||||
return nil, errors.New("unsupported value")
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewModuleRegistry() error = %v", err)
|
||||
}
|
||||
|
||||
_, err = registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
||||
if err == nil ||
|
||||
!strings.Contains(err.Error(), `module "metadata" stanza "metadata" prompt export`) ||
|
||||
!strings.Contains(err.Error(), "unsupported value") {
|
||||
t.Fatalf("BuildModule() error = %v, want wrapped exporter error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryValidatesOutputBeforePromptExport(t *testing.T) {
|
||||
called := false
|
||||
registry, err := NewModuleRegistry([]ModuleDefinition{
|
||||
{
|
||||
ID: module.Metadata,
|
||||
StanzaName: "metadata",
|
||||
Builder: func(ModuleContext, any) (*module.Output, error) {
|
||||
return &module.Output{ID: module.CurrentConditions, StanzaName: "metadata", Value: testRegistryValue{Message: "rich"}}, nil
|
||||
},
|
||||
PromptExporter: func(any) (any, error) {
|
||||
called = true
|
||||
return testRegistryValue{Message: "prompt"}, nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewModuleRegistry() error = %v", err)
|
||||
}
|
||||
|
||||
_, err = registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
||||
if err == nil || !strings.Contains(err.Error(), `module "metadata" produced output id "current_conditions"`) {
|
||||
t.Fatalf("BuildModule() error = %v, want output id validation error", err)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("PromptExporter called before output validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryPromptValueIsNotPersistedInSnapshotJSON(t *testing.T) {
|
||||
registry, err := NewModuleRegistry([]ModuleDefinition{
|
||||
{
|
||||
ID: module.Metadata,
|
||||
StanzaName: "metadata",
|
||||
Builder: func(ModuleContext, any) (*module.Output, error) {
|
||||
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: testRegistryValue{Message: "rich"}}, nil
|
||||
},
|
||||
PromptExporter: func(any) (any, error) {
|
||||
return testRegistryValue{Message: "prompt-only"}, nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewModuleRegistry() error = %v", err)
|
||||
}
|
||||
output, err := registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
snapshot, err := module.NewSnapshot([]module.Output{*output})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSnapshot() error = %v", err)
|
||||
}
|
||||
data, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
text := string(data)
|
||||
if !strings.Contains(text, `"message":"rich"`) {
|
||||
t.Fatalf("snapshot JSON missing rich value: %s", text)
|
||||
}
|
||||
if strings.Contains(text, "prompt-only") || strings.Contains(text, "promptValue") || strings.Contains(text, "PromptValue") {
|
||||
t.Fatalf("snapshot JSON includes runtime-only prompt value: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultAreaForecastDiscussionModuleOptions(t *testing.T) {
|
||||
tests := []struct {
|
||||
id report.ID
|
||||
wantSections string
|
||||
}{
|
||||
{id: report.Daily, wantSections: "long_term"},
|
||||
{id: report.Hourly, wantSections: "key_messages,short_term"},
|
||||
}
|
||||
|
||||
registry := report.DefaultRegistry()
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.id), func(t *testing.T) {
|
||||
definition := registry.MustLookup(tt.id)
|
||||
var found bool
|
||||
for _, item := range definition.Modules {
|
||||
if item.ID != module.AreaForecastDiscussion {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
options, ok := item.Options.(module.AreaForecastDiscussionOptions)
|
||||
if !ok {
|
||||
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", item.Options)
|
||||
}
|
||||
if strings.Join(options.Sections, ",") != tt.wantSections {
|
||||
t.Fatalf("AFD sections = %#v, want %s", options.Sections, tt.wantSections)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("default modules missing area_forecast_discussion")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, id := range []report.ID{report.Today, report.Tomorrow} {
|
||||
t.Run(string(id), func(t *testing.T) {
|
||||
definition := registry.MustLookup(id)
|
||||
var found bool
|
||||
for _, item := range definition.Modules {
|
||||
if item.ID != module.AreaForecastDiscussion {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if item.Options != nil {
|
||||
t.Fatalf("AFD options = %#v, want default all sections", item.Options)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("default modules missing area_forecast_discussion")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.ID("unknown")}})
|
||||
if err == nil || !strings.Contains(err.Error(), `unknown module "unknown"`) {
|
||||
t.Fatalf("error = %v, want unknown module", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryRejectsDuplicateModuleIDs(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
|
||||
{ID: module.Metadata},
|
||||
{ID: module.Metadata},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `duplicate module "metadata"`) {
|
||||
t.Fatalf("error = %v, want duplicate module", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryRejectsDuplicateStanzaNames(t *testing.T) {
|
||||
_, err := NewModuleRegistry([]ModuleDefinition{
|
||||
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}, Builder: noopModuleBuilder},
|
||||
{ID: module.CurrentConditions, StanzaName: "metadata", DefaultOptions: module.CurrentConditionsOptions{}, Builder: noopModuleBuilder},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `duplicate stanza name "metadata"`) {
|
||||
t.Fatalf("error = %v, want duplicate stanza name", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.TomorrowPlanning}})
|
||||
if err == nil || !strings.Contains(err.Error(), `module "tomorrow_planning" is not compatible with report "daily"`) {
|
||||
t.Fatalf("error = %v, want incompatible report", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryValidatesTodayPlanningSupport(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
if err := registry.ValidateComposition(report.Today, []module.ConfigItem{{ID: module.TodayPlanning}}); err != nil {
|
||||
t.Fatalf("ValidateComposition(today) error = %v", err)
|
||||
}
|
||||
for _, id := range []report.ID{report.Tomorrow, report.Daily} {
|
||||
t.Run(string(id), func(t *testing.T) {
|
||||
err := registry.ValidateComposition(id, []module.ConfigItem{{ID: module.TodayPlanning}})
|
||||
if err == nil || !strings.Contains(err.Error(), `module "today_planning" is not compatible with report`) {
|
||||
t.Fatalf("ValidateComposition(%s) error = %v, want incompatible report", id, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryValidatesDailyPlanningSupport(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
if err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.DailyPlanning}}); err != nil {
|
||||
t.Fatalf("ValidateComposition(daily) error = %v", err)
|
||||
}
|
||||
for _, id := range []report.ID{report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm} {
|
||||
t.Run(string(id), func(t *testing.T) {
|
||||
err := registry.ValidateComposition(id, []module.ConfigItem{{ID: module.DailyPlanning}})
|
||||
if err == nil || !strings.Contains(err.Error(), `module "daily_planning" is not compatible with report`) {
|
||||
t.Fatalf("ValidateComposition(%s) error = %v, want incompatible report", id, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistrySupportsTodayEligibleModules(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
err := registry.ValidateComposition(report.Today, []module.ConfigItem{
|
||||
{ID: module.Metadata},
|
||||
{ID: module.CurrentConditions},
|
||||
{ID: module.NarrativeForecast},
|
||||
{ID: module.HourlyForecast},
|
||||
{ID: module.DerivedDailySummary},
|
||||
{ID: module.DerivedDaypartSummaries},
|
||||
{ID: module.PrecipTiming},
|
||||
{ID: module.AlertDigest},
|
||||
{ID: module.SPCConvectiveOutlooks},
|
||||
{ID: module.AreaForecastDiscussion},
|
||||
{ID: module.SPCConvectiveDiscussion},
|
||||
{ID: module.WeatherStory},
|
||||
{ID: module.OutdoorWindows},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateComposition(today eligible modules) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryRejectsHourlyIncompatibleModules(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
for _, id := range []module.ID{
|
||||
module.NarrativeForecast,
|
||||
module.DerivedDailySummary,
|
||||
module.DerivedDaypartSummaries,
|
||||
module.OutdoorWindows,
|
||||
module.TodayPlanning,
|
||||
module.TomorrowPlanning,
|
||||
module.DailyPlanning,
|
||||
} {
|
||||
t.Run(string(id), func(t *testing.T) {
|
||||
err := registry.ValidateComposition(report.Hourly, []module.ConfigItem{{ID: id}})
|
||||
if err == nil || !strings.Contains(err.Error(), `not compatible with report "hourly"`) {
|
||||
t.Fatalf("ValidateComposition() error = %v, want incompatible hourly module", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryRejectsDefinitionsWithoutBuilders(t *testing.T) {
|
||||
_, err := NewModuleRegistry([]ModuleDefinition{
|
||||
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `module "metadata" has no builder`) {
|
||||
t.Fatalf("error = %v, want missing builder", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryRejectsUnsupportedMissingDataWarn(t *testing.T) {
|
||||
_, err := NewModuleRegistry([]ModuleDefinition{
|
||||
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}, MissingData: module.MissingDataWarn, Builder: noopModuleBuilder},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `unsupported missing data behavior`) {
|
||||
t.Fatalf("error = %v, want unsupported missing-data behavior", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
|
||||
{ID: module.Metadata, Options: module.CurrentConditionsOptions{}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `module "metadata" options have type module.CurrentConditionsOptions, want module.MetadataOptions`) {
|
||||
t.Fatalf("error = %v, want invalid option shape", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
|
||||
{ID: module.Metadata, Options: module.MetadataOptions{}},
|
||||
{ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateComposition() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveOutlookCollectedRequirementAvailability(t *testing.T) {
|
||||
ctx := ModuleContext{}
|
||||
if collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
|
||||
t.Fatal("collectedFactAvailable() = true, want false without source")
|
||||
}
|
||||
|
||||
ctx.Collected = facts.CollectedFacts{SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{}}
|
||||
if !collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
|
||||
t.Fatal("collectedFactAvailable() = false, want true with checked source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveOutlookDerivedRequirementAvailability(t *testing.T) {
|
||||
ctx := ModuleContext{}
|
||||
if derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
|
||||
t.Fatal("derivedFactAvailable() = true, want false without derived outlooks")
|
||||
}
|
||||
|
||||
ctx.Derived = facts.DerivedFacts{SPCConvectiveOutlooks: []weatherdata.ConvectiveOutlook{}}
|
||||
if !derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
|
||||
t.Fatal("derivedFactAvailable() = false, want true for checked empty derived outlooks")
|
||||
}
|
||||
}
|
||||
|
||||
func noopModuleBuilder(ModuleContext, any) (*module.Output, error) {
|
||||
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil
|
||||
}
|
||||
|
||||
type testRegistryValue struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func testRegistryModuleContext() ModuleContext {
|
||||
return ModuleContext{
|
||||
Resolved: report.Resolved{
|
||||
Definition: report.DefaultRegistry().MustLookup(report.Daily),
|
||||
},
|
||||
}
|
||||
}
|
||||
93
internal/briefing/narrative_forecast_module.go
Normal file
93
internal/briefing/narrative_forecast_module.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type NarrativeForecastModule struct {
|
||||
Product string `json:"product,omitempty"`
|
||||
IssuedAt time.Time `json:"issued_at,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
SourceLocation string `json:"source_location,omitempty"`
|
||||
SourceLocationID string `json:"source_location_id,omitempty"`
|
||||
Periods []NarrativeForecastPeriod `json:"periods,omitempty"`
|
||||
}
|
||||
|
||||
type NarrativeForecastPeriod struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
IsDay *bool `json:"is_day,omitempty"`
|
||||
TextDescription string `json:"text_description,omitempty"`
|
||||
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||
TemperatureF *float64 `json:"temperature_f,omitempty"`
|
||||
TemperatureCMin *float64 `json:"temperature_c_min,omitempty"`
|
||||
TemperatureFMin *float64 `json:"temperature_f_min,omitempty"`
|
||||
TemperatureCMax *float64 `json:"temperature_c_max,omitempty"`
|
||||
TemperatureFMax *float64 `json:"temperature_f_max,omitempty"`
|
||||
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
|
||||
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
|
||||
WindGustKmh *float64 `json:"wind_gust_kmh,omitempty"`
|
||||
WindGustMph *float64 `json:"wind_gust_mph,omitempty"`
|
||||
WindDirection string `json:"wind_direction,omitempty"`
|
||||
ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"`
|
||||
}
|
||||
|
||||
func buildNarrativeForecastModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
narrative := ctx.Collected.Narrative
|
||||
if narrative == nil || len(ctx.Derived.ValidPeriodNarrativePeriods) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
value := NarrativeForecastModule{
|
||||
Product: narrative.Product,
|
||||
IssuedAt: narrative.IssuedAt,
|
||||
UpdatedAt: copyTime(narrative.UpdatedAt),
|
||||
SourceLocation: narrative.LocationName,
|
||||
SourceLocationID: narrative.LocationID,
|
||||
Periods: narrativeForecastPeriods(ctx.Derived.ValidPeriodNarrativePeriods, ctx.Timezone),
|
||||
}
|
||||
if value.isEmpty() {
|
||||
return nil, nil
|
||||
}
|
||||
return &module.Output{ID: module.NarrativeForecast, StanzaName: "narrative_forecast", Value: value}, nil
|
||||
}
|
||||
|
||||
func narrativeForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []NarrativeForecastPeriod {
|
||||
out := make([]NarrativeForecastPeriod, 0, len(periods))
|
||||
for _, period := range periods {
|
||||
validPeriod := timeutil.Period{Start: period.StartTime, End: period.EndTime}
|
||||
out = append(out, NarrativeForecastPeriod{
|
||||
Name: period.Name,
|
||||
PeriodBegins: friendlyPeriodBeginsLabel(validPeriod, timezone),
|
||||
PeriodEnds: friendlyPeriodEndsLabel(validPeriod, timezone),
|
||||
IsDay: copyBool(period.IsDay),
|
||||
TextDescription: period.TextDescription,
|
||||
TemperatureC: copyFloat(period.TemperatureC),
|
||||
TemperatureF: copyFloat(period.TemperatureF),
|
||||
TemperatureCMin: copyFloat(period.TemperatureCMin),
|
||||
TemperatureFMin: copyFloat(period.TemperatureFMin),
|
||||
TemperatureCMax: copyFloat(period.TemperatureCMax),
|
||||
TemperatureFMax: copyFloat(period.TemperatureFMax),
|
||||
WindSpeedKmh: copyFloat(period.WindSpeedKmh),
|
||||
WindSpeedMph: copyFloat(period.WindSpeedMph),
|
||||
WindGustKmh: copyFloat(period.WindGustKmh),
|
||||
WindGustMph: copyFloat(period.WindGustMph),
|
||||
WindDirection: windDirectionLabel(period.WindDirectionDegrees),
|
||||
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (v NarrativeForecastModule) isEmpty() bool {
|
||||
return v.Product == "" &&
|
||||
v.IssuedAt.IsZero() &&
|
||||
v.UpdatedAt == nil &&
|
||||
v.SourceLocation == "" &&
|
||||
v.SourceLocationID == "" &&
|
||||
len(v.Periods) == 0
|
||||
}
|
||||
38
internal/briefing/outdoor_windows_module.go
Normal file
38
internal/briefing/outdoor_windows_module.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package briefing
|
||||
|
||||
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
|
||||
type OutdoorWindowsModule struct {
|
||||
Best *OutdoorWindowModule `json:"best,omitempty"`
|
||||
Worst *OutdoorWindowModule `json:"worst,omitempty"`
|
||||
}
|
||||
|
||||
type OutdoorWindowModule struct {
|
||||
Daypart string `json:"daypart"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
Reasons []string `json:"reasons,omitempty"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries)
|
||||
value := OutdoorWindowsModule{
|
||||
Best: outdoorWindowValue(windows.Best, ctx.Timezone),
|
||||
Worst: outdoorWindowValue(windows.Worst, ctx.Timezone),
|
||||
}
|
||||
return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil
|
||||
}
|
||||
|
||||
func outdoorWindowValue(window *OutdoorWindow, timezone string) *OutdoorWindowModule {
|
||||
if window == nil {
|
||||
return nil
|
||||
}
|
||||
return &OutdoorWindowModule{
|
||||
Daypart: window.Daypart,
|
||||
PeriodBegins: friendlyPeriodBeginsLabel(window.Period, timezone),
|
||||
PeriodEnds: friendlyPeriodEndsLabel(window.Period, timezone),
|
||||
Reasons: append([]string(nil), window.Reasons...),
|
||||
Score: window.Score,
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,15 @@
|
||||
// Package briefing builds report-specific structured briefing packages.
|
||||
// Package briefing builds prompt-facing module values.
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
const SchemaVersion = "weatherreporter.briefing.v1"
|
||||
|
||||
type Package struct {
|
||||
Metadata Metadata `json:"metadata"`
|
||||
CurrentConditions *CurrentConditionsContext `json:"currentConditions,omitempty"`
|
||||
Daily *Daily `json:"daily,omitempty"`
|
||||
ThreeDay *ThreeDay `json:"threeDay,omitempty"`
|
||||
Weekend *Weekend `json:"weekend,omitempty"`
|
||||
Storm *Storm `json:"storm,omitempty"`
|
||||
}
|
||||
|
||||
type Metadata struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
RunID string `json:"runId"`
|
||||
ReportID report.ID `json:"reportId"`
|
||||
Variant string `json:"variant,omitempty"`
|
||||
@@ -36,7 +22,7 @@ type Metadata struct {
|
||||
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
||||
SourceLocation string `json:"sourceLocation,omitempty"`
|
||||
Sources []SourceMetadata `json:"sources,omitempty"`
|
||||
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"`
|
||||
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
|
||||
Alerts *AlertStatus `json:"alerts,omitempty"`
|
||||
}
|
||||
|
||||
@@ -47,21 +33,6 @@ type LocationContext struct {
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
}
|
||||
|
||||
type CurrentConditionsContext struct {
|
||||
ConditionText string `json:"conditionText,omitempty"`
|
||||
IsDay *bool `json:"isDay,omitempty"`
|
||||
TemperatureC *float64 `json:"temperatureC,omitempty"`
|
||||
TemperatureF *float64 `json:"temperatureF,omitempty"`
|
||||
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty"`
|
||||
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"`
|
||||
DewpointC *float64 `json:"dewpointC,omitempty"`
|
||||
DewpointF *float64 `json:"dewpointF,omitempty"`
|
||||
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"`
|
||||
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"`
|
||||
WindSpeedMph *float64 `json:"windSpeedMph,omitempty"`
|
||||
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"`
|
||||
}
|
||||
|
||||
type SourceMetadata struct {
|
||||
Name string `json:"name"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
@@ -70,7 +41,7 @@ type SourceMetadata struct {
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
DataSHA256 string `json:"dataSha256,omitempty"`
|
||||
Missing bool `json:"missing,omitempty"`
|
||||
Warnings []forecast.SourceWarning `json:"warnings,omitempty"`
|
||||
Warnings []weatherdata.SourceWarning `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type AlertStatus struct {
|
||||
@@ -82,7 +53,7 @@ type AlertStatus struct {
|
||||
|
||||
type BuildContext struct {
|
||||
Resolved report.Resolved
|
||||
Bundle *forecast.Bundle
|
||||
Bundle *weatherdata.Bundle
|
||||
Units string
|
||||
Timezone string
|
||||
Location *LocationContext
|
||||
@@ -92,7 +63,6 @@ func BuildMetadata(ctx BuildContext) Metadata {
|
||||
metadata := ctx.Resolved.Metadata()
|
||||
sourceLocationID, sourceLocation := sourceLocation(ctx.Bundle)
|
||||
return Metadata{
|
||||
SchemaVersion: SchemaVersion,
|
||||
RunID: metadata.RunID,
|
||||
ReportID: metadata.ReportID,
|
||||
Variant: variantForReport(metadata.ReportID),
|
||||
@@ -110,13 +80,6 @@ func BuildMetadata(ctx BuildContext) Metadata {
|
||||
}
|
||||
}
|
||||
|
||||
func buildPackage(ctx BuildContext) Package {
|
||||
return Package{
|
||||
Metadata: BuildMetadata(ctx),
|
||||
CurrentConditions: currentConditions(ctx.Bundle),
|
||||
}
|
||||
}
|
||||
|
||||
func copyLocation(location *LocationContext) *LocationContext {
|
||||
if location == nil {
|
||||
return nil
|
||||
@@ -125,43 +88,15 @@ func copyLocation(location *LocationContext) *LocationContext {
|
||||
return &copied
|
||||
}
|
||||
|
||||
func currentConditions(bundle *forecast.Bundle) *CurrentConditionsContext {
|
||||
if bundle == nil || bundle.Current == nil {
|
||||
func copyBool(value *bool) *bool {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
current := bundle.Current
|
||||
context := CurrentConditionsContext{
|
||||
ConditionText: current.ConditionText,
|
||||
IsDay: copyBool(current.IsDay),
|
||||
TemperatureC: copyFloat(current.TemperatureC),
|
||||
TemperatureF: copyFloat(current.TemperatureF),
|
||||
ApparentTemperatureC: copyFloat(current.ApparentTemperatureC),
|
||||
ApparentTemperatureF: copyFloat(current.ApparentTemperatureF),
|
||||
DewpointC: copyFloat(current.DewpointC),
|
||||
DewpointF: copyFloat(current.DewpointF),
|
||||
RelativeHumidityPercent: copyFloat(current.RelativeHumidityPercent),
|
||||
WindSpeedKmh: copyFloat(current.WindSpeedKmh),
|
||||
WindSpeedMph: copyFloat(current.WindSpeedMph),
|
||||
WindDirectionDegrees: copyFloat(current.WindDirectionDegrees),
|
||||
}
|
||||
if context.ConditionText == "" &&
|
||||
context.IsDay == nil &&
|
||||
context.TemperatureC == nil &&
|
||||
context.TemperatureF == nil &&
|
||||
context.ApparentTemperatureC == nil &&
|
||||
context.ApparentTemperatureF == nil &&
|
||||
context.DewpointC == nil &&
|
||||
context.DewpointF == nil &&
|
||||
context.RelativeHumidityPercent == nil &&
|
||||
context.WindSpeedKmh == nil &&
|
||||
context.WindSpeedMph == nil &&
|
||||
context.WindDirectionDegrees == nil {
|
||||
return nil
|
||||
}
|
||||
return &context
|
||||
copied := *value
|
||||
return &copied
|
||||
}
|
||||
|
||||
func copyBool(value *bool) *bool {
|
||||
func copyInt(value *int) *int {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -177,18 +112,19 @@ func copyFloat(value *float64) *float64 {
|
||||
return &copied
|
||||
}
|
||||
|
||||
func Save(path string, pkg Package) error {
|
||||
if err := fileutil.WriteJSONAtomic(path, pkg); err != nil {
|
||||
return fmt.Errorf("save briefing package: %w", err)
|
||||
}
|
||||
func copyTime(value *time.Time) *time.Time {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copied := *value
|
||||
return &copied
|
||||
}
|
||||
|
||||
func sourceLocation(bundle *forecast.Bundle) (string, string) {
|
||||
func sourceLocation(bundle *weatherdata.Bundle) (string, string) {
|
||||
if bundle == nil {
|
||||
return "", ""
|
||||
}
|
||||
for _, run := range []*forecast.ForecastRun{bundle.Hourly, bundle.Narrative, bundle.Daily} {
|
||||
for _, run := range []*weatherdata.ForecastRun{bundle.Hourly, bundle.Narrative, bundle.Daily} {
|
||||
if run == nil {
|
||||
continue
|
||||
}
|
||||
@@ -199,7 +135,7 @@ func sourceLocation(bundle *forecast.Bundle) (string, string) {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func sourceMetadata(bundle *forecast.Bundle) []SourceMetadata {
|
||||
func sourceMetadata(bundle *weatherdata.Bundle) []SourceMetadata {
|
||||
if bundle == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -219,14 +155,14 @@ func sourceMetadata(bundle *forecast.Bundle) []SourceMetadata {
|
||||
return out
|
||||
}
|
||||
|
||||
func sourceWarnings(bundle *forecast.Bundle) []forecast.SourceWarning {
|
||||
func sourceWarnings(bundle *weatherdata.Bundle) []weatherdata.SourceWarning {
|
||||
if bundle == nil {
|
||||
return nil
|
||||
}
|
||||
return bundle.Warnings
|
||||
}
|
||||
|
||||
func alertStatus(bundle *forecast.Bundle) *AlertStatus {
|
||||
func alertStatus(bundle *weatherdata.Bundle) *AlertStatus {
|
||||
if bundle == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -247,21 +183,11 @@ func alertStatus(bundle *forecast.Bundle) *AlertStatus {
|
||||
return status
|
||||
}
|
||||
|
||||
func setRelevantAlertCount(metadata *Metadata, count int) {
|
||||
if metadata.Alerts == nil {
|
||||
if count == 0 {
|
||||
return
|
||||
}
|
||||
metadata.Alerts = &AlertStatus{}
|
||||
}
|
||||
metadata.Alerts.RelevantCount = count
|
||||
}
|
||||
|
||||
func variantForReport(id report.ID) string {
|
||||
switch id {
|
||||
case report.DailyToday:
|
||||
case report.Daily, report.Today:
|
||||
return "today"
|
||||
case report.DailyTomorrow:
|
||||
case report.Tomorrow:
|
||||
return "tomorrow"
|
||||
default:
|
||||
return ""
|
||||
|
||||
109
internal/briefing/precip_timing_module.go
Normal file
109
internal/briefing/precip_timing_module.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
)
|
||||
|
||||
const (
|
||||
precipTimingChanceLowerBound = 40
|
||||
precipTimingLikelyLowerBound = 50
|
||||
precipTimingExpectLowerBound = 70
|
||||
)
|
||||
|
||||
type PrecipTimingModule struct {
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||
ProbabilityThreshold float64 `json:"probability_threshold"`
|
||||
PrecipitationWindows []PrecipitationWindowModule `json:"precipitation_windows,omitempty"`
|
||||
ThunderMentioned bool `json:"thunder_mentioned"`
|
||||
}
|
||||
|
||||
type PrecipitationWindowModule struct {
|
||||
PeriodBegins string `json:"period_begins"`
|
||||
PeriodBeginsHourLabel string `json:"period_begins_hour_label,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
PeriodEndsHourLabel string `json:"period_ends_hour_label,omitempty"`
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||
MaxPopHourLabel string `json:"max_pop_hour_label,omitempty"`
|
||||
PrecipitationType string `json:"precipitation_type,omitempty"`
|
||||
ExpectationPhrase string `json:"expectation_phrase,omitempty"`
|
||||
}
|
||||
|
||||
func buildPrecipTimingModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
value := precipTimingValue(ctx.Derived.PrecipTiming, ctx.Timezone)
|
||||
return &module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: value}, nil
|
||||
}
|
||||
|
||||
func precipTimingValue(timing forecast.PrecipTiming, timezone string) PrecipTimingModule {
|
||||
value := PrecipTimingModule{
|
||||
ProbabilityThreshold: timing.ProbabilityThreshold,
|
||||
ThunderMentioned: timing.ThunderMentioned,
|
||||
}
|
||||
if timing.MaxPrecipitationProbability != nil {
|
||||
value.MaxPopPercent = roundedInt(&timing.MaxPrecipitationProbability.Value)
|
||||
value.MaxPopTime = clockLabel(timing.MaxPrecipitationProbability.Time, timezone)
|
||||
}
|
||||
for _, window := range timing.PrecipitationWindows {
|
||||
item := PrecipitationWindowModule{
|
||||
PeriodBegins: friendlyDateTimeLabel(window.Start, timezone),
|
||||
PeriodBeginsHourLabel: hourMinuteLabel(window.Start, timezone),
|
||||
}
|
||||
if window.End != nil {
|
||||
item.PeriodEnds = friendlyDateTimeLabel(*window.End, timezone)
|
||||
item.PeriodEndsHourLabel = hourMinuteLabel(*window.End, timezone)
|
||||
}
|
||||
item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value)
|
||||
item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone)
|
||||
item.MaxPopHourLabel = hourMinuteLabel(window.MaxPrecipitationProbability.Time, timezone)
|
||||
item.PrecipitationType = precipitationWindowType(window.TextDescriptions)
|
||||
if item.MaxPopPercent != nil {
|
||||
item.ExpectationPhrase = precipitationWindowExpectationPhrase(*item.MaxPopPercent, item.PrecipitationType)
|
||||
}
|
||||
value.PrecipitationWindows = append(value.PrecipitationWindows, item)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func precipitationWindowType(descriptions []string) string {
|
||||
combined := strings.ToLower(strings.Join(descriptions, " "))
|
||||
switch {
|
||||
case (strings.Contains(combined, "thunderstorm") || strings.Contains(combined, "t-storm")) &&
|
||||
(strings.Contains(combined, "shower") || strings.Contains(combined, "rain")):
|
||||
return "showers and thunderstorms"
|
||||
case strings.Contains(combined, "freezing rain"):
|
||||
return "freezing rain"
|
||||
case strings.Contains(combined, "thunderstorm") || strings.Contains(combined, "t-storm"):
|
||||
return "thunderstorms"
|
||||
case strings.Contains(combined, "shower"):
|
||||
return "showers"
|
||||
case strings.Contains(combined, "snow"):
|
||||
return "snow"
|
||||
case strings.Contains(combined, "drizzle"):
|
||||
return "drizzle"
|
||||
case strings.Contains(combined, "rain"):
|
||||
return "rain"
|
||||
default:
|
||||
return "precipitation"
|
||||
}
|
||||
}
|
||||
|
||||
func precipitationWindowExpectationPhrase(maxPopPercent int, precipitationType string) string {
|
||||
if precipitationType == "" {
|
||||
precipitationType = "precipitation"
|
||||
}
|
||||
switch {
|
||||
case maxPopPercent >= precipTimingExpectLowerBound:
|
||||
return fmt.Sprintf("Expect %s.", precipitationType)
|
||||
case maxPopPercent >= precipTimingLikelyLowerBound:
|
||||
return fmt.Sprintf("%s likely.", sentenceCase(precipitationType))
|
||||
case maxPopPercent >= precipTimingChanceLowerBound:
|
||||
return fmt.Sprintf("Chance of %s.", precipitationType)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
94
internal/briefing/spc_convective_discussion_module.go
Normal file
94
internal/briefing/spc_convective_discussion_module.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
const defaultSPCConvectiveDiscussionMinimumSeverityRank = defaultSPCRiskDigestMinimumSeverityRank
|
||||
const spcCategoricalOutlookType = defaultSPCRiskDigestOutlookType
|
||||
|
||||
type SPCConvectiveDiscussionModule struct {
|
||||
IncludedBecause string `json:"included_because"`
|
||||
Discussions []SPCConvectiveDiscussionRecord `json:"discussions"`
|
||||
}
|
||||
|
||||
type SPCConvectiveDiscussionRecord struct {
|
||||
Day int `json:"day,omitempty"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
Headline string `json:"headline,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Discussion string `json:"discussion,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
func buildSPCConvectiveDiscussionModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
qualifyingPeriods := spcConvectiveDiscussionQualifyingPeriods(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod)
|
||||
if len(qualifyingPeriods) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
records := spcConvectiveDiscussionRecords(qualifyingPeriods, ctx.Derived.SPCConvectiveDiscussions, ctx.Timezone)
|
||||
if len(records) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
value := SPCConvectiveDiscussionModule{
|
||||
IncludedBecause: fmt.Sprintf("%s severity_rank >= %d", spcCategoricalOutlookType, defaultSPCConvectiveDiscussionMinimumSeverityRank),
|
||||
Discussions: records,
|
||||
}
|
||||
return &module.Output{ID: module.SPCConvectiveDiscussion, StanzaName: string(module.SPCConvectiveDiscussion), Value: value}, nil
|
||||
}
|
||||
|
||||
func spcConvectiveDiscussionQualifyingPeriods(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period) map[int]timeutil.Period {
|
||||
periods := map[int]timeutil.Period{}
|
||||
for _, outlook := range outlooks {
|
||||
if outlook.OutlookType != spcCategoricalOutlookType {
|
||||
continue
|
||||
}
|
||||
if outlook.SeverityRank == nil || *outlook.SeverityRank < defaultSPCConvectiveDiscussionMinimumSeverityRank {
|
||||
continue
|
||||
}
|
||||
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
|
||||
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(reportPeriod) {
|
||||
continue
|
||||
}
|
||||
if existing, ok := periods[outlook.Day]; ok {
|
||||
if outlookPeriod.Start.Before(existing.Start) {
|
||||
existing.Start = outlookPeriod.Start
|
||||
}
|
||||
if outlookPeriod.End.After(existing.End) {
|
||||
existing.End = outlookPeriod.End
|
||||
}
|
||||
periods[outlook.Day] = existing
|
||||
continue
|
||||
}
|
||||
periods[outlook.Day] = outlookPeriod
|
||||
}
|
||||
return periods
|
||||
}
|
||||
|
||||
func spcConvectiveDiscussionRecords(qualifyingPeriods map[int]timeutil.Period, discussions []weatherdata.ConvectiveOutlookDiscussion, timezone string) []SPCConvectiveDiscussionRecord {
|
||||
records := make([]SPCConvectiveDiscussionRecord, 0, len(discussions))
|
||||
for _, discussion := range discussions {
|
||||
period, ok := qualifyingPeriods[discussion.Day]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if discussion.Discussion == "" {
|
||||
continue
|
||||
}
|
||||
records = append(records, SPCConvectiveDiscussionRecord{
|
||||
Day: discussion.Day,
|
||||
PeriodBegins: friendlyPeriodBeginsLabel(period, timezone),
|
||||
PeriodEnds: friendlyPeriodEndsLabel(period, timezone),
|
||||
Headline: discussion.Headline,
|
||||
Summary: discussion.Summary,
|
||||
Discussion: discussion.Discussion,
|
||||
UpdatedAt: friendlyOptionalTime(discussion.UpdatedAt, timezone),
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
317
internal/briefing/spc_convective_discussion_module_test.go
Normal file
317
internal/briefing/spc_convective_discussion_module_test.go
Normal file
@@ -0,0 +1,317 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
func TestSPCConvectiveDiscussionModuleOmitsBelowThresholdButOutlookRemains(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := spcConvectiveDiscussionContext(2, []weatherdata.ConvectiveOutlookDiscussion{
|
||||
spcDiscussion(1, "Lower risk", "General thunderstorms.", "No organized severe weather is expected.", "2026-05-29T08:30:00-05:00"),
|
||||
})
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(discussion) error = %v", err)
|
||||
}
|
||||
if output != nil {
|
||||
t.Fatalf("discussion output = %#v, want omitted below threshold", output)
|
||||
}
|
||||
|
||||
outlookOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(outlooks) error = %v", err)
|
||||
}
|
||||
outlookValue := moduleValue[SPCConvectiveOutlooksModule](t, outlookOutput)
|
||||
if !outlookValue.Checked || outlookValue.OutlookCount != 1 {
|
||||
t.Fatalf("outlook value = %#v, want lower-risk outlook still emitted", outlookValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveDiscussionModuleIncludesEqualThresholdDiscussion(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := spcConvectiveDiscussionContext(3, []weatherdata.ConvectiveOutlookDiscussion{
|
||||
spcDiscussion(1, "Severe storms possible", "Scattered severe storms are possible.", "A few storms may become severe during the afternoon.", "2026-05-29T08:30:00-05:00"),
|
||||
})
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
if output == nil || output.ID != module.SPCConvectiveDiscussion || output.StanzaName != "spc_convective_discussion" {
|
||||
t.Fatalf("output = %#v, want spc convective discussion stanza", output)
|
||||
}
|
||||
value := moduleValue[SPCConvectiveDiscussionModule](t, output)
|
||||
if value.IncludedBecause != "categorical severity_rank >= 3" || len(value.Discussions) != 1 {
|
||||
t.Fatalf("value = %#v, want threshold reason and one discussion", value)
|
||||
}
|
||||
discussion := value.Discussions[0]
|
||||
if discussion.Day != 1 || discussion.Headline != "Severe storms possible" || discussion.Summary == "" || discussion.Discussion == "" {
|
||||
t.Fatalf("discussion = %#v, want prompt-facing discussion fields", discussion)
|
||||
}
|
||||
if discussion.PeriodBegins != "2026-05-29 at 11:00 AM" || discussion.PeriodEnds != "2026-05-30 at 7:00 AM" {
|
||||
t.Fatalf("Period = %q/%q, want qualifying outlook valid period", discussion.PeriodBegins, discussion.PeriodEnds)
|
||||
}
|
||||
if discussion.UpdatedAt != "2026-05-29 at 8:30 AM" {
|
||||
t.Fatalf("UpdatedAt = %q, want friendly local time", discussion.UpdatedAt)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
text := string(data)
|
||||
for _, field := range []string{"included_because", "discussions", "period_begins", "period_ends", "headline", "summary", "discussion", "updated_at"} {
|
||||
if !strings.Contains(text, field) {
|
||||
t.Fatalf("json = %s, want field %s", text, field)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, `"period":`) {
|
||||
t.Fatalf("json = %s, want period_begins/period_ends instead of period", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveDiscussionModuleIncludesAboveThresholdDiscussion(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := spcConvectiveDiscussionContext(4, []weatherdata.ConvectiveOutlookDiscussion{
|
||||
spcDiscussion(1, "Enhanced severe risk", "Numerous severe storms are possible.", "Severe storms may produce damaging winds.", "2026-05-29T09:15:00-05:00"),
|
||||
})
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[SPCConvectiveDiscussionModule](t, output)
|
||||
if len(value.Discussions) != 1 || value.Discussions[0].Headline != "Enhanced severe risk" {
|
||||
t.Fatalf("value = %#v, want above-threshold discussion", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveDiscussionModuleIgnoresHighRankNonCategoricalOutlook(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
rank := 30
|
||||
ctx := testModuleContext()
|
||||
outlook := weatherdata.ConvectiveOutlook{
|
||||
ID: "day1-wind-30",
|
||||
Day: 1,
|
||||
OutlookType: "wind",
|
||||
Label: "30%",
|
||||
LabelText: "30% Wind Risk",
|
||||
SeverityRank: &rank,
|
||||
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||
}
|
||||
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||
}
|
||||
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
|
||||
spcDiscussion(1, "Wind risk discussion", "High wind probabilities.", "This discussion should not be emitted from wind severity rank.", "2026-05-29T08:30:00-05:00"),
|
||||
}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
if output != nil {
|
||||
t.Fatalf("output = %#v, want non-categorical outlook ignored for discussion threshold", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveDiscussionModuleRequiresCategoricalThresholdForMixedSameDayOutlooks(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
rank2 := 2
|
||||
rank30 := 30
|
||||
ctx := testModuleContext()
|
||||
categorical := weatherdata.ConvectiveOutlook{
|
||||
ID: "day1-marginal",
|
||||
Day: 1,
|
||||
OutlookType: "categorical",
|
||||
Label: "MRGL",
|
||||
LabelText: "Marginal Risk",
|
||||
SeverityRank: &rank2,
|
||||
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||
}
|
||||
wind := weatherdata.ConvectiveOutlook{
|
||||
ID: "day1-wind-30",
|
||||
Day: 1,
|
||||
OutlookType: "wind",
|
||||
Label: "30%",
|
||||
LabelText: "30% Wind Risk",
|
||||
SeverityRank: &rank30,
|
||||
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||
}
|
||||
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{categorical, wind},
|
||||
}
|
||||
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{categorical, wind}
|
||||
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
|
||||
spcDiscussion(1, "Mixed risk discussion", "Only wind is high.", "This discussion should not be emitted without categorical threshold.", "2026-05-29T08:30:00-05:00"),
|
||||
}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
if output != nil {
|
||||
t.Fatalf("output = %#v, want mixed day omitted when categorical outlook is below threshold", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveDiscussionModuleIncludesOnlyQualifyingDays(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
rank2 := 2
|
||||
rank4 := 4
|
||||
ctx := testModuleContext()
|
||||
ctx.Resolved.ValidPeriod.Start = mustParseModuleTime("2026-05-30T00:00:00-05:00")
|
||||
ctx.Resolved.ValidPeriod.End = mustParseModuleTime("2026-05-31T00:00:00-05:00")
|
||||
day1Outlook := weatherdata.ConvectiveOutlook{
|
||||
ID: "day1-marginal",
|
||||
Day: 1,
|
||||
OutlookType: "categorical",
|
||||
Label: "MRGL",
|
||||
LabelText: "Marginal Risk",
|
||||
SeverityRank: &rank2,
|
||||
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||
}
|
||||
day2Outlook := weatherdata.ConvectiveOutlook{
|
||||
ID: "day2-enhanced",
|
||||
Day: 2,
|
||||
OutlookType: "categorical",
|
||||
Label: "ENH",
|
||||
LabelText: "Enhanced Risk",
|
||||
SeverityRank: &rank4,
|
||||
ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||
ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"),
|
||||
}
|
||||
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{day1Outlook, day2Outlook},
|
||||
}
|
||||
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{day1Outlook, day2Outlook}
|
||||
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
|
||||
spcDiscussion(1, "Day 1 regional discussion", "Marginal risk discussion.", "Day 1 text should not be emitted.", "2026-05-29T08:30:00-05:00"),
|
||||
spcDiscussion(2, "Day 2 regional discussion", "Enhanced risk discussion.", "Day 2 text should be emitted.", "2026-05-30T08:30:00-05:00"),
|
||||
}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[SPCConvectiveDiscussionModule](t, output)
|
||||
if len(value.Discussions) != 1 {
|
||||
t.Fatalf("Discussions = %#v, want only the qualifying day discussion", value.Discussions)
|
||||
}
|
||||
if value.Discussions[0].Day != 2 || value.Discussions[0].Headline != "Day 2 regional discussion" {
|
||||
t.Fatalf("Discussions[0] = %#v, want day 2 discussion only", value.Discussions[0])
|
||||
}
|
||||
if value.Discussions[0].PeriodBegins != "2026-05-30 at 7:00 AM" || value.Discussions[0].PeriodEnds != "2026-05-31 at 7:00 AM" {
|
||||
t.Fatalf("Period = %q/%q, want qualifying day 2 outlook period", value.Discussions[0].PeriodBegins, value.Discussions[0].PeriodEnds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveDiscussionModuleOmitsNonOverlappingQualifyingOutlook(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
rank := 5
|
||||
ctx := testModuleContext()
|
||||
outlook := weatherdata.ConvectiveOutlook{
|
||||
ID: "day2-enhanced",
|
||||
Day: 2,
|
||||
OutlookType: "categorical",
|
||||
Label: "ENH",
|
||||
LabelText: "Enhanced Risk",
|
||||
SeverityRank: &rank,
|
||||
ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||
ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"),
|
||||
}
|
||||
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||
}
|
||||
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
|
||||
spcDiscussion(2, "Day 2 regional discussion", "Enhanced risk discussion.", "Day 2 text should not be emitted for today.", "2026-05-30T08:30:00-05:00"),
|
||||
}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
if output != nil {
|
||||
t.Fatalf("output = %#v, want non-overlapping discussion omitted", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveDiscussionModuleOmitsMissingDiscussionText(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := spcConvectiveDiscussionContext(3, []weatherdata.ConvectiveOutlookDiscussion{
|
||||
{Day: 1, Headline: "Severe storms possible", Summary: "Scattered severe storms are possible.", UpdatedAt: ptrModuleTime("2026-05-29T08:30:00-05:00")},
|
||||
})
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
if output != nil {
|
||||
t.Fatalf("output = %#v, want omitted when discussion text is missing", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveDiscussionModuleOmitsMissingSource(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Collected.SPCConvectiveOutlooks = nil
|
||||
ctx.Derived.SPCConvectiveOutlooks = nil
|
||||
ctx.Derived.SPCConvectiveDiscussions = nil
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
if output != nil {
|
||||
t.Fatalf("output = %#v, want omitted for missing optional source", output)
|
||||
}
|
||||
}
|
||||
|
||||
func spcConvectiveDiscussionContext(rank int, discussions []weatherdata.ConvectiveOutlookDiscussion) ModuleContext {
|
||||
ctx := testModuleContext()
|
||||
outlook := weatherdata.ConvectiveOutlook{
|
||||
ID: "day1-categorical",
|
||||
Day: 1,
|
||||
OutlookType: "categorical",
|
||||
Label: "SLGT",
|
||||
LabelText: "Slight Risk",
|
||||
SeverityRank: &rank,
|
||||
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||
}
|
||||
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||
AsOf: ptrModuleTime("2026-05-29T08:00:00-05:00"),
|
||||
IssuedAt: ptrModuleTime("2026-05-29T07:45:00-05:00"),
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||
}
|
||||
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||
ctx.Derived.SPCConvectiveDiscussions = discussions
|
||||
return ctx
|
||||
}
|
||||
|
||||
func spcDiscussion(day int, headline string, summary string, discussion string, updatedAt string) weatherdata.ConvectiveOutlookDiscussion {
|
||||
return weatherdata.ConvectiveOutlookDiscussion{
|
||||
Day: day,
|
||||
Headline: headline,
|
||||
Summary: summary,
|
||||
Discussion: discussion,
|
||||
UpdatedAt: ptrModuleTime(updatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func ptrModuleTime(value string) *time.Time {
|
||||
parsed := mustParseModuleTime(value)
|
||||
return &parsed
|
||||
}
|
||||
37
internal/briefing/spc_convective_outlook_definitions.go
Normal file
37
internal/briefing/spc_convective_outlook_definitions.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed assets/spc_convective_outlook_definitions.json
|
||||
var spcConvectiveOutlookDefinitionAssets embed.FS
|
||||
|
||||
var spcOutlookBackgroundDefinitions = mustLoadSPCOutlookBackgroundDefinitions()
|
||||
|
||||
func mustLoadSPCOutlookBackgroundDefinitions() map[string]SPCOutlookBackgroundDefinition {
|
||||
data, err := spcConvectiveOutlookDefinitionAssets.ReadFile("assets/spc_convective_outlook_definitions.json")
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("read embedded SPC outlook definitions: %v", err))
|
||||
}
|
||||
var definitions map[string]SPCOutlookBackgroundDefinition
|
||||
if err := json.Unmarshal(data, &definitions); err != nil {
|
||||
panic(fmt.Sprintf("decode embedded SPC outlook definitions: %v", err))
|
||||
}
|
||||
return definitions
|
||||
}
|
||||
|
||||
func spcOutlookBackgroundDefinition(outlookType string, label string) *SPCOutlookBackgroundDefinition {
|
||||
definition, ok := spcOutlookBackgroundDefinitions[spcOutlookDefinitionKey(outlookType, label)]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &definition
|
||||
}
|
||||
|
||||
func spcOutlookDefinitionKey(outlookType string, label string) string {
|
||||
return strings.ToLower(strings.TrimSpace(outlookType)) + ":" + strings.ToUpper(strings.TrimSpace(label))
|
||||
}
|
||||
162
internal/briefing/spc_convective_outlooks_module.go
Normal file
162
internal/briefing/spc_convective_outlooks_module.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
const defaultSPCRiskDigestOutlookType = "categorical"
|
||||
const defaultSPCRiskDigestMinimumSeverityRank = 3
|
||||
|
||||
type SPCConvectiveOutlooksModule struct {
|
||||
Checked bool `json:"checked"`
|
||||
AsOf string `json:"as_of,omitempty"`
|
||||
IssuedAt string `json:"issued_at,omitempty"`
|
||||
LocationID string `json:"location_id,omitempty"`
|
||||
LocationName string `json:"location_name,omitempty"`
|
||||
OutlookCount int `json:"outlook_count"`
|
||||
Outlooks []SPCConvectiveOutlookRecord `json:"outlooks,omitempty"`
|
||||
RiskDigest []SPCConvectiveOutlookDigest `json:"risk_digest,omitempty"`
|
||||
}
|
||||
|
||||
type SPCConvectiveOutlookRecord struct {
|
||||
Day int `json:"day,omitempty"`
|
||||
OutlookType string `json:"outlook_type,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
LabelText string `json:"label_text,omitempty"`
|
||||
BackgroundDefinition *SPCOutlookBackgroundDefinition `json:"background_definition,omitempty"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
IssuedAt string `json:"issued_at,omitempty"`
|
||||
ContainsLocation bool `json:"contains_location"`
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
}
|
||||
|
||||
type SPCOutlookBackgroundDefinition struct {
|
||||
PlainLanguage string `json:"plain_language,omitempty"`
|
||||
OfficialDescription string `json:"official_description,omitempty"`
|
||||
RelativeLevel string `json:"relative_level,omitempty"`
|
||||
}
|
||||
|
||||
type SPCConvectiveOutlookDigest struct {
|
||||
LabelText string `json:"label_text,omitempty"`
|
||||
RiskLabel string `json:"risk_label,omitempty"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
}
|
||||
|
||||
func buildSPCConvectiveOutlooksModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
value := SPCConvectiveOutlooksModule{}
|
||||
run := ctx.Collected.SPCConvectiveOutlooks
|
||||
if run != nil {
|
||||
value.Checked = true
|
||||
value.AsOf = friendlyOptionalTime(run.AsOf, ctx.Timezone)
|
||||
value.IssuedAt = friendlyOptionalTime(run.IssuedAt, ctx.Timezone)
|
||||
value.LocationID = run.LocationID
|
||||
value.LocationName = run.LocationName
|
||||
}
|
||||
source, ok := sourceByName(ctx.Collected.SourceProvenance, string(module.SPCConvectiveOutlooks))
|
||||
if value.Checked && value.AsOf == "" && ok && !source.FetchedAt.IsZero() {
|
||||
value.AsOf = friendlyDateTimeLabel(source.FetchedAt, ctx.Timezone)
|
||||
}
|
||||
if value.Checked && value.IssuedAt == "" && ok {
|
||||
value.IssuedAt = friendlyOptionalTime(source.IssuedAt, ctx.Timezone)
|
||||
}
|
||||
|
||||
value.Outlooks = spcConvectiveOutlookRecords(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod, ctx.Timezone)
|
||||
value.RiskDigest = spcConvectiveOutlookRiskDigest(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod, ctx.Timezone, defaultSPCRiskDigestPolicy())
|
||||
value.OutlookCount = len(value.Outlooks)
|
||||
return &module.Output{ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), Value: value}, nil
|
||||
}
|
||||
|
||||
type spcRiskDigestPolicy struct {
|
||||
OutlookType string
|
||||
MinimumSeverityRank int
|
||||
}
|
||||
|
||||
func defaultSPCRiskDigestPolicy() spcRiskDigestPolicy {
|
||||
return spcRiskDigestPolicy{
|
||||
OutlookType: defaultSPCRiskDigestOutlookType,
|
||||
MinimumSeverityRank: defaultSPCRiskDigestMinimumSeverityRank,
|
||||
}
|
||||
}
|
||||
|
||||
func spcConvectiveOutlookRecords(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period, timezone string) []SPCConvectiveOutlookRecord {
|
||||
records := make([]SPCConvectiveOutlookRecord, 0, len(outlooks))
|
||||
for _, outlook := range outlooks {
|
||||
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
|
||||
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(reportPeriod) {
|
||||
continue
|
||||
}
|
||||
records = append(records, SPCConvectiveOutlookRecord{
|
||||
Day: outlook.Day,
|
||||
OutlookType: outlook.OutlookType,
|
||||
Label: outlook.Label,
|
||||
LabelText: outlook.LabelText,
|
||||
BackgroundDefinition: spcOutlookBackgroundDefinition(outlook.OutlookType, outlook.Label),
|
||||
PeriodBegins: friendlyPeriodBeginsLabel(outlookPeriod, timezone),
|
||||
PeriodEnds: friendlyPeriodEndsLabel(outlookPeriod, timezone),
|
||||
IssuedAt: friendlyOptionalTime(outlook.IssuedAt, timezone),
|
||||
ContainsLocation: outlook.ContainsLocation,
|
||||
ImageURL: outlook.ImageURL,
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func spcConvectiveOutlookRiskDigest(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period, timezone string, policy spcRiskDigestPolicy) []SPCConvectiveOutlookDigest {
|
||||
records := make([]SPCConvectiveOutlookDigest, 0, len(outlooks))
|
||||
for _, outlook := range outlooks {
|
||||
if outlook.OutlookType != policy.OutlookType {
|
||||
continue
|
||||
}
|
||||
if outlook.SeverityRank == nil || *outlook.SeverityRank < policy.MinimumSeverityRank {
|
||||
continue
|
||||
}
|
||||
if !outlook.ContainsLocation {
|
||||
continue
|
||||
}
|
||||
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
|
||||
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(reportPeriod) {
|
||||
continue
|
||||
}
|
||||
records = append(records, SPCConvectiveOutlookDigest{
|
||||
LabelText: outlook.LabelText,
|
||||
RiskLabel: spcRiskDigestLabel(outlook.LabelText),
|
||||
PeriodBegins: friendlyMonthDayTimeLabel(outlookPeriod.Start, timezone),
|
||||
PeriodEnds: friendlyMonthDayTimeLabel(outlookPeriod.End, timezone),
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func spcRiskDigestLabel(labelText string) string {
|
||||
label := strings.TrimSpace(labelText)
|
||||
if label == "" {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(strings.ToLower(label))
|
||||
runes[0] = unicode.ToUpper(runes[0])
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
func friendlyOptionalTime(value *time.Time, timezone string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return friendlyDateTimeLabel(*value, timezone)
|
||||
}
|
||||
|
||||
func sourceByName(sources []weatherdata.Source, name string) (weatherdata.Source, bool) {
|
||||
for _, source := range sources {
|
||||
if source.Name == name {
|
||||
return source, true
|
||||
}
|
||||
}
|
||||
return weatherdata.Source{}, false
|
||||
}
|
||||
355
internal/briefing/spc_convective_outlooks_module_test.go
Normal file
355
internal/briefing/spc_convective_outlooks_module_test.go
Normal file
@@ -0,0 +1,355 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
rank := 3
|
||||
asOf := mustParseModuleTime("2026-05-29T14:00:00Z")
|
||||
issuedAt := mustParseModuleTime("2026-05-29T13:45:00Z")
|
||||
expiresAt := mustParseModuleTime("2026-05-30T07:00:00-05:00")
|
||||
outlook := weatherdata.ConvectiveOutlook{
|
||||
ID: "day1-categorical-slight",
|
||||
Provider: "spc",
|
||||
Product: "convective_outlook",
|
||||
Day: 1,
|
||||
OutlookType: "categorical",
|
||||
Label: "SLGT",
|
||||
LabelText: "Slight Risk",
|
||||
Forecaster: "Smith",
|
||||
SeverityRank: &rank,
|
||||
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||
IssuedAt: &issuedAt,
|
||||
ExpiresAt: &expiresAt,
|
||||
SourceURL: "https://www.spc.noaa.gov/products/outlook/day1otlk.html",
|
||||
ImageURL: "https://www.spc.noaa.gov/products/outlook/day1probotlk_2000_torn.gif",
|
||||
ContainsLocation: true,
|
||||
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[]}`),
|
||||
}
|
||||
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||
LocationID: "nws-lsx-grid-90-74",
|
||||
LocationName: "St. Louis, MO",
|
||||
AsOf: &asOf,
|
||||
IssuedAt: &issuedAt,
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||
}
|
||||
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
if output == nil || output.ID != module.SPCConvectiveOutlooks || output.StanzaName != "spc_convective_outlooks" {
|
||||
t.Fatalf("output = %#v, want spc convective outlook output", output)
|
||||
}
|
||||
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
|
||||
if !value.Checked || value.OutlookCount != 1 || value.AsOf != "2026-05-29 at 9:00 AM" || value.IssuedAt != "2026-05-29 at 8:45 AM" {
|
||||
t.Fatalf("SPCConvectiveOutlooksModule = %#v, want checked source timing and one outlook", value)
|
||||
}
|
||||
if value.LocationID != "nws-lsx-grid-90-74" || value.LocationName != "St. Louis, MO" {
|
||||
t.Fatalf("source location = %q/%q, want Weather API location", value.LocationID, value.LocationName)
|
||||
}
|
||||
if len(value.Outlooks) != 1 {
|
||||
t.Fatalf("Outlooks length = %d, want 1", len(value.Outlooks))
|
||||
}
|
||||
got := value.Outlooks[0]
|
||||
if got.Day != 1 || got.OutlookType != "categorical" || got.Label != "SLGT" || got.LabelText != "Slight Risk" {
|
||||
t.Fatalf("outlook = %#v, want categorical slight risk fields", got)
|
||||
}
|
||||
if got.BackgroundDefinition == nil ||
|
||||
got.BackgroundDefinition.PlainLanguage != "Scattered severe storms possible." ||
|
||||
got.BackgroundDefinition.OfficialDescription != "Isolated intense storms are possible within the risk area, but severe weather is generally expected to be short-lived and/or not widespread." ||
|
||||
got.BackgroundDefinition.RelativeLevel != "2 of 5" {
|
||||
t.Fatalf("background definition = %#v, want Slight Risk helper", got.BackgroundDefinition)
|
||||
}
|
||||
if got.PeriodBegins != "2026-05-29 at 11:00 AM" || got.PeriodEnds != "2026-05-30 at 7:00 AM" || got.IssuedAt != "2026-05-29 at 8:45 AM" {
|
||||
t.Fatalf("outlook times = %#v, want friendly local labels", got)
|
||||
}
|
||||
if !got.ContainsLocation || got.ImageURL == "" {
|
||||
t.Fatalf("outlook = %#v, want location flag and image URL", got)
|
||||
}
|
||||
if len(value.RiskDigest) != 1 {
|
||||
t.Fatalf("RiskDigest length = %d, want 1", len(value.RiskDigest))
|
||||
}
|
||||
digest := value.RiskDigest[0]
|
||||
if digest.LabelText != "Slight Risk" || digest.RiskLabel != "Slight risk" || digest.PeriodBegins != "May 29 at 11:00 AM" || digest.PeriodEnds != "May 30 at 7:00 AM" {
|
||||
t.Fatalf("risk digest = %#v, want prompt-facing slight risk record", digest)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
text := string(data)
|
||||
for _, field := range []string{"checked", "as_of", "issued_at", "location_id", "location_name", "outlook_count", "outlooks", "risk_digest", "background_definition", "plain_language", "official_description", "relative_level", "period_begins", "period_ends", "contains_location", "image_url"} {
|
||||
if !strings.Contains(text, field) {
|
||||
t.Fatalf("json = %s, want field %s", text, field)
|
||||
}
|
||||
}
|
||||
for _, omitted := range []string{"geometry", "coordinates", "forecaster", "provider", "severity_rank", "expires_at", "source_url", "valid_start", "valid_end", `"period":`} {
|
||||
if strings.Contains(text, omitted) {
|
||||
t.Fatalf("json = %s, want prompt-safe outlook without %s", text, omitted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCOutlookBackgroundDefinitionLookup(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
outlookType string
|
||||
label string
|
||||
want bool
|
||||
}{
|
||||
{name: "exact slight risk", outlookType: "categorical", label: "SLGT", want: true},
|
||||
{name: "normalized slight risk", outlookType: " Categorical ", label: "slgt", want: true},
|
||||
{name: "expanded marginal risk", outlookType: "categorical", label: "MRGL", want: true},
|
||||
{name: "expanded conditional tornado risk", outlookType: "tornado", label: "CIG3", want: true},
|
||||
{name: "unknown risk", outlookType: "categorical", label: "FOO"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
definition := spcOutlookBackgroundDefinition(tt.outlookType, tt.label)
|
||||
if tt.want && definition == nil {
|
||||
t.Fatalf("spcOutlookBackgroundDefinition(%q, %q) = nil, want definition", tt.outlookType, tt.label)
|
||||
}
|
||||
if !tt.want && definition != nil {
|
||||
t.Fatalf("spcOutlookBackgroundDefinition(%q, %q) = %#v, want nil", tt.outlookType, tt.label, definition)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveOutlooksModuleOmitsUnknownBackgroundDefinition(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
outlook := spcRiskDigestTestOutlook("categorical", "Unknown Risk", 2, true,
|
||||
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00")
|
||||
outlook.Label = "FOO"
|
||||
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||
}
|
||||
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
|
||||
if len(value.Outlooks) != 1 {
|
||||
t.Fatalf("Outlooks length = %d, want 1", len(value.Outlooks))
|
||||
}
|
||||
if value.Outlooks[0].BackgroundDefinition != nil {
|
||||
t.Fatalf("BackgroundDefinition = %#v, want nil for undefined risk", value.Outlooks[0].BackgroundDefinition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCOutlookBackgroundDefinitionsAssetHasUsableEntries(t *testing.T) {
|
||||
wantKeys := []string{
|
||||
"categorical:TSTM",
|
||||
"categorical:MRGL",
|
||||
"categorical:SLGT",
|
||||
"categorical:ENH",
|
||||
"categorical:MDT",
|
||||
"categorical:HIGH",
|
||||
"tornado:CIG1",
|
||||
"tornado:CIG2",
|
||||
"tornado:CIG3",
|
||||
"wind:CIG1",
|
||||
"wind:CIG2",
|
||||
"wind:CIG3",
|
||||
"hail:CIG1",
|
||||
"hail:CIG2",
|
||||
}
|
||||
if len(spcOutlookBackgroundDefinitions) != len(wantKeys) {
|
||||
t.Fatalf("embedded SPC outlook background definitions length = %d, want %d", len(spcOutlookBackgroundDefinitions), len(wantKeys))
|
||||
}
|
||||
for _, key := range wantKeys {
|
||||
if _, ok := spcOutlookBackgroundDefinitions[key]; !ok {
|
||||
t.Fatalf("embedded SPC outlook background definitions missing %q", key)
|
||||
}
|
||||
}
|
||||
for key, definition := range spcOutlookBackgroundDefinitions {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
t.Fatal("embedded SPC outlook background definitions contain empty key")
|
||||
}
|
||||
if definition.PlainLanguage == "" || definition.OfficialDescription == "" || definition.RelativeLevel == "" {
|
||||
t.Fatalf("embedded SPC outlook background definition %q is incomplete: %#v", key, definition)
|
||||
}
|
||||
if strings.Contains(definition.OfficialDescription, ".Note") || strings.Contains(definition.OfficialDescription, "higher.Note") {
|
||||
t.Fatalf("embedded SPC outlook background definition %q has missing sentence spacing: %q", key, definition.OfficialDescription)
|
||||
}
|
||||
if strings.Contains(definition.OfficialDescription, "by themselves") {
|
||||
t.Fatalf("embedded SPC outlook background definition %q has singular grammar issue: %q", key, definition.OfficialDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCRiskDigestDefaultPolicyConstants(t *testing.T) {
|
||||
if defaultSPCRiskDigestOutlookType != "categorical" {
|
||||
t.Fatalf("defaultSPCRiskDigestOutlookType = %q, want categorical", defaultSPCRiskDigestOutlookType)
|
||||
}
|
||||
if defaultSPCRiskDigestMinimumSeverityRank != 3 {
|
||||
t.Fatalf("defaultSPCRiskDigestMinimumSeverityRank = %d, want 3", defaultSPCRiskDigestMinimumSeverityRank)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveOutlooksRiskDigestFilters(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
outlook weatherdata.ConvectiveOutlook
|
||||
wantRisk bool
|
||||
}{
|
||||
{
|
||||
name: "categorical slight risk included",
|
||||
outlook: spcRiskDigestTestOutlook("categorical", "Slight Risk", 3, true,
|
||||
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"),
|
||||
wantRisk: true,
|
||||
},
|
||||
{
|
||||
name: "marginal risk excluded",
|
||||
outlook: spcRiskDigestTestOutlook("categorical", "Marginal Risk", 2, true,
|
||||
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"),
|
||||
},
|
||||
{
|
||||
name: "non categorical high rank excluded",
|
||||
outlook: spcRiskDigestTestOutlook("wind", "30% Wind Risk", 30, true,
|
||||
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"),
|
||||
},
|
||||
{
|
||||
name: "non overlapping excluded",
|
||||
outlook: spcRiskDigestTestOutlook("categorical", "Enhanced Risk", 4, true,
|
||||
"2026-05-30T07:00:00-05:00", "2026-05-31T07:00:00-05:00"),
|
||||
},
|
||||
{
|
||||
name: "location miss excluded",
|
||||
outlook: spcRiskDigestTestOutlook("categorical", "Moderate Risk", 5, false,
|
||||
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{tt.outlook},
|
||||
}
|
||||
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{tt.outlook}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
|
||||
gotRisk := len(value.RiskDigest) > 0
|
||||
if gotRisk != tt.wantRisk {
|
||||
t.Fatalf("RiskDigest = %#v, want included=%v", value.RiskDigest, tt.wantRisk)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveOutlooksModuleSkipsNonOverlappingOutlooks(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
rank := 5
|
||||
outlook := weatherdata.ConvectiveOutlook{
|
||||
ID: "tomorrow-enhanced",
|
||||
Day: 2,
|
||||
OutlookType: "categorical",
|
||||
Label: "ENH",
|
||||
LabelText: "Enhanced Risk",
|
||||
SeverityRank: &rank,
|
||||
ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||
ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"),
|
||||
ContainsLocation: true,
|
||||
}
|
||||
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||
AsOf: ptrModuleTime("2026-05-29T14:00:00Z"),
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||
}
|
||||
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
|
||||
if value.OutlookCount != 0 || len(value.Outlooks) != 0 {
|
||||
t.Fatalf("value = %#v, want non-overlapping outlook omitted", value)
|
||||
}
|
||||
if len(value.RiskDigest) != 0 {
|
||||
t.Fatalf("RiskDigest = %#v, want non-overlapping outlook omitted", value.RiskDigest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveOutlooksModuleBuildsCheckedEmptyStanza(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
asOf := mustParseModuleTime("2026-05-29T14:00:00Z")
|
||||
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||
LocationID: "nws-lsx-grid-90-74",
|
||||
LocationName: "St. Louis, MO",
|
||||
AsOf: &asOf,
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{},
|
||||
}
|
||||
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
|
||||
if !value.Checked || value.OutlookCount != 0 || len(value.Outlooks) != 0 {
|
||||
t.Fatalf("checked empty value = %#v, want checked source with no retained outlooks", value)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
if strings.Contains(string(data), "outlooks") {
|
||||
t.Fatalf("json = %s, want empty outlook list omitted", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveOutlooksModuleBuildsUncheckedStanzaForMissingSource(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Collected.SPCConvectiveOutlooks = nil
|
||||
ctx.Collected.SourceProvenance = []weatherdata.Source{{
|
||||
Name: string(module.SPCConvectiveOutlooks),
|
||||
Missing: true,
|
||||
}}
|
||||
ctx.Derived.SPCConvectiveOutlooks = nil
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
|
||||
if value.Checked || value.OutlookCount != 0 || value.AsOf != "" || value.IssuedAt != "" {
|
||||
t.Fatalf("missing source value = %#v, want unchecked empty stanza", value)
|
||||
}
|
||||
}
|
||||
|
||||
func spcRiskDigestTestOutlook(outlookType string, labelText string, rank int, containsLocation bool, validFrom string, validTo string) weatherdata.ConvectiveOutlook {
|
||||
return weatherdata.ConvectiveOutlook{
|
||||
ID: labelText,
|
||||
Day: 1,
|
||||
OutlookType: outlookType,
|
||||
LabelText: labelText,
|
||||
SeverityRank: &rank,
|
||||
ValidFrom: mustParseModuleTime(validFrom),
|
||||
ValidTo: mustParseModuleTime(validTo),
|
||||
ContainsLocation: containsLocation,
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
type Storm struct {
|
||||
TimingWindow timeutil.Period `json:"timingWindow"`
|
||||
EventHeadlines []string `json:"eventHeadlines,omitempty"`
|
||||
Hazards []string `json:"hazards,omitempty"`
|
||||
MostLikelyScenario []string `json:"mostLikelyScenario,omitempty"`
|
||||
ReasonableWorstCase []string `json:"reasonableWorstCase,omitempty"`
|
||||
ConfidenceInputs []string `json:"confidenceInputs,omitempty"`
|
||||
WhatToWatchNext []string `json:"whatToWatchNext,omitempty"`
|
||||
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
|
||||
HourlyPeriods []forecast.ForecastPeriod `json:"hourlyPeriods,omitempty"`
|
||||
DailyPeriods []forecast.ForecastPeriod `json:"dailyPeriods,omitempty"`
|
||||
NarrativePeriods []forecast.ForecastPeriod `json:"narrativePeriods,omitempty"`
|
||||
WindowSummary forecast.DaypartSummary `json:"windowSummary"`
|
||||
Discussion DiscussionContext `json:"discussion,omitempty"`
|
||||
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
|
||||
}
|
||||
|
||||
func BuildStorm(ctx BuildContext) (Package, error) {
|
||||
if ctx.Resolved.Definition.ID != report.Storm {
|
||||
return Package{}, fmt.Errorf("storm briefing requires a storm report definition")
|
||||
}
|
||||
if ctx.Bundle == nil {
|
||||
return Package{}, fmt.Errorf("forecast bundle is required")
|
||||
}
|
||||
period := ctx.Resolved.ValidPeriod
|
||||
hourly := forecast.SelectHourlyPeriods(ctx.Bundle.Hourly, period)
|
||||
narrative := forecast.SelectNarrativePeriods(ctx.Bundle, period)
|
||||
daily := forecast.SelectHourlyPeriods(ctx.Bundle.Daily, period)
|
||||
alerts := forecast.AlertOverlaps(ctx.Bundle.Alerts, period)
|
||||
summary := forecast.SummarizeDaypart("storm window", period, hourly)
|
||||
summary.AlertOverlaps = alerts
|
||||
|
||||
storm := &Storm{
|
||||
TimingWindow: period,
|
||||
EventHeadlines: stormHeadlines(alerts),
|
||||
Hazards: stormHazards(alerts, summary),
|
||||
MostLikelyScenario: mostLikelyStormScenario(hourly, narrative, summary),
|
||||
ReasonableWorstCase: reasonableWorstCase(alerts, summary),
|
||||
ConfidenceInputs: stormConfidenceInputs(ctx.Bundle),
|
||||
WhatToWatchNext: stormWatchItems(alerts, summary, ctx.Bundle),
|
||||
RelevantAlerts: alerts,
|
||||
HourlyPeriods: hourly,
|
||||
DailyPeriods: daily,
|
||||
NarrativePeriods: narrative,
|
||||
WindowSummary: summary,
|
||||
Discussion: buildDiscussion(ctx.Bundle.Discussion),
|
||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||
}
|
||||
pkg := buildPackage(ctx)
|
||||
pkg.Storm = storm
|
||||
setRelevantAlertCount(&pkg.Metadata, len(alerts))
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
func stormHeadlines(alerts []forecast.AlertOverlap) []string {
|
||||
var headlines []string
|
||||
for _, alert := range alerts {
|
||||
if alert.Headline != "" {
|
||||
headlines = appendUnique(headlines, alert.Headline)
|
||||
continue
|
||||
}
|
||||
if alert.Event != "" {
|
||||
headlines = appendUnique(headlines, alert.Event)
|
||||
}
|
||||
}
|
||||
if len(headlines) == 0 {
|
||||
return []string{"No active alert headline overlaps the selected storm window."}
|
||||
}
|
||||
return headlines
|
||||
}
|
||||
|
||||
func stormHazards(alerts []forecast.AlertOverlap, summary forecast.DaypartSummary) []string {
|
||||
hazards := map[string]struct{}{}
|
||||
for _, alert := range alerts {
|
||||
if alert.Event != "" {
|
||||
hazards[alert.Event] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, hazard := range hazardsForIndicators(summary.Indicators) {
|
||||
hazards[hazard] = struct{}{}
|
||||
}
|
||||
if summary.MaxPrecipitationProbability != nil && summary.MaxPrecipitationProbability.Value >= 50 {
|
||||
hazards["precipitation"] = struct{}{}
|
||||
}
|
||||
if summary.PeakWindGust != nil && summary.PeakWindGust.Value >= 30 {
|
||||
hazards["wind"] = struct{}{}
|
||||
}
|
||||
out := sortedSet(hazards)
|
||||
if len(out) == 0 {
|
||||
return []string{"No storm-specific hazard signal stands out in the selected source data."}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mostLikelyStormScenario(hourly []forecast.ForecastPeriod, narrative []forecast.ForecastPeriod, summary forecast.DaypartSummary) []string {
|
||||
var items []string
|
||||
if summary.DominantCondition != "" {
|
||||
items = append(items, "Dominant hourly condition: "+summary.DominantCondition+".")
|
||||
}
|
||||
if summary.MaxPrecipitationProbability != nil {
|
||||
items = append(items, fmt.Sprintf("Peak precipitation chance is near %.0f%% around %s.", summary.MaxPrecipitationProbability.Value, summary.MaxPrecipitationProbability.Time.Format("15:04")))
|
||||
}
|
||||
if summary.PeakWindGust != nil {
|
||||
items = append(items, fmt.Sprintf("Peak wind gust is near %.0f mph around %s.", summary.PeakWindGust.Value, summary.PeakWindGust.Time.Format("15:04")))
|
||||
}
|
||||
for _, period := range narrative {
|
||||
if period.TextDescription != "" {
|
||||
items = append(items, "Narrative guidance: "+period.TextDescription)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(items) == 0 && len(hourly) > 0 {
|
||||
items = append(items, "Hourly forecast periods are available, but no focused storm signal is prominent.")
|
||||
}
|
||||
if len(items) == 0 {
|
||||
items = append(items, "No active storm signal is evident from the selected forecast window.")
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func reasonableWorstCase(alerts []forecast.AlertOverlap, summary forecast.DaypartSummary) []string {
|
||||
var items []string
|
||||
for _, alert := range alerts {
|
||||
label := alert.Event
|
||||
if label == "" {
|
||||
label = alert.Headline
|
||||
}
|
||||
if label != "" {
|
||||
items = appendUnique(items, "Alert scenario to consider: "+label+".")
|
||||
}
|
||||
}
|
||||
if summary.Indicators.Wind {
|
||||
items = appendUnique(items, "Wind impacts could be higher where stronger gusts occur.")
|
||||
}
|
||||
if summary.Indicators.Snow || summary.Indicators.Ice {
|
||||
items = appendUnique(items, "Wintry precipitation could create travel impacts if it overlaps the event window.")
|
||||
}
|
||||
if len(items) == 0 {
|
||||
items = append(items, "No clear reasonable worst-case signal is represented in the selected data.")
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func stormConfidenceInputs(bundle *forecast.Bundle) []string {
|
||||
var items []string
|
||||
if bundle == nil {
|
||||
return []string{"No source bundle was available for confidence context."}
|
||||
}
|
||||
if bundle.Discussion != nil {
|
||||
items = appendUnique(items, bundle.Discussion.KeyMessages...)
|
||||
if bundle.Discussion.ShortTerm != nil && bundle.Discussion.ShortTerm.Text != "" {
|
||||
items = appendUnique(items, "Short-term discussion is available for confidence context.")
|
||||
}
|
||||
}
|
||||
if bundle.WeatherStory != nil && len(bundle.WeatherStory.Raw) > 0 {
|
||||
items = appendUnique(items, "Weather story source is available.")
|
||||
}
|
||||
for _, warning := range bundle.Warnings {
|
||||
if warning.Code != "" {
|
||||
items = appendUnique(items, "Source warning: "+warning.Code+".")
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
items = append(items, "No explicit confidence or uncertainty signal was available from the selected source context.")
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func stormWatchItems(alerts []forecast.AlertOverlap, summary forecast.DaypartSummary, bundle *forecast.Bundle) []string {
|
||||
var items []string
|
||||
if len(alerts) > 0 {
|
||||
items = append(items, "Watch for alert extensions, cancellations, or upgrades.")
|
||||
}
|
||||
if summary.MaxPrecipitationProbability != nil {
|
||||
items = append(items, "Watch precipitation timing and probability trends.")
|
||||
}
|
||||
if summary.PeakWindGust != nil {
|
||||
items = append(items, "Watch wind gust trends.")
|
||||
}
|
||||
if bundle != nil && bundle.Discussion != nil {
|
||||
items = append(items, "Watch the next forecast discussion update for confidence changes.")
|
||||
}
|
||||
if len(items) == 0 {
|
||||
items = append(items, "Watch for new alerts or stronger wording if the weather pattern changes.")
|
||||
}
|
||||
return appendUnique(nil, items...)
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
|
||||
func TestStormBriefingWithActiveAlert(t *testing.T) {
|
||||
location := mustLocation(t)
|
||||
resolved, err := report.Resolve(report.Storm, report.ResolveRequest{
|
||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
||||
Location: location,
|
||||
StormStart: mustParse("2026-05-29T06:00:00-05:00"),
|
||||
StormEnd: mustParse("2026-05-29T12:00:00-05:00"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve storm: %v", err)
|
||||
}
|
||||
precip := 80.0
|
||||
gust := 42.0
|
||||
bundle := &forecast.Bundle{
|
||||
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{
|
||||
StartTime: mustParse("2026-05-29T07:00:00-05:00"),
|
||||
EndTime: mustParse("2026-05-29T08:00:00-05:00"),
|
||||
TextDescription: "Severe thunderstorms and gusty wind",
|
||||
ProbabilityOfPrecipitationPercent: &precip,
|
||||
WindGustMph: &gust,
|
||||
}}},
|
||||
Daily: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{
|
||||
StartTime: mustParse("2026-05-29T06:00:00-05:00"),
|
||||
EndTime: mustParse("2026-05-29T18:00:00-05:00"),
|
||||
TextDescription: "Storms likely.",
|
||||
}}},
|
||||
Narrative: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{
|
||||
StartTime: mustParse("2026-05-29T06:00:00-05:00"),
|
||||
EndTime: mustParse("2026-05-29T18:00:00-05:00"),
|
||||
TextDescription: "Damaging wind possible in stronger storms.",
|
||||
}}},
|
||||
Alerts: &forecast.AlertRun{Alerts: []json.RawMessage{
|
||||
json.RawMessage(`{"event":"Severe Thunderstorm Warning","headline":"Severe storms near Testville","severity":"Severe","effective":"2026-05-29T06:30:00-05:00","expires":"2026-05-29T08:30:00-05:00"}`),
|
||||
}},
|
||||
Discussion: &forecast.Discussion{
|
||||
Product: "discussion",
|
||||
KeyMessages: []string{"Storms may intensify quickly."},
|
||||
ShortTerm: &forecast.DiscussionSection{Text: "Short-term storm coverage peaks this morning."},
|
||||
LongTerm: &forecast.DiscussionSection{Text: "Long-term pattern stays unsettled after the event."},
|
||||
},
|
||||
WeatherStory: &forecast.WeatherStory{Raw: json.RawMessage(`{"headline":"Storm risk"}`)},
|
||||
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
|
||||
}
|
||||
|
||||
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildStorm() error = %v", err)
|
||||
}
|
||||
|
||||
if pkg.Metadata.ReportID != report.Storm {
|
||||
t.Fatalf("ReportID = %q, want storm", pkg.Metadata.ReportID)
|
||||
}
|
||||
if pkg.Storm == nil {
|
||||
t.Fatal("Storm = nil")
|
||||
}
|
||||
if len(pkg.Storm.RelevantAlerts) != 1 || len(pkg.Storm.EventHeadlines) != 1 {
|
||||
t.Fatalf("alerts/headlines = %#v/%#v, want alert inputs", pkg.Storm.RelevantAlerts, pkg.Storm.EventHeadlines)
|
||||
}
|
||||
if !pkg.Storm.TimingWindow.Start.Equal(resolved.ValidPeriod.Start) || !pkg.Storm.TimingWindow.End.Equal(resolved.ValidPeriod.End) {
|
||||
t.Fatalf("TimingWindow = %#v, want resolved valid period %#v", pkg.Storm.TimingWindow, resolved.ValidPeriod)
|
||||
}
|
||||
if !strings.Contains(strings.Join(pkg.Storm.Hazards, ","), "Severe Thunderstorm Warning") {
|
||||
t.Fatalf("Hazards = %#v, want alert event", pkg.Storm.Hazards)
|
||||
}
|
||||
if len(pkg.Storm.HourlyPeriods) != 1 || len(pkg.Storm.DailyPeriods) != 1 || len(pkg.Storm.NarrativePeriods) != 1 {
|
||||
t.Fatalf("selected periods hourly/daily/narrative = %d/%d/%d, want selected source periods", len(pkg.Storm.HourlyPeriods), len(pkg.Storm.DailyPeriods), len(pkg.Storm.NarrativePeriods))
|
||||
}
|
||||
if pkg.Storm.WeatherStory == nil {
|
||||
t.Fatal("WeatherStory = nil, want available story context")
|
||||
}
|
||||
if pkg.Storm.Discussion.ShortTerm != "Short-term storm coverage peaks this morning." {
|
||||
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Storm.Discussion.ShortTerm)
|
||||
}
|
||||
if pkg.Storm.Discussion.LongTerm != "Long-term pattern stays unsettled after the event." {
|
||||
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Storm.Discussion.LongTerm)
|
||||
}
|
||||
if len(pkg.Storm.WhatToWatchNext) == 0 {
|
||||
t.Fatal("WhatToWatchNext length = 0, want watch inputs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStormBriefingWithDiscussionButNoAlert(t *testing.T) {
|
||||
location := mustLocation(t)
|
||||
resolved, err := report.Resolve(report.Storm, report.ResolveRequest{
|
||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
||||
Location: location,
|
||||
StormStart: mustParse("2026-05-29T06:00:00-05:00"),
|
||||
StormEnd: mustParse("2026-05-29T12:00:00-05:00"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve storm: %v", err)
|
||||
}
|
||||
bundle := &forecast.Bundle{
|
||||
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{StartTime: mustParse("2026-05-29T07:00:00-05:00"), EndTime: mustParse("2026-05-29T08:00:00-05:00"), TextDescription: "Showers"}}},
|
||||
Alerts: &forecast.AlertRun{},
|
||||
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Confidence is moderate."}},
|
||||
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
|
||||
}
|
||||
|
||||
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildStorm() error = %v", err)
|
||||
}
|
||||
|
||||
if len(pkg.Storm.RelevantAlerts) != 0 {
|
||||
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Storm.RelevantAlerts))
|
||||
}
|
||||
if !strings.Contains(strings.Join(pkg.Storm.EventHeadlines, " "), "No active alert") {
|
||||
t.Fatalf("EventHeadlines = %#v, want no-alert fallback", pkg.Storm.EventHeadlines)
|
||||
}
|
||||
if !strings.Contains(strings.Join(pkg.Storm.ConfidenceInputs, " "), "Confidence is moderate") {
|
||||
t.Fatalf("ConfidenceInputs = %#v, want discussion key message", pkg.Storm.ConfidenceInputs)
|
||||
}
|
||||
if len(pkg.Storm.MostLikelyScenario) == 0 {
|
||||
t.Fatal("MostLikelyScenario length = 0, want forecast scenario inputs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStormBriefingQuietWindow(t *testing.T) {
|
||||
location := mustLocation(t)
|
||||
resolved, err := report.Resolve(report.Storm, report.ResolveRequest{
|
||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
||||
Location: location,
|
||||
StormStart: mustParse("2026-05-29T06:00:00-05:00"),
|
||||
StormEnd: mustParse("2026-05-29T12:00:00-05:00"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve storm: %v", err)
|
||||
}
|
||||
bundle := &forecast.Bundle{
|
||||
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{StartTime: mustParse("2026-05-29T07:00:00-05:00"), EndTime: mustParse("2026-05-29T08:00:00-05:00"), TextDescription: "Clear"}}},
|
||||
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
|
||||
}
|
||||
|
||||
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildStorm() error = %v", err)
|
||||
}
|
||||
|
||||
if len(pkg.Storm.Hazards) != 1 || !strings.Contains(pkg.Storm.Hazards[0], "No storm-specific") {
|
||||
t.Fatalf("Hazards = %#v, want quiet hazard fallback", pkg.Storm.Hazards)
|
||||
}
|
||||
if !strings.Contains(strings.Join(pkg.Storm.WhatToWatchNext, " "), "new alerts") {
|
||||
t.Fatalf("WhatToWatchNext = %#v, want watch fallback", pkg.Storm.WhatToWatchNext)
|
||||
}
|
||||
}
|
||||
@@ -7,108 +7,38 @@ import (
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
type Daily struct {
|
||||
BottomLine BottomLine `json:"bottomLine"`
|
||||
Dayparts []forecast.DaypartSummary `json:"dayparts"`
|
||||
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
|
||||
OutdoorWindows OutdoorWindows `json:"outdoorWindows"`
|
||||
Planning *TomorrowPlanning `json:"planning,omitempty"`
|
||||
NarrativePeriods []forecast.ForecastPeriod `json:"narrativePeriods,omitempty"`
|
||||
Discussion DiscussionContext `json:"discussion,omitempty"`
|
||||
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
|
||||
ForecastSummaryDate string `json:"forecastSummaryDate"`
|
||||
}
|
||||
|
||||
type BottomLine struct {
|
||||
Summary string `json:"summary"`
|
||||
Hazards []string `json:"hazards,omitempty"`
|
||||
Temperature forecast.Range `json:"temperature,omitempty"`
|
||||
MaxPrecipProbability *forecast.TimedValue `json:"maxPrecipitationProbability,omitempty"`
|
||||
PeakWindGust *forecast.TimedValue `json:"peakWindGust,omitempty"`
|
||||
}
|
||||
|
||||
type OutdoorWindows struct {
|
||||
Best *OutdoorWindow `json:"best,omitempty"`
|
||||
Worst *OutdoorWindow `json:"worst,omitempty"`
|
||||
Best *OutdoorWindow
|
||||
Worst *OutdoorWindow
|
||||
}
|
||||
|
||||
type OutdoorWindow struct {
|
||||
Daypart string `json:"daypart"`
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
Reasons []string `json:"reasons,omitempty"`
|
||||
Score float64 `json:"score"`
|
||||
Daypart string
|
||||
Period timeutil.Period
|
||||
Reasons []string
|
||||
Score float64
|
||||
}
|
||||
|
||||
type TomorrowPlanning struct {
|
||||
MorningReadiness []string `json:"morningReadiness,omitempty"`
|
||||
CommuteSchoolWorkdayConcerns []string `json:"commuteSchoolWorkdayConcerns,omitempty"`
|
||||
OvernightChangeWatch []string `json:"overnightChangeWatch,omitempty"`
|
||||
MorningReadiness []string
|
||||
CommuteSchoolWorkdayConcerns []string
|
||||
OvernightChangeWatch []string
|
||||
}
|
||||
|
||||
type DiscussionContext struct {
|
||||
Product string `json:"product,omitempty"`
|
||||
KeyMessages []string `json:"keyMessages,omitempty"`
|
||||
ShortTerm string `json:"shortTerm,omitempty"`
|
||||
LongTerm string `json:"longTerm,omitempty"`
|
||||
type morningCommuteOvernightPlanning struct {
|
||||
MorningReadiness []string
|
||||
CommuteSchoolWorkdayConcerns []string
|
||||
OvernightChangeWatch []string
|
||||
}
|
||||
|
||||
type WeatherStoryContext struct {
|
||||
Available bool `json:"available"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, error) {
|
||||
if ctx.Resolved.Definition.ID != report.DailyToday && ctx.Resolved.Definition.ID != report.DailyTomorrow {
|
||||
return Package{}, fmt.Errorf("daily briefing requires a daily report definition")
|
||||
}
|
||||
if summary == nil {
|
||||
return Package{}, fmt.Errorf("daily forecast summary is required")
|
||||
}
|
||||
pkg := buildPackage(ctx)
|
||||
pkg.Daily = &Daily{
|
||||
BottomLine: buildBottomLine(summary),
|
||||
Dayparts: summary.Dayparts,
|
||||
RelevantAlerts: summary.AlertOverlaps,
|
||||
OutdoorWindows: buildOutdoorWindows(summary.Dayparts),
|
||||
NarrativePeriods: summary.NarrativePeriods,
|
||||
Discussion: buildDiscussion(summary.Discussion),
|
||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||
ForecastSummaryDate: summary.Date,
|
||||
}
|
||||
setRelevantAlertCount(&pkg.Metadata, len(summary.AlertOverlaps))
|
||||
if ctx.Resolved.Definition.ID == report.DailyTomorrow {
|
||||
pkg.Daily.Planning = buildTomorrowPlanning(summary)
|
||||
}
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
func buildBottomLine(summary *forecast.DailySummary) BottomLine {
|
||||
bottomLine := BottomLine{}
|
||||
conditions := map[string]struct{}{}
|
||||
hazards := map[string]struct{}{}
|
||||
for _, daypart := range summary.Dayparts {
|
||||
addRange(&bottomLine.Temperature, daypart.Temperature)
|
||||
maxTimedValue(&bottomLine.MaxPrecipProbability, daypart.MaxPrecipitationProbability)
|
||||
maxTimedValue(&bottomLine.PeakWindGust, daypart.PeakWindGust)
|
||||
if daypart.DominantCondition != "" {
|
||||
conditions[daypart.DominantCondition] = struct{}{}
|
||||
}
|
||||
for _, hazard := range hazardsForIndicators(daypart.Indicators) {
|
||||
hazards[hazard] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, alert := range summary.AlertOverlaps {
|
||||
if alert.Event != "" {
|
||||
hazards[alert.Event] = struct{}{}
|
||||
}
|
||||
}
|
||||
bottomLine.Hazards = sortedSet(hazards)
|
||||
bottomLine.Summary = bottomLineText(sortedSet(conditions), bottomLine.Hazards)
|
||||
return bottomLine
|
||||
type TodayPlanning struct {
|
||||
MorningReadiness []string
|
||||
CommuteSchoolWorkdayConcerns []string
|
||||
OutdoorPlanning []string
|
||||
LateDayChangeWatch []string
|
||||
}
|
||||
|
||||
func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
|
||||
@@ -131,8 +61,60 @@ func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
|
||||
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 {
|
||||
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")
|
||||
if morning != nil {
|
||||
planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...)
|
||||
@@ -167,6 +149,18 @@ func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning {
|
||||
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 {
|
||||
notes := []string{}
|
||||
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 50 {
|
||||
@@ -187,6 +181,27 @@ func readinessNotes(daypart forecast.DaypartSummary) []string {
|
||||
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 {
|
||||
notes := []string{}
|
||||
prefix := titleWord(daypart.Name)
|
||||
@@ -240,30 +255,6 @@ func daypartNamed(dayparts []forecast.DaypartSummary, name string) *forecast.Day
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildDiscussion(discussion *forecast.Discussion) DiscussionContext {
|
||||
if discussion == nil {
|
||||
return DiscussionContext{}
|
||||
}
|
||||
ctx := DiscussionContext{
|
||||
Product: discussion.Product,
|
||||
KeyMessages: discussion.KeyMessages,
|
||||
}
|
||||
if discussion.ShortTerm != nil {
|
||||
ctx.ShortTerm = discussion.ShortTerm.Text
|
||||
}
|
||||
if discussion.LongTerm != nil {
|
||||
ctx.LongTerm = discussion.LongTerm.Text
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func buildWeatherStory(bundle *forecast.Bundle) *WeatherStoryContext {
|
||||
if bundle == nil || bundle.WeatherStory == nil || len(bundle.WeatherStory.Raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &WeatherStoryContext{Available: true, Summary: string(bundle.WeatherStory.Raw)}
|
||||
}
|
||||
|
||||
func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
|
||||
score := 0.0
|
||||
reasons := []string{}
|
||||
@@ -297,27 +288,12 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
|
||||
}
|
||||
return OutdoorWindow{
|
||||
Daypart: daypart.Name,
|
||||
Start: daypart.Period.Start.Format("15:04"),
|
||||
End: daypart.Period.End.Format("15:04"),
|
||||
Period: daypart.Period,
|
||||
Reasons: dedupe(reasons),
|
||||
Score: math.Round(score*10) / 10,
|
||||
}
|
||||
}
|
||||
|
||||
func bottomLineText(conditions []string, hazards []string) string {
|
||||
if len(conditions) == 0 && len(hazards) == 0 {
|
||||
return "Quiet weather is expected."
|
||||
}
|
||||
parts := []string{}
|
||||
if len(conditions) > 0 {
|
||||
parts = append(parts, "Conditions: "+strings.Join(conditions, "; "))
|
||||
}
|
||||
if len(hazards) > 0 {
|
||||
parts = append(parts, "Watch points: "+strings.Join(hazards, "; "))
|
||||
}
|
||||
return strings.Join(parts, ". ") + "."
|
||||
}
|
||||
|
||||
func hazardsForIndicators(indicators forecast.Indicators) []string {
|
||||
var hazards []string
|
||||
if indicators.Snow {
|
||||
@@ -1,128 +0,0 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
type ThreeDay struct {
|
||||
Days []OutlookDay `json:"days"`
|
||||
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
|
||||
Discussion DiscussionContext `json:"discussion,omitempty"`
|
||||
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
|
||||
}
|
||||
|
||||
type OutlookDay struct {
|
||||
Date string `json:"date"`
|
||||
Period timeutil.Period `json:"period"`
|
||||
OverallCharacter string `json:"overallCharacter"`
|
||||
Temperature forecast.Range `json:"temperature,omitempty"`
|
||||
MaxPrecipitationProbability *forecast.TimedValue `json:"maxPrecipitationProbability,omitempty"`
|
||||
PeakWindGust *forecast.TimedValue `json:"peakWindGust,omitempty"`
|
||||
Risks []string `json:"risks,omitempty"`
|
||||
OutdoorWindows OutdoorWindows `json:"outdoorWindows"`
|
||||
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
|
||||
Dayparts []forecast.DaypartSummary `json:"dayparts"`
|
||||
}
|
||||
|
||||
func BuildThreeDay(ctx BuildContext, summaries []forecast.DailySummary) (Package, error) {
|
||||
if ctx.Resolved.Definition.ID != report.ThreeDay {
|
||||
return Package{}, fmt.Errorf("3-day briefing requires a 3-day report definition")
|
||||
}
|
||||
if len(summaries) == 0 {
|
||||
return Package{}, fmt.Errorf("3-day forecast summaries are required")
|
||||
}
|
||||
pkg := buildPackage(ctx)
|
||||
pkg.ThreeDay = &ThreeDay{
|
||||
Discussion: buildDiscussion(summaries[0].Discussion),
|
||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||
}
|
||||
for _, summary := range summaries {
|
||||
day := buildOutlookDay(summary)
|
||||
pkg.ThreeDay.Days = append(pkg.ThreeDay.Days, day)
|
||||
}
|
||||
pkg.ThreeDay.RelevantAlerts = collectOutlookAlerts(pkg.ThreeDay.Days)
|
||||
setRelevantAlertCount(&pkg.Metadata, len(pkg.ThreeDay.RelevantAlerts))
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
func collectOutlookAlerts(days []OutlookDay) []forecast.AlertOverlap {
|
||||
alerts := map[string]forecast.AlertOverlap{}
|
||||
for _, day := range days {
|
||||
for _, alert := range day.RelevantAlerts {
|
||||
key := alert.Event
|
||||
if key == "" {
|
||||
key = alert.Headline
|
||||
}
|
||||
if key != "" {
|
||||
alerts[key] = alert
|
||||
}
|
||||
}
|
||||
}
|
||||
keys := make([]string, 0, len(alerts))
|
||||
for key := range alerts {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
out := make([]forecast.AlertOverlap, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
out = append(out, alerts[key])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildOutlookDay(summary forecast.DailySummary) OutlookDay {
|
||||
day := OutlookDay{
|
||||
Date: summary.Date,
|
||||
Period: summary.Period,
|
||||
OutdoorWindows: buildOutdoorWindows(summary.Dayparts),
|
||||
RelevantAlerts: summary.AlertOverlaps,
|
||||
Dayparts: summary.Dayparts,
|
||||
}
|
||||
conditions := map[string]struct{}{}
|
||||
risks := map[string]struct{}{}
|
||||
for _, daypart := range summary.Dayparts {
|
||||
addRange(&day.Temperature, daypart.Temperature)
|
||||
maxTimedValue(&day.MaxPrecipitationProbability, daypart.MaxPrecipitationProbability)
|
||||
maxTimedValue(&day.PeakWindGust, daypart.PeakWindGust)
|
||||
if daypart.DominantCondition != "" {
|
||||
conditions[daypart.DominantCondition] = struct{}{}
|
||||
}
|
||||
for _, risk := range hazardsForIndicators(daypart.Indicators) {
|
||||
risks[risk] = struct{}{}
|
||||
}
|
||||
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 50 {
|
||||
risks["precipitation"] = struct{}{}
|
||||
}
|
||||
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
||||
risks["wind"] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, alert := range summary.AlertOverlaps {
|
||||
if alert.Event != "" {
|
||||
risks[alert.Event] = struct{}{}
|
||||
}
|
||||
}
|
||||
day.Risks = sortedSet(risks)
|
||||
day.OverallCharacter = outlookCharacter(sortedSet(conditions), day.Risks)
|
||||
return day
|
||||
}
|
||||
|
||||
func outlookCharacter(conditions []string, risks []string) string {
|
||||
if len(conditions) == 0 && len(risks) == 0 {
|
||||
return "Quiet weather is expected."
|
||||
}
|
||||
parts := []string{}
|
||||
if len(conditions) > 0 {
|
||||
parts = append(parts, strings.Join(conditions, "; "))
|
||||
}
|
||||
if len(risks) > 0 {
|
||||
parts = append(parts, "risks: "+strings.Join(risks, "; "))
|
||||
}
|
||||
return strings.Join(parts, ". ") + "."
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
func TestThreeDayBriefingBuildsOutlookDays(t *testing.T) {
|
||||
location := mustLocation(t)
|
||||
resolved, err := report.Resolve(report.ThreeDay, report.ResolveRequest{
|
||||
Now: mustParse("2026-05-29T06:00:00-05:00"),
|
||||
Location: location,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve 3-day: %v", err)
|
||||
}
|
||||
precip := 70.0
|
||||
gust := 35.0
|
||||
summaries := []forecast.DailySummary{
|
||||
{
|
||||
Date: "2026-05-29",
|
||||
Period: timeutil.Period{
|
||||
Start: mustParse("2026-05-29T06:00:00-05:00"),
|
||||
End: mustParse("2026-05-30T00:00:00-05:00"),
|
||||
},
|
||||
Dayparts: []forecast.DaypartSummary{
|
||||
{
|
||||
Name: "morning",
|
||||
DominantCondition: "Showers and thunderstorms",
|
||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
||||
Value: precip,
|
||||
Time: mustParse("2026-05-29T09:00:00-05:00"),
|
||||
},
|
||||
PeakWindGust: &forecast.TimedValue{
|
||||
Value: gust,
|
||||
Time: mustParse("2026-05-29T10:00:00-05:00"),
|
||||
},
|
||||
Indicators: forecast.Indicators{Wind: true},
|
||||
},
|
||||
},
|
||||
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
|
||||
Discussion: &forecast.Discussion{
|
||||
Product: "discussion",
|
||||
KeyMessages: []string{"Unsettled stretch."},
|
||||
ShortTerm: &forecast.DiscussionSection{Text: "Short-term rain chances remain focused today."},
|
||||
LongTerm: &forecast.DiscussionSection{Text: "Long-term warmth builds into the weekend."},
|
||||
},
|
||||
},
|
||||
{
|
||||
Date: "2026-05-30",
|
||||
Period: timeutil.Period{
|
||||
Start: mustParse("2026-05-30T00:00:00-05:00"),
|
||||
End: mustParse("2026-05-31T00:00:00-05:00"),
|
||||
},
|
||||
Dayparts: []forecast.DaypartSummary{{Name: "afternoon", DominantCondition: "Clear"}},
|
||||
},
|
||||
}
|
||||
|
||||
pkg, err := BuildThreeDay(BuildContext{
|
||||
Resolved: resolved,
|
||||
Units: "us",
|
||||
Timezone: "America/Chicago",
|
||||
}, summaries)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildThreeDay() error = %v", err)
|
||||
}
|
||||
|
||||
if pkg.Metadata.ReportID != report.ThreeDay {
|
||||
t.Fatalf("ReportID = %q, want three_day", pkg.Metadata.ReportID)
|
||||
}
|
||||
if pkg.ThreeDay == nil {
|
||||
t.Fatal("ThreeDay = nil")
|
||||
}
|
||||
if len(pkg.ThreeDay.Days) != 2 {
|
||||
t.Fatalf("Days length = %d, want 2", len(pkg.ThreeDay.Days))
|
||||
}
|
||||
first := pkg.ThreeDay.Days[0]
|
||||
if !strings.Contains(first.OverallCharacter, "Showers") || !strings.Contains(strings.Join(first.Risks, ","), "wind") {
|
||||
t.Fatalf("first day = %#v, want conditions and risks", first)
|
||||
}
|
||||
if len(pkg.ThreeDay.RelevantAlerts) != 1 {
|
||||
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.ThreeDay.RelevantAlerts))
|
||||
}
|
||||
if pkg.ThreeDay.Discussion.ShortTerm != "Short-term rain chances remain focused today." {
|
||||
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.ThreeDay.Discussion.ShortTerm)
|
||||
}
|
||||
if pkg.ThreeDay.Discussion.LongTerm != "Long-term warmth builds into the weekend." {
|
||||
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.ThreeDay.Discussion.LongTerm)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
24
internal/briefing/tomorrow_planning_module.go
Normal file
24
internal/briefing/tomorrow_planning_module.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package briefing
|
||||
|
||||
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
|
||||
type TomorrowPlanningModule struct {
|
||||
MorningReadiness []string `json:"morning_readiness,omitempty"`
|
||||
CommuteSchoolWorkdayConcerns []string `json:"commute_school_workday_concerns,omitempty"`
|
||||
OvernightChangeWatch []string `json:"overnight_change_watch,omitempty"`
|
||||
}
|
||||
|
||||
func buildTomorrowPlanningModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
summary := ctx.Derived.FirstDailySummary()
|
||||
if summary == nil {
|
||||
return &module.Output{ID: module.TomorrowPlanning, StanzaName: "tomorrow_planning", Value: TomorrowPlanningModule{}}, nil
|
||||
}
|
||||
planning := buildTomorrowPlanning(summary)
|
||||
value := TomorrowPlanningModule{}
|
||||
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.TomorrowPlanning, StanzaName: "tomorrow_planning", Value: value}, nil
|
||||
}
|
||||
44
internal/briefing/weather_story_module.go
Normal file
44
internal/briefing/weather_story_module.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
type WeatherStoryModule struct {
|
||||
Available bool `json:"available"`
|
||||
OfficeID string `json:"office_id,omitempty"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
AltText string `json:"alt_text,omitempty"`
|
||||
Priority bool `json:"priority"`
|
||||
Order int `json:"order"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
}
|
||||
|
||||
func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
story := ctx.Collected.WeatherStory
|
||||
if story == nil {
|
||||
return nil, nil
|
||||
}
|
||||
period := timeutil.Period{Start: story.StartTime, End: story.EndTime}
|
||||
value := WeatherStoryModule{
|
||||
Available: true,
|
||||
OfficeID: story.OfficeID,
|
||||
PeriodBegins: friendlyPeriodBeginsLabel(period, ctx.Timezone),
|
||||
PeriodEnds: friendlyPeriodEndsLabel(period, ctx.Timezone),
|
||||
UpdatedAt: copyTime(story.UpdatedAt),
|
||||
Title: story.Title,
|
||||
Description: story.Description,
|
||||
AltText: story.AltText,
|
||||
Priority: story.Priority,
|
||||
Order: story.Order,
|
||||
DownloadURL: story.DownloadURL,
|
||||
}
|
||||
return &module.Output{ID: module.WeatherStory, StanzaName: "weather_story", Value: value}, nil
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
|
||||
type Weekend struct {
|
||||
Days []OutlookDay `json:"days"`
|
||||
Planning WeekendPlanning `json:"planning"`
|
||||
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
|
||||
Discussion DiscussionContext `json:"discussion,omitempty"`
|
||||
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
|
||||
}
|
||||
|
||||
type WeekendPlanning struct {
|
||||
BestOutdoorWindows []OutdoorWindow `json:"bestOutdoorWindows,omitempty"`
|
||||
WorstWeatherWindows []OutdoorWindow `json:"worstWeatherWindows,omitempty"`
|
||||
RainStormTiming []string `json:"rainStormTiming,omitempty"`
|
||||
ComfortConcerns []string `json:"comfortConcerns,omitempty"`
|
||||
UncertaintyInputs []string `json:"uncertaintyInputs,omitempty"`
|
||||
}
|
||||
|
||||
func BuildWeekend(ctx BuildContext, summaries []forecast.DailySummary) (Package, error) {
|
||||
if ctx.Resolved.Definition.ID != report.Weekend {
|
||||
return Package{}, fmt.Errorf("weekend briefing requires a weekend report definition")
|
||||
}
|
||||
if len(summaries) == 0 {
|
||||
return Package{}, fmt.Errorf("weekend forecast summaries are required")
|
||||
}
|
||||
pkg := buildPackage(ctx)
|
||||
pkg.Weekend = &Weekend{
|
||||
Discussion: buildDiscussion(summaries[0].Discussion),
|
||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||
}
|
||||
for _, summary := range summaries {
|
||||
pkg.Weekend.Days = append(pkg.Weekend.Days, buildOutlookDay(summary))
|
||||
}
|
||||
pkg.Weekend.RelevantAlerts = collectOutlookAlerts(pkg.Weekend.Days)
|
||||
setRelevantAlertCount(&pkg.Metadata, len(pkg.Weekend.RelevantAlerts))
|
||||
pkg.Weekend.Planning = buildWeekendPlanning(pkg.Weekend.Days, pkg.Weekend.Discussion, ctx.Bundle)
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
func buildWeekendPlanning(days []OutlookDay, discussion DiscussionContext, bundle *forecast.Bundle) WeekendPlanning {
|
||||
planning := WeekendPlanning{}
|
||||
for _, day := range days {
|
||||
if day.OutdoorWindows.Best != nil {
|
||||
window := *day.OutdoorWindows.Best
|
||||
window.Daypart = day.Date + " " + window.Daypart
|
||||
planning.BestOutdoorWindows = append(planning.BestOutdoorWindows, window)
|
||||
}
|
||||
if day.OutdoorWindows.Worst != nil {
|
||||
window := *day.OutdoorWindows.Worst
|
||||
window.Daypart = day.Date + " " + window.Daypart
|
||||
planning.WorstWeatherWindows = append(planning.WorstWeatherWindows, window)
|
||||
}
|
||||
for _, daypart := range day.Dayparts {
|
||||
planning.RainStormTiming = appendUnique(planning.RainStormTiming, weekendRainStormNotes(day.Date, daypart)...)
|
||||
planning.ComfortConcerns = appendUnique(planning.ComfortConcerns, weekendComfortNotes(day.Date, daypart)...)
|
||||
}
|
||||
}
|
||||
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, discussion.KeyMessages...)
|
||||
if discussion.ShortTerm != "" {
|
||||
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, "Short-term discussion available for confidence context.")
|
||||
}
|
||||
if discussion.LongTerm != "" {
|
||||
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, "Long-term discussion available for uncertainty context.")
|
||||
}
|
||||
if bundle != nil {
|
||||
for _, warning := range bundle.Warnings {
|
||||
if warning.Code != "" {
|
||||
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, "Source warning: "+warning.Code+".")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(planning.RainStormTiming) == 0 {
|
||||
planning.RainStormTiming = append(planning.RainStormTiming, "No focused rain or storm timing stands out in the available weekend forecast.")
|
||||
}
|
||||
if len(planning.ComfortConcerns) == 0 {
|
||||
planning.ComfortConcerns = append(planning.ComfortConcerns, "No major heat, cold, or wind comfort concern stands out in the available weekend forecast.")
|
||||
}
|
||||
if len(planning.UncertaintyInputs) == 0 {
|
||||
planning.UncertaintyInputs = append(planning.UncertaintyInputs, "No explicit confidence or uncertainty signal was available from the selected source context.")
|
||||
}
|
||||
return planning
|
||||
}
|
||||
|
||||
func weekendRainStormNotes(date string, daypart forecast.DaypartSummary) []string {
|
||||
notes := []string{}
|
||||
label := weekendWindowLabel(date, daypart.Name)
|
||||
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 {
|
||||
notes = append(notes, fmt.Sprintf("%s precipitation chance peaks near %.0f%%.", label, daypart.MaxPrecipitationProbability.Value))
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
func weekendComfortNotes(date string, daypart forecast.DaypartSummary) []string {
|
||||
notes := []string{}
|
||||
label := weekendWindowLabel(date, daypart.Name)
|
||||
if daypart.Indicators.Heat {
|
||||
notes = append(notes, label+" heat may affect outdoor comfort.")
|
||||
}
|
||||
if daypart.Indicators.Cold {
|
||||
notes = append(notes, label+" cold may affect outdoor comfort.")
|
||||
}
|
||||
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 25 {
|
||||
notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", label, daypart.PeakWindGust.Value))
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
func weekendWindowLabel(date string, daypart string) string {
|
||||
if daypart == "" {
|
||||
return date
|
||||
}
|
||||
return strings.TrimSpace(date + " " + daypart)
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) {
|
||||
location := mustLocation(t)
|
||||
resolved, err := report.Resolve(report.Weekend, report.ResolveRequest{
|
||||
Now: mustParse("2026-05-29T08:00:00-05:00"),
|
||||
Location: location,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve weekend: %v", err)
|
||||
}
|
||||
precip := 70.0
|
||||
gust := 32.0
|
||||
summaries := []forecast.DailySummary{
|
||||
{
|
||||
Date: "2026-05-30",
|
||||
Period: timeutil.Period{
|
||||
Start: mustParse("2026-05-30T00:00:00-05:00"),
|
||||
End: mustParse("2026-05-31T00:00:00-05:00"),
|
||||
},
|
||||
Dayparts: []forecast.DaypartSummary{
|
||||
{
|
||||
Name: "afternoon",
|
||||
DominantCondition: "Showers and thunderstorms",
|
||||
Period: timeutil.Period{
|
||||
Start: mustParse("2026-05-30T12:00:00-05:00"),
|
||||
End: mustParse("2026-05-30T18:00:00-05:00"),
|
||||
},
|
||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
||||
Value: precip,
|
||||
Time: mustParse("2026-05-30T15:00:00-05:00"),
|
||||
},
|
||||
PeakWindGust: &forecast.TimedValue{
|
||||
Value: gust,
|
||||
Time: mustParse("2026-05-30T16:00:00-05:00"),
|
||||
},
|
||||
Indicators: forecast.Indicators{Wind: true},
|
||||
HourlyPeriods: []forecast.ForecastPeriod{
|
||||
{
|
||||
StartTime: mustParse("2026-05-30T15:00:00-05:00"),
|
||||
EndTime: mustParse("2026-05-30T16:00:00-05:00"),
|
||||
TextDescription: "Showers and thunderstorms",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
|
||||
Discussion: &forecast.Discussion{
|
||||
Product: "discussion",
|
||||
KeyMessages: []string{"Timing may shift."},
|
||||
ShortTerm: &forecast.DiscussionSection{Text: "Short-term showers exit before the weekend."},
|
||||
LongTerm: &forecast.DiscussionSection{Text: "Long-term weekend rain timing remains uncertain."},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
pkg, err := BuildWeekend(BuildContext{
|
||||
Resolved: resolved,
|
||||
Units: "us",
|
||||
Timezone: "America/Chicago",
|
||||
}, summaries)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWeekend() error = %v", err)
|
||||
}
|
||||
|
||||
if pkg.Metadata.ReportID != report.Weekend {
|
||||
t.Fatalf("ReportID = %q, want weekend", pkg.Metadata.ReportID)
|
||||
}
|
||||
if pkg.Weekend == nil {
|
||||
t.Fatal("Weekend = nil")
|
||||
}
|
||||
if len(pkg.Weekend.Days) != 1 {
|
||||
t.Fatalf("Days length = %d, want 1", len(pkg.Weekend.Days))
|
||||
}
|
||||
if len(pkg.Weekend.Planning.WorstWeatherWindows) == 0 {
|
||||
t.Fatalf("WorstWeatherWindows = %#v, want weather window", pkg.Weekend.Planning.WorstWeatherWindows)
|
||||
}
|
||||
if !strings.Contains(strings.Join(pkg.Weekend.Planning.RainStormTiming, " "), "precipitation") {
|
||||
t.Fatalf("RainStormTiming = %#v, want precipitation timing", pkg.Weekend.Planning.RainStormTiming)
|
||||
}
|
||||
if len(pkg.Weekend.Planning.UncertaintyInputs) == 0 {
|
||||
t.Fatal("UncertaintyInputs length = 0, want discussion context")
|
||||
}
|
||||
if pkg.Weekend.Discussion.ShortTerm != "Short-term showers exit before the weekend." {
|
||||
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Weekend.Discussion.ShortTerm)
|
||||
}
|
||||
if pkg.Weekend.Discussion.LongTerm != "Long-term weekend rain timing remains uncertain." {
|
||||
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Weekend.Discussion.LongTerm)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
// Package changes compares structured briefing snapshots.
|
||||
// Package changes compares structured module snapshots.
|
||||
package changes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
)
|
||||
|
||||
type Thresholds struct {
|
||||
@@ -25,83 +26,170 @@ type Change struct {
|
||||
Current string `json:"current,omitempty"`
|
||||
}
|
||||
|
||||
func CompareDaily(previous briefing.Package, current briefing.Package, thresholds Thresholds) ([]Change, error) {
|
||||
if previous.Daily == nil {
|
||||
return nil, fmt.Errorf("previous daily briefing is required")
|
||||
func CompareDaily(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) {
|
||||
previousSummary, err := requiredStanza[dailySummaryStanza](previous, "derived_daily_summary")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("previous daily summary: %w", err)
|
||||
}
|
||||
if current.Daily == nil {
|
||||
return nil, fmt.Errorf("current daily briefing is required")
|
||||
currentSummary, err := requiredStanza[dailySummaryStanza](current, "derived_daily_summary")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("current daily summary: %w", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
previousAlerts, _, err := module.StanzaValue[alertDigestStanza](previous, "alert_digest")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
currentAlerts, _, err := module.StanzaValue[alertDigestStanza](current, "alert_digest")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
previousTiming, previousHasTiming, err := module.StanzaValue[precipTimingStanza](previous, "precip_timing")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
currentTiming, currentHasTiming, err := module.StanzaValue[precipTimingStanza](current, "precip_timing")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var changes []Change
|
||||
changes = append(changes, compareTemperature(previous.Daily.BottomLine.Temperature, current.Daily.BottomLine.Temperature, thresholds.TemperatureDegrees)...)
|
||||
changes = append(changes, comparePrecipitation(previous.Daily.BottomLine.MaxPrecipProbability, current.Daily.BottomLine.MaxPrecipProbability, thresholds)...)
|
||||
changes = append(changes, compareWind(previous.Daily.BottomLine.PeakWindGust, current.Daily.BottomLine.PeakWindGust, float64(thresholds.WindGustMilesPerHour))...)
|
||||
changes = append(changes, compareAlerts(previous.Daily.RelevantAlerts, current.Daily.RelevantAlerts)...)
|
||||
changes = append(changes, compareIndicators(aggregateIndicators(previous.Daily.Dayparts), aggregateIndicators(current.Daily.Dayparts))...)
|
||||
changes = append(changes, compareTemperatureValues("Low", previousSummary.LowTempF, currentSummary.LowTempF, thresholds.TemperatureDegrees)...)
|
||||
changes = append(changes, compareTemperatureValues("High", previousSummary.HighTempF, currentSummary.HighTempF, thresholds.TemperatureDegrees)...)
|
||||
changes = append(changes, comparePrecipitationValues(previousSummary.DailyPrecipitationProbability, currentSummary.DailyPrecipitationProbability, thresholds.PrecipProbabilityPoints, "")...)
|
||||
if previousHasTiming && currentHasTiming {
|
||||
changes = append(changes, comparePrecipTiming(previousTiming.MaxPopTime, currentTiming.MaxPopTime, thresholds.PrecipTimingShiftMinutes, "")...)
|
||||
}
|
||||
changes = append(changes, compareWindValues(previousSummary.MaxWindGustMph, currentSummary.MaxWindGustMph, thresholds.WindGustMilesPerHour, "")...)
|
||||
changes = append(changes, compareAlerts(previousAlerts.Relevant, currentAlerts.Relevant)...)
|
||||
changes = append(changes, compareIndicators(aggregateIndicators(previousDayparts), aggregateIndicators(currentDayparts), "")...)
|
||||
sortChanges(changes)
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
func compareTemperature(previous forecast.Range, current forecast.Range, threshold float64) []Change {
|
||||
var changes []Change
|
||||
if previous.Min != nil && current.Min != nil && differenceAtLeast(*previous.Min, *current.Min, threshold) {
|
||||
changes = append(changes, Change{
|
||||
Type: "temperature_shift",
|
||||
Message: fmt.Sprintf("Low temperature changed from %.0f to %.0f.", *previous.Min, *current.Min),
|
||||
Previous: fmt.Sprintf("%.0f", *previous.Min),
|
||||
Current: fmt.Sprintf("%.0f", *current.Min),
|
||||
})
|
||||
}
|
||||
if previous.Max != nil && current.Max != nil && differenceAtLeast(*previous.Max, *current.Max, threshold) {
|
||||
changes = append(changes, Change{
|
||||
Type: "temperature_shift",
|
||||
Message: fmt.Sprintf("High temperature changed from %.0f to %.0f.", *previous.Max, *current.Max),
|
||||
Previous: fmt.Sprintf("%.0f", *previous.Max),
|
||||
Current: fmt.Sprintf("%.0f", *current.Max),
|
||||
})
|
||||
}
|
||||
return changes
|
||||
type dailySummaryStanza struct {
|
||||
Date string `json:"date,omitempty"`
|
||||
HighTempF *int `json:"high_temp_f,omitempty"`
|
||||
LowTempF *int `json:"low_temp_f,omitempty"`
|
||||
DailyPrecipitationProbability *int `json:"daily_precipitation_probability,omitempty"`
|
||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||
}
|
||||
|
||||
func comparePrecipitation(previous *forecast.TimedValue, current *forecast.TimedValue, thresholds Thresholds) []Change {
|
||||
type daypartSummaryStanza struct {
|
||||
Date string `json:"date,omitempty"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||
Snow bool `json:"snow,omitempty"`
|
||||
Ice bool `json:"ice,omitempty"`
|
||||
}
|
||||
|
||||
type precipTimingStanza struct {
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||
}
|
||||
|
||||
type alertDigestStanza struct {
|
||||
Relevant []alertSummaryStanza `json:"relevant,omitempty"`
|
||||
}
|
||||
|
||||
type alertSummaryStanza struct {
|
||||
Event string `json:"event,omitempty"`
|
||||
Headline string `json:"headline,omitempty"`
|
||||
}
|
||||
|
||||
type indicators struct {
|
||||
Snow bool
|
||||
Ice bool
|
||||
}
|
||||
|
||||
func requiredStanza[T any](snapshot module.Snapshot, name string) (T, error) {
|
||||
value, ok, err := module.StanzaValue[T](snapshot, name)
|
||||
if err != nil {
|
||||
return value, err
|
||||
}
|
||||
if !ok {
|
||||
return value, fmt.Errorf("stanza %q is required", name)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func compareTemperatureValues(label string, previous *int, current *int, threshold float64) []Change {
|
||||
if previous == nil || current == nil {
|
||||
return nil
|
||||
}
|
||||
var changes []Change
|
||||
previousCategory := precipitationCategory(previous.Value)
|
||||
currentCategory := precipitationCategory(current.Value)
|
||||
if previousCategory != currentCategory || differenceAtLeast(previous.Value, current.Value, float64(thresholds.PrecipProbabilityPoints)) {
|
||||
changes = append(changes, Change{
|
||||
Type: "precip_probability_change",
|
||||
Message: fmt.Sprintf("Peak precipitation chance changed from %.0f%% (%s) to %.0f%% (%s).", previous.Value, previousCategory, current.Value, currentCategory),
|
||||
Previous: fmt.Sprintf("%.0f%% %s", previous.Value, previousCategory),
|
||||
Current: fmt.Sprintf("%.0f%% %s", current.Value, currentCategory),
|
||||
})
|
||||
}
|
||||
shiftMinutes := int(math.Abs(current.Time.Sub(previous.Time).Minutes()))
|
||||
if thresholds.PrecipTimingShiftMinutes > 0 && shiftMinutes >= thresholds.PrecipTimingShiftMinutes {
|
||||
changes = append(changes, Change{
|
||||
Type: "precip_timing_shift",
|
||||
Message: fmt.Sprintf("Peak precipitation timing shifted from %s to %s.", clock(previous.Time), clock(current.Time)),
|
||||
Previous: clock(previous.Time),
|
||||
Current: clock(current.Time),
|
||||
})
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
func compareWind(previous *forecast.TimedValue, current *forecast.TimedValue, threshold float64) []Change {
|
||||
if previous == nil || current == nil || !differenceAtLeast(previous.Value, current.Value, threshold) {
|
||||
if !differenceAtLeast(float64(*previous), float64(*current), threshold) {
|
||||
return nil
|
||||
}
|
||||
return []Change{{
|
||||
Type: "wind_gust_change",
|
||||
Message: fmt.Sprintf("Peak wind gust changed from %.0f mph to %.0f mph.", previous.Value, current.Value),
|
||||
Previous: fmt.Sprintf("%.0f mph", previous.Value),
|
||||
Current: fmt.Sprintf("%.0f mph", current.Value),
|
||||
Type: "temperature_shift",
|
||||
Message: fmt.Sprintf("%s temperature changed from %d to %d.", label, *previous, *current),
|
||||
Previous: fmt.Sprintf("%d", *previous),
|
||||
Current: fmt.Sprintf("%d", *current),
|
||||
}}
|
||||
}
|
||||
|
||||
func compareAlerts(previous []forecast.AlertOverlap, current []forecast.AlertOverlap) []Change {
|
||||
func comparePrecipitationValues(previous *int, current *int, threshold int, prefix string) []Change {
|
||||
if previous == nil || current == nil {
|
||||
return nil
|
||||
}
|
||||
previousCategory := precipitationCategory(float64(*previous))
|
||||
currentCategory := precipitationCategory(float64(*current))
|
||||
if previousCategory == currentCategory && !differenceAtLeast(float64(*previous), float64(*current), float64(threshold)) {
|
||||
return nil
|
||||
}
|
||||
changeType := prefix + "precip_probability_change"
|
||||
return []Change{{
|
||||
Type: changeType,
|
||||
Message: fmt.Sprintf("Peak precipitation chance changed from %d%% (%s) to %d%% (%s).", *previous, previousCategory, *current, currentCategory),
|
||||
Previous: fmt.Sprintf("%d%% %s", *previous, previousCategory),
|
||||
Current: fmt.Sprintf("%d%% %s", *current, currentCategory),
|
||||
}}
|
||||
}
|
||||
|
||||
func comparePrecipTiming(previous string, current string, thresholdMinutes int, prefix string) []Change {
|
||||
if thresholdMinutes <= 0 || previous == "" || current == "" || previous == current {
|
||||
return nil
|
||||
}
|
||||
previousTime, previousOK := parseClock(previous)
|
||||
currentTime, currentOK := parseClock(current)
|
||||
if !previousOK || !currentOK {
|
||||
return nil
|
||||
}
|
||||
if int(math.Abs(currentTime.Sub(previousTime).Minutes())) < thresholdMinutes {
|
||||
return nil
|
||||
}
|
||||
return []Change{{
|
||||
Type: prefix + "precip_timing_shift",
|
||||
Message: fmt.Sprintf("Peak precipitation timing shifted from %s to %s.", previous, current),
|
||||
Previous: previous,
|
||||
Current: current,
|
||||
}}
|
||||
}
|
||||
|
||||
func compareWindValues(previous *int, current *int, threshold int, prefix string) []Change {
|
||||
if previous == nil || current == nil || !differenceAtLeast(float64(*previous), float64(*current), float64(threshold)) {
|
||||
return nil
|
||||
}
|
||||
return []Change{{
|
||||
Type: prefix + "wind_gust_change",
|
||||
Message: fmt.Sprintf("Peak wind gust changed from %d mph to %d mph.", *previous, *current),
|
||||
Previous: fmt.Sprintf("%d mph", *previous),
|
||||
Current: fmt.Sprintf("%d mph", *current),
|
||||
}}
|
||||
}
|
||||
|
||||
func compareAlerts(previous []alertSummaryStanza, current []alertSummaryStanza) []Change {
|
||||
previousSet := alertSet(previous)
|
||||
currentSet := alertSet(current)
|
||||
var changes []Change
|
||||
@@ -119,7 +207,7 @@ func compareAlerts(previous []forecast.AlertOverlap, current []forecast.AlertOve
|
||||
return changes
|
||||
}
|
||||
|
||||
func compareIndicators(previous forecast.Indicators, current forecast.Indicators) []Change {
|
||||
func compareIndicators(previous indicators, current indicators, prefix string) []Change {
|
||||
var changes []Change
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
@@ -132,7 +220,7 @@ func compareIndicators(previous forecast.Indicators, current forecast.Indicators
|
||||
if item.previous == item.current {
|
||||
continue
|
||||
}
|
||||
changeType := item.name + "_risk_change"
|
||||
changeType := prefix + item.name + "_risk_change"
|
||||
if item.current {
|
||||
changes = append(changes, Change{Type: changeType, Message: fmt.Sprintf("%s risk is now present.", item.name), Current: "present"})
|
||||
} else {
|
||||
@@ -142,16 +230,16 @@ func compareIndicators(previous forecast.Indicators, current forecast.Indicators
|
||||
return changes
|
||||
}
|
||||
|
||||
func aggregateIndicators(dayparts []forecast.DaypartSummary) forecast.Indicators {
|
||||
out := forecast.Indicators{}
|
||||
func aggregateIndicators(dayparts map[string]daypartSummaryStanza) indicators {
|
||||
out := indicators{}
|
||||
for _, daypart := range dayparts {
|
||||
out.Snow = out.Snow || daypart.Indicators.Snow
|
||||
out.Ice = out.Ice || daypart.Indicators.Ice
|
||||
out.Snow = out.Snow || daypart.Snow
|
||||
out.Ice = out.Ice || daypart.Ice
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func alertSet(alerts []forecast.AlertOverlap) map[string]struct{} {
|
||||
func alertSet(alerts []alertSummaryStanza) map[string]struct{} {
|
||||
out := map[string]struct{}{}
|
||||
for _, alert := range alerts {
|
||||
event := alert.Event
|
||||
@@ -185,10 +273,6 @@ func differenceAtLeast(previous float64, current float64, threshold float64) boo
|
||||
return math.Abs(current-previous) >= threshold
|
||||
}
|
||||
|
||||
func clock(t time.Time) string {
|
||||
return t.Format("15:04")
|
||||
}
|
||||
|
||||
func sortChanges(items []Change) {
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if items[i].Type == items[j].Type {
|
||||
@@ -197,3 +281,33 @@ func sortChanges(items []Change) {
|
||||
return items[i].Type < items[j].Type
|
||||
})
|
||||
}
|
||||
|
||||
func parseClock(value string) (time.Time, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
for _, layout := range []string{"3 PM", "3:04 PM", "15:04"} {
|
||||
if parsed, err := time.Parse(layout, value); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func parseTempRange(value string) (*int, *int) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parts := strings.Split(value, "-")
|
||||
if len(parts) == 1 {
|
||||
if parsed, err := strconv.Atoi(strings.TrimSpace(parts[0])); err == nil {
|
||||
return &parsed, &parsed
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
minValue, minErr := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
maxValue, maxErr := strconv.Atoi(strings.TrimSpace(parts[len(parts)-1]))
|
||||
if minErr != nil || maxErr != nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &minValue, &maxValue
|
||||
}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
package changes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
)
|
||||
|
||||
func TestCompareDailyNoMeaningfulChanges(t *testing.T) {
|
||||
previous := dailyBriefing(60, 70, 30, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
||||
current := dailyBriefing(61, 71, 35, at("2026-05-29T08:30:00Z"), nil, forecast.Indicators{})
|
||||
previous := dailySnapshot(t, 60, 70, 30, "8 AM", nil, false)
|
||||
current := dailySnapshot(t, 61, 71, 35, "8:30 AM", nil, false)
|
||||
|
||||
changes, err := CompareDaily(previous, current, testThresholds())
|
||||
if err != nil {
|
||||
@@ -22,8 +21,8 @@ func TestCompareDailyNoMeaningfulChanges(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCompareDailyTemperatureThreshold(t *testing.T) {
|
||||
previous := dailyBriefing(50, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
||||
current := dailyBriefing(58, 79, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
||||
previous := dailySnapshot(t, 50, 70, 10, "8 AM", nil, false)
|
||||
current := dailySnapshot(t, 58, 79, 10, "8 AM", nil, false)
|
||||
|
||||
changes, err := CompareDaily(previous, current, testThresholds())
|
||||
if err != nil {
|
||||
@@ -35,8 +34,8 @@ func TestCompareDailyTemperatureThreshold(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCompareDailyPrecipTimingShift(t *testing.T) {
|
||||
previous := dailyBriefing(60, 70, 60, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
||||
current := dailyBriefing(60, 70, 60, at("2026-05-29T11:00:00Z"), nil, forecast.Indicators{})
|
||||
previous := dailySnapshot(t, 60, 70, 60, "8 AM", nil, false)
|
||||
current := dailySnapshot(t, 60, 70, 60, "11 AM", nil, false)
|
||||
|
||||
changes, err := CompareDaily(previous, current, testThresholds())
|
||||
if err != nil {
|
||||
@@ -48,8 +47,8 @@ func TestCompareDailyPrecipTimingShift(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCompareDailyAlertAddedAndRemoved(t *testing.T) {
|
||||
previous := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), []string{"Wind Advisory"}, forecast.Indicators{})
|
||||
current := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), []string{"Flood Watch"}, forecast.Indicators{})
|
||||
previous := dailySnapshot(t, 60, 70, 10, "8 AM", []string{"Wind Advisory"}, false)
|
||||
current := dailySnapshot(t, 60, 70, 10, "8 AM", []string{"Flood Watch"}, false)
|
||||
|
||||
changes, err := CompareDaily(previous, current, testThresholds())
|
||||
if err != nil {
|
||||
@@ -61,8 +60,8 @@ func TestCompareDailyAlertAddedAndRemoved(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCompareDailyIndicatorChange(t *testing.T) {
|
||||
previous := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
||||
current := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{Snow: true})
|
||||
previous := dailySnapshot(t, 60, 70, 10, "8 AM", nil, false)
|
||||
current := dailySnapshot(t, 60, 70, 10, "8 AM", nil, true)
|
||||
|
||||
changes, err := CompareDaily(previous, current, testThresholds())
|
||||
if err != nil {
|
||||
@@ -73,26 +72,50 @@ func TestCompareDailyIndicatorChange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func dailyBriefing(low float64, high float64, precip float64, precipTime time.Time, alerts []string, indicators forecast.Indicators) briefing.Package {
|
||||
alertOverlaps := make([]forecast.AlertOverlap, 0, len(alerts))
|
||||
func TestCompareDailyRequiresComparisonStanzas(t *testing.T) {
|
||||
_, err := CompareDaily(snapshot(t), dailySnapshot(t, 60, 70, 10, "8 AM", nil, false), testThresholds())
|
||||
if err == nil {
|
||||
t.Fatal("CompareDaily() error = nil, want missing stanza error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "derived_daily_summary") {
|
||||
t.Fatalf("error = %q, want derived_daily_summary context", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func dailySnapshot(t *testing.T, low int, high int, precip int, precipTime string, alerts []string, snow bool) module.Snapshot {
|
||||
t.Helper()
|
||||
relevant := make([]alertSummaryStanza, 0, len(alerts))
|
||||
for _, alert := range alerts {
|
||||
alertOverlaps = append(alertOverlaps, forecast.AlertOverlap{Event: alert})
|
||||
relevant = append(relevant, alertSummaryStanza{Event: alert})
|
||||
}
|
||||
return briefing.Package{
|
||||
Daily: &briefing.Daily{
|
||||
BottomLine: briefing.BottomLine{
|
||||
Temperature: forecast.Range{Min: &low, Max: &high},
|
||||
MaxPrecipProbability: &forecast.TimedValue{
|
||||
Value: precip,
|
||||
Time: precipTime,
|
||||
},
|
||||
},
|
||||
RelevantAlerts: alertOverlaps,
|
||||
Dayparts: []forecast.DaypartSummary{
|
||||
{Name: "morning", Indicators: indicators},
|
||||
},
|
||||
return snapshot(t,
|
||||
module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: dailySummaryStanza{
|
||||
Date: "2026-05-29",
|
||||
HighTempF: &high,
|
||||
LowTempF: &low,
|
||||
DailyPrecipitationProbability: &precip,
|
||||
}},
|
||||
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
|
||||
"morning": {
|
||||
Date: "2026-05-29",
|
||||
PeriodBegins: "2026-05-29 at 6:00 AM",
|
||||
PeriodEnds: "2026-05-29 at 10:00 AM",
|
||||
TempRangeF: "60-70",
|
||||
Snow: snow,
|
||||
},
|
||||
}},
|
||||
module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: precipTimingStanza{MaxPopPercent: &precip, MaxPopTime: precipTime}},
|
||||
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: alertDigestStanza{Relevant: relevant}},
|
||||
)
|
||||
}
|
||||
|
||||
func snapshot(t *testing.T, outputs ...module.Output) module.Snapshot {
|
||||
t.Helper()
|
||||
snapshot, err := module.NewSnapshot(outputs)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSnapshot() error = %v", err)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func testThresholds() Thresholds {
|
||||
@@ -113,11 +136,3 @@ func countType(changes []Change, changeType string) int {
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func at(value string) time.Time {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
@@ -2,77 +2,143 @@ package changes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
"sort"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
)
|
||||
|
||||
func CompareThreeDay(previous briefing.Package, current briefing.Package, thresholds Thresholds) ([]Change, error) {
|
||||
if previous.ThreeDay == nil {
|
||||
return nil, fmt.Errorf("previous 3-day briefing is required")
|
||||
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)
|
||||
}
|
||||
if current.ThreeDay == nil {
|
||||
return nil, fmt.Errorf("current 3-day briefing is required")
|
||||
currentDayparts, err := requiredStanza[map[string]daypartSummaryStanza](current, "derived_daypart_summaries")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("current daypart summaries: %w", err)
|
||||
}
|
||||
previousDays := outlookDaysByDate(previous.ThreeDay.Days)
|
||||
currentDays := outlookDaysByDate(current.ThreeDay.Days)
|
||||
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: "outlook_day_added", Message: fmt.Sprintf("Outlook day added: %s.", date), Current: date})
|
||||
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)...)
|
||||
changes = append(changes, compareOutlookDay(date, previousDay, currentDay, thresholds, prefix)...)
|
||||
}
|
||||
for date := range previousDays {
|
||||
if _, ok := currentDays[date]; !ok {
|
||||
changes = append(changes, Change{Type: "outlook_day_removed", Message: fmt.Sprintf("Outlook day removed: %s.", date), Previous: date})
|
||||
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 briefing.OutlookDay, current briefing.OutlookDay, thresholds Thresholds) []Change {
|
||||
func compareOutlookDay(date string, previous outlookDay, current outlookDay, thresholds Thresholds, prefix string) []Change {
|
||||
var changes []Change
|
||||
for _, change := range compareTemperature(previous.Temperature, current.Temperature, thresholds.TemperatureDegrees) {
|
||||
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 comparePrecipitation(previous.MaxPrecipitationProbability, current.MaxPrecipitationProbability, thresholds) {
|
||||
for _, change := range comparePrecipTiming(previous.MaxPopTime, current.MaxPopTime, thresholds.PrecipTimingShiftMinutes, prefix+"outlook_") {
|
||||
change.Message = date + ": " + change.Message
|
||||
changes = append(changes, change)
|
||||
changes[len(changes)-1].Type = "outlook_" + change.Type
|
||||
}
|
||||
for _, change := range compareWind(previous.PeakWindGust, current.PeakWindGust, float64(thresholds.WindGustMilesPerHour)) {
|
||||
for _, change := range compareWindValues(previous.MaxWindGustMph, current.MaxWindGustMph, thresholds.WindGustMilesPerHour, prefix+"outlook_") {
|
||||
change.Message = date + ": " + change.Message
|
||||
change.Type = "outlook_" + change.Type
|
||||
changes = append(changes, change)
|
||||
}
|
||||
for _, change := range compareAlerts(previous.RelevantAlerts, current.RelevantAlerts) {
|
||||
for _, change := range compareIndicators(previous.Indicators, current.Indicators, prefix+"outlook_") {
|
||||
change.Message = date + ": " + change.Message
|
||||
change.Type = "outlook_" + change.Type
|
||||
changes = append(changes, change)
|
||||
}
|
||||
for _, change := range compareIndicators(aggregateIndicators(previous.Dayparts), aggregateIndicators(current.Dayparts)) {
|
||||
change.Message = date + ": " + change.Message
|
||||
change.Type = "outlook_" + change.Type
|
||||
changes = append(changes, change)
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
func outlookDaysByDate(days []briefing.OutlookDay) map[string]briefing.OutlookDay {
|
||||
out := map[string]briefing.OutlookDay{}
|
||||
for _, day := range days {
|
||||
date := day.Date
|
||||
if date == "" && !day.Period.Start.IsZero() {
|
||||
date = day.Period.Start.Format(time.DateOnly)
|
||||
func outlookDaysFromDayparts(dayparts map[string]daypartSummaryStanza) map[string]outlookDay {
|
||||
out := map[string]outlookDay{}
|
||||
var keys []string
|
||||
for key := range dayparts {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
if date != "" {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2,41 +2,13 @@ package changes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
)
|
||||
|
||||
func TestCompareThreeDayDetectsDayChanges(t *testing.T) {
|
||||
previousTemp := 70.0
|
||||
currentTemp := 78.0
|
||||
previousPrecip := 20.0
|
||||
currentPrecip := 70.0
|
||||
previous := briefing.Package{
|
||||
Metadata: briefing.Metadata{ReportID: report.ThreeDay},
|
||||
ThreeDay: &briefing.ThreeDay{Days: []briefing.OutlookDay{{
|
||||
Date: "2026-05-29",
|
||||
Temperature: forecast.Range{Max: &previousTemp},
|
||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
||||
Value: previousPrecip,
|
||||
Time: time.Date(2026, 5, 29, 9, 0, 0, 0, time.UTC),
|
||||
},
|
||||
}}},
|
||||
}
|
||||
current := briefing.Package{
|
||||
Metadata: briefing.Metadata{ReportID: report.ThreeDay},
|
||||
ThreeDay: &briefing.ThreeDay{Days: []briefing.OutlookDay{{
|
||||
Date: "2026-05-29",
|
||||
Temperature: forecast.Range{Max: ¤tTemp},
|
||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
||||
Value: currentPrecip,
|
||||
Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC),
|
||||
},
|
||||
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Snow: true}}},
|
||||
}}},
|
||||
}
|
||||
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,
|
||||
@@ -50,17 +22,22 @@ func TestCompareThreeDayDetectsDayChanges(t *testing.T) {
|
||||
if len(changes) == 0 {
|
||||
t.Fatal("changes length = 0, want detected 3-day changes")
|
||||
}
|
||||
var foundPrecip bool
|
||||
var foundSnow bool
|
||||
for _, change := range changes {
|
||||
if change.Type == "outlook_precip_probability_change" {
|
||||
foundPrecip = true
|
||||
}
|
||||
if change.Type == "outlook_snow_risk_change" {
|
||||
foundSnow = true
|
||||
}
|
||||
}
|
||||
if !foundPrecip || !foundSnow {
|
||||
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,
|
||||
},
|
||||
}})
|
||||
}
|
||||
|
||||
@@ -3,24 +3,17 @@ package changes
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
)
|
||||
|
||||
func CompareWeekend(previous briefing.Package, current briefing.Package, thresholds Thresholds) ([]Change, error) {
|
||||
if previous.Weekend == nil {
|
||||
return nil, fmt.Errorf("previous weekend briefing is required")
|
||||
}
|
||||
if current.Weekend == nil {
|
||||
return nil, fmt.Errorf("current weekend briefing is required")
|
||||
}
|
||||
previousOutlook := briefing.Package{ThreeDay: &briefing.ThreeDay{Days: previous.Weekend.Days}}
|
||||
currentOutlook := briefing.Package{ThreeDay: &briefing.ThreeDay{Days: current.Weekend.Days}}
|
||||
changes, err := CompareThreeDay(previousOutlook, currentOutlook, thresholds)
|
||||
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, err
|
||||
return nil, fmt.Errorf("previous weekend daypart summaries: %w", err)
|
||||
}
|
||||
for i := range changes {
|
||||
changes[i].Type = "weekend_" + changes[i].Type
|
||||
currentDayparts, err := requiredStanza[map[string]daypartSummaryStanza](current, "derived_daypart_summaries")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("current weekend daypart summaries: %w", err)
|
||||
}
|
||||
return changes, nil
|
||||
return compareOutlookDays(outlookDaysFromDayparts(previousDayparts), outlookDaysFromDayparts(currentDayparts), thresholds, "weekend_")
|
||||
}
|
||||
|
||||
@@ -1,31 +1,10 @@
|
||||
package changes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestCompareWeekendDetectsOutlookChanges(t *testing.T) {
|
||||
previousTemp := 70.0
|
||||
currentTemp := 78.0
|
||||
previous := briefing.Package{
|
||||
Metadata: briefing.Metadata{ReportID: report.Weekend},
|
||||
Weekend: &briefing.Weekend{Days: []briefing.OutlookDay{{
|
||||
Date: "2026-05-30",
|
||||
Temperature: forecast.Range{Max: &previousTemp},
|
||||
}}},
|
||||
}
|
||||
current := briefing.Package{
|
||||
Metadata: briefing.Metadata{ReportID: report.Weekend},
|
||||
Weekend: &briefing.Weekend{Days: []briefing.OutlookDay{{
|
||||
Date: "2026-05-30",
|
||||
Temperature: forecast.Range{Max: ¤tTemp},
|
||||
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Snow: true}}},
|
||||
}}},
|
||||
}
|
||||
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 {
|
||||
@@ -34,10 +13,7 @@ func TestCompareWeekendDetectsOutlookChanges(t *testing.T) {
|
||||
if len(changes) == 0 {
|
||||
t.Fatal("changes length = 0, want weekend changes")
|
||||
}
|
||||
for _, change := range changes {
|
||||
if change.Type == "weekend_outlook_snow_risk_change" {
|
||||
return
|
||||
}
|
||||
}
|
||||
if countType(changes, "weekend_outlook_snow_risk_change") == 0 {
|
||||
t.Fatalf("changes = %#v, want snow risk change", changes)
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
162
internal/cli/result.go
Normal file
162
internal/cli/result.go
Normal file
@@ -0,0 +1,162 @@
|
||||
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"`
|
||||
PreflightPath string `json:"preflightPath,omitempty"`
|
||||
GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"`
|
||||
GeneratedTextResultPath string `json:"generatedTextResultPath,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.PreflightPath = result.PreflightPath
|
||||
summary.GeneratedTextRawPath = result.GeneratedTextRawPath
|
||||
summary.GeneratedTextResultPath = result.GeneratedTextResultPath
|
||||
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
|
||||
}
|
||||
231
internal/cli/result_test.go
Normal file
231
internal/cli/result_test.go
Normal file
@@ -0,0 +1,231 @@
|
||||
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",
|
||||
PreflightPath: "/runs/hourly/preflight.json",
|
||||
ReportPath: "/runs/hourly/report.md",
|
||||
OutputPath: "/copies/hourly.md",
|
||||
MetadataPath: "/runs/hourly/metadata.json",
|
||||
GeneratedTextRawPath: "/runs/hourly/generated_text_raw.json",
|
||||
GeneratedTextResultPath: "/runs/hourly/generated_text_result.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.GeneratedTextRawPath == "" || summary.GeneratedTextResultPath == "" || 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))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewGenerateSummaryForMarkdownReportOmitsGeneratedTextAndNotification(t *testing.T) {
|
||||
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
|
||||
result := &app.ReportResult{
|
||||
DataPackagePath: "/runs/three-day/data_package.yaml",
|
||||
PreflightPath: "/runs/three-day/preflight.json",
|
||||
ReportPath: "/runs/three-day/report.md",
|
||||
OutputPath: "/copies/three-day.md",
|
||||
MetadataPath: "/runs/three-day/metadata.json",
|
||||
Metadata: state.Metadata{
|
||||
ReportID: report.ThreeDay,
|
||||
PromptID: "weather.three_day_outlook",
|
||||
RunID: "20260529T133000Z_three_day",
|
||||
GeneratedAt: generatedAt,
|
||||
ValidPeriod: testSummaryPeriod(generatedAt),
|
||||
},
|
||||
}
|
||||
|
||||
summary := newGenerateSummary(result, nil)
|
||||
|
||||
if summary.ReportID != report.ThreeDay || summary.ReportName != "3-Day Outlook" || summary.Status != "succeeded" {
|
||||
t.Fatalf("summary = %#v, want successful 3-day 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{"generatedTextRawPath", "generatedTextResultPath", "generatedTextPath", "renderContextPath", "notification"} {
|
||||
if strings.Contains(string(data), omitted) {
|
||||
t.Fatalf("summary JSON contains %q, want omitted:\n%s", omitted, string(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
PreflightPath: "/runs/hourly/preflight.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,7 +2,6 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -17,16 +16,18 @@ const helpText = `weatherreporter prepares weather reports from normalized forec
|
||||
|
||||
Usage:
|
||||
weatherreporter --help
|
||||
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
||||
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
|
||||
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
|
||||
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
|
||||
weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
|
||||
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] [--quiet]
|
||||
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
|
||||
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
|
||||
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
|
||||
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
|
||||
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet] --start TIME --end TIME
|
||||
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--quiet]
|
||||
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--quiet]
|
||||
weatherreporter inspect reports [--config PATH] [--limit N]
|
||||
weatherreporter inspect metadata [--config PATH] RUN_ID
|
||||
weatherreporter inspect briefing [--config PATH] RUN_ID
|
||||
weatherreporter inspect modules [--config PATH] RUN_ID
|
||||
weatherreporter inspect data-package [--config PATH] RUN_ID
|
||||
weatherreporter inspect prior [--config PATH] RUN_ID
|
||||
weatherreporter inspect sources [--config PATH] RUN_ID
|
||||
@@ -36,8 +37,9 @@ Options:
|
||||
--config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml.
|
||||
--units VALUE Override weather API units.
|
||||
--tz NAME Override weather API timezone.
|
||||
--out PATH Write an extra Markdown report copy for generate commands.
|
||||
--out PATH Write an extra Markdown report copy where supported by the generate command.
|
||||
--out-dir PATH Write extra Markdown report copies for run commands.
|
||||
--quiet Suppress successful generate and run output.
|
||||
`
|
||||
|
||||
type Runner struct {
|
||||
@@ -49,7 +51,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 {
|
||||
_ = stderr
|
||||
if r.Clock == nil {
|
||||
r.Clock = timeutil.SystemClock{}
|
||||
}
|
||||
@@ -60,23 +61,32 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr
|
||||
|
||||
switch args[0] {
|
||||
case "generate":
|
||||
req, err := r.resolveGenerate(args[1:])
|
||||
req, opts, err := r.resolveGenerateAction(args[1:])
|
||||
if err != nil {
|
||||
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":
|
||||
req, err := r.resolveRun(args[1:])
|
||||
req, opts, err := r.resolveRunAction(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := app.RunBatchDetailed(ctx, req)
|
||||
if result != nil {
|
||||
writeRunLogs(stderr, result)
|
||||
if encodeErr := writeJSON(stdout, result); encodeErr != nil {
|
||||
summary := newBatchSummary(result)
|
||||
if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, func(w io.Writer) {
|
||||
writeBatchStatus(w, result)
|
||||
}); encodeErr != nil {
|
||||
return encodeErr
|
||||
}
|
||||
if result.Failed > 0 {
|
||||
if summary.Status == summaryStatusFailed {
|
||||
return app.BatchError{Result: result}
|
||||
}
|
||||
}
|
||||
@@ -94,6 +104,7 @@ type commonOptions struct {
|
||||
Timezone string
|
||||
Output string
|
||||
OutputDir string
|
||||
Quiet bool
|
||||
}
|
||||
|
||||
type generateOptions struct {
|
||||
@@ -118,8 +129,8 @@ var inspectRunCommands = []inspectRunCommand{
|
||||
{Name: "metadata", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
|
||||
return app.InspectMetadata(ctx, req)
|
||||
}},
|
||||
{Name: "briefing", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
|
||||
return app.InspectBriefing(ctx, req)
|
||||
{Name: "modules", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
|
||||
return app.InspectModules(ctx, req)
|
||||
}},
|
||||
{Name: "data-package", Inspect: func(ctx context.Context, req app.InspectRunRequest) (any, error) {
|
||||
return app.InspectDataPackage(ctx, req)
|
||||
@@ -179,20 +190,25 @@ func runInspectRunCommand(ctx context.Context, stdout io.Writer, command inspect
|
||||
}
|
||||
|
||||
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 {
|
||||
r.Clock = timeutil.SystemClock{}
|
||||
}
|
||||
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 !ok {
|
||||
return app.GenerateRequest{}, fmt.Errorf("unknown generate report %q", args[0])
|
||||
if _, err := report.IDForCommandName(args[0]); err != nil {
|
||||
return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("unknown generate report %q", args[0])
|
||||
}
|
||||
reportKind := app.ReportKind(args[0])
|
||||
|
||||
opts, err := parseGenerateFlags(reportKind, args[1:])
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, err
|
||||
return app.GenerateRequest{}, commonOptions{}, err
|
||||
}
|
||||
cfg, err := config.Load(config.LoadOptions{
|
||||
Path: opts.ConfigPath,
|
||||
@@ -200,11 +216,11 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
||||
Timezone: opts.Timezone,
|
||||
})
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, err
|
||||
return app.GenerateRequest{}, commonOptions{}, err
|
||||
}
|
||||
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, err
|
||||
return app.GenerateRequest{}, commonOptions{}, err
|
||||
}
|
||||
|
||||
req := app.GenerateRequest{
|
||||
@@ -216,46 +232,59 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
||||
|
||||
switch reportKind {
|
||||
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 == "" {
|
||||
req.Date = timeutil.LocalDate(r.Clock.Now(), location)
|
||||
} else {
|
||||
req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
|
||||
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")
|
||||
return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("generate storm requires --start")
|
||||
}
|
||||
if opts.End == "" {
|
||||
return app.GenerateRequest{}, fmt.Errorf("generate storm requires --end")
|
||||
return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("generate storm requires --end")
|
||||
}
|
||||
period, err := report.ParseStormPeriod(opts.Start, opts.End, location)
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, err
|
||||
return app.GenerateRequest{}, commonOptions{}, 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) {
|
||||
req, _, err := r.resolveRunAction(args)
|
||||
return req, err
|
||||
}
|
||||
|
||||
func (r Runner) resolveRunAction(args []string) (app.BatchRequest, commonOptions, error) {
|
||||
if r.Clock == nil {
|
||||
r.Clock = timeutil.SystemClock{}
|
||||
}
|
||||
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 !ok {
|
||||
return app.BatchRequest{}, fmt.Errorf("unknown run batch %q", args[0])
|
||||
if _, err := report.BatchForCommandName(args[0]); err != nil {
|
||||
return app.BatchRequest{}, commonOptions{}, fmt.Errorf("unknown run batch %q", args[0])
|
||||
}
|
||||
batch := app.BatchKind(args[0])
|
||||
opts, err := parseRunFlags(args[1:])
|
||||
if err != nil {
|
||||
return app.BatchRequest{}, err
|
||||
return app.BatchRequest{}, commonOptions{}, err
|
||||
}
|
||||
cfg, err := config.Load(config.LoadOptions{
|
||||
Path: opts.ConfigPath,
|
||||
@@ -263,9 +292,9 @@ func (r Runner) resolveRun(args []string) (app.BatchRequest, error) {
|
||||
Timezone: opts.Timezone,
|
||||
})
|
||||
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
|
||||
return app.BatchRequest{Config: cfg, Batch: batch, Now: r.Clock.Now(), OutputDir: opts.OutputDir}, opts, nil
|
||||
}
|
||||
|
||||
func resolveRun(args []string) (app.BatchRequest, error) {
|
||||
@@ -277,7 +306,8 @@ func parseGenerateFlags(report app.ReportKind, args []string) (generateOptions,
|
||||
fs.SetOutput(io.Discard)
|
||||
opts := generateOptions{}
|
||||
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")
|
||||
}
|
||||
if report == app.ReportStorm {
|
||||
@@ -299,6 +329,7 @@ func parseRunFlags(args []string) (commonOptions, error) {
|
||||
opts := commonOptions{}
|
||||
addCommonFlags(fs, &opts, false)
|
||||
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 {
|
||||
return commonOptions{}, err
|
||||
}
|
||||
@@ -341,26 +372,6 @@ func parseInspectRunFlags(command string, args []string) (inspectOptions, error)
|
||||
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 {
|
||||
if item.Status == "failed" {
|
||||
_, _ = fmt.Fprintf(stderr, "report=%s status=failed error=%q\n", item.ReportID, item.Error)
|
||||
continue
|
||||
}
|
||||
_, _ = fmt.Fprintf(stderr, "report=%s status=succeeded output=%q\n", item.ReportID, item.OutputPath)
|
||||
}
|
||||
_, _ = 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) {
|
||||
fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path")
|
||||
fs.StringVar(&opts.Units, "units", "", "weather API units")
|
||||
@@ -369,31 +380,3 @@ func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) {
|
||||
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.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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user