202 Commits

Author SHA1 Message Date
8dd604afb4 Update default sections of the Area Forecast Discussion provided to different report types 2026-06-20 20:27:37 -05:00
52bb17c8fa Document CLI output contract 2026-06-20 23:01:14 +00:00
7952e4fb25 Wire CLI action summaries 2026-06-20 22:55:59 +00:00
0281327365 Centralize CLI output helpers 2026-06-20 22:49:51 +00:00
bf76eae301 Add CLI result summaries 2026-06-20 22:47:16 +00:00
0d47662cf9 Add detailed generate result 2026-06-20 22:43:49 +00:00
f4f009b904 Add a feature roadmap and staged implentation plan to harmonize CLI command outputs 2026-06-20 17:39:05 -05:00
3c1b753952 Tighten workspace artifact path handling 2026-06-20 09:09:56 -05:00
bdbab48d10 Document managed workspace artifact layout 2026-06-20 13:37:18 +00:00
16cc4b3f63 Update app workflow path expectations 2026-06-20 13:33:30 +00:00
0ef861ed8f Discover metadata with new workspace filenames 2026-06-20 13:31:22 +00:00
6ae7eb44cf Update managed workspace artifact paths 2026-06-20 13:29:41 +00:00
8f6aa8aa8b Add a feature roadmap and staged implentation plan to refactor the local workspace layout 2026-06-20 08:26:19 -05:00
b8e889ad13 Finalize and close the distributor report path refactor roadmap 2026-06-20 07:41:37 -05:00
15ee4af1a1 Document report-specific distributor paths 2026-06-20 03:00:29 +00:00
4c606eb39f Remove legacy distributor report path config 2026-06-20 02:58:41 +00:00
8d2ac163ae Use report-specific distributor paths 2026-06-20 02:55:06 +00:00
8709b5f4d8 Generalize distributor report path rendering 2026-06-20 02:49:39 +00:00
fd48ebecb8 Add per-report distributor path overrides 2026-06-20 02:46:05 +00:00
021e5dd8b1 Add report distributor path defaults 2026-06-20 02:42:20 +00:00
7adf5e1b08 Add a feature roadmap and implementation plan to refactor configuration for distributor output paths 2026-06-19 21:38:30 -05:00
455cc67d4c Use neutral endpoint in example config 2026-06-17 21:14:20 +00:00
dd3133ee2a Validate batch distributor uploads 2026-06-17 21:12:55 +00:00
b3637cddd6 Document batch distributor uploads 2026-06-17 21:11:28 +00:00
662db5e511 Report batch notifications in CLI output 2026-06-17 21:03:14 +00:00
2ef91cf1b1 Upload batch distributor notifications 2026-06-17 21:00:03 +00:00
1d2f176977 Suppress per-report notifications during batch runs 2026-06-17 20:52:27 +00:00
2b3bcdd4f1 Build batch distributor upload requests 2026-06-17 20:48:38 +00:00
a82f03feb8 Add batch notification app identity types 2026-06-17 20:44:34 +00:00
f1d4e38414 Add batch distributor notification state artifacts 2026-06-17 20:39:22 +00:00
32060bd370 Add batch distributor notification config 2026-06-17 20:36:02 +00:00
133f83f4ce Create a roadmap and implementation plan for batch distributor uploads 2026-06-17 15:31:36 -05:00
42f0e16b02 Fix to ensure unique document IDs when batch reports are generated 2026-06-17 14:39:53 -05:00
a2f0a2fc36 Refresh documentation for current collection behavior 2026-06-17 16:15:23 +00:00
9f552cff6b Validate batch collection migration 2026-06-17 16:12:17 +00:00
b913194fb4 Update batch collection documentation 2026-06-17 16:11:09 +00:00
3eccafad6b Remove static batch report resolution 2026-06-17 16:07:40 +00:00
a9d87bdbaa Reuse collected batch data 2026-06-17 16:04:14 +00:00
6f9255105d Add data-aware batch planning 2026-06-17 15:59:13 +00:00
c04e3c5599 Add daily coverage planning helper 2026-06-17 15:53:12 +00:00
f15315f1b9 Require collected data for report generation 2026-06-17 15:50:04 +00:00
0ef6cd567e Add app collection seam 2026-06-17 15:46:01 +00:00
b308ff4d6b Add canonical weather collection package 2026-06-17 15:42:00 +00:00
5ecbc06c85 Add a feature roadmap and implementation plan to refactor the morning and evening batch reports and add a standalone data collection package 2026-06-17 10:36:22 -05:00
21e97f5d4e Fix date formatting in the SPC alert digest lines 2026-06-16 22:07:36 -05:00
3900b3313b Add SPC Outlook summaries to the alert digest template 2026-06-16 21:42:20 -05:00
5d416cfc4a Implement alert instruction whitespace normalization 2026-06-16 21:19:03 -05:00
6532e8824a Update the alert digest wording 2026-06-16 21:09:17 -05:00
d321492995 Move the Alert Digest into a shared partial template, and add it to the today, tomorrow, and daily reports 2026-06-16 20:53:27 -05:00
b57110e5c8 Update the hourly report template to trim excess whitespace when alert and/or preciptiation sections are omitted 2026-06-16 20:44:28 -05:00
482e83903c Update the hourly report template to remove newlines between hourly forecast report items 2026-06-16 20:39:24 -05:00
21a7748b2c Update upstream weatherapi alert handling 2026-06-16 20:34:01 -05:00
b36e198bfe Update the shared precipitation timing template 2026-06-16 19:20:26 -05:00
f9d6d42b1b Updated precipitation timing language in the shared template 2026-06-16 19:10:11 -05:00
d9ab1e47ec Align documentation with cleanup results 2026-06-16 15:52:55 +00:00
4f755704d9 Document template partials 2026-06-16 15:45:23 +00:00
a13f04fce5 Clean up state artifact writes 2026-06-16 15:37:21 +00:00
1f5b347cd2 Clean up app test setup 2026-06-16 15:30:23 +00:00
3639636813 Clean up CLI test setup 2026-06-16 15:23:03 +00:00
ca27d81163 Share Scriptorium run execution plumbing 2026-06-16 15:14:11 +00:00
d74ba0f259 Unify report module config traversal 2026-06-16 15:08:20 +00:00
121f28fd29 Share day-style render context and template blocks 2026-06-16 15:03:35 +00:00
e3bcecc5c1 Share day-style generated text validation 2026-06-16 14:55:22 +00:00
0884eb0ce5 Added a staged roadmap to implement the small changes and refactors identified by the audit 2026-06-16 09:51:22 -05:00
a27e870522 Audit code quality and deduplication opportunities 2026-06-16 08:25:58 -05:00
d90801cff5 Separate the daily report and tomorrow report definitions 2026-06-16 08:17:25 -05:00
0f63159482 Document curated data package exports 2026-06-15 20:46:11 +00:00
2792933833 Add data package export regression coverage 2026-06-15 20:42:48 +00:00
9261431329 Curate daypart prompt exports 2026-06-15 20:38:56 +00:00
1bfd865333 Curate current and hourly prompt exports 2026-06-15 20:32:12 +00:00
e5af7477af Use exported module values in data packages 2026-06-15 20:27:28 +00:00
92fcbfcc05 Attach prompt export values in module registry 2026-06-15 20:25:12 +00:00
ff6aade42c Add runtime prompt values to module outputs 2026-06-15 20:23:02 +00:00
4fac69c9f0 Confirm daily documentation updates 2026-06-15 20:19:31 +00:00
90ab6973e6 Confirm legacy daily report cleanup 2026-06-15 20:19:31 +00:00
90a502f50e Confirm daily CLI workflow integration 2026-06-15 20:19:31 +00:00
273f462e09 Confirm daily report registry cutover 2026-06-15 20:19:31 +00:00
5361d5647b Confirm daily render context 2026-06-15 20:19:31 +00:00
d2e90da148 Confirm daily generated text assets 2026-06-15 20:19:31 +00:00
047ce32ac6 Confirm daily planning module implementation 2026-06-15 20:19:31 +00:00
5896168a93 Add feature roadmap and implementation plan to clean up and rationalize the fields provided to the data package 2026-06-15 13:03:31 -05:00
fe9c40741a Validate daily report cutover 2026-06-15 16:56:11 +00:00
88004a1827 Document daily report operations and templates 2026-06-15 16:54:41 +00:00
a515b7e7d9 Remove legacy daily report references 2026-06-15 16:49:37 +00:00
696454cf34 Require explicit dates for daily generation 2026-06-15 16:46:10 +00:00
8c97788682 Replace legacy daily report with generated text daily report 2026-06-15 16:43:06 +00:00
5203440ba0 Add daily render context 2026-06-15 16:29:18 +00:00
3d452a120a Add daily generated text assets 2026-06-15 16:23:13 +00:00
4eece7cc8a Add daily planning module 2026-06-15 16:17:22 +00:00
d0d0b698f9 Revise the feature roadmap for consistency with the implementation plan 2026-06-15 11:11:36 -05:00
4c4b01f265 Add a feature roadmap and implementation plan for a new daily report 2026-06-15 11:07:23 -05:00
58fe794227 Update the today report template 2026-06-15 11:01:03 -05:00
63dfc0b55a Validate Today report implementation 2026-06-15 15:05:23 +00:00
1ddc33eb17 Document Today report workflow 2026-06-15 15:04:14 +00:00
4a0238909b Add Today generate command workflow 2026-06-15 14:58:36 +00:00
3d5f71e72d Add Today report to morning batch 2026-06-15 14:54:37 +00:00
8ff5c44324 Add Today generated text assets 2026-06-15 14:45:49 +00:00
4e704e4f51 Add Today planning module scaffold 2026-06-15 14:37:08 +00:00
7b4c73d1e6 Add staged implementation plan for the new today report type 2026-06-15 08:48:04 -05:00
7efd8b5855 Add today report roadmap 2026-06-15 13:07:40 +00:00
7cbc59d8a7 Clarify future roadmap documentation 2026-06-15 13:00:59 +00:00
67b30dbad6 Remove completed roadmap cleanup plans 2026-06-15 12:52:18 +00:00
cd8d77b37c Reduce CLI test setup duplication 2026-06-15 12:48:36 +00:00
bb79232e3e Simplify Weather API source fetching 2026-06-15 12:44:57 +00:00
b4e0aadbef Clean up generated text helpers 2026-06-15 12:41:45 +00:00
4fe0f40cef Make report module overrides explicit 2026-06-15 12:36:53 +00:00
40b42f4bf3 Centralize report name resolution 2026-06-15 12:33:55 +00:00
e8f1aa5caf Centralize final report finalization 2026-06-15 12:28:54 +00:00
a02af0bce0 Centralize generated text template catalog 2026-06-15 12:24:47 +00:00
ff2f8c16a3 Create a staged roadmap to address the issues identified in the code quality audit 2026-06-15 07:16:43 -05:00
ace5577402 Audit code quality and deduplication opportunities 2026-06-15 07:08:20 -05:00
473f252aee Revise the report template for the tomorrow report. 2026-06-14 23:26:45 -05:00
74bd69834c Validate Tomorrow cutover 2026-06-14 23:55:56 +00:00
98cab70b53 Document Tomorrow generated-text workflow 2026-06-14 23:53:31 +00:00
dc0172ff82 Cover Tomorrow artifact and notification behavior 2026-06-14 23:49:18 +00:00
0b41773017 Route Tomorrow through generated text rendering 2026-06-14 23:44:31 +00:00
ddda42453f Add Tomorrow render context and template 2026-06-14 23:36:47 +00:00
295f06915f Add daypart presentation facts 2026-06-14 23:30:57 +00:00
120bce3391 Add Tomorrow generated text contract 2026-06-14 23:25:55 +00:00
386263784c Split Tomorrow report identity 2026-06-14 23:22:31 +00:00
afe6803a63 Add an implementation plan to convert the tomorrow report into the new hybrid deterministic/LLM format 2026-06-14 18:17:49 -05:00
5344c7880a Updated the current_conditions module to round all numeric values to integers 2026-06-14 17:40:19 -05:00
74e32eb18e Simplify and rationalize the hourly report template 2026-06-14 12:54:32 -05:00
ceb00ad45e Bugfix in the hourly weather template 2026-06-14 09:10:28 -05:00
7cd07c429b Revise the hourly weather template 2026-06-14 09:07:10 -05:00
28b8391d53 Refactor the template variable framework 2026-06-14 08:57:53 -05:00
bb8de054dc Update documentation for templated reports 2026-06-14 08:22:09 -05:00
9d6502460e Update buildRenderContext to use Definition.TemplateID instead of GeneratedTextSchemaID 2026-06-14 12:23:50 +00:00
fcf1108641 Strengthen final hourly workflow validation 2026-06-14 05:24:25 +00:00
185605fbf0 Document hourly generated text behavior 2026-06-14 05:21:15 +00:00
662d906997 Cover hourly CLI generation workflow 2026-06-14 05:17:05 +00:00
f6e20d1412 Notify hourly generated reports after success 2026-06-14 05:13:40 +00:00
9e14a9b7c7 Preserve generated text failure artifacts 2026-06-14 05:08:26 +00:00
422430613c Wire hourly generated text rendering 2026-06-14 05:01:26 +00:00
2a4ce64d6f Add structured Scriptorium run support 2026-06-14 04:55:07 +00:00
316ab8f3fc Add generated text state artifacts 2026-06-14 04:51:52 +00:00
f6a68426e1 Add hourly generated text contract 2026-06-14 04:46:53 +00:00
bed2b84100 Add embedded hourly report template assets 2026-06-14 04:38:48 +00:00
b39ec1e4d3 Add hourly generated-text report shell 2026-06-14 04:35:52 +00:00
dd92e9b061 Add report generation mode metadata 2026-06-14 04:28:30 +00:00
8d737395dc Rename rolling report to hourly 2026-06-14 04:26:05 +00:00
b3f7c9c1f2 Add roadmap and implementation plan to move towards hybrid deterministic/llm generation of reports 2026-06-13 23:20:04 -05:00
d425132ae7 Validate near-term implementation 2026-06-12 17:48:02 +00:00
925d351341 Document near-term report behavior 2026-06-12 17:45:37 +00:00
c4107490df Add near-term report config overrides 2026-06-12 17:42:49 +00:00
b38230bc35 Add near-term generation artifact coverage 2026-06-12 17:40:54 +00:00
1f5f9964e0 Add near-term generate command 2026-06-12 17:36:58 +00:00
b0d8c6983d Support near-term derived facts 2026-06-12 17:34:40 +00:00
55f0180599 Add near-term module composition 2026-06-12 17:31:37 +00:00
5284033fb8 Add near-term report identity 2026-06-12 17:28:26 +00:00
0b1423c90d Add a roadmap and implementation plan for rolling near-term forecast reports 2026-06-12 12:24:07 -05:00
e6a4bb2d16 Clean up and normalize prompt output modules 2026-06-12 12:00:40 -05:00
c3a051d372 Validate SPC convective outlook feature 2026-06-12 15:18:38 +00:00
3482551360 Document SPC convective outlook behavior 2026-06-12 15:17:06 +00:00
d7a72f8581 Add SPC convective workflow coverage 2026-06-12 15:14:09 +00:00
c09b7410ce Add SPC convective modules to report defaults 2026-06-12 15:10:40 +00:00
96ce0edd46 Add SPC convective discussion briefing module 2026-06-12 15:05:42 +00:00
7cd68ff222 Add SPC convective outlook briefing module 2026-06-12 15:02:49 +00:00
16680e3f61 Route SPC convective stanzas in prompt packages 2026-06-12 14:58:14 +00:00
3389d4fa93 Derive report-period SPC convective outlooks 2026-06-12 14:56:17 +00:00
0041845935 Fetch SPC convective outlook data 2026-06-12 14:51:54 +00:00
3bcccb4a7b Add SPC convective outlook data contracts 2026-06-12 14:47:42 +00:00
2aba52f552 Add roadmap and staged plan for implementing SPC convective outlook support 2026-06-12 09:42:43 -05:00
f149563c68 Refactor the yaml data package to group data sources by category 2026-06-10 11:50:22 -05:00
276e4f1189 Implement friendly time formatting in select modules 2026-06-10 10:36:52 -05:00
cb42cad6a6 Add an hourly forecast module and adjust the daily summary for consistency 2026-06-10 10:00:36 -05:00
ef044327c6 Refactored the derived_daily_summary module to utilize narrative forecast data where available 2026-06-10 09:35:39 -05:00
d1d0df11a8 Update wind direction and precipitation window presentation 2026-06-10 09:24:06 -05:00
c3da3af2f4 Add a new narrative forecast module and remove the unimplemented daily forecast stub 2026-06-10 08:24:50 -05:00
7b760a0823 Refactor to separate each module and report into an individual file 2026-06-10 08:04:06 -05:00
1e9c29aa55 Cleanup after implementation of the module architecture and remove completed roadmap files 2026-06-10 07:49:29 -05:00
1ddd88231a Clarify module package documentation 2026-06-09 21:42:38 +00:00
d665049f05 Record final module cutover validation 2026-06-09 21:38:24 +00:00
1af6169999 Align docs with module prompt packages 2026-06-09 21:37:28 +00:00
468197f7e0 Remove obsolete briefing snapshot artifacts 2026-06-09 21:33:34 +00:00
816cfb24aa Compare recent changes from module snapshots 2026-06-09 21:20:53 +00:00
479d144592 Run report generation through module snapshots 2026-06-09 21:12:13 +00:00
0b516d9762 Write prompt data packages as YAML 2026-06-09 21:08:17 +00:00
2483c2362d Persist module snapshots for generated reports 2026-06-09 20:58:35 +00:00
40639309b1 Add configurable report module composition 2026-06-09 20:51:22 +00:00
eefb0681dc Implement derived forecast modules 2026-06-09 20:43:53 +00:00
9501dad1dc Implement source-oriented briefing modules 2026-06-09 20:35:57 +00:00
24dba3bd60 Add module contracts and registry validation 2026-06-09 20:30:49 +00:00
e9508089ab Add collected and derived fact contracts 2026-06-09 20:23:53 +00:00
454f47b2b5 Split weather data types from forecast derivation 2026-06-09 20:16:47 +00:00
d8b417458b Add a roadmap and a staged implementation plan to move weatherreporter toward deterministic, reusable briefing modules that can
be composed per report type
2026-06-09 15:09:31 -05:00
195a130124 Updated defaults to upload only the current generated report path 2026-06-09 11:26:50 -05:00
8577fc29e4 Updated the distributor bundle path template 2026-06-08 10:29:42 -05:00
d71c7e4d28 Implement the distributor v0.5 PipelineID update 2026-06-08 07:04:31 -05:00
9bc8156615 Update default config example to use a stable distributor bundle_id and a unique per-run idempotency_key 2026-06-07 20:46:19 -05:00
183b23cf5a Implemented debug artifacts for the distributor notification adapter 2026-06-07 20:21:26 -05:00
138bc4e7e4 Refresh distributor documentation 2026-06-07 23:50:55 +00:00
e2529cfdf2 Validate distributor notification implementation 2026-06-07 23:47:47 +00:00
b982b27f84 Document distributor notification behavior 2026-06-07 23:46:39 +00:00
c573cd5b4d Report distributor notification outcomes in batches 2026-06-07 23:42:27 +00:00
c2758d7a91 Notify distributor after report generation 2026-06-07 23:38:03 +00:00
64cae8c4d9 Add distributor upload adapter 2026-06-07 23:34:21 +00:00
a2ba6f5382 Add distributor notification config validation 2026-06-07 23:18:39 +00:00
7c8d9191c1 Add file-backed environment secrets 2026-06-07 23:13:54 +00:00
9677835d84 Add an implementation plan to support distributor uploads for produced artifacts 2026-06-07 18:04:40 -05:00
63749a9572 Implement support for NWS weather stories 2026-05-30 07:47:49 -05:00
9ff90d33fc Add Woodpecker CI support 2026-05-30 07:47:13 -05:00
163 changed files with 30397 additions and 4116 deletions

50
.woodpecker/release.yml Normal file
View File

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

View File

@@ -1,14 +1,15 @@
# 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.
reports from normalized forecast data. It builds JSON module snapshots, passes
YAML prompt data packages to `scriptorium`, and keeps inspectable artifacts
under a local workspace. It can also upload successfully generated managed
Markdown reports to a configured `distributor` HTTP upload endpoint.
## Quickstart
```sh
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
weatherreporter generate today --out ./today.md
```
## Documentation

View File

