163 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
136 changed files with 21652 additions and 2278 deletions

View File

@@ -9,7 +9,7 @@ Markdown reports to a configured `distributor` HTTP upload endpoint.
## Quickstart ## Quickstart
```sh ```sh
weatherreporter generate daily --date 2026-05-29 --out ./daily.md weatherreporter generate today --out ./today.md
``` ```
## Documentation ## Documentation

View File

@@ -6,26 +6,29 @@ batches, and inspects stored artifacts.
## Shortest Useful Command ## Shortest Useful Command
```sh ```sh
weatherreporter generate daily --date 2026-05-29 --out ./daily.md weatherreporter generate today --out ./today.md
``` ```
This loads configuration, fetches weather data, writes managed workspace This loads configuration, collects weather data, writes managed workspace
artifacts, runs `scriptorium render` as a preflight check, runs artifacts, runs `scriptorium render` as a preflight check, runs structured
`scriptorium run`, and writes an extra Markdown copy to `./daily.md`. If `scriptorium run`, validates generated text, renders the embedded Today
distributor notification is enabled in configuration, the command also uploads template, and writes an extra Markdown copy to `./today.md`. If distributor
the managed Markdown report after final metadata is saved. notification is enabled in configuration, the command also uploads the managed
Markdown report after final metadata is saved.
## Commands ## Commands
```text ```text
weatherreporter --help weatherreporter --help
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] [--quiet]
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] 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 reports [--config PATH] [--limit N]
weatherreporter inspect metadata [--config PATH] RUN_ID weatherreporter inspect metadata [--config PATH] RUN_ID
weatherreporter inspect modules [--config PATH] RUN_ID weatherreporter inspect modules [--config PATH] RUN_ID
@@ -34,24 +37,128 @@ weatherreporter inspect prior [--config PATH] RUN_ID
weatherreporter inspect sources [--config PATH] RUN_ID weatherreporter inspect sources [--config PATH] RUN_ID
``` ```
`generate` commands write a JSON module snapshot, YAML data package, preflight Implemented `generate` commands emit a compact JSON summary to stdout on
artifact, managed Markdown report, and metadata under the configured workspace. success. The summary includes command identity, report identity, RunID, status,
`--out` writes an extra Markdown copy for the operator; distributor valid period, and managed artifact paths. They also write a JSON module
notification uses the managed report path, not the extra copy. `generate storm` snapshot, YAML data package, preflight artifact, managed Markdown report, and
requires explicit event-window bounds with `--start` and `--end`. 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 `run morning` generates Today Report, Tomorrow Report, and a dated Daily Report
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch for each later future local civil day with complete hourly forecast coverage.
runs continue independent reports after a failure, print a JSON summary to `run evening` generates Tomorrow Report and the same eligible future Daily
stdout, write compact status lines to stderr, and return nonzero when any report reports. Future Daily expansion starts with the day after tomorrow and skips
failed. `--out-dir` writes extra Markdown copies for the operator; distributor days that do not have every hourly forecast period for the local civil day.
notification uses each managed report path, not the extra copies. When Batch commands collect weather data once before planning; a collection failure
notification is enabled, batch summaries and status lines include notification stops the batch before any report is generated. Batch runs continue independent
status, accepted distributor run ID, or notification error fields for each reports after a later report failure, print a JSON summary to stdout, write
attempted report. 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. Hourly Report, 3-Day Outlook, and Weekend Outlook are explicit only; they are
They do not fetch weather data or invoke `scriptorium`. 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 ## Flags
@@ -59,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`. - `--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`. - `--units VALUE`: override configured Weather API units for `generate` and `run`.
- `--tz NAME`: override configured Weather API timezone 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`. - `--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`. - `--start TIME`: required start time for `generate storm`.
- `--end TIME`: required end 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. - `--limit N`: maximum records for `inspect reports`; defaults to `20`, and `0` means no limit.
@@ -75,23 +183,29 @@ are no distributor-specific CLI flags.
## Common Workflows ## Common Workflows
```sh ```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 tomorrow --out ./tomorrow.md
weatherreporter generate hourly
weatherreporter generate three-day --out ./three-day.md weatherreporter generate three-day --out ./three-day.md
weatherreporter generate weekend --out ./weekend.md weatherreporter generate weekend --out ./weekend.md
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
weatherreporter run morning --out-dir ./reports weatherreporter run morning --out-dir ./reports
weatherreporter run evening --out-dir ./reports weatherreporter run evening --out-dir ./reports
weatherreporter generate today --quiet
weatherreporter run morning --quiet
``` ```
## Inspection ## Inspection
```sh ```sh
weatherreporter inspect reports --limit 10 weatherreporter inspect reports --limit 10
weatherreporter inspect metadata 20260529T100000.000000000Z_daily_today weatherreporter inspect metadata 20260529T100000.000000000Z_today
weatherreporter inspect modules 20260529T100000.000000000Z_daily_today weatherreporter inspect modules 20260529T100000.000000000Z_today
weatherreporter inspect data-package 20260529T100000.000000000Z_daily_today weatherreporter inspect data-package 20260529T100000.000000000Z_today
weatherreporter inspect prior 20260529T100000.000000000Z_daily_today weatherreporter inspect prior 20260529T100000.000000000Z_today
weatherreporter inspect sources 20260529T100000.000000000Z_daily_today weatherreporter inspect sources 20260529T100000.000000000Z_today
weatherreporter inspect metadata 20260529T100000.000000000Z_daily
``` ```
`inspect reports` lists recent generated runs with artifact paths and source `inspect reports` lists recent generated runs with artifact paths and source

View File

@@ -29,8 +29,8 @@ weather_api:
base_url: https://weather.api.example.com/ base_url: https://weather.api.example.com/
``` ```
`weather_api.base_url` is required for commands that fetch weather data. Other `weather_api.base_url` is required for commands that collect weather data.
fields fall back to defaults. Other fields fall back to defaults.
## Production-Oriented Config ## Production-Oriented Config
@@ -41,7 +41,7 @@ config test suite.
### `weather_api` ### `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`. - `timeout`: HTTP timeout duration. Default: `10s`.
- `precision`: numeric precision query value. Default: `1`. - `precision`: numeric precision query value. Default: `1`.
- `units`: Weather API units query value. Default: `us`. - `units`: Weather API units query value. Default: `us`.
@@ -78,10 +78,13 @@ missing directories, and unreadable files fail config loading.
### `notify` ### `notify`
`notify.distributor` controls distributor notification after successful report `notify.distributor` controls distributor uploads after successful report
generation. It is disabled by default and does not add CLI flags. When enabled, rendering. It is disabled by default and does not add CLI flags. When enabled,
weatherreporter uploads one distributor bundle per generated report after `generate <report>` uploads one distributor bundle for the generated report
`scriptorium run` succeeds and final metadata is saved. 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: - `enabled`: whether distributor notification config is active. Default:
`false`. `false`.
@@ -92,33 +95,63 @@ weatherreporter uploads one distributor bundle per generated report after
- `timeout`: distributor operation timeout. Must be greater than zero when - `timeout`: distributor operation timeout. Must be greater than zero when
enabled. Default: `30s`. enabled. Default: `30s`.
- `failure_policy`: must be `error` when enabled. Default: `error`. - `failure_policy`: must be `error` when enabled. Default: `error`.
- `pipeline_id_template`: template for the distributor pipeline ID. Required - `pipeline_id_template`: template for single-report distributor pipeline IDs.
when enabled. Default: empty. Required when enabled. Default: empty.
- `bundle_id_template`: template for distributor bundle IDs. Default: - `bundle_id_template`: template for single-report distributor bundle IDs.
`weatherreporter.{location_id}.{report_id}`. Default: `weatherreporter.{location_id}.{report_id}`.
- `idempotency_key_template`: template for distributor idempotency keys. - `idempotency_key_template`: template for single-report distributor
Default: `{bundle_id}.{run_id}`. idempotency keys. Default: `{bundle_id}.{run_id}`.
- `report_path_templates`: ordered list of templates for Markdown report paths - `batch.enabled`: whether batch distributor notification config is active
inside the distributor bundle. Each rendered path maps to the same managed when distributor notification is enabled. Default: `true`.
Markdown report source. Default: - `batch.pipeline_id_template`: template for batch distributor pipeline IDs.
```yaml Required when distributor notification and batch notification are enabled.
- "{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md" 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}`.
Supported template variables are `location_id`, `report_id`, `run_id`, Single-report templates support `location_id`, `report_id`, `run_id`,
`artifact_group`, `batch_output_name`, `valid_start_date`, `valid_end_date`, `artifact_group`, `batch_output_name`, `valid_start_date`, `valid_end_date`,
`valid_start_time`, `valid_end_time`, `valid_start_stamp`, and `valid_start_time`, `valid_end_time`, `valid_start_stamp`, `valid_end_stamp`,
`valid_end_stamp`. Date values use `YYYY-MM-DD`, time values use `HHMM`, and 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. stamp values use `YYYY-MM-DDTHHMM` in the effective report timezone.
`pipeline_id_template` and `idempotency_key_template` may also use `bundle_id`. `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` The rendered pipeline ID selects the configured distributor `http_upload`
workflow. The rendered bundle ID is the stable logical source identity for the 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. 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 Rendered report paths must be unique relative paths with `/` separators. They
must not contain backslashes, empty path segments, `.`, `..`, `manifest.json`, must not contain backslashes, empty path segments, `.`, `..`, `manifest.json`,
or `.distributor.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` The upload token is read from the environment variable named by `token_env`
after config loading and `secrets.directory` processing. Config files should after config loading and `secrets.directory` processing. Config files should
@@ -130,7 +163,9 @@ name the variable only; they should not contain the token value.
- `sources`: optional map of source-specific overrides, using the same policy values. - `sources`: optional map of source-specific overrides, using the same policy values.
Hourly forecast data is required for generated reports. Optional sources use Hourly forecast data is required for generated reports. Optional sources use
the missing-source policy. the missing-source policy. Source override keys include `observations`,
`current`, `narrative`, `alerts`, `discussion`, `weather_story`, and
`spc_convective_outlooks`.
### `scriptorium` ### `scriptorium`
@@ -150,7 +185,9 @@ the missing-source policy.
- `notifications_dir`: distributor notification debug artifact directory under `workspace.root`. Default: `notifications`. - `notifications_dir`: distributor notification debug artifact directory under `workspace.root`. Default: `notifications`.
Workspace subdirectories must be relative paths that stay inside 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` ### `dayparts`
@@ -179,36 +216,84 @@ snapshot exists and a threshold is crossed.
`reports` optionally overrides the ordered deterministic modules declared by `reports` optionally overrides the ordered deterministic modules declared by
report definitions. Omit a report entry to use its default module order. report definitions. Omit a report entry to use its default module order.
Supported report keys are `daily`, `tomorrow`, `three_day`, `weekend`, and Supported report keys are `daily`, `today`, `tomorrow`, `hourly`,
`storm`. Canonical report IDs such as `daily_today` and `daily_tomorrow` are `three_day`, `weekend`, and `storm`. Canonical report IDs and accepted aliases
also accepted. 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: Each report entry supports:
- `deterministic_modules`: ordered module list. Entries may be string module - `deterministic_modules`: ordered module list. Entries may be string module
IDs or objects with `id` and optional `options`. 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: Example:
```yaml ```yaml
reports: reports:
daily: daily:
distributor:
path_templates:
- "daily/{valid_start_date}/{run_id}.md"
- "daily/{valid_start_date}/index.md"
deterministic_modules: deterministic_modules:
- metadata - metadata
- current_conditions - current_conditions
- narrative_forecast - narrative_forecast
- alert_digest
- spc_convective_outlooks
- id: area_forecast_discussion - id: area_forecast_discussion
options: options:
sections: sections:
- short_term - long_term
- spc_convective_discussion
- daily_planning
- hourly_forecast - 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 Unknown reports, unknown modules, duplicate modules, incompatible report/module
combinations, duplicate stanza names, and invalid options fail config loading. combinations, duplicate stanza names, and invalid options fail config loading.
`area_forecast_discussion.options.sections` may contain `product`, `area_forecast_discussion.options.sections` may contain `product`,
`key_messages`, `short_term`, and `long_term`. Empty or omitted `sections` `key_messages`, `short_term`, and `long_term`. Empty or omitted `sections`
includes all available AFD 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 The module registry accepts all module IDs documented in
[Module Contract Internals](internal/module.md). Unknown or unimplemented [Module Contract Internals](internal/module.md). Unknown or unimplemented

View File

@@ -104,7 +104,7 @@ func SubmitReport(reportPath, summaryPath string) error {
- Keep file contents stable after upload inputs are selected. Bundle digests are calculated from file bytes. - 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. - Treat upload success as admission only. `UploadFiles` and `UploadBundle` return after the server accepts and validates the upload, not after all destinations publish.
Valid bundle paths are relative slash paths. They must not be empty, absolute, contain backslashes, contain `.` or `..` path segments, contain empty path segments, or use reserved basenames `manifest.json` or `.distributor.json`. 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 ## Idempotency And Status

View File

@@ -68,7 +68,8 @@ Invalid paths include:
- paths containing backslashes; - paths containing backslashes;
- `.` or `..` path segments; - `.` or `..` path segments;
- empty path segments; - empty path segments;
- any basename of `manifest.json` or `.distributor.json`. - 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. Explicit file lists preserve caller order. File order is part of the bundle digest, so producers should choose it deliberately and keep it stable.

View File

@@ -6,7 +6,7 @@ This document describes the external Scriptorium CLI contract used by
## Purpose ## Purpose
`weatherreporter` invokes Scriptorium as a subprocess to preflight prompt input `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. uses, not the full Scriptorium product.
## Commands Used ## Commands Used
@@ -29,11 +29,25 @@ scriptorium run \
--out <artifact_path> --out <artifact_path>
``` ```
Structured generated-text report generation uses the same command shape:
```bash
scriptorium run \
--prompt <prompt_id> \
--input data_package=<path> \
--out <generated_text_raw_path>
```
`weatherreporter` always passes prompt input as `weatherreporter` always passes prompt input as
`--input data_package=<path>`. The data package is structured YAML created by `--input data_package=<path>`. The data package is structured YAML created by
`internal/promptinput`; module snapshots remain separate JSON artifacts for `internal/promptinput`; module snapshots remain separate JSON artifacts for
inspection and Recent Changes. 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 ## Configured Arguments
The adapter can prepend configured flags before prompt-specific arguments: The adapter can prepend configured flags before prompt-specific arguments:
@@ -68,11 +82,17 @@ Render results include:
- exit code - exit code
- truncation flags when applicable - 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 `weatherreporter` persists render preflight JSON when orchestration reaches the
preflight save point. The final Markdown artifact is written by Scriptorium to preflight save point. For direct Markdown reports, Scriptorium writes the
the `--out` path. 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 ## Failure Behavior
@@ -80,7 +100,7 @@ The adapter validates required request fields before starting Scriptorium:
- prompt ID - prompt ID
- data package path - 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 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 code and stderr. A `run` exit code such as `2` is still treated as an error by

View File

@@ -37,6 +37,10 @@ generation.
means the endpoint was checked and there are no current active alerts. The 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. 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 Malformed JSON envelopes, non-2xx statuses, and response read failures include
endpoint context in returned errors. Decode errors include source context when endpoint context in returned errors. Decode errors include source context when
they fail the fetch; optional malformed sources follow the missing-source policy. they fail the fetch; optional malformed sources follow the missing-source policy.
@@ -49,11 +53,66 @@ The adapter sends these query parameters:
- `units`: from `weather_api.units` - `units`: from `weather_api.units`
- `precision`: from `weather_api.precision` on observations, current - `precision`: from `weather_api.precision` on observations, current
conditions, hourly forecast, and narrative forecast requests conditions, hourly forecast, and narrative forecast requests
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast, and - `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast,
discussion requests discussion, and SPC convective outlook requests
Alerts do not receive `precision` or `tz`. Weather story requests receive only Alerts do not receive `precision` or `tz`. Weather story requests receive only
`format=json`. `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 ## Endpoints Used
@@ -66,6 +125,7 @@ The adapter fetches these endpoints once per bundle:
- `/alerts/active` - `/alerts/active`
- `/discussion` - `/discussion`
- `/weatherstories/latest` - `/weatherstories/latest`
- `/outlooks/convective`
`weatherreporter` does not call day-slice forecast endpoints or discussion `weatherreporter` does not call day-slice forecast endpoints or discussion
subsection endpoints. Report-period selection and daypart summarization happen subsection endpoints. Report-period selection and daypart summarization happen
@@ -88,6 +148,7 @@ source-specific `missing_source.sources` policy:
- `alerts` for `/alerts/active` - `alerts` for `/alerts/active`
- `discussion` for `/discussion` - `discussion` for `/discussion`
- `weather_story` for `/weatherstories/latest` - `weather_story` for `/weatherstories/latest`
- `spc_convective_outlooks` for `/outlooks/convective`
Policy behavior: Policy behavior:
@@ -99,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 follows the relevant error path, but explicit `data: null` is not a
missing-source condition. 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 ## Source Identity
For source payloads accepted into the bundle, including the explicit `null` For source payloads accepted into the bundle, including the explicit `null`
@@ -116,8 +180,8 @@ warnings list.
## Compatibility Assumptions ## Compatibility Assumptions
The adapter expects payload fields compatible with the internal forecast bundle The adapter expects payload fields compatible with the internal weather data
types in `internal/forecast/bundle.go`, including: bundle types in `internal/weatherdata/bundle.go`, including:
- observation timestamps and observation values - observation timestamps and observation values
- current condition values - current condition values
@@ -126,6 +190,8 @@ types in `internal/forecast/bundle.go`, including:
- discussion metadata, key messages, and short/long-term section text - discussion metadata, key messages, and short/long-term section text
- latest weather story title, description, timing, priority, order, alt text, - latest weather story title, description, timing, priority, order, alt text,
and download URL and download URL
- SPC convective outlook run metadata, outlooks, discussions, and GeoJSON
geometry
The adapter intentionally keeps upstream transport and envelope details inside The adapter intentionally keeps upstream transport and envelope details inside
`internal/adapters/weatherapi`; downstream packages consume the normalized `internal/adapters/weatherapi`; downstream packages consume the normalized

View File

@@ -5,11 +5,12 @@ This document describes the workflow coordinator in `internal/app`.
## Purpose ## Purpose
`internal/app` coordinates the top-level use cases after CLI parsing and config `internal/app` coordinates the top-level use cases after CLI parsing and config
loading are complete. It resolves report definitions, fetches weather data, loading are complete. It resolves report definitions, collects weather data
builds collected and derived facts, builds module snapshots and prompt-input through `internal/collect`, builds collected and derived facts, builds module
artifacts, invokes Scriptorium through the adapter boundary, optionally snapshots and prompt-input artifacts, invokes Scriptorium through the adapter
notifies distributor through an app-owned notifier boundary, persists managed boundary, optionally notifies distributor through an app-owned notifier
state, runs batches, and reads existing artifacts for inspection. boundary, persists managed state, runs batches, and reads existing artifacts
for inspection.
## Inputs And Outputs ## Inputs And Outputs
@@ -17,21 +18,22 @@ Inputs:
- `GenerateRequest` for one report command - `GenerateRequest` for one report command
- `BatchRequest` for morning or evening batch commands - `BatchRequest` for morning or evening batch commands
- `FetchBundleRequest` for explicit bundle fetch and save workflows - `FetchBundleRequest` for explicit bundle collection and save workflows
- `ReportRequest` for single-report generation - `ReportRequest` for single-report generation
- resolved report definitions from `internal/report` - resolved report definitions from `internal/report`
- weather data bundles from `internal/adapters/weatherapi` - collection results from `internal/collect`
- prior snapshots loaded from `internal/state` - prior snapshots loaded from `internal/state`
- optional renderer, notifier, and state-store fakes for tests - optional collector, renderer, notifier, and state-store fakes for tests
Outputs: Outputs:
- generated report results with JSON module snapshot, YAML data package, - generated report results with JSON module snapshot, YAML data package,
preflight, report, metadata, prior snapshot, Recent Changes, Scriptorium preflight, report, metadata, prior snapshot, Recent Changes, Scriptorium
result details, and notification result when attempted result details, generated-text artifact paths when applicable, and
notification result when attempted
- batch summaries with per-report status, artifact paths, error text, and - batch summaries with per-report status, artifact paths, error text, and
notification outcome when attempted one top-level batch notification result when attempted or skipped
- saved Weather API bundle JSON for fetch workflows - saved Weather API bundle JSON for explicit bundle collection workflows
- inspection JSON values for reports, metadata, module snapshots, data - inspection JSON values for reports, metadata, module snapshots, data
packages, prior snapshots, and source provenance packages, prior snapshots, and source provenance
@@ -44,10 +46,26 @@ construct Scriptorium argv.
Report selection and report identity policy come from `internal/report`. Report selection and report identity policy come from `internal/report`.
Collected and derived fact contracts come from `internal/facts`. Collected and derived fact contracts come from `internal/facts`.
Weather API transport stays in `internal/adapters/weatherapi`. Scriptorium Weather API transport stays in `internal/adapters/weatherapi`, and app-facing
subprocess behavior stays in `internal/adapters/scriptorium`. Distributor upstream collection stays in `internal/collect`. Scriptorium subprocess
upload behavior stays in `internal/adapters/distributor`. Filesystem layout and behavior stays in `internal/adapters/scriptorium`. Distributor upload behavior
persisted metadata stay in `internal/state`. 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 ## Config Fields Used
@@ -56,40 +74,67 @@ persisted metadata stay in `internal/state`.
- `workspace.*` for filesystem state - `workspace.*` for filesystem state
- `dayparts` for daily and outlook summarization - `dayparts` for daily and outlook summarization
- `recent_change.*` for structured Recent Changes thresholds - `recent_change.*` for structured Recent Changes thresholds
- `notify.distributor.*` for optional notification after report generation - `notify.distributor.*` for optional single-report and batch notification
after report generation
Output copy flags are command request fields. They are not configuration Output copy flags are command request fields. They are not configuration
defaults. defaults.
## Generation Workflow ## 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. `GenerateReport` then uses this setup:
2. Create or use a filesystem store.
3. Locate any prior compatible snapshot through `internal/state`. 1. Create or use a filesystem store.
4. Fetch a Weather API bundle. 2. Locate any prior compatible snapshot through `internal/state`.
5. Build collected and derived facts once. 3. Build collected and derived facts from the supplied collection.
6. Execute configured modules and save the module snapshot. 4. Execute configured modules and save the module snapshot.
7. Compute Recent Changes from structured prior and current module snapshots. 5. Compute Recent Changes from structured prior and current module snapshots.
8. Build and save the YAML Scriptorium `data_package`. 6. Build and save the YAML Scriptorium `data_package`.
9. Run Scriptorium render preflight. 7. Run Scriptorium render preflight.
10. Save preflight JSON when a render result is available. 8. Save preflight JSON when a render result is available.
11. Save metadata for inspection. 9. 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. For `scriptorium_markdown` reports, generation then:
14. Save metadata with the managed report path.
15. If distributor notification is enabled, notify using the managed report 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. path as the source file.
16. Save a distributor notification debug artifact and update metadata with its 4. Saves a distributor notification debug artifact and updates metadata with
path. its path.
If render preflight returns both a result and an error, preflight JSON and If render preflight returns both a result and an error, preflight JSON and
metadata are persisted before the error is returned. If Scriptorium report metadata are persisted before the error is returned. If Scriptorium report
generation returns an error after writing output, the managed report and generation returns an error after writing output, the managed report and
metadata remain inspectable. Notification is not attempted after Weather API, metadata remain inspectable. Notification is not attempted after collection,
module snapshot, prompt input, render, Scriptorium run, or metadata-save module snapshot, prompt input, render, Scriptorium run, or metadata-save
failures. 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, When notification is attempted, the debug artifact records request identity,
including rendered pipeline ID, bundle paths, accepted upload fields, including rendered pipeline ID, bundle paths, accepted upload fields,
distributor status fields, raw status report JSON when available, and redacted distributor status fields, raw status report JSON when available, and redacted
@@ -98,15 +143,38 @@ failure context.
## Batch Workflow ## Batch Workflow
`run morning` resolves Daily Today, 3-Day Outlook, and Weekend Outlook except `run morning` collects once, plans Today Report, Tomorrow Report, and eligible
on Sunday. `run evening` resolves Daily Tomorrow. Batch output copy names come future Daily Reports from the collected hourly forecast, then passes the same
from report definitions. Batch generation continues independent reports after a 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 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. JSON summary to stdout, and returns an aggregate error when any report failed.
When notification is enabled, each successfully generated report is notified
independently. Notification failure marks that report failed, records Batch report generation suppresses per-report distributor notification. After
notification fields in the batch result, and does not stop later reports. all planned reports finish, app orchestration evaluates batch notification:
`--out-dir` copies are never used as notification source files.
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 Workflow
@@ -117,13 +185,19 @@ inspection view.
## Failure Behavior ## Failure Behavior
- Resolve errors stop the requested workflow before fetching weather data. - Resolve errors stop the requested workflow before collection.
- Weather API and module execution errors stop that report before Scriptorium - Collection and module execution errors stop that report before Scriptorium
runs. runs.
- Prompt input validation fails before render preflight. - Prompt input validation fails before render preflight.
- Render and run errors preserve Scriptorium stderr and exit-code context. - Render and run errors preserve Scriptorium stderr and exit-code context.
- Notification errors are wrapped with report ID, RunID, and managed report path - Generated-text report errors preserve available intermediate artifacts and do
context and are recorded separately in batch results. 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. - Metadata and artifact path errors include filesystem context.
- Batch failures are recorded per report and surfaced through an aggregate - Batch failures are recorded per report and surfaced through an aggregate
batch error. batch error.
@@ -133,16 +207,27 @@ inspection view.
Inspect: Inspect:
- `internal/app/app_test.go` - `internal/app/app_test.go`
- `internal/app/batch_plan_test.go`
- `internal/collect/collect_test.go`
- `internal/cli/root_test.go` - `internal/cli/root_test.go`
- `internal/state/filesystem_test.go` - `internal/state/filesystem_test.go`
## Invariants ## Invariants
- Report behavior is resolved through `internal/report`. - 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 - Generated reports use the same app request and result types regardless of
report ID. report ID.
- Render preflight precedes Scriptorium report generation. - Render preflight precedes Scriptorium report generation.
- Generated-text reports render Markdown from a curated render context, not from
a raw data package.
- Recent Changes are computed from structured module snapshots. - Recent Changes are computed from structured module snapshots.
- Metadata links artifacts produced for a run. - Metadata links artifacts produced for a run.
- Distributor notification maps the managed Markdown report path to configured - Single-report distributor notification maps the managed Markdown report path
bundle paths; extra output copies are not upload sources. 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

@@ -28,14 +28,46 @@ Outputs:
supported reports, fact requirements, missing-data behavior, and builder supported reports, fact requirements, missing-data behavior, and builder
- `module.Output` values for source-oriented stanzas: - `module.Output` values for source-oriented stanzas:
`metadata`, `current_conditions`, `narrative_forecast`, `hourly_forecast`, `metadata`, `current_conditions`, `narrative_forecast`, `hourly_forecast`,
`alert_digest`, `area_forecast_discussion`, and `weather_story` `alert_digest`, `spc_convective_outlooks`,
`area_forecast_discussion`, `spc_convective_discussion`, and
`weather_story`
- `module.Output` values for derived stanzas: - `module.Output` values for derived stanzas:
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`, `derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
`outdoor_windows`, and `tomorrow_planning` `outdoor_windows`, `today_planning`, `tomorrow_planning`, and
`daily_planning`
Every registered composition entry has a builder. Unknown or unimplemented Every registered composition entry has a builder. Unknown or unimplemented
module IDs fail validation instead of being skipped. 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 Prompt-facing module values use local, human-readable date and time labels
where the LLM is expected to reason about report content. Canonical timestamps where the LLM is expected to reason about report content. Canonical timestamps
remain in report metadata, source provenance, and integration artifacts. remain in report metadata, source provenance, and integration artifacts.
@@ -45,7 +77,7 @@ remain in report metadata, source provenance, and integration artifacts.
- This package selects and shapes already-collected weather facts for prompts. - This package selects and shapes already-collected weather facts for prompts.
- It validates module composition against report compatibility and option - It validates module composition against report compatibility and option
types. types.
- It does not fetch weather data, compare prior snapshots, write module - It does not collect weather data, compare prior snapshots, write module
snapshots, build YAML data packages, invoke Scriptorium, or write workflow snapshots, build YAML data packages, invoke Scriptorium, or write workflow
metadata. metadata.
@@ -57,7 +89,26 @@ builders run. Configured `location` values are prompt context only; Weather API
`sourceLocationId` and `sourceLocation` remain source provenance. `sourceLocationId` and `sourceLocation` remain source provenance.
`area_forecast_discussion` uses optional `sections` configuration to include a `area_forecast_discussion` uses optional `sections` configuration to include a
subset of discussion fields. 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 ## External Adapters Used
@@ -86,6 +137,11 @@ return an error for invalid required inputs.
forecast discussion, and weather story stanzas. forecast discussion, and weather story stanzas.
- Alert digest output distinguishes checked empty alert data from missing alert - Alert digest output distinguishes checked empty alert data from missing alert
source data. 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 ## 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

@@ -6,10 +6,10 @@ This document describes the distributor upload adapter in
## Purpose ## Purpose
The adapter submits generated weatherreporter Markdown reports to a configured The adapter submits generated weatherreporter Markdown reports to a configured
distributor HTTP upload endpoint. It isolates distributor package types, distributor HTTP upload endpoint. It supports one or more file mappings per
token-env lookup, upload client construction, source-bundle file mapping, upload request. It isolates distributor package types, token-env lookup, upload
timeout handling, status polling, and upload error wrapping from app client construction, source-bundle file mapping, timeout handling, status
orchestration. polling, and upload error wrapping from app orchestration.
## Inputs And Outputs ## Inputs And Outputs
@@ -21,7 +21,7 @@ Inputs:
- pipeline ID - pipeline ID
- bundle ID - bundle ID
- idempotency key - idempotency key
- source Markdown report path and bundle-relative path mappings - source Markdown report paths and bundle-relative path mappings
- bundle created timestamp - bundle created timestamp
- context for cancellation - context for cancellation
@@ -40,8 +40,9 @@ Outputs:
The app layer passes weatherreporter-owned request values to the adapter. The The app layer passes weatherreporter-owned request values to the adapter. The
adapter does not choose report types, render templates, select output copies, adapter does not choose report types, render templates, select output copies,
configure destinations, wait for downstream publication, transform Markdown, or decide whether an upload represents one report or a batch, configure
persist notification state. destinations, wait for downstream publication, transform Markdown, or persist
notification state.
Full upstream distributor package and HTTP contract details stay under Full upstream distributor package and HTTP contract details stay under
`docs/integrations/distributor/`. `docs/integrations/distributor/`.
@@ -54,13 +55,22 @@ The adapter is built from `notify.distributor` config:
- `token_env` - `token_env`
- `timeout` - `timeout`
The app layer renders pipeline ID, bundle ID, idempotency key, and bundle paths The app layer renders single-report pipeline ID, bundle ID, idempotency key,
from: and bundle paths from:
- `pipeline_id_template` - `pipeline_id_template`
- `bundle_id_template` - `bundle_id_template`
- `idempotency_key_template` - `idempotency_key_template`
- `report_path_templates` - 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` The token value is read from the environment variable named by `token_env`
after config loading and `secrets.directory` processing. after config loading and `secrets.directory` processing.
@@ -70,9 +80,9 @@ after config loading and `secrets.directory` processing.
The adapter calls distributor `UploadFiles` with one or more file mappings: The adapter calls distributor `UploadFiles` with one or more file mappings:
- pipeline ID: the rendered distributor workflow selector - pipeline ID: the rendered distributor workflow selector
- source path: the managed Markdown report path selected by app orchestration - source paths: managed Markdown report paths selected by app orchestration
- bundle paths: rendered bundle-relative report paths - bundle paths: rendered bundle-relative report paths for each source
- created: the report generation timestamp - created: the report or batch generation timestamp
The adapter creates a distributor upload client with the configured endpoint, The adapter creates a distributor upload client with the configured endpoint,
bearer token, and timeout-backed HTTP client. It also wraps the upload context bearer token, and timeout-backed HTTP client. It also wraps the upload context
@@ -115,7 +125,8 @@ distributor service.
## Invariants ## Invariants
- Distributor package types do not leak outside the adapter. - Distributor package types do not leak outside the adapter.
- Only the managed Markdown report is uploaded. - Only managed Markdown report paths selected by app orchestration are
uploaded.
- The adapter never scans the workspace. - The adapter never scans the workspace.
- Token values are not included in errors, CLI output, metadata, docs, or - Token values are not included in errors, CLI output, metadata, docs, or
examples. examples.

View File

@@ -20,14 +20,29 @@ Inputs:
Outputs: Outputs:
- `facts.CollectedFacts` with normalized source facts plus separate source - `facts.CollectedFacts` with normalized source facts plus separate source
provenance and warnings 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, - `facts.DerivedFacts` with valid-period forecast slices, alert overlaps,
daily summaries, daypart summaries, and Storm Report window summary 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 ## Boundaries
- This package owns fact assembly and reusable deterministic derivation for a - This package owns fact assembly and reusable deterministic derivation for a
report run. 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 - It does not fetch upstream data, build prompt wording, compare prior
snapshots, write workflow state, invoke Scriptorium, or define modules. snapshots, write workflow state, invoke Scriptorium, or define modules.
@@ -53,8 +68,13 @@ and inspection.
- Invalid timezone names return an error. - Invalid timezone names return an error.
- Missing required hourly forecast data returns the underlying forecast - Missing required hourly forecast data returns the underlying forecast
derivation error for reports that require daily summaries. 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 - Missing optional narrative, alert, discussion, daily, or weather story data
produces empty or nil derived fields. 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 ## Tests
@@ -67,6 +87,8 @@ Inspect:
- Collected facts are built once from a fetched bundle. - Collected facts are built once from a fetched bundle.
- Derived facts are scoped to one resolved report. - 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. - Source provenance and warnings stay separate from ordinary fact fields.
- Prompt-specific wording and one-off presentation decisions stay outside this - Prompt-specific wording and one-off presentation decisions stay outside this
package. package.

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.

View File

@@ -5,9 +5,9 @@ This document describes the module contract in `internal/module`.
## Purpose ## Purpose
`internal/module` defines the shared identifiers and data envelopes used for `internal/module` defines the shared identifiers and data envelopes used for
prompt-facing modules. Report definitions use module IDs for composition, report modules. Report definitions use module IDs for composition, module
module builders produce outputs with stanza names, prompt input packages consume builders produce rich outputs with stanza names, prompt input packages consume
snapshots, and Recent Changes compares snapshot stanzas. runtime prompt export values, and Recent Changes compares snapshot stanzas.
## Inputs And Outputs ## Inputs And Outputs
@@ -23,8 +23,38 @@ Outputs:
- typed option structs for registered modules - typed option structs for registered modules
- `module.Snapshot` with schema version `weatherreporter.modules.v1` - `module.Snapshot` with schema version `weatherreporter.modules.v1`
- ordered snapshot outputs with module ID, stanza name, and typed value - 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` - 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 ## Registered Module IDs
The registry recognizes these IDs: The registry recognizes these IDs:
@@ -37,17 +67,141 @@ The registry recognizes these IDs:
- `derived_daypart_summaries` - `derived_daypart_summaries`
- `precip_timing` - `precip_timing`
- `alert_digest` - `alert_digest`
- `spc_convective_outlooks`
- `area_forecast_discussion` - `area_forecast_discussion`
- `spc_convective_discussion`
- `weather_story` - `weather_story`
- `outdoor_windows` - `outdoor_windows`
- `today_planning`
- `tomorrow_planning` - `tomorrow_planning`
- `daily_planning`
Every registered module has a builder. Report composition entries that refer to Every registered module has a builder. Report composition entries that refer to
unknown or unimplemented module IDs fail validation instead of being skipped. 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 ## Options
Most modules use an empty options struct. `area_forecast_discussion` accepts: Most modules use an empty options struct, including
`spc_convective_outlooks` and `spc_convective_discussion`.
`area_forecast_discussion` accepts:
```yaml ```yaml
sections: sections:
@@ -61,11 +215,43 @@ An omitted or empty `sections` list includes all available discussion sections.
Invalid option shapes fail during config normalization or composition Invalid option shapes fail during config normalization or composition
validation. 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 ## Boundaries
- This package owns module identifiers, config item envelopes, output - This package owns module identifiers, config item envelopes, output
envelopes, snapshot validation, and typed stanza lookup. envelopes, snapshot validation, and typed stanza lookup.
- It does not define report IDs, execute builders, fetch weather data, derive - It does not define report IDs, execute builders, collect weather data, derive
forecast facts, write state, or invoke Scriptorium. forecast facts, write state, or invoke Scriptorium.
## State Or Manifest Behavior ## State Or Manifest Behavior
@@ -73,7 +259,8 @@ validation.
`module.Snapshot` values are persisted by `internal/state` as JSON. Snapshot `module.Snapshot` values are persisted by `internal/state` as JSON. Snapshot
validation rejects missing schema version, missing module IDs, missing stanza validation rejects missing schema version, missing module IDs, missing stanza
names, duplicate module outputs, and duplicate stanza names while preserving names, duplicate module outputs, and duplicate stanza names while preserving
output order. output order. Snapshot JSON contains rich module values only; runtime prompt
export values are not persisted.
## Failure Behavior ## Failure Behavior
@@ -94,5 +281,8 @@ Inspect:
- `internal/module` does not import `internal/report`. - `internal/module` does not import `internal/report`.
- Module IDs are stable strings. - Module IDs are stable strings.
- Each emitted module output has exactly one stanza name and one typed value. - 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. - Snapshot output order is caller-owned and preserved.

View File

@@ -5,13 +5,15 @@ This document describes YAML prompt data package construction in
## Purpose ## Purpose
`internal/promptinput` converts report metadata, an ordered module snapshot, `internal/promptinput` converts report metadata, ordered module outputs, Recent
Recent Changes, and source warnings into the `data_package` file passed to Changes, and source warnings into the `data_package` file passed to
Scriptorium. Scriptorium.
The persisted data package is YAML with schema version The persisted data package is YAML with schema version
`weatherreporter.data_package.v2`. It is separate from the JSON module snapshot `weatherreporter.data_package.v3`. It is separate from the JSON module snapshot
used for inspection and comparison. 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 And Outputs
@@ -32,7 +34,7 @@ Outputs:
The YAML shape includes: The YAML shape includes:
```yaml ```yaml
schema_version: weatherreporter.data_package.v2 schema_version: weatherreporter.data_package.v3
run_id: <run_id> run_id: <run_id>
report: report:
id: <report_id> id: <report_id>
@@ -41,6 +43,7 @@ briefing:
metadata: {} metadata: {}
applicable_risk_products: applicable_risk_products:
alert_digest: {} alert_digest: {}
spc_convective_outlooks: {}
derived_summaries: derived_summaries:
derived_daily_summary: {} derived_daily_summary: {}
derived_daypart_summaries: {} derived_daypart_summaries: {}
@@ -49,6 +52,7 @@ briefing:
narrative_products: narrative_products:
narrative_forecast: {} narrative_forecast: {}
area_forecast_discussion: {} area_forecast_discussion: {}
spc_convective_discussion: {}
weather_story: {} weather_story: {}
raw_data: raw_data:
current_conditions: {} current_conditions: {}
@@ -62,22 +66,71 @@ weather module stanzas under prompt-facing categories. This grouping is a YAML
presentation concern only: module snapshots remain flat, and loaded presentation concern only: module snapshots remain flat, and loaded
`promptinput.Package` values expose flat stanza names in `Briefing.Values`. `promptinput.Package` values expose flat stanza names in `Briefing.Values`.
Within each category, stanza order follows the module snapshot output order. 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: Current categories are:
- `applicable_risk_products`: location-applicable alerts, warnings, outlooks, - `applicable_risk_products`: location-applicable alerts, warnings, outlooks,
discussions, and similar risk products. and similar risk products. Current stanzas include `alert_digest` and
`spc_convective_outlooks`.
- `derived_summaries`: deterministic summaries and calculated report facts. - `derived_summaries`: deterministic summaries and calculated report facts.
- `narrative_products`: official narrative text products and forecast stories. - `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. - `raw_data`: minimally transformed underlying weather data.
## Boundaries ## Boundaries
- This package owns prompt package schema, YAML marshaling, YAML loading, and - This package owns prompt package schema, YAML marshaling, YAML loading, and
validation. validation.
- It does not fetch weather data, derive forecast summaries, execute modules, - It does not collect weather data, derive forecast summaries, execute modules,
find prior snapshots, compare changes, choose artifact paths, or invoke choose module prompt export shapes, find prior snapshots, compare changes,
Scriptorium. choose artifact paths, or invoke Scriptorium.
## Config Fields Used ## Config Fields Used
@@ -117,5 +170,8 @@ Inspect:
- Scriptorium receives structured YAML through `--input data_package=<path>`. - Scriptorium receives structured YAML through `--input data_package=<path>`.
- Module stanza order is deterministic within each prompt-facing category. - Module stanza order is deterministic within each prompt-facing category.
- Every non-metadata module stanza has exactly one prompt-input 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 - Recent Changes are provided by `internal/changes`; this package does not
infer changes from rendered report text. infer changes from rendered report text.

View File

@@ -1,14 +1,15 @@
# Report Registry Internals # Report Registry Internals
This document describes report identity, valid-period resolution, batch This document describes report identity, valid-period resolution, output
membership, output naming, artifact grouping, and comparison declarations in naming, artifact grouping, batch command names, and comparison declarations in
`internal/report`. `internal/report`.
## Purpose ## Purpose
`internal/report` is the canonical source for report definitions. App, state, `internal/report` is the canonical source for report definitions, public
module building, and CLI wiring consume resolved definitions instead of owning command names, config-key aliases, and batch command names. App, config, state,
report identity policy themselves. module building, and CLI wiring consume report-owned helpers and resolved
definitions instead of owning report identity policy themselves.
## Definition Fields ## Definition Fields
@@ -16,36 +17,58 @@ Each report definition declares:
- report ID and display name - report ID and display name
- Scriptorium prompt ID - Scriptorium prompt ID
- generation mode
- valid-period resolver - valid-period resolver
- comparison strategy - comparison strategy
- managed artifact group - managed artifact group
- batch output copy filename - batch output copy filename
- generated-report eligibility - generated-report eligibility
- prior-report compatibility list - prior-report compatibility list
- morning or evening batch membership
- default ordered module composition - default ordered module composition
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`.
`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.
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 ## Reports
| Report | ID | Prompt | Artifact group | Batch copy | Prior compatibility | | Report | ID | Prompt | Generation mode | Artifact group | Batch copy | Prior compatibility |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- |
| Daily Today | `daily_today` | `weather.daily_report` | `daily` | `daily.md` | Daily Today, Daily Tomorrow | | Daily Report | `daily` | `weather.daily_generated_text` | `generated_text_template` | `daily` | `daily.md` | Daily Report |
| Daily Tomorrow | `daily_tomorrow` | `weather.daily_report` | `daily` | `tomorrow.md` | Daily Today, Daily Tomorrow | | Today Report | `today` | `weather.today_generated_text` | `generated_text_template` | `today` | `today.md` | Today Report |
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `three-day` | `three-day.md` | 3-Day Outlook | | Tomorrow Report | `tomorrow` | `weather.tomorrow_generated_text` | `generated_text_template` | `tomorrow` | `tomorrow.md` | Tomorrow Report |
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `weekend` | `weekend.md` | Weekend Outlook | | Hourly Report | `hourly` | `weather.hourly_generated_text` | `generated_text_template` | `hourly` | `hourly.md` | Hourly Report |
| Storm Report | `storm` | `weather.storm_report` | `storm` | `storm.md` | Storm 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. All report definitions are eligible for generation.
## Valid Periods ## 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. 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 - 3-Day Outlook covers the interval from generation time through local midnight
three days later. three days later.
- Weekend Outlook covers the upcoming weekend window and is not scheduled for - Weekend Outlook covers the upcoming weekend window.
Sunday morning batch resolution.
- Storm Report covers an explicit event window supplied by the caller. - 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 Storm event windows can be parsed from local `YYYY-MM-DDTHH:MM` timestamps in
@@ -54,17 +77,32 @@ must be after start time.
## Boundaries ## Boundaries
`internal/report` defines report metadata and time coverage. It does not fetch `internal/report` defines report metadata, public report names, batch command
weather data, build module values, compare snapshot contents, write state, 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. parse CLI flags, or invoke Scriptorium.
The CLI owns public command names. The app maps those command names to report The CLI parses flags and command structure, then uses report-owned helpers for
IDs, then uses the registry for report policy. report and batch command names. Config loading uses report-owned helpers for
report override keys.
## Config Fields Used ## Config Fields Used
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch 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 And App Usage
@@ -92,8 +130,14 @@ Inspect:
## Invariants ## Invariants
- Report selection goes through the registry. - 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. - Valid periods are half-open intervals independent of rendered report text.
- Artifact grouping, batch output filenames, generated-report eligibility, - Artifact grouping, batch output filenames, generated-report eligibility,
default module composition, comparison compatibility, and comparison strategy default module composition, comparison compatibility, and comparison strategy
are declared by report definition. 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,9 +6,9 @@ This document describes the subprocess adapter in
## Purpose ## Purpose
The adapter runs `scriptorium render` for prompt preflight and `scriptorium run` The adapter runs `scriptorium render` for prompt preflight and `scriptorium run`
for Markdown report generation. It isolates subprocess execution, argv for Markdown report generation or structured generated-text output. It isolates
construction, timeout handling, output capture, and exit-code interpretation subprocess execution, argv construction, timeout handling, output capture, and
from app and domain packages. exit-code interpretation from app and domain packages.
## Inputs And Outputs ## Inputs And Outputs
@@ -17,6 +17,7 @@ Inputs:
- prompt ID - prompt ID
- YAML prompt input data package path - YAML prompt input data package path
- report output path for `run` - report output path for `run`
- raw generated-text output path for structured `run`
- configured binary, config path, profile, timeout, and extra arguments - configured binary, config path, profile, timeout, and extra arguments
- context for cancellation - context for cancellation
@@ -27,16 +28,17 @@ Outputs:
- truncation flags for captured output - truncation flags for captured output
- exit code - exit code
- report output path for `run` - report output path for `run`
- raw generated-text output path for structured `run`
## Boundaries ## Boundaries
`internal/adapters/scriptorium` owns Scriptorium command construction and `internal/adapters/scriptorium` owns Scriptorium command construction and
subprocess execution. It does not choose report types, build prompt input, 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. The adapter exposes request and result structs for render, Markdown run, and
State persistence uses a state-owned preflight artifact shape; app structured generated-text run operations. State persistence uses state-owned
orchestration converts render results before saving. artifact shapes; app orchestration converts adapter results before saving.
## Config Fields Used ## Config Fields Used
@@ -60,6 +62,12 @@ Report generation argv starts with:
scriptorium run --prompt <prompt_id> --input data_package=<path> --out <path> 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 Configured `--config` and `--profile` flags are inserted after the subcommand
and before prompt-specific arguments. Extra arguments are appended after the and before prompt-specific arguments. Extra arguments are appended after the
built-in arguments. built-in arguments.
@@ -67,8 +75,8 @@ built-in arguments.
## Execution Behavior ## Execution Behavior
The adapter runs commands without shell interpolation. The same private The adapter runs commands without shell interpolation. The same private
execution path is used by render and run after command-specific request execution path is used by render, Markdown run, and structured run after
validation and argv construction. command-specific request validation and argv construction.
When `scriptorium.timeout` is greater than zero, each subprocess call uses a When `scriptorium.timeout` is greater than zero, each subprocess call uses a
context with that timeout. Stdout and stderr are captured separately, capped at 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. - Missing run output path returns an error before subprocess execution.
- Subprocess start errors, context cancellation, and timeouts are wrapped with - Subprocess start errors, context cancellation, and timeouts are wrapped with
operation context by the caller-facing method. operation context by the caller-facing method.
- Nonzero render and run exits return the captured result plus an error - Nonzero render, Markdown run, and structured run exits return the captured
containing the exit code and stderr. result plus an error containing the exit code and stderr.
## Tests ## Tests
@@ -97,5 +105,6 @@ Inspect:
- No shell interpolation is used. - No shell interpolation is used.
- The Scriptorium input name is `data_package`. - The Scriptorium input name is `data_package`.
- The file at the data package path is YAML produced by `internal/promptinput`. - The file at the data package path is YAML produced by `internal/promptinput`.
- Render and run preserve command-specific result structs. - Render, Markdown run, and structured run preserve command-specific result
structs.
- Scriptorium-specific flags stay inside adapter and config boundaries. - Scriptorium-specific flags stay inside adapter and config boundaries.

View File

@@ -16,6 +16,7 @@ Inputs:
- module snapshot - module snapshot
- prompt input data package - prompt input data package
- preflight artifact - preflight artifact
- generated-text raw, run-result, validated text, and render-context artifacts
- rendered report path preparation request - rendered report path preparation request
- RunID for inspection lookups - RunID for inspection lookups
@@ -24,10 +25,16 @@ Outputs:
- module snapshot JSON path - module snapshot JSON path
- prompt input data package YAML path - prompt input data package YAML path
- render preflight JSON 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 - managed Markdown report path
- metadata JSON path - metadata JSON path
- distributor notification debug artifact paths
- prior comparable snapshot metadata - prior comparable snapshot metadata
- loaded module snapshot or data package - loaded module snapshot, data package, generated text, generated-text run
result, or render context
- recent report records for inspection - recent report records for inspection
## Boundaries ## Boundaries
@@ -55,22 +62,35 @@ Workspace subdirectories must be relative paths that stay under
## Managed Layout ## Managed Layout
Paths are derived from the resolved report definition's artifact group, the Paths are derived from the resolved report definition's artifact group, the
valid-period start date for dated artifacts, and the RunID. valid-period start date, and the RunID. Filenames put the artifact kind before
the RunID.
```text ```text
<workspace.root>/ <workspace.root>/
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.modules.json reports/<artifact_group>/<YYYY-MM-DD>/report.<run_id>.md
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.metadata.json snapshots/<artifact_group>/<YYYY-MM-DD>/modules.<run_id>.json
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.yaml snapshots/<artifact_group>/<YYYY-MM-DD>/metadata.<run_id>.json
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_raw.<run_id>.json
notifications/<artifact_group>/<YYYY-MM-DD>/<run_id>.distributor.json snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_result.<run_id>.json
reports/<artifact_group>/<run_id>.md 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 module snapshots and links the module snapshot, data Metadata is stored beside module snapshots and links the module snapshot, data
package, preflight, report paths, notification path when attempted, and package, preflight, report paths, notification path when attempted, and
configured prompt location. Report listing walks metadata files under the configured prompt location. For generated-text-template reports, metadata also
snapshots directory. 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 ## Prior Lookup
@@ -78,28 +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 selects the latest earlier snapshot whose report ID is compatible with the
current report definition. 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. valid local date.
- 3-Day Outlook compares with prior 3-Day snapshots for the same valid local - 3-Day Outlook compares with prior 3-Day snapshots for the same valid local
date. date.
- Weekend Outlook compares with prior Weekend snapshots for the same weekend - Weekend Outlook compares with prior Weekend snapshots for the same weekend
window. window.
- Hourly Report uses the rolling-window comparison strategy and currently
returns no prior snapshot from filesystem lookup.
- Storm Report has no prior lookup because explicit event-window comparison is - Storm Report has no prior lookup because explicit event-window comparison is
not searched by the filesystem store. not searched by the filesystem store.
## Writes And Inspection ## Writes And Inspection
Durable JSON writes use shared atomic file helpers. Managed Markdown reports are Durable JSON writes use shared atomic file helpers. Generated-text raw and
prepared by creating their parent directory; Scriptorium writes the report body validated JSON artifacts are written atomically as bytes; generated-text run
to the prepared path. Extra Markdown copies are handled by app orchestration. result and render context artifacts are written atomically as JSON. Managed
Distributor notification debug artifacts are written atomically when Markdown reports are prepared by creating their parent directory; Scriptorium
notification is attempted and include rendered distributor pipeline ID, bundle writes the report body to the prepared path. Extra Markdown copies are handled
ID, idempotency key, bundle paths, upload status, latest run status, and by app orchestration. Distributor notification debug artifacts are written
redacted errors. atomically when notification is attempted and include rendered distributor
pipeline ID, bundle ID, idempotency key, bundle paths, upload status, latest
run status, and redacted errors.
Inspection helpers read existing metadata, module snapshot, and data package Single-report notification artifacts use schema version
files. Missing metadata directories return no inspection records or no prior `weatherreporter.distributor_notification.v1` and record one managed source
snapshot rather than creating state. 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 ## Failure Behavior
@@ -121,4 +171,8 @@ Inspect:
- Managed paths stay under the configured workspace root. - Managed paths stay under the configured workspace root.
- Artifact grouping comes from report definitions. - Artifact grouping comes from report definitions.
- Metadata links artifacts produced for a run. - 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. - Prior lookup is based on structured metadata, not rendered report text.

View File

@@ -4,10 +4,10 @@ This document describes Weather API ingestion into `weatherdata.Bundle`.
## Purpose ## Purpose
`internal/adapters/weatherapi` fetches normalized weather data from one `internal/adapters/weatherapi` fetches normalized weather data from the
configured Weather API endpoint and assembles the bundle consumed by forecast configured Weather API and assembles the bundle consumed by forecast derivation
derivation and module builders. Module builders expose normalized current and module builders. Module builders expose normalized current conditions and
conditions and weather story context when those sources are available. weather story context when those sources are available.
## Inputs And Outputs ## Inputs And Outputs
@@ -21,7 +21,8 @@ Outputs:
- `weatherdata.Bundle` with observation, current conditions, hourly forecast, - `weatherdata.Bundle` with observation, current conditions, hourly forecast,
narrative forecast, active alerts, discussion, latest weather story, source narrative forecast, active alerts, discussion, latest weather story, source
records, and source warnings records, source warnings, and typed SPC convective outlook data when that
optional source is available
- optional saved bundle JSON through app fetch helpers - optional saved bundle JSON through app fetch helpers
## Boundaries ## Boundaries
@@ -54,9 +55,19 @@ contract used by this project.
The adapter records source name, endpoint, query, fetch time, source timestamps The adapter records source name, endpoint, query, fetch time, source timestamps
when available, SHA-256 hash over compact raw `data` JSON, missing status, and when available, SHA-256 hash over compact raw `data` JSON, missing status, and
source warnings. Successful `data: null` responses from `/alerts/active` 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. `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 ## Skip And Resume Behavior
No resume behavior. Optional missing or malformed sources may be omitted, No resume behavior. Optional missing or malformed sources may be omitted,
@@ -72,6 +83,8 @@ data is required and cannot be skipped.
- Optional sources follow missing-source policy. - Optional sources follow missing-source policy.
- Explicit `data: null` from `/alerts/active` produces an empty, non-missing - Explicit `data: null` from `/alerts/active` produces an empty, non-missing
alert run. alert run.
- Explicit `data: null` from `/outlooks/convective` follows optional
missing-source policy.
## Tests ## Tests

View File

@@ -10,20 +10,36 @@ Generation commands:
```text ```text
weatherreporter generate daily --date 2026-05-29 weatherreporter generate daily --date 2026-05-29
weatherreporter generate today
weatherreporter generate tomorrow weatherreporter generate tomorrow
weatherreporter generate hourly
weatherreporter generate three-day weatherreporter generate three-day
weatherreporter generate weekend weatherreporter generate weekend
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 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 Generation commands resolve a report period, collect a Weather API bundle,
JSON module snapshot, builds a YAML prompt input data package, runs build a rich JSON module snapshot, build a curated YAML prompt input data
`scriptorium render`, runs `scriptorium run`, and writes managed artifacts under package, run `scriptorium render`, and write managed artifacts under the
the configured workspace. configured workspace. Markdown-path reports then run `scriptorium run` directly
to the managed Markdown report path.
`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 When distributor notification is enabled, weatherreporter uploads the managed
Markdown report after `scriptorium run` succeeds and final metadata is saved. Markdown report after report rendering succeeds and final metadata is saved.
`--out PATH` writes an extra Markdown copy for the current generated report; it `--out PATH` writes an extra Markdown copy for generated reports; it is not used
is not used as the distributor upload source. 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: Batch commands:
@@ -32,16 +48,26 @@ weatherreporter run morning
weatherreporter run evening weatherreporter run evening
``` ```
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook `run morning` generates Today Report, Tomorrow Report, and a dated Daily Report
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch for each later future local civil day with complete hourly forecast coverage.
commands print a JSON summary to stdout, write compact per-report status lines `run evening` generates Tomorrow Report and the same eligible future Daily
to stderr, continue independent reports after one report fails, and return reports. Future Daily expansion starts with the day after tomorrow. A Daily
nonzero when any report failed. When notification is configured, the summary and report is eligible only when the collected hourly forecast contains every
status lines include notification status, accepted distributor run ID, or hourly period for that local civil day; partial days are skipped. Batch commands
notification error fields for each attempted report. `--out-dir PATH` writes collect weather data once before planning, and a collection failure stops the
extra Markdown copies using report default filenames such as `daily.md`, batch before any report is generated.
`three-day.md`, `weekend.md`, and `tomorrow.md`; these copies are not used as
distributor upload sources. 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 ## Filesystem Layout
@@ -49,82 +75,45 @@ The default workspace root is `workspace`.
```text ```text
workspace/ workspace/
snapshots/ reports/<artifact_group>/<YYYY-MM-DD>/report.<run_id>.md
daily/
YYYY-MM-DD/ snapshots/<artifact_group>/<YYYY-MM-DD>/modules.<run_id>.json
<run_id>.modules.json snapshots/<artifact_group>/<YYYY-MM-DD>/metadata.<run_id>.json
<run_id>.metadata.json snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_raw.<run_id>.json
three-day/ snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_result.<run_id>.json
YYYY-MM-DD/ snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text.<run_id>.json
<run_id>.modules.json snapshots/<artifact_group>/<YYYY-MM-DD>/render_context.<run_id>.json
<run_id>.metadata.json
weekend/ data-packages/<artifact_group>/<YYYY-MM-DD>/data_package.<run_id>.yaml
YYYY-MM-DD/ preflight/<artifact_group>/<YYYY-MM-DD>/render.<run_id>.json
<run_id>.modules.json
<run_id>.metadata.json notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json
storm/ notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
data-packages/
daily/
YYYY-MM-DD/
<run_id>.data_package.yaml
three-day/
YYYY-MM-DD/
<run_id>.data_package.yaml
weekend/
YYYY-MM-DD/
<run_id>.data_package.yaml
storm/
YYYY-MM-DD/
<run_id>.data_package.yaml
preflight/
daily/
YYYY-MM-DD/
<run_id>.render.json
three-day/
YYYY-MM-DD/
<run_id>.render.json
weekend/
YYYY-MM-DD/
<run_id>.render.json
storm/
YYYY-MM-DD/
<run_id>.render.json
notifications/
daily/
YYYY-MM-DD/
<run_id>.distributor.json
three-day/
YYYY-MM-DD/
<run_id>.distributor.json
weekend/
YYYY-MM-DD/
<run_id>.distributor.json
storm/
YYYY-MM-DD/
<run_id>.distributor.json
reports/
daily/
<run_id>.md
three-day/
<run_id>.md
weekend/
<run_id>.md
storm/
<run_id>.md
``` ```
Managed artifact filenames use the RunID, so repeated runs for the same valid Managed artifact filenames use the artifact kind and RunID, so repeated runs
period do not overwrite each other. 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 ## 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 ```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: Each generated report writes metadata that links:
@@ -136,21 +125,36 @@ Each generated report writes metadata that links:
- prompt input data package path - prompt input data package path
- preflight output path - preflight output path
- managed Markdown report 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 - distributor notification debug artifact path, when notification is attempted
Batch summaries include report status, error text when applicable, notification Batch summaries include report status, error text when applicable, valid
outcome when attempted, valid period, and known artifact paths for each period, and known artifact paths for each attempted report. Single-report
attempted report. Notification fields are `notificationStatus`, notification fields on report items are empty for batch commands. When a batch
`notificationRunId`, and `notificationError`. 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
Distributor notification is configured with `notify.distributor` and is Distributor notification is configured with `notify.distributor` and is
disabled by default. When enabled, weatherreporter uploads the managed Markdown disabled by default. For `generate <report>`, weatherreporter uploads the
report path recorded in the report result and metadata. That single source file managed Markdown report path recorded in the report result and metadata. That
can be mapped to one or more configured bundle paths. By default, it is mapped single source file is mapped to report-specific bundle paths. Extra copies
to one dated report path. Extra copies written by `--out` or `--out-dir` are written by `--out` or `--out-dir` are operator conveniences only.
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` The rendered pipeline ID selects the configured distributor `http_upload`
workflow. The default bundle ID is a stable logical source identity derived from workflow. The default bundle ID is a stable logical source identity derived from
@@ -160,26 +164,40 @@ producer name, location ID, and report ID:
weatherreporter.{location_id}.{report_id} weatherreporter.{location_id}.{report_id}
``` ```
The default idempotency key appends RunID to the rendered bundle ID so each The default single-report idempotency key appends RunID to the rendered bundle
report generation has a distinct retry identity. The default bundle path uses ID so each report generation has a distinct retry identity. The default bundle
the valid-period start date, artifact group, and RunID. Distributor owns path uses the valid-period start date, artifact group, and RunID. Batch bundle
destination merge, retention, and derived snapshot behavior such as `latest`. 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`.
Notification happens after final metadata save. Weather API, module snapshot, Single-report notification happens after final metadata save for generated
data-package, render preflight, Scriptorium run, and metadata-save failures do reports. Batch notification happens after all planned reports have finished and
not trigger notification. A notification failure fails that report. only when all report generations succeeded. Collection, module snapshot,
In a batch, other reports continue, the failed report includes notification data-package, render preflight, Scriptorium run, generated-text validation,
fields in the JSON summary, and the batch returns nonzero. 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/`. The Each notification attempt writes a debug artifact under `notifications/`.
artifact records the rendered pipeline ID, bundle ID, idempotency key, managed Single-report artifacts live under
source path, bundle-relative paths, bundle created timestamp, accepted upload `notifications/<artifact_group>/<YYYY-MM-DD>/distributor.<run_id>.json`. Batch
response, and the latest distributor run status response when available. artifacts live under
Weatherreporter polls status until distributor reports `succeeded` or `failed`, `notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json`,
or until the configured notification timeout expires. The run status includes where the date directory is the batch start date in the effective report
the distributor status, error text, and raw run report JSON, which can show timezone. The artifact records the rendered pipeline ID, bundle ID,
actions such as `replace_older`, `skip_same`, `skip_destination_newer`, or idempotency key, managed source paths, bundle-relative paths, bundle created
`failed`. Token values are not written. 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, Weatherreporter is responsible for selecting the managed Markdown report,
constructing a source bundle, and submitting it to the configured distributor constructing a source bundle, and submitting it to the configured distributor
@@ -191,7 +209,7 @@ bundle, so existing uploaded dated report paths can remain available.
## Inspection ## Inspection
Inspection commands read existing workspace artifacts and emit JSON to stdout. 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 ```text
weatherreporter inspect reports --limit 10 weatherreporter inspect reports --limit 10
@@ -204,22 +222,25 @@ weatherreporter inspect sources RUN_ID
Use `inspect reports` to find 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 metadata` to see the artifact links recorded for a run. Use
`inspect modules` to review the persisted ordered module snapshot, and `inspect modules` to review the persisted ordered module snapshot with rich
`inspect data-package` to review the structured prompt package used for template-facing values, and `inspect data-package` to review the curated prompt
rendering. Use `inspect prior` to see the prior comparable snapshot selected for package passed to Scriptorium. Use `inspect prior` to see the prior comparable
Recent Changes, or `null` when none exists. Use `inspect sources` to review snapshot selected for Recent Changes, or `null` when none exists. Use
source provenance and warnings without dumping full weather payloads. `inspect sources` to review source provenance and warnings without dumping full
weather payloads.
## Recent Changes ## Recent Changes
Recent Changes are computed from structured module snapshots, not rendered Recent Changes are computed from structured module snapshots, not rendered
Markdown or YAML text. Markdown or YAML text.
Daily Today and Daily Tomorrow can compare with each other when they cover the Daily Report compares with prior Daily Report snapshots for the same valid
same valid local date. 3-Day Outlook compares with prior compatible 3-Day local date. Today Report compares with prior Today Report snapshots for the
snapshots for the same valid local date. Weekend Outlook compares with prior same valid local date. Tomorrow Report compares with prior Tomorrow Report
compatible Weekend snapshots for the same weekend window. Storm Report leaves snapshots for the same valid local date. 3-Day Outlook compares with prior
Recent Changes empty. 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, When no prior comparable snapshot exists, or no configured threshold is crossed,
`recentChanges.items` is empty. `recentChanges.items` is empty.
@@ -232,10 +253,17 @@ A failed generation run may still leave useful artifacts:
preflight JSON and metadata are written for inspection. preflight JSON and metadata are written for inspection.
- If `scriptorium run` exits nonzero after writing a report, the managed report - If `scriptorium run` exits nonzero after writing a report, the managed report
and metadata remain available. and metadata remain available.
- If distributor notification fails, report artifacts and final metadata remain - Generated-text failures for Daily, Today, Tomorrow, and Hourly reports preserve
available, but the report or batch command returns nonzero. 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 - 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: For a bad report, start with:

View File

@@ -121,8 +121,12 @@ The CLI is owned by `internal/cli`.
When adding or changing a command or flag: When adding or changing a command or flag:
- update help text and parser behavior together; - 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; - convert parsed values into app-layer request structs;
- keep domain decisions in `internal/app` or domain packages; - 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`; - add parser or command tests in `internal/cli`;
- update `docs/cli.md`; - update `docs/cli.md`;
- update `docs/operations.md` or `docs/troubleshooting.md` when behavior affects - update `docs/operations.md` or `docs/troubleshooting.md` when behavior affects

View File

@@ -5,18 +5,22 @@ documented outside `docs/roadmap/`.
## Automatic Storm Monitoring ## Automatic Storm Monitoring
Manual Storm Report generation is implemented through Manual Storm Report generation is available through:
`weatherreporter generate storm --start TIME --end TIME`. Automatic storm-event
evaluation remains deferred.
Proposed direction: ```sh
weatherreporter generate storm --start TIME --end TIME
```
1. detect candidate events deterministically from alerts, forecast discussion, Automatic storm-event evaluation is not implemented.
weather story context, hourly thresholds, and material forecast changes;
2. evaluate candidates through Scriptorium or another narrow evaluator adapter; Possible direction:
3. persist storm lifecycle state;
4. generate or update Storm Reports only when a meaningful event is present; 1. Detect candidate storm events from alerts, forecast discussion, weather
5. suppress ordinary low-impact thunder or rain chances. 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: Possible lifecycle states:
@@ -27,40 +31,41 @@ Possible lifecycle states:
- `deescalating` - `deescalating`
- `resolved` - `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; ## Future Report Types
- candidate detection has fixture coverage;
- evaluator failures are inspectable and do not create noisy report output;
- manual Storm Report generation remains available.
## Future Report Types And Modules
The module-based prompt package architecture is implemented. Future work should
add only modules backed by implemented upstream facts and clear report needs.
Possible future report types: Possible future report types:
- `next_6_hours` or another short-fuse planning report; - a short-fuse planning report distinct from the implemented Hourly Report, if
- event-specific reports with stable event IDs; a separate product is needed
- storm review or yesterday-style reports using historical observations; - 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 - archive-focused report variants if generated report history becomes a
first-class product. first-class product
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: Possible future modules:
- `hourly_table` for compact valid-period hourly facts; - `hourly_table` for compact valid-period hourly facts
- `forecast_delta` if a separate stanza is useful beyond current Recent - `forecast_delta` if a separate stanza is useful beyond current Recent
Changes; Changes
- `weekend_planning` if weekend-specific planning guidance needs a dedicated - `weekend_planning` if weekend-specific planning guidance needs a dedicated
deterministic stanza; deterministic stanza
- `storm_window_summary` if manual or automatic Storm Reports need a dedicated - `storm_window_summary` if manual or automatic Storm Reports need a dedicated
prompt-facing storm-window module; prompt-facing storm-window module
- separate AFD section aliases, such as `afd_key_messages`, - separate AFD section aliases, such as `afd_key_messages`,
`afd_short_term_text`, and `afd_long_term_text`, if separate stanzas prove `afd_short_term_text`, and `afd_long_term_text`, if separate stanzas prove
more useful than `area_forecast_discussion.options.sections`; more useful than `area_forecast_discussion.options.sections`
- SPC, radar, QPF, snow/rain total, or historical-observation modules once - SPC, radar, QPF, snow/rain total, or historical-observation modules once
upstream sources and report requirements exist. upstream sources and report requirements exist
QPF fields such as `measurable_qpf_total_in` and `max_hourly_qpf_in` should QPF fields such as `measurable_qpf_total_in` and `max_hourly_qpf_in` should
remain omitted until a real upstream quantitative precipitation source is remain omitted until a real upstream quantitative precipitation source is
@@ -68,80 +73,61 @@ represented in `CollectedFacts`.
Future module work should preserve these boundaries: Future module work should preserve these boundaries:
- collect upstream facts once per report run; - keep upstream collection in app orchestration
- keep upstream fetching out of modules; - keep upstream collection out of modules
- keep broad reusable calculations in `DerivedFacts`; - keep broad reusable calculations in `DerivedFacts`
- keep prompt-facing field shape inside module builders; - keep prompt-facing field shape inside module builders
- use typed options for configurable module behavior; - use typed options for configurable module behavior
- keep module snapshots structured and deterministic for Recent Changes. - keep module snapshots structured and deterministic for Recent Changes
## Distributor Notification Enhancements ## Distributor Notification Enhancements
Distributor notification currently uploads one managed Markdown report per Distributor notification uploads one managed Markdown report per successful
successful generated report through the configured HTTP upload pipeline. generated report through the configured HTTP upload pipeline. The following
enhancements are not implemented:
These enhancements are not current behavior: - `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
- `failure_policy: warn`; Any distributor enhancement should preserve the adapter boundary:
- uploading metadata, module snapshots, data packages, or preflight artifacts;
- polling distributor status after upload acceptance;
- durable upload retry queues;
- distributor-specific CLI flags;
- making distributor scan the weatherreporter workspace;
- handling destination routing, Markdown-to-HTML transformation, public URLs,
or nginx layout inside weatherreporter.
Any distributor enhancement should preserve the existing adapter boundary:
weatherreporter selects explicit generated files and submits source bundles, weatherreporter selects explicit generated files and submits source bundles,
while distributor owns destination routing and publication behavior. while distributor owns destination routing and publication behavior.
## Alternate Runtime Integrations ## Alternate Runtime Integrations
These ideas are not current behavior: These ideas are not implemented:
- native LLM client inside `weatherreporter`; - native LLM client inside `weatherreporter`
- database-backed state; - database-backed state
- public HTTP API; - public HTTP API
- multi-location selection; - multi-location selection
- daemon mode; - daemon mode
- multi-user authorization; - multi-user authorization
- plugin system; - plugin system
- dynamic module loading; - dynamic module loading
- user-defined module code; - user-defined module code
- YAML-defined module schemas; - YAML-defined module schemas
- module-owned Weather API fetching. - module-owned Weather API fetching
Each item needs its own design note before implementation. Non-roadmap docs Each item needs its own design note before implementation. Non-roadmap docs
must not describe these as available behavior. must not describe these as available behavior.
## Cleanup Refactors ## Deferred Refactors
The initial cleanup pass intentionally left these refactors out because the These refactors should remain deferred until new requirements or recurring
current implementation does not yet make them worth the added abstraction. maintenance costs make the added abstraction worthwhile:
Revisit these only when new source types, report types, operational - broad briefing weather-signal consolidation
requirements, or recurring maintenance costs make the duplication materially - generic workflow engine
more expensive: - Cobra migration
- manifest, resume, or progress system
- Weather API optional-source specification/helper refactor: consider when - global test helper package
additional Weather API sources make per-source fan-out, policy handling, and - logging subsystem
provenance wiring repetitive enough to obscure adapter behavior.
- Broad briefing weather-signal consolidation: consider when multiple module
builders repeatedly derive the same weather signals and tests begin to need
coordinated fixture updates.
- Generic workflow engine: defer unless generation, inspection, recovery, or
future background workflows gain enough shared step semantics to justify a
declared execution model.
- Cobra migration: defer while the standard-library CLI remains small,
explicit, and covered by parser tests.
- Manifest, resume, or progress system: defer until operators need resumable
runs, checkpoint recovery, or richer audit trails than the current durable
artifacts and metadata provide.
- Global test helper package: defer while package-local helpers keep tests
clear; revisit only if setup duplication starts to hide behavior.
- Logging subsystem: defer until there are recurring operator diagnostics that
cannot be handled with current errors, metadata, inspection commands, and
artifact output.
Any future implementation should preserve the existing public CLI, artifact Any future implementation should preserve the existing public CLI, artifact
paths, report identities, module boundaries, and adapter boundaries unless a paths, report identities, module boundaries, and adapter boundaries unless a

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` ## `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. Likely cause: no Weather API base URL is configured.
@@ -185,20 +185,158 @@ prompt, profile, model configuration, or validation issue.
Relevant docs: [Operations guide](operations.md), Relevant docs: [Operations guide](operations.md),
[Scriptorium integration](integrations/scriptorium.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 ## Batch Command Returns Nonzero
Symptom: `run morning` or `run evening` 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 Diagnostic: if stdout contains a JSON summary, inspect each failed report item
lines. 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 Safe fix: for collection failures, fix the configuration or upstream Weather
metadata, sources, module snapshot, and data package for that RunID. 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). 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 ## Invalid Secrets Directory
Symptom: config loading fails with `read secrets directory`, `secret file`, or Symptom: config loading fails with `read secrets directory`, `secret file`, or
@@ -245,12 +383,14 @@ within the same distributor token and pipeline. By default the bundle ID is a
stable report-stream identity and the idempotency key appends RunID. stable report-stream identity and the idempotency key appends RunID.
Diagnostic: inspect the failed batch JSON or stderr line for pipeline, bundle, Diagnostic: inspect the failed batch JSON or stderr line for pipeline, bundle,
and idempotency context. Compare the configured templates with the report RunID and idempotency context. For batch commands, use the top-level notification
and report path. 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. It records the Also inspect the notification artifact linked from metadata or from the
rendered pipeline ID, bundle ID, idempotency key, upload result, distributor run top-level batch notification path. It records the rendered pipeline ID, bundle
status, status error, and raw run report JSON when available. 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 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 report, but do not reuse the same rendered key for different generated report
@@ -268,9 +408,10 @@ Likely cause: the distributor endpoint rejected the token, pipeline ID, bundle
ID, idempotency key, source file, or one of the rendered bundle paths. ID, idempotency key, source file, or one of the rendered bundle paths.
Diagnostic: inspect stdout JSON or stderr status lines for Diagnostic: inspect stdout JSON or stderr status lines for
`notificationError`. Confirm `notify.distributor.endpoint`, `notificationError` or the top-level batch notification `error`. Confirm
`notify.distributor.endpoint`,
`notify.distributor.pipeline_id_template`, `notify.distributor.pipeline_id_template`,
`notify.distributor.report_path_templates`, and token configuration. Token report-specific distributor paths, and token configuration. Token
values are redacted from weatherreporter errors. values are redacted from weatherreporter errors.
If the upload was accepted but destination output did not change, inspect the If the upload was accepted but destination output did not change, inspect the
@@ -295,8 +436,8 @@ Likely cause: the configured distributor endpoint is unreachable, slow, or
temporarily unavailable. temporarily unavailable.
Diagnostic: check network access from the weatherreporter host to Diagnostic: check network access from the weatherreporter host to
`notify.distributor.endpoint`. For batch runs, inspect which reports have `notify.distributor.endpoint`. For batch runs, inspect the top-level
`notificationStatus: "failed"`. notification object and the artifact linked by `notification.path`.
Safe fix: restore distributor service availability and rerun the affected Safe fix: restore distributor service availability and rerun the affected
report or batch. Stable idempotency keys make retrying the same generated report report or batch. Stable idempotency keys make retrying the same generated report

View File

@@ -1,5 +1,5 @@
weather_api: weather_api:
base_url: https://weather.api.rakestrawhome.com/ base_url: https://weather.api.example.com/
timeout: 15s timeout: 15s
precision: 1 precision: 1
units: us units: us
@@ -21,11 +21,14 @@ notify:
token_env: DISTRIBUTOR_UPLOAD_TOKEN token_env: DISTRIBUTOR_UPLOAD_TOKEN
timeout: 30s timeout: 30s
failure_policy: error failure_policy: error
pipeline_id_template: "weatherreporter.{artifact_group}" pipeline_id_template: "weatherreporter.{report_id}"
bundle_id_template: "weatherreporter.{location_id}.{report_id}" bundle_id_template: "weatherreporter.{location_id}.{report_id}"
idempotency_key_template: "{bundle_id}.{run_id}" idempotency_key_template: "{bundle_id}.{run_id}"
report_path_templates: batch:
- "{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md" enabled: true
pipeline_id_template: "weatherreporter"
bundle_id_template: "weatherreporter.{location_id}.{batch}"
idempotency_key_template: "{bundle_id}.{batch_run_id}"
missing_source: missing_source:
default: warn default: warn
@@ -69,6 +72,10 @@ recent_change:
reports: reports:
daily: daily:
distributor:
path_templates:
- "daily/{valid_start_date}/{run_id}.md"
- "daily/{valid_start_date}/index.md"
deterministic_modules: deterministic_modules:
- metadata - metadata
- current_conditions - current_conditions
@@ -77,6 +84,26 @@ reports:
- derived_daypart_summaries - derived_daypart_summaries
- precip_timing - precip_timing
- alert_digest - 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 - id: area_forecast_discussion
options: options:
sections: sections:
@@ -84,6 +111,23 @@ reports:
- key_messages - key_messages
- short_term - short_term
- long_term - long_term
- spc_convective_discussion
- weather_story - weather_story
- outdoor_windows - outdoor_windows
- hourly_forecast - 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

View File

@@ -79,6 +79,12 @@ type RunRequest struct {
OutputPath string OutputPath string
} }
type StructuredRunRequest struct {
PromptID string
DataPackagePath string
OutputPath string
}
type RenderResult struct { type RenderResult struct {
Command []string `json:"command"` Command []string `json:"command"`
Stdout string `json:"stdout"` Stdout string `json:"stdout"`
@@ -98,6 +104,16 @@ type RunResult struct {
OutputPath string `json:"outputPath"` 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) { func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, error) {
if req.PromptID == "" { if req.PromptID == "" {
return nil, fmt.Errorf("prompt id is required") 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) { 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 == "" { if req.PromptID == "" {
return nil, fmt.Errorf("prompt id is required") 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 == "" { if req.OutputPath == "" {
return nil, fmt.Errorf("output path is required") 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 { 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(), Command: execution.argv(),
Stdout: string(execution.result.Stdout), Stdout: string(execution.result.Stdout),
Stderr: string(execution.result.Stderr), Stderr: string(execution.result.Stderr),
@@ -147,17 +243,11 @@ func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
OutputPath: req.OutputPath, OutputPath: req.OutputPath,
} }
if execution.result.ExitCode != 0 { 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 return result, nil
} }
type execution struct {
binary string
args []string
result CommandResult
}
func (r Runner) execute(ctx context.Context, args []string) (execution, error) { func (r Runner) execute(ctx context.Context, args []string) (execution, error) {
binary := r.Binary binary := r.Binary
if binary == "" { if binary == "" {

View File

@@ -2,6 +2,7 @@ package scriptorium
import ( import (
"context" "context"
"fmt"
"reflect" "reflect"
"strings" "strings"
"testing" "testing"
@@ -19,7 +20,7 @@ func TestRenderConstructsCommand(t *testing.T) {
} }
result, err := runner.Render(context.Background(), RenderRequest{ result, err := runner.Render(context.Background(), RenderRequest{
PromptID: "weather.daily_report", PromptID: "weather.markdown_report",
DataPackagePath: "/tmp/data_package.yaml", DataPackagePath: "/tmp/data_package.yaml",
}) })
if err != nil { if err != nil {
@@ -30,7 +31,7 @@ func TestRenderConstructsCommand(t *testing.T) {
"render", "render",
"--config", "/etc/scriptorium.yml", "--config", "/etc/scriptorium.yml",
"--profile", "weather", "--profile", "weather",
"--prompt", "weather.daily_report", "--prompt", "weather.markdown_report",
"--input", "data_package=/tmp/data_package.yaml", "--input", "data_package=/tmp/data_package.yaml",
"--format", "json", "--format", "json",
} }
@@ -56,7 +57,7 @@ func TestRenderReturnsResultForNonzeroExit(t *testing.T) {
} }
result, err := runner.Render(context.Background(), RenderRequest{ result, err := runner.Render(context.Background(), RenderRequest{
PromptID: "weather.daily_report", PromptID: "weather.markdown_report",
DataPackagePath: "/tmp/data_package.yaml", DataPackagePath: "/tmp/data_package.yaml",
}) })
if err == nil { if err == nil {
@@ -84,7 +85,7 @@ func TestRunConstructsCommand(t *testing.T) {
} }
result, err := runner.Run(context.Background(), RunRequest{ result, err := runner.Run(context.Background(), RunRequest{
PromptID: "weather.daily_report", PromptID: "weather.markdown_report",
DataPackagePath: "/tmp/data_package.yaml", DataPackagePath: "/tmp/data_package.yaml",
OutputPath: "/tmp/daily.md", OutputPath: "/tmp/daily.md",
}) })
@@ -96,7 +97,7 @@ func TestRunConstructsCommand(t *testing.T) {
"run", "run",
"--config", "/etc/scriptorium.yml", "--config", "/etc/scriptorium.yml",
"--profile", "weather", "--profile", "weather",
"--prompt", "weather.daily_report", "--prompt", "weather.markdown_report",
"--input", "data_package=/tmp/data_package.yaml", "--input", "data_package=/tmp/data_package.yaml",
"--out", "/tmp/daily.md", "--out", "/tmp/daily.md",
} }
@@ -129,7 +130,7 @@ func TestRunReturnsResultForValidationExit(t *testing.T) {
} }
result, err := runner.Run(context.Background(), RunRequest{ result, err := runner.Run(context.Background(), RunRequest{
PromptID: "weather.daily_report", PromptID: "weather.markdown_report",
DataPackagePath: "/tmp/data_package.yaml", DataPackagePath: "/tmp/data_package.yaml",
OutputPath: "/tmp/daily.md", OutputPath: "/tmp/daily.md",
}) })
@@ -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 { type fakeCommands struct {
name string name string
args []string args []string
timeout time.Duration timeout time.Duration
result CommandResult result CommandResult
err error err error
calls int
} }
func (f *fakeCommands) Run(_ context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) { func (f *fakeCommands) Run(_ context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) {
f.calls++
f.name = name f.name = name
f.args = append([]string{}, args...) f.args = append([]string{}, args...)
f.timeout = timeout f.timeout = timeout
return f.result, f.err 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

@@ -21,6 +21,11 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
const (
convectiveOutlooksEndpoint = "/outlooks/convective"
sourceSPCConvectiveOutlooks = "spc_convective_outlooks"
)
type Client struct { type Client struct {
baseURL *url.URL baseURL *url.URL
httpClient *http.Client httpClient *http.Client
@@ -112,6 +117,9 @@ func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
if err := builder.fetchWeatherStory(ctx); err != nil { if err := builder.fetchWeatherStory(ctx); err != nil {
return nil, err return nil, err
} }
if err := builder.fetchSPCConvectiveOutlooks(ctx); err != nil {
return nil, err
}
return builder.bundle, nil return builder.bundle, nil
} }
@@ -122,18 +130,32 @@ type bundleBuilder struct {
fetchedAt time.Time fetchedAt time.Time
} }
type sourceRequest struct {
name string
endpoint string
query queryOptions
missingMessage string
required bool
decodeLabel string
}
type fetchedSource struct {
raw json.RawMessage
source weatherdata.Source
}
func (b *bundleBuilder) fetchObservation(ctx context.Context) error { func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "observations", "/observations", queryOptions{precision: true}) var observation weatherdata.Observation
if err != nil { fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "observations",
endpoint: "/observations",
query: queryOptions{precision: true},
missingMessage: "observation data is missing",
}, &observation)
if err != nil || !ok {
return err return err
} }
if raw == nil { source := fetched.source
return b.handleMissing(&source, "observation data is missing", false)
}
var observation weatherdata.Observation
if err := decodeSource(raw, &observation); err != nil {
return b.handleMalformed(&source, err, false)
}
source.IssuedAt = &observation.Timestamp source.IssuedAt = &observation.Timestamp
b.bundle.Observation = &observation b.bundle.Observation = &observation
b.addSource(source) b.addSource(source)
@@ -141,34 +163,36 @@ func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
} }
func (b *bundleBuilder) fetchCurrent(ctx context.Context) error { func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "current", "/conditions/current", queryOptions{precision: true}) var current weatherdata.Current
if err != nil { fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "current",
endpoint: "/conditions/current",
query: queryOptions{precision: true},
missingMessage: "current conditions data is missing",
}, &current)
if err != nil || !ok {
return err return err
} }
if raw == nil { source := fetched.source
return b.handleMissing(&source, "current conditions data is missing", false)
}
var current weatherdata.Current
if err := decodeSource(raw, &current); err != nil {
return b.handleMalformed(&source, err, false)
}
b.bundle.Current = &current b.bundle.Current = &current
b.addSource(source) b.addSource(source)
return nil return nil
} }
func (b *bundleBuilder) fetchHourly(ctx context.Context) error { func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "hourly", "/forecast/hourly", queryOptions{precision: true, timezone: true}) var hourly weatherdata.ForecastRun
if err != nil { fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "hourly",
endpoint: "/forecast/hourly",
query: queryOptions{precision: true, timezone: true},
missingMessage: "hourly forecast data is missing",
required: true,
decodeLabel: "hourly forecast",
}, &hourly)
if err != nil || !ok {
return err return err
} }
if raw == nil { source := fetched.source
return b.handleMissing(&source, "hourly forecast data is missing", true)
}
var hourly weatherdata.ForecastRun
if err := decodeSource(raw, &hourly); err != nil {
return fmt.Errorf("decode hourly forecast from %s: %w", source.Endpoint, err)
}
if len(hourly.Periods) == 0 { if len(hourly.Periods) == 0 {
return fmt.Errorf("hourly forecast from %s contains no periods", source.Endpoint) return fmt.Errorf("hourly forecast from %s contains no periods", source.Endpoint)
} }
@@ -180,17 +204,17 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
} }
func (b *bundleBuilder) fetchNarrative(ctx context.Context) error { func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "narrative", "/forecast/narrative", queryOptions{precision: true, timezone: true}) var narrative weatherdata.ForecastRun
if err != nil { fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "narrative",
endpoint: "/forecast/narrative",
query: queryOptions{precision: true, timezone: true},
missingMessage: "narrative forecast data is missing",
}, &narrative)
if err != nil || !ok {
return err return err
} }
if raw == nil { source := fetched.source
return b.handleMissing(&source, "narrative forecast data is missing", false)
}
var narrative weatherdata.ForecastRun
if err := decodeSource(raw, &narrative); err != nil {
return b.handleMalformed(&source, err, false)
}
source.IssuedAt = &narrative.IssuedAt source.IssuedAt = &narrative.IssuedAt
source.UpdatedAt = narrative.UpdatedAt source.UpdatedAt = narrative.UpdatedAt
b.bundle.Narrative = &narrative b.bundle.Narrative = &narrative
@@ -213,7 +237,7 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
} }
var alerts weatherdata.AlertRun var alerts weatherdata.AlertRun
if err := decodeSource(raw, &alerts); err != nil { if err := decodeSource(raw, &alerts); err != nil {
return b.handleMalformed(&source, err, false) return b.handleMalformed(&source, err, sourceRequest{name: "alerts"})
} }
alerts.Raw = append(json.RawMessage(nil), raw...) alerts.Raw = append(json.RawMessage(nil), raw...)
if alerts.AsOf != nil { if alerts.AsOf != nil {
@@ -225,17 +249,17 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
} }
func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error { func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "discussion", "/discussion", queryOptions{timezone: true}) var discussion weatherdata.Discussion
if err != nil { fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "discussion",
endpoint: "/discussion",
query: queryOptions{timezone: true},
missingMessage: "forecast discussion data is missing",
}, &discussion)
if err != nil || !ok {
return err return err
} }
if raw == nil { source := fetched.source
return b.handleMissing(&source, "forecast discussion data is missing", false)
}
var discussion weatherdata.Discussion
if err := decodeSource(raw, &discussion); err != nil {
return b.handleMalformed(&source, err, false)
}
source.IssuedAt = &discussion.IssuedAt source.IssuedAt = &discussion.IssuedAt
source.UpdatedAt = discussion.UpdatedAt source.UpdatedAt = discussion.UpdatedAt
b.bundle.Discussion = &discussion b.bundle.Discussion = &discussion
@@ -244,17 +268,17 @@ func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
} }
func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error { func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "weather_story", "/weatherstories/latest", queryOptions{omitUnits: true}) var story weatherdata.WeatherStory
if err != nil { fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "weather_story",
endpoint: "/weatherstories/latest",
query: queryOptions{omitUnits: true},
missingMessage: "NWS weather story data is missing",
}, &story)
if err != nil || !ok {
return err return err
} }
if raw == nil { source := fetched.source
return b.handleMissing(&source, "NWS weather story data is missing", false)
}
var story weatherdata.WeatherStory
if err := decodeSource(raw, &story); err != nil {
return b.handleMalformed(&source, err, false)
}
if !story.StartTime.IsZero() { if !story.StartTime.IsZero() {
source.IssuedAt = &story.StartTime source.IssuedAt = &story.StartTime
} }
@@ -264,6 +288,51 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
return nil return nil
} }
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 { func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error {
source.Missing = true source.Missing = true
if required { if required {
@@ -272,9 +341,13 @@ func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string
return b.applyMissingPolicy(source, "missing_source", message) return b.applyMissingPolicy(source, "missing_source", message)
} }
func (b *bundleBuilder) handleMalformed(source *weatherdata.Source, err error, required bool) error { func (b *bundleBuilder) handleMalformed(source *weatherdata.Source, err error, request sourceRequest) error {
if required { if request.required {
return fmt.Errorf("decode %s from %s: %w", source.Name, source.Endpoint, err) label := request.name
if request.decodeLabel != "" {
label = request.decodeLabel
}
return fmt.Errorf("decode %s from %s: %w", label, source.Endpoint, err)
} }
source.Missing = true source.Missing = true
return b.applyMissingPolicy(source, "malformed_source", fmt.Sprintf("malformed %s data: %v", source.Name, err)) return b.applyMissingPolicy(source, "malformed_source", fmt.Sprintf("malformed %s data: %v", source.Name, err))

View File

@@ -55,12 +55,39 @@ func TestFetchBundleFromFixtures(t *testing.T) {
if bundle.WeatherStory.UpdatedAt == nil { if bundle.WeatherStory.UpdatedAt == nil {
t.Fatalf("WeatherStory.UpdatedAt = nil, want update timestamp") t.Fatalf("WeatherStory.UpdatedAt = nil, want update timestamp")
} }
if len(bundle.Sources) != 7 { if bundle.SPCConvectiveOutlooks == nil || len(bundle.SPCConvectiveOutlooks.Outlooks) != 1 {
t.Fatalf("Sources length = %d, want 7", len(bundle.Sources)) t.Fatalf("SPCConvectiveOutlooks = %#v, want one outlook", bundle.SPCConvectiveOutlooks)
}
if len(bundle.SPCConvectiveOutlooks.Outlooks[0].Geometry) == 0 {
t.Fatalf("SPCConvectiveOutlooks.Outlooks[0].Geometry is empty, want GeoJSON")
}
if len(bundle.SPCConvectiveOutlooks.Discussions) != 1 || bundle.SPCConvectiveOutlooks.Discussions[0].Headline != "Severe storms possible" {
t.Fatalf("SPCConvectiveOutlooks.Discussions = %#v, want one discussion", bundle.SPCConvectiveOutlooks.Discussions)
}
if len(bundle.Sources) != 8 {
t.Fatalf("Sources length = %d, want 8", len(bundle.Sources))
} }
if len(bundle.Warnings) != 0 { if len(bundle.Warnings) != 0 {
t.Fatalf("Warnings length = %d, want no warnings", len(bundle.Warnings)) t.Fatalf("Warnings length = %d, want no warnings", len(bundle.Warnings))
} }
wantPaths := []string{
"/observations",
"/conditions/current",
"/forecast/hourly",
"/forecast/narrative",
"/alerts/active",
"/discussion",
"/weatherstories/latest",
convectiveOutlooksEndpoint,
}
if len(requested) != len(wantPaths) {
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") { if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested) t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
} }
@@ -70,6 +97,9 @@ func TestFetchBundleFromFixtures(t *testing.T) {
if !containsPath(requested, "/weatherstories/latest") { if !containsPath(requested, "/weatherstories/latest") {
t.Fatalf("requested paths = %v, want weather story endpoint", requested) 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) { func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
@@ -92,6 +122,12 @@ func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
} }
continue 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") { if !strings.Contains(rawURL, "units=us") {
t.Fatalf("request %q missing units=us", rawURL) t.Fatalf("request %q missing units=us", rawURL)
} }
@@ -127,6 +163,25 @@ func TestFetchBundleRecordsSourceHash(t *testing.T) {
if story.IssuedAt == nil || story.UpdatedAt == nil { if story.IssuedAt == nil || story.UpdatedAt == nil {
t.Fatalf("weather story source timestamps = issued %#v updated %#v, want both", story.IssuedAt, story.UpdatedAt) 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) { func TestHTTPErrorIsActionable(t *testing.T) {
@@ -189,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) { func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -370,6 +478,7 @@ func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested
"/alerts/active": "alerts.json", "/alerts/active": "alerts.json",
"/discussion": "discussion.json", "/discussion": "discussion.json",
"/weatherstories/latest": "weather_story.json", "/weatherstories/latest": "weather_story.json",
convectiveOutlooksEndpoint: "convective_outlooks.json",
} }
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if requested != nil { if requested != nil {

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

@@ -10,13 +10,14 @@ import (
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor" distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium" "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/weatherapi"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes" "gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts" "gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
@@ -28,18 +29,20 @@ import (
type ReportKind string type ReportKind string
const ( const (
ReportDaily ReportKind = "daily" ReportDaily ReportKind = ReportKind(report.CommandNameDaily)
ReportTomorrow ReportKind = "tomorrow" ReportToday ReportKind = ReportKind(report.CommandNameToday)
ReportThreeDay ReportKind = "three-day" ReportTomorrow ReportKind = ReportKind(report.CommandNameTomorrow)
ReportWeekend ReportKind = "weekend" ReportHourly ReportKind = ReportKind(report.CommandNameHourly)
ReportStorm ReportKind = "storm" ReportThreeDay ReportKind = ReportKind(report.CommandNameThreeDay)
ReportWeekend ReportKind = ReportKind(report.CommandNameWeekend)
ReportStorm ReportKind = ReportKind(report.CommandNameStorm)
) )
type BatchKind string type BatchKind string
const ( const (
BatchMorning BatchKind = "morning" BatchMorning BatchKind = BatchKind(report.BatchNameMorning)
BatchEvening BatchKind = "evening" BatchEvening BatchKind = BatchKind(report.BatchNameEvening)
) )
type GenerateRequest struct { type GenerateRequest struct {
@@ -50,6 +53,7 @@ type GenerateRequest struct {
Date time.Time Date time.Time
StormStart time.Time StormStart time.Time
StormEnd time.Time StormEnd time.Time
Collector Collector
Notifier Notifier Notifier Notifier
} }
@@ -58,6 +62,7 @@ type BatchRequest struct {
Batch BatchKind Batch BatchKind
Now time.Time Now time.Time
OutputDir string OutputDir string
Collector Collector
Renderer Renderer Renderer Renderer
Store state.Store Store state.Store
Notifier Notifier Notifier Notifier
@@ -82,9 +87,11 @@ type ReportRequest struct {
Config config.Config Config config.Config
Resolved report.Resolved Resolved report.Resolved
OutputPath string OutputPath string
Collection collect.Result
Renderer Renderer Renderer Renderer
Store state.Store Store state.Store
Notifier Notifier Notifier Notifier
noNotify bool
} }
type ReportResult struct { type ReportResult struct {
@@ -102,6 +109,11 @@ type ReportResult struct {
RecentChanges []changes.Change RecentChanges []changes.Change
RenderResult *scriptorium.RenderResult RenderResult *scriptorium.RenderResult
RunResult *scriptorium.RunResult RunResult *scriptorium.RunResult
StructuredRunResult *scriptorium.StructuredRunResult
GeneratedTextRawPath string
GeneratedTextResultPath string
GeneratedTextPath string
RenderContextPath string
Notification *NotificationResult Notification *NotificationResult
} }
@@ -112,9 +124,29 @@ type BatchResult struct {
Total int `json:"total"` Total int `json:"total"`
Succeeded int `json:"succeeded"` Succeeded int `json:"succeeded"`
Failed int `json:"failed"` Failed int `json:"failed"`
Notification *BatchNotificationResult `json:"notification,omitempty"`
Reports []BatchReportResult `json:"reports"` Reports []BatchReportResult `json:"reports"`
} }
type BatchNotificationResult struct {
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
RunID string `json:"runId,omitempty"`
PipelineID string `json:"pipelineId,omitempty"`
BundleID string `json:"bundleId,omitempty"`
IdempotencyKey string `json:"idempotencyKey,omitempty"`
Path string `json:"path,omitempty"`
IncludedReports []BatchNotificationReport `json:"includedReports,omitempty"`
Error string `json:"error,omitempty"`
}
type BatchNotificationReport struct {
ReportID report.ID `json:"reportId"`
RunID string `json:"runId"`
SourcePath string `json:"sourcePath"`
BundlePaths []string `json:"bundlePaths"`
}
type BatchReportResult struct { type BatchReportResult struct {
ReportID report.ID `json:"reportId"` ReportID report.ID `json:"reportId"`
ReportName string `json:"reportName"` ReportName string `json:"reportName"`
@@ -144,12 +176,46 @@ func (e BatchError) Error() string {
if e.Result == nil { if e.Result == nil {
return "batch failed" return "batch failed"
} }
if batchNotificationFailed(e.Result) && batchReportFailures(e.Result) == 0 {
if e.Result.Notification.Error != "" {
return fmt.Sprintf("batch %s notification failed: %s", e.Result.Batch, e.Result.Notification.Error)
}
return fmt.Sprintf("batch %s notification failed", e.Result.Batch)
}
return fmt.Sprintf("batch %s failed: %d of %d reports failed", e.Result.Batch, e.Result.Failed, e.Result.Total) return fmt.Sprintf("batch %s failed: %d of %d reports failed", e.Result.Batch, e.Result.Failed, e.Result.Total)
} }
func batchNotificationFailed(result *BatchResult) bool {
return result != nil && result.Notification != nil && result.Notification.Status == "failed"
}
func batchReportFailures(result *BatchResult) int {
if result == nil {
return 0
}
failures := 0
for _, item := range result.Reports {
if item.Status == "failed" {
failures++
}
}
return failures
}
type Renderer interface { type Renderer interface {
Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error) Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error)
Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error) Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error)
StructuredRun(context.Context, scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error)
}
type Collector interface {
Run(context.Context, collect.Request) (*collect.Result, error)
}
type defaultCollector struct{}
func (defaultCollector) Run(ctx context.Context, req collect.Request) (*collect.Result, error) {
return collect.Run(ctx, req)
} }
type Notifier interface { type Notifier interface {
@@ -202,24 +268,33 @@ func (e *NotificationError) Unwrap() error {
} }
func Generate(ctx context.Context, req GenerateRequest) error { func Generate(ctx context.Context, req GenerateRequest) error {
_, err := GenerateDetailed(ctx, req)
return err
}
func GenerateDetailed(ctx context.Context, req GenerateRequest) (*ReportResult, error) {
now := req.Now now := req.Now
if now.IsZero() { if now.IsZero() {
now = time.Now() now = time.Now()
} }
collection, err := collectWeather(ctx, req.Config, req.Collector)
if err != nil {
return nil, err
}
resolved, err := ResolveGenerate(req, now) resolved, err := ResolveGenerate(req, now)
if err != nil { if err != nil {
return err return nil, err
} }
if resolved.Definition.Generated { if resolved.Definition.Generated {
_, err := GenerateReport(ctx, ReportRequest{ return GenerateReport(ctx, ReportRequest{
Config: req.Config, Config: req.Config,
Resolved: resolved, Resolved: resolved,
OutputPath: req.OutputPath, OutputPath: req.OutputPath,
Collection: *collection,
Notifier: req.Notifier, Notifier: req.Notifier,
}) })
return err
} }
return fmt.Errorf("generate is not implemented") return nil, fmt.Errorf("generate is not implemented")
} }
func RunBatch(ctx context.Context, req BatchRequest) error { func RunBatch(ctx context.Context, req BatchRequest) error {
@@ -238,7 +313,14 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
if now.IsZero() { if now.IsZero() {
now = time.Now() now = time.Now()
} }
resolvedReports, err := ResolveBatch(req, now) if _, err := report.BatchForCommandName(string(req.Batch)); err != nil {
return nil, err
}
collection, err := collectWeather(ctx, req.Config, req.Collector)
if err != nil {
return nil, err
}
plannedReports, err := planBatchRun(req, now, *collection)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -253,27 +335,31 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
} }
startedAt := now startedAt := now
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt} result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
for _, resolved := range resolvedReports { for _, planned := range plannedReports {
resolved := planned.Resolved
if !resolved.Definition.Generated { if !resolved.Definition.Generated {
return nil, fmt.Errorf("run is not implemented") return nil, fmt.Errorf("run is not implemented")
} }
} }
for _, resolved := range resolvedReports { for _, planned := range plannedReports {
item := batchReportResult(resolved) resolved := planned.Resolved
item := batchReportResult(planned)
if paths, err := store.Paths(resolved); err == nil { if paths, err := store.Paths(resolved); err == nil {
item.DataPackagePath = paths.DataPackage item.DataPackagePath = paths.DataPackage
item.PreflightPath = paths.Preflight item.PreflightPath = paths.Preflight
item.ReportPath = paths.RenderedReport item.ReportPath = paths.RenderedReport
item.MetadataPath = paths.Metadata item.MetadataPath = paths.Metadata
} }
outputPath := batchOutputPath(req.OutputDir, resolved.Definition) outputPath := plannedBatchOutputPath(req.OutputDir, planned)
reportResult, err := GenerateReport(ctx, ReportRequest{ reportResult, err := GenerateReport(ctx, ReportRequest{
Config: req.Config, Config: req.Config,
Resolved: resolved, Resolved: resolved,
OutputPath: outputPath, OutputPath: outputPath,
Collection: *collection,
Renderer: req.Renderer, Renderer: req.Renderer,
Store: store, Store: store,
Notifier: req.Notifier, Notifier: req.Notifier,
noNotify: true,
}) })
if err != nil { if err != nil {
item.Status = "failed" item.Status = "failed"
@@ -306,13 +392,21 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
result.Reports = append(result.Reports, item) result.Reports = append(result.Reports, item)
} }
result.Total = len(result.Reports) result.Total = len(result.Reports)
batchNotification, err := notifyBatch(ctx, req.Config, req.Batch, batchRunID(startedAt, req.Batch), startedAt, result, plannedReports, store, req.Notifier)
if batchNotification != nil {
result.Notification = batchNotification
}
if err != nil {
result.Failed++
}
result.FinishedAt = time.Now() result.FinishedAt = time.Now()
return result, nil return result, nil
} }
return nil, fmt.Errorf("run is not implemented") return nil, fmt.Errorf("run is not implemented")
} }
func batchReportResult(resolved report.Resolved) BatchReportResult { func batchReportResult(planned plannedBatchReport) BatchReportResult {
resolved := planned.Resolved
metadata := resolved.Metadata() metadata := resolved.Metadata()
return BatchReportResult{ return BatchReportResult{
ReportID: resolved.Definition.ID, ReportID: resolved.Definition.ID,
@@ -324,11 +418,18 @@ func batchReportResult(resolved report.Resolved) BatchReportResult {
} }
} }
func batchOutputPath(outputDir string, definition report.Definition) string { func plannedBatchOutputPath(outputDir string, planned plannedBatchReport) string {
if outputDir == "" || definition.BatchOutputName == "" { if outputDir == "" {
return "" return ""
} }
return filepath.Join(outputDir, definition.BatchOutputName) outputCopyName := planned.OutputCopyName
if outputCopyName == "" {
outputCopyName = planned.Resolved.Definition.BatchOutputName
}
if outputCopyName == "" {
return ""
}
return filepath.Join(outputDir, outputCopyName)
} }
func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) { func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) {
@@ -336,7 +437,7 @@ func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error
if err != nil { if err != nil {
return report.Resolved{}, err return report.Resolved{}, err
} }
id, err := reportIDForCommand(req.Report) id, err := report.IDForCommandName(string(req.Report))
if err != nil { if err != nil {
return report.Resolved{}, err return report.Resolved{}, err
} }
@@ -353,71 +454,41 @@ func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error
}) })
} }
func ResolveBatch(req BatchRequest, now time.Time) ([]report.Resolved, error) {
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
if err != nil {
return nil, err
}
batch, err := reportBatchForCommand(req.Batch)
if err != nil {
return nil, err
}
registry, err := reportRegistry(req.Config)
if err != nil {
return nil, err
}
return registry.BatchReports(batch, report.ResolveRequest{
Now: now,
Location: location,
})
}
func reportRegistry(cfg config.Config) (report.Registry, error) { func reportRegistry(cfg config.Config) (report.Registry, error) {
registry, err := report.DefaultRegistry().WithModuleOverrides(cfg.ReportModuleOverrides()) overrides, err := cfg.ReportModuleOverrides()
if err != nil {
return report.Registry{}, err
}
registry, err := report.DefaultRegistry().WithModuleOverrides(overrides)
if err != nil { if err != nil {
return report.Registry{}, err return report.Registry{}, err
} }
return registry, nil return registry, nil
} }
func reportIDForCommand(kind ReportKind) (report.ID, error) {
switch kind {
case ReportDaily:
return report.DailyToday, nil
case ReportTomorrow:
return report.DailyTomorrow, nil
case ReportThreeDay:
return report.ThreeDay, nil
case ReportWeekend:
return report.Weekend, nil
case ReportStorm:
return report.Storm, nil
default:
return "", fmt.Errorf("unknown report command %q", kind)
}
}
func reportBatchForCommand(kind BatchKind) (report.Batch, error) {
switch kind {
case BatchMorning:
return report.Morning, nil
case BatchEvening:
return report.Evening, nil
default:
return "", fmt.Errorf("unknown batch command %q", kind)
}
}
func FetchBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) { func FetchBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) {
client, err := weatherapi.New(req.Config) result, err := collectWeather(ctx, req.Config, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
bundle, err := client.FetchBundle(ctx) return result.Bundle, nil
}
func collectWeather(ctx context.Context, cfg config.Config, collector Collector) (*collect.Result, error) {
if collector == nil {
collector = defaultCollector{}
}
result, err := collector.Run(ctx, collect.Request{Config: cfg})
if err != nil { if err != nil {
return nil, err return nil, err
} }
return bundle, nil if result == nil {
return nil, fmt.Errorf("collect weather bundle: collector returned nil result")
}
if result.Bundle == nil {
return nil, fmt.Errorf("collect weather bundle: collector returned nil bundle")
}
return result, nil
} }
func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) { func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) {
@@ -428,13 +499,18 @@ func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherda
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := weatherapi.SaveBundle(req.OutputPath, bundle); err != nil { if err := fileutil.WriteJSONAtomic(req.OutputPath, bundle); err != nil {
return nil, err return nil, fmt.Errorf("save bundle: %w", err)
} }
return bundle, nil return bundle, nil
} }
func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) { func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) {
bundle := req.Collection.Bundle
if bundle == nil {
return nil, fmt.Errorf("collected weather bundle is required")
}
store := req.Store store := req.Store
if store == nil { if store == nil {
defaultStore, err := defaultStore(req.Config) defaultStore, err := defaultStore(req.Config)
@@ -452,10 +528,6 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
return nil, err return nil, err
} }
bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config})
if err != nil {
return nil, err
}
reportFacts, err := BuildReportFacts(ModuleSnapshotRequest{ reportFacts, err := BuildReportFacts(ModuleSnapshotRequest{
Config: req.Config, Config: req.Config,
Resolved: req.Resolved, Resolved: req.Resolved,
@@ -487,6 +559,10 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
DataPackage: paths.DataPackage, DataPackage: paths.DataPackage,
Preflight: paths.Preflight, Preflight: paths.Preflight,
RenderedReport: paths.RenderedReport, RenderedReport: paths.RenderedReport,
GeneratedTextRaw: paths.GeneratedTextRaw,
GeneratedTextResult: paths.GeneratedTextResult,
GeneratedText: paths.GeneratedText,
RenderContext: paths.RenderContext,
}) })
dataPackage, err := promptinput.Build(promptinput.BuildRequest{ dataPackage, err := promptinput.Build(promptinput.BuildRequest{
Metadata: promptMetadata(metadata), Metadata: promptMetadata(metadata),
@@ -531,9 +607,36 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
return nil, metadataErr return nil, metadataErr
} }
if renderErr != nil { if renderErr != nil {
if req.Resolved.Definition.GenerationMode == report.GenerationModeGeneratedTextTemplate {
return nil, generatedReportError(req.Resolved, metadata.RunID, "render preflight", renderErr)
}
return nil, renderErr return nil, renderErr
} }
if req.Resolved.Definition.GenerationMode == report.GenerationModeGeneratedTextTemplate {
return generateTextTemplateReport(ctx, generatedReportRequest{
ReportRequest: req,
store: store,
paths: paths,
moduleSnapshot: moduleSnapshot,
moduleSnapshotPath: moduleSnapshotPath,
reportFacts: reportFacts,
dataPackage: dataPackage,
dataPackagePath: dataPackagePath,
briefingMetadata: briefingMetadata,
metadata: metadata,
metadataPath: metadataPath,
preflightPath: preflightPath,
priorSnapshot: priorSnapshot,
recentChanges: recentChanges,
renderResult: renderResult,
renderer: renderer,
})
}
if req.Resolved.Definition.GenerationMode != report.GenerationModeScriptoriumMarkdown {
return nil, fmt.Errorf("generation mode %q is not supported for report %q", req.Resolved.Definition.GenerationMode, req.Resolved.Definition.ID)
}
reportPath, err := store.PrepareRenderedReport(ctx, req.Resolved) reportPath, err := store.PrepareRenderedReport(ctx, req.Resolved)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -543,53 +646,317 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
DataPackagePath: dataPackagePath, DataPackagePath: dataPackagePath,
OutputPath: reportPath, OutputPath: reportPath,
}) })
if runErr == nil && req.OutputPath != "" && req.OutputPath != reportPath { finalized, err := finalizeRenderedReport(ctx, finalizeRenderedReportRequest{
if err := fileutil.CopyFileAtomic(reportPath, req.OutputPath); err != nil { Config: req.Config,
Store: store,
Resolved: req.Resolved,
Metadata: metadata,
ManagedReportPath: reportPath,
OutputPath: req.OutputPath,
Notifier: req.Notifier,
GenerationErr: runErr,
noNotify: req.noNotify,
})
if err != nil {
if finalizeResultEmpty(finalized) {
return nil, err
}
return renderedReportResult(reportResultRequest{
moduleSnapshot: moduleSnapshot,
moduleSnapshotPath: moduleSnapshotPath,
dataPackage: dataPackage,
dataPackagePath: dataPackagePath,
preflightPath: preflightPath,
reportPath: reportPath,
finalized: finalized,
priorSnapshot: priorSnapshot,
recentChanges: recentChanges,
renderResult: renderResult,
runResult: runResult,
}), err
}
return renderedReportResult(reportResultRequest{
moduleSnapshot: moduleSnapshot,
moduleSnapshotPath: moduleSnapshotPath,
dataPackage: dataPackage,
dataPackagePath: dataPackagePath,
preflightPath: preflightPath,
reportPath: reportPath,
finalized: finalized,
priorSnapshot: priorSnapshot,
recentChanges: recentChanges,
renderResult: renderResult,
runResult: runResult,
}), nil
}
type generatedReportRequest struct {
ReportRequest
store state.Store
paths state.ArtifactPaths
moduleSnapshot module.Snapshot
moduleSnapshotPath string
reportFacts ReportFacts
dataPackage promptinput.Package
dataPackagePath string
briefingMetadata briefing.Metadata
metadata state.Metadata
metadataPath string
preflightPath string
priorSnapshot *state.PriorSnapshot
recentChanges []changes.Change
renderResult *scriptorium.RenderResult
renderer Renderer
}
func generateTextTemplateReport(ctx context.Context, req generatedReportRequest) (*ReportResult, error) {
handler, err := generatedtext.LookupDefinition(req.Resolved.Definition)
if err != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "lookup generated text catalog", err)
}
structuredResult, runErr := req.renderer.StructuredRun(ctx, scriptorium.StructuredRunRequest{
PromptID: req.Resolved.Definition.PromptID,
DataPackagePath: req.dataPackagePath,
OutputPath: req.paths.GeneratedTextRaw,
})
generatedTextResultPath := req.paths.GeneratedTextResult
if structuredResult != nil {
var err error
generatedTextResultPath, err = req.store.SaveGeneratedTextResult(ctx, req.Resolved, structuredResult)
if err != nil {
return nil, err
}
req.metadata.GeneratedTextResultPath = generatedTextResultPath
req.metadataPath, err = req.store.SaveMetadata(ctx, req.metadata)
if err != nil {
return nil, err return nil, err
} }
} }
outputPath := reportPath
if req.OutputPath != "" {
outputPath = req.OutputPath
}
metadata.RenderedReportPath = reportPath
metadataPath, metadataErr = store.SaveMetadata(ctx, metadata)
if metadataErr != nil {
return nil, metadataErr
}
if runErr != nil { if runErr != nil {
return nil, runErr return nil, generatedReportError(req.Resolved, req.metadata.RunID, "structured generated text", runErr)
} }
notification, notificationPath, err := notifyReport(ctx, req.Config, req.Resolved, reportPath, metadata, req.Notifier, store) rawGeneratedText, err := req.store.LoadGeneratedText(ctx, req.paths.GeneratedTextRaw)
if notificationPath != "" { if err != nil {
metadata.NotificationPath = notificationPath return nil, generatedReportError(req.Resolved, req.metadata.RunID, "load raw generated text", err)
metadataPath, metadataErr = store.SaveMetadata(ctx, metadata)
if metadataErr != nil {
return nil, metadataErr
} }
generatedText, normalizedGeneratedText, err := handler.Validate(rawGeneratedText)
if err != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "validate generated text", err)
} }
generatedTextPath, err := req.store.SaveGeneratedText(ctx, req.Resolved, normalizedGeneratedText)
if err != nil {
return nil, err
}
req.metadata.GeneratedTextPath = generatedTextPath
req.metadataPath, err = req.store.SaveMetadata(ctx, req.metadata)
if err != nil { if err != nil {
return nil, err return nil, err
} }
renderContext, err := handler.BuildRenderContext(req.briefingMetadata, req.moduleSnapshot, req.reportFacts.Collected, req.reportFacts.Derived, generatedText)
if err != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "build render context", err)
}
renderContextPath, err := req.store.SaveRenderContext(ctx, req.Resolved, renderContext)
if err != nil {
return nil, err
}
req.metadata.RenderContextPath = renderContextPath
req.metadataPath, err = req.store.SaveMetadata(ctx, req.metadata)
if err != nil {
return nil, err
}
rendered, err := handler.Render(renderContext)
if err != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "render template", err)
}
reportPath, err := req.store.PrepareRenderedReport(ctx, req.Resolved)
if err != nil {
return nil, err
}
if err := fileutil.WriteFileAtomic(reportPath, rendered); err != nil {
return nil, err
}
finalized, err := finalizeRenderedReport(ctx, finalizeRenderedReportRequest{
Config: req.Config,
Store: req.store,
Resolved: req.Resolved,
Metadata: req.metadata,
ManagedReportPath: reportPath,
OutputPath: req.OutputPath,
Notifier: req.Notifier,
noNotify: req.noNotify,
})
if err != nil {
if finalizeResultEmpty(finalized) {
return nil, err
}
return renderedReportResult(reportResultRequest{
moduleSnapshot: req.moduleSnapshot,
moduleSnapshotPath: req.moduleSnapshotPath,
dataPackage: req.dataPackage,
dataPackagePath: req.dataPackagePath,
preflightPath: req.preflightPath,
reportPath: reportPath,
finalized: finalized,
priorSnapshot: req.priorSnapshot,
recentChanges: req.recentChanges,
renderResult: req.renderResult,
structuredRunResult: structuredResult,
generatedTextRawPath: req.paths.GeneratedTextRaw,
generatedTextResultPath: generatedTextResultPath,
generatedTextPath: generatedTextPath,
renderContextPath: renderContextPath,
}), err
}
return renderedReportResult(reportResultRequest{
moduleSnapshot: req.moduleSnapshot,
moduleSnapshotPath: req.moduleSnapshotPath,
dataPackage: req.dataPackage,
dataPackagePath: req.dataPackagePath,
preflightPath: req.preflightPath,
reportPath: reportPath,
finalized: finalized,
priorSnapshot: req.priorSnapshot,
recentChanges: req.recentChanges,
renderResult: req.renderResult,
structuredRunResult: structuredResult,
generatedTextRawPath: req.paths.GeneratedTextRaw,
generatedTextResultPath: generatedTextResultPath,
generatedTextPath: generatedTextPath,
renderContextPath: renderContextPath,
}), nil
}
func finalizeResultEmpty(result finalizeRenderedReportResult) bool {
return result.OutputPath == "" &&
result.NotificationPath == "" &&
result.MetadataPath == "" &&
result.Metadata.RunID == "" &&
result.Notification == nil
}
type reportResultRequest struct {
moduleSnapshot module.Snapshot
moduleSnapshotPath string
dataPackage promptinput.Package
dataPackagePath string
preflightPath string
reportPath string
finalized finalizeRenderedReportResult
priorSnapshot *state.PriorSnapshot
recentChanges []changes.Change
renderResult *scriptorium.RenderResult
runResult *scriptorium.RunResult
structuredRunResult *scriptorium.StructuredRunResult
generatedTextRawPath string
generatedTextResultPath string
generatedTextPath string
renderContextPath string
}
func renderedReportResult(req reportResultRequest) *ReportResult {
return &ReportResult{ return &ReportResult{
ModuleSnapshot: moduleSnapshot, ModuleSnapshot: req.moduleSnapshot,
ModuleSnapshotPath: moduleSnapshotPath, ModuleSnapshotPath: req.moduleSnapshotPath,
DataPackage: dataPackage, DataPackage: req.dataPackage,
DataPackagePath: dataPackagePath, DataPackagePath: req.dataPackagePath,
PreflightPath: preflightPath, PreflightPath: req.preflightPath,
ReportPath: reportPath, ReportPath: req.reportPath,
OutputPath: req.finalized.OutputPath,
NotificationPath: req.finalized.NotificationPath,
Metadata: req.finalized.Metadata,
MetadataPath: req.finalized.MetadataPath,
PriorSnapshot: req.priorSnapshot,
RecentChanges: req.recentChanges,
RenderResult: req.renderResult,
RunResult: req.runResult,
StructuredRunResult: req.structuredRunResult,
GeneratedTextRawPath: req.generatedTextRawPath,
GeneratedTextResultPath: req.generatedTextResultPath,
GeneratedTextPath: req.generatedTextPath,
RenderContextPath: req.renderContextPath,
Notification: req.finalized.Notification,
}
}
type finalizeRenderedReportRequest struct {
Config config.Config
Store state.Store
Resolved report.Resolved
Metadata state.Metadata
ManagedReportPath string
OutputPath string
Notifier Notifier
GenerationErr error
noNotify bool
}
type finalizeRenderedReportResult struct {
OutputPath string
NotificationPath string
Metadata state.Metadata
MetadataPath string
Notification *NotificationResult
}
func finalizeRenderedReport(ctx context.Context, req finalizeRenderedReportRequest) (finalizeRenderedReportResult, error) {
if req.Store == nil {
return finalizeRenderedReportResult{}, fmt.Errorf("state store is required")
}
if req.ManagedReportPath == "" {
return finalizeRenderedReportResult{}, fmt.Errorf("managed report path is required for report %q", req.Resolved.Definition.ID)
}
metadata := req.Metadata
metadata.RenderedReportPath = req.ManagedReportPath
outputPath := req.ManagedReportPath
if req.OutputPath != "" {
outputPath = req.OutputPath
if req.GenerationErr == nil && req.OutputPath != req.ManagedReportPath {
if err := fileutil.CopyFileAtomic(req.ManagedReportPath, req.OutputPath); err != nil {
return finalizeRenderedReportResult{}, err
}
}
}
metadataPath, err := req.Store.SaveMetadata(ctx, metadata)
if err != nil {
return finalizeRenderedReportResult{}, err
}
result := finalizeRenderedReportResult{
OutputPath: outputPath, OutputPath: outputPath,
NotificationPath: notificationPath,
Metadata: metadata, Metadata: metadata,
MetadataPath: metadataPath, MetadataPath: metadataPath,
PriorSnapshot: priorSnapshot, }
RecentChanges: recentChanges, if req.GenerationErr != nil {
RenderResult: renderResult, return result, req.GenerationErr
RunResult: runResult, }
Notification: notification, if req.noNotify {
}, nil return result, nil
}
notification, notificationPath, err := notifyReport(ctx, req.Config, req.Resolved, req.ManagedReportPath, metadata, req.Notifier, req.Store)
if notificationPath != "" {
metadata.NotificationPath = notificationPath
metadataPath, saveErr := req.Store.SaveMetadata(ctx, metadata)
if saveErr != nil {
return finalizeRenderedReportResult{}, saveErr
}
result.Metadata = metadata
result.MetadataPath = metadataPath
result.NotificationPath = notificationPath
}
result.Notification = notification
if err != nil {
return result, err
}
return result, nil
} }
func notifyReport(ctx context.Context, cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata, notifier Notifier, store state.Store) (*NotificationResult, string, error) { func notifyReport(ctx context.Context, cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata, notifier Notifier, store state.Store) (*NotificationResult, string, error) {
@@ -632,14 +999,8 @@ func reportNotifier(cfg config.Config, notifier Notifier) (Notifier, bool) {
} }
func buildNotificationRequest(cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata) (NotificationRequest, error) { func buildNotificationRequest(cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata) (NotificationRequest, error) {
values := config.DistributorTemplateValues{ values, err := distributorTemplateValuesForReport(cfg, resolved, metadata.RunID, resolved.Definition.BatchOutputName)
LocationID: cfg.Location.ID, if err != nil {
ReportID: string(resolved.Definition.ID),
RunID: metadata.RunID,
ArtifactGroup: resolved.Definition.ArtifactGroup,
BatchOutputName: resolved.Definition.BatchOutputName,
}
if err := addDistributorValidPeriodValues(&values, resolved.ValidPeriod, cfg.WeatherAPI.Timezone); err != nil {
return NotificationRequest{}, err return NotificationRequest{}, err
} }
bundleID, err := config.RenderDistributorBundleID(cfg.Notify.Distributor.BundleIDTemplate, values) bundleID, err := config.RenderDistributorBundleID(cfg.Notify.Distributor.BundleIDTemplate, values)
@@ -655,7 +1016,7 @@ func buildNotificationRequest(cfg config.Config, resolved report.Resolved, repor
if err != nil { if err != nil {
return NotificationRequest{}, err return NotificationRequest{}, err
} }
bundlePaths, err := config.RenderDistributorReportPaths(cfg.Notify.Distributor.ReportPathTemplates, values) bundlePaths, err := renderDistributorReportBundlePaths(cfg, resolved, metadata.RunID, reportPath, values)
if err != nil { if err != nil {
return NotificationRequest{}, err return NotificationRequest{}, err
} }
@@ -671,6 +1032,59 @@ func buildNotificationRequest(cfg config.Config, resolved report.Resolved, repor
}, nil }, nil
} }
func distributorTemplateValuesForReport(cfg config.Config, resolved report.Resolved, runID string, batchOutputName string) (config.DistributorTemplateValues, error) {
values := config.DistributorTemplateValues{
LocationID: cfg.Location.ID,
ReportID: string(resolved.Definition.ID),
RunID: runID,
ArtifactGroup: resolved.Definition.ArtifactGroup,
BatchOutputName: batchOutputName,
}
if values.BatchOutputName == "" {
values.BatchOutputName = resolved.Definition.BatchOutputName
}
if err := addDistributorValidPeriodValues(&values, resolved.ValidPeriod, cfg.WeatherAPI.Timezone); err != nil {
return config.DistributorTemplateValues{}, err
}
if resolved.Definition.ID == report.Storm {
values.StormID = values.ValidStartStamp + "-" + values.ValidEndStamp
}
return values, nil
}
func renderDistributorReportBundlePaths(cfg config.Config, resolved report.Resolved, runID string, sourcePath string, values config.DistributorTemplateValues) ([]string, error) {
templates, name, err := distributorPathTemplatesForReport(cfg, resolved.Definition)
if err != nil {
return nil, distributorReportPathError(resolved.Definition.ID, runID, sourcePath, err)
}
paths, err := config.RenderDistributorReportPaths(name, templates, values)
if err != nil {
return nil, distributorReportPathError(resolved.Definition.ID, runID, sourcePath, err)
}
return paths, nil
}
func distributorPathTemplatesForReport(cfg config.Config, definition report.Definition) ([]string, string, error) {
overrides, err := cfg.ReportDistributorPathOverrides()
if err != nil {
return nil, "", err
}
if templates, ok := overrides[definition.ID]; ok {
return append([]string(nil), templates...), fmt.Sprintf("reports.%s.distributor.path_templates", definition.ID), nil
}
if len(definition.DistributorPathTemplates) > 0 {
return append([]string(nil), definition.DistributorPathTemplates...), fmt.Sprintf("report.%s.distributor_path_templates", definition.ID), nil
}
return nil, "", fmt.Errorf("no distributor path templates configured")
}
func distributorReportPathError(id report.ID, runID string, sourcePath string, err error) error {
if sourcePath != "" {
return fmt.Errorf("report %q run %q source path %q: %w", id, runID, sourcePath, err)
}
return fmt.Errorf("report %q run %q: %w", id, runID, err)
}
func addDistributorValidPeriodValues(values *config.DistributorTemplateValues, period timeutil.Period, timezone string) error { func addDistributorValidPeriodValues(values *config.DistributorTemplateValues, period timeutil.Period, timezone string) error {
location, err := timeutil.LoadLocation(timezone) location, err := timeutil.LoadLocation(timezone)
if err != nil { if err != nil {
@@ -778,6 +1192,38 @@ func (n distributorNotifier) Notify(ctx context.Context, req NotificationRequest
return notification, nil return notification, nil
} }
func (n distributorNotifier) NotifyBatch(ctx context.Context, req batchNotificationRequest) (*NotificationResult, error) {
result, err := n.client.Upload(ctx, batchDistributorUploadRequest(req))
notification := notificationResultFromUpload(req.PipelineID, req.BundleID, req.IdempotencyKey, result)
if err != nil {
return notification, err
}
return notification, nil
}
func notificationResultFromUpload(pipelineID string, bundleID string, idempotencyKey string, result distributoradapter.UploadResult) *NotificationResult {
notification := &NotificationResult{
PipelineID: pipelineID,
BundleID: bundleID,
IdempotencyKey: idempotencyKey,
RunID: result.RunID,
Status: result.Status,
UploadStatus: result.UploadStatus,
StatusError: result.StatusError,
}
if result.RunStatus != nil {
if result.RunStatus.PipelineID != "" {
notification.PipelineID = result.RunStatus.PipelineID
}
notification.AcceptedAt = result.RunStatus.AcceptedAt
notification.StartedAt = result.RunStatus.StartedAt
notification.FinishedAt = result.RunStatus.FinishedAt
notification.Report = append([]byte(nil), result.RunStatus.Report...)
notification.Error = result.RunStatus.Error
}
return notification
}
func distributorUploadFiles(sourcePath string, bundlePaths []string) []distributoradapter.UploadFile { func distributorUploadFiles(sourcePath string, bundlePaths []string) []distributoradapter.UploadFile {
files := make([]distributoradapter.UploadFile, 0, len(bundlePaths)) files := make([]distributoradapter.UploadFile, 0, len(bundlePaths))
for _, bundlePath := range bundlePaths { for _, bundlePath := range bundlePaths {
@@ -911,7 +1357,7 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.
PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes, PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes,
} }
switch reportID { switch reportID {
case report.DailyToday, report.DailyTomorrow: case report.Daily, report.Today, report.Tomorrow:
return changes.CompareDaily(previous, current, thresholds) return changes.CompareDaily(previous, current, thresholds)
case report.ThreeDay: case report.ThreeDay:
return changes.CompareThreeDay(previous, current, thresholds) return changes.CompareThreeDay(previous, current, thresholds)
@@ -935,3 +1381,10 @@ func preflightArtifact(result *scriptorium.RenderResult) state.PreflightArtifact
ExitCode: result.ExitCode, ExitCode: result.ExitCode,
} }
} }
func generatedReportError(resolved report.Resolved, runID string, operation string, err error) error {
if err == nil {
return nil
}
return fmt.Errorf("generate report %q run %q: %s: %w", resolved.Definition.ID, runID, operation, err)
}

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

@@ -19,17 +19,21 @@ type AlertSummary struct {
Event string `json:"event,omitempty"` Event string `json:"event,omitempty"`
Headline string `json:"headline,omitempty"` Headline string `json:"headline,omitempty"`
Severity string `json:"severity,omitempty"` Severity string `json:"severity,omitempty"`
PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
Instruction string `json:"instruction,omitempty"`
Description string `json:"description,omitempty"`
} }
func buildAlertDigestModule(ctx ModuleContext, _ any) (*module.Output, error) { func buildAlertDigestModule(ctx ModuleContext, _ any) (*module.Output, error) {
value := alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps) value := alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps, ctx.Timezone)
if value == nil { if value == nil {
value = &AlertDigestModule{} value = &AlertDigestModule{}
} }
return &module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: *value}, nil return &module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: *value}, nil
} }
func alertDigest(collected facts.CollectedFacts, overlaps []forecast.AlertOverlap) *AlertDigestModule { func alertDigest(collected facts.CollectedFacts, overlaps []forecast.AlertOverlap, timezone string) *AlertDigestModule {
missing := sourceMissing(collected.SourceProvenance, "alerts") missing := sourceMissing(collected.SourceProvenance, "alerts")
if collected.Alerts == nil && !missing { if collected.Alerts == nil && !missing {
return nil return nil
@@ -45,6 +49,10 @@ func alertDigest(collected facts.CollectedFacts, overlaps []forecast.AlertOverla
Event: overlap.Event, Event: overlap.Event,
Headline: overlap.Headline, Headline: overlap.Headline,
Severity: overlap.Severity, Severity: overlap.Severity,
PeriodBegins: friendlyMonthDayTimeLabel(overlap.Period.Start, timezone),
PeriodEnds: friendlyMonthDayTimeLabel(overlap.Period.End, timezone),
Instruction: overlap.Instruction,
Description: overlap.Description,
}) })
} }
return value return value

View File

@@ -59,21 +59,24 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
t.Fatalf("HourlyForecast = %#v, want hourly metadata and one valid-period period", value) t.Fatalf("HourlyForecast = %#v, want hourly metadata and one valid-period period", value)
} }
period := value.Periods[0] period := value.Periods[0]
if period.TextDescription != "Showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 { if period.HourLabel != "8:00 AM" || period.TextDescription != "Showers likely." || period.TextDescriptionLower != "showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 {
t.Fatalf("HourlyForecast period = %#v, want hourly period facts", period) t.Fatalf("HourlyForecast period = %#v, want hourly period facts", period)
} }
if period.StartTime != "2026-05-29 at 8:00 AM" || period.EndTime != "2026-05-29 at 9:00 AM" { 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.StartTime, period.EndTime) 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 { if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
t.Fatalf("HourlyForecast period = %#v, want compass wind and precip chance", period) 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) data, err := json.Marshal(output.Value)
if err != nil { if err != nil {
t.Fatalf("Marshal hourly forecast: %v", err) t.Fatalf("Marshal hourly forecast: %v", err)
} }
jsonText := string(data) jsonText := string(data)
for _, field := range []string{"source_location_id", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} { for _, field := range []string{"source_location_id", "hour_label", "period_begins", "period_ends", "text_description", "text_description_lower", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "mention_precipitation", "relative_humidity_percent"} {
if !strings.Contains(jsonText, field) { if !strings.Contains(jsonText, field) {
t.Fatalf("hourly json = %s, want field %s", jsonText, field) t.Fatalf("hourly json = %s, want field %s", jsonText, field)
} }
@@ -81,8 +84,71 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
if strings.Contains(jsonText, "wind_direction_degrees") || strings.Contains(jsonText, "Tomorrow") { if strings.Contains(jsonText, "wind_direction_degrees") || strings.Contains(jsonText, "Tomorrow") {
t.Fatalf("hourly json = %s, want valid-period prompt fields only", jsonText) t.Fatalf("hourly json = %s, want valid-period prompt fields only", jsonText)
} }
if strings.Contains(jsonText, `"start_time":"2026-05-29T`) || strings.Contains(jsonText, `"end_time":"2026-05-29T`) { if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) {
t.Fatalf("hourly json = %s, want friendly local start/end times", jsonText) 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")
} }
} }
@@ -97,6 +163,21 @@ func TestHourlyForecastModuleRejectsUnsupportedReports(t *testing.T) {
} }
} }
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) { func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := testModuleContext() ctx := testModuleContext()
@@ -113,8 +194,8 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
if period.Name != "Today" || period.TextDescription != "Morning storms, then partly sunny." { if period.Name != "Today" || period.TextDescription != "Morning storms, then partly sunny." {
t.Fatalf("NarrativeForecast period = %#v, want Today narrative", period) t.Fatalf("NarrativeForecast period = %#v, want Today narrative", period)
} }
if period.StartTime != "2026-05-29 at 6:00 AM" || period.EndTime != "2026-05-29 at 6:00 PM" { 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.StartTime, period.EndTime) 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 { 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) t.Fatalf("NarrativeForecast period = %#v, want day, temperature, and precip values", period)
@@ -127,7 +208,7 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
t.Fatalf("Marshal narrative forecast: %v", err) t.Fatalf("Marshal narrative forecast: %v", err)
} }
jsonText := string(data) jsonText := string(data)
for _, field := range []string{"source_location_id", "text_description", "temperature_f", "wind_speed_mph", "wind_direction", "probability_of_precipitation_percent"} { for _, field := range []string{"source_location_id", "period_begins", "period_ends", "text_description", "temperature_f", "wind_speed_mph", "wind_direction", "probability_of_precipitation_percent"} {
if !strings.Contains(jsonText, field) { if !strings.Contains(jsonText, field) {
t.Fatalf("narrative json = %s, want field %s", jsonText, field) t.Fatalf("narrative json = %s, want field %s", jsonText, field)
} }
@@ -135,8 +216,8 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
if strings.Contains(jsonText, "wind_direction_degrees") { if strings.Contains(jsonText, "wind_direction_degrees") {
t.Fatalf("narrative json = %s, want compass wind_direction without degrees field", jsonText) t.Fatalf("narrative json = %s, want compass wind_direction without degrees field", jsonText)
} }
if strings.Contains(jsonText, `"start_time":"2026-05-29T`) || strings.Contains(jsonText, `"end_time":"2026-05-29T`) { if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) {
t.Fatalf("narrative json = %s, want friendly local start/end times", jsonText) t.Fatalf("narrative json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText)
} }
if strings.Contains(jsonText, "Tomorrow night") { if strings.Contains(jsonText, "Tomorrow night") {
t.Fatalf("narrative json = %s, want only valid-period narrative periods", jsonText) t.Fatalf("narrative json = %s, want only valid-period narrative periods", jsonText)
@@ -163,7 +244,7 @@ func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
t.Fatalf("BuildModule() error = %v", err) t.Fatalf("BuildModule() error = %v", err)
} }
value := moduleValue[MetadataModule](t, output) value := moduleValue[MetadataModule](t, output)
if value.RunID == "" || value.ReportID != report.DailyToday || value.PromptID != "weather.daily_report" { if value.RunID == "" || value.ReportID != report.Daily || value.PromptID != "weather.daily_generated_text" {
t.Fatalf("metadata = %#v, want report identity", value) t.Fatalf("metadata = %#v, want report identity", value)
} }
if value.Location == nil || value.Location.Name != "Brentwood" { if value.Location == nil || value.Location.Name != "Brentwood" {
@@ -194,18 +275,24 @@ func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) {
t.Fatalf("BuildModule() error = %v", err) t.Fatalf("BuildModule() error = %v", err)
} }
value := moduleValue[CurrentConditionsModule](t, output) value := moduleValue[CurrentConditionsModule](t, output)
if value.ConditionText != "Partly cloudy" || value.TemperatureF == nil || *value.TemperatureF != 74 { if value.ConditionText != "Partly cloudy" || value.ConditionTextLower != "partly cloudy" || value.TemperatureF == nil || *value.TemperatureF != 74 {
t.Fatalf("CurrentConditions = %#v, want current condition facts", value) 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" { if value.WindDirection != "S" {
t.Fatalf("WindDirection = %q, want S", value.WindDirection) 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) data, err := json.Marshal(output.Value)
if err != nil { if err != nil {
t.Fatalf("Marshal current conditions: %v", err) t.Fatalf("Marshal current conditions: %v", err)
} }
jsonText := string(data) jsonText := string(data)
for _, field := range []string{"condition_text", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction"} { for _, field := range []string{"condition_text", "condition_text_lower", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction", "wind_direction_text"} {
if !strings.Contains(jsonText, field) { if !strings.Contains(jsonText, field) {
t.Fatalf("current json = %s, want field %s", jsonText, field) t.Fatalf("current json = %s, want field %s", jsonText, field)
} }
@@ -215,6 +302,44 @@ func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) {
} }
} }
func TestCurrentConditionsPromptExportOmitsTemplateHelpers(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.CurrentConditions})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
richText := mustMarshalModuleJSON(t, output.Value)
for _, field := range []string{"condition_text_lower", "wind_direction_text"} {
if !strings.Contains(richText, field) {
t.Fatalf("rich current conditions json = %s, want helper field %s", richText, field)
}
}
prompt := moduleDataPackageValue[CurrentConditionsPromptExport](t, output)
if prompt.ConditionText != "Partly cloudy" || prompt.TemperatureF == nil || *prompt.TemperatureF != 74 {
t.Fatalf("current prompt export = %#v, want condition text and temperature", prompt)
}
if prompt.ApparentTemperatureF == nil || *prompt.ApparentTemperatureF != 76 || prompt.RelativeHumidityPercent == nil || *prompt.RelativeHumidityPercent != 71 || prompt.WindSpeedMph == nil || *prompt.WindSpeedMph != 8 {
t.Fatalf("current prompt export = %#v, want apparent temperature, humidity, and wind speed", prompt)
}
if prompt.WindDirection != "S" {
t.Fatalf("current prompt wind direction = %q, want S", prompt.WindDirection)
}
promptText := mustMarshalModuleJSON(t, output.DataPackageValue())
for _, field := range []string{"condition_text", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction"} {
if !strings.Contains(promptText, field) {
t.Fatalf("current prompt json = %s, want field %s", promptText, field)
}
}
for _, field := range []string{"condition_text_lower", "wind_direction_text"} {
if strings.Contains(promptText, field) {
t.Fatalf("current prompt json = %s, want omitted helper field %s", promptText, field)
}
}
}
func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) { func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := testModuleContext() ctx := testModuleContext()
@@ -242,6 +367,39 @@ func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) {
} }
} }
func TestAlertDigestIncludesPeriodAndGuidance(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Collected.Alerts = &weatherdata.AlertRun{Alerts: []json.RawMessage{json.RawMessage(`{"event":"Wind Advisory"}`)}}
ctx.Derived.AlertOverlaps = []forecast.AlertOverlap{{
Event: "Wind Advisory",
Headline: "Wind Advisory until 8 PM",
Severity: "Moderate",
Period: timeutil.Period{Start: mustParseModuleTime("2026-06-17T18:00:00Z"), End: mustParseModuleTime("2026-06-18T01:00:00Z")},
Instruction: "Secure outdoor objects.",
Description: "Gusty winds may blow around unsecured objects.",
}}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.AlertDigest})
if err != nil {
t.Fatalf("BuildModule(alert digest) error = %v", err)
}
value := moduleValue[AlertDigestModule](t, output)
if len(value.Relevant) != 1 {
t.Fatalf("Relevant length = %d, want 1", len(value.Relevant))
}
alert := value.Relevant[0]
if alert.Event != "Wind Advisory" || alert.Headline != "Wind Advisory until 8 PM" || alert.Severity != "Moderate" {
t.Fatalf("alert identity = %#v, want preserved event/headline/severity", alert)
}
if alert.PeriodBegins != "June 17 at 1:00 PM" || alert.PeriodEnds != "June 17 at 8:00 PM" {
t.Fatalf("alert period = %q/%q, want friendly local labels", alert.PeriodBegins, alert.PeriodEnds)
}
if alert.Instruction != "Secure outdoor objects." || alert.Description != "Gusty winds may blow around unsecured objects." {
t.Fatalf("alert guidance = %#v, want instruction and description preserved", alert)
}
}
func TestBaseModulesOmitMissingOptionalOutputs(t *testing.T) { func TestBaseModulesOmitMissingOptionalOutputs(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := testModuleContext() ctx := testModuleContext()
@@ -285,13 +443,19 @@ func TestAreaForecastDiscussionAndWeatherStoryModules(t *testing.T) {
if !story.Available || story.Title != "Rain Chances" || story.Description != "Scattered showers are possible." { if !story.Available || story.Title != "Rain Chances" || story.Description != "Scattered showers are possible." {
t.Fatalf("WeatherStory = %#v, want structured story fields", story) 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) data, err := json.Marshal(storyOutput.Value)
if err != nil { if err != nil {
t.Fatalf("Marshal weather story: %v", err) t.Fatalf("Marshal weather story: %v", err)
} }
if !strings.Contains(string(data), "download_url") { if !strings.Contains(string(data), "download_url") || !strings.Contains(string(data), "period_begins") || !strings.Contains(string(data), "period_ends") {
t.Fatalf("weather story json = %s, want snake_case download_url", string(data)) 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) { func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
@@ -314,9 +478,65 @@ func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
} }
} }
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 { func testModuleContext() ModuleContext {
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00") generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
definition := report.DefaultRegistry().MustLookup(report.DailyToday) definition := report.DefaultRegistry().MustLookup(report.Daily)
resolved := report.Resolved{ resolved := report.Resolved{
Definition: definition, Definition: definition,
GeneratedAt: generatedAt, GeneratedAt: generatedAt,
@@ -327,10 +547,10 @@ func testModuleContext() ModuleContext {
}, },
} }
isDay := true isDay := true
tempF := 74.0 tempF := 74.4
apparentF := 76.0 apparentF := 75.6
humidity := 71.0 humidity := 70.6
windMph := 8.0 windMph := 8.4
windDirection := 190.0 windDirection := 190.0
narrativeTempF := 81.0 narrativeTempF := 81.0
narrativePop := 60.0 narrativePop := 60.0
@@ -484,6 +704,28 @@ func moduleValue[T any](t *testing.T, output *module.Output) T {
return value return value
} }
func moduleDataPackageValue[T any](t *testing.T, output *module.Output) T {
t.Helper()
var value T
data, err := json.Marshal(output.DataPackageValue())
if err != nil {
t.Fatalf("marshal module data package value: %v", err)
}
if err := json.Unmarshal(data, &value); err != nil {
t.Fatalf("decode module data package value: %v", err)
}
return value
}
func mustMarshalModuleJSON(t *testing.T, value any) string {
t.Helper()
data, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal module value: %v", err)
}
return string(data)
}
func mustParseModuleTime(value string) time.Time { func mustParseModuleTime(value string) time.Time {
parsed, err := time.Parse(time.RFC3339, value) parsed, err := time.Parse(time.RFC3339, value)
if err != nil { if err != nil {

View File

@@ -1,19 +1,40 @@
package briefing package briefing
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module" import (
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
)
type CurrentConditionsModule struct { type CurrentConditionsModule struct {
ConditionText string `json:"condition_text,omitempty"` ConditionText string `json:"condition_text,omitempty"`
ConditionTextLower string `json:"condition_text_lower,omitempty"`
IsDay *bool `json:"is_day,omitempty"` IsDay *bool `json:"is_day,omitempty"`
TemperatureC *float64 `json:"temperature_c,omitempty"` TemperatureC *int `json:"temperature_c,omitempty"`
TemperatureF *float64 `json:"temperature_f,omitempty"` TemperatureF *int `json:"temperature_f,omitempty"`
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"` ApparentTemperatureC *int `json:"apparent_temperature_c,omitempty"`
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"` ApparentTemperatureF *int `json:"apparent_temperature_f,omitempty"`
DewpointC *float64 `json:"dewpoint_c,omitempty"` DewpointC *int `json:"dewpoint_c,omitempty"`
DewpointF *float64 `json:"dewpoint_f,omitempty"` DewpointF *int `json:"dewpoint_f,omitempty"`
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"` RelativeHumidityPercent *int `json:"relative_humidity_percent,omitempty"`
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"` WindSpeedKmh *int `json:"wind_speed_kmh,omitempty"`
WindSpeedMph *float64 `json:"wind_speed_mph,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"` WindDirection string `json:"wind_direction,omitempty"`
} }
@@ -24,17 +45,19 @@ func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, err
} }
value := CurrentConditionsModule{ value := CurrentConditionsModule{
ConditionText: current.ConditionText, ConditionText: current.ConditionText,
ConditionTextLower: strings.ToLower(current.ConditionText),
IsDay: copyBool(current.IsDay), IsDay: copyBool(current.IsDay),
TemperatureC: copyFloat(current.TemperatureC), TemperatureC: roundedInt(current.TemperatureC),
TemperatureF: copyFloat(current.TemperatureF), TemperatureF: roundedInt(current.TemperatureF),
ApparentTemperatureC: copyFloat(current.ApparentTemperatureC), ApparentTemperatureC: roundedInt(current.ApparentTemperatureC),
ApparentTemperatureF: copyFloat(current.ApparentTemperatureF), ApparentTemperatureF: roundedInt(current.ApparentTemperatureF),
DewpointC: copyFloat(current.DewpointC), DewpointC: roundedInt(current.DewpointC),
DewpointF: copyFloat(current.DewpointF), DewpointF: roundedInt(current.DewpointF),
RelativeHumidityPercent: copyFloat(current.RelativeHumidityPercent), RelativeHumidityPercent: roundedInt(current.RelativeHumidityPercent),
WindSpeedKmh: copyFloat(current.WindSpeedKmh), WindSpeedKmh: roundedInt(current.WindSpeedKmh),
WindSpeedMph: copyFloat(current.WindSpeedMph), WindSpeedMph: roundedInt(current.WindSpeedMph),
WindDirection: windDirectionLabel(current.WindDirectionDegrees), WindDirection: windDirectionLabel(current.WindDirectionDegrees),
WindDirectionText: windDirectionTextLabel(current.WindDirectionDegrees),
} }
if value.isEmpty() { if value.isEmpty() {
return nil, nil return nil, nil
@@ -42,8 +65,30 @@ func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, err
return &module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: value}, nil return &module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: value}, nil
} }
func exportCurrentConditionsPromptValue(value any) (any, error) {
rich, ok := value.(CurrentConditionsModule)
if !ok {
return nil, unexpectedPromptExportValue(value, CurrentConditionsModule{})
}
return CurrentConditionsPromptExport{
ConditionText: rich.ConditionText,
IsDay: copyBool(rich.IsDay),
TemperatureC: copyInt(rich.TemperatureC),
TemperatureF: copyInt(rich.TemperatureF),
ApparentTemperatureC: copyInt(rich.ApparentTemperatureC),
ApparentTemperatureF: copyInt(rich.ApparentTemperatureF),
DewpointC: copyInt(rich.DewpointC),
DewpointF: copyInt(rich.DewpointF),
RelativeHumidityPercent: copyInt(rich.RelativeHumidityPercent),
WindSpeedKmh: copyInt(rich.WindSpeedKmh),
WindSpeedMph: copyInt(rich.WindSpeedMph),
WindDirection: rich.WindDirection,
}, nil
}
func (v CurrentConditionsModule) isEmpty() bool { func (v CurrentConditionsModule) isEmpty() bool {
return v.ConditionText == "" && return v.ConditionText == "" &&
v.ConditionTextLower == "" &&
v.IsDay == nil && v.IsDay == nil &&
v.TemperatureC == nil && v.TemperatureC == nil &&
v.TemperatureF == nil && v.TemperatureF == nil &&
@@ -54,5 +99,6 @@ func (v CurrentConditionsModule) isEmpty() bool {
v.RelativeHumidityPercent == nil && v.RelativeHumidityPercent == nil &&
v.WindSpeedKmh == nil && v.WindSpeedKmh == nil &&
v.WindSpeedMph == nil && v.WindSpeedMph == nil &&
v.WindDirection == "" 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

@@ -2,24 +2,66 @@ package briefing
import ( import (
"fmt" "fmt"
"sort"
"strings" "strings"
"unicode" "unicode"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
type DerivedDaypartSummaryModule struct { type DerivedDaypartSummaryModule struct {
Date string `json:"date,omitempty"` Date string `json:"date,omitempty"`
Period string `json:"period,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"` TempRangeF string `json:"temp_range_f,omitempty"`
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"` ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"` MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"` MaxPopTime string `json:"max_pop_time,omitempty"`
MentionPrecipitation bool `json:"mention_precipitation,omitempty"`
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"` MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"` MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
DominantCondition string `json:"dominant_condition,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"` NotableConditions []string `json:"notable_conditions,omitempty"`
Snow bool `json:"snow,omitempty"` Snow bool `json:"snow,omitempty"`
Ice bool `json:"ice,omitempty"` Ice bool `json:"ice,omitempty"`
@@ -43,13 +85,70 @@ func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Outpu
return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil
} }
func exportDerivedDaypartSummariesPromptValue(value any) (any, error) {
rich, ok := value.(map[string]DerivedDaypartSummaryModule)
if !ok {
return nil, unexpectedPromptExportValue(value, map[string]DerivedDaypartSummaryModule{})
}
out := make(map[string]DerivedDaypartSummaryPromptExport, len(rich))
for key, daypart := range rich {
out[key] = derivedDaypartSummaryPromptValue(daypart)
}
return out, nil
}
func derivedDaypartSummaryPromptValue(rich DerivedDaypartSummaryModule) DerivedDaypartSummaryPromptExport {
maxPopTime := rich.MaxPopTime
if rich.MaxPopTimeLabel != "" {
maxPopTime = rich.MaxPopTimeLabel
}
return DerivedDaypartSummaryPromptExport{
Date: rich.Date,
DisplayName: rich.DisplayName,
PeriodBegins: rich.PeriodBegins,
PeriodEnds: rich.PeriodEnds,
TempRangeF: rich.TempRangeF,
ApparentTempRangeF: rich.ApparentTempRangeF,
MaxPopPercent: copyInt(rich.MaxPopPercent),
MaxPopTime: maxPopTime,
MentionPrecipitation: rich.MentionPrecipitation,
MaxWindGustMph: copyInt(rich.MaxWindGustMph),
MaxWindGustTime: rich.MaxWindGustTime,
DominantCondition: rich.DominantCondition,
TemperatureTrend: rich.TemperatureTrend,
TemperatureStartPhraseF: rich.TemperatureStartPhraseF,
TemperatureEndPhraseF: rich.TemperatureEndPhraseF,
TemperaturePeakPhraseF: rich.TemperaturePeakPhraseF,
TemperatureSteadyPhraseF: rich.TemperatureSteadyPhraseF,
NotableConditions: append([]string(nil), rich.NotableConditions...),
Snow: rich.Snow,
Ice: rich.Ice,
Fog: rich.Fog,
Heat: rich.Heat,
Cold: rich.Cold,
Wind: rich.Wind,
RelevantAlertCount: rich.RelevantAlertCount,
}
}
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule { func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
temperature := daypartTemperatureDisplay(daypart)
value := DerivedDaypartSummaryModule{ value := DerivedDaypartSummaryModule{
Date: localDateLabel(daypart.Period.Start, timezone), Date: localDateLabel(daypart.Period.Start, timezone),
Period: friendlyPeriodLabel(daypart.Period, timezone), DisplayName: titleWord(strings.TrimSpace(daypart.Name)),
PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone),
PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone),
TempRangeF: rangeLabel(daypart.Temperature), 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), ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
DominantCondition: daypart.DominantCondition, DominantCondition: daypart.DominantCondition,
DominantConditionLower: strings.ToLower(daypart.DominantCondition),
DominantConditionDisplay: sentenceCase(daypart.DominantCondition),
NotableConditions: append([]string(nil), daypart.NotableConditions...), NotableConditions: append([]string(nil), daypart.NotableConditions...),
Snow: daypart.Indicators.Snow, Snow: daypart.Indicators.Snow,
Ice: daypart.Indicators.Ice, Ice: daypart.Indicators.Ice,
@@ -62,6 +161,8 @@ func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string
if daypart.MaxPrecipitationProbability != nil { if daypart.MaxPrecipitationProbability != nil {
value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value) value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value)
value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone) 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 { if daypart.PeakWindGust != nil {
value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value) value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value)
@@ -70,6 +171,208 @@ func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string
return value 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 { func multipleSummaryDates(summaries []forecast.DailySummary) bool {
seen := map[string]struct{}{} seen := map[string]struct{}{}
for _, summary := range summaries { for _, summary := range summaries {

View File

@@ -16,7 +16,7 @@ import (
func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) { func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday) ctx := derivedModuleContext(report.Daily)
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary}) output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
if err != nil { if err != nil {
@@ -67,7 +67,7 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) { func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday) ctx := derivedModuleContext(report.Daily)
ctx.Derived.DailySummaries[0].NarrativePeriods = nil ctx.Derived.DailySummaries[0].NarrativePeriods = nil
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary}) output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
@@ -89,7 +89,7 @@ func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) { func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday) ctx := derivedModuleContext(report.Daily)
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming}) output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
if err != nil { if err != nil {
@@ -102,19 +102,31 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
if len(rainy.PrecipitationWindows) != 2 { if len(rainy.PrecipitationWindows) != 2 {
t.Fatalf("rainy precipitation windows = %#v, want two windows", rainy.PrecipitationWindows) t.Fatalf("rainy precipitation windows = %#v, want two windows", rainy.PrecipitationWindows)
} }
if rainy.PrecipitationWindows[0].Start != "8 AM" || rainy.PrecipitationWindows[0].End != "9 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 { if rainy.PrecipitationWindows[0].PeriodBegins != "2026-05-29 at 8:00 AM" || rainy.PrecipitationWindows[0].PeriodBeginsHourLabel != "8:00 AM" || rainy.PrecipitationWindows[0].PeriodEnds != "2026-05-29 at 9:00 AM" || rainy.PrecipitationWindows[0].PeriodEndsHourLabel != "9:00 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 || rainy.PrecipitationWindows[0].MaxPopHourLabel != "8:00 AM" {
t.Fatalf("first precipitation window = %#v, want 8-9 AM at 60%%", rainy.PrecipitationWindows[0]) t.Fatalf("first precipitation window = %#v, want 8-9 AM at 60%%", rainy.PrecipitationWindows[0])
} }
if rainy.PrecipitationWindows[1].Start != "12 PM" || rainy.PrecipitationWindows[1].End != "2 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 { if rainy.PrecipitationWindows[0].PrecipitationType != "showers" || rainy.PrecipitationWindows[0].ExpectationPhrase != "Showers likely." {
t.Fatalf("first precipitation window phrase = %#v, want showers likely", rainy.PrecipitationWindows[0])
}
if rainy.PrecipitationWindows[1].PeriodBegins != "2026-05-29 at 12:00 PM" || rainy.PrecipitationWindows[1].PeriodBeginsHourLabel != "12:00 PM" || rainy.PrecipitationWindows[1].PeriodEnds != "2026-05-29 at 2:00 PM" || rainy.PrecipitationWindows[1].PeriodEndsHourLabel != "2:00 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 || rainy.PrecipitationWindows[1].MaxPopHourLabel != "12:00 PM" {
t.Fatalf("second precipitation window = %#v, want noon-2 PM at 80%%", rainy.PrecipitationWindows[1]) t.Fatalf("second precipitation window = %#v, want noon-2 PM at 80%%", rainy.PrecipitationWindows[1])
} }
if rainy.PrecipitationWindows[1].PrecipitationType != "showers and thunderstorms" || rainy.PrecipitationWindows[1].ExpectationPhrase != "Expect showers and thunderstorms." {
t.Fatalf("second precipitation window phrase = %#v, want expect showers and thunderstorms", rainy.PrecipitationWindows[1])
}
data, err := json.Marshal(output.Value) data, err := json.Marshal(output.Value)
if err != nil { if err != nil {
t.Fatalf("marshal precip timing: %v", err) t.Fatalf("marshal precip timing: %v", err)
} }
if !strings.Contains(string(data), "precipitation_windows") || !strings.Contains(string(data), "probability_threshold") { if !strings.Contains(string(data), "precipitation_windows") || !strings.Contains(string(data), "probability_threshold") || !strings.Contains(string(data), "period_begins_hour_label") || !strings.Contains(string(data), "max_pop_hour_label") {
t.Fatalf("precip timing json = %s, want threshold and windows", string(data)) 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") { 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)) t.Fatalf("precip timing json = %s, want no ambiguous first/last fields", string(data))
} }
@@ -133,9 +145,119 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
} }
} }
func TestPrecipTimingModuleBuildsExpectationPhrases(t *testing.T) {
now := mustParseModuleTime("2026-05-29T08:00:00-05:00")
tests := []struct {
name string
maxPop float64
descriptions []string
wantType string
wantPhrase string
}{
{
name: "chance lower bound",
maxPop: 40,
descriptions: []string{"Scattered showers"},
wantType: "showers",
wantPhrase: "Chance of showers.",
},
{
name: "chance upper bound",
maxPop: 49,
descriptions: []string{"Rain possible"},
wantType: "rain",
wantPhrase: "Chance of rain.",
},
{
name: "likely lower bound",
maxPop: 50,
descriptions: []string{"Drizzle"},
wantType: "drizzle",
wantPhrase: "Drizzle likely.",
},
{
name: "likely upper bound",
maxPop: 69,
descriptions: []string{"Freezing rain"},
wantType: "freezing rain",
wantPhrase: "Freezing rain likely.",
},
{
name: "expect lower bound",
maxPop: 70,
descriptions: []string{"Snow"},
wantType: "snow",
wantPhrase: "Expect snow.",
},
{
name: "showers and thunderstorms preferred",
maxPop: 100,
descriptions: []string{"Showers likely", "Thunderstorms possible"},
wantType: "showers and thunderstorms",
wantPhrase: "Expect showers and thunderstorms.",
},
{
name: "thunderstorms only",
maxPop: 80,
descriptions: []string{"Thunderstorms"},
wantType: "thunderstorms",
wantPhrase: "Expect thunderstorms.",
},
{
name: "unknown fallback",
maxPop: 95,
descriptions: []string{"Unsettled conditions"},
wantType: "precipitation",
wantPhrase: "Expect precipitation.",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
value := precipTimingValue(forecast.PrecipTiming{
ProbabilityThreshold: 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) { func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday) ctx := derivedModuleContext(report.Daily)
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries}) output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
if err != nil { if err != nil {
@@ -150,14 +272,20 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 { if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 {
t.Fatalf("morning = %#v, want temp range and precip peak", morning) t.Fatalf("morning = %#v, want temp range and precip peak", morning)
} }
if morning.Date != "2026-05-29" || morning.Period != "2026-05-29 at 6:00 AM to 2026-05-29 at 12:00 PM" { 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 period = %q/%q, want friendly local date and period labels", morning.Date, morning.Period) 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"] afternoon := value["afternoon"]
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 { if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 {
t.Fatalf("afternoon = %#v, want heat and wind hazard values", afternoon) t.Fatalf("afternoon = %#v, want heat and wind hazard values", afternoon)
} }
overnight := value["overnight"]
if !overnight.Cold { if !overnight.Cold {
t.Fatalf("overnight = %#v, want cold hazard", overnight) t.Fatalf("overnight = %#v, want cold hazard", overnight)
} }
@@ -166,19 +294,236 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
t.Fatalf("marshal daypart summaries: %v", err) t.Fatalf("marshal daypart summaries: %v", err)
} }
jsonText := string(data) jsonText := string(data)
for _, field := range []string{"date", "period", "temp_range_f", "max_pop_percent", "max_wind_gust_mph", "dominant_condition"} { for _, field := range []string{"date", "display_name", "period_begins", "period_ends", "temp_range_f", "temperature_phrase_f", "temperature_trend", "temperature_steady_phrase_f", "max_pop_percent", "max_pop_time_label", "mention_precipitation", "max_wind_gust_mph", "dominant_condition", "dominant_condition_lower", "dominant_condition_display"} {
if !strings.Contains(jsonText, field) { if !strings.Contains(jsonText, field) {
t.Fatalf("daypart json = %s, want field %s", jsonText, field) t.Fatalf("daypart json = %s, want field %s", jsonText, field)
} }
} }
if strings.Contains(jsonText, `"period":{"start"`) || strings.Contains(jsonText, `T06:00:00`) { if strings.Contains(jsonText, `"period":`) || strings.Contains(jsonText, `T06:00:00`) {
t.Fatalf("daypart json = %s, want friendly period label instead of raw timestamps", jsonText) 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) { func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyTomorrow) ctx := derivedModuleContext(report.Tomorrow)
outdoorOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows}) outdoorOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows})
if err != nil { if err != nil {
@@ -191,6 +536,12 @@ func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing
if outdoor.Best.Daypart != "overnight" || outdoor.Worst.Daypart != "afternoon" { if outdoor.Best.Daypart != "overnight" || outdoor.Worst.Daypart != "afternoon" {
t.Fatalf("outdoor windows = %#v, want quiet overnight and stormy afternoon", outdoor) 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}) planningOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TomorrowPlanning})
if err != nil { if err != nil {
@@ -209,9 +560,154 @@ func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing
} }
} }
func TestDailyPlanningModulePackagesPlanningFields(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := dailyModuleContext()
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning})
if err != nil {
t.Fatalf("BuildModule(daily planning) error = %v", err)
}
if output.ID != module.DailyPlanning || output.StanzaName != "daily_planning" {
t.Fatalf("output = %#v, want daily planning stanza", output)
}
planning := moduleValue[DailyPlanningModule](t, output)
if len(planning.MorningReadiness) == 0 ||
len(planning.CommuteSchoolWorkdayConcerns) == 0 ||
len(planning.OvernightChangeWatch) == 0 {
t.Fatalf("daily planning = %#v, want populated planning fields", planning)
}
if !containsString(planning.MorningReadiness, "Morning precipitation chance peaks near 60%.") {
t.Fatalf("MorningReadiness = %#v, want precipitation readiness note", planning.MorningReadiness)
}
if !containsString(planning.CommuteSchoolWorkdayConcerns, "Afternoon alert overlap needs attention.") {
t.Fatalf("CommuteSchoolWorkdayConcerns = %#v, want alert-overlap concern", planning.CommuteSchoolWorkdayConcerns)
}
if !containsString(planning.OvernightChangeWatch, "Watch for forecast timing or intensity adjustments overnight.") {
t.Fatalf("OvernightChangeWatch = %#v, want overnight fallback note", planning.OvernightChangeWatch)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("marshal daily planning: %v", err)
}
jsonText := string(data)
for _, field := range []string{"morning_readiness", "commute_school_workday_concerns", "overnight_change_watch"} {
if !strings.Contains(jsonText, field) {
t.Fatalf("daily planning json = %s, want field %s", jsonText, field)
}
}
if strings.Contains(jsonText, "tomorrow_planning") || strings.Contains(jsonText, "morningReadiness") {
t.Fatalf("daily planning json = %s, want daily snake_case fields only", jsonText)
}
}
func TestDailyPlanningModuleRejectsUnsupportedReports(t *testing.T) {
registry := MustDefaultModuleRegistry()
for _, id := range []report.ID{report.Today, report.Tomorrow, report.Hourly, 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) { func TestDerivedModulesHandleMissingData(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday) ctx := derivedModuleContext(report.Daily)
ctx.Derived.DailySummaries = nil ctx.Derived.DailySummaries = nil
ctx.Derived.DaypartSummaries = nil ctx.Derived.DaypartSummaries = nil
@@ -302,6 +798,26 @@ func derivedModuleContext(id report.ID) ModuleContext {
} }
} }
func todayModuleContext() ModuleContext {
ctx := derivedModuleContext(report.Daily)
ctx.Resolved.Definition = report.Definition{
ID: report.Today,
Name: "Today Report",
PromptID: "weather.today_generated_text",
}
return ctx
}
func dailyModuleContext() ModuleContext {
ctx := derivedModuleContext(report.Tomorrow)
ctx.Resolved.Definition = report.Definition{
ID: report.Daily,
Name: "Daily Report",
PromptID: "weather.daily_generated_text",
}
return ctx
}
func derivedDaypart(name string, start string, end string, text string, temperature float64, apparent *float64, precip float64, gust float64) forecast.DaypartSummary { func derivedDaypart(name string, start string, end string, text string, temperature float64, apparent *float64, precip float64, gust float64) forecast.DaypartSummary {
hour := derivedHour(start, text, precip, temperature, apparent, gust) hour := derivedHour(start, text, precip, temperature, apparent, gust)
return forecast.SummarizeDaypart(name, timeutil.Period{ return forecast.SummarizeDaypart(name, timeutil.Period{
@@ -310,6 +826,24 @@ func derivedDaypart(name string, start string, end string, text string, temperat
}, []weatherdata.ForecastPeriod{hour}) }, []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 { func derivedHour(start string, text string, precip float64, temperature float64, apparent *float64, gust float64) weatherdata.ForecastPeriod {
startTime := mustParseModuleTime(start) startTime := mustParseModuleTime(start)
endTime := startTime.Add(time.Hour) endTime := startTime.Add(time.Hour)

View File

@@ -1,12 +1,16 @@
package briefing package briefing
import ( import (
"strings"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
const DefaultHourlyForecastPrecipMentionProbabilityThreshold = 20
type HourlyForecastModule struct { type HourlyForecastModule struct {
Product string `json:"product,omitempty"` Product string `json:"product,omitempty"`
IssuedAt time.Time `json:"issued_at,omitempty"` IssuedAt time.Time `json:"issued_at,omitempty"`
@@ -16,9 +20,57 @@ type HourlyForecastModule struct {
Periods []HourlyForecastPeriod `json:"periods,omitempty"` Periods []HourlyForecastPeriod `json:"periods,omitempty"`
} }
type HourlyForecastPromptExport struct {
Product string `json:"product,omitempty"`
IssuedAt time.Time `json:"issued_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
SourceLocation string `json:"source_location,omitempty"`
SourceLocationID string `json:"source_location_id,omitempty"`
Periods []HourlyForecastPromptPeriod `json:"periods,omitempty"`
}
type HourlyForecastPeriod struct { type HourlyForecastPeriod struct {
StartTime string `json:"start_time,omitempty"` HourLabel string `json:"hour_label,omitempty"`
EndTime string `json:"end_time,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"` Name string `json:"name,omitempty"`
IsDay *bool `json:"is_day,omitempty"` IsDay *bool `json:"is_day,omitempty"`
ConditionCode *int `json:"condition_code,omitempty"` ConditionCode *int `json:"condition_code,omitempty"`
@@ -71,12 +123,30 @@ func buildHourlyForecastModule(ctx ModuleContext, _ any) (*module.Output, error)
return &module.Output{ID: module.HourlyForecast, StanzaName: "hourly_forecast", Value: value}, nil return &module.Output{ID: module.HourlyForecast, StanzaName: "hourly_forecast", Value: value}, nil
} }
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod { func exportHourlyForecastPromptValue(value any) (any, error) {
out := make([]HourlyForecastPeriod, 0, len(periods)) 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 { for _, period := range periods {
out = append(out, HourlyForecastPeriod{ out = append(out, HourlyForecastPromptPeriod{
StartTime: friendlyDateTimeLabel(period.StartTime, timezone), PeriodBegins: period.PeriodBegins,
EndTime: friendlyDateTimeLabel(period.EndTime, timezone), PeriodEnds: period.PeriodEnds,
Name: period.Name, Name: period.Name,
IsDay: copyBool(period.IsDay), IsDay: copyBool(period.IsDay),
ConditionCode: copyInt(period.ConditionCode), ConditionCode: copyInt(period.ConditionCode),
@@ -93,6 +163,55 @@ func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string
WindSpeedMph: copyFloat(period.WindSpeedMph), WindSpeedMph: copyFloat(period.WindSpeedMph),
WindGustKmh: copyFloat(period.WindGustKmh), WindGustKmh: copyFloat(period.WindGustKmh),
WindGustMph: copyFloat(period.WindGustMph), 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), WindDirection: windDirectionLabel(period.WindDirectionDegrees),
BarometricPressurePa: copyFloat(period.BarometricPressurePa), BarometricPressurePa: copyFloat(period.BarometricPressurePa),
BarometricPressureInHg: copyFloat(period.BarometricPressureInHg), BarometricPressureInHg: copyFloat(period.BarometricPressureInHg),
@@ -102,6 +221,7 @@ func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string
ApparentTemperatureF: copyFloat(period.ApparentTemperatureF), ApparentTemperatureF: copyFloat(period.ApparentTemperatureF),
CloudCoverPercent: copyFloat(period.CloudCoverPercent), CloudCoverPercent: copyFloat(period.CloudCoverPercent),
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent), ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
MentionPrecipitation: mentionHourlyForecastPrecipitation(period.ProbabilityOfPrecipitationPercent, threshold),
PrecipitationAmountMm: copyFloat(period.PrecipitationAmountMm), PrecipitationAmountMm: copyFloat(period.PrecipitationAmountMm),
PrecipitationAmountIn: copyFloat(period.PrecipitationAmountIn), PrecipitationAmountIn: copyFloat(period.PrecipitationAmountIn),
SnowfallDepthMM: copyFloat(period.SnowfallDepthMM), SnowfallDepthMM: copyFloat(period.SnowfallDepthMM),
@@ -113,6 +233,10 @@ func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string
return out return out
} }
func mentionHourlyForecastPrecipitation(probability *float64, threshold float64) bool {
return probability != nil && *probability >= threshold
}
func (v HourlyForecastModule) isEmpty() bool { func (v HourlyForecastModule) isEmpty() bool {
return v.Product == "" && return v.Product == "" &&
v.IssuedAt.IsZero() && v.IssuedAt.IsZero() &&

View File

@@ -44,7 +44,7 @@ func buildMetadataModule(ctx ModuleContext, _ any) (*module.Output, error) {
ValidPeriod: metadata.ValidPeriod, ValidPeriod: metadata.ValidPeriod,
Location: copyLocation(ctx.Location), Location: copyLocation(ctx.Location),
SourceWarnings: sourceWarningSummaries(ctx.Collected.SourceWarnings), SourceWarnings: sourceWarningSummaries(ctx.Collected.SourceWarnings),
Alerts: alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps), Alerts: alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps, ctx.Timezone),
} }
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil
} }

View File

@@ -60,6 +60,36 @@ func windDirectionLabel(degrees *float64) string {
return labels[sector] 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 { func timedClockLabel(value *forecast.TimedValue, timezone string) string {
if value == nil { if value == nil {
return "" return ""
@@ -67,18 +97,18 @@ func timedClockLabel(value *forecast.TimedValue, timezone string) string {
return clockLabel(value.Time, timezone) return clockLabel(value.Time, timezone)
} }
func periodClockLabel(period timeutil.Period, timezone string) string { func friendlyPeriodBeginsLabel(period timeutil.Period, timezone string) string {
if !period.IsValid() { if !period.IsValid() {
return "" return ""
} }
return clockLabel(period.Start, timezone) + "-" + clockLabel(period.End, timezone) return friendlyDateTimeLabel(period.Start, timezone)
} }
func friendlyPeriodLabel(period timeutil.Period, timezone string) string { func friendlyPeriodEndsLabel(period timeutil.Period, timezone string) string {
if !period.IsValid() { if !period.IsValid() {
return "" return ""
} }
return friendlyDateTimeLabel(period.Start, timezone) + " to " + friendlyDateTimeLabel(period.End, timezone) return friendlyDateTimeLabel(period.End, timezone)
} }
func friendlyDateTimeLabel(value time.Time, timezone string) string { func friendlyDateTimeLabel(value time.Time, timezone string) string {
@@ -92,6 +122,17 @@ func friendlyDateTimeLabel(value time.Time, timezone string) string {
return value.In(location).Format("2006-01-02 at 3:04 PM") return value.In(location).Format("2006-01-02 at 3:04 PM")
} }
func friendlyMonthDayTimeLabel(value time.Time, timezone string) string {
if value.IsZero() {
return ""
}
location, err := timeutil.LoadLocation(timezone)
if err != nil {
location = time.UTC
}
return value.In(location).Format("January 2 at 3:04 PM")
}
func friendlyDateLabel(date string, timezone string) string { func friendlyDateLabel(date string, timezone string) string {
location, err := timeutil.LoadLocation(timezone) location, err := timeutil.LoadLocation(timezone)
if err != nil { if err != nil {
@@ -126,3 +167,11 @@ func clockLabel(value time.Time, timezone string) string {
} }
return label 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

@@ -27,3 +27,25 @@ func TestWindDirectionLabelUsesSixteenPointCompass(t *testing.T) {
}) })
} }
} }
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

@@ -21,6 +21,8 @@ type ModuleContext struct {
type ModuleBuilder func(ModuleContext, any) (*module.Output, error) type ModuleBuilder func(ModuleContext, any) (*module.Output, error)
type ModulePromptExporter func(value any) (any, error)
type ModuleDefinition struct { type ModuleDefinition struct {
ID module.ID ID module.ID
StanzaName string StanzaName string
@@ -31,12 +33,17 @@ type ModuleDefinition struct {
MissingData module.MissingDataBehavior MissingData module.MissingDataBehavior
AllowDuplicate bool AllowDuplicate bool
Builder ModuleBuilder Builder ModuleBuilder
PromptExporter ModulePromptExporter
} }
type ModuleRegistry struct { type ModuleRegistry struct {
definitions map[module.ID]ModuleDefinition definitions map[module.ID]ModuleDefinition
} }
func unexpectedPromptExportValue(got any, want any) error {
return fmt.Errorf("value has type %T, want %T", got, want)
}
func DefaultModuleRegistry() (ModuleRegistry, error) { func DefaultModuleRegistry() (ModuleRegistry, error) {
return NewModuleRegistry(defaultModuleDefinitions()) return NewModuleRegistry(defaultModuleDefinitions())
} }
@@ -130,6 +137,15 @@ func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (
if output.StanzaName != definition.StanzaName { if output.StanzaName != definition.StanzaName {
return nil, fmt.Errorf("module %q produced stanza %q, want %q", definition.ID, output.StanzaName, definition.StanzaName) return nil, fmt.Errorf("module %q produced stanza %q, want %q", definition.ID, output.StanzaName, definition.StanzaName)
} }
if definition.PromptExporter == nil {
output.PromptValue = output.Value
return output, nil
}
promptValue, err := definition.PromptExporter(output.Value)
if err != nil {
return nil, fmt.Errorf("module %q stanza %q prompt export: %w", definition.ID, definition.StanzaName, err)
}
output.PromptValue = promptValue
return output, nil return output, nil
} }
@@ -162,6 +178,8 @@ func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContex
return ctx.Collected.Discussion != nil return ctx.Collected.Discussion != nil
case module.CollectedWeatherStory: case module.CollectedWeatherStory:
return ctx.Collected.WeatherStory != nil return ctx.Collected.WeatherStory != nil
case module.CollectedSPCConvectiveOutlooks:
return ctx.Collected.SPCConvectiveOutlooks != nil
case module.CollectedSourceMetadata: case module.CollectedSourceMetadata:
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0 return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
default: default:
@@ -183,6 +201,8 @@ func derivedFactAvailable(requirement module.FactRequirement, ctx ModuleContext)
return len(ctx.Derived.DaypartSummaries) > 0 return len(ctx.Derived.DaypartSummaries) > 0
case module.RequiresDerivedPrecipTiming: case module.RequiresDerivedPrecipTiming:
return true return true
case module.RequiresDerivedSPCConvectiveOutlooks:
return ctx.Derived.SPCConvectiveOutlooks != nil
default: default:
return false return false
} }
@@ -245,8 +265,8 @@ func (d ModuleDefinition) ValidateOptions(options any) error {
} }
func defaultModuleDefinitions() []ModuleDefinition { func defaultModuleDefinitions() []ModuleDefinition {
allReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend, report.Storm} allReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm}
daypartReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend} daypartReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.ThreeDay, report.Weekend}
return []ModuleDefinition{ return []ModuleDefinition{
{ {
ID: module.Metadata, ID: module.Metadata,
@@ -265,6 +285,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
SupportedReports: allReports, SupportedReports: allReports,
MissingData: module.MissingDataOmit, MissingData: module.MissingDataOmit,
Builder: buildCurrentConditionsModule, Builder: buildCurrentConditionsModule,
PromptExporter: exportCurrentConditionsPromptValue,
}, },
{ {
ID: module.NarrativeForecast, ID: module.NarrativeForecast,
@@ -272,7 +293,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
DefaultOptions: module.NarrativeForecastOptions{}, DefaultOptions: module.NarrativeForecastOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast}, RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods}, RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods},
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow}, SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
MissingData: module.MissingDataOmit, MissingData: module.MissingDataOmit,
Builder: buildNarrativeForecastModule, Builder: buildNarrativeForecastModule,
}, },
@@ -282,16 +303,17 @@ func defaultModuleDefinitions() []ModuleDefinition {
DefaultOptions: module.HourlyForecastOptions{}, DefaultOptions: module.HourlyForecastOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast}, RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods}, RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow}, SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly},
MissingData: module.MissingDataOmit, MissingData: module.MissingDataOmit,
Builder: buildHourlyForecastModule, Builder: buildHourlyForecastModule,
PromptExporter: exportHourlyForecastPromptValue,
}, },
{ {
ID: module.DerivedDailySummary, ID: module.DerivedDailySummary,
StanzaName: "derived_daily_summary", StanzaName: "derived_daily_summary",
DefaultOptions: module.DerivedDailySummaryOptions{}, DefaultOptions: module.DerivedDailySummaryOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming}, RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming},
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow}, SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
MissingData: module.MissingDataError, MissingData: module.MissingDataError,
Builder: buildDerivedDailySummaryModule, Builder: buildDerivedDailySummaryModule,
}, },
@@ -303,12 +325,13 @@ func defaultModuleDefinitions() []ModuleDefinition {
SupportedReports: daypartReports, SupportedReports: daypartReports,
MissingData: module.MissingDataError, MissingData: module.MissingDataError,
Builder: buildDerivedDaypartSummariesModule, Builder: buildDerivedDaypartSummariesModule,
PromptExporter: exportDerivedDaypartSummariesPromptValue,
}, },
{ {
ID: module.PrecipTiming, ID: module.PrecipTiming,
StanzaName: "precip_timing", StanzaName: "precip_timing",
DefaultOptions: module.PrecipTimingOptions{}, DefaultOptions: module.PrecipTimingOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries}, RequiredDerived: []module.FactRequirement{module.RequiresDerivedPrecipTiming},
SupportedReports: allReports, SupportedReports: allReports,
MissingData: module.MissingDataEmpty, MissingData: module.MissingDataEmpty,
Builder: buildPrecipTimingModule, Builder: buildPrecipTimingModule,
@@ -323,6 +346,16 @@ func defaultModuleDefinitions() []ModuleDefinition {
MissingData: module.MissingDataEmpty, MissingData: module.MissingDataEmpty,
Builder: buildAlertDigestModule, 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, ID: module.AreaForecastDiscussion,
StanzaName: "area_forecast_discussion", StanzaName: "area_forecast_discussion",
@@ -332,6 +365,16 @@ func defaultModuleDefinitions() []ModuleDefinition {
MissingData: module.MissingDataOmit, MissingData: module.MissingDataOmit,
Builder: buildAreaForecastDiscussionModule, 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, ID: module.WeatherStory,
StanzaName: "weather_story", StanzaName: "weather_story",
@@ -350,14 +393,32 @@ func defaultModuleDefinitions() []ModuleDefinition {
MissingData: module.MissingDataEmpty, MissingData: module.MissingDataEmpty,
Builder: buildOutdoorWindowsModule, Builder: buildOutdoorWindowsModule,
}, },
{
ID: module.TodayPlanning,
StanzaName: "today_planning",
DefaultOptions: module.TodayPlanningOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
SupportedReports: []report.ID{report.Today},
MissingData: module.MissingDataEmpty,
Builder: buildTodayPlanningModule,
},
{ {
ID: module.TomorrowPlanning, ID: module.TomorrowPlanning,
StanzaName: "tomorrow_planning", StanzaName: "tomorrow_planning",
DefaultOptions: module.TomorrowPlanningOptions{}, DefaultOptions: module.TomorrowPlanningOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries}, RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
SupportedReports: []report.ID{report.DailyTomorrow}, SupportedReports: []report.ID{report.Tomorrow},
MissingData: module.MissingDataEmpty, MissingData: module.MissingDataEmpty,
Builder: buildTomorrowPlanningModule, Builder: buildTomorrowPlanningModule,
}, },
{
ID: module.DailyPlanning,
StanzaName: "daily_planning",
DefaultOptions: module.DailyPlanningOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
SupportedReports: []report.ID{report.Daily},
MissingData: module.MissingDataEmpty,
Builder: buildDailyPlanningModule,
},
} }
} }

View File

@@ -1,11 +1,15 @@
package briefing package briefing
import ( import (
"encoding/json"
"errors"
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) { func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) {
@@ -38,6 +42,9 @@ func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
t.Fatalf("BuildModule(%s) error = %v", item.ID, err) t.Fatalf("BuildModule(%s) error = %v", item.ID, err)
} }
if output != nil { if output != nil {
if output.DataPackageValue() == nil {
t.Fatalf("BuildModule(%s) data package value = nil", item.ID)
}
outputs = append(outputs, *output) outputs = append(outputs, *output)
} }
} }
@@ -52,9 +59,266 @@ func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
} }
} }
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) { func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.ID("unknown")}}) err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.ID("unknown")}})
if err == nil || !strings.Contains(err.Error(), `unknown module "unknown"`) { if err == nil || !strings.Contains(err.Error(), `unknown module "unknown"`) {
t.Fatalf("error = %v, want unknown module", err) t.Fatalf("error = %v, want unknown module", err)
} }
@@ -62,7 +326,7 @@ func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
func TestModuleRegistryRejectsDuplicateModuleIDs(t *testing.T) { func TestModuleRegistryRejectsDuplicateModuleIDs(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{ err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
{ID: module.Metadata}, {ID: module.Metadata},
{ID: module.Metadata}, {ID: module.Metadata},
}) })
@@ -83,12 +347,84 @@ func TestModuleRegistryRejectsDuplicateStanzaNames(t *testing.T) {
func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) { func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.TomorrowPlanning}}) err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.TomorrowPlanning}})
if err == nil || !strings.Contains(err.Error(), `module "tomorrow_planning" is not compatible with report "daily_today"`) { if err == nil || !strings.Contains(err.Error(), `module "tomorrow_planning" is not compatible with report "daily"`) {
t.Fatalf("error = %v, want incompatible report", err) t.Fatalf("error = %v, want incompatible report", err)
} }
} }
func TestModuleRegistryValidatesTodayPlanningSupport(t *testing.T) {
registry := MustDefaultModuleRegistry()
if err := registry.ValidateComposition(report.Today, []module.ConfigItem{{ID: module.TodayPlanning}}); err != nil {
t.Fatalf("ValidateComposition(today) error = %v", err)
}
for _, id := range []report.ID{report.Tomorrow, report.Daily} {
t.Run(string(id), func(t *testing.T) {
err := registry.ValidateComposition(id, []module.ConfigItem{{ID: module.TodayPlanning}})
if err == nil || !strings.Contains(err.Error(), `module "today_planning" is not compatible with report`) {
t.Fatalf("ValidateComposition(%s) error = %v, want incompatible report", id, err)
}
})
}
}
func TestModuleRegistryValidatesDailyPlanningSupport(t *testing.T) {
registry := MustDefaultModuleRegistry()
if err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.DailyPlanning}}); err != nil {
t.Fatalf("ValidateComposition(daily) error = %v", err)
}
for _, id := range []report.ID{report.Today, report.Tomorrow, report.Hourly, 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) { func TestModuleRegistryRejectsDefinitionsWithoutBuilders(t *testing.T) {
_, err := NewModuleRegistry([]ModuleDefinition{ _, err := NewModuleRegistry([]ModuleDefinition{
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}}, {ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}},
@@ -109,7 +445,7 @@ func TestModuleRegistryRejectsUnsupportedMissingDataWarn(t *testing.T) {
func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) { func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{ err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
{ID: module.Metadata, Options: module.CurrentConditionsOptions{}}, {ID: module.Metadata, Options: module.CurrentConditionsOptions{}},
}) })
if err == nil || !strings.Contains(err.Error(), `module "metadata" options have type module.CurrentConditionsOptions, want module.MetadataOptions`) { if err == nil || !strings.Contains(err.Error(), `module "metadata" options have type module.CurrentConditionsOptions, want module.MetadataOptions`) {
@@ -119,7 +455,7 @@ func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) {
func TestModuleRegistryAcceptsTypedOptions(t *testing.T) { func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{ err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
{ID: module.Metadata, Options: module.MetadataOptions{}}, {ID: module.Metadata, Options: module.MetadataOptions{}},
{ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}}, {ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}},
}) })
@@ -128,6 +464,42 @@ func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
} }
} }
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) { func noopModuleBuilder(ModuleContext, any) (*module.Output, error) {
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil
} }
type testRegistryValue struct {
Message string `json:"message"`
}
func testRegistryModuleContext() ModuleContext {
return ModuleContext{
Resolved: report.Resolved{
Definition: report.DefaultRegistry().MustLookup(report.Daily),
},
}
}

View File

@@ -4,6 +4,7 @@ import (
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
@@ -18,8 +19,8 @@ type NarrativeForecastModule struct {
type NarrativeForecastPeriod struct { type NarrativeForecastPeriod struct {
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
StartTime string `json:"start_time,omitempty"` PeriodBegins string `json:"period_begins,omitempty"`
EndTime string `json:"end_time,omitempty"` PeriodEnds string `json:"period_ends,omitempty"`
IsDay *bool `json:"is_day,omitempty"` IsDay *bool `json:"is_day,omitempty"`
TextDescription string `json:"text_description,omitempty"` TextDescription string `json:"text_description,omitempty"`
TemperatureC *float64 `json:"temperature_c,omitempty"` TemperatureC *float64 `json:"temperature_c,omitempty"`
@@ -58,10 +59,11 @@ func buildNarrativeForecastModule(ctx ModuleContext, _ any) (*module.Output, err
func narrativeForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []NarrativeForecastPeriod { func narrativeForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []NarrativeForecastPeriod {
out := make([]NarrativeForecastPeriod, 0, len(periods)) out := make([]NarrativeForecastPeriod, 0, len(periods))
for _, period := range periods { for _, period := range periods {
validPeriod := timeutil.Period{Start: period.StartTime, End: period.EndTime}
out = append(out, NarrativeForecastPeriod{ out = append(out, NarrativeForecastPeriod{
Name: period.Name, Name: period.Name,
StartTime: friendlyDateTimeLabel(period.StartTime, timezone), PeriodBegins: friendlyPeriodBeginsLabel(validPeriod, timezone),
EndTime: friendlyDateTimeLabel(period.EndTime, timezone), PeriodEnds: friendlyPeriodEndsLabel(validPeriod, timezone),
IsDay: copyBool(period.IsDay), IsDay: copyBool(period.IsDay),
TextDescription: period.TextDescription, TextDescription: period.TextDescription,
TemperatureC: copyFloat(period.TemperatureC), TemperatureC: copyFloat(period.TemperatureC),

View File

@@ -9,8 +9,8 @@ type OutdoorWindowsModule struct {
type OutdoorWindowModule struct { type OutdoorWindowModule struct {
Daypart string `json:"daypart"` Daypart string `json:"daypart"`
Start string `json:"start"` PeriodBegins string `json:"period_begins,omitempty"`
End string `json:"end"` PeriodEnds string `json:"period_ends,omitempty"`
Reasons []string `json:"reasons,omitempty"` Reasons []string `json:"reasons,omitempty"`
Score float64 `json:"score"` Score float64 `json:"score"`
} }
@@ -18,20 +18,20 @@ type OutdoorWindowModule struct {
func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) { func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) {
windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries) windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries)
value := OutdoorWindowsModule{ value := OutdoorWindowsModule{
Best: outdoorWindowValue(windows.Best), Best: outdoorWindowValue(windows.Best, ctx.Timezone),
Worst: outdoorWindowValue(windows.Worst), Worst: outdoorWindowValue(windows.Worst, ctx.Timezone),
} }
return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil
} }
func outdoorWindowValue(window *OutdoorWindow) *OutdoorWindowModule { func outdoorWindowValue(window *OutdoorWindow, timezone string) *OutdoorWindowModule {
if window == nil { if window == nil {
return nil return nil
} }
return &OutdoorWindowModule{ return &OutdoorWindowModule{
Daypart: window.Daypart, Daypart: window.Daypart,
Start: window.Start, PeriodBegins: friendlyPeriodBeginsLabel(window.Period, timezone),
End: window.End, PeriodEnds: friendlyPeriodEndsLabel(window.Period, timezone),
Reasons: append([]string(nil), window.Reasons...), Reasons: append([]string(nil), window.Reasons...),
Score: window.Score, Score: window.Score,
} }

View File

@@ -185,9 +185,9 @@ func alertStatus(bundle *weatherdata.Bundle) *AlertStatus {
func variantForReport(id report.ID) string { func variantForReport(id report.ID) string {
switch id { switch id {
case report.DailyToday: case report.Daily, report.Today:
return "today" return "today"
case report.DailyTomorrow: case report.Tomorrow:
return "tomorrow" return "tomorrow"
default: default:
return "" return ""

View File

@@ -1,10 +1,19 @@
package briefing package briefing
import ( import (
"fmt"
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
) )
const (
precipTimingChanceLowerBound = 40
precipTimingLikelyLowerBound = 50
precipTimingExpectLowerBound = 70
)
type PrecipTimingModule struct { type PrecipTimingModule struct {
MaxPopPercent *int `json:"max_pop_percent,omitempty"` MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"` MaxPopTime string `json:"max_pop_time,omitempty"`
@@ -14,10 +23,15 @@ type PrecipTimingModule struct {
} }
type PrecipitationWindowModule struct { type PrecipitationWindowModule struct {
Start string `json:"start"` PeriodBegins string `json:"period_begins"`
End string `json:"end,omitempty"` 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"` MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"` MaxPopTime string `json:"max_pop_time,omitempty"`
MaxPopHourLabel string `json:"max_pop_hour_label,omitempty"`
PrecipitationType string `json:"precipitation_type,omitempty"`
ExpectationPhrase string `json:"expectation_phrase,omitempty"`
} }
func buildPrecipTimingModule(ctx ModuleContext, _ any) (*module.Output, error) { func buildPrecipTimingModule(ctx ModuleContext, _ any) (*module.Output, error) {
@@ -36,14 +50,60 @@ func precipTimingValue(timing forecast.PrecipTiming, timezone string) PrecipTimi
} }
for _, window := range timing.PrecipitationWindows { for _, window := range timing.PrecipitationWindows {
item := PrecipitationWindowModule{ item := PrecipitationWindowModule{
Start: clockLabel(window.Start, timezone), PeriodBegins: friendlyDateTimeLabel(window.Start, timezone),
PeriodBeginsHourLabel: hourMinuteLabel(window.Start, timezone),
} }
if window.End != nil { if window.End != nil {
item.End = clockLabel(*window.End, timezone) item.PeriodEnds = friendlyDateTimeLabel(*window.End, timezone)
item.PeriodEndsHourLabel = hourMinuteLabel(*window.End, timezone)
} }
item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value) item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value)
item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone) item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone)
item.MaxPopHourLabel = hourMinuteLabel(window.MaxPrecipitationProbability.Time, timezone)
item.PrecipitationType = precipitationWindowType(window.TextDescriptions)
if item.MaxPopPercent != nil {
item.ExpectationPhrase = precipitationWindowExpectationPhrase(*item.MaxPopPercent, item.PrecipitationType)
}
value.PrecipitationWindows = append(value.PrecipitationWindows, item) value.PrecipitationWindows = append(value.PrecipitationWindows, item)
} }
return value return value
} }
func precipitationWindowType(descriptions []string) string {
combined := strings.ToLower(strings.Join(descriptions, " "))
switch {
case (strings.Contains(combined, "thunderstorm") || strings.Contains(combined, "t-storm")) &&
(strings.Contains(combined, "shower") || strings.Contains(combined, "rain")):
return "showers and thunderstorms"
case strings.Contains(combined, "freezing rain"):
return "freezing rain"
case strings.Contains(combined, "thunderstorm") || strings.Contains(combined, "t-storm"):
return "thunderstorms"
case strings.Contains(combined, "shower"):
return "showers"
case strings.Contains(combined, "snow"):
return "snow"
case strings.Contains(combined, "drizzle"):
return "drizzle"
case strings.Contains(combined, "rain"):
return "rain"
default:
return "precipitation"
}
}
func precipitationWindowExpectationPhrase(maxPopPercent int, precipitationType string) string {
if precipitationType == "" {
precipitationType = "precipitation"
}
switch {
case maxPopPercent >= precipTimingExpectLowerBound:
return fmt.Sprintf("Expect %s.", precipitationType)
case maxPopPercent >= precipTimingLikelyLowerBound:
return fmt.Sprintf("%s likely.", sentenceCase(precipitationType))
case maxPopPercent >= precipTimingChanceLowerBound:
return fmt.Sprintf("Chance of %s.", precipitationType)
default:
return ""
}
}

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

@@ -7,6 +7,7 @@ import (
"strings" "strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
) )
type OutdoorWindows struct { type OutdoorWindows struct {
@@ -16,8 +17,7 @@ type OutdoorWindows struct {
type OutdoorWindow struct { type OutdoorWindow struct {
Daypart string Daypart string
Start string Period timeutil.Period
End string
Reasons []string Reasons []string
Score float64 Score float64
} }
@@ -28,6 +28,19 @@ type TomorrowPlanning struct {
OvernightChangeWatch []string OvernightChangeWatch []string
} }
type morningCommuteOvernightPlanning struct {
MorningReadiness []string
CommuteSchoolWorkdayConcerns []string
OvernightChangeWatch []string
}
type TodayPlanning struct {
MorningReadiness []string
CommuteSchoolWorkdayConcerns []string
OutdoorPlanning []string
LateDayChangeWatch []string
}
func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows { func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
var best *OutdoorWindow var best *OutdoorWindow
var worst *OutdoorWindow var worst *OutdoorWindow
@@ -48,8 +61,60 @@ func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
return OutdoorWindows{Best: best, Worst: worst} return OutdoorWindows{Best: best, Worst: worst}
} }
func buildTodayPlanning(summary *forecast.DailySummary) *TodayPlanning {
planning := &TodayPlanning{}
morning := daypartNamed(summary.Dayparts, "morning")
if morning != nil {
planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...)
}
if len(planning.MorningReadiness) == 0 {
planning.MorningReadiness = append(planning.MorningReadiness, "Morning weather looks routine based on the available hourly forecast.")
}
for _, daypart := range summary.Dayparts {
if daypart.Name == "overnight" || daypart.Name == "evening" {
continue
}
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, concernNotes(daypart)...)
}
for _, alert := range summary.AlertOverlaps {
if alert.Event != "" {
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, "Active alert to plan around: "+alert.Event+".")
}
}
if len(planning.CommuteSchoolWorkdayConcerns) == 0 {
planning.CommuteSchoolWorkdayConcerns = append(planning.CommuteSchoolWorkdayConcerns, "No major commute, school, or workday weather concerns stand out in the available forecast.")
}
planning.OutdoorPlanning = append(planning.OutdoorPlanning, outdoorPlanningNotes(summary.Dayparts)...)
if len(planning.OutdoorPlanning) == 0 {
planning.OutdoorPlanning = append(planning.OutdoorPlanning, "No standout outdoor weather constraints are evident in the available forecast.")
}
for _, name := range []string{"afternoon", "evening"} {
daypart := daypartNamed(summary.Dayparts, name)
if daypart != nil {
planning.LateDayChangeWatch = appendUnique(planning.LateDayChangeWatch, lateDayWatchNotes(*daypart)...)
}
}
if len(planning.LateDayChangeWatch) == 0 {
planning.LateDayChangeWatch = append(planning.LateDayChangeWatch, "Watch for forecast timing or intensity adjustments later today.")
}
return planning
}
func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning { func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning {
planning := &TomorrowPlanning{} base := buildMorningCommuteOvernightPlanning(summary)
return &TomorrowPlanning{
MorningReadiness: append([]string(nil), base.MorningReadiness...),
CommuteSchoolWorkdayConcerns: append([]string(nil), base.CommuteSchoolWorkdayConcerns...),
OvernightChangeWatch: append([]string(nil), base.OvernightChangeWatch...),
}
}
func buildMorningCommuteOvernightPlanning(summary *forecast.DailySummary) *morningCommuteOvernightPlanning {
planning := &morningCommuteOvernightPlanning{}
morning := daypartNamed(summary.Dayparts, "morning") morning := daypartNamed(summary.Dayparts, "morning")
if morning != nil { if morning != nil {
planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...) planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...)
@@ -84,6 +149,18 @@ func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning {
return planning return planning
} }
func outdoorPlanningNotes(dayparts []forecast.DaypartSummary) []string {
windows := buildOutdoorWindows(dayparts)
var notes []string
if windows.Best != nil {
notes = append(notes, fmt.Sprintf("Best outdoor window: %s (%s).", titleWord(windows.Best.Daypart), strings.Join(windows.Best.Reasons, ", ")))
}
if windows.Worst != nil && (windows.Best == nil || windows.Worst.Daypart != windows.Best.Daypart) {
notes = append(notes, fmt.Sprintf("Toughest outdoor window: %s (%s).", titleWord(windows.Worst.Daypart), strings.Join(windows.Worst.Reasons, ", ")))
}
return appendUnique(nil, notes...)
}
func readinessNotes(daypart forecast.DaypartSummary) []string { func readinessNotes(daypart forecast.DaypartSummary) []string {
notes := []string{} notes := []string{}
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 50 { if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 50 {
@@ -104,6 +181,27 @@ func readinessNotes(daypart forecast.DaypartSummary) []string {
return appendUnique(nil, notes...) return appendUnique(nil, notes...)
} }
func lateDayWatchNotes(daypart forecast.DaypartSummary) []string {
notes := []string{}
prefix := titleWord(daypart.Name)
if prefix == "" {
prefix = "Late-day"
}
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 {
notes = append(notes, fmt.Sprintf("%s precipitation timing may shift; current peak is near %.0f%%.", prefix, daypart.MaxPrecipitationProbability.Value))
}
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", prefix, daypart.PeakWindGust.Value))
}
if daypart.Indicators.Snow || daypart.Indicators.Ice {
notes = append(notes, prefix+" wintry weather could affect late-day travel.")
}
if len(daypart.AlertOverlaps) > 0 {
notes = append(notes, prefix+" alert timing could affect late-day plans.")
}
return appendUnique(nil, notes...)
}
func concernNotes(daypart forecast.DaypartSummary) []string { func concernNotes(daypart forecast.DaypartSummary) []string {
notes := []string{} notes := []string{}
prefix := titleWord(daypart.Name) prefix := titleWord(daypart.Name)
@@ -190,8 +288,7 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
} }
return OutdoorWindow{ return OutdoorWindow{
Daypart: daypart.Name, Daypart: daypart.Name,
Start: daypart.Period.Start.Format("15:04"), Period: daypart.Period,
End: daypart.Period.End.Format("15:04"),
Reasons: dedupe(reasons), Reasons: dedupe(reasons),
Score: math.Round(score*10) / 10, Score: math.Round(score*10) / 10,
} }

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

@@ -4,13 +4,14 @@ import (
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
) )
type WeatherStoryModule struct { type WeatherStoryModule struct {
Available bool `json:"available"` Available bool `json:"available"`
OfficeID string `json:"office_id,omitempty"` OfficeID string `json:"office_id,omitempty"`
StartTime time.Time `json:"start_time"` PeriodBegins string `json:"period_begins,omitempty"`
EndTime time.Time `json:"end_time"` PeriodEnds string `json:"period_ends,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
@@ -25,11 +26,12 @@ func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
if story == nil { if story == nil {
return nil, nil return nil, nil
} }
period := timeutil.Period{Start: story.StartTime, End: story.EndTime}
value := WeatherStoryModule{ value := WeatherStoryModule{
Available: true, Available: true,
OfficeID: story.OfficeID, OfficeID: story.OfficeID,
StartTime: story.StartTime, PeriodBegins: friendlyPeriodBeginsLabel(period, ctx.Timezone),
EndTime: story.EndTime, PeriodEnds: friendlyPeriodEndsLabel(period, ctx.Timezone),
UpdatedAt: copyTime(story.UpdatedAt), UpdatedAt: copyTime(story.UpdatedAt),
Title: story.Title, Title: story.Title,
Description: story.Description, Description: story.Description,

View File

@@ -84,7 +84,8 @@ type dailySummaryStanza struct {
type daypartSummaryStanza struct { type daypartSummaryStanza struct {
Date string `json:"date,omitempty"` Date string `json:"date,omitempty"`
Period string `json:"period,omitempty"` PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
TempRangeF string `json:"temp_range_f,omitempty"` TempRangeF string `json:"temp_range_f,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"` MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"` MaxPopTime string `json:"max_pop_time,omitempty"`

View File

@@ -96,7 +96,13 @@ func dailySnapshot(t *testing.T, low int, high int, precip int, precipTime strin
DailyPrecipitationProbability: &precip, DailyPrecipitationProbability: &precip,
}}, }},
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{ module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
"morning": {Date: "2026-05-29", Period: "2026-05-29 at 6:00 AM to 2026-05-29 at 10:00 AM", TempRangeF: "60-70", Snow: snow}, "morning": {
Date: "2026-05-29",
PeriodBegins: "2026-05-29 at 6:00 AM",
PeriodEnds: "2026-05-29 at 10:00 AM",
TempRangeF: "60-70",
Snow: snow,
},
}}, }},
module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: precipTimingStanza{MaxPopPercent: &precip, MaxPopTime: precipTime}}, module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: precipTimingStanza{MaxPopPercent: &precip, MaxPopTime: precipTime}},
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: alertDigestStanza{Relevant: relevant}}, module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: alertDigestStanza{Relevant: relevant}},

View File

@@ -32,7 +32,8 @@ func outlookSnapshot(t *testing.T, date string, tempRange string, precip int, pr
return snapshot(t, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{ return snapshot(t, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
date + "_morning": { date + "_morning": {
Date: date, Date: date,
Period: date + " at 6:00 AM to " + date + " at 10:00 AM", PeriodBegins: date + " at 6:00 AM",
PeriodEnds: date + " at 10:00 AM",
TempRangeF: tempRange, TempRangeF: tempRange,
MaxPopPercent: &precip, MaxPopPercent: &precip,
MaxPopTime: precipTime, MaxPopTime: precipTime,

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 ( import (
"context" "context"
"encoding/json"
"flag" "flag"
"fmt" "fmt"
"io" "io"
@@ -17,13 +16,15 @@ const helpText = `weatherreporter prepares weather reports from normalized forec
Usage: Usage:
weatherreporter --help weatherreporter --help
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] [--quiet]
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] 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 reports [--config PATH] [--limit N]
weatherreporter inspect metadata [--config PATH] RUN_ID weatherreporter inspect metadata [--config PATH] RUN_ID
weatherreporter inspect modules [--config PATH] RUN_ID weatherreporter inspect modules [--config PATH] RUN_ID
@@ -36,8 +37,9 @@ Options:
--config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml. --config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml.
--units VALUE Override weather API units. --units VALUE Override weather API units.
--tz NAME Override weather API timezone. --tz NAME Override weather API timezone.
--out PATH Write an extra Markdown report copy 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. --out-dir PATH Write extra Markdown report copies for run commands.
--quiet Suppress successful generate and run output.
` `
type Runner struct { 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 { func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error {
_ = stderr
if r.Clock == nil { if r.Clock == nil {
r.Clock = timeutil.SystemClock{} r.Clock = timeutil.SystemClock{}
} }
@@ -60,23 +61,32 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr
switch args[0] { switch args[0] {
case "generate": case "generate":
req, err := r.resolveGenerate(args[1:]) req, opts, err := r.resolveGenerateAction(args[1:])
if err != nil { if err != nil {
return err return err
} }
return app.Generate(ctx, req) result, err := app.GenerateDetailed(ctx, req)
if result != nil {
summary := newGenerateSummary(result, err)
if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, nil); encodeErr != nil {
return encodeErr
}
}
return err
case "run": case "run":
req, err := r.resolveRun(args[1:]) req, opts, err := r.resolveRunAction(args[1:])
if err != nil { if err != nil {
return err return err
} }
result, err := app.RunBatchDetailed(ctx, req) result, err := app.RunBatchDetailed(ctx, req)
if result != nil { if result != nil {
writeRunLogs(stderr, result) summary := newBatchSummary(result)
if encodeErr := writeJSON(stdout, result); encodeErr != nil { if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, func(w io.Writer) {
writeBatchStatus(w, result)
}); encodeErr != nil {
return encodeErr return encodeErr
} }
if result.Failed > 0 { if summary.Status == summaryStatusFailed {
return app.BatchError{Result: result} return app.BatchError{Result: result}
} }
} }
@@ -94,6 +104,7 @@ type commonOptions struct {
Timezone string Timezone string
Output string Output string
OutputDir string OutputDir string
Quiet bool
} }
type generateOptions struct { type generateOptions struct {
@@ -179,20 +190,25 @@ func runInspectRunCommand(ctx context.Context, stdout io.Writer, command inspect
} }
func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) { func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
req, _, err := r.resolveGenerateAction(args)
return req, err
}
func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commonOptions, error) {
if r.Clock == nil { if r.Clock == nil {
r.Clock = timeutil.SystemClock{} r.Clock = timeutil.SystemClock{}
} }
if len(args) == 0 { if len(args) == 0 {
return app.GenerateRequest{}, fmt.Errorf("generate requires a report name") return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("generate requires a report name")
} }
reportKind, ok := reportKind(args[0]) if _, err := report.IDForCommandName(args[0]); err != nil {
if !ok { return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("unknown generate report %q", args[0])
return app.GenerateRequest{}, fmt.Errorf("unknown generate report %q", args[0])
} }
reportKind := app.ReportKind(args[0])
opts, err := parseGenerateFlags(reportKind, args[1:]) opts, err := parseGenerateFlags(reportKind, args[1:])
if err != nil { if err != nil {
return app.GenerateRequest{}, err return app.GenerateRequest{}, commonOptions{}, err
} }
cfg, err := config.Load(config.LoadOptions{ cfg, err := config.Load(config.LoadOptions{
Path: opts.ConfigPath, Path: opts.ConfigPath,
@@ -200,11 +216,11 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
Timezone: opts.Timezone, Timezone: opts.Timezone,
}) })
if err != nil { if err != nil {
return app.GenerateRequest{}, err return app.GenerateRequest{}, commonOptions{}, err
} }
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone) location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
if err != nil { if err != nil {
return app.GenerateRequest{}, err return app.GenerateRequest{}, commonOptions{}, err
} }
req := app.GenerateRequest{ req := app.GenerateRequest{
@@ -216,46 +232,59 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
switch reportKind { switch reportKind {
case app.ReportDaily: case app.ReportDaily:
if opts.Date == "" {
return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("generate daily requires --date YYYY-MM-DD")
}
req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
if err != nil {
return app.GenerateRequest{}, commonOptions{}, err
}
case app.ReportToday:
if opts.Date == "" { if opts.Date == "" {
req.Date = timeutil.LocalDate(r.Clock.Now(), location) req.Date = timeutil.LocalDate(r.Clock.Now(), location)
} else { } else {
req.Date, err = timeutil.ParseLocalDate(opts.Date, location) req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
if err != nil { if err != nil {
return app.GenerateRequest{}, err return app.GenerateRequest{}, commonOptions{}, err
} }
} }
case app.ReportStorm: case app.ReportStorm:
if opts.Start == "" { 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 == "" { 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) period, err := report.ParseStormPeriod(opts.Start, opts.End, location)
if err != nil { if err != nil {
return app.GenerateRequest{}, err return app.GenerateRequest{}, commonOptions{}, err
} }
req.StormStart = period.Start req.StormStart = period.Start
req.StormEnd = period.End req.StormEnd = period.End
} }
return req, nil return req, opts.commonOptions, nil
} }
func (r Runner) resolveRun(args []string) (app.BatchRequest, error) { func (r Runner) resolveRun(args []string) (app.BatchRequest, error) {
req, _, err := r.resolveRunAction(args)
return req, err
}
func (r Runner) resolveRunAction(args []string) (app.BatchRequest, commonOptions, error) {
if r.Clock == nil { if r.Clock == nil {
r.Clock = timeutil.SystemClock{} r.Clock = timeutil.SystemClock{}
} }
if len(args) == 0 { if len(args) == 0 {
return app.BatchRequest{}, fmt.Errorf("run requires a batch name") return app.BatchRequest{}, commonOptions{}, fmt.Errorf("run requires a batch name")
} }
batch, ok := batchKind(args[0]) if _, err := report.BatchForCommandName(args[0]); err != nil {
if !ok { return app.BatchRequest{}, commonOptions{}, fmt.Errorf("unknown run batch %q", args[0])
return app.BatchRequest{}, fmt.Errorf("unknown run batch %q", args[0])
} }
batch := app.BatchKind(args[0])
opts, err := parseRunFlags(args[1:]) opts, err := parseRunFlags(args[1:])
if err != nil { if err != nil {
return app.BatchRequest{}, err return app.BatchRequest{}, commonOptions{}, err
} }
cfg, err := config.Load(config.LoadOptions{ cfg, err := config.Load(config.LoadOptions{
Path: opts.ConfigPath, Path: opts.ConfigPath,
@@ -263,9 +292,9 @@ func (r Runner) resolveRun(args []string) (app.BatchRequest, error) {
Timezone: opts.Timezone, Timezone: opts.Timezone,
}) })
if err != nil { if err != nil {
return app.BatchRequest{}, err return app.BatchRequest{}, commonOptions{}, err
} }
return app.BatchRequest{Config: cfg, Batch: batch, Now: r.Clock.Now(), OutputDir: opts.OutputDir}, nil return app.BatchRequest{Config: cfg, Batch: batch, Now: r.Clock.Now(), OutputDir: opts.OutputDir}, opts, nil
} }
func resolveRun(args []string) (app.BatchRequest, error) { func resolveRun(args []string) (app.BatchRequest, error) {
@@ -277,7 +306,8 @@ func parseGenerateFlags(report app.ReportKind, args []string) (generateOptions,
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
opts := generateOptions{} opts := generateOptions{}
addCommonFlags(fs, &opts.commonOptions, true) addCommonFlags(fs, &opts.commonOptions, true)
if report == app.ReportDaily { fs.BoolVar(&opts.Quiet, "quiet", false, "suppress successful action output")
if report == app.ReportDaily || report == app.ReportToday {
fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD") fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD")
} }
if report == app.ReportStorm { if report == app.ReportStorm {
@@ -299,6 +329,7 @@ func parseRunFlags(args []string) (commonOptions, error) {
opts := commonOptions{} opts := commonOptions{}
addCommonFlags(fs, &opts, false) addCommonFlags(fs, &opts, false)
fs.StringVar(&opts.OutputDir, "out-dir", "", "extra Markdown report copy directory") fs.StringVar(&opts.OutputDir, "out-dir", "", "extra Markdown report copy directory")
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress successful action output")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return commonOptions{}, err return commonOptions{}, err
} }
@@ -341,36 +372,6 @@ func parseInspectRunFlags(command string, args []string) (inspectOptions, error)
return opts, nil return opts, nil
} }
func writeJSON(stdout io.Writer, value any) error {
encoder := json.NewEncoder(stdout)
encoder.SetIndent("", " ")
return encoder.Encode(value)
}
func writeRunLogs(stderr io.Writer, result *app.BatchResult) {
if stderr == nil || result == nil {
return
}
for _, item := range result.Reports {
notificationFields := ""
if item.NotificationStatus != "" {
notificationFields += fmt.Sprintf(" notificationStatus=%q", item.NotificationStatus)
}
if item.NotificationRunID != "" {
notificationFields += fmt.Sprintf(" notificationRunId=%q", item.NotificationRunID)
}
if item.NotificationError != "" {
notificationFields += fmt.Sprintf(" notificationError=%q", item.NotificationError)
}
if item.Status == "failed" {
_, _ = fmt.Fprintf(stderr, "report=%s status=failed error=%q%s\n", item.ReportID, item.Error, notificationFields)
continue
}
_, _ = fmt.Fprintf(stderr, "report=%s status=succeeded output=%q%s\n", item.ReportID, item.OutputPath, notificationFields)
}
_, _ = fmt.Fprintf(stderr, "batch=%s total=%d succeeded=%d failed=%d\n", result.Batch, result.Total, result.Succeeded, result.Failed)
}
func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) { func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) {
fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path") fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path")
fs.StringVar(&opts.Units, "units", "", "weather API units") fs.StringVar(&opts.Units, "units", "", "weather API units")
@@ -379,31 +380,3 @@ func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) {
fs.StringVar(&opts.Output, "out", "", "extra Markdown report copy path") fs.StringVar(&opts.Output, "out", "", "extra Markdown report copy path")
} }
} }
func reportKind(value string) (app.ReportKind, bool) {
switch value {
case string(app.ReportDaily):
return app.ReportDaily, true
case string(app.ReportTomorrow):
return app.ReportTomorrow, true
case string(app.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

@@ -66,7 +66,14 @@ type DistributorNotifyConfig struct {
PipelineIDTemplate string `yaml:"pipeline_id_template"` PipelineIDTemplate string `yaml:"pipeline_id_template"`
BundleIDTemplate string `yaml:"bundle_id_template"` BundleIDTemplate string `yaml:"bundle_id_template"`
IdempotencyKeyTemplate string `yaml:"idempotency_key_template"` IdempotencyKeyTemplate string `yaml:"idempotency_key_template"`
ReportPathTemplates []string `yaml:"report_path_templates"` 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 { type MissingSourceConfig struct {
@@ -106,9 +113,15 @@ type RecentChangeConfig struct {
type ReportConfig struct { type ReportConfig struct {
DeterministicModules []ModuleConfigItem `yaml:"deterministic_modules"` DeterministicModules []ModuleConfigItem `yaml:"deterministic_modules"`
Distributor ReportDistributorConfig `yaml:"distributor"`
deterministicModulesSet bool deterministicModulesSet bool
} }
type ReportDistributorConfig struct {
PathTemplates []string `yaml:"path_templates"`
pathTemplatesSet bool
}
type ModuleConfigItem struct { type ModuleConfigItem struct {
ID module.ID `yaml:"id"` ID module.ID `yaml:"id"`
Options any `yaml:"options,omitempty"` Options any `yaml:"options,omitempty"`
@@ -127,6 +140,10 @@ func (c *ReportConfig) UnmarshalYAML(value *yaml.Node) error {
return err return err
} }
c.deterministicModulesSet = true c.deterministicModulesSet = true
case "distributor":
if err := node.Decode(&c.Distributor); err != nil {
return err
}
default: default:
return fmt.Errorf("unknown report entry field %q", key) return fmt.Errorf("unknown report entry field %q", key)
} }
@@ -134,6 +151,112 @@ func (c *ReportConfig) UnmarshalYAML(value *yaml.Node) error {
return nil 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 { func (m *ModuleConfigItem) UnmarshalYAML(value *yaml.Node) error {
switch value.Kind { switch value.Kind {
case yaml.ScalarNode: case yaml.ScalarNode:

File diff suppressed because it is too large Load Diff

View File

@@ -31,8 +31,11 @@ func Defaults() Config {
PipelineIDTemplate: "", PipelineIDTemplate: "",
BundleIDTemplate: "weatherreporter.{location_id}.{report_id}", BundleIDTemplate: "weatherreporter.{location_id}.{report_id}",
IdempotencyKeyTemplate: "{bundle_id}.{run_id}", IdempotencyKeyTemplate: "{bundle_id}.{run_id}",
ReportPathTemplates: []string{ Batch: DistributorBatchNotifyConfig{
"{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md", Enabled: true,
PipelineIDTemplate: "weatherreporter",
BundleIDTemplate: "weatherreporter.{location_id}.{batch}",
IdempotencyKeyTemplate: "{bundle_id}.{batch_run_id}",
}, },
}, },
}, },

View File

@@ -18,6 +18,15 @@ type DistributorTemplateValues struct {
ValidEndTime string ValidEndTime string
ValidStartStamp string ValidStartStamp string
ValidEndStamp string ValidEndStamp string
StormID string
BundleID string
}
type DistributorBatchTemplateValues struct {
LocationID string
Batch string
BatchRunID string
BatchStartedDate string
BundleID string BundleID string
} }
@@ -33,6 +42,7 @@ var distributorTemplateVariables = map[string]struct{}{
"valid_end_time": {}, "valid_end_time": {},
"valid_start_stamp": {}, "valid_start_stamp": {},
"valid_end_stamp": {}, "valid_end_stamp": {},
"storm_id": {},
} }
var distributorIdempotencyTemplateVariables = map[string]struct{}{ var distributorIdempotencyTemplateVariables = map[string]struct{}{
@@ -47,11 +57,29 @@ var distributorIdempotencyTemplateVariables = map[string]struct{}{
"valid_end_time": {}, "valid_end_time": {},
"valid_start_stamp": {}, "valid_start_stamp": {},
"valid_end_stamp": {}, "valid_end_stamp": {},
"storm_id": {},
"bundle_id": {}, "bundle_id": {},
} }
var distributorPipelineTemplateVariables = distributorIdempotencyTemplateVariables 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) { func RenderDistributorBundleID(template string, values DistributorTemplateValues) (string, error) {
return renderDistributorTemplate("notify.distributor.bundle_id_template", template, values, distributorTemplateVariables) return renderDistributorTemplate("notify.distributor.bundle_id_template", template, values, distributorTemplateVariables)
} }
@@ -71,23 +99,56 @@ func RenderDistributorIdempotencyKey(template string, values DistributorTemplate
return renderDistributorTemplate("notify.distributor.idempotency_key_template", template, values, distributorIdempotencyTemplateVariables) return renderDistributorTemplate("notify.distributor.idempotency_key_template", template, values, distributorIdempotencyTemplateVariables)
} }
func RenderDistributorReportPaths(templates []string, values DistributorTemplateValues) ([]string, error) { 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 { if len(templates) == 0 {
return nil, fmt.Errorf("notify.distributor.report_path_templates must contain at least one entry") return nil, fmt.Errorf("%s must contain at least one entry", name)
} }
paths := make([]string, 0, len(templates)) paths := make([]string, 0, len(templates))
seen := make(map[string]struct{}, len(templates)) seen := make(map[string]struct{}, len(templates))
for i, template := range templates { for i, template := range templates {
name := fmt.Sprintf("notify.distributor.report_path_templates[%d]", i) itemName := fmt.Sprintf("%s[%d]", name, i)
rendered, err := renderDistributorTemplate(name, template, values, distributorTemplateVariables) rendered, err := renderDistributorTemplate(itemName, template, values, distributorTemplateVariables)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := ValidateDistributorReportPath(name, rendered); err != nil { if err := ValidateDistributorReportPath(itemName, rendered); err != nil {
return nil, err return nil, err
} }
if _, ok := seen[rendered]; ok { if _, ok := seen[rendered]; ok {
return nil, fmt.Errorf("notify.distributor.report_path_templates renders duplicate path %q", rendered) return nil, fmt.Errorf("%s renders duplicate path %q", name, rendered)
} }
seen[rendered] = struct{}{} seen[rendered] = struct{}{}
paths = append(paths, rendered) paths = append(paths, rendered)
@@ -100,6 +161,11 @@ func validateDistributorTemplate(name, template string, allowed map[string]struc
return err 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) { func renderDistributorTemplate(name, template string, values DistributorTemplateValues, allowed map[string]struct{}) (string, error) {
var rendered strings.Builder var rendered strings.Builder
for i := 0; i < len(template); { for i := 0; i < len(template); {
@@ -128,6 +194,34 @@ func renderDistributorTemplate(name, template string, values DistributorTemplate
return rendered.String(), nil 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 { func distributorTemplateValue(variable string, values DistributorTemplateValues) string {
switch variable { switch variable {
case "location_id": case "location_id":
@@ -152,6 +246,25 @@ func distributorTemplateValue(variable string, values DistributorTemplateValues)
return values.ValidStartStamp return values.ValidStartStamp
case "valid_end_stamp": case "valid_end_stamp":
return values.ValidEndStamp 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": case "bundle_id":
return values.BundleID return values.BundleID
default: default:
@@ -178,7 +291,7 @@ func ValidateDistributorReportPath(name, path string) error {
if segment == "." || segment == ".." { if segment == "." || segment == ".." {
return fmt.Errorf("%s must not render . or .. path segments", name) return fmt.Errorf("%s must not render . or .. path segments", name)
} }
if segment == "manifest.json" || segment == ".distributor.json" { if segment == "manifest.json" || segment == distributorSidecarBasename() {
return fmt.Errorf("%s must not render reserved path segment %q", name, segment) return fmt.Errorf("%s must not render reserved path segment %q", name, segment)
} }
} }
@@ -186,6 +299,10 @@ func ValidateDistributorReportPath(name, path string) error {
return nil return nil
} }
func distributorSidecarBasename() string {
return "." + "distributor.json"
}
func isDistributorAbsolutePath(path string) bool { func isDistributorAbsolutePath(path string) bool {
if filepath.IsAbs(path) || strings.HasPrefix(path, "/") { if filepath.IsAbs(path) || strings.HasPrefix(path, "/") {
return true return true

View File

@@ -4,7 +4,6 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"reflect" "reflect"
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
@@ -12,65 +11,163 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
func (cfg Config) ReportModuleOverrides() map[report.ID][]module.ConfigItem { func (cfg Config) ReportModuleOverrides() (map[report.ID][]module.ConfigItem, error) {
overrides := map[report.ID][]module.ConfigItem{} return traverseReportModules(&cfg, reportModuleTraversalOptions{
for key, reportCfg := range cfg.Reports { normalizeOptions: true,
if !reportCfg.deterministicModulesSet { })
continue }
}
id, err := reportIDForConfigKey(key) func (cfg Config) ReportDistributorPathOverrides() (map[report.ID][]string, error) {
if err != nil { return traverseReportDistributorPathOverrides(cfg)
continue
}
items := make([]module.ConfigItem, 0, len(reportCfg.DeterministicModules))
for _, item := range reportCfg.DeterministicModules {
items = append(items, module.ConfigItem{ID: item.ID, Options: item.Options})
}
overrides[id] = items
}
return overrides
} }
func normalizeReportModules(cfg *Config) error { func normalizeReportModules(cfg *Config) error {
if cfg.Reports == nil { if cfg.Reports == nil {
cfg.Reports = map[string]ReportConfig{} cfg.Reports = map[string]ReportConfig{}
} }
_, err := traverseReportModules(cfg, reportModuleTraversalOptions{
normalizeOptions: true,
updateConfig: true,
})
return err
}
func validateReportModules(cfg Config) error {
_, err := traverseReportModules(&cfg, reportModuleTraversalOptions{
normalizeOptions: true,
})
return err
}
func validateReportDistributorPathOverrides(cfg Config) error {
_, err := traverseReportDistributorPathOverrides(cfg)
return err
}
type reportModuleTraversalOptions struct {
normalizeOptions bool
updateConfig bool
}
func traverseReportModules(cfg *Config, opts reportModuleTraversalOptions) (map[report.ID][]module.ConfigItem, error) {
overrides := map[report.ID][]module.ConfigItem{}
if cfg.Reports == nil {
return overrides, nil
}
moduleRegistry, err := briefing.DefaultModuleRegistry() moduleRegistry, err := briefing.DefaultModuleRegistry()
if err != nil { if err != nil {
return fmt.Errorf("initialize module registry: %w", err) return nil, fmt.Errorf("initialize module registry: %w", err)
} }
reportRegistry := report.DefaultRegistry() reportRegistry := report.DefaultRegistry()
seenReports := map[report.ID]string{} seenReports := map[report.ID]string{}
for key, reportCfg := range cfg.Reports { for key, reportCfg := range cfg.Reports {
reportID, err := reportIDForConfigKey(key) reportID, err := report.IDForConfigKey(key)
if err != nil { if err != nil {
return err return nil, fmt.Errorf("reports.%s: %w", key, err)
} }
if previous, ok := seenReports[reportID]; ok { if previous, ok := seenReports[reportID]; ok {
return fmt.Errorf("reports.%s duplicates report override %q", key, previous) return nil, fmt.Errorf("reports.%s duplicates report override %q", key, previous)
} }
seenReports[reportID] = key seenReports[reportID] = key
if _, err := reportRegistry.Lookup(reportID); err != nil { if _, err := reportRegistry.Lookup(reportID); err != nil {
return fmt.Errorf("reports.%s: %w", key, err) return nil, fmt.Errorf("reports.%s: %w", key, err)
} }
if !reportCfg.deterministicModulesSet { if !reportCfg.deterministicModulesSet {
continue continue
} }
items := make([]module.ConfigItem, 0, len(reportCfg.DeterministicModules)) items, normalized, err := moduleItemsFromConfig(moduleRegistry, key, reportCfg.DeterministicModules, opts.normalizeOptions)
for i, rawItem := range reportCfg.DeterministicModules {
options, err := normalizeModuleOptions(moduleRegistry, rawItem.ID, rawItem.Options)
if err != nil { if err != nil {
return fmt.Errorf("reports.%s.deterministic_modules[%d]: %w", key, i, err) return nil, err
}
reportCfg.DeterministicModules[i].Options = options
items = append(items, module.ConfigItem{ID: rawItem.ID, Options: options})
} }
if err := moduleRegistry.ValidateComposition(reportID, items); err != nil { if err := moduleRegistry.ValidateComposition(reportID, items); err != nil {
return fmt.Errorf("reports.%s.deterministic_modules: %w", key, err) return nil, fmt.Errorf("reports.%s.deterministic_modules: %w", key, err)
} }
overrides[reportID] = items
if opts.updateConfig {
reportCfg.DeterministicModules = normalized
cfg.Reports[key] = reportCfg cfg.Reports[key] = reportCfg
} }
return nil }
return overrides, nil
}
func traverseReportDistributorPathOverrides(cfg Config) (map[report.ID][]string, error) {
overrides := map[report.ID][]string{}
if cfg.Reports == nil {
return overrides, nil
}
reportRegistry := report.DefaultRegistry()
seenReports := map[report.ID]string{}
for key, reportCfg := range cfg.Reports {
reportID, err := report.IDForConfigKey(key)
if err != nil {
return nil, fmt.Errorf("reports.%s: %w", key, err)
}
if previous, ok := seenReports[reportID]; ok {
return nil, fmt.Errorf("reports.%s duplicates report override %q", key, previous)
}
seenReports[reportID] = key
if _, err := reportRegistry.Lookup(reportID); err != nil {
return nil, fmt.Errorf("reports.%s: %w", key, err)
}
if !reportCfg.Distributor.pathTemplatesSet {
continue
}
if err := validateReportDistributorPathTemplates(key, reportID, reportCfg.Distributor.PathTemplates); err != nil {
return nil, err
}
overrides[reportID] = append([]string(nil), reportCfg.Distributor.PathTemplates...)
}
return overrides, nil
}
func validateReportDistributorPathTemplates(reportKey string, reportID report.ID, templates []string) error {
name := fmt.Sprintf("reports.%s.distributor.path_templates", reportKey)
_, err := RenderDistributorReportPaths(name, templates, sampleDistributorTemplateValuesForReport(reportID))
return err
}
func sampleDistributorTemplateValues() DistributorTemplateValues {
return sampleDistributorTemplateValuesForReport(report.Storm)
}
func sampleDistributorTemplateValuesForReport(reportID report.ID) DistributorTemplateValues {
stormID := ""
if reportID == report.Storm {
stormID = "2026-05-29T0000-2026-05-30T0000"
}
return DistributorTemplateValues{
LocationID: "location",
ReportID: "report",
RunID: "run",
ArtifactGroup: "artifact",
BatchOutputName: "report.md",
ValidStartDate: "2026-05-29",
ValidEndDate: "2026-05-30",
ValidStartTime: "0000",
ValidEndTime: "0000",
ValidStartStamp: "2026-05-29T0000",
ValidEndStamp: "2026-05-30T0000",
StormID: stormID,
}
}
func moduleItemsFromConfig(registry briefing.ModuleRegistry, reportKey string, items []ModuleConfigItem, normalizeOptions bool) ([]module.ConfigItem, []ModuleConfigItem, error) {
out := make([]module.ConfigItem, 0, len(items))
normalizedItems := append([]ModuleConfigItem(nil), items...)
for i, item := range items {
options := item.Options
if normalizeOptions {
var err error
options, err = normalizeModuleOptions(registry, item.ID, item.Options)
if err != nil {
return nil, nil, fmt.Errorf("reports.%s.deterministic_modules[%d]: %w", reportKey, i, err)
}
normalizedItems[i].Options = options
}
out = append(out, module.ConfigItem{ID: item.ID, Options: options})
}
return out, normalizedItems, nil
} }
func normalizeModuleOptions(registry briefing.ModuleRegistry, id module.ID, raw any) (any, error) { func normalizeModuleOptions(registry briefing.ModuleRegistry, id module.ID, raw any) (any, error) {
@@ -84,6 +181,9 @@ func normalizeModuleOptions(registry briefing.ModuleRegistry, id module.ID, raw
if definition.DefaultOptions == nil { if definition.DefaultOptions == nil {
return nil, fmt.Errorf("module %q does not accept options", id) return nil, fmt.Errorf("module %q does not accept options", id)
} }
if err := definition.ValidateOptions(raw); err == nil {
return raw, nil
}
optionType := reflect.TypeOf(definition.DefaultOptions) optionType := reflect.TypeOf(definition.DefaultOptions)
normalized, err := decodeKnownOptions(raw, optionType) normalized, err := decodeKnownOptions(raw, optionType)
if err != nil { if err != nil {
@@ -105,21 +205,3 @@ func decodeKnownOptions(raw any, optionType reflect.Type) (any, error) {
} }
return target.Elem().Interface(), nil return target.Elem().Interface(), nil
} }
func reportIDForConfigKey(key string) (report.ID, error) {
normalized := strings.ReplaceAll(strings.TrimSpace(strings.ToLower(key)), "-", "_")
switch normalized {
case "daily", "daily_today":
return report.DailyToday, nil
case "tomorrow", "daily_tomorrow":
return report.DailyTomorrow, nil
case "three_day", "three_day_outlook":
return report.ThreeDay, nil
case "weekend", "weekend_outlook":
return report.Weekend, nil
case "storm", "storm_report":
return report.Storm, nil
default:
return "", fmt.Errorf("reports.%s is not a known report", key)
}
}

View File

@@ -9,7 +9,10 @@ import (
) )
func Validate(cfg Config) error { func Validate(cfg Config) error {
if err := normalizeReportModules(&cfg); err != nil { if err := validateReportModules(cfg); err != nil {
return err
}
if err := validateReportDistributorPathOverrides(cfg); err != nil {
return err return err
} }
if cfg.WeatherAPI.BaseURL != "" { if cfg.WeatherAPI.BaseURL != "" {
@@ -121,22 +124,7 @@ func validateDistributorNotify(cfg DistributorNotifyConfig) error {
if err := validateDistributorTemplate("notify.distributor.idempotency_key_template", cfg.IdempotencyKeyTemplate, distributorIdempotencyTemplateVariables); err != nil { if err := validateDistributorTemplate("notify.distributor.idempotency_key_template", cfg.IdempotencyKeyTemplate, distributorIdempotencyTemplateVariables); err != nil {
return err return err
} }
if len(cfg.ReportPathTemplates) == 0 { values := sampleDistributorTemplateValues()
return fmt.Errorf("notify.distributor.report_path_templates must contain at least one entry when enabled")
}
values := DistributorTemplateValues{
LocationID: "location",
ReportID: "report",
RunID: "run",
ArtifactGroup: "artifact",
BatchOutputName: "report.md",
ValidStartDate: "2026-05-29",
ValidEndDate: "2026-05-30",
ValidStartTime: "0000",
ValidEndTime: "0000",
ValidStartStamp: "2026-05-29T0000",
ValidEndStamp: "2026-05-30T0000",
}
bundleID, err := RenderDistributorBundleID(cfg.BundleIDTemplate, values) bundleID, err := RenderDistributorBundleID(cfg.BundleIDTemplate, values)
if err != nil { if err != nil {
return err return err
@@ -145,13 +133,56 @@ func validateDistributorNotify(cfg DistributorNotifyConfig) error {
if _, err := RenderDistributorPipelineID(cfg.PipelineIDTemplate, values); err != nil { if _, err := RenderDistributorPipelineID(cfg.PipelineIDTemplate, values); err != nil {
return err return err
} }
if _, err := RenderDistributorReportPaths(cfg.ReportPathTemplates, values); err != nil { if err := validateDistributorBatchNotify(cfg.Batch); err != nil {
return err return err
} }
return nil return nil
} }
func validateDistributorBatchNotify(cfg DistributorBatchNotifyConfig) error {
if !cfg.Enabled {
return nil
}
if cfg.PipelineIDTemplate == "" {
return fmt.Errorf("notify.distributor.batch.pipeline_id_template is required when enabled")
}
if err := validateDistributorBatchTemplate("notify.distributor.batch.pipeline_id_template", cfg.PipelineIDTemplate, distributorBatchPipelineTemplateVariables); err != nil {
return err
}
if cfg.BundleIDTemplate == "" {
return fmt.Errorf("notify.distributor.batch.bundle_id_template is required when enabled")
}
if err := validateDistributorBatchTemplate("notify.distributor.batch.bundle_id_template", cfg.BundleIDTemplate, distributorBatchTemplateVariables); err != nil {
return err
}
if cfg.IdempotencyKeyTemplate == "" {
return fmt.Errorf("notify.distributor.batch.idempotency_key_template is required when enabled")
}
if err := validateDistributorBatchTemplate("notify.distributor.batch.idempotency_key_template", cfg.IdempotencyKeyTemplate, distributorBatchIdempotencyTemplateVariables); err != nil {
return err
}
values := DistributorBatchTemplateValues{
LocationID: "location",
Batch: "morning",
BatchRunID: "20260529T100000.000000000Z_morning",
BatchStartedDate: "2026-05-29",
}
bundleID, err := RenderDistributorBatchBundleID(cfg.BundleIDTemplate, values)
if err != nil {
return err
}
values.BundleID = bundleID
if _, err := RenderDistributorBatchPipelineID(cfg.PipelineIDTemplate, values); err != nil {
return err
}
if _, err := RenderDistributorBatchIdempotencyKey(cfg.IdempotencyKeyTemplate, values); err != nil {
return err
}
return nil
}
func validatePolicy(name string, policy MissingSourcePolicy) error { func validatePolicy(name string, policy MissingSourcePolicy) error {
switch policy { switch policy {
case MissingSourceError, MissingSourceWarn, MissingSourceNone: case MissingSourceError, MissingSourceWarn, MissingSourceNone:

View File

@@ -3,6 +3,7 @@ package facts
import ( import (
"fmt" "fmt"
"sort"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
@@ -21,6 +22,7 @@ type CollectedFacts struct {
Discussion *weatherdata.Discussion Discussion *weatherdata.Discussion
Daily *weatherdata.ForecastRun Daily *weatherdata.ForecastRun
WeatherStory *weatherdata.WeatherStory WeatherStory *weatherdata.WeatherStory
SPCConvectiveOutlooks *weatherdata.ConvectiveOutlookRun
SourceProvenance []weatherdata.Source SourceProvenance []weatherdata.Source
SourceWarnings []weatherdata.SourceWarning SourceWarnings []weatherdata.SourceWarning
@@ -40,6 +42,7 @@ func BuildCollected(bundle *weatherdata.Bundle) CollectedFacts {
Discussion: bundle.Discussion, Discussion: bundle.Discussion,
Daily: bundle.Daily, Daily: bundle.Daily,
WeatherStory: bundle.WeatherStory, WeatherStory: bundle.WeatherStory,
SPCConvectiveOutlooks: bundle.SPCConvectiveOutlooks,
SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...), SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...),
SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...), SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...),
} }
@@ -56,6 +59,7 @@ func (f CollectedFacts) Bundle() *weatherdata.Bundle {
Discussion: f.Discussion, Discussion: f.Discussion,
Daily: f.Daily, Daily: f.Daily,
WeatherStory: f.WeatherStory, WeatherStory: f.WeatherStory,
SPCConvectiveOutlooks: f.SPCConvectiveOutlooks,
Sources: append([]weatherdata.Source(nil), f.SourceProvenance...), Sources: append([]weatherdata.Source(nil), f.SourceProvenance...),
Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...), Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...),
} }
@@ -73,6 +77,8 @@ type DerivedFacts struct {
ValidPeriodNarrativePeriods []weatherdata.ForecastPeriod ValidPeriodNarrativePeriods []weatherdata.ForecastPeriod
ValidPeriodDailyPeriods []weatherdata.ForecastPeriod ValidPeriodDailyPeriods []weatherdata.ForecastPeriod
AlertOverlaps []forecast.AlertOverlap AlertOverlaps []forecast.AlertOverlap
SPCConvectiveOutlooks []weatherdata.ConvectiveOutlook
SPCConvectiveDiscussions []weatherdata.ConvectiveOutlookDiscussion
DailySummaries []forecast.DailySummary DailySummaries []forecast.DailySummary
DaypartSummaries []forecast.DaypartSummary DaypartSummaries []forecast.DaypartSummary
PrecipTiming forecast.PrecipTiming PrecipTiming forecast.PrecipTiming
@@ -96,16 +102,20 @@ func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) {
} }
bundle := req.Collected.Bundle() bundle := req.Collected.Bundle()
period := req.Resolved.ValidPeriod period := req.Resolved.ValidPeriod
spcOutlooks, spcDiscussions := selectSPCConvectiveOutlooks(req.Collected.SPCConvectiveOutlooks, period)
derived := DerivedFacts{ derived := DerivedFacts{
ValidPeriodHourlyPeriods: forecast.SelectHourlyPeriods(req.Collected.Hourly, period), ValidPeriodHourlyPeriods: forecast.SelectHourlyPeriods(req.Collected.Hourly, period),
ValidPeriodNarrativePeriods: forecast.SelectHourlyPeriods(req.Collected.Narrative, period), ValidPeriodNarrativePeriods: forecast.SelectHourlyPeriods(req.Collected.Narrative, period),
ValidPeriodDailyPeriods: forecast.SelectHourlyPeriods(req.Collected.Daily, period), ValidPeriodDailyPeriods: forecast.SelectHourlyPeriods(req.Collected.Daily, period),
AlertOverlaps: forecast.AlertOverlaps(req.Collected.Alerts, period), AlertOverlaps: forecast.AlertOverlaps(req.Collected.Alerts, period),
SPCConvectiveOutlooks: spcOutlooks,
SPCConvectiveDiscussions: spcDiscussions,
} }
derived.PrecipTiming = forecast.BuildPrecipTiming(derived.ValidPeriodHourlyPeriods) derived.PrecipTiming = forecast.BuildPrecipTiming(derived.ValidPeriodHourlyPeriods)
switch req.Resolved.Definition.ID { switch req.Resolved.Definition.ID {
case report.DailyToday, report.DailyTomorrow: case report.Hourly:
case report.Daily, report.Today, report.Tomorrow:
summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts) summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts)
if err != nil { if err != nil {
return DerivedFacts{}, err return DerivedFacts{}, err
@@ -139,3 +149,81 @@ func collectDaypartSummaries(derived DerivedFacts) []forecast.DaypartSummary {
} }
return out return out
} }
func selectSPCConvectiveOutlooks(run *weatherdata.ConvectiveOutlookRun, period timeutil.Period) ([]weatherdata.ConvectiveOutlook, []weatherdata.ConvectiveOutlookDiscussion) {
if run == nil {
return nil, nil
}
outlooks := make([]weatherdata.ConvectiveOutlook, 0, len(run.Outlooks))
days := map[int]struct{}{}
for _, outlook := range run.Outlooks {
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(period) {
continue
}
outlooks = append(outlooks, outlook)
days[outlook.Day] = struct{}{}
}
sort.SliceStable(outlooks, func(i, j int) bool {
left := outlooks[i]
right := outlooks[j]
if left.Day != right.Day {
return left.Day < right.Day
}
if left.OutlookType != right.OutlookType {
return left.OutlookType < right.OutlookType
}
leftRank, leftRankOK := severityRank(left)
rightRank, rightRankOK := severityRank(right)
if leftRankOK != rightRankOK {
return leftRankOK
}
if leftRankOK && leftRank != rightRank {
return leftRank > rightRank
}
if !left.ValidFrom.Equal(right.ValidFrom) {
return left.ValidFrom.Before(right.ValidFrom)
}
if left.Label != right.Label {
return left.Label < right.Label
}
return left.ID < right.ID
})
discussions := make([]weatherdata.ConvectiveOutlookDiscussion, 0, len(run.Discussions))
for _, discussion := range run.Discussions {
if _, ok := days[discussion.Day]; ok {
discussions = append(discussions, discussion)
}
}
sort.SliceStable(discussions, func(i, j int) bool {
left := discussions[i]
right := discussions[j]
if left.Day != right.Day {
return left.Day < right.Day
}
if left.UpdatedAt != nil && right.UpdatedAt != nil && !left.UpdatedAt.Equal(*right.UpdatedAt) {
return left.UpdatedAt.Before(*right.UpdatedAt)
}
if (left.UpdatedAt != nil) != (right.UpdatedAt != nil) {
return left.UpdatedAt != nil
}
if left.Headline != right.Headline {
return left.Headline < right.Headline
}
if left.Summary != right.Summary {
return left.Summary < right.Summary
}
return left.Discussion < right.Discussion
})
return outlooks, discussions
}
func severityRank(outlook weatherdata.ConvectiveOutlook) (int, bool) {
if outlook.SeverityRank == nil {
return 0, false
}
return *outlook.SeverityRank, true
}

View File

@@ -2,6 +2,7 @@ package facts
import ( import (
"encoding/json" "encoding/json"
"strings"
"testing" "testing"
"time" "time"
@@ -16,6 +17,13 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
FetchedAt: fetchedAt, FetchedAt: fetchedAt,
Current: &weatherdata.Current{ConditionText: "Clear"}, Current: &weatherdata.Current{ConditionText: "Clear"},
Hourly: &weatherdata.ForecastRun{Product: "hourly"}, Hourly: &weatherdata.ForecastRun{Product: "hourly"},
SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{
Product: "convective_outlook",
Outlooks: []weatherdata.ConvectiveOutlook{{
ID: "day1-categorical-slight",
Label: "SLGT",
}},
},
Sources: []weatherdata.Source{{Name: "hourly"}}, Sources: []weatherdata.Source{{Name: "hourly"}},
Warnings: []weatherdata.SourceWarning{{Source: "discussion", Code: "missing_source"}}, Warnings: []weatherdata.SourceWarning{{Source: "discussion", Code: "missing_source"}},
} }
@@ -24,6 +32,9 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
if collected.FetchedAt != fetchedAt || collected.Current.ConditionText != "Clear" || collected.Hourly.Product != "hourly" { if collected.FetchedAt != fetchedAt || collected.Current.ConditionText != "Clear" || collected.Hourly.Product != "hourly" {
t.Fatalf("CollectedFacts = %#v, want source facts copied from bundle", collected) t.Fatalf("CollectedFacts = %#v, want source facts copied from bundle", collected)
} }
if collected.SPCConvectiveOutlooks == nil || collected.SPCConvectiveOutlooks.Outlooks[0].Label != "SLGT" {
t.Fatalf("SPCConvectiveOutlooks = %#v, want source copied from bundle", collected.SPCConvectiveOutlooks)
}
if len(collected.SourceProvenance) != 1 || collected.SourceProvenance[0].Name != "hourly" { if len(collected.SourceProvenance) != 1 || collected.SourceProvenance[0].Name != "hourly" {
t.Fatalf("SourceProvenance = %#v, want hourly source", collected.SourceProvenance) t.Fatalf("SourceProvenance = %#v, want hourly source", collected.SourceProvenance)
} }
@@ -36,11 +47,16 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
if collected.SourceProvenance[0].Name != "hourly" || collected.SourceWarnings[0].Source != "discussion" { if collected.SourceProvenance[0].Name != "hourly" || collected.SourceWarnings[0].Source != "discussion" {
t.Fatalf("collected source slices changed after bundle mutation: %#v %#v", collected.SourceProvenance, collected.SourceWarnings) t.Fatalf("collected source slices changed after bundle mutation: %#v %#v", collected.SourceProvenance, collected.SourceWarnings)
} }
roundTrip := collected.Bundle()
if roundTrip.SPCConvectiveOutlooks == nil || roundTrip.SPCConvectiveOutlooks.Outlooks[0].ID != "day1-categorical-slight" {
t.Fatalf("Bundle().SPCConvectiveOutlooks = %#v, want collected source restored", roundTrip.SPCConvectiveOutlooks)
}
} }
func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) { func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) {
location := testLocation() location := testLocation()
resolved := resolveForTest(t, report.DailyToday, mustParse("2026-05-29T08:00:00-05:00"), location) resolved := resolveForTest(t, report.Daily, mustParse("2026-05-29T08:00:00-05:00"), location)
derived, err := BuildDerived(BuildDerivedRequest{ derived, err := BuildDerived(BuildDerivedRequest{
Resolved: resolved, Resolved: resolved,
Timezone: location.String(), Timezone: location.String(),
@@ -119,7 +135,7 @@ func TestBuildDerivedOutlookBuildsPartialDaySummariesWithMissingOptionalSources(
func TestBuildDerivedWeekendAndTomorrow(t *testing.T) { func TestBuildDerivedWeekendAndTomorrow(t *testing.T) {
location := testLocation() location := testLocation()
for _, id := range []report.ID{report.DailyTomorrow, report.Weekend} { for _, id := range []report.ID{report.Tomorrow, report.Weekend} {
resolved := resolveForTest(t, id, mustParse("2026-05-29T08:00:00-05:00"), location) resolved := resolveForTest(t, id, mustParse("2026-05-29T08:00:00-05:00"), location)
derived, err := BuildDerived(BuildDerivedRequest{ derived, err := BuildDerived(BuildDerivedRequest{
Resolved: resolved, Resolved: resolved,
@@ -136,6 +152,63 @@ func TestBuildDerivedWeekendAndTomorrow(t *testing.T) {
} }
} }
func TestBuildDerivedHourlyUsesRollingWindowFacts(t *testing.T) {
location := testLocation()
resolved := resolveForTest(t, report.Hourly, mustParse("2026-05-29T08:30:00-05:00"), location)
bundle := testBundle(location)
bundle.Alerts = &weatherdata.AlertRun{Alerts: []json.RawMessage{
json.RawMessage(`{"event":"Expired Advisory","headline":"Ends at start","severity":"Minor","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T08:30:00-05:00"}`),
json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T11:00:00-05:00","expires":"2026-05-29T15:00:00-05:00"}`),
json.RawMessage(`{"event":"Evening Advisory","headline":"Starts at end","severity":"Minor","effective":"2026-05-29T14:30:00-05:00","expires":"2026-05-29T18:00:00-05:00"}`),
}}
bundle.SPCConvectiveOutlooks = testConvectiveOutlookRun(location)
derived, err := BuildDerived(BuildDerivedRequest{
Resolved: resolved,
Timezone: location.String(),
Dayparts: testDayparts(),
Collected: BuildCollected(bundle),
})
if err != nil {
t.Fatalf("BuildDerived() error = %v", err)
}
if len(derived.ValidPeriodHourlyPeriods) != 4 {
t.Fatalf("ValidPeriodHourlyPeriods length = %d, want 4 rolling-window hours", len(derived.ValidPeriodHourlyPeriods))
}
if derived.ValidPeriodHourlyPeriods[0].StartTime.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" ||
derived.ValidPeriodHourlyPeriods[3].StartTime.Format(time.RFC3339) != "2026-05-29T14:00:00-05:00" {
t.Fatalf("ValidPeriodHourlyPeriods = %#v, want only hours overlapping 8:30 AM-2:30 PM", derived.ValidPeriodHourlyPeriods)
}
if len(derived.ValidPeriodNarrativePeriods) != 1 {
t.Fatalf("ValidPeriodNarrativePeriods length = %d, want overlapping narrative period", len(derived.ValidPeriodNarrativePeriods))
}
if len(derived.DailySummaries) != 0 || len(derived.DaypartSummaries) != 0 || derived.StormWindowSummary != nil {
t.Fatalf("hourly summaries daily=%#v daypart=%#v storm=%#v, want none", derived.DailySummaries, derived.DaypartSummaries, derived.StormWindowSummary)
}
if derived.PrecipTiming.FirstPrecipitation == nil || derived.PrecipTiming.FirstPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" {
t.Fatalf("PrecipTiming.FirstPrecipitation = %#v, want first selected rainy hour", derived.PrecipTiming.FirstPrecipitation)
}
if derived.PrecipTiming.LastPrecipitation == nil || derived.PrecipTiming.LastPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T14:00:00-05:00" {
t.Fatalf("PrecipTiming.LastPrecipitation = %#v, want final selected rainy window end", derived.PrecipTiming.LastPrecipitation)
}
if len(derived.PrecipTiming.PrecipitationWindows) != 2 {
t.Fatalf("PrecipTiming.PrecipitationWindows = %#v, want two hourly windows", derived.PrecipTiming.PrecipitationWindows)
}
if len(derived.AlertOverlaps) != 1 || derived.AlertOverlaps[0].Event != "Flood Watch" {
t.Fatalf("AlertOverlaps = %#v, want only alert overlapping hourly period", derived.AlertOverlaps)
}
if derived.AlertOverlaps[0].Overlap.End.Format(time.RFC3339) != "2026-05-29T14:30:00-05:00" {
t.Fatalf("Alert overlap end = %s, want clipped to hourly end", derived.AlertOverlaps[0].Overlap.End.Format(time.RFC3339))
}
if got, want := outlookIDs(derived.SPCConvectiveOutlooks), []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic"}; strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("SPCConvectiveOutlooks IDs = %#v, want %#v", got, want)
}
if got, want := discussionHeadlines(derived.SPCConvectiveDiscussions), []string{"day1 early", "day1 late"}; strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("SPCConvectiveDiscussions = %#v, want retained discussions for overlapping SPC day %#v", got, want)
}
}
func TestBuildDerivedStormBuildsWindowSummary(t *testing.T) { func TestBuildDerivedStormBuildsWindowSummary(t *testing.T) {
location := testLocation() location := testLocation()
resolved := resolveStormForTest(t, location) resolved := resolveStormForTest(t, location)
@@ -166,6 +239,124 @@ func TestBuildDerivedStormBuildsWindowSummary(t *testing.T) {
} }
} }
func TestBuildDerivedSelectsSPCConvectiveOutlooksByValidPeriod(t *testing.T) {
location := testLocation()
now := mustParse("2026-05-29T08:00:00-05:00")
bundle := testBundle(location)
bundle.SPCConvectiveOutlooks = testConvectiveOutlookRun(location)
tests := []struct {
name string
resolved report.Resolved
wantOutlookIDs []string
wantDiscussion []string
}{
{
name: "daily",
resolved: resolveForTest(t, report.Daily, now, location),
wantOutlookIDs: []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic"},
wantDiscussion: []string{"day1 early", "day1 late"},
},
{
name: "daily tomorrow",
resolved: resolveForTest(t, report.Tomorrow, now, location),
wantOutlookIDs: []string{"sat-enhanced"},
wantDiscussion: []string{"day2"},
},
{
name: "three day",
resolved: resolveForTest(t, report.ThreeDay, now, location),
wantOutlookIDs: []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic", "sat-enhanced", "sun-slight"},
wantDiscussion: []string{"day1 early", "day1 late", "day2", "day3"},
},
{
name: "weekend",
resolved: resolveForTest(t, report.Weekend, now, location),
wantOutlookIDs: []string{"sat-enhanced", "sun-slight"},
wantDiscussion: []string{"day2", "day3"},
},
{
name: "storm",
resolved: resolveStormForTest(t, location),
wantOutlookIDs: []string{"fri-storm", "fri-low"},
wantDiscussion: []string{"day1 early", "day1 late"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
derived, err := BuildDerived(BuildDerivedRequest{
Resolved: tt.resolved,
Timezone: location.String(),
Dayparts: testDayparts(),
Collected: BuildCollected(bundle),
})
if err != nil {
t.Fatalf("BuildDerived() error = %v", err)
}
if got := outlookIDs(derived.SPCConvectiveOutlooks); strings.Join(got, ",") != strings.Join(tt.wantOutlookIDs, ",") {
t.Fatalf("SPCConvectiveOutlooks IDs = %#v, want %#v", got, tt.wantOutlookIDs)
}
if got := discussionHeadlines(derived.SPCConvectiveDiscussions); strings.Join(got, ",") != strings.Join(tt.wantDiscussion, ",") {
t.Fatalf("SPCConvectiveDiscussions = %#v, want %#v", got, tt.wantDiscussion)
}
})
}
}
func TestBuildDerivedUnsupportedReportReturnsActionableError(t *testing.T) {
location := testLocation()
resolved := resolveForTest(t, report.Hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
resolved.Definition.ID = report.ID("future_report")
_, err := BuildDerived(BuildDerivedRequest{
Resolved: resolved,
Timezone: location.String(),
Dayparts: testDayparts(),
Collected: BuildCollected(testBundle(location)),
})
if err == nil || !strings.Contains(err.Error(), `derived facts are not implemented for report "future_report"`) {
t.Fatalf("BuildDerived() error = %v, want actionable unsupported report error", err)
}
}
func TestBuildDerivedSPCConvectiveOutlooksDistinguishesMissingAndCheckedEmpty(t *testing.T) {
location := testLocation()
resolved := resolveForTest(t, report.Daily, mustParse("2026-05-29T08:00:00-05:00"), location)
bundle := testBundle(location)
bundle.SPCConvectiveOutlooks = nil
derived, err := BuildDerived(BuildDerivedRequest{
Resolved: resolved,
Timezone: location.String(),
Dayparts: testDayparts(),
Collected: BuildCollected(bundle),
})
if err != nil {
t.Fatalf("BuildDerived(missing source) error = %v", err)
}
if derived.SPCConvectiveOutlooks != nil || derived.SPCConvectiveDiscussions != nil {
t.Fatalf("missing source derived outlooks=%#v discussions=%#v, want nil slices", derived.SPCConvectiveOutlooks, derived.SPCConvectiveDiscussions)
}
bundle.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{Outlooks: []weatherdata.ConvectiveOutlook{}, Discussions: []weatherdata.ConvectiveOutlookDiscussion{}}
derived, err = BuildDerived(BuildDerivedRequest{
Resolved: resolved,
Timezone: location.String(),
Dayparts: testDayparts(),
Collected: BuildCollected(bundle),
})
if err != nil {
t.Fatalf("BuildDerived(checked empty source) error = %v", err)
}
if derived.SPCConvectiveOutlooks == nil || len(derived.SPCConvectiveOutlooks) != 0 {
t.Fatalf("checked empty outlooks = %#v, want non-nil empty slice", derived.SPCConvectiveOutlooks)
}
if derived.SPCConvectiveDiscussions == nil || len(derived.SPCConvectiveDiscussions) != 0 {
t.Fatalf("checked empty discussions = %#v, want non-nil empty slice", derived.SPCConvectiveDiscussions)
}
}
func testBundle(location *time.Location) *weatherdata.Bundle { func testBundle(location *time.Location) *weatherdata.Bundle {
return &weatherdata.Bundle{ return &weatherdata.Bundle{
FetchedAt: mustParse("2026-05-29T10:00:00Z"), FetchedAt: mustParse("2026-05-29T10:00:00Z"),
@@ -193,6 +384,65 @@ func testBundle(location *time.Location) *weatherdata.Bundle {
} }
} }
func testConvectiveOutlookRun(location *time.Location) *weatherdata.ConvectiveOutlookRun {
rank1 := 1
rank2 := 2
rank3 := 3
rank4 := 4
rank5 := 5
return &weatherdata.ConvectiveOutlookRun{
Outlooks: []weatherdata.ConvectiveOutlook{
convectiveOutlook(location, "mon-outside", 4, "categorical", "MDT", &rank5, "2026-06-01T00:00:00-05:00", "2026-06-02T00:00:00-05:00"),
convectiveOutlook(location, "sun-slight", 3, "categorical", "SLGT", &rank3, "2026-05-31T06:00:00-05:00", "2026-06-01T00:00:00-05:00"),
convectiveOutlook(location, "fri-low", 1, "categorical", "MRGL", &rank1, "2026-05-29T06:00:00-05:00", "2026-05-29T12:00:00-05:00"),
convectiveOutlook(location, "fri-missing-rank", 1, "categorical", "GEN", nil, "2026-05-29T08:00:00-05:00", "2026-05-29T10:00:00-05:00"),
convectiveOutlook(location, "sat-enhanced", 2, "categorical", "ENH", &rank4, "2026-05-30T01:00:00-05:00", "2026-05-30T12:00:00-05:00"),
convectiveOutlook(location, "fri-high", 1, "categorical", "SLGT", &rank3, "2026-05-29T07:00:00-05:00", "2026-05-29T11:00:00-05:00"),
convectiveOutlook(location, "fri-probabilistic", 1, "probabilistic", "5%", &rank2, "2026-05-29T05:00:00-05:00", "2026-05-29T10:00:00-05:00"),
convectiveOutlook(location, "fri-storm", 1, "categorical", "SLGT", &rank2, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00"),
},
Discussions: []weatherdata.ConvectiveOutlookDiscussion{
{Day: 4, Headline: "day4", UpdatedAt: ptrTime(mustParse("2026-05-31T10:00:00-05:00"))},
{Day: 1, Headline: "day1 late", UpdatedAt: ptrTime(mustParse("2026-05-29T09:00:00-05:00"))},
{Day: 3, Headline: "day3", UpdatedAt: ptrTime(mustParse("2026-05-31T08:00:00-05:00"))},
{Day: 1, Headline: "day1 early", UpdatedAt: ptrTime(mustParse("2026-05-29T08:00:00-05:00"))},
{Day: 2, Headline: "day2", UpdatedAt: ptrTime(mustParse("2026-05-30T08:00:00-05:00"))},
},
}
}
func convectiveOutlook(location *time.Location, id string, day int, outlookType string, label string, rank *int, validFrom string, validTo string) weatherdata.ConvectiveOutlook {
return weatherdata.ConvectiveOutlook{
ID: id,
Day: day,
OutlookType: outlookType,
Label: label,
SeverityRank: rank,
ValidFrom: mustParse(validFrom).In(location),
ValidTo: mustParse(validTo).In(location),
}
}
func outlookIDs(outlooks []weatherdata.ConvectiveOutlook) []string {
out := make([]string, 0, len(outlooks))
for _, outlook := range outlooks {
out = append(out, outlook.ID)
}
return out
}
func discussionHeadlines(discussions []weatherdata.ConvectiveOutlookDiscussion) []string {
out := make([]string, 0, len(discussions))
for _, discussion := range discussions {
out = append(out, discussion.Headline)
}
return out
}
func ptrTime(value time.Time) *time.Time {
return &value
}
func hour(location *time.Location, start string, end string, text string, precip float64, gust float64) weatherdata.ForecastPeriod { func hour(location *time.Location, start string, end string, text string, precip float64, gust float64) weatherdata.ForecastPeriod {
temperature := 70.0 temperature := 70.0
return weatherdata.ForecastPeriod{ return weatherdata.ForecastPeriod{
@@ -215,7 +465,11 @@ func testDayparts() []forecast.DaypartDefinition {
func resolveForTest(t *testing.T, id report.ID, now time.Time, location *time.Location) report.Resolved { func resolveForTest(t *testing.T, id report.ID, now time.Time, location *time.Location) report.Resolved {
t.Helper() t.Helper()
resolved, err := report.Resolve(id, report.ResolveRequest{Now: now, Location: location}) req := report.ResolveRequest{Now: now, Location: location}
if id == report.Daily {
req.Date = now
}
resolved, err := report.Resolve(id, req)
if err != nil { if err != nil {
t.Fatalf("resolve %s: %v", id, err) t.Fatalf("resolve %s: %v", id, err)
} }

View File

@@ -65,6 +65,7 @@ type AlertOverlap struct {
Period timeutil.Period `json:"period"` Period timeutil.Period `json:"period"`
Overlap timeutil.Period `json:"overlap"` Overlap timeutil.Period `json:"overlap"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Instruction string `json:"instruction,omitempty"`
} }
type PrecipTiming struct { type PrecipTiming struct {
@@ -81,6 +82,7 @@ type PrecipitationWindow struct {
End *time.Time `json:"end,omitempty"` End *time.Time `json:"end,omitempty"`
MaxPrecipitationProbability TimedValue `json:"maxPrecipitationProbability"` MaxPrecipitationProbability TimedValue `json:"maxPrecipitationProbability"`
ProbabilityThreshold float64 `json:"probabilityThreshold"` ProbabilityThreshold float64 `json:"probabilityThreshold"`
TextDescriptions []string `json:"textDescriptions,omitempty"`
} }
func BuildPrecipTiming(periods []weatherdata.ForecastPeriod) PrecipTiming { func BuildPrecipTiming(periods []weatherdata.ForecastPeriod) PrecipTiming {
@@ -114,6 +116,7 @@ func buildPrecipTimingWithThreshold(periods []weatherdata.ForecastPeriod, thresh
}, },
ProbabilityThreshold: threshold, ProbabilityThreshold: threshold,
} }
appendActiveTextDescription(active, forecastPeriod.TextDescription)
activeLastEnd = forecastPeriod.EndTime activeLastEnd = forecastPeriod.EndTime
if timing.FirstPrecipitation == nil { if timing.FirstPrecipitation == nil {
timing.FirstPrecipitation = &TimedValue{ timing.FirstPrecipitation = &TimedValue{
@@ -135,6 +138,8 @@ func buildPrecipTimingWithThreshold(periods []weatherdata.ForecastPeriod, thresh
if forecastPeriod.StartTime.After(activeLastEnd) { if forecastPeriod.StartTime.After(activeLastEnd) {
closeActive() closeActive()
startActive(forecastPeriod, probability) startActive(forecastPeriod, probability)
} else {
appendActiveTextDescription(active, forecastPeriod.TextDescription)
} }
if probability > active.MaxPrecipitationProbability.Value { if probability > active.MaxPrecipitationProbability.Value {
active.MaxPrecipitationProbability = TimedValue{ active.MaxPrecipitationProbability = TimedValue{
@@ -166,6 +171,17 @@ func buildPrecipTimingWithThreshold(periods []weatherdata.ForecastPeriod, thresh
return timing return timing
} }
func appendActiveTextDescription(window *PrecipitationWindow, text string) {
if window == nil {
return
}
text = strings.TrimSpace(text)
if text == "" {
return
}
window.TextDescriptions = append(window.TextDescriptions, text)
}
func BuildDailySummary(bundle *weatherdata.Bundle, date time.Time, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) { func BuildDailySummary(bundle *weatherdata.Bundle, date time.Time, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) {
if bundle == nil { if bundle == nil {
return nil, fmt.Errorf("forecast bundle is required") return nil, fmt.Errorf("forecast bundle is required")
@@ -448,6 +464,7 @@ func AlertOverlaps(alertRun *weatherdata.AlertRun, period timeutil.Period) []Ale
Period: alert.Period, Period: alert.Period,
Overlap: intersect(alert.Period, period), Overlap: intersect(alert.Period, period),
Description: alert.Description, Description: alert.Description,
Instruction: alert.Instruction,
}) })
} }
sort.SliceStable(overlaps, func(i int, j int) bool { sort.SliceStable(overlaps, func(i int, j int) bool {
@@ -461,6 +478,7 @@ type parsedAlert struct {
Headline string Headline string
Severity string Severity string
Description string Description string
Instruction string
Period timeutil.Period Period timeutil.Period
} }
@@ -473,14 +491,19 @@ func parseAlert(raw json.RawMessage) (parsedAlert, bool) {
Event: stringField(fields, "event"), Event: stringField(fields, "event"),
Headline: firstStringField(fields, "headline", "title"), Headline: firstStringField(fields, "headline", "title"),
Severity: stringField(fields, "severity"), Severity: stringField(fields, "severity"),
Description: firstStringField(fields, "description", "instruction"), Description: stringField(fields, "description"),
Instruction: collapseWhitespace(stringField(fields, "instruction")),
} }
start, startOK := firstTimeField(fields, "effective", "onset", "startsAt", "startTime", "sent") start, startOK := firstTimeField(fields, "onset", "startsAt", "startTime", "effective", "sent")
end, endOK := firstTimeField(fields, "expires", "ends", "endsAt", "endTime") end, endOK := firstTimeField(fields, "ends", "endsAt", "endTime", "expires")
if !startOK || !endOK { if !startOK || !endOK {
return parsedAlert{}, false return parsedAlert{}, false
} }
alert.Period = timeutil.Period{Start: start, End: end} period := timeutil.Period{Start: start, End: end}
if !period.IsValid() {
return parsedAlert{}, false
}
alert.Period = period
return alert, true return alert, true
} }
@@ -519,6 +542,10 @@ func firstTimeField(fields map[string]json.RawMessage, names ...string) (time.Ti
return time.Time{}, false return time.Time{}, false
} }
func collapseWhitespace(value string) string {
return strings.Join(strings.Fields(value), " ")
}
func intersect(left timeutil.Period, right timeutil.Period) timeutil.Period { func intersect(left timeutil.Period, right timeutil.Period) timeutil.Period {
start := left.Start start := left.Start
if right.Start.After(start) { if right.Start.After(start) {

View File

@@ -210,6 +210,115 @@ func TestAlertOverlap(t *testing.T) {
} }
} }
func TestAlertOverlapUsesOnsetAndEnds(t *testing.T) {
raw := json.RawMessage(`{"event":"Wind Advisory","headline":"Wind Advisory issued June 16 at 12:39PM CDT until June 17 at 8:00PM CDT by NWS St Louis MO","severity":"Moderate","description":"Southwest winds 20 to 30 mph with gusts up to 45 mph expected.","instruction":"Secure outdoor objects.\n\nUse extra\tcaution.","effective":"2026-06-16T17:39:00Z","onset":"2026-06-17T18:00:00Z","ends":"2026-06-18T01:00:00Z","expires":"2026-06-17T08:45:00Z"}`)
alertRun := &weatherdata.AlertRun{Alerts: []json.RawMessage{raw}}
period := timeutil.Period{
Start: mustParse("2026-06-17T12:00:00-05:00"),
End: mustParse("2026-06-17T21:00:00-05:00"),
}
overlaps := AlertOverlaps(alertRun, period)
if len(overlaps) != 1 {
t.Fatalf("overlaps length = %d, want 1", len(overlaps))
}
if overlaps[0].Period.Start.Format(time.RFC3339) != "2026-06-17T18:00:00Z" {
t.Fatalf("alert period start = %s, want onset", overlaps[0].Period.Start.Format(time.RFC3339))
}
if overlaps[0].Period.End.Format(time.RFC3339) != "2026-06-18T01:00:00Z" {
t.Fatalf("alert period end = %s, want ends", overlaps[0].Period.End.Format(time.RFC3339))
}
if overlaps[0].Overlap.Start.Format(time.RFC3339) != "2026-06-17T18:00:00Z" {
t.Fatalf("overlap start = %s, want onset", overlaps[0].Overlap.Start.Format(time.RFC3339))
}
if overlaps[0].Overlap.End.Format(time.RFC3339) != "2026-06-18T01:00:00Z" {
t.Fatalf("overlap end = %s, want alert ends", overlaps[0].Overlap.End.Format(time.RFC3339))
}
if overlaps[0].Description != "Southwest winds 20 to 30 mph with gusts up to 45 mph expected." {
t.Fatalf("Description = %q, want preserved description", overlaps[0].Description)
}
if overlaps[0].Instruction != "Secure outdoor objects. Use extra caution." {
t.Fatalf("Instruction = %q, want preserved instruction", overlaps[0].Instruction)
}
}
func TestCollapseWhitespace(t *testing.T) {
tests := []struct {
name string
value string
want string
}{
{name: "newlines", value: "Secure outdoor objects.\nUse extra caution.", want: "Secure outdoor objects. Use extra caution."},
{name: "crlf", value: "Secure outdoor objects.\r\nUse extra caution.", want: "Secure outdoor objects. Use extra caution."},
{name: "tabs and repeated spaces", value: "Secure\toutdoor objects.", want: "Secure outdoor objects."},
{name: "leading trailing", value: " Secure outdoor objects. ", want: "Secure outdoor objects."},
{name: "empty", value: "", want: ""},
{name: "all whitespace", value: " \n\t\r\n ", want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := collapseWhitespace(tt.value); got != tt.want {
t.Fatalf("collapseWhitespace(%q) = %q, want %q", tt.value, got, tt.want)
}
})
}
}
func TestAlertOverlapUsesOnsetAndEndsForLocalDateRelevance(t *testing.T) {
location, err := time.LoadLocation("America/Chicago")
if err != nil {
t.Fatalf("load location: %v", err)
}
raw := json.RawMessage(`{"event":"Wind Advisory","headline":"Wind Advisory issued June 16 at 12:39PM CDT until June 17 at 8:00PM CDT by NWS St Louis MO","severity":"Moderate","effective":"2026-06-16T17:39:00Z","onset":"2026-06-17T18:00:00Z","ends":"2026-06-18T01:00:00Z","expires":"2026-06-17T08:45:00Z"}`)
alertRun := &weatherdata.AlertRun{Alerts: []json.RawMessage{raw}}
june16 := timeutil.CivilDay(time.Date(2026, 6, 16, 12, 0, 0, 0, location), location)
june17Afternoon := timeutil.Period{
Start: time.Date(2026, 6, 17, 12, 0, 0, 0, location),
End: time.Date(2026, 6, 17, 21, 0, 0, 0, location),
}
if overlaps := AlertOverlaps(alertRun, june16); len(overlaps) != 0 {
t.Fatalf("June 16 overlaps length = %d, want none: %#v", len(overlaps), overlaps)
}
if overlaps := AlertOverlaps(alertRun, june17Afternoon); len(overlaps) != 1 {
t.Fatalf("June 17 afternoon overlaps length = %d, want 1: %#v", len(overlaps), overlaps)
}
}
func TestAlertOverlapSupportsOlderEffectiveExpiresPayload(t *testing.T) {
raw := json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T07:00:00-05:00","expires":"2026-05-29T10:00:00-05:00"}`)
alertRun := &weatherdata.AlertRun{Alerts: []json.RawMessage{raw}}
period := timeutil.Period{
Start: mustParse("2026-05-29T06:00:00-05:00"),
End: mustParse("2026-05-29T11:00:00-05:00"),
}
overlaps := AlertOverlaps(alertRun, period)
if len(overlaps) != 1 {
t.Fatalf("overlaps length = %d, want 1", len(overlaps))
}
if overlaps[0].Period.Start.Format(time.RFC3339) != "2026-05-29T07:00:00-05:00" {
t.Fatalf("alert period start = %s, want effective fallback", overlaps[0].Period.Start.Format(time.RFC3339))
}
if overlaps[0].Period.End.Format(time.RFC3339) != "2026-05-29T10:00:00-05:00" {
t.Fatalf("alert period end = %s, want expires fallback", overlaps[0].Period.End.Format(time.RFC3339))
}
}
func TestAlertOverlapSkipsInvalidSelectedPeriod(t *testing.T) {
raw := json.RawMessage(`{"event":"Wind Advisory","headline":"Invalid event period","severity":"Moderate","onset":"2026-06-17T18:00:00Z","ends":"2026-06-17T08:45:00Z"}`)
alertRun := &weatherdata.AlertRun{Alerts: []json.RawMessage{raw}}
period := timeutil.Period{
Start: mustParse("2026-06-17T00:00:00Z"),
End: mustParse("2026-06-18T00:00:00Z"),
}
if overlaps := AlertOverlaps(alertRun, period); len(overlaps) != 0 {
t.Fatalf("overlaps length = %d, want invalid alert skipped: %#v", len(overlaps), overlaps)
}
}
func TestBuildPrecipTimingBuildsThresholdWindows(t *testing.T) { func TestBuildPrecipTimingBuildsThresholdWindows(t *testing.T) {
location := time.FixedZone("Test", -5*60*60) location := time.FixedZone("Test", -5*60*60)
periods := []weatherdata.ForecastPeriod{ periods := []weatherdata.ForecastPeriod{
@@ -245,10 +354,16 @@ func TestBuildPrecipTimingBuildsThresholdWindows(t *testing.T) {
if first.MaxPrecipitationProbability.Value != 60 || first.MaxPrecipitationProbability.Time.Format(time.RFC3339) != "2026-05-29T10:00:00-05:00" { if first.MaxPrecipitationProbability.Value != 60 || first.MaxPrecipitationProbability.Time.Format(time.RFC3339) != "2026-05-29T10:00:00-05:00" {
t.Fatalf("first window max = %#v, want 60 at 10 AM", first.MaxPrecipitationProbability) t.Fatalf("first window max = %#v, want 60 at 10 AM", first.MaxPrecipitationProbability)
} }
if len(first.TextDescriptions) != 2 || first.TextDescriptions[0] != "Showers" || first.TextDescriptions[1] != "Rain likely" {
t.Fatalf("first window text descriptions = %#v, want contributing hourly descriptions", first.TextDescriptions)
}
second := timing.PrecipitationWindows[1] second := timing.PrecipitationWindows[1]
if second.Start.Format(time.RFC3339) != "2026-05-29T12:00:00-05:00" || second.End == nil || second.End.Format(time.RFC3339) != "2026-05-29T13:00:00-05:00" { if second.Start.Format(time.RFC3339) != "2026-05-29T12:00:00-05:00" || second.End == nil || second.End.Format(time.RFC3339) != "2026-05-29T13:00:00-05:00" {
t.Fatalf("second window = %#v, want noon-1 PM", second) t.Fatalf("second window = %#v, want noon-1 PM", second)
} }
if len(second.TextDescriptions) != 1 || second.TextDescriptions[0] != "Thunderstorms" {
t.Fatalf("second window text descriptions = %#v, want thunderstorm description", second.TextDescriptions)
}
if !timing.ThunderMentioned { if !timing.ThunderMentioned {
t.Fatal("ThunderMentioned = false, want true") t.Fatal("ThunderMentioned = false, want true")
} }

View File

@@ -0,0 +1,195 @@
package generatedtext
import (
"fmt"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"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/reporttemplate"
)
const (
schemaIDHourly = "hourly"
schemaIDDaily = "daily"
schemaIDToday = "today"
schemaIDTomorrow = "tomorrow"
templateIDHourly = "hourly"
templateIDDaily = "daily"
templateIDToday = "today"
templateIDTomorrow = "tomorrow"
)
type validator func([]byte) (any, []byte, error)
type renderContextBuilder func(report.ID, string, briefing.Metadata, module.Snapshot, facts.CollectedFacts, facts.DerivedFacts, any) (any, error)
type catalogEntry struct {
schemaID string
templateID string
validate validator
renderContextBuilder renderContextBuilder
}
type Handler struct {
reportID report.ID
schemaID string
templateID string
validate validator
renderContextBuilder renderContextBuilder
}
var catalog = []catalogEntry{
{
schemaID: schemaIDHourly,
templateID: templateIDHourly,
validate: validateHourly,
renderContextBuilder: buildHourlyContext,
},
{
schemaID: schemaIDDaily,
templateID: templateIDDaily,
validate: validateDaily,
renderContextBuilder: buildDailyContext,
},
{
schemaID: schemaIDToday,
templateID: templateIDToday,
validate: validateToday,
renderContextBuilder: buildTodayContext,
},
{
schemaID: schemaIDTomorrow,
templateID: templateIDTomorrow,
validate: validateTomorrow,
renderContextBuilder: buildTomorrowContext,
},
}
func LookupDefinition(definition report.Definition) (Handler, error) {
if definition.GenerationMode != report.GenerationModeGeneratedTextTemplate {
return Handler{}, fmt.Errorf("report %q uses generation mode %q, not %q", definition.ID, definition.GenerationMode, report.GenerationModeGeneratedTextTemplate)
}
var schemaKnown, templateKnown bool
for _, entry := range catalog {
if entry.schemaID == definition.GeneratedTextSchemaID {
schemaKnown = true
}
if entry.templateID == definition.TemplateID {
templateKnown = true
}
if entry.schemaID == definition.GeneratedTextSchemaID && entry.templateID == definition.TemplateID {
return Handler{
reportID: definition.ID,
schemaID: entry.schemaID,
templateID: entry.templateID,
validate: entry.validate,
renderContextBuilder: entry.renderContextBuilder,
}, nil
}
}
if !schemaKnown {
return Handler{}, fmt.Errorf("generated text schema %q is not supported for report %q", definition.GeneratedTextSchemaID, definition.ID)
}
if !templateKnown {
return Handler{}, fmt.Errorf("report template %q is not supported for report %q", definition.TemplateID, definition.ID)
}
return Handler{}, fmt.Errorf("generated text schema %q and report template %q are not supported together for report %q", definition.GeneratedTextSchemaID, definition.TemplateID, definition.ID)
}
func (h Handler) SchemaID() string {
return h.schemaID
}
func (h Handler) TemplateID() string {
return h.templateID
}
func (h Handler) Schema() ([]byte, error) {
data, err := reporttemplate.Schema(h.schemaID)
if err != nil {
return nil, fmt.Errorf("load generated text schema %q for report %q: %w", h.schemaID, h.reportID, err)
}
return data, nil
}
func (h Handler) Template() (string, error) {
source, err := reporttemplate.Template(h.templateID)
if err != nil {
return "", fmt.Errorf("load report template %q for report %q: %w", h.templateID, h.reportID, err)
}
return source, nil
}
func (h Handler) Validate(data []byte) (any, []byte, error) {
if h.validate == nil {
return nil, nil, fmt.Errorf("generated text validator is not registered for schema %q on report %q", h.schemaID, h.reportID)
}
return h.validate(data)
}
func (h Handler) BuildRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
if h.renderContextBuilder == nil {
return nil, fmt.Errorf("render-context builder is not registered for template %q on report %q", h.templateID, h.reportID)
}
return h.renderContextBuilder(h.reportID, h.templateID, metadata, snapshot, collected, derived, generated)
}
func (h Handler) Render(data any) ([]byte, error) {
rendered, err := reporttemplate.Render(h.templateID, data)
if err != nil {
return nil, fmt.Errorf("render report template %q for report %q: %w", h.templateID, h.reportID, err)
}
return rendered, nil
}
func validateHourly(data []byte) (any, []byte, error) {
return ValidateHourly(data)
}
func validateDaily(data []byte) (any, []byte, error) {
return ValidateDaily(data)
}
func validateToday(data []byte) (any, []byte, error) {
return ValidateToday(data)
}
func validateTomorrow(data []byte) (any, []byte, error) {
return ValidateTomorrow(data)
}
func buildHourlyContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
hourly, ok := generated.(Hourly)
if !ok {
return nil, fmt.Errorf("report template %q requires hourly generated text for report %q", templateID, reportID)
}
return BuildHourlyRenderContext(metadata, snapshot, hourly, collected, derived)
}
func buildDailyContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
daily, ok := generated.(Daily)
if !ok {
return nil, fmt.Errorf("report template %q requires daily generated text for report %q", templateID, reportID)
}
return BuildDailyRenderContext(metadata, snapshot, daily, collected, derived)
}
func buildTodayContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
today, ok := generated.(Today)
if !ok {
return nil, fmt.Errorf("report template %q requires today generated text for report %q", templateID, reportID)
}
return BuildTodayRenderContext(metadata, snapshot, today, collected, derived)
}
func buildTomorrowContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
tomorrow, ok := generated.(Tomorrow)
if !ok {
return nil, fmt.Errorf("report template %q requires tomorrow generated text for report %q", templateID, reportID)
}
return BuildTomorrowRenderContext(metadata, snapshot, tomorrow, collected, derived)
}

View File

@@ -0,0 +1,324 @@
package generatedtext
import (
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
func TestCatalogCompleteForGeneratedTextTemplateReports(t *testing.T) {
for _, definition := range report.DefaultRegistry().All() {
if definition.GenerationMode != report.GenerationModeGeneratedTextTemplate {
continue
}
t.Run(string(definition.ID), func(t *testing.T) {
handler, err := LookupDefinition(definition)
if err != nil {
t.Fatalf("LookupDefinition() error = %v", err)
}
if handler.SchemaID() != definition.GeneratedTextSchemaID {
t.Fatalf("SchemaID() = %q, want %q", handler.SchemaID(), definition.GeneratedTextSchemaID)
}
if handler.TemplateID() != definition.TemplateID {
t.Fatalf("TemplateID() = %q, want %q", handler.TemplateID(), definition.TemplateID)
}
if handler.validate == nil {
t.Fatal("validator is nil")
}
if handler.renderContextBuilder == nil {
t.Fatal("render-context builder is nil")
}
if schema, err := handler.Schema(); err != nil {
t.Fatalf("Schema() error = %v", err)
} else if len(schema) == 0 {
t.Fatal("Schema() returned empty asset")
}
if template, err := handler.Template(); err != nil {
t.Fatalf("Template() error = %v", err)
} else if template == "" {
t.Fatal("Template() returned empty asset")
}
})
}
}
func TestCatalogLookupRejectsUnsupportedSchemaAndTemplate(t *testing.T) {
tests := []struct {
name string
definition report.Definition
want []string
}{
{
name: "schema",
definition: func() report.Definition {
definition := report.DefaultRegistry().MustLookup(report.Hourly)
definition.GeneratedTextSchemaID = "missing-schema"
return definition
}(),
want: []string{`generated text schema "missing-schema"`, `report "hourly"`},
},
{
name: "template",
definition: func() report.Definition {
definition := report.DefaultRegistry().MustLookup(report.Hourly)
definition.TemplateID = "missing-template"
return definition
}(),
want: []string{`report template "missing-template"`, `report "hourly"`},
},
{
name: "combination",
definition: func() report.Definition {
definition := report.DefaultRegistry().MustLookup(report.Hourly)
definition.TemplateID = "tomorrow"
return definition
}(),
want: []string{`generated text schema "hourly"`, `report template "tomorrow"`, `report "hourly"`},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := LookupDefinition(test.definition)
if err == nil {
t.Fatal("LookupDefinition() error = nil, want error")
}
for _, want := range test.want {
if !strings.Contains(err.Error(), want) {
t.Fatalf("LookupDefinition() error = %v, want %q", err, want)
}
}
})
}
}
func TestCatalogLookupSupportsTodayDefinition(t *testing.T) {
definition := report.Definition{
ID: report.Today,
GenerationMode: report.GenerationModeGeneratedTextTemplate,
GeneratedTextSchemaID: "today",
TemplateID: "today",
}
handler, err := LookupDefinition(definition)
if err != nil {
t.Fatalf("LookupDefinition(today) error = %v", err)
}
if handler.SchemaID() != "today" || handler.TemplateID() != "today" {
t.Fatalf("handler IDs = %q/%q, want today/today", handler.SchemaID(), handler.TemplateID())
}
if schema, err := handler.Schema(); err != nil {
t.Fatalf("Schema() error = %v", err)
} else if len(schema) == 0 {
t.Fatal("Schema() returned empty asset")
}
if template, err := handler.Template(); err != nil {
t.Fatalf("Template() error = %v", err)
} else if !strings.Contains(template, "# {{ .Report.Title }}") {
t.Fatalf("Template() = %q, want Today template source", template)
}
}
func TestCatalogLookupSupportsDailyDefinitionAssets(t *testing.T) {
definition := report.Definition{
ID: report.Daily,
GenerationMode: report.GenerationModeGeneratedTextTemplate,
GeneratedTextSchemaID: "daily",
TemplateID: "daily",
}
handler, err := LookupDefinition(definition)
if err != nil {
t.Fatalf("LookupDefinition(daily) error = %v", err)
}
if handler.SchemaID() != "daily" || handler.TemplateID() != "daily" {
t.Fatalf("handler IDs = %q/%q, want daily/daily", handler.SchemaID(), handler.TemplateID())
}
if schema, err := handler.Schema(); err != nil {
t.Fatalf("Schema() error = %v", err)
} else if len(schema) == 0 {
t.Fatal("Schema() returned empty asset")
}
if template, err := handler.Template(); err != nil {
t.Fatalf("Template() error = %v", err)
} else if !strings.Contains(template, "# {{ .Report.Title }}") {
t.Fatalf("Template() = %q, want Daily template source", template)
}
}
func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) {
hourlyHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Hourly))
if err != nil {
t.Fatalf("LookupDefinition(hourly) error = %v", err)
}
hourly, normalized, err := hourlyHandler.Validate([]byte(`{
"summary": " Storm chances increase. ",
"forecast_discussion": " A front will keep the region unsettled. "
}`))
if err != nil {
t.Fatalf("Validate(hourly) error = %v", err)
}
if _, ok := hourly.(Hourly); !ok {
t.Fatalf("hourly generated text type = %T, want generatedtext.Hourly", hourly)
}
if !strings.Contains(string(normalized), `"summary":"Storm chances increase."`) {
t.Fatalf("hourly normalized text = %s, want trimmed summary", normalized)
}
tomorrowHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Tomorrow))
if err != nil {
t.Fatalf("LookupDefinition(tomorrow) error = %v", err)
}
tomorrow, normalized, err := tomorrowHandler.Validate([]byte(`{
"summary": " Storms become more likely tomorrow. ",
"forecast_discussion": [" A front will keep showers in the forecast. ", ""]
}`))
if err != nil {
t.Fatalf("Validate(tomorrow) error = %v", err)
}
if _, ok := tomorrow.(Tomorrow); !ok {
t.Fatalf("tomorrow generated text type = %T, want generatedtext.Tomorrow", tomorrow)
}
if !strings.Contains(string(normalized), `"forecast_discussion":["A front will keep showers in the forecast."]`) {
t.Fatalf("tomorrow normalized text = %s, want trimmed discussion paragraph", normalized)
}
todayHandler, err := LookupDefinition(report.Definition{
ID: report.Today,
GenerationMode: report.GenerationModeGeneratedTextTemplate,
GeneratedTextSchemaID: "today",
TemplateID: "today",
})
if err != nil {
t.Fatalf("LookupDefinition(today) error = %v", err)
}
today, normalized, err := todayHandler.Validate([]byte(`{
"summary": " Showers are likely today. ",
"forecast_discussion": [" A front will keep rain chances elevated. ", ""]
}`))
if err != nil {
t.Fatalf("Validate(today) error = %v", err)
}
if _, ok := today.(Today); !ok {
t.Fatalf("today generated text type = %T, want generatedtext.Today", today)
}
if !strings.Contains(string(normalized), `"forecast_discussion":["A front will keep rain chances elevated."]`) {
t.Fatalf("today normalized text = %s, want trimmed discussion paragraph", normalized)
}
dailyHandler, err := LookupDefinition(report.Definition{
ID: report.Daily,
GenerationMode: report.GenerationModeGeneratedTextTemplate,
GeneratedTextSchemaID: "daily",
TemplateID: "daily",
})
if err != nil {
t.Fatalf("LookupDefinition(daily) error = %v", err)
}
daily, normalized, err := dailyHandler.Validate([]byte(`{
"summary": " Showers are possible during the selected day. ",
"forecast_discussion": [" A front will keep rain chances in the forecast. ", ""]
}`))
if err != nil {
t.Fatalf("Validate(daily) error = %v", err)
}
if _, ok := daily.(Daily); !ok {
t.Fatalf("daily generated text type = %T, want generatedtext.Daily", daily)
}
if !strings.Contains(string(normalized), `"forecast_discussion":["A front will keep rain chances in the forecast."]`) {
t.Fatalf("daily normalized text = %s, want trimmed discussion paragraph", normalized)
}
}
func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
hourlyHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Hourly))
if err != nil {
t.Fatalf("LookupDefinition(hourly) error = %v", err)
}
_, err = hourlyHandler.BuildRenderContext(testMetadata(), testSnapshot(t), testCollected(), testDerived(), Tomorrow{
Summary: "Storms become more likely tomorrow.",
ForecastDiscussion: []string{"A front will keep showers in the forecast."},
})
if err == nil {
t.Fatal("BuildRenderContext(hourly) error = nil, want type mismatch")
}
if !strings.Contains(err.Error(), `requires hourly generated text`) {
t.Fatalf("BuildRenderContext(hourly) error = %v, want hourly generated text requirement", err)
}
tomorrowHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Tomorrow))
if err != nil {
t.Fatalf("LookupDefinition(tomorrow) error = %v", err)
}
_, err = tomorrowHandler.BuildRenderContext(testTomorrowMetadata(), testTomorrowSnapshot(t), testCollected(), testTomorrowDerived(), Hourly{
Summary: "Storm chances increase.",
ForecastDiscussion: "A front will keep showers in the forecast.",
})
if err == nil {
t.Fatal("BuildRenderContext(tomorrow) error = nil, want type mismatch")
}
if !strings.Contains(err.Error(), `requires tomorrow generated text`) {
t.Fatalf("BuildRenderContext(tomorrow) error = %v, want tomorrow generated text requirement", err)
}
todayHandler, err := LookupDefinition(report.Definition{
ID: report.Today,
GenerationMode: report.GenerationModeGeneratedTextTemplate,
GeneratedTextSchemaID: "today",
TemplateID: "today",
})
if err != nil {
t.Fatalf("LookupDefinition(today) error = %v", err)
}
_, err = todayHandler.BuildRenderContext(testTodayMetadata(), testTodaySnapshot(t), testCollected(), testTodayDerived(), Tomorrow{
Summary: "Storms become more likely tomorrow.",
ForecastDiscussion: []string{"A front will keep showers in the forecast."},
})
if err == nil {
t.Fatal("BuildRenderContext(today) error = nil, want type mismatch")
}
if !strings.Contains(err.Error(), `requires today generated text`) {
t.Fatalf("BuildRenderContext(today) error = %v, want today generated text requirement", err)
}
dailyHandler, err := LookupDefinition(report.Definition{
ID: report.Daily,
GenerationMode: report.GenerationModeGeneratedTextTemplate,
GeneratedTextSchemaID: "daily",
TemplateID: "daily",
})
if err != nil {
t.Fatalf("LookupDefinition(daily) error = %v", err)
}
_, err = dailyHandler.BuildRenderContext(testDailyMetadata(), testDailySnapshot(t), testCollected(), testDailyDerived(), Tomorrow{
Summary: "Storms become more likely tomorrow.",
ForecastDiscussion: []string{"A front will keep showers in the forecast."},
})
if err == nil {
t.Fatal("BuildRenderContext(daily) error = nil, want type mismatch")
}
if !strings.Contains(err.Error(), `requires daily generated text`) {
t.Fatalf("BuildRenderContext(daily) error = %v, want daily generated text requirement", err)
}
}
func TestCatalogBuildRenderContextSupportsDaily(t *testing.T) {
handler, err := LookupDefinition(report.Definition{
ID: report.Daily,
GenerationMode: report.GenerationModeGeneratedTextTemplate,
GeneratedTextSchemaID: "daily",
TemplateID: "daily",
})
if err != nil {
t.Fatalf("LookupDefinition(daily) error = %v", err)
}
ctx, err := handler.BuildRenderContext(testDailyMetadata(), testDailySnapshot(t), testCollected(), testDailyDerived(), Daily{
Summary: "Showers are possible during the selected day.",
ForecastDiscussion: []string{"A front will keep rain chances in the forecast."},
})
if err != nil {
t.Fatalf("BuildRenderContext(daily) error = %v", err)
}
if _, ok := ctx.(DailyRenderContext); !ok {
t.Fatalf("BuildRenderContext(daily) = %T, want DailyRenderContext", ctx)
}
}

View File

@@ -0,0 +1,28 @@
package generatedtext
type Daily struct {
Summary string `json:"summary"`
ForecastDiscussion []string `json:"forecast_discussion"`
PrecipitationTiming string `json:"precipitation_timing,omitempty"`
Confidence string `json:"confidence,omitempty"`
}
func ValidateDaily(data []byte) (Daily, []byte, error) {
return validateDayStyleGeneratedText[Daily, *Daily](data, "daily")
}
func (d *Daily) dayStyleFields() dayStyleFields {
return dayStyleFields{
Summary: d.Summary,
ForecastDiscussion: d.ForecastDiscussion,
PrecipitationTiming: d.PrecipitationTiming,
Confidence: d.Confidence,
}
}
func (d *Daily) setDayStyleFields(fields dayStyleFields) {
d.Summary = fields.Summary
d.ForecastDiscussion = fields.ForecastDiscussion
d.PrecipitationTiming = fields.PrecipitationTiming
d.Confidence = fields.Confidence
}

View File

@@ -0,0 +1,111 @@
package generatedtext
import (
"strings"
"testing"
)
func TestValidateDailyNormalizesJSON(t *testing.T) {
value, normalized, err := ValidateDaily([]byte(`{
"summary": " Showers are possible during the selected day. ",
"forecast_discussion": [
" A front will keep rain chances in the forecast. ",
"",
" Temperatures stay seasonable by afternoon. "
],
"precipitation_timing": " Rain is most likely during the afternoon. ",
"confidence": " Medium "
}`))
if err != nil {
t.Fatalf("ValidateDaily() error = %v", err)
}
if value.Summary != "Showers are possible during the selected day." {
t.Fatalf("Summary = %q, want trimmed summary", value.Summary)
}
if strings.Join(value.ForecastDiscussion, "|") != "A front will keep rain chances in the forecast.|Temperatures stay seasonable by afternoon." {
t.Fatalf("ForecastDiscussion = %#v, want trimmed non-empty paragraphs", value.ForecastDiscussion)
}
if value.PrecipitationTiming != "Rain is most likely during the afternoon." {
t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming)
}
want := `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast.","Temperatures stay seasonable by afternoon."],"precipitation_timing":"Rain is most likely during the afternoon.","confidence":"Medium"}`
if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want)
}
}
func TestValidateDailyOmitsEmptyOptionalFields(t *testing.T) {
_, normalized, err := ValidateDaily([]byte(`{
"summary": "Showers are possible during the selected day.",
"forecast_discussion": ["A front will keep rain chances in the forecast."],
"precipitation_timing": " ",
"confidence": " "
}`))
if err != nil {
t.Fatalf("ValidateDaily() error = %v", err)
}
want := `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."]}`
if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want)
}
}
func TestValidateDailyRejectsInvalidInput(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{
name: "malformed",
in: `{`,
want: "decode daily generated text",
},
{
name: "unknown field",
in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."],"extra":"value"}`,
want: `unknown field "extra"`,
},
{
name: "missing summary",
in: `{"forecast_discussion":["A front will keep rain chances in the forecast."]}`,
want: "summary is required",
},
{
name: "blank summary",
in: `{"summary":" ","forecast_discussion":["A front will keep rain chances in the forecast."]}`,
want: "summary is required",
},
{
name: "missing forecast discussion",
in: `{"summary":"Showers are possible during the selected day."}`,
want: "forecast discussion is required",
},
{
name: "blank forecast discussion",
in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":[" ",""]}`,
want: "forecast discussion is required",
},
{
name: "forecast discussion wrong type",
in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":"A front will keep rain chances in the forecast."}`,
want: "cannot unmarshal string",
},
{
name: "multiple values",
in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."]} {}`,
want: "multiple JSON values",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, _, err := ValidateDaily([]byte(test.in))
if err == nil {
t.Fatal("ValidateDaily() error = nil, want error")
}
if !strings.Contains(err.Error(), test.want) {
t.Fatalf("ValidateDaily() error = %v, want %q", err, test.want)
}
})
}
}

View File

@@ -0,0 +1,63 @@
package generatedtext
import (
"fmt"
"strings"
)
type dayStyleFields struct {
Summary string
ForecastDiscussion []string
PrecipitationTiming string
Confidence string
}
type dayStyleGeneratedText interface {
dayStyleFields() dayStyleFields
setDayStyleFields(dayStyleFields)
}
func validateDayStyleGeneratedText[T any, PT interface {
*T
dayStyleGeneratedText
}](data []byte, name string) (T, []byte, error) {
value, err := decodeGeneratedText[T](data, name)
if err != nil {
var zero T
return zero, nil, err
}
pointer := PT(&value)
fields := pointer.dayStyleFields()
fields.Summary = strings.TrimSpace(fields.Summary)
fields.PrecipitationTiming = strings.TrimSpace(fields.PrecipitationTiming)
fields.Confidence = strings.TrimSpace(fields.Confidence)
fields.ForecastDiscussion = trimNonEmpty(fields.ForecastDiscussion)
if fields.Summary == "" {
var zero T
return zero, nil, fmt.Errorf("%s generated text summary is required", name)
}
if len(fields.ForecastDiscussion) == 0 {
var zero T
return zero, nil, fmt.Errorf("%s generated text forecast discussion is required", name)
}
pointer.setDayStyleFields(fields)
normalized, err := normalizeGeneratedText(value, name)
if err != nil {
var zero T
return zero, nil, err
}
return value, normalized, nil
}
func trimNonEmpty(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
out = append(out, trimmed)
}
}
return out
}

View File

@@ -0,0 +1,131 @@
package generatedtext
import (
"fmt"
"strings"
"testing"
)
func TestValidateDayStyleGeneratedTextSharedBehavior(t *testing.T) {
reports := []struct {
name string
validate func([]byte) (any, []byte, error)
}{
{
name: "daily",
validate: func(data []byte) (any, []byte, error) {
value, normalized, err := ValidateDaily(data)
return value, normalized, err
},
},
{
name: "today",
validate: func(data []byte) (any, []byte, error) {
value, normalized, err := ValidateToday(data)
return value, normalized, err
},
},
{
name: "tomorrow",
validate: func(data []byte) (any, []byte, error) {
value, normalized, err := ValidateTomorrow(data)
return value, normalized, err
},
},
}
for _, report := range reports {
t.Run(report.name, func(t *testing.T) {
t.Run("requires summary", func(t *testing.T) {
_, _, err := report.validate([]byte(`{"forecast_discussion":["First paragraph."]}`))
want := fmt.Sprintf("%s generated text summary is required", report.name)
if err == nil || err.Error() != want {
t.Fatalf("validate() error = %v, want %q", err, want)
}
})
t.Run("requires non-empty forecast discussion", func(t *testing.T) {
_, _, err := report.validate([]byte(`{"summary":"Shared summary.","forecast_discussion":[" ",""]}`))
want := fmt.Sprintf("%s generated text forecast discussion is required", report.name)
if err == nil || err.Error() != want {
t.Fatalf("validate() error = %v, want %q", err, want)
}
})
t.Run("trims and normalizes", func(t *testing.T) {
value, normalized, err := report.validate([]byte(`{
"summary": " Shared summary. ",
"forecast_discussion": [
" First paragraph. ",
"",
" Second paragraph. "
],
"precipitation_timing": " Afternoon. ",
"confidence": " Medium "
}`))
if err != nil {
t.Fatalf("validate() error = %v", err)
}
fields := dayStyleFieldsForTest(t, value)
if fields.Summary != "Shared summary." {
t.Fatalf("Summary = %q, want trimmed summary", fields.Summary)
}
if strings.Join(fields.ForecastDiscussion, "|") != "First paragraph.|Second paragraph." {
t.Fatalf("ForecastDiscussion = %#v, want trimmed non-empty paragraphs", fields.ForecastDiscussion)
}
if fields.PrecipitationTiming != "Afternoon." {
t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", fields.PrecipitationTiming)
}
if fields.Confidence != "Medium" {
t.Fatalf("Confidence = %q, want trimmed confidence", fields.Confidence)
}
want := `{"summary":"Shared summary.","forecast_discussion":["First paragraph.","Second paragraph."],"precipitation_timing":"Afternoon.","confidence":"Medium"}`
if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want)
}
})
t.Run("omits empty optional fields", func(t *testing.T) {
_, normalized, err := report.validate([]byte(`{
"summary": "Shared summary.",
"forecast_discussion": ["First paragraph."],
"precipitation_timing": " ",
"confidence": " "
}`))
if err != nil {
t.Fatalf("validate() error = %v", err)
}
want := `{"summary":"Shared summary.","forecast_discussion":["First paragraph."]}`
if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want)
}
})
t.Run("rejects unknown fields", func(t *testing.T) {
_, _, err := report.validate([]byte(`{"summary":"Shared summary.","forecast_discussion":["First paragraph."],"extra":"value"}`))
if err == nil {
t.Fatal("validate() error = nil, want error")
}
if !strings.Contains(err.Error(), `unknown field "extra"`) {
t.Fatalf("validate() error = %v, want unknown field error", err)
}
})
})
}
}
func dayStyleFieldsForTest(t *testing.T, value any) dayStyleFields {
t.Helper()
switch value := value.(type) {
case Daily:
return (&value).dayStyleFields()
case Today:
return (&value).dayStyleFields()
case Tomorrow:
return (&value).dayStyleFields()
default:
t.Fatalf("value type = %T, want day-style generated text", value)
return dayStyleFields{}
}
}

View File

@@ -0,0 +1,38 @@
// Package generatedtext validates structured LLM text and render contexts.
package generatedtext
import (
"fmt"
"strings"
)
type Hourly struct {
Summary string `json:"summary"`
ForecastDiscussion string `json:"forecast_discussion"`
PrecipitationTiming string `json:"precipitation_timing,omitempty"`
Confidence string `json:"confidence,omitempty"`
}
func ValidateHourly(data []byte) (Hourly, []byte, error) {
value, err := decodeGeneratedText[Hourly](data, "hourly")
if err != nil {
return Hourly{}, nil, err
}
value.Summary = strings.TrimSpace(value.Summary)
value.ForecastDiscussion = strings.TrimSpace(value.ForecastDiscussion)
value.PrecipitationTiming = strings.TrimSpace(value.PrecipitationTiming)
value.Confidence = strings.TrimSpace(value.Confidence)
if value.Summary == "" {
return Hourly{}, nil, fmt.Errorf("hourly generated text summary is required")
}
if value.ForecastDiscussion == "" {
return Hourly{}, nil, fmt.Errorf("hourly generated text forecast discussion is required")
}
normalized, err := normalizeGeneratedText(value, "hourly")
if err != nil {
return Hourly{}, nil, err
}
return value, normalized, nil
}

View File

@@ -0,0 +1,102 @@
package generatedtext
import (
"strings"
"testing"
)
func TestValidateHourlyNormalizesJSON(t *testing.T) {
value, normalized, err := ValidateHourly([]byte(`{
"summary": " Storm chances increase. ",
"forecast_discussion": " A front will keep the region unsettled. ",
"precipitation_timing": " Showers are most likely early this afternoon. ",
"confidence": " Medium "
}`))
if err != nil {
t.Fatalf("ValidateHourly() error = %v", err)
}
if value.Summary != "Storm chances increase." {
t.Fatalf("Summary = %q, want trimmed summary", value.Summary)
}
if value.ForecastDiscussion != "A front will keep the region unsettled." {
t.Fatalf("ForecastDiscussion = %q, want trimmed discussion", value.ForecastDiscussion)
}
if value.PrecipitationTiming != "Showers are most likely early this afternoon." {
t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming)
}
want := `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"Showers are most likely early this afternoon.","confidence":"Medium"}`
if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want)
}
}
func TestValidateHourlyOmitsEmptyConfidence(t *testing.T) {
_, normalized, err := ValidateHourly([]byte(`{
"summary": "Storm chances increase.",
"forecast_discussion": "A front will keep the region unsettled.",
"precipitation_timing": " ",
"confidence": " "
}`))
if err != nil {
t.Fatalf("ValidateHourly() error = %v", err)
}
want := `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled."}`
if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want)
}
}
func TestValidateHourlyRejectsInvalidInput(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{
name: "malformed",
in: `{`,
want: "decode hourly generated text",
},
{
name: "unknown field",
in: `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled.","extra":"value"}`,
want: `unknown field "extra"`,
},
{
name: "missing summary",
in: `{"forecast_discussion":"A front will keep the region unsettled."}`,
want: "summary is required",
},
{
name: "blank forecast discussion",
in: `{"summary":"Storm chances increase.","forecast_discussion":" "}`,
want: "forecast discussion is required",
},
{
name: "old timing field rejected",
in: `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled.","timing":"Late morning."}`,
want: `unknown field "timing"`,
},
{
name: "old impacts field rejected",
in: `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled.","impacts":"Brief downpours."}`,
want: `unknown field "impacts"`,
},
{
name: "multiple values",
in: `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled."} {}`,
want: "multiple JSON values",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, _, err := ValidateHourly([]byte(test.in))
if err == nil {
t.Fatal("ValidateHourly() error = nil, want error")
}
if !strings.Contains(err.Error(), test.want) {
t.Fatalf("ValidateHourly() error = %v, want %q", err, test.want)
}
})
}
}

View File

@@ -0,0 +1,33 @@
package generatedtext
import (
"bytes"
"encoding/json"
"fmt"
"io"
)
func decodeGeneratedText[T any](data []byte, name string) (T, error) {
var value T
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&value); err != nil {
return value, fmt.Errorf("decode %s generated text: %w", name, err)
}
var extra any
if err := decoder.Decode(&extra); err != nil {
if err != io.EOF {
return value, fmt.Errorf("decode %s generated text: %w", name, err)
}
return value, nil
}
return value, fmt.Errorf("decode %s generated text: multiple JSON values", name)
}
func normalizeGeneratedText[T any](value T, name string) ([]byte, error) {
normalized, err := json.Marshal(value)
if err != nil {
return nil, fmt.Errorf("normalize %s generated text: %w", name, err)
}
return normalized, nil
}

View File

@@ -0,0 +1,757 @@
package generatedtext
import (
"fmt"
"sort"
"strings"
"time"
"unicode"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"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/timeutil"
)
type HourlyRenderContext struct {
Report HourlyReportContext
GeneratedText Hourly
Modules HourlyTemplateModules
Collected facts.CollectedFacts
Derived facts.DerivedFacts
}
type HourlyReportContext struct {
Title string
LocationName string
GeneratedAt time.Time
GeneratedAtLabel string
ValidPeriod timeutil.Period
ValidPeriodLabel string
Timezone string
}
type HourlyTemplateModules struct {
Metadata *briefing.MetadataModule
CurrentConditions *briefing.CurrentConditionsModule
HourlyForecast *briefing.HourlyForecastModule
PrecipTiming *briefing.PrecipTimingModule
AlertDigest *briefing.AlertDigestModule
SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule
AreaForecastDiscussion *briefing.AreaForecastDiscussionModule
SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule
WeatherStory *briefing.WeatherStoryModule
}
type TomorrowRenderContext struct {
Report TomorrowReportContext
GeneratedText Tomorrow
Modules TomorrowTemplateModules
Collected facts.CollectedFacts
Derived facts.DerivedFacts
}
type DailyRenderContext struct {
Report DailyReportContext
GeneratedText Daily
Modules DailyTemplateModules
Collected facts.CollectedFacts
Derived facts.DerivedFacts
}
type TodayRenderContext struct {
Report TodayReportContext
GeneratedText Today
Modules TodayTemplateModules
Collected facts.CollectedFacts
Derived facts.DerivedFacts
}
type TodayReportContext struct {
Title string
ForecastDate time.Time
ForecastDateLabel string
ForecastDayName string
GeneratedAt time.Time
GeneratedAtLabel string
ValidPeriod timeutil.Period
Timezone string
}
type TodayTemplateModules struct {
Metadata *briefing.MetadataModule
CurrentConditions *briefing.CurrentConditionsModule
HourlyForecast *briefing.HourlyForecastModule
DerivedDailySummary *briefing.DerivedDailySummaryModule
DerivedDaypartSummaries *map[string]briefing.DerivedDaypartSummaryModule
Dayparts []TodayDaypartContext
PrecipTiming *briefing.PrecipTimingModule
AlertDigest *briefing.AlertDigestModule
SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule
AreaForecastDiscussion *briefing.AreaForecastDiscussionModule
SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule
WeatherStory *briefing.WeatherStoryModule
TodayPlanning *briefing.TodayPlanningModule
}
type TodayDaypartContext struct {
Key string
Summary briefing.DerivedDaypartSummaryModule
}
type TomorrowReportContext struct {
Title string
ForecastDate time.Time
ForecastDateLabel string
ForecastDayName string
GeneratedAt time.Time
GeneratedAtLabel string
ValidPeriod timeutil.Period
Timezone string
}
type TomorrowTemplateModules struct {
Metadata *briefing.MetadataModule
CurrentConditions *briefing.CurrentConditionsModule
HourlyForecast *briefing.HourlyForecastModule
DerivedDailySummary *briefing.DerivedDailySummaryModule
DerivedDaypartSummaries *map[string]briefing.DerivedDaypartSummaryModule
Dayparts []TomorrowDaypartContext
PrecipTiming *briefing.PrecipTimingModule
AlertDigest *briefing.AlertDigestModule
SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule
AreaForecastDiscussion *briefing.AreaForecastDiscussionModule
SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule
WeatherStory *briefing.WeatherStoryModule
TomorrowPlanning *briefing.TomorrowPlanningModule
}
type TomorrowDaypartContext struct {
Key string
Summary briefing.DerivedDaypartSummaryModule
}
type DailyReportContext struct {
Title string
ForecastDate time.Time
ForecastDateLabel string
ForecastDayName string
GeneratedAt time.Time
GeneratedAtLabel string
ValidPeriod timeutil.Period
Timezone string
}
type DailyTemplateModules struct {
Metadata *briefing.MetadataModule
CurrentConditions *briefing.CurrentConditionsModule
HourlyForecast *briefing.HourlyForecastModule
DerivedDailySummary *briefing.DerivedDailySummaryModule
DerivedDaypartSummaries *map[string]briefing.DerivedDaypartSummaryModule
Dayparts []DailyDaypartContext
PrecipTiming *briefing.PrecipTimingModule
AlertDigest *briefing.AlertDigestModule
SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule
AreaForecastDiscussion *briefing.AreaForecastDiscussionModule
SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule
WeatherStory *briefing.WeatherStoryModule
OutdoorWindows *briefing.OutdoorWindowsModule
DailyPlanning *briefing.DailyPlanningModule
}
type DailyDaypartContext struct {
Key string
Summary briefing.DerivedDaypartSummaryModule
}
type dayStyleReportContext struct {
Title string
ForecastDate time.Time
ForecastDateLabel string
ForecastDayName string
GeneratedAt time.Time
GeneratedAtLabel string
ValidPeriod timeutil.Period
Timezone string
}
type dayStyleTemplateModules struct {
Metadata *briefing.MetadataModule
CurrentConditions *briefing.CurrentConditionsModule
HourlyForecast *briefing.HourlyForecastModule
DerivedDailySummary *briefing.DerivedDailySummaryModule
DerivedDaypartSummaries *map[string]briefing.DerivedDaypartSummaryModule
PrecipTiming *briefing.PrecipTimingModule
AlertDigest *briefing.AlertDigestModule
SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule
AreaForecastDiscussion *briefing.AreaForecastDiscussionModule
SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule
WeatherStory *briefing.WeatherStoryModule
}
func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Hourly, collected facts.CollectedFacts, derived facts.DerivedFacts) (HourlyRenderContext, error) {
location, err := timeutil.LoadLocation(metadata.Timezone)
if err != nil {
return HourlyRenderContext{}, fmt.Errorf("build hourly render context: %w", err)
}
if metadata.GeneratedAt.IsZero() {
return HourlyRenderContext{}, fmt.Errorf("build hourly render context: generatedAt is required")
}
if !metadata.ValidPeriod.IsValid() {
return HourlyRenderContext{}, fmt.Errorf("build hourly render context: valid period is required")
}
modules, err := hourlyTemplateModules(snapshot)
if err != nil {
return HourlyRenderContext{}, err
}
return HourlyRenderContext{
Report: HourlyReportContext{
Title: "Hourly Report",
LocationName: locationName(metadata),
GeneratedAt: metadata.GeneratedAt,
GeneratedAtLabel: generatedAtLabel(metadata.GeneratedAt, location),
ValidPeriod: metadata.ValidPeriod,
ValidPeriodLabel: periodLabel(metadata.ValidPeriod, location),
Timezone: metadata.Timezone,
},
GeneratedText: generated,
Modules: modules,
Collected: collected,
Derived: derived,
}, nil
}
func BuildDailyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Daily, collected facts.CollectedFacts, derived facts.DerivedFacts) (DailyRenderContext, error) {
reportContext, err := buildDayStyleReportContext(metadata, "daily", func(dayName string) string {
return dayName + "'s Weather"
})
if err != nil {
return DailyRenderContext{}, err
}
modules, err := dailyTemplateModules(snapshot, derived)
if err != nil {
return DailyRenderContext{}, err
}
return DailyRenderContext{
Report: reportContext.dailyReportContext(),
GeneratedText: generated,
Modules: modules,
Collected: collected,
Derived: derived,
}, nil
}
func BuildTodayRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Today, collected facts.CollectedFacts, derived facts.DerivedFacts) (TodayRenderContext, error) {
reportContext, err := buildDayStyleReportContext(metadata, "today", func(string) string {
return "Today's Weather"
})
if err != nil {
return TodayRenderContext{}, err
}
modules, err := todayTemplateModules(snapshot, derived)
if err != nil {
return TodayRenderContext{}, err
}
return TodayRenderContext{
Report: reportContext.todayReportContext(),
GeneratedText: generated,
Modules: modules,
Collected: collected,
Derived: derived,
}, nil
}
func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Tomorrow, collected facts.CollectedFacts, derived facts.DerivedFacts) (TomorrowRenderContext, error) {
reportContext, err := buildDayStyleReportContext(metadata, "tomorrow", func(dayName string) string {
return dayName + "'s Weather"
})
if err != nil {
return TomorrowRenderContext{}, err
}
modules, err := tomorrowTemplateModules(snapshot, derived)
if err != nil {
return TomorrowRenderContext{}, err
}
return TomorrowRenderContext{
Report: reportContext.tomorrowReportContext(),
GeneratedText: generated,
Modules: modules,
Collected: collected,
Derived: derived,
}, nil
}
func buildDayStyleReportContext(metadata briefing.Metadata, name string, title func(string) string) (dayStyleReportContext, error) {
location, err := timeutil.LoadLocation(metadata.Timezone)
if err != nil {
return dayStyleReportContext{}, fmt.Errorf("build %s render context: %w", name, err)
}
if metadata.GeneratedAt.IsZero() {
return dayStyleReportContext{}, fmt.Errorf("build %s render context: generatedAt is required", name)
}
if !metadata.ValidPeriod.IsValid() {
return dayStyleReportContext{}, fmt.Errorf("build %s render context: valid period is required", name)
}
forecastDate := metadata.ValidPeriod.Start.In(location)
forecastDayName := forecastDate.Format("Monday")
return dayStyleReportContext{
Title: title(forecastDayName),
ForecastDate: forecastDate,
ForecastDateLabel: forecastDate.Format("Monday, January 2, 2006"),
ForecastDayName: forecastDayName,
GeneratedAt: metadata.GeneratedAt,
GeneratedAtLabel: generatedAtLabel(metadata.GeneratedAt, location),
ValidPeriod: metadata.ValidPeriod,
Timezone: metadata.Timezone,
}, nil
}
func (ctx dayStyleReportContext) dailyReportContext() DailyReportContext {
return DailyReportContext{
Title: ctx.Title,
ForecastDate: ctx.ForecastDate,
ForecastDateLabel: ctx.ForecastDateLabel,
ForecastDayName: ctx.ForecastDayName,
GeneratedAt: ctx.GeneratedAt,
GeneratedAtLabel: ctx.GeneratedAtLabel,
ValidPeriod: ctx.ValidPeriod,
Timezone: ctx.Timezone,
}
}
func (ctx dayStyleReportContext) todayReportContext() TodayReportContext {
return TodayReportContext{
Title: ctx.Title,
ForecastDate: ctx.ForecastDate,
ForecastDateLabel: ctx.ForecastDateLabel,
ForecastDayName: ctx.ForecastDayName,
GeneratedAt: ctx.GeneratedAt,
GeneratedAtLabel: ctx.GeneratedAtLabel,
ValidPeriod: ctx.ValidPeriod,
Timezone: ctx.Timezone,
}
}
func (ctx dayStyleReportContext) tomorrowReportContext() TomorrowReportContext {
return TomorrowReportContext{
Title: ctx.Title,
ForecastDate: ctx.ForecastDate,
ForecastDateLabel: ctx.ForecastDateLabel,
ForecastDayName: ctx.ForecastDayName,
GeneratedAt: ctx.GeneratedAt,
GeneratedAtLabel: ctx.GeneratedAtLabel,
ValidPeriod: ctx.ValidPeriod,
Timezone: ctx.Timezone,
}
}
func dailyTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (DailyTemplateModules, error) {
common, lookup, err := dayStyleTemplateModuleSnapshot(snapshot)
if err != nil {
return DailyTemplateModules{}, err
}
outdoor, err := lookup.outdoorWindows()
if err != nil {
return DailyTemplateModules{}, err
}
planning, err := lookup.dailyPlanning()
if err != nil {
return DailyTemplateModules{}, err
}
return DailyTemplateModules{
Metadata: common.Metadata,
CurrentConditions: common.CurrentConditions,
HourlyForecast: common.HourlyForecast,
DerivedDailySummary: common.DerivedDailySummary,
DerivedDaypartSummaries: common.DerivedDaypartSummaries,
Dayparts: orderedDailyDayparts(common.DerivedDaypartSummaries, derived.DaypartSummaries),
PrecipTiming: common.PrecipTiming,
AlertDigest: common.AlertDigest,
SPCConvectiveOutlooks: common.SPCConvectiveOutlooks,
AreaForecastDiscussion: common.AreaForecastDiscussion,
SPCConvectiveDiscussion: common.SPCConvectiveDiscussion,
WeatherStory: common.WeatherStory,
OutdoorWindows: outdoor,
DailyPlanning: planning,
}, nil
}
func todayTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TodayTemplateModules, error) {
common, lookup, err := dayStyleTemplateModuleSnapshot(snapshot)
if err != nil {
return TodayTemplateModules{}, err
}
planning, err := lookup.todayPlanning()
if err != nil {
return TodayTemplateModules{}, err
}
return TodayTemplateModules{
Metadata: common.Metadata,
CurrentConditions: common.CurrentConditions,
HourlyForecast: common.HourlyForecast,
DerivedDailySummary: common.DerivedDailySummary,
DerivedDaypartSummaries: common.DerivedDaypartSummaries,
Dayparts: orderedTodayDayparts(common.DerivedDaypartSummaries, derived.DaypartSummaries),
PrecipTiming: common.PrecipTiming,
AlertDigest: common.AlertDigest,
SPCConvectiveOutlooks: common.SPCConvectiveOutlooks,
AreaForecastDiscussion: common.AreaForecastDiscussion,
SPCConvectiveDiscussion: common.SPCConvectiveDiscussion,
WeatherStory: common.WeatherStory,
TodayPlanning: planning,
}, nil
}
func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, error) {
lookup := newModuleSnapshotLookup(snapshot)
metadata, err := lookup.metadata()
if err != nil {
return HourlyTemplateModules{}, err
}
current, err := lookup.currentConditions()
if err != nil {
return HourlyTemplateModules{}, err
}
hourly, err := lookup.hourlyForecast()
if err != nil {
return HourlyTemplateModules{}, err
}
precip, err := lookup.precipTiming()
if err != nil {
return HourlyTemplateModules{}, err
}
alerts, err := lookup.alertDigest()
if err != nil {
return HourlyTemplateModules{}, err
}
outlooks, err := lookup.spcConvectiveOutlooks()
if err != nil {
return HourlyTemplateModules{}, err
}
discussion, err := lookup.areaForecastDiscussion()
if err != nil {
return HourlyTemplateModules{}, err
}
spcDiscussion, err := lookup.spcConvectiveDiscussion()
if err != nil {
return HourlyTemplateModules{}, err
}
story, err := lookup.weatherStory()
if err != nil {
return HourlyTemplateModules{}, err
}
return HourlyTemplateModules{
Metadata: metadata,
CurrentConditions: current,
HourlyForecast: hourly,
PrecipTiming: precip,
AlertDigest: alerts,
SPCConvectiveOutlooks: outlooks,
AreaForecastDiscussion: discussion,
SPCConvectiveDiscussion: spcDiscussion,
WeatherStory: story,
}, nil
}
func tomorrowTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TomorrowTemplateModules, error) {
common, lookup, err := dayStyleTemplateModuleSnapshot(snapshot)
if err != nil {
return TomorrowTemplateModules{}, err
}
planning, err := lookup.tomorrowPlanning()
if err != nil {
return TomorrowTemplateModules{}, err
}
return TomorrowTemplateModules{
Metadata: common.Metadata,
CurrentConditions: common.CurrentConditions,
HourlyForecast: common.HourlyForecast,
DerivedDailySummary: common.DerivedDailySummary,
DerivedDaypartSummaries: common.DerivedDaypartSummaries,
Dayparts: orderedTomorrowDayparts(common.DerivedDaypartSummaries, derived.DaypartSummaries),
PrecipTiming: common.PrecipTiming,
AlertDigest: common.AlertDigest,
SPCConvectiveOutlooks: common.SPCConvectiveOutlooks,
AreaForecastDiscussion: common.AreaForecastDiscussion,
SPCConvectiveDiscussion: common.SPCConvectiveDiscussion,
WeatherStory: common.WeatherStory,
TomorrowPlanning: planning,
}, nil
}
func dayStyleTemplateModuleSnapshot(snapshot module.Snapshot) (dayStyleTemplateModules, moduleSnapshotLookup, error) {
lookup := newModuleSnapshotLookup(snapshot)
metadata, err := lookup.metadata()
if err != nil {
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
}
current, err := lookup.currentConditions()
if err != nil {
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
}
hourly, err := lookup.hourlyForecast()
if err != nil {
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
}
daily, err := lookup.derivedDailySummary()
if err != nil {
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
}
dayparts, err := lookup.derivedDaypartSummaries()
if err != nil {
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
}
precip, err := lookup.precipTiming()
if err != nil {
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
}
alerts, err := lookup.alertDigest()
if err != nil {
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
}
outlooks, err := lookup.spcConvectiveOutlooks()
if err != nil {
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
}
discussion, err := lookup.areaForecastDiscussion()
if err != nil {
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
}
spcDiscussion, err := lookup.spcConvectiveDiscussion()
if err != nil {
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
}
story, err := lookup.weatherStory()
if err != nil {
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
}
return dayStyleTemplateModules{
Metadata: metadata,
CurrentConditions: current,
HourlyForecast: hourly,
DerivedDailySummary: daily,
DerivedDaypartSummaries: dayparts,
PrecipTiming: precip,
AlertDigest: alerts,
SPCConvectiveOutlooks: outlooks,
AreaForecastDiscussion: discussion,
SPCConvectiveDiscussion: spcDiscussion,
WeatherStory: story,
}, lookup, nil
}
type moduleSnapshotLookup struct {
snapshot module.Snapshot
stanzas map[string]module.Output
}
func newModuleSnapshotLookup(snapshot module.Snapshot) moduleSnapshotLookup {
stanzas := make(map[string]module.Output, len(snapshot.Outputs))
for _, output := range snapshot.Outputs {
stanzas[output.StanzaName] = output
}
return moduleSnapshotLookup{
snapshot: snapshot,
stanzas: stanzas,
}
}
func (lookup moduleSnapshotLookup) metadata() (*briefing.MetadataModule, error) {
return optionalStanza[briefing.MetadataModule](lookup, module.Metadata)
}
func (lookup moduleSnapshotLookup) currentConditions() (*briefing.CurrentConditionsModule, error) {
return optionalStanza[briefing.CurrentConditionsModule](lookup, module.CurrentConditions)
}
func (lookup moduleSnapshotLookup) hourlyForecast() (*briefing.HourlyForecastModule, error) {
return optionalStanza[briefing.HourlyForecastModule](lookup, module.HourlyForecast)
}
func (lookup moduleSnapshotLookup) derivedDailySummary() (*briefing.DerivedDailySummaryModule, error) {
return optionalStanza[briefing.DerivedDailySummaryModule](lookup, module.DerivedDailySummary)
}
func (lookup moduleSnapshotLookup) derivedDaypartSummaries() (*map[string]briefing.DerivedDaypartSummaryModule, error) {
return optionalStanza[map[string]briefing.DerivedDaypartSummaryModule](lookup, module.DerivedDaypartSummaries)
}
func (lookup moduleSnapshotLookup) precipTiming() (*briefing.PrecipTimingModule, error) {
return optionalStanza[briefing.PrecipTimingModule](lookup, module.PrecipTiming)
}
func (lookup moduleSnapshotLookup) alertDigest() (*briefing.AlertDigestModule, error) {
return optionalStanza[briefing.AlertDigestModule](lookup, module.AlertDigest)
}
func (lookup moduleSnapshotLookup) spcConvectiveOutlooks() (*briefing.SPCConvectiveOutlooksModule, error) {
return optionalStanza[briefing.SPCConvectiveOutlooksModule](lookup, module.SPCConvectiveOutlooks)
}
func (lookup moduleSnapshotLookup) areaForecastDiscussion() (*briefing.AreaForecastDiscussionModule, error) {
return optionalStanza[briefing.AreaForecastDiscussionModule](lookup, module.AreaForecastDiscussion)
}
func (lookup moduleSnapshotLookup) spcConvectiveDiscussion() (*briefing.SPCConvectiveDiscussionModule, error) {
return optionalStanza[briefing.SPCConvectiveDiscussionModule](lookup, module.SPCConvectiveDiscussion)
}
func (lookup moduleSnapshotLookup) weatherStory() (*briefing.WeatherStoryModule, error) {
return optionalStanza[briefing.WeatherStoryModule](lookup, module.WeatherStory)
}
func (lookup moduleSnapshotLookup) outdoorWindows() (*briefing.OutdoorWindowsModule, error) {
return optionalStanza[briefing.OutdoorWindowsModule](lookup, module.OutdoorWindows)
}
func (lookup moduleSnapshotLookup) dailyPlanning() (*briefing.DailyPlanningModule, error) {
return optionalStanza[briefing.DailyPlanningModule](lookup, module.DailyPlanning)
}
func (lookup moduleSnapshotLookup) todayPlanning() (*briefing.TodayPlanningModule, error) {
return optionalStanza[briefing.TodayPlanningModule](lookup, module.TodayPlanning)
}
func (lookup moduleSnapshotLookup) tomorrowPlanning() (*briefing.TomorrowPlanningModule, error) {
return optionalStanza[briefing.TomorrowPlanningModule](lookup, module.TomorrowPlanning)
}
func optionalStanza[T any](lookup moduleSnapshotLookup, id module.ID) (*T, error) {
name := string(id)
output, ok := lookup.stanzas[name]
if !ok || output.Value == nil {
return nil, nil
}
value, _, err := module.StanzaValue[T](lookup.snapshot, name)
if err != nil {
return nil, fmt.Errorf("build render context module %q: %w", id, err)
}
return &value, nil
}
func orderedTodayDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []TodayDaypartContext {
orderedRows := orderedDaypartRows(dayparts, ordered)
out := make([]TodayDaypartContext, 0, len(orderedRows))
for _, row := range orderedRows {
out = append(out, TodayDaypartContext{Key: row.Key, Summary: row.Summary})
}
return out
}
func orderedDailyDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []DailyDaypartContext {
orderedRows := orderedDaypartRows(dayparts, ordered)
out := make([]DailyDaypartContext, 0, len(orderedRows))
for _, row := range orderedRows {
out = append(out, DailyDaypartContext{Key: row.Key, Summary: row.Summary})
}
return out
}
func orderedTomorrowDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []TomorrowDaypartContext {
orderedRows := orderedDaypartRows(dayparts, ordered)
out := make([]TomorrowDaypartContext, 0, len(orderedRows))
for _, row := range orderedRows {
out = append(out, TomorrowDaypartContext{Key: row.Key, Summary: row.Summary})
}
return out
}
type orderedDaypartRow struct {
Key string
Summary briefing.DerivedDaypartSummaryModule
}
func orderedDaypartRows(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []orderedDaypartRow {
if dayparts == nil || len(*dayparts) == 0 {
return nil
}
out := make([]orderedDaypartRow, 0, len(*dayparts))
seen := map[string]struct{}{}
for _, daypart := range ordered {
for _, key := range daypartModuleKeyCandidates(daypart) {
value, ok := (*dayparts)[key]
if !ok {
continue
}
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
out = append(out, orderedDaypartRow{Key: key, Summary: value})
break
}
}
var remaining []string
for key := range *dayparts {
if _, ok := seen[key]; !ok {
remaining = append(remaining, key)
}
}
sort.Strings(remaining)
for _, key := range remaining {
out = append(out, orderedDaypartRow{Key: key, Summary: (*dayparts)[key]})
}
return out
}
func daypartModuleKeyCandidates(daypart forecast.DaypartSummary) []string {
key := normalizedDaypartKey(daypart.Name)
if key == "" {
key = "unnamed"
}
if daypart.Period.Start.IsZero() {
return []string{key}
}
return []string{key, daypart.Period.Start.Format(timeutil.DateLayout) + "_" + key}
}
func normalizedDaypartKey(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(), "_")
}
func locationName(metadata briefing.Metadata) string {
if metadata.Location != nil {
if metadata.Location.Name != "" && metadata.Location.Region != "" {
return metadata.Location.Name + ", " + metadata.Location.Region
}
if metadata.Location.Name != "" {
return metadata.Location.Name
}
}
if metadata.SourceLocation != "" {
return metadata.SourceLocation
}
if metadata.SourceLocationID != "" {
return metadata.SourceLocationID
}
return "Unknown location"
}
func periodLabel(period timeutil.Period, location *time.Location) string {
return fmt.Sprintf("%s to %s", timeLabel(period.Start, location), timeLabel(period.End, location))
}
func timeLabel(value time.Time, location *time.Location) string {
return value.In(location).Format("2006-01-02 at 3:04 PM")
}
func generatedAtLabel(value time.Time, location *time.Location) string {
return value.In(location).Format("Monday, January 2, 2006 at 3:04 PM")
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
package generatedtext
type Today struct {
Summary string `json:"summary"`
ForecastDiscussion []string `json:"forecast_discussion"`
PrecipitationTiming string `json:"precipitation_timing,omitempty"`
Confidence string `json:"confidence,omitempty"`
}
func ValidateToday(data []byte) (Today, []byte, error) {
return validateDayStyleGeneratedText[Today, *Today](data, "today")
}
func (t *Today) dayStyleFields() dayStyleFields {
return dayStyleFields{
Summary: t.Summary,
ForecastDiscussion: t.ForecastDiscussion,
PrecipitationTiming: t.PrecipitationTiming,
Confidence: t.Confidence,
}
}
func (t *Today) setDayStyleFields(fields dayStyleFields) {
t.Summary = fields.Summary
t.ForecastDiscussion = fields.ForecastDiscussion
t.PrecipitationTiming = fields.PrecipitationTiming
t.Confidence = fields.Confidence
}

View File

@@ -0,0 +1,111 @@
package generatedtext
import (
"strings"
"testing"
)
func TestValidateTodayNormalizesJSON(t *testing.T) {
value, normalized, err := ValidateToday([]byte(`{
"summary": " Showers are likely today. ",
"forecast_discussion": [
" A front will keep rain chances elevated. ",
"",
" Temperatures stay mild through the afternoon. "
],
"precipitation_timing": " Rain is most likely during the afternoon. ",
"confidence": " Medium "
}`))
if err != nil {
t.Fatalf("ValidateToday() error = %v", err)
}
if value.Summary != "Showers are likely today." {
t.Fatalf("Summary = %q, want trimmed summary", value.Summary)
}
if strings.Join(value.ForecastDiscussion, "|") != "A front will keep rain chances elevated.|Temperatures stay mild through the afternoon." {
t.Fatalf("ForecastDiscussion = %#v, want trimmed non-empty paragraphs", value.ForecastDiscussion)
}
if value.PrecipitationTiming != "Rain is most likely during the afternoon." {
t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming)
}
want := `{"summary":"Showers are likely today.","forecast_discussion":["A front will keep rain chances elevated.","Temperatures stay mild through the afternoon."],"precipitation_timing":"Rain is most likely during the afternoon.","confidence":"Medium"}`
if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want)
}
}
func TestValidateTodayOmitsEmptyOptionalFields(t *testing.T) {
_, normalized, err := ValidateToday([]byte(`{
"summary": "Showers are likely today.",
"forecast_discussion": ["A front will keep rain chances elevated."],
"precipitation_timing": " ",
"confidence": " "
}`))
if err != nil {
t.Fatalf("ValidateToday() error = %v", err)
}
want := `{"summary":"Showers are likely today.","forecast_discussion":["A front will keep rain chances elevated."]}`
if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want)
}
}
func TestValidateTodayRejectsInvalidInput(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{
name: "malformed",
in: `{`,
want: "decode today generated text",
},
{
name: "unknown field",
in: `{"summary":"Showers are likely today.","forecast_discussion":["A front will keep rain chances elevated."],"extra":"value"}`,
want: `unknown field "extra"`,
},
{
name: "missing summary",
in: `{"forecast_discussion":["A front will keep rain chances elevated."]}`,
want: "summary is required",
},
{
name: "blank summary",
in: `{"summary":" ","forecast_discussion":["A front will keep rain chances elevated."]}`,
want: "summary is required",
},
{
name: "missing forecast discussion",
in: `{"summary":"Showers are likely today."}`,
want: "forecast discussion is required",
},
{
name: "blank forecast discussion",
in: `{"summary":"Showers are likely today.","forecast_discussion":[" ",""]}`,
want: "forecast discussion is required",
},
{
name: "forecast discussion wrong type",
in: `{"summary":"Showers are likely today.","forecast_discussion":"A front will keep rain chances elevated."}`,
want: "cannot unmarshal string",
},
{
name: "multiple values",
in: `{"summary":"Showers are likely today.","forecast_discussion":["A front will keep rain chances elevated."]} {}`,
want: "multiple JSON values",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, _, err := ValidateToday([]byte(test.in))
if err == nil {
t.Fatal("ValidateToday() error = nil, want error")
}
if !strings.Contains(err.Error(), test.want) {
t.Fatalf("ValidateToday() error = %v, want %q", err, test.want)
}
})
}
}

View File

@@ -0,0 +1,28 @@
package generatedtext
type Tomorrow struct {
Summary string `json:"summary"`
ForecastDiscussion []string `json:"forecast_discussion"`
PrecipitationTiming string `json:"precipitation_timing,omitempty"`
Confidence string `json:"confidence,omitempty"`
}
func ValidateTomorrow(data []byte) (Tomorrow, []byte, error) {
return validateDayStyleGeneratedText[Tomorrow, *Tomorrow](data, "tomorrow")
}
func (t *Tomorrow) dayStyleFields() dayStyleFields {
return dayStyleFields{
Summary: t.Summary,
ForecastDiscussion: t.ForecastDiscussion,
PrecipitationTiming: t.PrecipitationTiming,
Confidence: t.Confidence,
}
}
func (t *Tomorrow) setDayStyleFields(fields dayStyleFields) {
t.Summary = fields.Summary
t.ForecastDiscussion = fields.ForecastDiscussion
t.PrecipitationTiming = fields.PrecipitationTiming
t.Confidence = fields.Confidence
}

View File

@@ -0,0 +1,111 @@
package generatedtext
import (
"strings"
"testing"
)
func TestValidateTomorrowNormalizesJSON(t *testing.T) {
value, normalized, err := ValidateTomorrow([]byte(`{
"summary": " Storms become more likely tomorrow. ",
"forecast_discussion": [
" A front will keep showers in the forecast. ",
"",
" Temperatures stay seasonable by afternoon. "
],
"precipitation_timing": " Rain is most likely before sunrise. ",
"confidence": " Medium "
}`))
if err != nil {
t.Fatalf("ValidateTomorrow() error = %v", err)
}
if value.Summary != "Storms become more likely tomorrow." {
t.Fatalf("Summary = %q, want trimmed summary", value.Summary)
}
if strings.Join(value.ForecastDiscussion, "|") != "A front will keep showers in the forecast.|Temperatures stay seasonable by afternoon." {
t.Fatalf("ForecastDiscussion = %#v, want trimmed non-empty paragraphs", value.ForecastDiscussion)
}
if value.PrecipitationTiming != "Rain is most likely before sunrise." {
t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming)
}
want := `{"summary":"Storms become more likely tomorrow.","forecast_discussion":["A front will keep showers in the forecast.","Temperatures stay seasonable by afternoon."],"precipitation_timing":"Rain is most likely before sunrise.","confidence":"Medium"}`
if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want)
}
}
func TestValidateTomorrowOmitsEmptyOptionalFields(t *testing.T) {
_, normalized, err := ValidateTomorrow([]byte(`{
"summary": "Storms become more likely tomorrow.",
"forecast_discussion": ["A front will keep showers in the forecast."],
"precipitation_timing": " ",
"confidence": " "
}`))
if err != nil {
t.Fatalf("ValidateTomorrow() error = %v", err)
}
want := `{"summary":"Storms become more likely tomorrow.","forecast_discussion":["A front will keep showers in the forecast."]}`
if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want)
}
}
func TestValidateTomorrowRejectsInvalidInput(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{
name: "malformed",
in: `{`,
want: "decode tomorrow generated text",
},
{
name: "unknown field",
in: `{"summary":"Storms become more likely tomorrow.","forecast_discussion":["A front will keep showers in the forecast."],"extra":"value"}`,
want: `unknown field "extra"`,
},
{
name: "missing summary",
in: `{"forecast_discussion":["A front will keep showers in the forecast."]}`,
want: "summary is required",
},
{
name: "blank summary",
in: `{"summary":" ","forecast_discussion":["A front will keep showers in the forecast."]}`,
want: "summary is required",
},
{
name: "missing forecast discussion",
in: `{"summary":"Storms become more likely tomorrow."}`,
want: "forecast discussion is required",
},
{
name: "blank forecast discussion",
in: `{"summary":"Storms become more likely tomorrow.","forecast_discussion":[" ",""]}`,
want: "forecast discussion is required",
},
{
name: "forecast discussion wrong type",
in: `{"summary":"Storms become more likely tomorrow.","forecast_discussion":"A front will keep showers in the forecast."}`,
want: "cannot unmarshal string",
},
{
name: "multiple values",
in: `{"summary":"Storms become more likely tomorrow.","forecast_discussion":["A front will keep showers in the forecast."]} {}`,
want: "multiple JSON values",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, _, err := ValidateTomorrow([]byte(test.in))
if err == nil {
t.Fatal("ValidateTomorrow() error = nil, want error")
}
if !strings.Contains(err.Error(), test.want) {
t.Fatalf("ValidateTomorrow() error = %v, want %q", err, test.want)
}
})
}
}

View File

@@ -21,8 +21,12 @@ const (
AlertDigest ID = "alert_digest" AlertDigest ID = "alert_digest"
AreaForecastDiscussion ID = "area_forecast_discussion" AreaForecastDiscussion ID = "area_forecast_discussion"
WeatherStory ID = "weather_story" WeatherStory ID = "weather_story"
SPCConvectiveOutlooks ID = "spc_convective_outlooks"
SPCConvectiveDiscussion ID = "spc_convective_discussion"
OutdoorWindows ID = "outdoor_windows" OutdoorWindows ID = "outdoor_windows"
TodayPlanning ID = "today_planning"
TomorrowPlanning ID = "tomorrow_planning" TomorrowPlanning ID = "tomorrow_planning"
DailyPlanning ID = "daily_planning"
) )
type ConfigItem struct { type ConfigItem struct {
@@ -34,6 +38,7 @@ type Output struct {
ID ID `json:"id"` ID ID `json:"id"`
StanzaName string `json:"stanzaName"` StanzaName string `json:"stanzaName"`
Value any `json:"value"` Value any `json:"value"`
PromptValue any `json:"-" yaml:"-"`
} }
type Snapshot struct { type Snapshot struct {
@@ -41,6 +46,13 @@ type Snapshot struct {
Outputs []Output `json:"outputs"` Outputs []Output `json:"outputs"`
} }
func (o Output) DataPackageValue() any {
if o.PromptValue != nil {
return o.PromptValue
}
return o.Value
}
func NewSnapshot(outputs []Output) (Snapshot, error) { func NewSnapshot(outputs []Output) (Snapshot, error) {
snapshot := Snapshot{ snapshot := Snapshot{
SchemaVersion: SnapshotSchemaVersion, SchemaVersion: SnapshotSchemaVersion,
@@ -111,6 +123,7 @@ const (
CollectedAlerts FactRequirement = "collected.alerts" CollectedAlerts FactRequirement = "collected.alerts"
CollectedDiscussion FactRequirement = "collected.discussion" CollectedDiscussion FactRequirement = "collected.discussion"
CollectedWeatherStory FactRequirement = "collected.weather_story" CollectedWeatherStory FactRequirement = "collected.weather_story"
CollectedSPCConvectiveOutlooks FactRequirement = "collected.spc_convective_outlooks"
CollectedSourceMetadata FactRequirement = "collected.source_metadata" CollectedSourceMetadata FactRequirement = "collected.source_metadata"
RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods" RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods"
RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods" RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods"
@@ -118,6 +131,7 @@ const (
RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries" RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries"
RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries" RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries"
RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing" RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing"
RequiresDerivedSPCConvectiveOutlooks FactRequirement = "derived.spc_convective_outlooks"
) )
type MissingDataBehavior string type MissingDataBehavior string
@@ -141,5 +155,9 @@ type AreaForecastDiscussionOptions struct {
Sections []string `json:"sections,omitempty" yaml:"sections,omitempty"` Sections []string `json:"sections,omitempty" yaml:"sections,omitempty"`
} }
type WeatherStoryOptions struct{} type WeatherStoryOptions struct{}
type SPCConvectiveOutlooksOptions struct{}
type SPCConvectiveDiscussionOptions struct{}
type OutdoorWindowsOptions struct{} type OutdoorWindowsOptions struct{}
type TodayPlanningOptions struct{}
type TomorrowPlanningOptions struct{} type TomorrowPlanningOptions struct{}
type DailyPlanningOptions struct{}

View File

@@ -13,7 +13,12 @@ type testStanza struct {
func TestSnapshotPreservesOutputOrderAndJSON(t *testing.T) { func TestSnapshotPreservesOutputOrderAndJSON(t *testing.T) {
snapshot, err := NewSnapshot([]Output{ snapshot, err := NewSnapshot([]Output{
{ID: Metadata, StanzaName: "metadata", Value: testStanza{Message: "first", Count: 1}}, {
ID: Metadata,
StanzaName: "metadata",
Value: testStanza{Message: "first", Count: 1},
PromptValue: testStanza{Message: "prompt-only", Count: 10},
},
{ID: AlertDigest, StanzaName: "alert_digest", Value: testStanza{Message: "second", Count: 2}}, {ID: AlertDigest, StanzaName: "alert_digest", Value: testStanza{Message: "second", Count: 2}},
}) })
if err != nil { if err != nil {
@@ -35,6 +40,26 @@ func TestSnapshotPreservesOutputOrderAndJSON(t *testing.T) {
if got != want { if got != want {
t.Fatalf("json = %s, want %s", got, want) t.Fatalf("json = %s, want %s", got, want)
} }
if strings.Contains(got, "PromptValue") || strings.Contains(got, "promptValue") || strings.Contains(got, "prompt-only") {
t.Fatalf("json includes runtime-only prompt value: %s", got)
}
}
func TestOutputDataPackageValue(t *testing.T) {
output := Output{
ID: Metadata,
StanzaName: "metadata",
Value: testStanza{Message: "rich", Count: 1},
PromptValue: testStanza{Message: "prompt", Count: 2},
}
if got := output.DataPackageValue(); got != output.PromptValue {
t.Fatalf("DataPackageValue() = %#v, want prompt value", got)
}
output.PromptValue = nil
if got := output.DataPackageValue(); got != output.Value {
t.Fatalf("DataPackageValue() = %#v, want rich value fallback", got)
}
} }
func TestSnapshotRejectsDuplicateOutputs(t *testing.T) { func TestSnapshotRejectsDuplicateOutputs(t *testing.T) {
@@ -57,7 +82,12 @@ func TestSnapshotRejectsDuplicateOutputs(t *testing.T) {
func TestStanzaValueDecodesTypedOutput(t *testing.T) { func TestStanzaValueDecodesTypedOutput(t *testing.T) {
snapshot, err := NewSnapshot([]Output{ snapshot, err := NewSnapshot([]Output{
{ID: Metadata, StanzaName: "metadata", Value: testStanza{Message: "available", Count: 3}}, {
ID: Metadata,
StanzaName: "metadata",
Value: testStanza{Message: "available", Count: 3},
PromptValue: testStanza{Message: "prompt-only", Count: 99},
},
}) })
if err != nil { if err != nil {
t.Fatalf("NewSnapshot() error = %v", err) t.Fatalf("NewSnapshot() error = %v", err)
@@ -82,3 +112,29 @@ func TestStanzaValueDecodesTypedOutput(t *testing.T) {
t.Fatal("StanzaValue(missing) found = true, want false") t.Fatal("StanzaValue(missing) found = true, want false")
} }
} }
func TestSPCConvectiveModuleContractsAreStable(t *testing.T) {
if SPCConvectiveOutlooks != ID("spc_convective_outlooks") {
t.Fatalf("SPCConvectiveOutlooks = %q, want stable source/module ID", SPCConvectiveOutlooks)
}
if SPCConvectiveDiscussion != ID("spc_convective_discussion") {
t.Fatalf("SPCConvectiveDiscussion = %q, want stable discussion module ID", SPCConvectiveDiscussion)
}
if CollectedSPCConvectiveOutlooks != FactRequirement("collected.spc_convective_outlooks") {
t.Fatalf("CollectedSPCConvectiveOutlooks = %q, want collected requirement", CollectedSPCConvectiveOutlooks)
}
if RequiresDerivedSPCConvectiveOutlooks != FactRequirement("derived.spc_convective_outlooks") {
t.Fatalf("RequiresDerivedSPCConvectiveOutlooks = %q, want derived requirement", RequiresDerivedSPCConvectiveOutlooks)
}
_ = SPCConvectiveOutlooksOptions{}
_ = SPCConvectiveDiscussionOptions{}
}
func TestDailyPlanningModuleContractIsStable(t *testing.T) {
if DailyPlanning != ID("daily_planning") {
t.Fatalf("DailyPlanning = %q, want stable module ID", DailyPlanning)
}
_ = DailyPlanningOptions{}
}

View File

@@ -16,7 +16,7 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
const SchemaVersion = "weatherreporter.data_package.v2" const SchemaVersion = "weatherreporter.data_package.v3"
const ( const (
metadataStanza = "metadata" metadataStanza = "metadata"
@@ -35,13 +35,17 @@ var briefingCategoryOrder = []string{
var briefingStanzaCategories = map[string]string{ var briefingStanzaCategories = map[string]string{
string(module.AlertDigest): categoryApplicableRiskProducts, string(module.AlertDigest): categoryApplicableRiskProducts,
string(module.SPCConvectiveOutlooks): categoryApplicableRiskProducts,
string(module.DerivedDailySummary): categoryDerivedSummaries, string(module.DerivedDailySummary): categoryDerivedSummaries,
string(module.DerivedDaypartSummaries): categoryDerivedSummaries, string(module.DerivedDaypartSummaries): categoryDerivedSummaries,
string(module.PrecipTiming): categoryDerivedSummaries, string(module.PrecipTiming): categoryDerivedSummaries,
string(module.OutdoorWindows): categoryDerivedSummaries, string(module.OutdoorWindows): categoryDerivedSummaries,
string(module.DailyPlanning): categoryDerivedSummaries,
string(module.TodayPlanning): categoryDerivedSummaries,
string(module.TomorrowPlanning): categoryDerivedSummaries, string(module.TomorrowPlanning): categoryDerivedSummaries,
string(module.NarrativeForecast): categoryNarrativeProducts, string(module.NarrativeForecast): categoryNarrativeProducts,
string(module.AreaForecastDiscussion): categoryNarrativeProducts, string(module.AreaForecastDiscussion): categoryNarrativeProducts,
string(module.SPCConvectiveDiscussion): categoryNarrativeProducts,
string(module.WeatherStory): categoryNarrativeProducts, string(module.WeatherStory): categoryNarrativeProducts,
string(module.CurrentConditions): categoryRawData, string(module.CurrentConditions): categoryRawData,
string(module.HourlyForecast): categoryRawData, string(module.HourlyForecast): categoryRawData,
@@ -129,7 +133,7 @@ func stanzasFromSnapshot(snapshot module.Snapshot) BriefingStanzas {
order := make([]string, 0, len(snapshot.Outputs)) order := make([]string, 0, len(snapshot.Outputs))
for _, output := range snapshot.Outputs { for _, output := range snapshot.Outputs {
order = append(order, output.StanzaName) order = append(order, output.StanzaName)
values[output.StanzaName] = output.Value values[output.StanzaName] = output.DataPackageValue()
} }
return BriefingStanzas{Order: order, Values: values} return BriefingStanzas{Order: order, Values: values}
} }

View File

@@ -19,11 +19,11 @@ func TestBuildDailyDataPackage(t *testing.T) {
if pkg.SchemaVersion != SchemaVersion { if pkg.SchemaVersion != SchemaVersion {
t.Fatalf("SchemaVersion = %q, want %q", pkg.SchemaVersion, SchemaVersion) t.Fatalf("SchemaVersion = %q, want %q", pkg.SchemaVersion, SchemaVersion)
} }
if pkg.RunID != "20260529T100000Z_daily_today" { if pkg.RunID != "20260529T100000Z_daily" {
t.Fatalf("RunID = %q, want metadata run id", pkg.RunID) t.Fatalf("RunID = %q, want metadata run id", pkg.RunID)
} }
if pkg.Report.PromptID != "weather.daily_report" { if pkg.Report.PromptID != "weather.daily_generated_text" {
t.Fatalf("PromptID = %q, want weather.daily_report", pkg.Report.PromptID) t.Fatalf("PromptID = %q, want weather.daily_generated_text", pkg.Report.PromptID)
} }
if pkg.Report.CurrentLocalDate != "2026-05-29" { if pkg.Report.CurrentLocalDate != "2026-05-29" {
t.Fatalf("CurrentLocalDate = %q, want 2026-05-29", pkg.Report.CurrentLocalDate) t.Fatalf("CurrentLocalDate = %q, want 2026-05-29", pkg.Report.CurrentLocalDate)
@@ -107,6 +107,7 @@ func TestBuildUsesNamedSnapshotStanzas(t *testing.T) {
req.Modules = snapshotWithOutputs(t, req.Modules = snapshotWithOutputs(t,
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": req.Metadata.RunID}}, module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": req.Metadata.RunID}},
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{"days": []string{"2026-05-29"}}}, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{"days": []string{"2026-05-29"}}},
module.Output{ID: module.TodayPlanning, StanzaName: "today_planning", Value: map[string]any{"morning_readiness": []string{"routine"}}},
) )
pkg, err := Build(req) pkg, err := Build(req)
@@ -120,6 +121,47 @@ func TestBuildUsesNamedSnapshotStanzas(t *testing.T) {
if _, ok := pkg.Briefing.Values["derived_daypart_summaries"]; !ok { if _, ok := pkg.Briefing.Values["derived_daypart_summaries"]; !ok {
t.Fatal("Briefing.Values[derived_daypart_summaries] missing") t.Fatal("Briefing.Values[derived_daypart_summaries] missing")
} }
if _, ok := pkg.Briefing.Values["today_planning"]; !ok {
t.Fatal("Briefing.Values[today_planning] missing")
}
}
func TestBuildUsesPromptValuesFromSnapshot(t *testing.T) {
req := validBuildRequest(t)
req.Modules = snapshotWithOutputs(t,
module.Output{
ID: module.Metadata,
StanzaName: "metadata",
Value: map[string]string{"run_id": "rich"},
PromptValue: map[string]string{"run_id": "prompt"},
},
module.Output{
ID: module.CurrentConditions,
StanzaName: "current_conditions",
Value: map[string]string{"condition_text": "Rich conditions"},
PromptValue: map[string]string{"condition_text": "Prompt conditions"},
},
module.Output{
ID: module.AlertDigest,
StanzaName: "alert_digest",
Value: map[string]bool{"checked": true},
},
)
pkg, err := Build(req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if got := pkg.Briefing.Values["metadata"].(map[string]string)["run_id"]; got != "prompt" {
t.Fatalf("metadata.run_id = %q, want prompt value", got)
}
if got := pkg.Briefing.Values["current_conditions"].(map[string]string)["condition_text"]; got != "Prompt conditions" {
t.Fatalf("current_conditions.condition_text = %q, want prompt value", got)
}
if got := pkg.Briefing.Values["alert_digest"].(map[string]bool)["checked"]; !got {
t.Fatalf("alert_digest.checked = %v, want rich value fallback", got)
}
} }
func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) { func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) {
@@ -139,7 +181,7 @@ func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) {
if string(first) != string(second) { if string(first) != string(second) {
t.Fatalf("YAML output changed between marshals:\n%s\n---\n%s", string(first), string(second)) t.Fatalf("YAML output changed between marshals:\n%s\n---\n%s", string(first), string(second))
} }
if !strings.Contains(string(first), "schema_version: weatherreporter.data_package.v2") || if !strings.Contains(string(first), "schema_version: weatherreporter.data_package.v3") ||
!strings.Contains(string(first), "briefing:\n") || !strings.Contains(string(first), "briefing:\n") ||
!strings.Contains(string(first), " applicable_risk_products:\n") || !strings.Contains(string(first), " applicable_risk_products:\n") ||
!strings.Contains(string(first), " derived_summaries:\n") || !strings.Contains(string(first), " derived_summaries:\n") ||
@@ -164,6 +206,50 @@ func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) {
} }
} }
func TestMarshalYAMLPlacesSPCConvectiveStanzasInPromptCategories(t *testing.T) {
req := validBuildRequest(t)
req.Modules = snapshotWithOutputs(t,
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": req.Metadata.RunID}},
module.Output{ID: module.SPCConvectiveDiscussion, StanzaName: string(module.SPCConvectiveDiscussion), Value: map[string]any{"discussions": []string{"day1"}}},
module.Output{ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), Value: map[string]any{"outlook_count": 1}},
)
pkg, err := Build(req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
data, err := MarshalYAML(pkg)
if err != nil {
t.Fatalf("MarshalYAML() error = %v", err)
}
text := string(data)
riskIndex := strings.Index(text, " applicable_risk_products:\n")
outlookIndex := strings.Index(text, " spc_convective_outlooks:\n")
narrativeIndex := strings.Index(text, " narrative_products:\n")
discussionIndex := strings.Index(text, " spc_convective_discussion:\n")
if riskIndex < 0 || outlookIndex < 0 || narrativeIndex < 0 || discussionIndex < 0 {
t.Fatalf("YAML output missing SPC convective category placement:\n%s", text)
}
if !(riskIndex < outlookIndex && outlookIndex < narrativeIndex && narrativeIndex < discussionIndex) {
t.Fatalf("YAML output placed SPC convective stanzas in wrong order:\n%s", text)
}
if strings.Contains(text, " raw_data:\n spc_convective") || strings.Contains(text, " derived_summaries:\n spc_convective") {
t.Fatalf("YAML output placed SPC convective stanzas in wrong category:\n%s", text)
}
loaded, err := LoadYAML(data)
if err != nil {
t.Fatalf("LoadYAML() error = %v", err)
}
if _, ok := loaded.Briefing.Values[string(module.SPCConvectiveOutlooks)]; !ok {
t.Fatal("loaded package missing spc_convective_outlooks stanza")
}
if _, ok := loaded.Briefing.Values[string(module.SPCConvectiveDiscussion)]; !ok {
t.Fatal("loaded package missing spc_convective_discussion stanza")
}
}
func TestLoadYAMLRoundTrip(t *testing.T) { func TestLoadYAMLRoundTrip(t *testing.T) {
pkg, err := Build(validBuildRequest(t)) pkg, err := Build(validBuildRequest(t))
if err != nil { if err != nil {
@@ -203,11 +289,11 @@ func TestMarshalYAMLRejectsUncategorizedStanza(t *testing.T) {
func TestLoadYAMLRejectsMisplacedStanza(t *testing.T) { func TestLoadYAMLRejectsMisplacedStanza(t *testing.T) {
data := []byte(` data := []byte(`
schema_version: weatherreporter.data_package.v2 schema_version: weatherreporter.data_package.v3
run_id: 20260529T100000Z_daily_today run_id: 20260529T100000Z_daily
report: report:
id: daily_today id: daily
prompt_id: weather.daily_report prompt_id: weather.daily_generated_text
generated_at: 2026-05-29T10:00:00Z generated_at: 2026-05-29T10:00:00Z
timezone: America/Chicago timezone: America/Chicago
current_local_date: "2026-05-29" current_local_date: "2026-05-29"
@@ -216,7 +302,7 @@ report:
end: 2026-05-30T05:00:00Z end: 2026-05-30T05:00:00Z
briefing: briefing:
metadata: metadata:
run_id: 20260529T100000Z_daily_today run_id: 20260529T100000Z_daily
raw_data: raw_data:
alert_digest: alert_digest:
checked: true checked: true
@@ -230,15 +316,32 @@ recent_changes:
} }
} }
func TestLoadYAMLRejectsOldSchemaVersion(t *testing.T) {
pkg, err := Build(validBuildRequest(t))
if err != nil {
t.Fatalf("Build() error = %v", err)
}
data, err := MarshalYAML(pkg)
if err != nil {
t.Fatalf("MarshalYAML() error = %v", err)
}
data = []byte(strings.Replace(string(data), "weatherreporter.data_package.v3", "weatherreporter.data_package.v2", 1))
_, err = LoadYAML(data)
if err == nil || !strings.Contains(err.Error(), "schemaVersion must be weatherreporter.data_package.v3") {
t.Fatalf("LoadYAML() error = %v, want current schema version error", err)
}
}
func validBuildRequest(t *testing.T) BuildRequest { func validBuildRequest(t *testing.T) BuildRequest {
t.Helper() t.Helper()
generatedAt := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC) generatedAt := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC)
return BuildRequest{ return BuildRequest{
Metadata: Metadata{ Metadata: Metadata{
RunID: "20260529T100000Z_daily_today", RunID: "20260529T100000Z_daily",
ReportID: report.DailyToday, ReportID: report.Daily,
Variant: "today", Variant: "today",
PromptID: "weather.daily_report", PromptID: "weather.daily_generated_text",
GeneratedAt: generatedAt, GeneratedAt: generatedAt,
Timezone: "America/Chicago", Timezone: "America/Chicago",
ValidPeriod: timeutil.Period{ ValidPeriod: timeutil.Period{
@@ -247,7 +350,7 @@ func validBuildRequest(t *testing.T) BuildRequest {
}, },
}, },
Modules: snapshotWithOutputs(t, Modules: snapshotWithOutputs(t,
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": "20260529T100000Z_daily_today"}}, module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": "20260529T100000Z_daily"}},
module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: map[string]string{"condition_text": "Partly cloudy"}}, module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: map[string]string{"condition_text": "Partly cloudy"}},
module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]string{"date": "2026-05-29"}}, module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]string{"date": "2026-05-29"}},
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: map[string]bool{"checked": true}}, module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: map[string]bool{"checked": true}},

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