@@ -1,49 +1,164 @@
# Weatherreporter CLI
`weatherreporter` generates Markdown weather reports, runs scheduled report
batches, and inspects previously generated artifacts.
batches, and inspects stored artifacts.
## 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`.
This loads configuration, collects weather data, writes managed workspace
artifacts, runs `scriptorium render` as a preflight check, runs structured
`scriptorium run`, validates generated text, renders the embedded Today
template, and writes an extra Markdown copy to `./today.md`. If distributor
notification is enabled in configuration, the command also uploads the managed
Markdown report after final metadata is saved.
## Commands
```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`.
Implemented `generate` commands emit a compact JSON summary to stdout on
success. The summary includes command identity, report identity, RunID, status,
valid period, and managed artifact paths. They also write a JSON module
snapshot, YAML data package, preflight artifact, managed Markdown report, and
metadata under the configured workspace. `--out` writes an extra Markdown copy
for the operator; distributor notification uses the managed report path, not
the extra copy. `generate daily`,
`generate today`, `generate tomorrow`, and `generate hourly` write managed
generated-text artifacts, validate structured text from Scriptorium, and render
the managed Markdown report from embedded templates. `generate daily` requires
`--date YYYY-MM-DD` for the selected local civil day; omitting `--date` is a
command error and stops before weather data is collected. `generate hourly`
covers the next six hours in the effective report timezone and does not accept
date or event window flags. `generate storm` requires explicit event-window
bounds with `--start` and `--end`.
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
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.
`run morning` generates Today Report, Tomorrow Report, and a dated Daily Report
for each later future local civil day with complete hourly forecast coverage.
`run evening` generates Tomorrow Report and the same eligible future Daily
reports. Future Daily expansion starts with the day after tomorrow and skips
days that do not have every hourly forecast period for the local civil day.
Batch commands collect weather data once before planning; a collection failure
stops the batch before any report is generated. Batch runs continue independent
reports after a later report failure, print a JSON summary to stdout, write
compact status lines to stderr, and return nonzero when any report failed.
`--out-dir` writes extra Markdown copies for the operator; distributor
notification uses managed report paths, not the extra copies. Today and
Tomorrow use their report default copy names, and dynamic Daily copies use
`daily-YYYY-MM-DD.md`. When distributor and batch notification are enabled, a
fully successful batch uploads one distributor bundle after report generation
finishes. The JSON summary exposes that upload as a top-level `notification`
object, and stderr includes one `batchNotification` status line. If any planned
report fails, the batch notification is skipped for the whole batch.
`inspect` commands read existing workspace artifacts and emit JSON to stdout.
They do not fetch weather data or invoke `scriptorium`.
Hourly Report, 3-Day Outlook, and Weekend Outlook are explicit only; they are
not included in `run morning` or `run evening`.
`inspect` commands read existing workspace artifacts and emit the requested
JSON data to stdout. They do not collect weather data or invoke `scriptorium`.
Inspection commands do not accept `--quiet`.
## Output
Action commands, meaning `generate` and `run`, emit JSON summaries to stdout by
default. Pre-run errors, such as invalid flags, missing required arguments, or
configuration load failures, return an error without emitting partial JSON.
`--quiet` suppresses successful action-command stdout and routine stderr. It
does not hide returned errors. Inspection commands are data-output commands;
they always write the requested JSON to stdout and are not quietable.
Generate summaries have this shape:
```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"
},
"reportPath": "workspace/reports/today/2026-05-29/report.20260529T120000.000000000Z_today.md",
"metadataPath": "workspace/snapshots/today/2026-05-29/metadata.20260529T120000.000000000Z_today.json",
"dataPackagePath": "workspace/data-packages/today/2026-05-29/data_package.20260529T120000.000000000Z_today.yaml",
"preflightPath": "workspace/preflight/today/2026-05-29/render.20260529T120000.000000000Z_today.json",
"generatedTextRawPath": "workspace/snapshots/today/2026-05-29/generated_text_raw.20260529T120000.000000000Z_today.json",
"generatedTextResultPath": "workspace/snapshots/today/2026-05-29/generated_text_result.20260529T120000.000000000Z_today.json",
"generatedTextPath": "workspace/snapshots/today/2026-05-29/generated_text.20260529T120000.000000000Z_today.json",
"renderContextPath": "workspace/snapshots/today/2026-05-29/render_context.20260529T120000.000000000Z_today.json"
}
```
Markdown-path reports omit the generated-text fields. If distributor
notification is attempted, summaries include `notificationPath`; successful
notification also includes a compact `notification` object. If notification
fails after report artifacts exist, the summary has `"status": "failed"` and an
`error` string while retaining inspectable artifact paths.
Run summaries have this shape:
```json
{
"command": "run",
"batch": "morning",
"status": "succeeded",
"startedAt": "2026-05-29T12:00:00Z",
"finishedAt": "2026-05-29T12:01:00Z",
"total": 1,
"succeeded": 1,
"failed": 0,
"reports": [
{
"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"
},
"reportPath": "workspace/reports/today/2026-05-29/report.20260529T120000.000000000Z_today.md",
"metadataPath": "workspace/snapshots/today/2026-05-29/metadata.20260529T120000.000000000Z_today.json",
"dataPackagePath": "workspace/data-packages/today/2026-05-29/data_package.20260529T120000.000000000Z_today.yaml",
"preflightPath": "workspace/preflight/today/2026-05-29/render.20260529T120000.000000000Z_today.json"
}
]
}
```
`run` status is `failed` when any report failed or the top-level batch
notification failed. Batch stderr uses compact status lines, for example:
```text
report=today status=succeeded output="reports/today.md"
batch=morning total=2 succeeded=2 failed=0
```
## Flags
@@ -51,9 +166,10 @@ They do not fetch weather data or invoke `scriptorium`.
- `--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 PATH`: write an extra Markdown report copy where supported by the `generate` command.
- `--out-dir PATH`: write extra Markdown report copies for `run morning` and `run evening`.
- `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone.
- `--quiet`: suppress successful stdout and routine stderr for `generate` and `run`.
- `--date YYYY-MM-DD`: required date for `generate daily`; optional date for `generate today`, defaulting 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.
@@ -61,30 +177,40 @@ They do not fetch weather data or invoke `scriptorium`.
Storm times accept `YYYY-MM-DDTHH:MM` in the configured timezone or RFC3339
timestamps with explicit offsets.
Distributor notification is configured only through `notify.distributor`; there
are no distributor-specific CLI flags.
## Common Workflows
```sh
weatherreporter generate today --out ./today.md
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
weatherreporter generate tomorrow --out ./tomorrow.md
weatherreporter generate hourly
weatherreporter generate three-day --out ./three-day.md
weatherreporter generate weekend --out ./weekend.md
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
weatherreporter run morning --out-dir ./reports
weatherreporter run evening --out-dir ./reports
weatherreporter generate today --quiet
weatherreporter run morning --quiet
```
## Inspection
```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
weatherreporter inspect metadata 20260529T100000.000000000Z_daily
```
`inspect reports` lists recent generated runs with artifact paths and source
warning counts. The other inspect commands require a RunID. `inspect prior`
warning counts. The other inspect commands require a RunID. `inspect modules`
returns the persisted ordered module snapshot for a run. `inspect prior`
returns the prior comparable snapshot metadata selected from stored metadata, or
`null` when none exists. `inspect sources` shows source provenance and source
warnings without dumping full weather payloads.

View File

@@ -16,9 +16,9 @@ Precedence is:
2. configuration file
3. built-in defaults
The implemented configuration overrides are `--units` and `--tz`. Output flags
control report copies for the current command but do not change configuration
files. Environment-variable configuration is not implemented.
The CLI configuration overrides are `--units` and `--tz`. Output flags control
report copies for the current command but do not change configuration files.
Environment variables do not override configuration fields.
## Minimal Config
@@ -29,8 +29,8 @@ 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.
`weather_api.base_url` is required for commands that collect weather data.
Other fields fall back to defaults.
## Production-Oriented Config
@@ -41,7 +41,7 @@ config test suite.
### `weather_api`
- `base_url`: absolute base URL for the Weather API. Required for generation and fetch workflows.
- `base_url`: absolute base URL for the Weather API. Required for generation and collection workflows.
- `timeout`: HTTP timeout duration. Default: `10s`.
- `precision`: numeric precision query value. Default: `1`.
- `units`: Weather API units query value. Default: `us`.
@@ -53,7 +53,7 @@ Timezone values may be IANA names, configured aliases such as `Chicago` and
### `location`
`location` is descriptive prompt context included in briefing metadata and
`location` is descriptive prompt context included in module metadata and
Scriptorium data packages. It does not select a Weather API endpoint or enable
multiple configured forecast locations.
@@ -64,13 +64,108 @@ multiple configured forecast locations.
The prompt-facing location object also includes `timezone`, derived from the
effective `weather_api.timezone` after CLI overrides such as `--tz`.
### `secrets`
- `directory`: optional directory of file-backed environment secrets. Default:
empty, which disables secret loading.
When configured, each regular file directly under `secrets.directory` is loaded
after config file parsing and CLI overrides. The file basename must be a valid
environment variable name matching `[A-Za-z_][A-Za-z0-9_]*`; the file contents
become the environment variable value and overwrite any existing value. One
trailing LF or CRLF is stripped. Subdirectories, symlinks, invalid filenames,
missing directories, and unreadable files fail config loading.
### `notify`
`notify.distributor` controls distributor uploads after successful report
rendering. It is disabled by default and does not add CLI flags. When enabled,
`generate <report>` uploads one distributor bundle for the generated report
after final metadata is saved. `run morning` and `run evening` use
`notify.distributor.batch`: when batch notification is enabled and every
planned report succeeds, weatherreporter uploads one distributor bundle that
contains all managed Markdown reports from that batch.
- `enabled`: whether distributor notification config is active. Default:
`false`.
- `endpoint`: absolute distributor endpoint URL. Required when enabled.
Default: `https://distributor.example.com`.
- `token_env`: environment variable name that will contain the distributor
upload token. Required when enabled. Default: `DISTRIBUTOR_UPLOAD_TOKEN`.
- `timeout`: distributor operation timeout. Must be greater than zero when
enabled. Default: `30s`.
- `failure_policy`: must be `error` when enabled. Default: `error`.
- `pipeline_id_template`: template for single-report distributor pipeline IDs.
Required when enabled. Default: empty.
- `bundle_id_template`: template for single-report distributor bundle IDs.
Default: `weatherreporter.{location_id}.{report_id}`.
- `idempotency_key_template`: template for single-report distributor
idempotency keys. Default: `{bundle_id}.{run_id}`.
- `batch.enabled`: whether batch distributor notification config is active
when distributor notification is enabled. Default: `true`.
- `batch.pipeline_id_template`: template for batch distributor pipeline IDs.
Required when distributor notification and batch notification are enabled.
Default: `weatherreporter`.
- `batch.bundle_id_template`: template for batch distributor bundle IDs.
Required when distributor notification and batch notification are enabled.
Default: `weatherreporter.{location_id}.{batch}`.
- `batch.idempotency_key_template`: template for batch distributor idempotency
keys. Required when distributor notification and batch notification are
enabled. Default: `{bundle_id}.{batch_run_id}`.
Single-report templates support `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`. Date values use `YYYY-MM-DD`, time values use `HHMM`, and
stamp values use `YYYY-MM-DDTHHMM` in the effective report timezone.
`storm_id` is derived from the storm report valid period as
`{valid_start_stamp}-{valid_end_stamp}`; it renders empty for non-storm
reports. `pipeline_id_template` and `idempotency_key_template` may also use
`bundle_id`.
The rendered pipeline ID selects the configured distributor `http_upload`
workflow. The rendered bundle ID is the stable logical source identity for the
report stream. The rendered idempotency key is the per-run retry identity.
Batch templates support `location_id`, `batch`, `batch_run_id`, and
`batch_started_date`. Batch idempotency templates may also use `bundle_id`.
`batch_started_date` is the batch start date in the effective report timezone.
Batch bundle IDs identify a logical batch stream; batch idempotency keys
identify a specific retryable batch attempt.
Rendered report paths must be unique relative paths with `/` separators. They
must not contain backslashes, empty path segments, `.`, `..`, `manifest.json`,
or the reserved distributor sidecar basename, formed from a leading dot plus
`distributor.json`. In a batch upload, uniqueness is checked across every
rendered bundle path for every included report before distributor is called.
Managed Markdown report paths are the only upload source files; copies written
with `--out` or `--out-dir` are never uploaded.
Distributor bundle paths are report-specific. Weatherreporter uses
`reports.<report>.distributor.path_templates` when that override is configured;
otherwise it uses the report definition defaults:
- `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`
The upload token is read from the environment variable named by `token_env`
after config loading and `secrets.directory` processing. Config files should
name the variable only; they should not contain the token value.
### `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.
Hourly forecast data is required for generated reports. Optional sources use
the missing-source policy. Source override keys include `observations`,
`current`, `narrative`, `alerts`, `discussion`, `weather_story`, and
`spc_convective_outlooks`.
### `scriptorium`
@@ -83,13 +178,16 @@ stub source slots use the missing-source policy.
### `workspace`
- `root`: workspace root for managed artifacts. Default: `workspace`.
- `snapshots_dir`: briefing and metadata directory under `workspace.root`. Default: `snapshots`.
- `snapshots_dir`: module snapshot and metadata directory under `workspace.root`. Default: `snapshots`.
- `reports_dir`: managed Markdown report directory under `workspace.root`. Default: `reports`.
- `data_packages_dir`: prompt input package directory under `workspace.root`. Default: `data-packages`.
- `preflight_dir`: Scriptorium render output directory under `workspace.root`. Default: `preflight`.
- `notifications_dir`: distributor notification debug artifact directory under `workspace.root`. Default: `notifications`.
Workspace subdirectories must be relative paths that stay inside
`workspace.root`.
`workspace.root`. Managed artifact paths below those directories are grouped by
artifact group and valid-period start date; the path template is not
configurable.
### `dayparts`
@@ -110,19 +208,112 @@ midday, afternoon, and evening.
- `wind_gust_miles_per_hour`: wind gust change threshold. Default: `10`.
- `precip_timing_shift_minutes`: precipitation timing shift threshold. Default: `120`.
Recent Changes are added to prompt input when a prior comparable briefing
Recent Changes are added to prompt input when a prior comparable module
snapshot exists and a threshold is crossed.
### `reports`
`reports` optionally overrides the ordered deterministic modules declared by
report definitions. Omit a report entry to use its default module order.
Supported report keys are `daily`, `today`, `tomorrow`, `hourly`,
`three_day`, `weekend`, and `storm`. Canonical report IDs and accepted aliases
are also valid, including `three_day_outlook`, `weekend_outlook`, and
`storm_report`. Hyphens and underscores are treated equivalently in report
keys. Retired report keys are not supported.
`reports.today` applies only to the Today Report. `reports.daily` applies only
to the dated Daily Report.
Each report entry supports:
- `deterministic_modules`: ordered module list. Entries may be string module
IDs or objects with `id` and optional `options`.
- `distributor.path_templates`: optional ordered distributor bundle path
templates for this report. If omitted, the report definition defaults are
used. If present, the list must contain at least one template.
Example:
```yaml
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
- alert_digest
- spc_convective_outlooks
- id: area_forecast_discussion
options:
sections:
- long_term
- spc_convective_discussion
- 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
- area_forecast_discussion
- 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
```
Unknown reports, unknown modules, duplicate modules, incompatible report/module
combinations, duplicate stanza names, and invalid options fail config loading.
`area_forecast_discussion.options.sections` may contain `product`,
`key_messages`, `short_term`, and `long_term`. Empty or omitted `sections`
includes all available AFD sections. Default report definitions may choose a
smaller report-specific subset, such as daily reports using only `long_term`.
The module registry accepts all module IDs documented in
[Module Contract Internals](internal/module.md). Unknown or unimplemented
module IDs fail validation instead of being skipped.
## Secrets
Configuration files should not contain secrets. The current Weather API and
Scriptorium integration settings do not require secret fields.
Configuration files should not contain raw secrets. Use `secrets.directory` to
load secret values from files into environment variables for integrations that
read credentials from the environment. Secret file names become environment
variable names, and secret file contents become values. For distributor
notification, this allows a file such as
`<secrets.directory>/DISTRIBUTOR_UPLOAD_TOKEN` to supply the token referenced by
`notify.distributor.token_env`.
## Maintained Examples
- [examples/minimal-config.yml](../examples/minimal-config.yml): smallest
useful config for generation and fetching.
- [examples/config.yml](../examples/config.yml): production-oriented config
covering implemented fields.
covering maintained fields.
Both example files are loaded by the config test suite.

View File

@@ -0,0 +1,136 @@
# Upstream Producer Integration
Audience: developers and LLM coding agents adding `distributor` support to an upstream Go producer application.
This document is the copyable implementation guide for submitting producer outputs to a `distributor` pipeline whose source backend is `http_upload`.
## Required Inputs
The upstream application needs these values from deployment or operator configuration:
- distributor endpoint: the HTTP server base URL, such as `https://distributor.example.com`;
- upload token: bearer token that authenticates the producer;
- pipeline id: configured `http_upload` pipeline that should process this upload;
- generated files: regular local files to include in the source bundle;
- bundle id: stable identifier for the logical report stream or artifact;
- idempotency key: unique key for one producer run, reused only when retrying that same run.
Do not put destination routing, public URLs, transform settings, or credentials in the source manifest. Those belong in the `distributor` pipeline configuration.
The token, pipeline id, bundle id, and idempotency key have different jobs. The token authenticates the producer. The pipeline id selects the configured distributor workflow, including destinations and publishing policy. The bundle id tells `distributor` whether a new upload is a newer version of the same source; keep it stable across runs that should replace the same managed destination artifact. The idempotency key tells `distributor` whether an upload request is a retry; change it for each distinct producer run so new content is enqueued.
## Recommended Workflow
Use `gitea.maximumdirect.net/eric/distributor/pkg/upload`.
For most producers, use `UploadFiles`. It accepts producer-generated files, builds a temporary valid source bundle with `pkg/bundle`, uploads a gzip-compressed tar archive, and removes temporary files when the call returns.
Use `UploadBundle` only when the producer already assembled a complete bundle directory containing `manifest.json`.
Add the dependency from the upstream application:
```sh
go get gitea.maximumdirect.net/eric/distributor
```
## Minimal Go Example
```go
package reports
import (
"context"
"errors"
"fmt"
"os"
"time"
"gitea.maximumdirect.net/eric/distributor/pkg/bundle"
"gitea.maximumdirect.net/eric/distributor/pkg/upload"
)
func SubmitReport(reportPath, summaryPath string) error {
endpoint := os.Getenv("DISTRIBUTOR_UPLOAD_ENDPOINT")
token := os.Getenv("DISTRIBUTOR_UPLOAD_TOKEN")
if endpoint == "" || token == "" {
return fmt.Errorf("distributor endpoint and token are required")
}
pipelineID := "weather-hourly"
reportID := "weather.hourly.brentwood"
runID := time.Now().UTC().Format("20060102T150405.000000000Z")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
client, err := upload.NewClient(upload.ClientOptions{
Endpoint: endpoint,
Token: token,
})
if err != nil {
return err
}
result, err := client.UploadFiles(ctx, upload.UploadFilesOptions{
PipelineID: pipelineID,
ID: reportID,
IdempotencyKey: reportID + "." + runID,
Files: []bundle.BundleFile{
{SourcePath: reportPath, Path: "report.md"},
{SourcePath: summaryPath, Path: "summary.txt"},
},
})
if err != nil {
var conflict *upload.IdempotencyConflictError
if errors.As(err, &conflict) {
return fmt.Errorf("idempotency key was reused for different bundle content: %w", err)
}
return err
}
fmt.Printf("distributor accepted run %s\n", result.RunID)
return nil
}
```
## Producer Responsibilities
- Use a stable bundle id for the logical producer output that should replace the same destination artifact, such as `weather.hourly.brentwood`.
- Set `PipelineID` to the configured upload pipeline that should process the bundle.
- Do not include per-run timestamps, random values, or job ids in the bundle id unless each run should be treated as a different source.
- Use an idempotency key that changes for every distinct producer run, such as `<bundle-id>.<run-id>`.
- Reuse the same idempotency key only when retrying the exact same producer run with the same source manifest.
- Map each generated file to a clean slash-separated bundle path, such as `report.md` or `assets/chart.png`.
- Include only regular files. Symlinks, directories as files, devices, FIFOs, and sockets are rejected.
- Keep file contents stable after upload inputs are selected. Bundle digests are calculated from file bytes.
- Treat upload success as admission only. `UploadFiles` and `UploadBundle` return after the server accepts and validates the upload, not after all destinations publish.
Valid bundle paths are relative slash paths. They must not be empty, absolute, contain backslashes, contain `.` or `..` path segments, contain empty path segments, or use reserved basenames such as `manifest.json` and the distributor sidecar basename formed from a leading dot plus `distributor.json`.
## Idempotency And Status
`pkg/upload` sends `Idempotency-Key` on every upload. If the caller omits one, the package generates a random key for that call and reuses it for in-process retries. That is enough for transient network retry within one process, but it does not give cross-process retry identity.
For producer jobs that may retry after process restart, supply a key derived from the producer run, such as `<bundle-id>.<run-id>`. Reusing the same key with the same token, pipeline id, and normalized source manifest returns the original accepted run. Reusing the same key with different source content in that scope returns a conflict. Reusing one key across multiple distinct report generations prevents those generations from being treated as new uploads.
`Status` polls `/runs/<run-id>` while the distributor server retains the in-memory status record. Status values are `accepted`, `queued`, `running`, `succeeded`, and `failed`. Completed records expire according to the server's `server.http.retention` setting, and server restart clears status and idempotency records.
Optional status check:
```go
status, err := client.Status(ctx, result.RunID)
if err != nil {
return err
}
if status.Status == "failed" {
return fmt.Errorf("distributor run failed: %s", status.Error)
}
```
## References
In the `distributor` source tree:
- `docs/consumers/pkg-upload.md`: Go upload package workflow.
- `docs/consumers/pkg-bundle.md`: Go bundle package workflow.
- `docs/integrations/http-upload.md`: canonical HTTP upload wire contract.
- `docs/integrations/source-bundle.md`: canonical source bundle file-format contract.

View File

@@ -0,0 +1,91 @@
# `pkg/bundle`
Audience: upstream Go producer developers and LLM coding agents using `distributor` source bundle helpers.
Import path:
```go
import "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
```
`pkg/bundle` builds, writes, parses, and validates local source bundles. Use it directly when a producer writes bundles for `distributor` to discover, or when a producer wants to assemble and validate a bundle before using another transport.
The canonical source bundle file-format contract is [Source Bundle Contract](../integrations/source-bundle.md).
## Preferred Complete-Bundle Workflow
Use `WriteBundle` when producer-generated files live outside the final bundle root.
```go
manifest, err := bundle.WriteBundle(bundle.WriteBundleOptions{
Root: "/var/spool/distributor/weather/hourly-2026-06-07T15",
ID: "weather.hourly.brentwood",
Files: []bundle.BundleFile{
{SourcePath: "/tmp/weather/report.md", Path: "report.md"},
{SourcePath: "/tmp/weather/summary.txt", Path: "summary.txt"},
},
})
if err != nil {
return err
}
_ = manifest
```
`WriteBundle` copies each source file into a staged bundle root, writes `manifest.json`, validates the staged bundle, and promotes it into place. Set `Overwrite: true` only when the producer intentionally replaces an existing bundle root.
## Existing Bundle Root Workflow
Use `BuildManifest` and `WriteManifest` when files are already staged under the final bundle root.
```go
root := "/var/spool/distributor/weather/hourly-2026-06-07T15"
manifest, err := bundle.BuildManifest(bundle.BuildOptions{
Root: root,
ID: "weather.hourly.brentwood",
Files: []string{"report.md", "summary.txt"},
})
if err != nil {
return err
}
if err := bundle.WriteManifest(root, manifest, bundle.WriteManifestOptions{}); err != nil {
return err
}
if err := bundle.ValidateBundle(root, manifest); err != nil {
return err
}
```
Use `Scan: true` instead of `Files` only when every valid regular file under the root should be included. Scan mode includes dotfiles, skips reserved metadata files, rejects symlinks, and sorts paths lexically.
## Paths And Ordering
Bundle paths are slash-separated paths relative to the bundle root.
Invalid paths include:
- empty paths;
- absolute paths;
- paths containing backslashes;
- `.` or `..` path segments;
- empty path segments;
- any reserved basename, including `manifest.json` and the distributor sidecar
basename formed from a leading dot plus `distributor.json`.
Explicit file lists preserve caller order. File order is part of the bundle digest, so producers should choose it deliberately and keep it stable.
The manifest `ID` is the logical source identity used by `distributor` destination comparison. Keep it stable for runs that should replace the same managed destination artifact. If every run uses a different manifest `ID`, `distributor` treats those runs as different sources and may report a destination conflict instead of replacing older output.
## Validation And Digest Helpers
Use `ValidateBundle` before handing an existing local bundle to another process. It verifies manifest semantics, file existence, regular-file type, file size, per-file SHA-256 digests, and bundle digest.
Useful helpers:
- `LoadManifest`: read `manifest.json` from a bundle root.
- `ParseManifest` and `MarshalManifest`: parse or write manifest bytes.
- `ValidateManifest`: validate manifest-only semantics.
- `FileDigest`, `BundleDigest`, and `ValidateDigest`: digest helpers for diagnostics and tests.
## Boundaries
`pkg/bundle` does not upload bundles, publish destinations, transform Markdown, select pipelines, configure credentials, or write destination state. Those concerns belong to `pkg/upload` or the `distributor` application.

View File

@@ -0,0 +1,122 @@
# `pkg/upload`
Audience: upstream Go producer developers and LLM coding agents submitting bundles to `distributor serve`.
Import path:
```go
import "gitea.maximumdirect.net/eric/distributor/pkg/upload"
```
`pkg/upload` is the producer-facing HTTP upload client. It builds on `pkg/bundle`, packages valid source bundles as gzip-compressed tar archives, sends bearer authentication, routes uploads to a configured pipeline, includes idempotency keys, and exposes a status polling helper.
`UploadFiles` examples also use:
```go
import "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
```
The canonical HTTP wire contract is [HTTP Upload API Contract](../integrations/http-upload.md).
## Client Construction
```go
client, err := upload.NewClient(upload.ClientOptions{
Endpoint: "https://distributor.example.com",
Token: token,
})
if err != nil {
return err
}
```
`Endpoint` is the distributor server base URL. The client derives `/v1/pipelines/<pipeline-id>/upload` and `/runs/<run-id>`. `Token` is required and is sent as `Authorization: Bearer <token>`. Token values are redacted from client errors.
`HTTPClient` and `Retry` are optional. Defaults use a 30 second HTTP timeout and safe retry settings.
## Upload Producer Files
Use `UploadFiles` when the producer has generated output files but has not assembled a bundle directory.
```go
result, err := client.UploadFiles(ctx, upload.UploadFilesOptions{
PipelineID: "weather-hourly",
ID: "weather.hourly.brentwood",
IdempotencyKey: "weather.hourly.brentwood.20260607T150000Z",
Files: []bundle.BundleFile{
{SourcePath: "/tmp/weather/report.md", Path: "report.md"},
{SourcePath: "/tmp/weather/summary.txt", Path: "summary.txt"},
},
})
if err != nil {
return err
}
_ = result.RunID
```
`PipelineID` is required and selects the configured distributor workflow for this upload. `ID` is the source manifest id and identifies the logical artifact inside that workflow. `UploadFiles` creates a temporary bundle, writes and validates a manifest, uploads the archive, and removes temporary files when the call returns. It does not write into producer source directories.
## Upload An Existing Bundle
Use `UploadBundle` when the producer already has a complete local bundle root containing `manifest.json`.
```go
result, err := client.UploadBundle(ctx, upload.UploadBundleOptions{
PipelineID: "weather-hourly",
Root: "/var/spool/weather/hourly-2026-06-07T15",
IdempotencyKey: "weather.hourly.brentwood.20260607T150000Z",
})
if err != nil {
return err
}
_ = result.RunID
```
`PipelineID` is required for existing bundles too. `UploadBundle` validates the local bundle by default and uploads only `manifest.json` plus manifest-listed files. Unlisted files are not uploaded.
## Result And Status
Upload success means the server returned `202 Accepted` after staging and validating the upload. It does not mean all configured destinations have published.
Poll status while the server retains the in-memory run record:
```go
status, err := client.Status(ctx, result.RunID)
if err != nil {
return err
}
if status.Status == "failed" {
return fmt.Errorf("distributor run failed: %s", status.Error)
}
```
Status values are `accepted`, `queued`, `running`, `succeeded`, and `failed`. Completed records expire according to `server.http.retention`; server restart clears run status and idempotency records.
## Idempotency And Retry
Every upload request includes `Idempotency-Key`.
If `IdempotencyKey` is omitted, the client generates a random 128-bit lowercase hexadecimal key for that upload operation and reuses it for retries within the same call. For cross-process retry safety, producers should pass a key derived from the producer run, such as `<bundle-id>.<run-id>`.
Do not reuse the same idempotency key for multiple distinct report generations. Reuse it only when retrying the exact same run with the same token, pipeline id, and source manifest. A repeated key with the same manifest in that scope returns the original accepted run instead of enqueueing another run; a repeated key with different content returns an idempotency conflict.
The client retries only safe cases:
- `503 Service Unavailable`;
- temporary network errors;
- ambiguous mid-upload failures.
It does not retry after `202 Accepted` and does not retry `400`, `401`, `403`, `404`, `409`, `413`, or `415`.
Detect conflicting key reuse with `errors.As`:
```go
var conflict *upload.IdempotencyConflictError
if errors.As(err, &conflict) {
return fmt.Errorf("idempotency key was reused for different bundle content: %w", err)
}
```
## Boundaries
`pkg/upload` does not configure server pipelines, choose destinations, wait for publication completion automatically, persist client queues, provide durable idempotency across server restarts, or expose destination state. It submits complete source bundles to the configured HTTP upload API.

View File

@@ -6,7 +6,7 @@ This document describes the external Scriptorium CLI contract used by
## Purpose
`weatherreporter` invokes Scriptorium as a subprocess to preflight prompt input
and generate Markdown reports. This page documents the CLI surface the adapter
and generate report artifacts. This page documents the CLI surface the adapter
uses, not the full Scriptorium product.
## Commands Used
@@ -29,9 +29,24 @@ scriptorium run \
--out <artifact_path>
```
Structured generated-text report generation uses the same command shape:
```bash
scriptorium run \
--prompt <prompt_id> \
--input data_package=<path> \
--out <generated_text_raw_path>
```
`weatherreporter` always passes prompt input as
`--input data_package=<path>`. The data package is structured JSON created by
`internal/promptinput`.
`--input data_package=<path>`. The data package is structured YAML created by
`internal/promptinput`; module snapshots remain separate JSON artifacts for
inspection and Recent Changes.
For generated-text reports, Scriptorium selects the structured output schema
from the prompt configuration associated with the prompt ID. `weatherreporter`
does not pass `--format`, schema path, or JSON Schema flags for structured
generation.
## Configured Arguments
@@ -67,11 +82,17 @@ Render results include:
- exit code
- truncation flags when applicable
Run results include the same fields plus the requested output path.
Run results include the same fields plus the requested output path. Structured
generated-text run results use the same captured fields and output-path
recording, with the output path pointing at the raw generated-text JSON
artifact.
`weatherreporter` persists render preflight JSON when orchestration reaches the
preflight save point. The final Markdown artifact is written by Scriptorium to
the `--out` path.
preflight save point. For direct Markdown reports, Scriptorium writes the
managed Markdown artifact to the `--out` path. For generated-text-template
reports, Scriptorium writes raw JSON to the `--out` path; later
weatherreporter workflow steps validate those bytes and render Markdown from an
embedded template.
## Failure Behavior
@@ -79,7 +100,7 @@ The adapter validates required request fields before starting Scriptorium:
- prompt ID
- data package path
- output path for `run`
- output path for `run` and structured generated-text `run`
Nonzero exits return both the captured result and an error containing the exit
code and stderr. A `run` exit code such as `2` is still treated as an error by

View File

@@ -6,7 +6,7 @@ This document describes the external Weather API contract used by
## Purpose
`weatherreporter` uses a configured Weather API base URL to fetch normalized
weather source data and assemble a `forecast.Bundle`. This is an integration
weather source data and assemble a `weatherdata.Bundle`. This is an integration
contract for the project adapter, not a complete public API reference for the
upstream service.
@@ -37,6 +37,10 @@ generation.
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.
For `/outlooks/convective`, `data: null` means no latest run is available and
follows missing-source policy. A non-null run with empty `outlooks` and
`discussions` arrays is checked empty data, not a missing source.
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.
@@ -45,15 +49,70 @@ they fail the fetch; optional malformed sources follow the missing-source policy
The adapter sends these query parameters:
- `format`: from `weather_api.format`; the implemented configuration requires
`json`
- `format`: from `weather_api.format`; configuration validation requires `json`
- `units`: from `weather_api.units`
- `precision`: from `weather_api.precision` on observations, current
conditions, hourly forecast, and narrative forecast requests
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast, and
discussion requests
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast,
discussion, and SPC convective outlook requests
Alerts do not receive `precision` or `tz`.
Alerts do not receive `precision` or `tz`. Weather story requests receive only
`format=json`. SPC convective outlook requests receive only `format=json` and
`tz`; they do not receive `units` or `precision`.
## SPC Convective Outlooks
The adapter fetches SPC convective outlook data from:
```text
GET /outlooks/convective?format=json&tz=<weather_api.timezone>
```
The response uses the standard `data` envelope. `data: null` means no latest
run is available and follows missing-source policy. A non-null object with
empty `outlooks` and `discussions` arrays is accepted as checked empty data.
Run fields consumed by weatherreporter:
- `locationId`
- `locationName`
- `asOf`
- `issuedAt`
- `updatedAt`
- `product`
- `outlooks`
- `discussions`
Outlook fields consumed:
- `id`
- `provider`
- `product`
- `day`
- `outlookType`
- `label`
- `labelText`
- `forecaster`
- `severityRank`
- `validFrom`
- `validTo`
- `issuedAt`
- `expiresAt`
- `sourceUrl`
- `imageUrl`
- `containsLocation`
- `geometry`
Discussion fields consumed:
- `day`
- `headline`
- `summary`
- `discussion`
- `updatedAt`
GeoJSON `geometry` is decoded into collected weather facts and persisted in
bundle/debug artifacts, but prompt-facing SPC module output omits geometry.
## Endpoints Used
@@ -65,6 +124,8 @@ The adapter fetches these endpoints once per bundle:
- `/forecast/narrative`
- `/alerts/active`
- `/discussion`
- `/weatherstories/latest`
- `/outlooks/convective`
`weatherreporter` does not call day-slice forecast endpoints or discussion
subsection endpoints. Report-period selection and daypart summarization happen
@@ -86,10 +147,8 @@ source-specific `missing_source.sources` policy:
- `narrative` for `/forecast/narrative`
- `alerts` for `/alerts/active`
- `discussion` for `/discussion`
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.
- `weather_story` for `/weatherstories/latest`
- `spc_convective_outlooks` for `/outlooks/convective`
Policy behavior:
@@ -101,6 +160,9 @@ For `/alerts/active`, an HTTP error or missing `data` field still fails or
follows the relevant error path, but explicit `data: null` is not a
missing-source condition.
For `/outlooks/convective`, a non-null data object with empty outlook and
discussion arrays is accepted as checked empty data.
## Source Identity
For source payloads accepted into the bundle, including the explicit `null`
@@ -118,14 +180,18 @@ warnings list.
## Compatibility Assumptions
The adapter expects payload fields compatible with the internal forecast bundle
types in `internal/forecast/bundle.go`, including:
The adapter expects payload fields compatible with the internal weather data
bundle types in `internal/weatherdata/bundle.go`, including:
- observation timestamps and observation values
- current condition values
- forecast run metadata and `periods`
- active alert run data
- discussion metadata, key messages, and short/long-term section text
- latest weather story title, description, timing, priority, order, alt text,
and download URL
- SPC convective outlook run metadata, outlooks, discussions, and GeoJSON
geometry
The adapter intentionally keeps upstream transport and envelope details inside
`internal/adapters/weatherapi`; downstream packages consume the normalized

View File

@@ -1,14 +1,16 @@
# App Orchestration Internals
This document describes the implemented workflow coordinator in `internal/app`.
This document describes the 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.
loading are complete. It resolves report definitions, collects weather data
through `internal/collect`, builds collected and derived facts, builds module
snapshots and prompt-input artifacts, invokes Scriptorium through the adapter
boundary, optionally notifies distributor through an app-owned notifier
boundary, persists managed state, runs batches, and reads existing artifacts
for inspection.
## Inputs And Outputs
@@ -16,78 +18,164 @@ Inputs:
- `GenerateRequest` for one report command
- `BatchRequest` for morning or evening batch commands
- `FetchBundleRequest` for explicit bundle fetch and save workflows
- `BriefingRequest` and `ReportRequest` for package-level orchestration tests
and internal composition
- `FetchBundleRequest` for explicit bundle collection and save workflows
- `ReportRequest` for single-report generation
- resolved report definitions from `internal/report`
- forecast bundles from `internal/adapters/weatherapi`
- collection results from `internal/collect`
- prior snapshots loaded from `internal/state`
- optional renderer and state-store fakes for tests
- optional collector, renderer, notifier, and state-store fakes for tests
Outputs:
- generated report results with briefing, data package, preflight, report,
metadata, prior snapshot, Recent Changes, and Scriptorium result details
- batch summaries with per-report status, artifact paths, and error text
- saved Weather API bundle JSON for fetch workflows
- inspection JSON values for reports, metadata, briefings, data packages, prior
snapshots, and source provenance
- generated report results with JSON module snapshot, YAML data package,
preflight, report, metadata, prior snapshot, Recent Changes, Scriptorium
result details, generated-text artifact paths when applicable, and
notification result when attempted
- batch summaries with per-report status, artifact paths, error text, and
one top-level batch notification result when attempted or skipped
- saved Weather API bundle JSON for explicit bundle collection workflows
- inspection JSON values for reports, metadata, module snapshots, data
packages, prior snapshots, and source provenance
## Boundaries
`internal/app` owns workflow order and request composition. It does not parse
CLI flags, load YAML files directly, implement HTTP transport, derive forecast
facts, define report periods, compare rendered Markdown, or construct
Scriptorium argv.
CLI flags, load YAML files directly, implement HTTP transport, own fact
derivation algorithms, define report periods, compare rendered Markdown, or
construct Scriptorium argv.
Report selection and report identity policy come from `internal/report`.
Weather API transport stays in `internal/adapters/weatherapi`. Scriptorium
subprocess behavior stays in `internal/adapters/scriptorium`. Filesystem layout
and persisted metadata stay in `internal/state`.
Collected and derived fact contracts come from `internal/facts`.
Weather API transport stays in `internal/adapters/weatherapi`, and app-facing
upstream collection stays in `internal/collect`. Scriptorium subprocess
behavior stays in `internal/adapters/scriptorium`. Distributor upload behavior
stays in `internal/adapters/distributor`. Filesystem layout and persisted
metadata stay in `internal/state`.
## Data Flow Terms
- `collect.Result` is the app-facing upstream collection result. It carries the
normalized `weatherdata.Bundle` used by report generation.
- `CollectedFacts` are normalized source facts derived from a collected Weather
API bundle and made available to derivation and module builders.
- `DerivedFacts` are deterministic calculations over collected facts, the
resolved valid period, daypart configuration, and report-specific windows.
- `module.Output` values are ordered deterministic stanzas built from collected
and derived facts for prompt input and inspection.
- `GeneratedText` is structured prose returned by Scriptorium for
generated-text-template reports and validated by `internal/generatedtext`.
- `RenderContext` is the typed template input built from report metadata,
module outputs, and validated generated text before Markdown rendering.
## Config Fields Used
- `weather_api.*` for Weather API client construction and briefing metadata
- `weather_api.*` for Weather API client construction and module metadata
- `scriptorium.*` for renderer construction
- `workspace.*` for filesystem state
- `dayparts` for daily and outlook summarization
- `recent_change.*` for structured Recent Changes thresholds
- `notify.distributor.*` for optional single-report and batch notification
after report generation
Output copy flags are command request fields. They are not configuration
defaults.
## Generation Workflow
Single-report generation follows this order:
Single-report commands validate the report command, collect once through
`internal/collect`, resolve the requested report, and pass the resolved report
plus explicit collection into `GenerateReport`. `GenerateDetailed` returns the
resulting `ReportResult`; `Generate` wraps the same workflow for error-only
callers.
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.
`GenerateReport` then uses this setup:
1. Create or use a filesystem store.
2. Locate any prior compatible snapshot through `internal/state`.
3. Build collected and derived facts from the supplied collection.
4. Execute configured modules and save the module snapshot.
5. Compute Recent Changes from structured prior and current module snapshots.
6. Build and save the YAML Scriptorium `data_package`.
7. Run Scriptorium render preflight.
8. Save preflight JSON when a render result is available.
9. Save metadata for inspection.
For `scriptorium_markdown` reports, generation then:
10. Runs Scriptorium report generation to the managed report path.
For `generated_text_template` reports, generation then:
10. Looks up the generated-text catalog entry for the report schema/template
IDs.
11. Runs structured Scriptorium generation to the raw generated-text JSON path.
12. Saves the structured Scriptorium run result.
13. Validates and saves normalized generated text.
14. Builds and saves a typed render context.
15. Renders Markdown from the embedded template to the managed report path.
After either mode has produced a managed Markdown report, shared finalization:
1. Copies the managed report to the requested `--out` or `--out-dir` path when
provided.
2. Saves final metadata with the managed report path and any generated-text
artifact paths already produced.
3. If distributor notification is enabled, notifies using the managed report
path as the source file.
4. Saves a distributor notification debug artifact and updates metadata with
its path.
If render preflight returns both a result and an error, preflight JSON and
metadata are persisted before the error is returned. If Scriptorium report
generation returns an error after writing output, the managed report and
metadata remain inspectable.
metadata remain inspectable. Notification is not attempted after collection,
module snapshot, prompt input, render, Scriptorium run, or metadata-save
failures.
Generated-text report failures are returned with report ID, RunID, and the
failed operation. When available, the app preserves the latest generated-text
artifacts already reached by the workflow: preflight output, structured run
result, raw generated text, validated generated text, and render context.
When notification is attempted, the debug artifact records request identity,
including rendered pipeline ID, bundle paths, accepted upload fields,
distributor status fields, raw status report JSON when available, and redacted
failure context.
`--out` copies are never used as notification source files.
## Batch Workflow
`run morning` resolves Daily Today, 3-Day Outlook, and Weekend Outlook except
on Sunday. `run evening` resolves Daily Tomorrow. Batch output copy names come
from report definitions. Batch generation continues independent reports after a
`run morning` collects once, plans Today Report, Tomorrow Report, and eligible
future Daily Reports from the collected hourly forecast, then passes the same
collection into each report generation. `run evening` uses the same collection
and planning rules, but starts with Tomorrow Report. Future Daily reports start
with the day after tomorrow and require complete hourly forecast coverage for
the target local civil day. Dynamic Daily `--out-dir` copies use
`daily-YYYY-MM-DD.md`; other batch copies use report definition output names.
A collection failure stops the batch before planning or report generation.
After planning succeeds, 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.
Batch report generation suppresses per-report distributor notification. After
all planned reports finish, app orchestration evaluates batch notification:
1. If distributor notification is disabled, the batch notification result is
omitted.
2. If batch notification is disabled, the batch notification result is omitted
and there is no per-report fallback upload.
3. If any planned report failed, the batch notification result is `skipped`
with reason `one or more reports failed`, and distributor is not called.
4. If every report succeeded, app orchestration renders batch pipeline, bundle
ID, and idempotency key templates, renders report-specific distributor
paths for each included report, validates every managed source path and
bundle path, checks duplicate bundle paths across the batch, calls the
notifier once with a multi-file request, and saves a batch notification
debug artifact.
Batch notification failure records a top-level failed notification, increments
the aggregate batch failure count, and returns an aggregate batch error without
marking individual report items failed. `--out-dir` copies are never used as
notification source files.
## Inspection Workflow
Inspection workflows load existing filesystem state only. They do not fetch
@@ -97,10 +185,19 @@ inspection view.
## Failure Behavior
- Resolve errors stop the requested workflow before fetching weather data.
- Weather API and briefing errors stop that report before Scriptorium runs.
- Resolve errors stop the requested workflow before collection.
- Collection and module execution errors stop that report before Scriptorium
runs.
- Prompt input validation fails before render preflight.
- Render and run errors preserve Scriptorium stderr and exit-code context.
- Generated-text report errors preserve available intermediate artifacts and do
not create extra output copies.
- Single-report notification errors are wrapped with report ID, RunID, and
managed report path context. Detailed generation returns the inspectable
report, metadata, and notification artifact paths when finalization has
already saved them.
- Batch notification errors are recorded on the top-level batch notification
result and do not change individual report item status.
- Metadata and artifact path errors include filesystem context.
- Batch failures are recorded per report and surfaced through an aggregate
batch error.
@@ -110,14 +207,27 @@ inspection view.
Inspect:
- `internal/app/app_test.go`
- `internal/app/batch_plan_test.go`
- `internal/collect/collect_test.go`
- `internal/cli/root_test.go`
- `internal/state/filesystem_test.go`
## Invariants
- Report behavior is resolved through `internal/report`.
- Generate and run commands collect once before report generation.
- Batch planning is app-owned because future Daily membership depends on
collected hourly forecast coverage.
- 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.
- Generated-text reports render Markdown from a curated render context, not from
a raw data package.
- Recent Changes are computed from structured module snapshots.
- Metadata links artifacts produced for a run.
- Single-report distributor notification maps the managed Markdown report path
to configured bundle paths.
- Batch distributor notification maps each included managed Markdown report
path to bundle paths rendered for that report and uploads once for the
batch.
- Extra output copies are not upload sources.

View File

@@ -1,45 +1,114 @@
# Briefing Internals
# Module Builder Internals
This document describes the implemented briefing package boundary.
This document describes module builder behavior in `internal/briefing`.
## Purpose
`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.
`internal/briefing` turns report metadata, collected weather data, and derived
forecast facts into prompt-facing module outputs. The package also owns the
module registry used to validate report composition and config overrides.
Module outputs are structured prompt inputs. They are not rendered report prose
and they are not persisted by this package.
## Inputs And Outputs
Inputs:
- resolved report definition, generation time, timezone, and valid period
- forecast bundle with source provenance and warnings
- derived daily or period summaries where required
- collected facts built from `weatherdata.Bundle`
- derived daily, daypart, precipitation, alert, and storm-window facts where
required
- configured units, timezone, and descriptive location context
- typed module options from report defaults or config overrides
Outputs:
- `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`
- `ModuleDefinition` values with module ID, stanza name, option type,
supported reports, fact requirements, missing-data behavior, and builder
- `module.Output` values for source-oriented stanzas:
`metadata`, `current_conditions`, `narrative_forecast`, `hourly_forecast`,
`alert_digest`, `spc_convective_outlooks`,
`area_forecast_discussion`, `spc_convective_discussion`, and
`weather_story`
- `module.Output` values for derived stanzas:
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
`outdoor_windows`, `today_planning`, `tomorrow_planning`, and
`daily_planning`
Every registered composition entry has a builder. Unknown or unimplemented
module IDs fail validation instead of being skipped.
Daily Report supports the Daily-style civil-day modules plus `daily_planning`
and `hourly_forecast`; those outputs feed the dated Daily GeneratedText prompt
package and embedded Markdown template.
Tomorrow Report supports the Daily-style civil-day modules plus
`tomorrow_planning` and `hourly_forecast`; those outputs feed the Tomorrow
GeneratedText prompt package and embedded Markdown template.
Today Report supports the Daily-style civil-day modules plus `today_planning`
and `hourly_forecast`; those outputs feed the Today GeneratedText prompt
package and embedded Markdown template.
`today_planning` is a Today-specific deterministic planning stanza with
morning readiness, commute/school/workday concerns, outdoor planning, and
late-day change-watch fields. It is compatible with `report.Today` only.
`daily_planning` is a dated Daily deterministic planning stanza with morning
readiness, commute/school/workday concerns, and overnight change-watch fields.
It is compatible only with the `daily` report ID value. The default Daily
Report composition includes it.
Hourly Report supports source and valid-period modules that operate over its
rolling six-hour period: `metadata`, `current_conditions`, `hourly_forecast`,
`precip_timing`, `alert_digest`, `spc_convective_outlooks`,
`area_forecast_discussion`, `spc_convective_discussion`, and `weather_story`.
It does not support daily/daypart-only modules such as
`derived_daily_summary`, `derived_daypart_summaries`, `outdoor_windows`,
`today_planning`, `tomorrow_planning`, or `daily_planning`.
Prompt-facing module values use local, human-readable date and time labels
where the LLM is expected to reason about report content. Canonical timestamps
remain in report metadata, source provenance, and integration artifacts.
## Boundaries
- 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.
- This package selects and shapes already-collected weather facts for prompts.
- It validates module composition against report compatibility and option
types.
- It does not collect weather data, compare prior snapshots, write module
snapshots, build YAML data packages, invoke Scriptorium, or write workflow
metadata.
## Config Fields Used
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
The app layer passes effective units, timezone, and location context into the
module context. `internal/facts` consumes daypart configuration before module
builders run. Configured `location` values are prompt context only; Weather API
`sourceLocationId` and `sourceLocation` remain source provenance.
Current conditions are copied from the normalized `/conditions/current` bundle
source only; observation station and timestamp fields remain provenance.
`area_forecast_discussion` uses optional `sections` configuration to include a
subset of discussion fields. Hourly Report defaults this module to
`key_messages` and `short_term`; Daily Report defaults it to `long_term`.
`spc_convective_outlooks` uses collected SPC run metadata and derived
report-period outlooks. It emits `checked: true` for a successfully fetched
empty run, reports `outlook_count`, and includes prompt-facing outlook fields
such as risk label, `period_begins`, `period_ends`, image URL, and whether the
outlook contains the configured location. It also emits a curated `risk_digest`
for categorical outlooks that overlap the report period, contain the location,
and meet the configured-in-code minimum severity for report rendering. It does
not emit GeoJSON geometry, source URL, expiration time, or severity rank.
Prompt-facing module intervals use friendly local `period_begins` and
`period_ends` labels. Canonical report metadata, source provenance,
`issued_at`, `updated_at`, and point-in-time fields remain separate.
`spc_convective_discussion` uses the same derived report-period outlooks and
discussion records. It is omitted unless at least one retained categorical
outlook for the same SPC day has severity rank `3` or higher and matching
discussion text exists.
## External Adapters Used
@@ -47,39 +116,45 @@ None directly.
## State Or Manifest Behavior
`briefing.Save` writes briefing JSON atomically. Managed workspace placement is
owned by `internal/state`.
None. `internal/app` collects module outputs into a `module.Snapshot`, and
`internal/state` persists that snapshot.
## Skip And Resume Behavior
None. Builders either return a complete briefing package or an error.
None. Builders either emit a module output, omit optional unavailable data, or
return an error for invalid required inputs.
## 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.
- Required derived modules return errors when their dependent facts are not
available.
- Module registry construction rejects duplicate module IDs and duplicate
stanza names.
- Composition validation rejects unknown modules, duplicate modules,
incompatible report/module combinations, duplicate stanza names, and invalid
option shapes.
- Source-oriented module builders omit missing optional current conditions,
forecast discussion, and weather story stanzas.
- Alert digest output distinguishes checked empty alert data from missing alert
source data.
- SPC convective outlook output distinguishes checked empty outlook data from
missing outlook source data and omits GeoJSON geometry from prompt-facing
fields.
- SPC convective discussion output is omitted unless a retained outlook has
severity rank `3` or higher and matching discussion text is available.
## Tests
Inspect:
- `internal/briefing/daily_test.go`
- `internal/briefing/three_day_test.go`
- `internal/briefing/weekend_test.go`
- `internal/briefing/storm_test.go`
- `internal/briefing/base_modules_test.go`
- `internal/briefing/derived_modules_test.go`
- `internal/briefing/modules_test.go`
- `internal/app/app_test.go`
## Invariants
- Briefings contain structured weather facts and source context.
- Module outputs contain structured weather facts and source context.
- Common metadata includes RunID, report ID, prompt ID, valid period, source
provenance, source hashes, source warnings, and configured prompt location.
- LLM prompt input packaging and Scriptorium execution remain outside this
boundary.
- Prompt input packaging and Scriptorium execution remain outside this package.

View File

@@ -4,15 +4,15 @@ This document describes structured Recent Changes comparison.
## Purpose
`internal/changes` compares current and prior briefing packages and emits
`internal/changes` compares current and prior module snapshots and emits
compact change records for prompt input data packages.
## Inputs And Outputs
Inputs:
- prior briefing package
- current briefing package
- prior module snapshot
- current module snapshot
- comparison thresholds from configuration
Outputs:
@@ -22,7 +22,7 @@ Outputs:
## Boundaries
- This package compares structured briefing data only.
- This package compares structured module snapshot data only.
- It does not read filesystem state, find prior snapshots, render Markdown,
invoke Scriptorium, or compare generated report text.
@@ -41,7 +41,7 @@ None.
## State Or Manifest Behavior
None directly. The app loads prior briefing snapshots through `internal/state`
None directly. The app loads prior module snapshots through `internal/state`
before calling comparison functions.
## Skip And Resume Behavior
@@ -51,11 +51,12 @@ 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.
- Daily comparison requires `derived_daily_summary` and
`derived_daypart_summaries` stanzas. It also uses `alert_digest` and
`precip_timing` when present.
- 3-Day comparison requires `derived_daypart_summaries`.
- Weekend comparison requires `derived_daypart_summaries`.
- Storm Report comparison returns no changes.
## Tests

67
docs/internal/cli.md Normal file
View File

@@ -0,0 +1,67 @@
# CLI Internals
This document describes command output ownership in `internal/cli`.
## Purpose
`internal/cli` owns command parsing, app request construction, help text, and
presentation of command results. It converts app-layer results into stable CLI
summaries and writes stdout/stderr through shared output helpers.
## Command Categories
- Action commands: `generate` and `run`. These perform work, write artifacts,
and return compact summaries.
- Inspection commands: `inspect reports`, `inspect metadata`, `inspect
modules`, `inspect data-package`, `inspect prior`, and `inspect sources`.
These read existing artifacts and return requested data.
Future commands must declare which category they belong to before adding output
behavior.
## Stdout And Stderr
Action commands write JSON summaries to stdout by default. `run` also writes
compact status lines to stderr through `writeBatchStatus`. `generate` does not
write routine stderr today. Pre-run errors return without partial JSON.
Inspection commands write requested JSON data to stdout with `writeJSON`. They
do not use action output helpers and do not support quiet mode.
Returned errors are not hidden by output helpers. The caller remains
responsible for displaying command errors.
## Quiet Mode
`--quiet` is supported only by action commands. It suppresses successful stdout
and routine stderr by passing `outputOptions{Quiet: true}` to
`writeActionResult`. It does not suppress returned errors.
Quiet mode is intentionally not accepted by inspection commands because
inspection stdout is the command result.
## Summary Ownership
CLI-safe summary structs live in `internal/cli/result.go`.
- `newGenerateSummary` converts `*app.ReportResult` plus an optional error into
the generate JSON contract.
- `newBatchSummary` converts `*app.BatchResult` into the run JSON contract and
derives the top-level run status.
Summary types must not expose full app internals, module contents, data package
contents, raw generated text, Scriptorium result bodies, or full distributor
payloads.
## Helper Path
New action commands should:
1. parse command-specific flags into CLI option structs;
2. call the app-layer use case;
3. convert app results into a CLI summary type;
4. write through `writeActionResult`;
5. use a status writer only for routine stderr status lines.
New inspection commands should call the app inspection use case and write the
returned data through `writeJSON`.

58
docs/internal/collect.md Normal file
View File

@@ -0,0 +1,58 @@
# Collection Internals
This document describes the app-facing upstream collection boundary in
`internal/collect`.
## Purpose
`internal/collect` is the canonical package used by app workflows to collect
upstream Weather API data. It constructs the Weather API adapter, fetches a
normalized bundle, and returns that bundle without applying report selection or
batch policy.
## Contract
Inputs:
- `collect.Request`, containing the effective `config.Config`
- `context.Context` for cancellation
Output:
- `collect.Result`, containing `*weatherdata.Bundle`
`Run` returns an actionable error when Weather API adapter construction or
bundle fetch fails. The package does not derive `facts.CollectedFacts`, build
modules, resolve report periods, select reports, write state, invoke
Scriptorium, or notify distributor.
## App Usage
`internal/app` owns a narrow `Collector` interface for orchestration tests. The
default implementation calls `collect.Run`.
Single-report generation collects once, resolves the requested report, and
passes the explicit collection into report generation. Batch generation
collects once before planning and passes the same collection into each planned
report. If collection returns no bundle, app orchestration returns an error
before report generation.
## Boundaries
Weather API HTTP details stay in `internal/adapters/weatherapi`. The collection
package returns normalized `weatherdata` only. It must not know about report
IDs, prompt IDs, batch names, Daily eligibility, module composition, Recent
Changes, state paths, or Scriptorium arguments.
## Tests
Inspect:
- `internal/collect/collect_test.go`
- `internal/app/app_test.go`
## Invariants
- App-facing Weather API collection goes through `internal/collect`.
- Collection returns normalized source data, not report facts or prompt input.
- Report and batch policy belongs outside `internal/collect`.

View File

@@ -0,0 +1,134 @@
# Distributor Adapter Internals
This document describes the distributor upload adapter in
`internal/adapters/distributor`.
## Purpose
The adapter submits generated weatherreporter Markdown reports to a configured
distributor HTTP upload endpoint. It supports one or more file mappings per
upload request. It isolates distributor package types, token-env lookup, upload
client construction, source-bundle file mapping, timeout handling, status
polling, and upload error wrapping from app orchestration.
## Inputs And Outputs
Inputs:
- distributor endpoint URL
- token environment variable name
- upload timeout
- pipeline ID
- bundle ID
- idempotency key
- source Markdown report paths and bundle-relative path mappings
- bundle created timestamp
- context for cancellation
Outputs:
- accepted distributor run ID
- accepted distributor upload status
- distributor run status, status polling error, and raw run report JSON when available
- weatherreporter-owned idempotency conflict error when applicable
## Boundaries
`internal/adapters/distributor` is the only weatherreporter package that imports
`gitea.maximumdirect.net/eric/distributor/pkg/upload` or
`gitea.maximumdirect.net/eric/distributor/pkg/bundle`.
The app layer passes weatherreporter-owned request values to the adapter. The
adapter does not choose report types, render templates, select output copies,
decide whether an upload represents one report or a batch, configure
destinations, wait for downstream publication, transform Markdown, or persist
notification state.
Full upstream distributor package and HTTP contract details stay under
`docs/integrations/distributor/`.
## Config Fields Used
The adapter is built from `notify.distributor` config:
- `endpoint`
- `token_env`
- `timeout`
The app layer renders single-report pipeline ID, bundle ID, idempotency key,
and bundle paths from:
- `pipeline_id_template`
- `bundle_id_template`
- `idempotency_key_template`
- report-specific path templates
For batch uploads, the app layer renders pipeline ID, bundle ID, and
idempotency key from `notify.distributor.batch.*`, resolves report-specific
path templates once per included report, and passes the resulting multi-file
request to this adapter.
Report-specific path resolution happens entirely in the app layer. Explicit
`reports.<report>.distributor.path_templates` overrides take precedence over
report definition defaults.
The token value is read from the environment variable named by `token_env`
after config loading and `secrets.directory` processing.
## Upload Behavior
The adapter calls distributor `UploadFiles` with one or more file mappings:
- pipeline ID: the rendered distributor workflow selector
- source paths: managed Markdown report paths selected by app orchestration
- bundle paths: rendered bundle-relative report paths for each source
- created: the report or batch generation timestamp
The adapter creates a distributor upload client with the configured endpoint,
bearer token, and timeout-backed HTTP client. It also wraps the upload context
with the configured timeout when the timeout is greater than zero.
After upload acceptance, the adapter polls distributor `Status` for the accepted
run ID until the run reaches `succeeded` or `failed`, or until the configured
timeout expires. It returns the latest status, error text, and raw report JSON in
weatherreporter-owned types so app orchestration can persist them in the
notification debug artifact. Status lookup failures or timeout before a terminal
state are kept as debug status errors on an otherwise accepted upload. A
terminal distributor run status of `failed` is returned as a notification failure
with the status report preserved.
## Failure Behavior
The adapter validates required endpoint, token env name, token value, pipeline
ID, bundle ID, idempotency key, upload files, source paths, bundle paths, and
upload client inputs before uploading.
Upload failures include endpoint, pipeline ID, bundle ID, idempotency key,
source paths, and bundle paths context. Token values are redacted from adapter
errors.
Distributor idempotency conflicts are exposed as a weatherreporter-owned
`IdempotencyConflictError`, so callers do not depend on upstream distributor
types.
## Tests
Inspect:
- `internal/adapters/distributor/client_test.go`
- `internal/app/app_test.go`
- `internal/cli/root_test.go`
Adapter tests use a fake upload client factory and do not require a live
distributor service.
## Invariants
- Distributor package types do not leak outside the adapter.
- Only managed Markdown report paths selected by app orchestration are
uploaded.
- The adapter never scans the workspace.
- Token values are not included in errors, CLI output, metadata, docs, or
examples.
- Destination routing and Markdown-to-HTML transformation belong to
distributor, not weatherreporter.

94
docs/internal/facts.md Normal file
View File

@@ -0,0 +1,94 @@
# Fact Contracts Internals
This document describes the fact contract boundary.
## Purpose
`internal/facts` separates normalized upstream facts collected for a report run
from conservative report-scoped facts derived from them. The package gives app
orchestration one place to build reusable facts before module execution.
## Inputs And Outputs
Inputs:
- `weatherdata.Bundle` from the Weather API adapter
- resolved report definition and valid period
- report timezone
- configured daypart definitions
Outputs:
- `facts.CollectedFacts` with normalized source facts plus separate source
provenance and warnings. SPC convective outlook source data is carried
through when present in the bundle, including upstream geometry and source
provenance.
- `facts.DerivedFacts` with valid-period forecast slices, alert overlaps,
report-period SPC convective outlooks and discussions, daily summaries,
daypart summaries, and Storm Report window summary
Hourly Report uses the generic valid-period hourly and narrative selection
for its rolling six-hour window. Its derived facts include precipitation timing
from the selected hourly periods, alert overlaps for the six-hour period, and
SPC outlooks/discussions overlapping that period. It does not build daily
summaries, daypart summaries, or a storm-window summary.
## Boundaries
- This package owns fact assembly and reusable deterministic derivation for a
report run.
- SPC convective outlook derivation selects already-collected outlooks whose
half-open valid intervals overlap the resolved report period and retains
discussions for represented outlook days.
- Derived SPC outlook records preserve the collected outlook fields, including
geometry, for downstream components that need source-level facts. Prompt
modules decide which fields are exposed to Scriptorium.
- It does not fetch upstream data, build prompt wording, compare prior
snapshots, write workflow state, invoke Scriptorium, or define modules.
## Config Fields Used
- `dayparts[].name`
- `dayparts[].start`
- `dayparts[].end`
- `weather_api.timezone`
## External Adapters Used
None directly. Collected facts are built from `weatherdata.Bundle`.
## State Or Manifest Behavior
None. Source provenance and warnings remain data fields for downstream metadata
and inspection.
## Failure Behavior
- Invalid or missing report valid periods return an error.
- Invalid timezone names return an error.
- Missing required hourly forecast data returns the underlying forecast
derivation error for reports that require daily summaries.
- Hourly Report can derive its default module facts without daily or
daypart summaries.
- Missing optional narrative, alert, discussion, daily, or weather story data
produces empty or nil derived fields.
- Missing optional SPC convective outlook data produces a nil collected field.
- A present SPC convective outlook source with no report-period matches
produces non-nil empty derived outlook and discussion slices.
## Tests
Inspect:
- `internal/facts/facts_test.go`
- `internal/app/app_test.go`
## Invariants
- Collected facts are built once from a fetched bundle.
- Derived facts are scoped to one resolved report.
- SPC convective outlook selection uses the resolved report period and the
already-collected outlook run.
- Source provenance and warnings stay separate from ordinary fact fields.
- Prompt-specific wording and one-off presentation decisions stay outside this
package.

View File

@@ -5,14 +5,14 @@ This document describes deterministic forecast summarization in
## Purpose
`internal/forecast` converts normalized bundle data into daily and period
summaries used by briefing builders.
`internal/forecast` converts normalized weather data into daily and period
summaries used by fact builders and module builders.
## Inputs And Outputs
Inputs:
- `forecast.Bundle`
- `weatherdata.Bundle`
- local date or resolved report period
- timezone
- configured daypart definitions
@@ -43,12 +43,12 @@ configuration.
## External Adapters Used
None directly. Forecast data arrives through `forecast.Bundle`.
None directly. Forecast data arrives through `weatherdata.Bundle`.
## State Or Manifest Behavior
None. Source warnings and provenance from the bundle are carried into summaries
for later metadata and briefing output.
for later metadata and module output.
## Skip And Resume Behavior

View File

@@ -0,0 +1,149 @@
# Generated Text Internals
This document describes structured generated-text handling in
`internal/generatedtext`.
## Purpose
`internal/generatedtext` validates structured text returned for
generated-text-template reports and builds curated render contexts for
templates. It also owns the generated-text catalog that connects report
definitions to validators, render-context builders, schema assets, and template
assets.
## Inputs And Outputs
Inputs:
- raw GeneratedText JSON for Daily, Today, Tomorrow Report, or Hourly Report
- report metadata from `internal/briefing`
- a module snapshot from `internal/module`
- validated generated text
Outputs:
- typed `Daily` generated text
- typed `Today` generated text
- typed `Tomorrow` generated text
- typed `Hourly` generated text
- normalized stable JSON for validated generated text
- typed `DailyRenderContext` values for `internal/reporttemplate`
- typed `TodayRenderContext` values for `internal/reporttemplate`
- typed `TomorrowRenderContext` values for `internal/reporttemplate`
- typed `HourlyRenderContext` values for `internal/reporttemplate`
- generated-text catalog handlers for report definitions that use
`generated_text_template`
## JSON Contracts
Daily, Today, and Tomorrow use the same day-style generated-text JSON shape:
```json
{
"summary": "string",
"forecast_discussion": ["string"],
"precipitation_timing": "string",
"confidence": "string"
}
```
The day-style contract requires `summary` after trimming whitespace.
`forecast_discussion` must contain at least one nonblank paragraph after
trimming blank items. `precipitation_timing` and `confidence` are optional and
omitted from normalized JSON when blank. Unknown fields are rejected.
The report-specific Go API is:
| Report | Type | Validator | Schema ID | Template ID | Prompt ID |
| --- | --- | --- | --- | --- | --- |
| Daily Report | `Daily` | `ValidateDaily` | `daily` | `daily` | `weather.daily_generated_text` |
| Today Report | `Today` | `ValidateToday` | `today` | `today` | `weather.today_generated_text` |
| Tomorrow Report | `Tomorrow` | `ValidateTomorrow` | `tomorrow` | `tomorrow` | `weather.tomorrow_generated_text` |
Hourly generated text uses the same top-level field names, but
`forecast_discussion` is a single string:
```json
{
"summary": "string",
"forecast_discussion": "string",
"precipitation_timing": "string",
"confidence": "string"
}
```
Hourly `summary` and `forecast_discussion` are required after trimming
whitespace. `precipitation_timing` and `confidence` are optional and omitted
from normalized JSON when blank. Unknown fields are rejected. The Hourly catalog
entry uses type `Hourly`, validator `ValidateHourly`, schema ID `hourly`,
template ID `hourly`, and prompt ID `weather.hourly_generated_text`.
## Render Contexts
Daily, Today, Tomorrow, and Hourly render contexts all include:
- display metadata derived from report metadata;
- validated generated text;
- typed module outputs decoded from the module snapshot;
- collected facts;
- derived facts.
Daily, Today, and Tomorrow share common civil-day render-context fields such as
forecast date labels, valid period, generated-at labels, current conditions,
hourly forecast, precipitation timing, alert digest, SPC outlooks, AFD, SPC
discussion, weather story, daily summary, and ordered daypart summaries.
Each civil-day report keeps its report-specific planning module:
- Daily exposes `DailyPlanning`.
- Today exposes `TodayPlanning`.
- Tomorrow exposes `TomorrowPlanning`.
Today's ordered daypart context omits unavailable or elapsed dayparts according
to Today report rules. Daily and Tomorrow use fallback daypart behavior.
## Boundaries
- This package owns typed generated-text validation and render-context shaping.
- It owns generated-text catalog lookup for schema/template combinations.
- It uses typed module snapshot decoding through `module.StanzaValue`.
- It does not invoke Scriptorium, write state artifacts, choose report
definitions, compare snapshots, or own embedded template/schema files.
- It renders through `internal/reporttemplate`; embedded asset lookup remains
in `internal/reporttemplate`.
- It does not use a Go JSON Schema dependency; schema enforcement in Go is
limited to typed JSON decoding, unknown-field rejection, and required-field
checks.
## Failure Behavior
- Malformed generated-text JSON fails with decode context.
- Unknown generated-text JSON fields fail during decoding.
- Empty required fields fail after trimming whitespace.
- Daily, Today, and Tomorrow forecast discussion fails when no nonblank
paragraphs remain.
- Missing optional render-context stanzas become nil module pointers.
- Invalid render metadata, including missing timezone, missing generated time,
or invalid valid period, fails before template rendering.
- Unsupported generated-text schema IDs, template IDs, or schema/template
combinations fail during catalog lookup with report ID context.
## Tests
Inspect:
- `internal/generatedtext/hourly_test.go`
- `internal/generatedtext/daily_test.go`
- `internal/generatedtext/today_test.go`
- `internal/generatedtext/tomorrow_test.go`
- `internal/generatedtext/catalog_test.go`
- `internal/generatedtext/render_context_test.go`
## Invariants
- Render contexts are curated structs, not raw prompt-input packages.
- Required generated text is normalized before downstream artifact storage.
- Generated-text-template reports must have one catalog entry matching their
report definition schema and template IDs.
- Missing optional weather narrative stanzas produce empty or fallback render
context fields rather than forcing raw module data into templates.

288
docs/internal/module.md Normal file
View File

@@ -0,0 +1,288 @@
# Module Contract Internals
This document describes the module contract in `internal/module`.
## Purpose
`internal/module` defines the shared identifiers and data envelopes used for
report modules. Report definitions use module IDs for composition, module
builders produce rich outputs with stanza names, prompt input packages consume
runtime prompt export values, and Recent Changes compares snapshot stanzas.
## Inputs And Outputs
Inputs:
- ordered `module.ConfigItem` values from report definitions or config
overrides
- `module.Output` values produced by module builders
Outputs:
- stable `module.ID` constants
- typed option structs for registered modules
- `module.Snapshot` with schema version `weatherreporter.modules.v1`
- ordered snapshot outputs with module ID, stanza name, and typed value
- runtime-only prompt export values on module outputs
- `module.Output.DataPackageValue`, which selects the prompt export value and
falls back to the rich value for hand-built or loaded snapshots
- typed stanza lookup through `module.StanzaValue`
## Rich Values And Prompt Exports
Each `module.Output` has two value surfaces:
- `Value`: the rich module value used by templates, module snapshots,
inspection, Recent Changes, and render contexts.
- `PromptValue`: the runtime-only prompt export used when building Scriptorium
data packages.
`PromptValue` is deliberately excluded from module snapshot JSON. Persisted
module snapshots keep only the rich `value` field so inspection and
render-context reconstruction keep the full deterministic template surface.
The `internal/briefing` module registry attaches prompt export values when it
builds module outputs. Modules without a custom exporter use default
pass-through behavior, so their prompt value is the same as their rich value.
Modules with custom prompt export policy own typed prompt export structs near
the module builder. Custom prompt exports are:
- `current_conditions`
- `hourly_forecast`
- `derived_daypart_summaries`
Custom exporters remove template-only helpers or confusing duplicates from the
data package without shrinking the rich module structs used by templates.
Exporter failures include module ID and stanza context.
## Registered Module IDs
The registry recognizes these IDs:
- `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`
- `daily_planning`
Every registered module has a builder. Report composition entries that refer to
unknown or unimplemented module IDs fail validation instead of being skipped.
## Daily Composition
The default Daily Report module order is:
1. `metadata`
2. `current_conditions`
3. `narrative_forecast`
4. `derived_daily_summary`
5. `derived_daypart_summaries`
6. `precip_timing`
7. `alert_digest`
8. `spc_convective_outlooks`
9. `area_forecast_discussion`
10. `spc_convective_discussion`
11. `weather_story`
12. `outdoor_windows`
13. `daily_planning`
14. `hourly_forecast`
The embedded Daily template uses selected deterministic fields from these
module outputs after GeneratedText validation. Its `area_forecast_discussion`
item is configured to include only `long_term`.
## Today Composition
The default Today Report module order is:
1. `metadata`
2. `current_conditions`
3. `narrative_forecast`
4. `derived_daily_summary`
5. `derived_daypart_summaries`
6. `precip_timing`
7. `alert_digest`
8. `spc_convective_outlooks`
9. `area_forecast_discussion`
10. `spc_convective_discussion`
11. `weather_story`
12. `outdoor_windows`
13. `hourly_forecast`
14. `today_planning`
The embedded Today template uses selected deterministic fields from these
module outputs after GeneratedText validation.
## Tomorrow Composition
The default Tomorrow Report module order is:
1. `metadata`
2. `current_conditions`
3. `narrative_forecast`
4. `derived_daily_summary`
5. `derived_daypart_summaries`
6. `precip_timing`
7. `alert_digest`
8. `spc_convective_outlooks`
9. `area_forecast_discussion`
10. `spc_convective_discussion`
11. `weather_story`
12. `outdoor_windows`
13. `tomorrow_planning`
14. `hourly_forecast`
The embedded Tomorrow template uses selected deterministic fields from these
module outputs after GeneratedText validation.
## Daily Planning
`daily_planning` emits dated daily planning facts for the `daily` report ID.
Its output stanza is also named `daily_planning`. The module is supported only
by that report ID and depends on daily summaries for the selected local civil
day. The default Daily Report composition includes it.
The output uses this shape:
- `morning_readiness`
- `commute_school_workday_concerns`
- `overnight_change_watch`
The type is `briefing.DailyPlanningModule`; it is independent from
`briefing.TomorrowPlanningModule`.
## Today Planning
`today_planning` emits current-day planning facts for Today Report. Its output
stanza is also named `today_planning`. The module is supported only by Today
Report and depends on daily and daypart summaries for the current local civil
day.
The output uses this shape:
- `morning_readiness`
- `commute_school_workday_concerns`
- `outdoor_planning`
- `late_day_change_watch`
The type is `briefing.TodayPlanningModule`; it is independent from
`briefing.TomorrowPlanningModule`.
## Hourly Composition
The default Hourly Report module order is:
1. `metadata`
2. `current_conditions`
3. `hourly_forecast`
4. `precip_timing`
5. `alert_digest`
6. `spc_convective_outlooks`
7. `area_forecast_discussion`
8. `spc_convective_discussion`
9. `weather_story`
Hourly Report does not include daily or daypart summary modules by default.
Its `area_forecast_discussion` item is configured to include only
`key_messages` and `short_term`.
## Options
Most modules use an empty options struct, including
`spc_convective_outlooks` and `spc_convective_discussion`.
`area_forecast_discussion` accepts:
```yaml
sections:
- product
- key_messages
- short_term
- long_term
```
An omitted or empty `sections` list includes all available discussion sections.
Invalid option shapes fail during config normalization or composition
validation.
## SPC Convective Module Outputs
`spc_convective_outlooks` emits a risk-product stanza with:
- `checked`
- `as_of`
- `issued_at`
- `location_id`
- `location_name`
- `outlook_count`
- `outlooks`
- `risk_digest`
Each outlook entry may include `day`, `outlook_type`, `label`, `label_text`,
`period_begins`, `period_ends`, `issued_at`, `contains_location`, and
`image_url`. It omits GeoJSON geometry, source URL, expiration time, and
severity rank.
The optional `risk_digest` list is a curated report-rendering subset of
categorical outlooks that overlap the report period, contain the configured
location, and meet the minimum severity threshold. Entries include `label_text`,
`risk_label`, `period_begins`, and `period_ends`; they do not expose severity
rank.
`spc_convective_discussion` emits a narrative stanza only when a retained
report-period categorical outlook has severity rank `3` or higher and matching
discussion text is available. Its output includes `included_because` and
`discussions`; each discussion may include `day`, `period_begins`,
`period_ends`, `headline`, `summary`, `discussion`, and `updated_at`.
Discussions are included only for SPC days whose retained categorical outlooks
meet the severity threshold.
## Boundaries
- This package owns module identifiers, config item envelopes, output
envelopes, snapshot validation, and typed stanza lookup.
- It does not define report IDs, execute builders, collect weather data, derive
forecast facts, write state, or invoke Scriptorium.
## State Or Manifest Behavior
`module.Snapshot` values are persisted by `internal/state` as JSON. Snapshot
validation rejects missing schema version, missing module IDs, missing stanza
names, duplicate module outputs, and duplicate stanza names while preserving
output order. Snapshot JSON contains rich module values only; runtime prompt
export values are not persisted.
## Failure Behavior
- Snapshot construction fails for duplicate module outputs or duplicate stanza
names.
- Typed stanza lookup returns `found=false` for missing stanzas.
- Typed stanza lookup wraps JSON marshal/decode failures with stanza context.
## Tests
Inspect:
- `internal/module/module_test.go`
- `internal/briefing/modules_test.go`
- `internal/report/period_test.go`
## Invariants
- `internal/module` does not import `internal/report`.
- Module IDs are stable strings.
- Each emitted module output has exactly one stanza name and one rich typed
value.
- Built module outputs have a data-package value, either from a custom prompt
exporter or from default pass-through behavior.
- Snapshot output order is caller-owned and preserved.

View File

@@ -1,40 +1,142 @@
# Prompt Input Internals
This document describes prompt input data package construction.
This document describes YAML prompt data package construction in
`internal/promptinput`.
## Purpose
`internal/promptinput` converts a structured briefing package and optional
Recent Changes into the `data_package` JSON passed to Scriptorium prompts.
`internal/promptinput` converts report metadata, ordered module outputs, Recent
Changes, and source warnings into the `data_package` file passed to
Scriptorium.
The persisted data package is YAML with schema version
`weatherreporter.data_package.v3`. It is separate from the JSON module snapshot
used for inspection and comparison. Data packages serialize each module
output's prompt export value, not necessarily the full rich module value saved
in the module snapshot.
## Inputs And Outputs
Inputs:
- `briefing.Package`
- report metadata from app/state orchestration
- `module.Snapshot`
- optional `[]changes.Change`
Outputs:
- `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`
- `promptinput.Package` with schema version, RunID, report metadata, named
module stanzas grouped for prompt presentation, Recent Changes, and source
warnings
- YAML bytes from `promptinput.MarshalYAML`
- YAML file written atomically by `promptinput.Save`
The YAML shape includes:
```yaml
schema_version: weatherreporter.data_package.v3
run_id: <run_id>
report:
id: <report_id>
prompt_id: <prompt_id>
briefing:
metadata: {}
applicable_risk_products:
alert_digest: {}
spc_convective_outlooks: {}
derived_summaries:
derived_daily_summary: {}
derived_daypart_summaries: {}
precip_timing: {}
outdoor_windows: {}
narrative_products:
narrative_forecast: {}
area_forecast_discussion: {}
spc_convective_discussion: {}
weather_story: {}
raw_data:
current_conditions: {}
hourly_forecast: {}
recent_changes:
items: []
```
The `briefing` mapping keeps `metadata` directly under `briefing` and groups
weather module stanzas under prompt-facing categories. This grouping is a YAML
presentation concern only: module snapshots remain flat, and loaded
`promptinput.Package` values expose flat stanza names in `Briefing.Values`.
Within each category, stanza order follows the module snapshot output order.
Prompt-facing module intervals use local `period_begins` and `period_ends`
labels; canonical report metadata and source timestamps remain structured
timestamps where applicable.
## Module Export Boundary
Data packages are curated prompt inputs. They are not full template render
contexts and should not be treated as a dump of every field available to Go
templates.
When module outputs are built by `internal/briefing`, the registry attaches a
runtime prompt export value. `internal/promptinput` serializes
`output.DataPackageValue()` for each stanza. That helper prefers the runtime
prompt export and falls back to the rich `Value` when no prompt export is set,
which keeps loaded snapshots and hand-built tests usable.
Modules without custom export policy use pass-through behavior. Modules with
custom exports currently include:
- `current_conditions`: omits lower-case condition text and duplicate
wind-direction text.
- `hourly_forecast`: omits hour labels, lower-case description text, and the
template precipitation-mention helper while keeping forecast facts.
- `derived_daypart_summaries`: omits deterministic sentence-construction
helpers while keeping daypart period, condition, temperature trend,
precipitation, wind, notable-condition, hazard, and alert-relevance facts.
The rich module snapshot and generated-template render context still contain
the helper fields used by deterministic Markdown templates.
Daily Report, Today Report, Tomorrow Report, and Hourly Report module snapshots
use the same package schema and categories when converted into prompt input.
The default hourly module list places
`precip_timing` under `derived_summaries`, alert and SPC outlooks under
`applicable_risk_products`, AFD/SPC discussion/weather story under
`narrative_products`, and current/hourly data under `raw_data`. It does not
include civil-day summary stanzas. Generated-text and render context artifacts
are produced later in app orchestration and are not part of the YAML data
package.
The default Daily, Today, and Tomorrow module lists include civil-day summary
stanzas, planning stanzas, and `hourly_forecast` in the data package before
structured GeneratedText is requested from Scriptorium. Daily uses
`daily_planning`, Today uses `today_planning`, and Tomorrow uses
`tomorrow_planning`.
Current categories are:
- `applicable_risk_products`: location-applicable alerts, warnings, outlooks,
and similar risk products. Current stanzas include `alert_digest` and
`spc_convective_outlooks`.
- `derived_summaries`: deterministic summaries and calculated report facts.
- `narrative_products`: official narrative text products and forecast stories.
Current stanzas include `narrative_forecast`,
`area_forecast_discussion`, `spc_convective_discussion`, and
`weather_story`.
- `raw_data`: minimally transformed underlying weather data.
## Boundaries
- This package owns the prompt input schema and validation.
- It does not fetch weather data, derive forecast summaries, find prior
snapshots, compare changes, or invoke Scriptorium.
- This package owns prompt package schema, YAML marshaling, YAML loading, and
validation.
- It does not collect weather data, derive forecast summaries, execute modules,
choose module prompt export shapes, find prior snapshots, compare changes,
choose artifact paths, or invoke Scriptorium.
## Config Fields Used
None directly. Config-derived values, including timezone and prompt location
context, are already present in briefing metadata before this package runs.
None directly. Config-derived values such as timezone, units, and prompt
location are already present in report metadata and module stanzas before this
package runs.
## External Adapters Used
@@ -42,7 +144,7 @@ None.
## State Or Manifest Behavior
`promptinput.Save` writes JSON atomically. Managed workspace paths are owned by
`promptinput.Save` writes YAML atomically. Managed workspace paths are owned by
`internal/state`.
## Skip And Resume Behavior
@@ -51,9 +153,10 @@ 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.
Validation fails before render preflight when required top-level fields are
missing or inconsistent, when the valid period is invalid, or when no module
stanzas are present. Save failures include filesystem operation and path
context.
## Tests
@@ -64,6 +167,11 @@ Inspect:
## 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.
- Scriptorium receives structured YAML through `--input data_package=<path>`.
- Module stanza order is deterministic within each prompt-facing category.
- Every non-metadata module stanza has exactly one prompt-input category.
- Data-package stanzas use curated module prompt exports when present and rich
values only as pass-through or fallback values.
- Data packages are narrower than generated-template render contexts.
- Recent Changes are provided by `internal/changes`; this package does not
infer changes from rendered report text.

View File

@@ -1,14 +1,15 @@
# Report Registry Internals
This document describes report identity, valid-period resolution, batch
membership, output naming, artifact grouping, and comparison declarations in
This document describes report identity, valid-period resolution, output
naming, artifact grouping, batch command names, and comparison declarations in
`internal/report`.
## Purpose
`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.
`internal/report` is the canonical source for report definitions, public
command names, config-key aliases, and batch command names. App, config, state,
module building, and CLI wiring consume report-owned helpers and resolved
definitions instead of owning report identity policy themselves.
## Definition Fields
@@ -16,35 +17,58 @@ Each report definition declares:
- report ID and display name
- Scriptorium prompt ID
- generation mode
- valid-period resolver
- comparison strategy
- managed artifact group
- batch output copy filename
- generated-report eligibility
- prior-report compatibility list
- morning or evening batch membership
- default ordered module composition
## Implemented Reports
Report-owned helpers map public command names and config keys to report IDs.
The generate command names are `daily`, `today`, `tomorrow`, `hourly`,
`three-day`, `weekend`, and `storm`. Config keys also accept selected
underscore and descriptive aliases such as `three_day_outlook`,
`weekend_outlook`, and `storm_report`.
| Report | ID | Prompt | Artifact group | Batch copy | Prior compatibility |
| --- | --- | --- | --- | --- | --- |
| Daily Today | `daily_today` | `weather.daily_report` | `daily` | `daily.md` | Daily Today, Daily Tomorrow |
| Daily Tomorrow | `daily_tomorrow` | `weather.daily_report` | `daily` | `tomorrow.md` | Daily Today, Daily Tomorrow |
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `three-day` | `three-day.md` | 3-Day Outlook |
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `weekend` | `weekend.md` | Weekend Outlook |
| Storm Report | `storm` | `weather.storm_report` | `storm` | `storm.md` | Storm Report |
`daily` resolves to the dated Daily Report ID `daily`. `today` resolves to the
independent Today report ID `today`. `reports.today` is not an alias for
`reports.daily`, and retired report keys are not supported.
All implemented report definitions are eligible for generation.
Markdown report definitions use the `scriptorium_markdown` generation mode.
Their template and structured-text schema identifiers are empty. Daily Report,
Today Report, Tomorrow Report, and Hourly Report declare
`generated_text_template`; the app uses their template and schema identifiers
to validate generated text and render embedded Markdown templates.
## Reports
| Report | ID | Prompt | Generation mode | Artifact group | Batch copy | Prior compatibility |
| --- | --- | --- | --- | --- | --- | --- |
| Daily Report | `daily` | `weather.daily_generated_text` | `generated_text_template` | `daily` | `daily.md` | Daily Report |
| Today Report | `today` | `weather.today_generated_text` | `generated_text_template` | `today` | `today.md` | Today Report |
| Tomorrow Report | `tomorrow` | `weather.tomorrow_generated_text` | `generated_text_template` | `tomorrow` | `tomorrow.md` | Tomorrow Report |
| Hourly Report | `hourly` | `weather.hourly_generated_text` | `generated_text_template` | `hourly` | `hourly.md` | Hourly Report |
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `scriptorium_markdown` | `three-day` | `three-day.md` | 3-Day Outlook |
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `scriptorium_markdown` | `weekend` | `weekend.md` | Weekend Outlook |
| Storm Report | `storm` | `weather.storm_report` | `scriptorium_markdown` | `storm` | `storm.md` | Storm Report |
All report definitions are eligible for generation.
## Valid Periods
- Daily Today covers the selected local civil day, or the current local civil
- Daily Report covers the selected local civil day and requires an explicit
date.
- Today Report 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.
- Tomorrow Report covers the next local civil day from generation time.
- Hourly Report covers the half-open six-hour period from generation time in
the effective report timezone. The duration is an internal report constant,
not a configuration field.
- 3-Day Outlook covers the interval from generation time through local midnight
three days later.
- Weekend Outlook covers the upcoming weekend window and is not scheduled for
Sunday morning batch resolution.
- Weekend Outlook covers the upcoming weekend window.
- Storm Report covers an explicit event window supplied by the caller.
Storm event windows can be parsed from local `YYYY-MM-DDTHH:MM` timestamps in
@@ -53,23 +77,39 @@ must be after start time.
## Boundaries
`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.
`internal/report` defines report metadata, public report names, batch command
names, output naming, and time coverage. It does not collect weather data, plan
batch membership, build module values, compare snapshot contents, write state,
parse CLI flags, or invoke Scriptorium.
The CLI owns public command names. The app maps those command names to report
IDs, then uses the registry for report policy.
The CLI parses flags and command structure, then uses report-owned helpers for
report and batch command names. Config loading uses report-owned helpers for
report override keys.
## Config Fields Used
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
output path copying uses batch output names from report definitions.
output path copying uses batch output names from report definitions. Report
module overrides can use short keys such as `daily`, `today`, `tomorrow`, and
`hourly`, or descriptive names such as `three_day_outlook`.
## Batch Commands
`internal/report` owns the public batch command names `morning` and `evening`
and validates them through `BatchForCommandName`. Data-dependent batch
membership is owned by `internal/app`, because it depends on collected hourly
forecast coverage.
Report definitions still declare default batch output copy filenames. App
batch planning uses those filenames for fixed report entries and supplies
date-qualified names for dynamic Daily entries.
## State And App Usage
- State paths use `ArtifactGroup`.
- Batch output copies use `BatchOutputName`.
- Generation checks `Generated`.
- Module composition defaults use `Modules`.
- Prior lookup checks `CompatiblePriorIDs` and the comparison strategy.
- RunIDs include the resolved report ID.
@@ -90,8 +130,14 @@ Inspect:
## Invariants
- Report selection goes through the registry.
- Daily Today and Daily Tomorrow both use `weather.daily_report`.
- Public command names, config-key aliases, and batch command names are owned
by `internal/report`.
- Direct Markdown reports have empty template and generated-text schema IDs.
- Generated-text-template reports declare prompt, template, and schema IDs in
their report definition.
- Valid periods are half-open intervals independent of rendered report text.
- Artifact grouping, batch output filenames, generated-report eligibility,
comparison compatibility, and comparison strategy are declared by report
definition.
default module composition, comparison compatibility, and comparison strategy
are declared by report definition.
- App-owned batch planning uses report definitions but does not live in the
report registry.

View File

@@ -0,0 +1,125 @@
# Report Template Internals
This document describes embedded Markdown templates and GeneratedText schemas
in `internal/reporttemplate`.
## Purpose
`internal/reporttemplate` owns repository-native report templates and companion
GeneratedText JSON schemas. The implemented template assets are Daily, Today,
Tomorrow, and Hourly.
The package embeds assets from:
- `internal/reporttemplate/templates/*.md.tmpl`
- `internal/reporttemplate/templates/partials/*.md.tmpl`
- `internal/reporttemplate/schemas/*.schema.json`
Generated-text prompt source files live under
`internal/reporttemplate/prompts/`. They are repository assets for prompt
registration, not embedded lookup APIs.
## Inputs And Outputs
Inputs:
- template ID from a report definition
- typed render context built by `internal/generatedtext`
Outputs:
- template source for inspection and tests
- GeneratedText schema bytes for prompt/schema configuration
- rendered Markdown bytes for app orchestration to persist
The implemented template IDs are `daily`, `today`, `tomorrow`, and `hourly`.
The implemented schema IDs are also `daily`, `today`, `tomorrow`, and
`hourly`, backed by matching `*.generated_text.schema.json` files.
Generated-text prompt sources are maintained under
`internal/reporttemplate/prompts/`, including Daily's
`daily.generated_text.md` source for prompt ID `weather.daily_generated_text`.
## Boundaries
This package owns embedded asset lookup, Go template parsing, and Markdown
template execution. It does not collect weather data, build module outputs,
validate GeneratedText, construct render contexts, choose report definitions,
write artifacts, invoke Scriptorium, or notify distributor.
GeneratedText validation is owned by `internal/generatedtext`. App
orchestration uses `internal/generatedtext` catalog lookup to connect
`internal/report` definition schema/template IDs to the matching validator,
render-context builder, and embedded assets.
## Template Contracts
Daily, Today, Tomorrow, and Hourly rendering use typed render contexts with:
- report metadata labels such as title, location, valid period, and generation
time
- validated GeneratedText prose slots
- deterministic labels derived from module outputs, including current
conditions, hourly forecast rows, precipitation timing, alerts, SPC outlooks,
forecast discussion, SPC discussion, and weather story
Daily, Today, and Tomorrow additionally expose forecast-date labels, ordered
daypart forecast rows, daily/daypart summaries, planning facts, and a
multi-paragraph forecast discussion generated-text slot. The ordered daypart
slice is built in Go so templates do not range over maps.
The Daily template asset uses the same Markdown structure as Tomorrow's
template and renders from `generatedtext.DailyRenderContext`.
Templates use `text/template` with `missingkey=error`, so missing context fields
fail rendering instead of producing incomplete Markdown.
Daily and Tomorrow call the shared `daypart_forecast` partial. Today calls
`today_daypart_forecast` so it can omit elapsed or missing dayparts. Daily,
Today, Tomorrow, and Hourly call the shared `alert_digest` and
`precipitation_timing` partials. Partial files are parsed with each top-level
template at render time and receive the same typed render context as the
caller. The `alert_digest` partial renders the combined Alerts and Risk
Products section from relevant NWS alerts and curated SPC outlook digest
records.
## Schema Contract
The GeneratedText schemas describe the structured prose Scriptorium is expected
to write for each generated-text prompt. Hourly requires:
- `summary`
- `forecast_discussion`
Daily, Today, and Tomorrow require `summary` and a nonempty
`forecast_discussion` array. All generated-text schemas allow optional
`precipitation_timing` and `confidence`, and reject additional properties.
Weather truth remains in module outputs; GeneratedText is limited to prose
slots consumed by the template.
## Failure Behavior
- Unknown template IDs return actionable lookup errors.
- Unknown schema IDs return actionable lookup errors.
- Template parse errors include the template ID.
- Partial read or parse errors include the partial path.
- Template execution errors include the template ID and usually identify the
missing context field.
## Tests
Inspect:
- `internal/reporttemplate/reporttemplate_test.go`
- `internal/generatedtext/render_context_test.go`
- `internal/app/app_test.go`
- `internal/cli/root_test.go`
## Invariants
- Embedded templates and schemas live as separate files, not inline Go strings.
- Shared Markdown partials live under `templates/partials/`.
- Report definitions select templates by ID.
- Templates render from curated render contexts, not raw data packages.
- GeneratedText schemas describe LLM prose slots, not deterministic weather
facts.

View File

@@ -6,17 +6,18 @@ This document describes the subprocess adapter in
## Purpose
The adapter runs `scriptorium render` for prompt preflight and `scriptorium run`
for Markdown report generation. It isolates subprocess execution, argv
construction, timeout handling, output capture, and exit-code interpretation
from app and domain packages.
for Markdown report generation or structured generated-text output. It isolates
subprocess execution, argv construction, timeout handling, output capture, and
exit-code interpretation from app and domain packages.
## Inputs And Outputs
Inputs:
- prompt ID
- prompt input data package path
- YAML prompt input data package path
- report output path for `run`
- raw generated-text output path for structured `run`
- configured binary, config path, profile, timeout, and extra arguments
- context for cancellation
@@ -27,16 +28,17 @@ Outputs:
- truncation flags for captured output
- exit code
- report output path for `run`
- raw generated-text output path for structured `run`
## Boundaries
`internal/adapters/scriptorium` owns Scriptorium command construction and
subprocess execution. It does not choose report types, build prompt input,
fetch weather data, decide workflow order, or persist workflow metadata.
collect weather data, decide workflow order, or persist workflow metadata.
The adapter exposes request and result structs for render and run operations.
State persistence uses a state-owned preflight artifact shape; app
orchestration converts render results before saving.
The adapter exposes request and result structs for render, Markdown run, and
structured generated-text run operations. State persistence uses state-owned
artifact shapes; app orchestration converts adapter results before saving.
## Config Fields Used
@@ -60,6 +62,12 @@ Report generation argv starts with:
scriptorium run --prompt <prompt_id> --input data_package=<path> --out <path>
```
Structured generated-text argv uses the same `scriptorium run` form, with the
`--out` value set to the raw generated-text JSON artifact path. The adapter
does not add `--format`, schema path, or JSON Schema flags for structured
generation; Scriptorium selects the structured output schema from prompt
configuration.
Configured `--config` and `--profile` flags are inserted after the subcommand
and before prompt-specific arguments. Extra arguments are appended after the
built-in arguments.
@@ -67,8 +75,8 @@ 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.
execution path is used by render, Markdown run, and structured run after
command-specific request validation and argv construction.
When `scriptorium.timeout` is greater than zero, each subprocess call uses a
context with that timeout. Stdout and stderr are captured separately, capped at
@@ -81,8 +89,8 @@ context with that timeout. Stdout and stderr are captured separately, capped at
- 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.
- Nonzero render, Markdown run, and structured run exits return the captured
result plus an error containing the exit code and stderr.
## Tests
@@ -96,5 +104,7 @@ Inspect:
- No shell interpolation is used.
- The Scriptorium input name is `data_package`.
- Render and run preserve command-specific result structs.
- The file at the data package path is YAML produced by `internal/promptinput`.
- Render, Markdown run, and structured run preserve command-specific result
structs.
- Scriptorium-specific flags stay inside adapter and config boundaries.

View File

@@ -13,28 +13,35 @@ Inputs:
- workspace configuration
- resolved report definition and valid period
- briefing package
- module snapshot
- prompt input data package
- preflight artifact
- generated-text raw, run-result, validated text, and render-context artifacts
- rendered report path preparation request
- RunID for inspection lookups
Outputs:
- briefing snapshot JSON path
- prompt input data package JSON path
- module snapshot JSON path
- prompt input data package YAML path
- render preflight JSON path
- generated-text raw JSON path
- generated-text run-result JSON path
- validated generated-text JSON path
- render context JSON path
- managed Markdown report path
- metadata JSON path
- distributor notification debug artifact paths
- prior comparable snapshot metadata
- loaded briefing or data package
- loaded module snapshot, data package, generated text, generated-text run
result, or render context
- recent report records for inspection
## Boundaries
`internal/state` owns local filesystem layout, path validation, durable writes,
metadata reads, prior lookup, and report listing. It does not fetch weather
data, derive forecasts, build prompt input content, compare briefing contents,
data, derive forecasts, build prompt input content, compare module contents,
invoke Scriptorium, import adapter result types, or parse CLI flags.
Preflight persistence uses the state-owned `PreflightArtifact` shape. The app
@@ -47,6 +54,7 @@ converts adapter render results into that shape before saving.
- `workspace.reports_dir`
- `workspace.data_packages_dir`
- `workspace.preflight_dir`
- `workspace.notifications_dir`
Workspace subdirectories must be relative paths that stay under
`workspace.root`.
@@ -54,20 +62,35 @@ Workspace subdirectories must be relative paths that stay under
## Managed Layout
Paths are derived from the resolved report definition's artifact group, the
valid-period start date for JSON artifacts, and the RunID.
valid-period start date, and the RunID. Filenames put the artifact kind before
the RunID.
```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
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
```
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.
Metadata is stored beside module snapshots and links the module snapshot, data
package, preflight, report paths, notification path when attempted, and
configured prompt location. For generated-text-template reports, metadata also
records the generated text schema ID and links the raw generated text,
Scriptorium run result, validated generated text, and render context artifacts.
Markdown-report metadata omits those generated-text fields. Report listing
walks metadata files under the snapshots directory.
Batch notification artifacts are stored under the notifications tree rather
than report metadata because they describe a batch-level upload. The date
directory is the batch start date in the effective report timezone.
## Prior Lookup
@@ -75,24 +98,58 @@ 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
- Daily Report compares with prior Daily Report snapshots for the same valid
local date.
- Today Report compares with prior Today Report snapshots for the same valid
local date.
- Tomorrow Report compares with prior Tomorrow Report snapshots for the same
valid local date.
- 3-Day Outlook compares with prior 3-Day snapshots for the same valid local
date.
- Weekend Outlook compares with prior Weekend snapshots for the same weekend
window.
- Storm Report currently has no prior lookup because explicit event-window
comparison is not searched by the filesystem store.
- Hourly Report uses the rolling-window comparison strategy and currently
returns no prior snapshot from filesystem lookup.
- Storm Report has no prior lookup because explicit event-window comparison is
not searched by the filesystem store.
## Writes And Inspection
Durable JSON writes use shared atomic file helpers. 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.
Durable JSON writes use shared atomic file helpers. Generated-text raw and
validated JSON artifacts are written atomically as bytes; generated-text run
result and render context artifacts are written atomically as JSON. Managed
Markdown reports are prepared by creating their parent directory; Scriptorium
writes the report body to the prepared path. Extra Markdown copies are handled
by app orchestration. Distributor notification debug artifacts are written
atomically when notification is attempted and include rendered distributor
pipeline ID, bundle ID, idempotency key, bundle paths, upload status, latest
run status, and redacted errors.
Inspection helpers read existing metadata, briefing, and data package files.
Missing metadata directories return no inspection records or no prior snapshot
rather than creating state.
Single-report notification artifacts use schema version
`weatherreporter.distributor_notification.v1` and record one managed source
path plus that source's bundle paths. Batch notification artifacts use schema
version `weatherreporter.batch_distributor_notification.v1` and record:
- `batch`
- `batchRunId`
- `attemptedAt`
- `endpoint`
- `pipelineId`
- `bundleId`
- `idempotencyKey`
- `bundleCreated`
- `includedReports`, each with `reportId`, `runId`, `sourcePath`, and
`bundlePaths`
- `status`
- `upload`
- `runStatus`
- `statusError`
- `error`
Inspection helpers read existing metadata, module snapshot, data package,
generated text, generated-text run result, and render context files. Missing
metadata directories return no inspection records or no prior snapshot rather
than creating state.
## Failure Behavior
@@ -114,4 +171,8 @@ Inspect:
- Managed paths stay under the configured workspace root.
- Artifact grouping comes from report definitions.
- Metadata links artifacts produced for a run.
- Generated-text artifacts live under the snapshots tree beside module
snapshots and metadata.
- Batch notification artifacts live under `notifications/batches` and are not
linked from report metadata.
- Prior lookup is based on structured metadata, not rendered report text.

View File

@@ -1,13 +1,13 @@
# Weather Data Internals
This document describes Weather API ingestion into `forecast.Bundle`.
This document describes Weather API ingestion into `weatherdata.Bundle`.
## Purpose
`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.
`internal/adapters/weatherapi` fetches normalized weather data from the
configured Weather API and assembles the bundle consumed by forecast derivation
and module builders. Module builders expose normalized current conditions and
weather story context when those sources are available.
## Inputs And Outputs
@@ -19,17 +19,17 @@ Inputs:
Outputs:
- `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
- `weatherdata.Bundle` with observation, current conditions, hourly forecast,
narrative forecast, active alerts, discussion, latest weather story, source
records, source warnings, and typed SPC convective outlook data when that
optional source is available
- optional saved bundle JSON through app fetch helpers
## Boundaries
- 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
- It does not derive dayparts, resolve report periods, build module values, compare
snapshots, write report state, or invoke Scriptorium.
## Config Fields Used
@@ -55,9 +55,19 @@ contract used by this project.
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.
represent a checked empty active-alert list, not a missing source. Successful
non-null `/outlooks/convective` responses with empty outlook and discussion
arrays represent checked empty outlook data.
`app.FetchAndSaveBundle` can write bundle JSON atomically for inspection.
SPC convective outlook data is stored on
`weatherdata.Bundle.SPCConvectiveOutlooks`. The collected run keeps upstream
run metadata, location identifiers, ordered outlook records, discussion
records, and each outlook's raw GeoJSON geometry. Source provenance for this
payload uses the `spc_convective_outlooks` source name, endpoint
`/outlooks/convective`, the query sent by the adapter, timestamps, and a hash
of the raw `data` object.
## Skip And Resume Behavior
No resume behavior. Optional missing or malformed sources may be omitted,
@@ -70,9 +80,11 @@ data is required and cannot be skipped.
- 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.
- Optional sources follow missing-source policy.
- Explicit `data: null` from `/alerts/active` produces an empty, non-missing
alert run.
- Explicit `data: null` from `/outlooks/convective` follows optional
missing-source policy.
## Tests

View File

@@ -1,40 +1,73 @@
# Weatherreporter Operations
This guide covers normal operation, generated artifacts, inspection, recovery,
and current operational caveats. For symptom-specific diagnosis, see
and operational caveats. For symptom-specific diagnosis, see
[Troubleshooting](troubleshooting.md).
## Normal Workflow
Implemented generation commands:
Generation commands:
```text
weatherreporter generate daily --date 2026-05-29
weatherreporter generate today
weatherreporter generate tomorrow
weatherreporter generate hourly
weatherreporter generate three-day
weatherreporter generate weekend
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
```
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.
Generation commands resolve a report period, collect a Weather API bundle,
build a rich JSON module snapshot, build a curated YAML prompt input data
package, run `scriptorium render`, and write managed artifacts under the
configured workspace. Markdown-path reports then run `scriptorium run` directly
to the managed Markdown report path.
Implemented batch commands:
`generate daily`, `generate today`, `generate tomorrow`, and `generate hourly`
use the generated-text-template workflow. They run structured `scriptorium run`
to raw GeneratedText JSON, validate the structured text, save a render context,
and render the managed Markdown report from embedded templates. `generate
daily` requires `--date YYYY-MM-DD` for the selected local civil day.
`generate today` covers the selected or current local civil day. `generate
hourly` covers the six-hour rolling period from generation time in the
effective report timezone and is not included in `run morning` or
`run evening`.
When distributor notification is enabled, weatherreporter uploads the managed
Markdown report after report rendering succeeds and final metadata is saved.
`--out PATH` writes an extra Markdown copy for generated reports; it is not used
as the distributor upload source. Generate commands emit a compact JSON summary
to stdout by default. Use `--quiet` to suppress successful stdout for cron jobs
or other schedulers that only need nonzero exits and external logs.
Batch commands:
```text
weatherreporter run morning
weatherreporter run evening
```
`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`.
`run morning` generates Today Report, Tomorrow Report, and a dated Daily Report
for each later future local civil day with complete hourly forecast coverage.
`run evening` generates Tomorrow Report and the same eligible future Daily
reports. Future Daily expansion starts with the day after tomorrow. A Daily
report is eligible only when the collected hourly forecast contains every
hourly period for that local civil day; partial days are skipped. Batch commands
collect weather data once before planning, and a collection failure stops the
batch before any report is generated.
After planning succeeds, 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. Batch commands do
not upload each report independently. When distributor notification and batch
notification are enabled, weatherreporter uploads one distributor bundle only
after every planned report succeeds. If any report fails, the batch upload is
skipped for the whole batch. `--out-dir PATH` writes extra Markdown copies
using report default filenames such as `today.md` and `tomorrow.md`; dynamic
Daily copies use `daily-YYYY-MM-DD.md`. These copies are not used as
distributor upload sources. Use `--quiet` to suppress successful batch summary
and status output; failures still return nonzero.
## Filesystem Layout
@@ -42,69 +75,45 @@ The default workspace root is `workspace`.
```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.
Managed artifact filenames use the artifact kind and RunID, so repeated runs
for the same valid period do not overwrite each other. The date directory is
the valid-period start date in the effective report timezone. Generated-text
artifacts are written only for Daily, Today, Tomorrow, and Hourly reports.
## RunID And Metadata
RunIDs are based on generation time plus report ID:
RunIDs are based on generation time plus report ID. Reports that can be
generated more than once in a single command may append a report-specific
disambiguator. Daily appends the local valid date so multiple dynamic Daily
reports in one batch have distinct managed artifacts:
```text
20260529T100000.123456789Z_daily_today
20260529T100000.123456789Z_daily_2026-05-31
20260529T100000.123456789Z_today
```
Batch notification RunIDs use the batch start timestamp plus the batch command
name:
```text
20260529T100000.123456789Z_morning
20260529T220000.123456789Z_evening
```
Each generated report writes metadata that links:
@@ -112,46 +121,126 @@ Each generated report writes metadata that links:
- RunID, report ID, variant, and prompt ID
- generation time, timezone, and valid period
- source location, source hashes, and source warnings
- briefing snapshot path
- module snapshot path
- prompt input data package path
- preflight output path
- managed Markdown report path
- generated text schema ID and generated-text artifact paths for
generated-text-template reports
- distributor notification debug artifact path, when notification is attempted
Batch summaries include report status, error text when applicable, valid
period, and known artifact paths for each attempted report.
period, and known artifact paths for each attempted report. Single-report
notification fields on report items are empty for batch commands. When a batch
notification is attempted, skipped, or fails, the summary includes one
top-level `notification` object with fields such as `status`, `reason`,
`runId`, `pipelineId`, `bundleId`, `idempotencyKey`, `path`,
`includedReports`, and `error`.
## Distributor Notification
Distributor notification is configured with `notify.distributor` and is
disabled by default. For `generate <report>`, weatherreporter uploads the
managed Markdown report path recorded in the report result and metadata. That
single source file is mapped to report-specific bundle paths. Extra copies
written by `--out` or `--out-dir` are operator conveniences only.
For `run morning` and `run evening`, per-report notification is suppressed. If
`notify.distributor.enabled` and `notify.distributor.batch.enabled` are both
true, the batch uploads once after all reports finish successfully. The upload
contains one file mapping set per included report. Each mapping uses the
managed Markdown report as the source and report-specific path templates for
that report. All rendered bundle paths across the batch must be unique. If any
report fails, weatherreporter records a top-level
notification status of `skipped` with reason `one or more reports failed` and
does not call distributor. If batch notification is disabled, run commands do
not fall back to per-report uploads.
The rendered pipeline ID selects the configured distributor `http_upload`
workflow. The default bundle ID is a stable logical source identity derived from
producer name, location ID, and report ID:
```text
weatherreporter.{location_id}.{report_id}
```
The default single-report idempotency key appends RunID to the rendered bundle
ID so each report generation has a distinct retry identity. The default bundle
path uses the valid-period start date, artifact group, and RunID. Batch bundle
IDs default to `weatherreporter.{location_id}.{batch}`, and batch idempotency
keys default to `{bundle_id}.{batch_run_id}`. Distributor owns destination
merge, retention, and derived snapshot behavior such as `latest`. For Daily,
the default report ID and artifact group values are both `daily`, and the
default output filename value is `daily.md`. For Today, the default report ID
and artifact group values are both `today`, and the batch output filename value
is `today.md`.
Single-report notification happens after final metadata save for generated
reports. Batch notification happens after all planned reports have finished and
only when all report generations succeeded. Collection, module snapshot,
data-package, render preflight, Scriptorium run, generated-text validation,
template rendering, and metadata-save failures do not trigger notification. A
single-report notification failure fails that report. A batch notification
failure makes the batch return nonzero and increments the aggregate failure
count, but individual report items remain succeeded.
Each notification attempt writes a debug artifact under `notifications/`.
Single-report artifacts live under
`notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json`. Batch
artifacts live under
`notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json`,
where the date directory is the batch start date in the effective report
timezone. The artifact records the rendered pipeline ID, bundle ID,
idempotency key, managed source paths, bundle-relative paths, bundle created
timestamp, accepted upload response, and the latest distributor run status
response when available. Weatherreporter polls status until distributor reports
`succeeded` or `failed`, or until the configured notification timeout expires.
The run status includes the distributor status, error text, and raw run report
JSON, which can show actions such as `replace_older`, `skip_same`,
`skip_destination_newer`, or `failed`. Token values are not written.
Weatherreporter is responsible for selecting the managed Markdown report,
constructing a source bundle, and submitting it to the configured distributor
HTTP endpoint. Distributor remains responsible for destination routing,
publication, and any downstream Markdown-to-HTML transformation. Distributor
leaves destination files alone when they are not tracked by a newly uploaded
bundle, so existing uploaded dated report paths can remain available.
## Inspection
Inspection commands read existing workspace artifacts and emit JSON to stdout.
They do not fetch weather data or run `scriptorium`.
They do not collect weather data or run `scriptorium`.
```text
weatherreporter inspect reports --limit 10
weatherreporter inspect metadata RUN_ID
weatherreporter inspect briefing RUN_ID
weatherreporter inspect modules RUN_ID
weatherreporter inspect data-package RUN_ID
weatherreporter inspect prior RUN_ID
weatherreporter inspect sources RUN_ID
```
Use `inspect reports` to find recent RunIDs and artifact paths. Use
Use `inspect reports` to find 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
`inspect modules` to review the persisted ordered module snapshot with rich
template-facing values, and `inspect data-package` to review the curated prompt
package passed to Scriptorium. Use `inspect prior` to see the prior comparable
snapshot selected for Recent Changes, or `null` when none exists. Use
`inspect sources` to review source provenance and warnings without dumping full
weather payloads.
## Recent Changes
Recent Changes are computed from structured briefing snapshots, not rendered
Markdown text.
Recent Changes are computed from structured module snapshots, not rendered
Markdown or YAML text.
Daily Today and Daily Tomorrow can compare with each other when they cover the
same valid local date. 3-Day Outlook compares with prior compatible 3-Day
snapshots for the same valid local date. Weekend Outlook compares with prior
compatible Weekend snapshots for the same weekend window. Storm Report currently
leaves Recent Changes empty.
Daily Report compares with prior Daily Report snapshots for the same valid
local date. Today Report compares with prior Today Report snapshots for the
same valid local date. Tomorrow Report compares with prior Tomorrow Report
snapshots for the same valid local date. 3-Day Outlook compares with prior
compatible 3-Day snapshots for the same valid local date. Weekend Outlook
compares with prior compatible Weekend snapshots for the same weekend window.
Hourly Report and Storm Report leave Recent Changes empty.
When no prior comparable snapshot exists, or no configured threshold is crossed,
`recentChanges.items` is empty.
@@ -164,15 +253,24 @@ A failed generation run may still leave useful artifacts:
preflight JSON and metadata are written for inspection.
- If `scriptorium run` exits nonzero after writing a report, the managed report
and metadata remain available.
- Generated-text failures for Daily, Today, Tomorrow, and Hourly reports preserve
available intermediate artifacts, such as the structured run result, raw
generated-text JSON, validated generated text, and render context. Metadata
links those paths when it can be safely written.
- If single-report distributor notification fails, report artifacts and final
metadata remain available, but the report command returns nonzero.
- If batch distributor notification fails, report artifacts and final metadata
remain available, the top-level batch notification links the debug artifact,
and the batch command returns nonzero.
- For batch commands, inspect the stdout JSON summary first, then inspect the
artifact paths for each failed report.
artifact paths for each failed report or the top-level notification path.
For a bad report, start with:
```text
weatherreporter inspect metadata RUN_ID
weatherreporter inspect sources RUN_ID
weatherreporter inspect briefing RUN_ID
weatherreporter inspect modules RUN_ID
weatherreporter inspect data-package RUN_ID
weatherreporter inspect prior RUN_ID
```

View File

@@ -3,16 +3,22 @@
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 projects shape, boundaries, and invariants as the code evolves.
## 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.
`weatherreporter` is a deterministic weather briefing and report-preparation application. It consumes normalized weather data from the internal weatherfeeder-backed API, derives report-specific module snapshots and prompt packages, compares module snapshots against prior runs, and invokes an external prompt runner to produce human-facing reports.
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 application should keep meteorological data selection, daypart grouping, threshold detection, forecast-period resolution, and recent-change comparison inside Go domain packages. LLM prompts should receive curated module-based prompt packages rather than raw unbounded source payloads wherever practical.
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.
Report types must be defined through a registry or equivalent mechanism. Each report definition should declare its report ID, prompt ID, valid-period resolver, module composition, comparison strategy, and output naming behavior. Avoid scattering report-type conditionals across CLI and orchestration code.
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.
Generated reports must be associated with explicit metadata, including report type, location, generation time, valid period, source product timestamps or hashes, module snapshot path, and output path. Recent Changes must be based on structured snapshot comparison rather than comparison of rendered Markdown report text.
`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.
`distributor` is also an external adapter. Upload behavior must be isolated
under `internal/adapters/distributor`, dependency types from the distributor
module must not leak outside that adapter, and the selected upload source must
be the managed Markdown report rather than optional output copies or broad
workspace scans.
## Project Shape
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.
@@ -54,7 +60,7 @@ Configuration precedence is:
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.
Configuration files should not contain raw secrets unless the application is explicitly designed for that. Prefer environment variables or secret files for secrets. File-backed secrets are loaded through `secrets.directory`; secret values must not be logged, persisted, or included in user-facing output.
## Adapters and External Integrations

View File

@@ -13,13 +13,17 @@ Developers and LLM coding agents should use it with
- `internal/config`: configuration structs, defaults, loading, overrides, and
validation.
- `internal/fileutil`: shared atomic filesystem write and copy helpers.
- `internal/adapters/distributor`: Distributor upload adapter.
- `internal/adapters/weatherapi`: Weather API HTTP adapter.
- `internal/adapters/scriptorium`: Scriptorium subprocess adapter.
- `internal/forecast`: normalized bundle types and deterministic forecast
derivation.
- `internal/weatherdata`: normalized weather source facts, source metadata, and
source warnings.
- `internal/forecast`: deterministic forecast derivation.
- `internal/facts`: collected and derived report fact contracts.
- `internal/module`: module IDs, config items, output envelopes, and snapshots.
- `internal/report`: report definitions, valid periods, batches, output names,
and comparison declarations.
- `internal/briefing`: report-specific briefing package builders.
- `internal/briefing`: prompt-facing module value builders and module registry.
- `internal/changes`: structured Recent Changes comparison.
- `internal/promptinput`: Scriptorium `data_package` construction and
validation.
@@ -45,7 +49,7 @@ 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/adapters/distributor ./internal/adapters/weatherapi ./internal/adapters/scriptorium
go test ./internal/forecast ./internal/report ./internal/briefing ./internal/changes ./internal/promptinput
```
@@ -64,6 +68,8 @@ Run `gofmt -w` on changed Go files before committing.
- Use atomic writes for durable JSON artifacts where practical.
- Keep report selection and prompt IDs centralized in `internal/report`.
- Keep Scriptorium argv construction inside `internal/adapters/scriptorium`.
- Keep distributor package types and upload-client construction inside
`internal/adapters/distributor`.
- Keep Weather API transport and envelope handling inside
`internal/adapters/weatherapi`.
@@ -72,8 +78,10 @@ Run `gofmt -w` on changed Go files before committing.
Prefer the Go standard library. Add dependencies only when they materially
improve correctness, interoperability, security, or maintainability.
Current external dependency:
Current external dependencies:
- `gitea.maximumdirect.net/eric/distributor` for distributor source bundle
construction and HTTP upload client behavior.
- `gopkg.in/yaml.v3` for YAML configuration parsing.
When adding a dependency:
@@ -113,8 +121,12 @@ The CLI is owned by `internal/cli`.
When adding or changing a command or flag:
- update help text and parser behavior together;
- declare whether the command is an action command or an inspection/data-output
command;
- convert parsed values into app-layer request structs;
- keep domain decisions in `internal/app` or domain packages;
- use the centralized output helpers in `internal/cli/output.go`;
- keep action-command summary conversion in `internal/cli/result.go`;
- add parser or command tests in `internal/cli`;
- update `docs/cli.md`;
- update `docs/operations.md` or `docs/troubleshooting.md` when behavior affects
@@ -145,12 +157,14 @@ When an external contract changes, update the matching file under
## Tests
Core tests must not require live Weather API or Scriptorium services.
Core tests must not require live Weather API, Scriptorium, or distributor
services.
Preferred test patterns:
- fake command runners for subprocess behavior;
- `httptest.Server` for Weather API behavior;
- fake distributor upload clients for notification behavior;
- filesystem temp directories for state behavior;
- deterministic clocks for report periods and RunIDs;
- table tests for config validation, CLI parsing, period resolution, and
@@ -187,7 +201,8 @@ Update:
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/integrations/` for external Weather API, Scriptorium, or distributor
contract changes;
- `docs/roadmap/` only for unimplemented or deferred work.
Non-roadmap docs must describe implemented behavior only.

View File

@@ -3,21 +3,24 @@
This roadmap contains project work that is not implemented. 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.
Manual Storm Report generation is available through:
Proposed direction:
```sh
weatherreporter generate storm --start TIME --end TIME
```
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.
Automatic storm-event evaluation is not implemented.
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 +31,104 @@ 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
Possible future report types:
These ideas are not current behavior:
- 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
- native LLM client inside `weatherreporter`;
- database-backed state;
- public HTTP API;
- multi-location selection;
- daemon mode;
- multi-user authorization;
- plugin system.
New reports should keep report identity, prompt IDs, templates, valid-period
resolution, artifact grouping, batch output names, and comparison policy inside
`internal/report`.
## Future Modules
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 these boundaries:
- 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
Distributor notification uploads one managed Markdown report per successful
generated report through the configured HTTP upload pipeline. The following
enhancements are not implemented:
- `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
These ideas are not implemented:
- 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.
These refactors should remain deferred until new requirements or recurring
maintenance costs make the added abstraction worthwhile:
Revisit these only when new source types, report types, operational
requirements, or recurring maintenance costs make the duplication materially
more expensive:
- Weather API optional-source specification/helper refactor: consider when
additional Weather API sources make per-source fan-out, policy handling, and
provenance wiring repetitive enough to obscure adapter behavior.
- Broad briefing weather-signal consolidation: consider when multiple briefing
builders repeatedly derive the same weather signals and tests begin to need
coordinated fixture updates.
- Generic workflow engine: defer unless generation, inspection, recovery, or
future background workflows gain enough shared step semantics to justify a
declared execution model.
- Plugin architecture: defer until there is a concrete external extension
contract and at least one implemented extension point.
- Cobra migration: defer while the standard-library CLI remains small,
explicit, and covered by parser tests.
- Manifest, resume, or progress system: defer until operators need resumable
runs, checkpoint recovery, or richer audit trails than the current durable
artifacts and metadata provide.
- Global test helper package: defer while package-local helpers keep tests
clear; revisit only if setup duplication starts to hide behavior.
- Logging subsystem: defer until there are recurring operator diagnostics that
cannot be handled with current errors, metadata, inspection commands, and
artifact output.
- 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.

442
docs/templates.md Normal file
View File

@@ -0,0 +1,442 @@
# Report Templates
## Purpose
This guide describes the implemented Markdown report template surface for
`weatherreporter`. It is for maintainers editing embedded report templates,
especially generated-text-template reports.
Templates are Go `text/template` files. The implemented top-level templates
are:
- `internal/reporttemplate/templates/daily.md.tmpl`
- `internal/reporttemplate/templates/today.md.tmpl`
- `internal/reporttemplate/templates/tomorrow.md.tmpl`
- `internal/reporttemplate/templates/hourly.md.tmpl`
Shared named partials live under `internal/reporttemplate/templates/partials/`:
- `alert_digest.md.tmpl`, used by Daily, Today, Tomorrow, and Hourly for the
combined Alerts and Risk Products section
- `daypart_forecast.md.tmpl`, used by Daily and Tomorrow
- `today_daypart_forecast.md.tmpl`, used by Today
- `precipitation_timing.md.tmpl`, used by Daily, Today, Tomorrow, and Hourly
Templates are rendered from structured contexts such as `DailyRenderContext`,
`TodayRenderContext`, `TomorrowRenderContext`, and `HourlyRenderContext`.
Weather data collection, derivation, module execution, generated text
validation, and artifact paths are handled before template rendering.
## Editing Rules
- Use Go `text/template` syntax.
- Keep templates focused on Markdown layout, headings, ordering, and simple
conditional display.
- Do not put weather derivation, source selection, or path construction logic in
templates.
- Missing template keys are errors. A misspelled variable will fail rendering.
- No custom template functions are registered.
- Named partials are invoked with `{{ template "name" . }}`. Pass the current
render context (`.`) unless the partial is intentionally designed for a
narrower value.
- Optional module stanzas are pointers and should be guarded with
`{{ with .Modules.WeatherStory }}...{{ end }}`.
- Slices can be rendered with `{{ range .Items }}...{{ else }}...{{ end }}`.
## Hourly Context
The hourly template receives five top-level values:
| Variable | Type | Description |
| --- | --- | --- |
| `.Report` | HourlyReportContext | Display metadata and friendly labels for the rendered report. |
| `.GeneratedText` | Hourly | Structured text returned by Scriptorium. |
| `.Modules` | HourlyTemplateModules | Preferred deterministic template surface, keyed by module purpose. |
| `.Collected` | facts.CollectedFacts | Normalized upstream facts for advanced template use. |
| `.Derived` | facts.DerivedFacts | Shared derived facts for advanced template use. |
Prefer `.Modules` for normal template edits. `.Collected` and `.Derived` are
available when a template needs lower-level facts, but templates should still
avoid nontrivial derivation.
## Report
| Variable | Type | Description |
| --- | --- | --- |
| `.Report.Title` | string | Display title. Currently `Hourly Report`. |
| `.Report.LocationName` | string | Prompt/report location label, such as `Brentwood, MO`. |
| `.Report.GeneratedAt` | time.Time | Canonical generation timestamp. |
| `.Report.GeneratedAtLabel` | string | Friendly local generation time label. |
| `.Report.ValidPeriod` | timeutil.Period | Canonical valid period. |
| `.Report.ValidPeriodLabel` | string | Friendly local valid period label, such as `2026-05-29 at 8:30 AM to 2026-05-29 at 2:30 PM`. |
| `.Report.Timezone` | string | Effective report timezone. |
## GeneratedText
These fields are written by Scriptorium as structured JSON, validated by
weatherreporter, and then inserted into the render context.
| Variable | Type | Description |
| --- | --- | --- |
| `.GeneratedText.Summary` | string | Required short prose summary. |
| `.GeneratedText.ForecastDiscussion` | string | Required prose for the Forecast Discussion section. |
| `.GeneratedText.PrecipitationTiming` | string | Optional prose rendered after deterministic precipitation windows. |
| `.GeneratedText.Confidence` | string | Optional confidence or uncertainty note. Empty when omitted by the LLM; not rendered by the current hourly template. |
Example:
```gotemplate
{{ .GeneratedText.Summary }}
## Forecast Discussion
{{ .GeneratedText.ForecastDiscussion }}
```
## Tomorrow Context
The Tomorrow template receives five top-level values:
| Variable | Type | Description |
| --- | --- | --- |
| `.Report` | TomorrowReportContext | Display metadata and friendly labels for the rendered report. |
| `.GeneratedText` | Tomorrow | Structured text returned by Scriptorium. |
| `.Modules` | TomorrowTemplateModules | Preferred deterministic template surface, keyed by module purpose. |
| `.Collected` | facts.CollectedFacts | Normalized upstream facts for advanced template use. |
| `.Derived` | facts.DerivedFacts | Shared derived facts for advanced template use. |
Tomorrow report metadata includes `.Report.Title`, `.Report.ForecastDate`,
`.Report.ForecastDateLabel`, `.Report.ForecastDayName`,
`.Report.GeneratedAt`, `.Report.GeneratedAtLabel`, `.Report.ValidPeriod`, and
`.Report.Timezone`.
Tomorrow generated text uses the same `.GeneratedText.Summary`,
`.GeneratedText.PrecipitationTiming`, and `.GeneratedText.Confidence` fields as
Hourly. `.GeneratedText.ForecastDiscussion` is a slice of paragraphs and should
be rendered with `range`.
Tomorrow uses the shared `alert_digest`, `daypart_forecast`, and
`precipitation_timing` partials.
Tomorrow modules include the Hourly module fields plus:
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.DerivedDailySummary` | *briefing.DerivedDailySummaryModule | Daily summary facts for the forecast date. |
| `.Modules.DerivedDaypartSummaries` | *map[string]briefing.DerivedDaypartSummaryModule | Raw daypart summary map, when direct keyed access is needed. |
| `.Modules.Dayparts` | []generatedtext.TomorrowDaypartContext | Ordered daypart summaries for deterministic template rendering. |
| `.Modules.TomorrowPlanning` | *briefing.TomorrowPlanningModule | Planning facts for the next local civil day. |
Prefer `.Modules.Dayparts` over ranging through
`.Modules.DerivedDaypartSummaries`; it follows configured daypart order and
falls back to sorted keys for any unmatched entries.
## Daily Context
The Daily template receives the same five top-level values as Tomorrow, using
`DailyReportContext`, `Daily`, and `DailyTemplateModules`.
Daily report metadata includes `.Report.Title`, `.Report.ForecastDate`,
`.Report.ForecastDateLabel`, `.Report.ForecastDayName`,
`.Report.GeneratedAt`, `.Report.GeneratedAtLabel`, `.Report.ValidPeriod`, and
`.Report.Timezone`.
Daily generated text uses `.GeneratedText.Summary`,
`.GeneratedText.ForecastDiscussion`, `.GeneratedText.PrecipitationTiming`, and
`.GeneratedText.Confidence`. Forecast discussion is a slice of paragraphs and
should be rendered with `range`.
Daily uses the shared `alert_digest`, `daypart_forecast`, and
`precipitation_timing` partials.
Daily uses template ID `daily`, generated-text schema ID `daily`, and prompt
source `internal/reporttemplate/prompts/daily.generated_text.md`.
Daily modules include the Hourly module fields plus:
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.DerivedDailySummary` | *briefing.DerivedDailySummaryModule | Daily summary facts for the forecast date. |
| `.Modules.DerivedDaypartSummaries` | *map[string]briefing.DerivedDaypartSummaryModule | Raw daypart summary map, when direct keyed access is needed. |
| `.Modules.Dayparts` | []generatedtext.DailyDaypartContext | Ordered daypart summaries for deterministic template rendering. |
| `.Modules.DailyPlanning` | *briefing.DailyPlanningModule | Planning facts for the selected local civil day. |
Prefer `.Modules.Dayparts` over ranging through
`.Modules.DerivedDaypartSummaries`; it follows configured daypart order and
falls back to sorted keys for any unmatched entries.
## Today Context
The Today template receives the same five top-level values as Tomorrow, using
`TodayReportContext`, `Today`, and `TodayTemplateModules`.
Today report metadata includes `.Report.Title`, `.Report.ForecastDate`,
`.Report.ForecastDateLabel`, `.Report.ForecastDayName`,
`.Report.GeneratedAt`, `.Report.GeneratedAtLabel`, `.Report.ValidPeriod`, and
`.Report.Timezone`.
Today generated text uses `.GeneratedText.Summary`,
`.GeneratedText.ForecastDiscussion`, `.GeneratedText.PrecipitationTiming`, and
`.GeneratedText.Confidence`. Forecast discussion is a slice of paragraphs and
should be rendered with `range`.
Today uses the `today_daypart_forecast` partial so elapsed or missing dayparts
can be omitted while Daily and Tomorrow keep their fallback row. It also uses
the shared `alert_digest` and `precipitation_timing` partials.
Today uses template ID `today`, generated-text schema ID `today`, and prompt
source `internal/reporttemplate/prompts/today.generated_text.md`.
Today modules include the Hourly module fields plus:
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.DerivedDailySummary` | *briefing.DerivedDailySummaryModule | Daily summary facts for the forecast date. |
| `.Modules.DerivedDaypartSummaries` | *map[string]briefing.DerivedDaypartSummaryModule | Raw daypart summary map, when direct keyed access is needed. |
| `.Modules.Dayparts` | []generatedtext.TodayDaypartContext | Ordered daypart summaries for deterministic template rendering. |
| `.Modules.TodayPlanning` | *briefing.TodayPlanningModule | Planning facts for the current local civil day. |
Prefer `.Modules.Dayparts` over ranging through
`.Modules.DerivedDaypartSummaries`; it follows configured daypart order and
falls back to sorted keys for any unmatched entries.
## Modules
`.Modules` exposes typed outputs from the same module pipeline used for the
prompt data package. Module fields are pointers because missing-data policy may
omit a stanza.
Templates render from rich module values, not from the curated YAML data
package. Some fields documented below are deterministic wording helpers for
Markdown templates and are intentionally omitted from data packages passed to
Scriptorium. The data package is a prompt input, while the render context is the
template surface.
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.Metadata` | *briefing.MetadataModule | Report metadata module output, when present. |
| `.Modules.CurrentConditions` | *briefing.CurrentConditionsModule | Current conditions from `/conditions/current`. |
| `.Modules.HourlyForecast` | *briefing.HourlyForecastModule | Hourly forecast periods overlapping the report valid period. |
| `.Modules.PrecipTiming` | *briefing.PrecipTimingModule | Derived precipitation timing facts and threshold windows. |
| `.Modules.AlertDigest` | *briefing.AlertDigestModule | Active alert status and relevant alert overlaps. |
| `.Modules.SPCConvectiveOutlooks` | *briefing.SPCConvectiveOutlooksModule | SPC outlooks that overlap the report valid period. |
| `.Modules.AreaForecastDiscussion` | *briefing.AreaForecastDiscussionModule | AFD key messages and configured discussion sections. |
| `.Modules.SPCConvectiveDiscussion` | *briefing.SPCConvectiveDiscussionModule | SPC discussions retained for qualifying overlapping categorical risk days. |
| `.Modules.WeatherStory` | *briefing.WeatherStoryModule | Latest NWS weather story, when available. |
### Current Conditions
Common fields:
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.CurrentConditions.ConditionText` | string | Current condition text. |
| `.Modules.CurrentConditions.ConditionTextLower` | string | Lower-case current condition text for inline sentences. |
| `.Modules.CurrentConditions.TemperatureF` | *int | Rounded current temperature. |
| `.Modules.CurrentConditions.ApparentTemperatureF` | *int | Rounded apparent temperature. |
| `.Modules.CurrentConditions.RelativeHumidityPercent` | *int | Rounded relative humidity. |
| `.Modules.CurrentConditions.WindDirection` | string | 16-point compass wind direction. |
| `.Modules.CurrentConditions.WindDirectionText` | string | Lower-case full wind direction text, such as `northwest`. |
| `.Modules.CurrentConditions.WindSpeedMph` | *int | Rounded wind speed. |
Example:
```gotemplate
{{ with .Modules.CurrentConditions }}
{{ .ConditionText }}{{ with .TemperatureF }}; {{ . }} F{{ end }}{{ with .WindDirection }}; wind {{ . }}{{ end }}{{ with .WindSpeedMph }} {{ . }} mph{{ end }}
{{ else }}
No current conditions available.
{{ end }}
```
### Hourly Forecast
Common period fields:
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.HourlyForecast.Periods` | []briefing.HourlyForecastPeriod | Ordered periods for the hourly report valid period. |
| `.Modules.HourlyForecast.Periods[].HourLabel` | string | Friendly hour label such as `4:00 PM`. |
| `.Modules.HourlyForecast.Periods[].PeriodBegins` | string | Friendly local period start label. |
| `.Modules.HourlyForecast.Periods[].PeriodEnds` | string | Friendly local period end label. |
| `.Modules.HourlyForecast.Periods[].Name` | string | Source period name. |
| `.Modules.HourlyForecast.Periods[].TextDescription` | string | Hourly forecast text. |
| `.Modules.HourlyForecast.Periods[].TextDescriptionLower` | string | Lower-case hourly forecast text for inline sentences. |
| `.Modules.HourlyForecast.Periods[].TemperatureF` | *float64 | Forecast temperature. |
| `.Modules.HourlyForecast.Periods[].ProbabilityOfPrecipitationPercent` | *float64 | Forecast precipitation probability. |
| `.Modules.HourlyForecast.Periods[].MentionPrecipitation` | bool | True when precipitation probability meets the hourly mention threshold. |
| `.Modules.HourlyForecast.Periods[].WindDirection` | string | 16-point compass wind direction. |
| `.Modules.HourlyForecast.Periods[].WindSpeedMph` | *float64 | Wind speed. |
| `.Modules.HourlyForecast.Periods[].WindGustMph` | *float64 | Wind gust. |
Example:
```gotemplate
{{ with .Modules.HourlyForecast }}{{ range .Periods }}
- **{{ .HourLabel }}:**{{ with .TemperatureF }} {{ . }}°F{{ end }} and {{ .TextDescriptionLower }}.{{ if .MentionPrecipitation }}{{ with .ProbabilityOfPrecipitationPercent }} Probability of precipitation is {{ . }}%.{{ end }}{{ end }}
{{ else }}
- No hourly forecast rows available.
{{ end }}{{ end }}
```
### Precipitation Timing
Common fields:
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.PrecipTiming.MaxPopPercent` | *int | Highest hourly precipitation probability in the valid period. |
| `.Modules.PrecipTiming.MaxPopTime` | string | Friendly local time for the highest hourly precipitation probability. |
| `.Modules.PrecipTiming.ProbabilityThreshold` | float64 | Threshold used to define precipitation windows. |
| `.Modules.PrecipTiming.PrecipitationWindows` | []briefing.PrecipitationWindowModule | One or more threshold precipitation windows. |
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodBegins` | string | Friendly local window start. |
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodBeginsHourLabel` | string | Friendly window start hour, such as `4:00 PM`. |
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodEnds` | string | Friendly local window end; omitted for open windows. |
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodEndsHourLabel` | string | Friendly window end hour; omitted for open windows. |
| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopPercent` | *int | Highest precipitation probability inside the window. |
| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopTime` | string | Friendly local time for the window maximum. |
| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopHourLabel` | string | Friendly hour label for the window maximum. |
| `.Modules.PrecipTiming.PrecipitationWindows[].PrecipitationType` | string | Conservatively inferred precipitation type, such as `showers and thunderstorms`. |
| `.Modules.PrecipTiming.PrecipitationWindows[].ExpectationPhrase` | string | Probability-based sentence used by precipitation timing templates. |
| `.Modules.PrecipTiming.ThunderMentioned` | bool | Whether thunder is mentioned in the forecast text. |
### Daypart Summaries
Daily, Today, and Tomorrow templates should use `.Modules.Dayparts` for
ordered daypart rendering. Each item has `Key` and `Summary`; `Summary` is a
rich `briefing.DerivedDaypartSummaryModule`.
The shared daypart partials render from these same `.Modules.Dayparts` values.
Edit `daypart_forecast.md.tmpl` for common Daily/Tomorrow wording, and edit
`today_daypart_forecast.md.tmpl` for Today-specific omission behavior.
Common rich daypart fields:
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.Dayparts[].Summary.DisplayName` | string | Human-readable daypart label. |
| `.Modules.Dayparts[].Summary.PeriodBegins` | string | Friendly local daypart start. |
| `.Modules.Dayparts[].Summary.PeriodEnds` | string | Friendly local daypart end. |
| `.Modules.Dayparts[].Summary.TempRangeF` | string | Rounded temperature range or single temperature. |
| `.Modules.Dayparts[].Summary.TemperaturePhraseF` | string | Temperature phrase used for steady template wording. |
| `.Modules.Dayparts[].Summary.TemperatureTrend` | string | Trend category such as `rising`, `falling`, `peaking`, or `steady`. |
| `.Modules.Dayparts[].Summary.TemperatureStartPhraseF` | string | Starting temperature phrase for rising/falling wording. |
| `.Modules.Dayparts[].Summary.TemperatureEndPhraseF` | string | Ending temperature phrase for rising/falling wording. |
| `.Modules.Dayparts[].Summary.TemperaturePeakPhraseF` | string | Peak temperature phrase for peaking wording. |
| `.Modules.Dayparts[].Summary.TemperatureSteadyPhraseF` | string | Steady temperature phrase. |
| `.Modules.Dayparts[].Summary.MaxPopPercent` | *int | Highest precipitation probability in the daypart. |
| `.Modules.Dayparts[].Summary.MaxPopTime` | string | Friendly local time for the highest precipitation probability. |
| `.Modules.Dayparts[].Summary.MaxPopTimeLabel` | string | Clock-style label for deterministic precipitation timing text. |
| `.Modules.Dayparts[].Summary.MentionPrecipitation` | bool | True when precipitation probability should be mentioned by the template. |
| `.Modules.Dayparts[].Summary.DominantCondition` | string | Dominant condition text. |
| `.Modules.Dayparts[].Summary.DominantConditionLower` | string | Lower-case condition text for inline sentences. |
| `.Modules.Dayparts[].Summary.DominantConditionDisplay` | string | Display-case condition text for bullet starts. |
| `.Modules.Dayparts[].Summary.NotableConditions` | []string | Notable condition labels retained for the daypart. |
Template-only daypart helpers such as `TemperaturePhraseF`,
`DominantConditionLower`, `DominantConditionDisplay`, and `MaxPopTimeLabel`
remain available here even though they are not serialized into data-package
YAML.
### Alert Digest
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.AlertDigest.Checked` | bool | Whether alert data was checked successfully. |
| `.Modules.AlertDigest.ActiveCount` | int | Active alert count from the source. |
| `.Modules.AlertDigest.RelevantCount` | int | Alert count overlapping the report period. |
| `.Modules.AlertDigest.Missing` | bool | True when alert data is unavailable. |
| `.Modules.AlertDigest.Relevant` | []briefing.AlertSummary | Relevant alert summaries. |
| `.Modules.AlertDigest.Relevant[].Event` | string | Alert event name. |
| `.Modules.AlertDigest.Relevant[].Headline` | string | Alert headline. |
| `.Modules.AlertDigest.Relevant[].Severity` | string | Alert severity. |
| `.Modules.AlertDigest.Relevant[].PeriodBegins` | string | Friendly local alert applicability start. |
| `.Modules.AlertDigest.Relevant[].PeriodEnds` | string | Friendly local alert applicability end. |
| `.Modules.AlertDigest.Relevant[].Instruction` | string | Alert instruction text, when provided. |
| `.Modules.AlertDigest.Relevant[].Description` | string | Alert description text, when provided. |
### SPC Outlooks And Discussion
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.SPCConvectiveOutlooks.Checked` | bool | Whether SPC outlook data was checked successfully. |
| `.Modules.SPCConvectiveOutlooks.AsOf` | string | Friendly source as-of time. |
| `.Modules.SPCConvectiveOutlooks.IssuedAt` | string | Friendly source issue time. |
| `.Modules.SPCConvectiveOutlooks.Outlooks` | []briefing.SPCConvectiveOutlookRecord | Overlapping outlook records. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].Day` | int | SPC day number. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].OutlookType` | string | Outlook type, such as `categorical`. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].Label` | string | Short outlook label. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].LabelText` | string | Human-readable outlook label. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodBegins` | string | Friendly outlook period start. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodEnds` | string | Friendly outlook period end. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].ImageURL` | string | Source image URL. |
| `.Modules.SPCConvectiveOutlooks.RiskDigest` | []briefing.SPCConvectiveOutlookDigest | Curated categorical outlooks for the shared Alerts and Risk Products section. |
| `.Modules.SPCConvectiveOutlooks.RiskDigest[].LabelText` | string | Human-readable outlook label. |
| `.Modules.SPCConvectiveOutlooks.RiskDigest[].RiskLabel` | string | Sentence-style risk label for report rendering. |
| `.Modules.SPCConvectiveOutlooks.RiskDigest[].PeriodBegins` | string | Friendly outlook period start. |
| `.Modules.SPCConvectiveOutlooks.RiskDigest[].PeriodEnds` | string | Friendly outlook period end. |
| `.Modules.SPCConvectiveDiscussion.IncludedBecause` | string | Criterion used to include discussions. |
| `.Modules.SPCConvectiveDiscussion.Discussions` | []briefing.SPCConvectiveDiscussionRecord | Retained discussion records. |
| `.Modules.SPCConvectiveDiscussion.Discussions[].Headline` | string | Discussion headline. |
| `.Modules.SPCConvectiveDiscussion.Discussions[].Summary` | string | Discussion summary. |
| `.Modules.SPCConvectiveDiscussion.Discussions[].Discussion` | string | Full discussion text. |
### Area Forecast Discussion
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.AreaForecastDiscussion.Product` | string | Source product identifier. |
| `.Modules.AreaForecastDiscussion.KeyMessages` | []string | AFD key messages. |
| `.Modules.AreaForecastDiscussion.ShortTerm` | string | AFD short-term section text. |
| `.Modules.AreaForecastDiscussion.LongTerm` | string | AFD long-term section text. |
### Weather Story
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.WeatherStory.Available` | bool | True when a story is available. |
| `.Modules.WeatherStory.OfficeID` | string | Source office ID. |
| `.Modules.WeatherStory.PeriodBegins` | string | Friendly story period start. |
| `.Modules.WeatherStory.PeriodEnds` | string | Friendly story period end. |
| `.Modules.WeatherStory.UpdatedAt` | *time.Time | Canonical update timestamp. |
| `.Modules.WeatherStory.Title` | string | Story title. |
| `.Modules.WeatherStory.Description` | string | Story description. |
| `.Modules.WeatherStory.AltText` | string | Story image alt text. |
| `.Modules.WeatherStory.Priority` | bool | Source priority flag. |
| `.Modules.WeatherStory.Order` | int | Source order. |
| `.Modules.WeatherStory.DownloadURL` | string | Source download URL. |
## Collected And Derived Facts
The template also receives the full `facts.CollectedFacts` and
`facts.DerivedFacts` structs:
- `.Collected` contains normalized source data and provenance from upstream
Weather API fetches.
- `.Derived` contains shared slices and calculations used across modules, such
as valid-period hourly periods, precipitation timing, alert overlaps, and SPC
filtering inputs.
These values are intentionally lower-level than `.Modules`. Use them when a
template needs a specific field that is not exposed by a module, but keep
calculation-heavy changes in Go.
## Validation
After editing a template, run:
```bash
go test ./internal/reporttemplate ./internal/generatedtext ./internal/app
```
For a full check, run:
```bash
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Template render tests exercise the Daily, Today, Tomorrow, and Hourly
templates through `internal/generatedtext/render_context_test.go` and
`internal/reporttemplate/reporttemplate_test.go`.

View File

@@ -6,7 +6,7 @@ fixes. See [CLI reference](cli.md), [Configuration reference](config.md), and
## `weather_api.base_url is required`
Symptom: a generation command fails before fetching weather data.
Symptom: a generation command fails before collecting weather data.
Likely cause: no Weather API base URL is configured.
@@ -185,20 +185,266 @@ prompt, profile, model configuration, or validation issue.
Relevant docs: [Operations guide](operations.md),
[Scriptorium integration](integrations/scriptorium.md).
## Generated Text Validation Fails
Symptom: Daily, Today, Tomorrow, or Hourly generation fails with generated-text
decode, unknown-field, required-field, or multiple-JSON-values context.
Likely cause: Scriptorium wrote structured JSON that does not match the
GeneratedText contract for the selected report.
Diagnostic:
```sh
weatherreporter inspect metadata RUN_ID
```
Then inspect the generated-text raw path recorded in metadata, if present.
Safe fix: update the Scriptorium prompt or schema configuration so the prompt
writes the expected structured JSON for the report.
Relevant docs: [Operations guide](operations.md),
[Generated Text internals](internal/generatedtext.md),
[Scriptorium integration](integrations/scriptorium.md).
## Template Rendering Fails
Symptom: Daily, Today, Tomorrow, or Hourly generation fails with report template
parsing or execution context after generated text validation succeeds.
Likely cause: an embedded template references a missing context field or
receives a value shape that does not match its typed render context.
Diagnostic:
```sh
weatherreporter inspect metadata RUN_ID
```
If metadata records generated-text and render-context paths, inspect those
artifacts along with the template named by the report definition.
Safe fix: update the embedded template or render-context builder so the
template uses the implemented typed context.
Relevant docs: [Report Templates](templates.md),
[Report Template internals](internal/reporttemplate.md).
## Batch Command Returns Nonzero
Symptom: `run morning` or `run evening` returns nonzero.
Likely cause: at least one report in the batch failed.
Likely cause: weather collection failed before planning, or at least one
planned report failed after planning succeeded, or every report succeeded but
the top-level batch distributor notification failed.
Diagnostic: inspect stdout for the JSON summary and stderr for compact status
lines.
Diagnostic: if stdout contains a JSON summary, inspect each failed report item
and the top-level `notification` object. Stderr includes one
`batchNotification` line when batch notification is attempted, skipped, or
fails. If no summary was emitted, inspect the command error; configuration,
Weather API collection, or batch validation failed before any report artifacts
were created.
Safe fix: use the failed report's artifact paths from the summary, then inspect
metadata, sources, briefing, and data package for that RunID.
Safe fix: for collection failures, fix the configuration or upstream Weather
API availability and rerun the batch. For report failures, use the failed
report's artifact paths from the summary, then inspect metadata, sources,
module snapshot, and data package for that RunID. For a batch notification
failure, inspect the notification artifact path from the top-level
`notification.path`.
Relevant docs: [CLI reference](cli.md), [Operations guide](operations.md).
## Batch Upload Skipped
Symptom: a batch JSON summary contains
`"notification":{"status":"skipped","reason":"one or more reports failed"}`.
Likely cause: at least one planned report failed, so weatherreporter did not
call distributor for the batch.
Diagnostic: inspect the failed report items in the batch JSON summary and the
matching stderr report lines. A skipped batch notification has no distributor
run ID and no notification artifact path.
Safe fix: fix the report-generation failure first, then rerun the batch. The
batch upload is all-or-nothing.
Relevant docs: [Operations guide](operations.md).
## Batch Upload Fails
Symptom: every report item in a batch summary is succeeded, but the batch
returns nonzero and the top-level notification has `status: "failed"`.
Likely cause: the distributor upload was rejected, the distributor service was
unavailable, status polling reached a terminal distributor failure, or
weatherreporter rejected the batch file mapping before upload.
Diagnostic: inspect `notification.error`, `notification.pipelineId`,
`notification.bundleId`, `notification.idempotencyKey`, and
`notification.path` in stdout. Then inspect the notification artifact; it
records included report source paths, bundle paths, upload status, distributor
run status, status lookup error, and raw status report JSON when available.
Safe fix: fix the endpoint, token, distributor pipeline, batch identity
templates, or report path templates indicated by the error, then rerun the
batch. Individual report artifacts from the failed batch notification remain
available and do not need to be regenerated for diagnosis.
Relevant docs: [Configuration reference](config.md),
[Operations guide](operations.md).
## Duplicate Batch Bundle Path
Symptom: a batch returns nonzero with duplicate bundle path context before a
distributor run ID is accepted.
Likely cause: report-specific distributor path templates rendered the same
bundle-relative path for two included reports in the same batch.
Diagnostic: inspect the error in stdout or stderr. The validation error
includes the duplicate bundle path plus the report IDs, RunIDs, and managed
source paths involved.
Safe fix: configure a per-report distributor path override so every report in a
batch renders a unique path. Include values such as `{artifact_group}`,
`{valid_start_date}`, `{batch_output_name}`, or `{run_id}` when needed.
Relevant docs: [Configuration reference](config.md),
[Operations guide](operations.md).
## Distributor Source Conflict
Symptom: distributor accepts or rejects an upload with conflict context for a
source, destination, digest, or idempotency key.
Likely cause: the rendered bundle ID or idempotency key does not match the
intended producer identity. A bundle ID identifies the logical source stream;
an idempotency key identifies a retry of the same upload request.
Diagnostic: inspect the report notification artifact linked from metadata or
the batch notification artifact linked from the top-level notification path.
Compare the rendered pipeline ID, bundle ID, idempotency key, included source
paths, and bundle paths with `notify.distributor.*` templates and distributor
pipeline state.
Safe fix: keep bundle ID templates stable for the source stream that should be
updated, and keep idempotency keys stable only for retries of the same generated
content. Do not reuse one idempotency key for different report or batch
content.
Relevant docs: [Operations guide](operations.md),
[Distributor adapter internals](internal/distributor-adapter.md).
## Invalid Secrets Directory
Symptom: config loading fails with `read secrets directory`, `secret file`, or
environment variable name context.
Likely cause: `secrets.directory` points to a missing directory or contains an
invalid entry. Secret entries must be regular files directly under the
configured directory, and file basenames must match
`[A-Za-z_][A-Za-z0-9_]*`.
Diagnostic: list the configured directory and inspect entry names and file
types. Do not print secret file contents.
Safe fix: create the directory, remove subdirectories or symlinks, fix invalid
filenames, and ensure the weatherreporter process can read each secret file.
Relevant docs: [Configuration reference](config.md).
## Distributor Token Is Missing
Symptom: notification fails with a message that the distributor token
environment variable is not set.
Likely cause: `notify.distributor.enabled` is true, but the environment
variable named by `notify.distributor.token_env` was not populated directly or
through `secrets.directory`.
Diagnostic: check `notify.distributor.token_env`, then verify a matching secret
file exists under `secrets.directory` or that the process environment includes
the variable. Do not print the token value.
Safe fix: create a readable secret file whose basename matches `token_env`, or
set the environment variable through the service manager.
Relevant docs: [Configuration reference](config.md),
[Operations guide](operations.md).
## Distributor Upload Conflict
Symptom: notification fails with idempotency conflict context.
Likely cause: the same idempotency key was reused for different bundle content
within the same distributor token and pipeline. By default the bundle ID is a
stable report-stream identity and the idempotency key appends RunID.
Diagnostic: inspect the failed batch JSON or stderr line for pipeline, bundle,
and idempotency context. For batch commands, use the top-level notification
object rather than per-report notification fields. Compare the configured
templates with the report RunID or batch RunID and report path.
Also inspect the notification artifact linked from metadata or from the
top-level batch notification path. It records the rendered pipeline ID, bundle
ID, idempotency key, upload result, distributor run status, status error, and
raw run report JSON when available.
Safe fix: keep idempotency templates stable for retries of the same generated
report, but do not reuse the same rendered key for different generated report
content.
Relevant docs: [Operations guide](operations.md),
[Distributor adapter internals](internal/distributor-adapter.md).
## Distributor Upload Rejected
Symptom: notification fails with distributor upload rejection, HTTP status, or
bundle validation context.
Likely cause: the distributor endpoint rejected the token, pipeline ID, bundle
ID, idempotency key, source file, or one of the rendered bundle paths.
Diagnostic: inspect stdout JSON or stderr status lines for
`notificationError` or the top-level batch notification `error`. Confirm
`notify.distributor.endpoint`,
`notify.distributor.pipeline_id_template`,
report-specific distributor paths, and token configuration. Token
values are redacted from weatherreporter errors.
If the upload was accepted but destination output did not change, inspect the
notification artifact's `runStatus.report`. Distributor actions such as
`replace_older`, `skip_same`, `skip_destination_newer`, or `failed` explain how
the destination handled the uploaded bundle.
Safe fix: fix the endpoint, token, templates, or distributor-side upload
configuration. The weatherreporter upload source is the managed Markdown report,
not `--out` or `--out-dir` copies.
Relevant docs: [Configuration reference](config.md),
[Operations guide](operations.md),
[Distributor adapter internals](internal/distributor-adapter.md).
## Distributor Unavailable
Symptom: notification fails with network, timeout, or service unavailable
context.
Likely cause: the configured distributor endpoint is unreachable, slow, or
temporarily unavailable.
Diagnostic: check network access from the weatherreporter host to
`notify.distributor.endpoint`. For batch runs, inspect the top-level
notification object and the artifact linked by `notification.path`.
Safe fix: restore distributor service availability and rerun the affected
report or batch. Stable idempotency keys make retrying the same generated report
safe unless the distributor reports a conflict.
Relevant docs: [Operations guide](operations.md).
## Unknown RunID
Symptom: an inspect command fails with `metadata for run id ... was not found`.

View File

@@ -1,5 +1,5 @@
weather_api:
base_url: https://weather.api.rakestrawhome.com/
base_url: https://weather.api.example.com/
timeout: 15s
precision: 1
units: us
@@ -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
View File

@@ -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
View File

@@ -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=

View 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]")
}

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

View File

@@ -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 == "" {

View File

@@ -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
}

View File

@@ -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,12 @@ 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"
)
type Client struct {
@@ -83,11 +88,11 @@ 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) {
fetchedAt := c.now()
builder := bundleBuilder{
client: c,
bundle: &forecast.Bundle{FetchedAt: fetchedAt},
bundle: &weatherdata.Bundle{FetchedAt: fetchedAt},
fetchedAt: fetchedAt,
}
@@ -109,10 +114,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 +126,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 +163,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",
}, &current)
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, &current); err != nil {
return b.handleMalformed(&source, err, false)
}
source := fetched.source
b.bundle.Current = &current
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 +204,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 +231,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 +249,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 +267,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 +341,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 +374,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 +389,40 @@ 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) {
func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string, opts queryOptions) (json.RawMessage, weatherdata.Source, error) {
reqURL := c.endpointURL(endpoint, opts)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
if err != nil {
return nil, forecast.Source{}, fmt.Errorf("create request for %s: %w", endpoint, err)
return nil, weatherdata.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)
return nil, weatherdata.Source{}, fmt.Errorf("fetch %s: %w", endpoint, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
return nil, forecast.Source{}, fmt.Errorf("read %s response: %w", endpoint, err)
return nil, weatherdata.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{}, fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
}
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()),
@@ -366,7 +449,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)
query.Set("units", c.units)
if !opts.omitUnits {
query.Set("units", c.units)
}
if opts.precision {
query.Set("precision", strconv.Itoa(c.precision))
}
@@ -406,7 +491,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)
}

View File

@@ -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,44 @@ 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) {
t.Fatalf("requested paths = %v, want %d source endpoints", requested, len(wantPaths))
}
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 +94,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,8 +113,23 @@ 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") {
@@ -99,6 +153,35 @@ 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) {
@@ -161,6 +244,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 +305,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 +366,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()
@@ -296,12 +471,14 @@ type handlerOverride struct {
func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server {
t.Helper()
fixtures := map[string]string{
"/observations": "observations.json",
"/conditions/current": "current.json",
"/forecast/hourly": "hourly.json",
"/forecast/narrative": "narrative.json",
"/alerts/active": "alerts.json",
"/discussion": "discussion.json",
"/observations": "observations.json",
"/conditions/current": "current.json",
"/forecast/hourly": "hourly.json",
"/forecast/narrative": "narrative.json",
"/alerts/active": "alerts.json",
"/discussion": "discussion.json",
"/weatherstories/latest": "weather_story.json",
convectiveOutlooksEndpoint: "convective_outlooks.json",
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if requested != nil {
@@ -355,7 +532,7 @@ func containsPath(requested []string, path string) bool {
return false
}
func sourceByName(t *testing.T, sources []forecast.Source, name string) forecast.Source {
func sourceByName(t *testing.T, sources []weatherdata.Source, name string) weatherdata.Source {
t.Helper()
for _, source := range sources {
if source.Name == name {
@@ -363,7 +540,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 {

View 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"
}
]
}
}

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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
View 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)
}

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

View File

@@ -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 {
@@ -24,11 +25,11 @@ type InspectRunRequest struct {
}
type SourceInspection struct {
RunID string `json:"runId"`
ReportID report.ID `json:"reportId"`
SourceLocation string `json:"sourceLocation,omitempty"`
Sources []briefing.SourceMetadata `json:"sources,omitempty"`
Warnings []forecast.SourceWarning `json:"warnings,omitempty"`
RunID string `json:"runId"`
ReportID report.ID `json:"reportId"`
SourceLocation string `json:"sourceLocation,omitempty"`
Sources []briefing.SourceMetadata `json:"sources,omitempty"`
Warnings []weatherdata.SourceWarning `json:"warnings,omitempty"`
}
func InspectReports(ctx context.Context, req InspectReportsRequest) ([]state.ReportRecord, error) {
@@ -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) {

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

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

View 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)
}
if value.Alerts == nil || !value.Alerts.Checked || value.Alerts.ActiveCount != 1 || value.Alerts.RelevantCount != 1 {
t.Fatalf("Alerts = %#v, want checked alert status", value.Alerts)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("Marshal metadata: %v", err)
}
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)
}
}
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
}

View 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 == ""
}

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

View File

@@ -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: &currentIsDay,
TemperatureF: &currentTemp,
ApparentTemperatureF: &currentFeelsLike,
RelativeHumidityPercent: &currentHumidity,
WindSpeedMph: &currentWind,
}
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
}

View 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))
}

View 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(), "_")
}

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

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

View File

@@ -0,0 +1,64 @@
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"`
Alerts *AlertDigestModule `json:"alerts,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),
Alerts: alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps, ctx.Timezone),
}
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
}

View 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")
}

View 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)
}
})
}
}

View 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,
},
}
}

View 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),
},
}
}

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

View 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,
}
}

View File

@@ -1,43 +1,29 @@
// 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"`
PromptID string `json:"promptId"`
GeneratedAt time.Time `json:"generatedAt"`
Units string `json:"units"`
Timezone string `json:"timezone"`
ValidPeriod timeutil.Period `json:"validPeriod"`
Location *LocationContext `json:"location,omitempty"`
SourceLocationID string `json:"sourceLocationId,omitempty"`
SourceLocation string `json:"sourceLocation,omitempty"`
Sources []SourceMetadata `json:"sources,omitempty"`
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"`
Alerts *AlertStatus `json:"alerts,omitempty"`
RunID string `json:"runId"`
ReportID report.ID `json:"reportId"`
Variant string `json:"variant,omitempty"`
PromptID string `json:"promptId"`
GeneratedAt time.Time `json:"generatedAt"`
Units string `json:"units"`
Timezone string `json:"timezone"`
ValidPeriod timeutil.Period `json:"validPeriod"`
Location *LocationContext `json:"location,omitempty"`
SourceLocationID string `json:"sourceLocationId,omitempty"`
SourceLocation string `json:"sourceLocation,omitempty"`
Sources []SourceMetadata `json:"sources,omitempty"`
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
Alerts *AlertStatus `json:"alerts,omitempty"`
}
type LocationContext struct {
@@ -47,30 +33,15 @@ 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"`
FetchedAt time.Time `json:"fetchedAt"`
IssuedAt *time.Time `json:"issuedAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
DataSHA256 string `json:"dataSha256,omitempty"`
Missing bool `json:"missing,omitempty"`
Warnings []forecast.SourceWarning `json:"warnings,omitempty"`
Name string `json:"name"`
Endpoint string `json:"endpoint,omitempty"`
FetchedAt time.Time `json:"fetchedAt"`
IssuedAt *time.Time `json:"issuedAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
DataSHA256 string `json:"dataSha256,omitempty"`
Missing bool `json:"missing,omitempty"`
Warnings []weatherdata.SourceWarning `json:"warnings,omitempty"`
}
type AlertStatus struct {
@@ -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
}
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 ""

View 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 ""
}
}

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

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

View File

@@ -0,0 +1,154 @@
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"`
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 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,
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
}

View File

@@ -0,0 +1,257 @@
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.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", "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 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,
}
}

View File

@@ -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...)
}

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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, ". ") + "."
}

View File

@@ -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)
}
}

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

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

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

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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,
},
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,
},
RelevantAlerts: alertOverlaps,
Dayparts: []forecast.DaypartSummary{
{Name: "morning", Indicators: indicators},
},
},
}},
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
}

View File

@@ -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)
}
sort.Strings(keys)
for _, key := range keys {
daypart := dayparts[key]
date := daypartDate(daypart)
if date == "" {
continue
}
if date != "" {
out[date] = day
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
}

View File

@@ -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: &currentTemp},
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,
},
}})
}

View File

@@ -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_")
}

View File

@@ -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: &currentTemp},
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)
}
t.Fatalf("changes = %#v, want snow risk change", changes)
}

75
internal/cli/output.go Normal file
View 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)
}

View 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
View 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
View 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),
}
}

View File

@@ -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

View File

@@ -0,0 +1,31 @@
// Package collect owns upstream weather source collection for application use.
package collect
import (
"context"
"fmt"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/weatherapi"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type Request struct {
Config config.Config
}
type Result struct {
Bundle *weatherdata.Bundle
}
func Run(ctx context.Context, req Request) (*Result, error) {
client, err := weatherapi.New(req.Config)
if err != nil {
return nil, fmt.Errorf("prepare weather collection: %w", err)
}
bundle, err := client.FetchBundle(ctx)
if err != nil {
return nil, fmt.Errorf("collect weather bundle: %w", err)
}
return &Result{Bundle: bundle}, nil
}

View File

@@ -0,0 +1,99 @@
package collect
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
)
func TestRunFetchesBundle(t *testing.T) {
server := collectionTestServer(t, nil)
defer server.Close()
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
result, err := Run(context.Background(), Request{Config: cfg})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if result == nil || result.Bundle == nil {
t.Fatal("Run() result bundle = nil, want fetched bundle")
}
if result.Bundle.Hourly == nil {
t.Fatal("Hourly = nil, want fetched hourly forecast")
}
if result.Bundle.WeatherStory == nil || result.Bundle.WeatherStory.Title != "Several Chances for Rain Through Monday" {
t.Fatalf("WeatherStory = %#v, want fetched weather story", result.Bundle.WeatherStory)
}
}
func TestRunWrapsAdapterConstructionError(t *testing.T) {
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = ""
cfg.Secrets.Directory = "super-secret-directory"
_, err := Run(context.Background(), Request{Config: cfg})
if err == nil {
t.Fatal("Run() error = nil, want adapter construction error")
}
if !strings.Contains(err.Error(), "prepare weather collection") {
t.Fatalf("error = %q, want collection setup context", err.Error())
}
if strings.Contains(err.Error(), cfg.Secrets.Directory) {
t.Fatalf("error = %q, want no secret path leakage", err.Error())
}
}
func TestRunWrapsFetchError(t *testing.T) {
server := collectionTestServer(t, map[string]int{"/observations": http.StatusBadGateway})
defer server.Close()
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
_, err := Run(context.Background(), Request{Config: cfg})
if err == nil {
t.Fatal("Run() error = nil, want fetch error")
}
if !strings.Contains(err.Error(), "collect weather bundle") {
t.Fatalf("error = %q, want collection fetch context", err.Error())
}
if !strings.Contains(err.Error(), "/observations") {
t.Fatalf("error = %q, want source endpoint context", err.Error())
}
}
func collectionTestServer(t *testing.T, statusByPath map[string]int) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if status := statusByPath[r.URL.Path]; status != 0 {
http.Error(w, "upstream failure", status)
return
}
switch r.URL.Path {
case "/observations":
_, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`))
case "/conditions/current":
_, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`))
case "/forecast/hourly":
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T13:00:00-05:00","endTime":"2026-05-29T14:00:00-05:00"}]}}`))
case "/forecast/narrative":
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[]}}`))
case "/alerts/active":
_, _ = w.Write([]byte(`{"data":{"alerts":[]}}`))
case "/discussion":
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":[],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for saved bundle."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for saved bundle."}}}`))
case "/weatherstories/latest":
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
case "/outlooks/convective":
_, _ = w.Write([]byte(`{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`))
default:
http.NotFound(w, r)
}
}))
}

View File

@@ -2,24 +2,36 @@
// precedence, and validation.
package config
import "time"
import (
"fmt"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gopkg.in/yaml.v3"
)
type MissingSourcePolicy string
type NotifyFailurePolicy string
const (
MissingSourceError MissingSourcePolicy = "error"
MissingSourceWarn MissingSourcePolicy = "warn"
MissingSourceNone MissingSourcePolicy = "none"
NotifyFailureError NotifyFailurePolicy = "error"
)
type Config struct {
WeatherAPI WeatherAPIConfig `yaml:"weather_api"`
Location LocationConfig `yaml:"location"`
MissingSource MissingSourceConfig `yaml:"missing_source"`
Scriptorium ScriptoriumConfig `yaml:"scriptorium"`
Workspace WorkspaceConfig `yaml:"workspace"`
Dayparts []DaypartConfig `yaml:"dayparts"`
RecentChange RecentChangeConfig `yaml:"recent_change"`
WeatherAPI WeatherAPIConfig `yaml:"weather_api"`
Location LocationConfig `yaml:"location"`
Secrets SecretsConfig `yaml:"secrets"`
Notify NotifyConfig `yaml:"notify"`
MissingSource MissingSourceConfig `yaml:"missing_source"`
Scriptorium ScriptoriumConfig `yaml:"scriptorium"`
Workspace WorkspaceConfig `yaml:"workspace"`
Dayparts []DaypartConfig `yaml:"dayparts"`
RecentChange RecentChangeConfig `yaml:"recent_change"`
Reports map[string]ReportConfig `yaml:"reports"`
}
type WeatherAPIConfig struct {
@@ -37,6 +49,33 @@ type LocationConfig struct {
Region string `yaml:"region"`
}
type SecretsConfig struct {
Directory string `yaml:"directory"`
}
type NotifyConfig struct {
Distributor DistributorNotifyConfig `yaml:"distributor"`
}
type DistributorNotifyConfig struct {
Enabled bool `yaml:"enabled"`
Endpoint string `yaml:"endpoint"`
TokenEnv string `yaml:"token_env"`
Timeout time.Duration `yaml:"timeout"`
FailurePolicy NotifyFailurePolicy `yaml:"failure_policy"`
PipelineIDTemplate string `yaml:"pipeline_id_template"`
BundleIDTemplate string `yaml:"bundle_id_template"`
IdempotencyKeyTemplate string `yaml:"idempotency_key_template"`
Batch DistributorBatchNotifyConfig `yaml:"batch"`
}
type DistributorBatchNotifyConfig struct {
Enabled bool `yaml:"enabled"`
PipelineIDTemplate string `yaml:"pipeline_id_template"`
BundleIDTemplate string `yaml:"bundle_id_template"`
IdempotencyKeyTemplate string `yaml:"idempotency_key_template"`
}
type MissingSourceConfig struct {
Default MissingSourcePolicy `yaml:"default"`
Sources map[string]MissingSourcePolicy `yaml:"sources"`
@@ -51,11 +90,12 @@ type ScriptoriumConfig struct {
}
type WorkspaceConfig struct {
Root string `yaml:"root"`
SnapshotsDir string `yaml:"snapshots_dir"`
ReportsDir string `yaml:"reports_dir"`
DataPackagesDir string `yaml:"data_packages_dir"`
PreflightDir string `yaml:"preflight_dir"`
Root string `yaml:"root"`
SnapshotsDir string `yaml:"snapshots_dir"`
ReportsDir string `yaml:"reports_dir"`
DataPackagesDir string `yaml:"data_packages_dir"`
PreflightDir string `yaml:"preflight_dir"`
NotificationsDir string `yaml:"notifications_dir"`
}
type DaypartConfig struct {
@@ -70,3 +110,187 @@ type RecentChangeConfig struct {
WindGustMilesPerHour int `yaml:"wind_gust_miles_per_hour"`
PrecipTimingShiftMinutes int `yaml:"precip_timing_shift_minutes"`
}
type ReportConfig struct {
DeterministicModules []ModuleConfigItem `yaml:"deterministic_modules"`
Distributor ReportDistributorConfig `yaml:"distributor"`
deterministicModulesSet bool
}
type ReportDistributorConfig struct {
PathTemplates []string `yaml:"path_templates"`
pathTemplatesSet bool
}
type ModuleConfigItem struct {
ID module.ID `yaml:"id"`
Options any `yaml:"options,omitempty"`
}
func (c *ReportConfig) UnmarshalYAML(value *yaml.Node) error {
if value.Kind != yaml.MappingNode {
return fmt.Errorf("report entry must be a mapping")
}
for i := 0; i < len(value.Content); i += 2 {
key := value.Content[i].Value
node := value.Content[i+1]
switch key {
case "deterministic_modules":
if err := node.Decode(&c.DeterministicModules); err != nil {
return err
}
c.deterministicModulesSet = true
case "distributor":
if err := node.Decode(&c.Distributor); err != nil {
return err
}
default:
return fmt.Errorf("unknown report entry field %q", key)
}
}
return nil
}
func (c *DistributorNotifyConfig) UnmarshalYAML(value *yaml.Node) error {
if value.Kind != yaml.MappingNode {
return fmt.Errorf("notify distributor entry must be a mapping")
}
for i := 0; i < len(value.Content); i += 2 {
key := value.Content[i].Value
node := value.Content[i+1]
switch key {
case "enabled":
if err := node.Decode(&c.Enabled); err != nil {
return err
}
case "endpoint":
if err := node.Decode(&c.Endpoint); err != nil {
return err
}
case "token_env":
if err := node.Decode(&c.TokenEnv); err != nil {
return err
}
case "timeout":
if err := node.Decode(&c.Timeout); err != nil {
return err
}
case "failure_policy":
if err := node.Decode(&c.FailurePolicy); err != nil {
return err
}
case "pipeline_id_template":
if err := node.Decode(&c.PipelineIDTemplate); err != nil {
return err
}
case "bundle_id_template":
if err := node.Decode(&c.BundleIDTemplate); err != nil {
return err
}
case "idempotency_key_template":
if err := node.Decode(&c.IdempotencyKeyTemplate); err != nil {
return err
}
case "batch":
if err := node.Decode(&c.Batch); err != nil {
return err
}
default:
return fmt.Errorf("unknown notify distributor field %q", key)
}
}
return nil
}
func (c *DistributorBatchNotifyConfig) UnmarshalYAML(value *yaml.Node) error {
if value.Kind != yaml.MappingNode {
return fmt.Errorf("notify distributor batch entry must be a mapping")
}
for i := 0; i < len(value.Content); i += 2 {
key := value.Content[i].Value
node := value.Content[i+1]
switch key {
case "enabled":
if err := node.Decode(&c.Enabled); err != nil {
return err
}
case "pipeline_id_template":
if err := node.Decode(&c.PipelineIDTemplate); err != nil {
return err
}
case "bundle_id_template":
if err := node.Decode(&c.BundleIDTemplate); err != nil {
return err
}
case "idempotency_key_template":
if err := node.Decode(&c.IdempotencyKeyTemplate); err != nil {
return err
}
default:
return fmt.Errorf("unknown notify distributor batch field %q", key)
}
}
return nil
}
func (c *ReportDistributorConfig) UnmarshalYAML(value *yaml.Node) error {
if value.Kind != yaml.MappingNode {
return fmt.Errorf("report distributor entry must be a mapping")
}
for i := 0; i < len(value.Content); i += 2 {
key := value.Content[i].Value
node := value.Content[i+1]
switch key {
case "path_templates":
if err := node.Decode(&c.PathTemplates); err != nil {
return err
}
c.pathTemplatesSet = true
default:
return fmt.Errorf("unknown report distributor field %q", key)
}
}
return nil
}
func (c ReportDistributorConfig) PathTemplatesSet() bool {
return c.pathTemplatesSet
}
func (m *ModuleConfigItem) UnmarshalYAML(value *yaml.Node) error {
switch value.Kind {
case yaml.ScalarNode:
if value.Value == "" {
return fmt.Errorf("module id is required")
}
m.ID = module.ID(value.Value)
return nil
case yaml.MappingNode:
var sawID bool
for i := 0; i < len(value.Content); i += 2 {
key := value.Content[i].Value
node := value.Content[i+1]
switch key {
case "id":
if err := node.Decode(&m.ID); err != nil {
return err
}
sawID = true
case "options":
var options any
if err := node.Decode(&options); err != nil {
return err
}
m.Options = options
default:
return fmt.Errorf("unknown module entry field %q", key)
}
}
if !sawID || m.ID == "" {
return fmt.Errorf("module id is required")
}
return nil
default:
return fmt.Errorf("module entry must be a string or mapping")
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,27 @@ func Defaults() Config {
Name: "Brentwood",
Region: "St. Louis Metro",
},
Secrets: SecretsConfig{
Directory: "",
},
Notify: NotifyConfig{
Distributor: DistributorNotifyConfig{
Enabled: false,
Endpoint: "https://distributor.example.com",
TokenEnv: "DISTRIBUTOR_UPLOAD_TOKEN",
Timeout: 30 * time.Second,
FailurePolicy: NotifyFailureError,
PipelineIDTemplate: "",
BundleIDTemplate: "weatherreporter.{location_id}.{report_id}",
IdempotencyKeyTemplate: "{bundle_id}.{run_id}",
Batch: DistributorBatchNotifyConfig{
Enabled: true,
PipelineIDTemplate: "weatherreporter",
BundleIDTemplate: "weatherreporter.{location_id}.{batch}",
IdempotencyKeyTemplate: "{bundle_id}.{batch_run_id}",
},
},
},
MissingSource: MissingSourceConfig{
Default: MissingSourceWarn,
Sources: map[string]MissingSourcePolicy{},
@@ -27,11 +48,12 @@ func Defaults() Config {
Timeout: 2 * time.Minute,
},
Workspace: WorkspaceConfig{
Root: "workspace",
SnapshotsDir: "snapshots",
ReportsDir: "reports",
DataPackagesDir: "data-packages",
PreflightDir: "preflight",
Root: "workspace",
SnapshotsDir: "snapshots",
ReportsDir: "reports",
DataPackagesDir: "data-packages",
PreflightDir: "preflight",
NotificationsDir: "notifications",
},
Dayparts: []DaypartConfig{
{Name: "overnight", Start: "00:00", End: "06:00"},
@@ -46,5 +68,6 @@ func Defaults() Config {
WindGustMilesPerHour: 10,
PrecipTimingShiftMinutes: 120,
},
Reports: map[string]ReportConfig{},
}
}

View File

@@ -35,6 +35,14 @@ func Load(opts LoadOptions) (Config, error) {
cfg.WeatherAPI.Timezone = opts.Timezone
}
if err := normalizeReportModules(&cfg); err != nil {
return Config{}, err
}
if err := loadSecrets(cfg.Secrets); err != nil {
return Config{}, err
}
if err := Validate(cfg); err != nil {
return Config{}, err
}
@@ -57,5 +65,8 @@ func mergeFile(cfg *Config, path string) error {
if cfg.MissingSource.Sources == nil {
cfg.MissingSource.Sources = map[string]MissingSourcePolicy{}
}
if cfg.Reports == nil {
cfg.Reports = map[string]ReportConfig{}
}
return nil
}

View File

@@ -0,0 +1,318 @@
package config
import (
"fmt"
"path/filepath"
"strings"
)
type DistributorTemplateValues struct {
LocationID string
ReportID string
RunID string
ArtifactGroup string
BatchOutputName string
ValidStartDate string
ValidEndDate string
ValidStartTime string
ValidEndTime string
ValidStartStamp string
ValidEndStamp string
StormID string
BundleID string
}
type DistributorBatchTemplateValues struct {
LocationID string
Batch string
BatchRunID string
BatchStartedDate string
BundleID string
}
var distributorTemplateVariables = map[string]struct{}{
"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": {},
"storm_id": {},
}
var distributorIdempotencyTemplateVariables = map[string]struct{}{
"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": {},
"storm_id": {},
"bundle_id": {},
}
var distributorPipelineTemplateVariables = distributorIdempotencyTemplateVariables
var distributorBatchTemplateVariables = map[string]struct{}{
"location_id": {},
"batch": {},
"batch_run_id": {},
"batch_started_date": {},
}
var distributorBatchIdempotencyTemplateVariables = map[string]struct{}{
"location_id": {},
"batch": {},
"batch_run_id": {},
"batch_started_date": {},
"bundle_id": {},
}
var distributorBatchPipelineTemplateVariables = distributorBatchTemplateVariables
func RenderDistributorBundleID(template string, values DistributorTemplateValues) (string, error) {
return renderDistributorTemplate("notify.distributor.bundle_id_template", template, values, distributorTemplateVariables)
}
func RenderDistributorPipelineID(template string, values DistributorTemplateValues) (string, error) {
rendered, err := renderDistributorTemplate("notify.distributor.pipeline_id_template", template, values, distributorPipelineTemplateVariables)
if err != nil {
return "", err
}
if strings.TrimSpace(rendered) == "" {
return "", fmt.Errorf("notify.distributor.pipeline_id_template renders an empty pipeline id")
}
return rendered, nil
}
func RenderDistributorIdempotencyKey(template string, values DistributorTemplateValues) (string, error) {
return renderDistributorTemplate("notify.distributor.idempotency_key_template", template, values, distributorIdempotencyTemplateVariables)
}
func RenderDistributorBatchBundleID(template string, values DistributorBatchTemplateValues) (string, error) {
rendered, err := renderDistributorBatchTemplate("notify.distributor.batch.bundle_id_template", template, values, distributorBatchTemplateVariables)
if err != nil {
return "", err
}
if strings.TrimSpace(rendered) == "" {
return "", fmt.Errorf("notify.distributor.batch.bundle_id_template renders an empty bundle id")
}
return rendered, nil
}
func RenderDistributorBatchPipelineID(template string, values DistributorBatchTemplateValues) (string, error) {
rendered, err := renderDistributorBatchTemplate("notify.distributor.batch.pipeline_id_template", template, values, distributorBatchPipelineTemplateVariables)
if err != nil {
return "", err
}
if strings.TrimSpace(rendered) == "" {
return "", fmt.Errorf("notify.distributor.batch.pipeline_id_template renders an empty pipeline id")
}
return rendered, nil
}
func RenderDistributorBatchIdempotencyKey(template string, values DistributorBatchTemplateValues) (string, error) {
rendered, err := renderDistributorBatchTemplate("notify.distributor.batch.idempotency_key_template", template, values, distributorBatchIdempotencyTemplateVariables)
if err != nil {
return "", err
}
if strings.TrimSpace(rendered) == "" {
return "", fmt.Errorf("notify.distributor.batch.idempotency_key_template renders an empty idempotency key")
}
return rendered, nil
}
func RenderDistributorReportPaths(name string, templates []string, values DistributorTemplateValues) ([]string, error) {
if len(templates) == 0 {
return nil, fmt.Errorf("%s must contain at least one entry", name)
}
paths := make([]string, 0, len(templates))
seen := make(map[string]struct{}, len(templates))
for i, template := range templates {
itemName := fmt.Sprintf("%s[%d]", name, i)
rendered, err := renderDistributorTemplate(itemName, template, values, distributorTemplateVariables)
if err != nil {
return nil, err
}
if err := ValidateDistributorReportPath(itemName, rendered); err != nil {
return nil, err
}
if _, ok := seen[rendered]; ok {
return nil, fmt.Errorf("%s renders duplicate path %q", name, rendered)
}
seen[rendered] = struct{}{}
paths = append(paths, rendered)
}
return paths, nil
}
func validateDistributorTemplate(name, template string, allowed map[string]struct{}) error {
_, err := renderDistributorTemplate(name, template, DistributorTemplateValues{}, allowed)
return err
}
func validateDistributorBatchTemplate(name, template string, allowed map[string]struct{}) error {
_, err := renderDistributorBatchTemplate(name, template, DistributorBatchTemplateValues{}, allowed)
return err
}
func renderDistributorTemplate(name, template string, values DistributorTemplateValues, allowed map[string]struct{}) (string, error) {
var rendered strings.Builder
for i := 0; i < len(template); {
switch template[i] {
case '{':
end := strings.IndexByte(template[i+1:], '}')
if end < 0 {
return "", fmt.Errorf("%s contains an unclosed template variable", name)
}
variable := template[i+1 : i+1+end]
if variable == "" {
return "", fmt.Errorf("%s contains an empty template variable", name)
}
if _, ok := allowed[variable]; !ok {
return "", fmt.Errorf("%s contains unknown template variable %q", name, variable)
}
rendered.WriteString(distributorTemplateValue(variable, values))
i += end + 2
case '}':
return "", fmt.Errorf("%s contains an unopened template variable", name)
default:
rendered.WriteByte(template[i])
i++
}
}
return rendered.String(), nil
}
func renderDistributorBatchTemplate(name, template string, values DistributorBatchTemplateValues, allowed map[string]struct{}) (string, error) {
var rendered strings.Builder
for i := 0; i < len(template); {
switch template[i] {
case '{':
end := strings.IndexByte(template[i+1:], '}')
if end < 0 {
return "", fmt.Errorf("%s contains an unclosed template variable", name)
}
variable := template[i+1 : i+1+end]
if variable == "" {
return "", fmt.Errorf("%s contains an empty template variable", name)
}
if _, ok := allowed[variable]; !ok {
return "", fmt.Errorf("%s contains unknown template variable %q", name, variable)
}
rendered.WriteString(distributorBatchTemplateValue(variable, values))
i += end + 2
case '}':
return "", fmt.Errorf("%s contains an unopened template variable", name)
default:
rendered.WriteByte(template[i])
i++
}
}
return rendered.String(), nil
}
func distributorTemplateValue(variable string, values DistributorTemplateValues) string {
switch variable {
case "location_id":
return values.LocationID
case "report_id":
return values.ReportID
case "run_id":
return values.RunID
case "artifact_group":
return values.ArtifactGroup
case "batch_output_name":
return values.BatchOutputName
case "valid_start_date":
return values.ValidStartDate
case "valid_end_date":
return values.ValidEndDate
case "valid_start_time":
return values.ValidStartTime
case "valid_end_time":
return values.ValidEndTime
case "valid_start_stamp":
return values.ValidStartStamp
case "valid_end_stamp":
return values.ValidEndStamp
case "storm_id":
return values.StormID
case "bundle_id":
return values.BundleID
default:
return ""
}
}
func distributorBatchTemplateValue(variable string, values DistributorBatchTemplateValues) string {
switch variable {
case "location_id":
return values.LocationID
case "batch":
return values.Batch
case "batch_run_id":
return values.BatchRunID
case "batch_started_date":
return values.BatchStartedDate
case "bundle_id":
return values.BundleID
default:
return ""
}
}
func ValidateDistributorReportPath(name, path string) error {
if path == "" {
return fmt.Errorf("%s renders an empty path", name)
}
if isDistributorAbsolutePath(path) {
return fmt.Errorf("%s must render a relative path", name)
}
if strings.Contains(path, "\\") {
return fmt.Errorf("%s must not render backslashes", name)
}
segments := strings.Split(path, "/")
for _, segment := range segments {
if segment == "" {
return fmt.Errorf("%s must not render empty path segments", name)
}
if segment == "." || segment == ".." {
return fmt.Errorf("%s must not render . or .. path segments", name)
}
if segment == "manifest.json" || segment == distributorSidecarBasename() {
return fmt.Errorf("%s must not render reserved path segment %q", name, segment)
}
}
return nil
}
func distributorSidecarBasename() string {
return "." + "distributor.json"
}
func isDistributorAbsolutePath(path string) bool {
if filepath.IsAbs(path) || strings.HasPrefix(path, "/") {
return true
}
if len(path) >= 3 && isASCIIAlpha(path[0]) && path[1] == ':' && (path[2] == '/' || path[2] == '\\') {
return true
}
return false
}
func isASCIIAlpha(ch byte) bool {
return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z')
}

Some files were not shown because too many files have changed in this diff Show More