63 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
88 changed files with 8653 additions and 3524 deletions

View File

@@ -9,7 +9,7 @@ batches, and inspects stored artifacts.
weatherreporter generate today --out ./today.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 structured artifacts, runs `scriptorium render` as a preflight check, runs structured
`scriptorium run`, validates generated text, renders the embedded Today `scriptorium run`, validates generated text, renders the embedded Today
template, and writes an extra Markdown copy to `./today.md`. If distributor template, and writes an extra Markdown copy to `./today.md`. If distributor
@@ -20,15 +20,15 @@ Markdown report after final metadata is saved.
```text ```text
weatherreporter --help weatherreporter --help
weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] [--quiet]
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate weekend [--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 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] 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] 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
@@ -37,34 +37,128 @@ weatherreporter inspect prior [--config PATH] RUN_ID
weatherreporter inspect sources [--config PATH] RUN_ID weatherreporter inspect sources [--config PATH] RUN_ID
``` ```
Implemented `generate` commands write a JSON module snapshot, YAML data package, Implemented `generate` commands emit a compact JSON summary to stdout on
preflight artifact, managed Markdown report, and metadata under the configured success. The summary includes command identity, report identity, RunID, status,
workspace. `--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 daily`, snapshot, YAML data package, preflight artifact, managed Markdown report, and
metadata under the configured workspace. `--out` writes an extra Markdown copy
for the operator; distributor notification uses the managed report path, not
the extra copy. `generate daily`,
`generate today`, `generate tomorrow`, and `generate hourly` write managed `generate today`, `generate tomorrow`, and `generate hourly` write managed
generated-text artifacts, validate structured text from Scriptorium, and render generated-text artifacts, validate structured text from Scriptorium, and render
the managed Markdown report from embedded templates. `generate daily` requires the managed Markdown report from embedded templates. `generate daily` requires
`--date YYYY-MM-DD` for the selected local civil day; omitting `--date` is a `--date YYYY-MM-DD` for the selected local civil day; omitting `--date` is a
command error and stops before weather data is fetched. `generate hourly` 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 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 date or event window flags. `generate storm` requires explicit event-window
bounds with `--start` and `--end`. bounds with `--start` and `--end`.
`run morning` generates Today Report 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 Report. 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.
Hourly Report is explicit only; it is not included in `run morning` or `run Hourly Report, 3-Day Outlook, and Weekend Outlook are explicit only; they are
evening`. not included in `run morning` or `run evening`.
`inspect` commands read existing workspace artifacts and emit JSON to stdout. `inspect` commands read existing workspace artifacts and emit the requested
They do not fetch weather data or invoke `scriptorium`. 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
@@ -74,6 +168,7 @@ They do not fetch weather data or invoke `scriptorium`.
- `--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 where supported by the `generate` command. - `--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`.
- `--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. - `--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`.
@@ -97,6 +192,8 @@ 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

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
report rendering 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 @@ report rendering succeeds and final metadata is saved.
- `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
@@ -152,7 +185,9 @@ the missing-source policy. Source override keys include `observations`,
- `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`
@@ -194,12 +229,19 @@ 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
@@ -209,7 +251,7 @@ reports:
- id: area_forecast_discussion - id: area_forecast_discussion
options: options:
sections: sections:
- short_term - long_term
- spc_convective_discussion - spc_convective_discussion
- daily_planning - daily_planning
- hourly_forecast - hourly_forecast
@@ -250,7 +292,8 @@ 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

@@ -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,12 +18,12 @@ 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:
@@ -31,8 +32,8 @@ Outputs:
result details, generated-text artifact paths when applicable, and result details, generated-text artifact paths when applicable, and
notification result when attempted 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
@@ -45,15 +46,18 @@ 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 ## Data Flow Terms
- `CollectedFacts` are normalized source facts fetched once from Weather API - `collect.Result` is the app-facing upstream collection result. It carries the
and made available to derivation and module builders. 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 - `DerivedFacts` are deterministic calculations over collected facts, the
resolved valid period, daypart configuration, and report-specific windows. resolved valid period, daypart configuration, and report-specific windows.
- `module.Output` values are ordered deterministic stanzas built from collected - `module.Output` values are ordered deterministic stanzas built from collected
@@ -70,40 +74,45 @@ 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 shares this setup: 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.
For `scriptorium_markdown` reports, generation then: For `scriptorium_markdown` reports, generation then:
12. Runs Scriptorium report generation to the managed report path. 10. Runs Scriptorium report generation to the managed report path.
For `generated_text_template` reports, generation then: For `generated_text_template` reports, generation then:
12. Looks up the generated-text catalog entry for the report schema/template 10. Looks up the generated-text catalog entry for the report schema/template
IDs. IDs.
13. Runs structured Scriptorium generation to the raw generated-text JSON path. 11. Runs structured Scriptorium generation to the raw generated-text JSON path.
14. Saves the structured Scriptorium run result. 12. Saves the structured Scriptorium run result.
15. Validates and saves normalized generated text. 13. Validates and saves normalized generated text.
16. Builds and saves a typed render context. 14. Builds and saves a typed render context.
17. Renders Markdown from the embedded template to the managed report path. 15. Renders Markdown from the embedded template to the managed report path.
After either mode has produced a managed Markdown report, shared finalization: After either mode has produced a managed Markdown report, shared finalization:
@@ -119,7 +128,7 @@ After either mode has produced a managed Markdown report, shared finalization:
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 Generated-text report failures are returned with report ID, RunID, and the
@@ -134,17 +143,38 @@ failure context.
## Batch Workflow ## Batch Workflow
`run morning` resolves Today Report, 3-Day Outlook, and Weekend Outlook except `run morning` collects once, plans Today Report, Tomorrow Report, and eligible
on Sunday. `run evening` resolves Tomorrow Report. Daily Report is generated future Daily Reports from the collected hourly forecast, then passes the same
only through `generate daily --date YYYY-MM-DD`; it is not part of scheduled collection into each report generation. `run evening` uses the same collection
batches. Batch output copy names come from report definitions. Batch generation and planning rules, but starts with Tomorrow Report. Future Daily reports start
continues independent reports after a failure, records each result, writes with the day after tomorrow and require complete hourly forecast coverage for
compact status lines to stderr, emits a JSON summary to stdout, and returns an the target local civil day. Dynamic Daily `--out-dir` copies use
aggregate error when any report failed. When notification is enabled, each `daily-YYYY-MM-DD.md`; other batch copies use report definition output names.
successfully generated report is notified independently. Notification failure A collection failure stops the batch before planning or report generation.
marks that report failed, records notification fields in the batch result, and After planning succeeds, batch generation continues independent reports after a
does not stop later reports. `--out-dir` copies are never used as notification failure, records each result, writes compact status lines to stderr, emits a
source files. JSON summary to stdout, and returns an aggregate error when any report failed.
Batch report generation suppresses per-report distributor notification. After
all planned reports finish, app orchestration evaluates batch notification:
1. If distributor notification is disabled, the batch notification result is
omitted.
2. If batch notification is disabled, the batch notification result is omitted
and there is no per-report fallback upload.
3. If any planned report failed, the batch notification result is `skipped`
with reason `one or more reports failed`, and distributor is not called.
4. If every report succeeded, app orchestration renders batch pipeline, bundle
ID, and idempotency key templates, renders report-specific distributor
paths for each included report, validates every managed source path and
bundle path, checks duplicate bundle paths across the batch, calls the
notifier once with a multi-file request, and saves a batch notification
debug artifact.
Batch notification failure records a top-level failed notification, increments
the aggregate batch failure count, and returns an aggregate batch error without
marking individual report items failed. `--out-dir` copies are never used as
notification source files.
## Inspection Workflow ## Inspection Workflow
@@ -155,15 +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.
- Generated-text report errors preserve available intermediate artifacts and do - Generated-text report errors preserve available intermediate artifacts and do
not create extra output copies. not create extra output copies.
- Notification errors are wrapped with report ID, RunID, and managed report path - Single-report notification errors are wrapped with report ID, RunID, and
context and are recorded separately in batch results. 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.
@@ -173,12 +207,17 @@ 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.
@@ -186,5 +225,9 @@ Inspect:
a raw data package. 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

@@ -77,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.
@@ -90,14 +90,16 @@ builders run. Configured `location` values are prompt context only; Weather API
`area_forecast_discussion` uses optional `sections` configuration to include a `area_forecast_discussion` uses optional `sections` configuration to include a
subset of discussion fields. Hourly Report defaults this module to subset of discussion fields. Hourly Report defaults this module to
`key_messages` and `short_term`. `key_messages` and `short_term`; Daily Report defaults it to `long_term`.
`spc_convective_outlooks` uses collected SPC run metadata and derived `spc_convective_outlooks` uses collected SPC run metadata and derived
report-period outlooks. It emits `checked: true` for a successfully fetched report-period outlooks. It emits `checked: true` for a successfully fetched
empty run, reports `outlook_count`, and includes prompt-facing outlook fields empty run, reports `outlook_count`, and includes prompt-facing outlook fields
such as risk label, `period_begins`, `period_ends`, image URL, and whether the such as risk label, `period_begins`, `period_ends`, image URL, and whether the
outlook contains the configured location. It does not emit GeoJSON geometry, outlook contains the configured location. It also emits a curated `risk_digest`
source URL, expiration time, or severity rank. 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 Prompt-facing module intervals use friendly local `period_begins` and
`period_ends` labels. Canonical report metadata, source provenance, `period_ends` labels. Canonical report metadata, source provenance,

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

@@ -9,8 +9,7 @@ This document describes structured generated-text handling in
generated-text-template reports and builds curated render contexts for generated-text-template reports and builds curated render contexts for
templates. It also owns the generated-text catalog that connects report templates. It also owns the generated-text catalog that connects report
definitions to validators, render-context builders, schema assets, and template definitions to validators, render-context builders, schema assets, and template
assets. The implemented contracts are Daily render context, Today Report, assets.
Tomorrow Report, and Hourly Report.
## Inputs And Outputs ## Inputs And Outputs
@@ -35,9 +34,9 @@ Outputs:
- generated-text catalog handlers for report definitions that use - generated-text catalog handlers for report definitions that use
`generated_text_template` `generated_text_template`
The Daily generated text JSON accepts the same public fields and validation ## JSON Contracts
rules as Tomorrow. Its catalog entry is selected through schema ID `daily` and
template ID `daily`, with prompt ID `weather.daily_generated_text`. Daily, Today, and Tomorrow use the same day-style generated-text JSON shape:
```json ```json
{ {
@@ -48,7 +47,21 @@ template ID `daily`, with prompt ID `weather.daily_generated_text`.
} }
``` ```
The hourly generated text JSON accepts: 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 ```json
{ {
@@ -59,39 +72,35 @@ The hourly generated text JSON accepts:
} }
``` ```
`summary` and `forecast_discussion` are required after trimming whitespace. Hourly `summary` and `forecast_discussion` are required after trimming
`precipitation_timing` and `confidence` are optional and omitted from normalized whitespace. `precipitation_timing` and `confidence` are optional and omitted
JSON when blank. 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`.
The Today generated text JSON accepts the same public fields and validation ## Render Contexts
rules as Tomorrow. It is selected by the active Today report definition through
schema ID `today`, template ID `today`, and prompt ID
`weather.today_generated_text`:
```json Daily, Today, Tomorrow, and Hourly render contexts all include:
{
"summary": "string",
"forecast_discussion": ["string"],
"precipitation_timing": "string",
"confidence": "string"
}
```
The Tomorrow generated text JSON accepts: - display metadata derived from report metadata;
- validated generated text;
- typed module outputs decoded from the module snapshot;
- collected facts;
- derived facts.
```json Daily, Today, and Tomorrow share common civil-day render-context fields such as
{ forecast date labels, valid period, generated-at labels, current conditions,
"summary": "string", hourly forecast, precipitation timing, alert digest, SPC outlooks, AFD, SPC
"forecast_discussion": ["string"], discussion, weather story, daily summary, and ordered daypart summaries.
"precipitation_timing": "string",
"confidence": "string"
}
```
`summary` is required after trimming whitespace. `forecast_discussion` must Each civil-day report keeps its report-specific planning module:
contain at least one nonblank paragraph after trimming blank items.
`precipitation_timing` and `confidence` are optional and omitted from normalized - Daily exposes `DailyPlanning`.
JSON when blank. - 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 ## Boundaries

View File

@@ -44,8 +44,8 @@ render-context reconstruction keep the full deterministic template surface.
The `internal/briefing` module registry attaches prompt export values when it The `internal/briefing` module registry attaches prompt export values when it
builds module outputs. Modules without a custom exporter use default builds module outputs. Modules without a custom exporter use default
pass-through behavior, so their prompt value is the same as their rich value. pass-through behavior, so their prompt value is the same as their rich value.
Modules that need cleanup own typed prompt export structs near the module Modules with custom prompt export policy own typed prompt export structs near
builder. Current custom prompt exports are: the module builder. Custom prompt exports are:
- `current_conditions` - `current_conditions`
- `hourly_forecast` - `hourly_forecast`
@@ -99,7 +99,8 @@ The default Daily Report module order is:
14. `hourly_forecast` 14. `hourly_forecast`
The embedded Daily template uses selected deterministic fields from these The embedded Daily template uses selected deterministic fields from these
module outputs after GeneratedText validation. module outputs after GeneratedText validation. Its `area_forecast_discussion`
item is configured to include only `long_term`.
## Today Composition ## Today Composition
@@ -225,12 +226,19 @@ validation.
- `location_name` - `location_name`
- `outlook_count` - `outlook_count`
- `outlooks` - `outlooks`
- `risk_digest`
Each outlook entry may include `day`, `outlook_type`, `label`, `label_text`, Each outlook entry may include `day`, `outlook_type`, `label`, `label_text`,
`period_begins`, `period_ends`, `issued_at`, `contains_location`, and `period_begins`, `period_ends`, `issued_at`, `contains_location`, and
`image_url`. It omits GeoJSON geometry, source URL, expiration time, and `image_url`. It omits GeoJSON geometry, source URL, expiration time, and
severity rank. 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 `spc_convective_discussion` emits a narrative stanza only when a retained
report-period categorical outlook has severity rank `3` or higher and matching report-period categorical outlook has severity rank `3` or higher and matching
discussion text is available. Its output includes `included_because` and discussion text is available. Its output includes `included_because` and
@@ -243,7 +251,7 @@ meet the severity threshold.
- 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

View File

@@ -128,7 +128,7 @@ Current categories are:
- 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,
choose module prompt export shapes, find prior snapshots, compare changes, choose module prompt export shapes, find prior snapshots, compare changes,
choose artifact paths, or invoke Scriptorium. choose artifact paths, or invoke Scriptorium.

View File

@@ -1,7 +1,7 @@
# 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
@@ -24,7 +24,6 @@ Each report definition declares:
- 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. Report-owned helpers map public command names and config keys to report IDs.
@@ -69,8 +68,7 @@ All report definitions are eligible for generation.
not a configuration field. 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
@@ -80,8 +78,9 @@ must be after start time.
## Boundaries ## Boundaries
`internal/report` defines report metadata, public report names, batch command `internal/report` defines report metadata, public report names, batch command
names, and time coverage. It does not fetch weather data, build module values, names, output naming, and time coverage. It does not collect weather data, plan
compare snapshot contents, write state, parse CLI flags, or invoke Scriptorium. batch membership, build module values, compare snapshot contents, write state,
parse CLI flags, or invoke Scriptorium.
The CLI parses flags and command structure, then uses report-owned helpers for The CLI parses flags and command structure, then uses report-owned helpers for
report and batch command names. Config loading uses report-owned helpers for report and batch command names. Config loading uses report-owned helpers for
@@ -94,11 +93,16 @@ output path copying uses batch output names from report definitions. Report
module overrides can use short keys such as `daily`, `today`, `tomorrow`, and module overrides can use short keys such as `daily`, `today`, `tomorrow`, and
`hourly`, or descriptive names such as `three_day_outlook`. `hourly`, or descriptive names such as `three_day_outlook`.
## Batch Membership ## Batch Commands
Morning batches include Today Report, 3-Day Outlook, and Weekend Outlook `internal/report` owns the public batch command names `morning` and `evening`
except on Sunday. Evening batches include Tomorrow Report. Daily Report and and validates them through `BatchForCommandName`. Data-dependent batch
Hourly Report are not part of a scheduled 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
@@ -135,3 +139,5 @@ Inspect:
- 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

@@ -12,6 +12,7 @@ Tomorrow, and Hourly.
The package embeds assets from: The package embeds assets from:
- `internal/reporttemplate/templates/*.md.tmpl` - `internal/reporttemplate/templates/*.md.tmpl`
- `internal/reporttemplate/templates/partials/*.md.tmpl`
- `internal/reporttemplate/schemas/*.schema.json` - `internal/reporttemplate/schemas/*.schema.json`
Generated-text prompt source files live under Generated-text prompt source files live under
@@ -42,7 +43,7 @@ Generated-text prompt sources are maintained under
## Boundaries ## Boundaries
This package owns embedded asset lookup, Go template parsing, and Markdown This package owns embedded asset lookup, Go template parsing, and Markdown
template execution. It does not fetch weather data, build module outputs, template execution. It does not collect weather data, build module outputs,
validate GeneratedText, construct render contexts, choose report definitions, validate GeneratedText, construct render contexts, choose report definitions,
write artifacts, invoke Scriptorium, or notify distributor. write artifacts, invoke Scriptorium, or notify distributor.
@@ -73,6 +74,15 @@ template and renders from `generatedtext.DailyRenderContext`.
Templates use `text/template` with `missingkey=error`, so missing context fields Templates use `text/template` with `missingkey=error`, so missing context fields
fail rendering instead of producing incomplete Markdown. 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 ## Schema Contract
The GeneratedText schemas describe the structured prose Scriptorium is expected The GeneratedText schemas describe the structured prose Scriptorium is expected
@@ -92,6 +102,7 @@ slots consumed by the template.
- Unknown template IDs return actionable lookup errors. - Unknown template IDs return actionable lookup errors.
- Unknown schema IDs return actionable lookup errors. - Unknown schema IDs return actionable lookup errors.
- Template parse errors include the template ID. - 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 - Template execution errors include the template ID and usually identify the
missing context field. missing context field.
@@ -107,6 +118,7 @@ Inspect:
## Invariants ## Invariants
- Embedded templates and schemas live as separate files, not inline Go strings. - 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. - Report definitions select templates by ID.
- Templates render from curated render contexts, not raw data packages. - Templates render from curated render contexts, not raw data packages.
- GeneratedText schemas describe LLM prose slots, not deterministic weather - GeneratedText schemas describe LLM prose slots, not deterministic weather

View File

@@ -34,7 +34,7 @@ Outputs:
`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, Markdown run, and The adapter exposes request and result structs for render, Markdown run, and
structured generated-text run operations. State persistence uses state-owned structured generated-text run operations. State persistence uses state-owned

View File

@@ -31,6 +31,7 @@ Outputs:
- render context 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, data package, generated text, generated-text run - loaded module snapshot, data package, generated text, generated-text run
result, or render context result, or render context
@@ -61,20 +62,22 @@ 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
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.raw.json snapshots/<artifact_group>/<YYYY-MM-DD>/metadata.<run_id>.json
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.run.json snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_raw.<run_id>.json
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.json snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_result.<run_id>.json
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.render_context.json snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text.<run_id>.json
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.yaml snapshots/<artifact_group>/<YYYY-MM-DD>/render_context.<run_id>.json
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json data-packages/<artifact_group>/<YYYY-MM-DD>/data_package.<run_id>.yaml
notifications/<artifact_group>/<YYYY-MM-DD>/<run_id>.distributor.json preflight/<artifact_group>/<YYYY-MM-DD>/render.<run_id>.json
reports/<artifact_group>/<run_id>.md 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
@@ -85,6 +88,10 @@ Scriptorium run result, validated generated text, and render context artifacts.
Markdown-report metadata omits those generated-text fields. Report listing Markdown-report metadata omits those generated-text fields. Report listing
walks metadata files under the snapshots directory. 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
Prior snapshot lookup reads stored metadata through the shared lookup path and Prior snapshot lookup reads stored metadata through the shared lookup path and
@@ -118,6 +125,27 @@ atomically when notification is attempted and include rendered distributor
pipeline ID, bundle ID, idempotency key, bundle paths, upload status, latest pipeline ID, bundle ID, idempotency key, bundle paths, upload status, latest
run status, and redacted errors. run status, and redacted errors.
Single-report notification artifacts use schema version
`weatherreporter.distributor_notification.v1` and record one managed source
path plus that source's bundle paths. Batch notification artifacts use schema
version `weatherreporter.batch_distributor_notification.v1` and record:
- `batch`
- `batchRunId`
- `attemptedAt`
- `endpoint`
- `pipelineId`
- `bundleId`
- `idempotencyKey`
- `bundleCreated`
- `includedReports`, each with `reportId`, `runId`, `sourcePath`, and
`bundlePaths`
- `status`
- `upload`
- `runStatus`
- `statusError`
- `error`
Inspection helpers read existing metadata, module snapshot, data package, Inspection helpers read existing metadata, module snapshot, data package,
generated text, generated-text run result, and render context files. Missing generated text, generated-text run result, and render context files. Missing
metadata directories return no inspection records or no prior snapshot rather metadata directories return no inspection records or no prior snapshot rather
@@ -145,4 +173,6 @@ Inspect:
- Metadata links artifacts produced for a run. - Metadata links artifacts produced for a run.
- Generated-text artifacts live under the snapshots tree beside module - Generated-text artifacts live under the snapshots tree beside module
snapshots and metadata. 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

@@ -18,11 +18,11 @@ 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
``` ```
Generation commands resolve a report period, fetch a Weather API bundle, build Generation commands resolve a report period, collect a Weather API bundle,
a rich JSON module snapshot, build a curated YAML prompt input data package, run build a rich JSON module snapshot, build a curated YAML prompt input data
`scriptorium render`, and write managed artifacts under the configured package, run `scriptorium render`, and write managed artifacts under the
workspace. Markdown-path reports then run `scriptorium run` directly to the configured workspace. Markdown-path reports then run `scriptorium run` directly
managed Markdown report path. to the managed Markdown report path.
`generate daily`, `generate today`, `generate tomorrow`, and `generate hourly` `generate daily`, `generate today`, `generate tomorrow`, and `generate hourly`
use the generated-text-template workflow. They run structured `scriptorium run` use the generated-text-template workflow. They run structured `scriptorium run`
@@ -31,13 +31,15 @@ and render the managed Markdown report from embedded templates. `generate
daily` requires `--date YYYY-MM-DD` for the selected local civil day. daily` requires `--date YYYY-MM-DD` for the selected local civil day.
`generate today` covers the selected or current local civil day. `generate `generate today` covers the selected or current local civil day. `generate
hourly` covers the six-hour rolling period from generation time in the hourly` covers the six-hour rolling period from generation time in the
effective report timezone and is not part of scheduled morning or evening effective report timezone and is not included in `run morning` or
batches. `run evening`.
When distributor notification is enabled, weatherreporter uploads the managed When distributor notification is enabled, weatherreporter uploads the managed
Markdown report after report rendering 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 generated reports; it is not used `--out PATH` writes an extra Markdown copy for generated reports; it 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:
@@ -46,16 +48,26 @@ weatherreporter run morning
weatherreporter run evening weatherreporter run evening
``` ```
`run morning` generates Today Report 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 Report. 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 `today.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
@@ -63,146 +75,47 @@ 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
<run_id>.generated_text.raw.json snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text_result.<run_id>.json
<run_id>.generated_text.run.json snapshots/<artifact_group>/<YYYY-MM-DD>/generated_text.<run_id>.json
<run_id>.generated_text.json snapshots/<artifact_group>/<YYYY-MM-DD>/render_context.<run_id>.json
<run_id>.render_context.json
today/ 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
<run_id>.generated_text.raw.json notifications/batches/<batch>/<YYYY-MM-DD>/distributor.<batch_run_id>.json
<run_id>.generated_text.run.json
<run_id>.generated_text.json
<run_id>.render_context.json
three-day/
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
weekend/
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
hourly/
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
<run_id>.generated_text.raw.json
<run_id>.generated_text.run.json
<run_id>.generated_text.json
<run_id>.render_context.json
tomorrow/
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
<run_id>.generated_text.raw.json
<run_id>.generated_text.run.json
<run_id>.generated_text.json
<run_id>.render_context.json
storm/
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
data-packages/
daily/
YYYY-MM-DD/
<run_id>.data_package.yaml
today/
YYYY-MM-DD/
<run_id>.data_package.yaml
three-day/
YYYY-MM-DD/
<run_id>.data_package.yaml
weekend/
YYYY-MM-DD/
<run_id>.data_package.yaml
hourly/
YYYY-MM-DD/
<run_id>.data_package.yaml
tomorrow/
YYYY-MM-DD/
<run_id>.data_package.yaml
storm/
YYYY-MM-DD/
<run_id>.data_package.yaml
preflight/
daily/
YYYY-MM-DD/
<run_id>.render.json
today/
YYYY-MM-DD/
<run_id>.render.json
three-day/
YYYY-MM-DD/
<run_id>.render.json
weekend/
YYYY-MM-DD/
<run_id>.render.json
hourly/
YYYY-MM-DD/
<run_id>.render.json
tomorrow/
YYYY-MM-DD/
<run_id>.render.json
storm/
YYYY-MM-DD/
<run_id>.render.json
notifications/
daily/
YYYY-MM-DD/
<run_id>.distributor.json
today/
YYYY-MM-DD/
<run_id>.distributor.json
three-day/
YYYY-MM-DD/
<run_id>.distributor.json
weekend/
YYYY-MM-DD/
<run_id>.distributor.json
hourly/
YYYY-MM-DD/
<run_id>.distributor.json
tomorrow/
YYYY-MM-DD/
<run_id>.distributor.json
storm/
YYYY-MM-DD/
<run_id>.distributor.json
reports/
daily/
<run_id>.md
today/
<run_id>.md
three-day/
<run_id>.md
weekend/
<run_id>.md
hourly/
<run_id>.md
tomorrow/
<run_id>.md
storm/
<run_id>.md
``` ```
Managed artifact filenames use the RunID, so repeated runs for the same valid 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 20260529T100000.123456789Z_daily_2026-05-31
20260529T100000.123456789Z_today 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:
- RunID, report ID, variant, and prompt ID - RunID, report ID, variant, and prompt ID
@@ -216,19 +129,32 @@ Each generated report writes metadata that links:
generated-text-template reports 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
@@ -238,31 +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
For Daily, the default report ID and artifact group values are both `daily`, keys default to `{bundle_id}.{batch_run_id}`. Distributor owns destination
and the default output filename value is `daily.md`. merge, retention, and derived snapshot behavior such as `latest`. For Daily,
For Today, the default report ID and artifact group values are both `today`, the default report ID and artifact group values are both `daily`, and the
and the batch output filename value is `today.md`. 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 for generated reports. Weather Single-report notification happens after final metadata save for generated
API, module snapshot, data-package, render preflight, Scriptorium run, reports. Batch notification happens after all planned reports have finished and
generated-text validation, template rendering, and metadata-save failures do only when all report generations succeeded. Collection, module snapshot,
not trigger notification. A notification failure fails that report. data-package, render preflight, Scriptorium run, generated-text validation,
In a batch, other reports continue, the failed report includes notification template rendering, and metadata-save failures do not trigger notification. A
fields in the JSON summary, and the batch returns nonzero. 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
@@ -274,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
@@ -322,10 +257,13 @@ A failed generation run may still leave useful artifacts:
available intermediate artifacts, such as the structured run result, raw available intermediate artifacts, such as the structured run result, raw
generated-text JSON, validated generated text, and render context. Metadata generated-text JSON, validated generated text, and render context. Metadata
links those paths when it can be safely written. links those paths when it can be safely written.
- If distributor notification fails, report artifacts and final metadata remain - If single-report distributor notification fails, report artifacts and final
available, but the report or batch command returns nonzero. 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

@@ -1,659 +0,0 @@
# Code Quality And Deduplication Audit
## Executive summary
Overall code quality is strong. The repository is modular, the important
external boundaries are mostly contained behind adapters, state paths are
centralized, durable writes use shared file helpers, and report/module identity
policy is mostly registry-driven.
The top three cleanup targets before the next major release are:
1. Day-style generated-text report duplication across Daily, Today, and
Tomorrow.
2. Duplicated report-module config normalization and validation logic.
3. Repeated Scriptorium run and structured-run result plumbing.
The codebase appears ready for a limited cleanup pass. I do not see a major
architectural risk that would require a broad rewrite. The most useful cleanup
work is narrow, behavior-preserving, and should keep public CLI syntax and
managed artifact paths stable.
## Repository map reviewed
Reviewed documentation and examples:
- `README.md`
- `docs/policy/architecture.md`
- `docs/policy/development.md`
- `docs/policy/documentation.md`
- `docs/config.md`
- `docs/cli.md`
- `docs/operations.md`
- `docs/troubleshooting.md`
- `docs/internal/*.md`
- `docs/integrations/{weatherapi,scriptorium,distributor}`
- `docs/roadmap/{daily,data-package-exports,future,implementation}.md`
- `examples/config.yml`
- `examples/minimal-config.yml`
Reviewed code areas:
- `cmd/weatherreporter`
- `internal/cli`
- `internal/config`
- `internal/app`
- `internal/report`
- `internal/module`
- `internal/briefing`
- `internal/facts`
- `internal/forecast`
- `internal/generatedtext`
- `internal/reporttemplate`
- `internal/promptinput`
- `internal/changes`
- `internal/state`
- `internal/fileutil`
- `internal/adapters/{weatherapi,scriptorium,distributor}`
- `internal/weatherdata`
- package-level tests throughout `internal`
Major execution paths reviewed:
- `weatherreporter generate ...` parsing and orchestration.
- `weatherreporter run morning/evening` batch orchestration and output.
- `weatherreporter inspect ...` artifact inspection.
- Weather API bundle fan-out and missing-source handling.
- module snapshot and prompt-input construction.
- generated-text-template workflow for `daily`, `today`, `tomorrow`, and
`hourly`.
- Scriptorium render/run/structured-run calls.
- distributor notification request construction, upload, status, and debug
artifact persistence.
Areas not deeply inspected:
- Historical files under `workspace/`; these are generated artifacts rather
than source of truth.
- Full live integration behavior against Weather API, Scriptorium, or
distributor; tests and adapter code were inspected instead.
## High-confidence deduplication opportunities
### 1. Consolidate common Daily/Today/Tomorrow generated-text report plumbing
Affected files/packages:
- `internal/generatedtext/daily.go`
- `internal/generatedtext/today.go`
- `internal/generatedtext/tomorrow.go`
- `internal/generatedtext/render_context.go`
- `internal/reporttemplate/templates/daily.md.tmpl`
- `internal/reporttemplate/templates/today.md.tmpl`
- `internal/reporttemplate/templates/tomorrow.md.tmpl`
- `internal/reporttemplate/schemas/*.generated_text.schema.json`
Duplicated or near-duplicated behavior:
- `Daily`, `Today`, and `Tomorrow` have the same GeneratedText JSON shape:
`summary`, array-valued `forecast_discussion`, optional
`precipitation_timing`, and optional `confidence`.
- `ValidateDaily`, `ValidateToday`, and `ValidateTomorrow` perform the same
trim, required-field, normalize, and error-shaping steps with only the report
name changing.
- `DailyReportContext`, `TodayReportContext`, and `TomorrowReportContext` have
the same fields.
- `dailyTemplateModules`, `todayTemplateModules`, and
`tomorrowTemplateModules` repeat the same module-snapshot lookups for common
stanzas, then differ only in ordered daypart wrapper and planning module.
- The Daily, Today, and Tomorrow Markdown templates repeat large daypart and
precipitation-timing blocks. For example, the Daypart Forecast logic appears
in all three templates, with small policy differences such as Today omitting
missing daypart lines while Daily/Tomorrow render `Forecast details are
limited`.
Why it matters:
- These reports are intentionally independent at the public/report-definition
layer, but their current internal GeneratedText and daypart template surfaces
are close enough that wording, validation, and render-context changes are
likely to require edits in multiple places.
- Small drift in deterministic report wording is easy to introduce accidentally
because the duplicated template logic is dense and hard to compare.
- New day-style reports would likely copy this pattern again.
Recommended refactor:
- Keep separate exported report-specific types and template files, because the
reports are intended to diverge.
- Introduce a small shared day-style GeneratedText validation helper that takes
the report name and typed value, or introduce an unexported common
`dayGeneratedText` shape embedded by report-specific types if that keeps
JSON and schema behavior clear.
- Add a shared `dayReportCommonModules` or equivalent helper that loads common
module stanzas once and lets Daily/Today/Tomorrow add only report-specific
planning fields and daypart wrapper types.
- Consider named template partials or a small deterministic presentation struct
for shared daypart/precipitation line rendering. Do not collapse the separate
report template files; make the shared pieces explicit and opt-in.
Suggested tests:
- Keep existing `internal/generatedtext` validation tests for each public type.
- Add a table test that Daily, Today, and Tomorrow validators share the same
required-field behavior and unknown-field rejection.
- Add render-context tests proving common module fields are populated for all
three day-style reports and each report still exposes its own planning module.
- Add template tests that pin the intended daypart behavior for all three
reports after shared rendering cleanup.
Risk level: medium. The behavior is user-facing Markdown and prompt-schema
related, but the cleanup can be done in small steps with existing tests.
### 2. Centralize report-module config normalization and validation
Affected files/packages:
- `internal/config/reports.go`
- `internal/config/config_test.go`
Duplicated or near-duplicated behavior:
- `normalizeReportModules` and `validateReportModules` both traverse
`cfg.Reports`, resolve config keys with `report.IDForConfigKey`, track
duplicate report aliases, initialize the default report and module
registries, normalize module options, and call
`ModuleRegistry.ValidateComposition`.
- `Config.ReportModuleOverrides` performs another report-key and duplicate
alias traversal before returning normalized module items.
Why it matters:
- Report-module configuration is user-visible policy. Drift between load-time
normalization, validation, and app-time override extraction could cause a
config to pass one path and fail another, or produce different error wording.
- Adding a new report key, module option type, or validation rule currently
requires touching and reasoning about multiple similar loops.
Recommended refactor:
- Introduce one unexported helper that resolves report config entries into a
canonical structure, for example `normalizedReportOverrides(cfg,
normalizeOptions bool)`.
- Have `Load` call the helper in mutating mode and have `Validate` or
`ReportModuleOverrides` reuse the same canonical traversal. Preserve current
error prefixes such as `reports.<key>.deterministic_modules[...]`.
- Keep config loading and validation in `internal/config`; do not move user
config parsing into `internal/app`.
Suggested tests:
- Existing config tests for unknown report keys, duplicate report aliases,
unknown modules, incompatible modules, duplicate modules, and invalid module
options should continue passing.
- Add one regression test that a manually constructed `config.Config` with raw
module options fails or normalizes consistently through the same helper path.
- Add table coverage that duplicate aliases produce identical error context from
`Load` and `ReportModuleOverrides`.
Risk level: low to medium. This is internal config plumbing, but it protects a
public configuration surface.
### 3. Deduplicate Scriptorium run and structured-run execution plumbing
Affected files/packages:
- `internal/adapters/scriptorium/runner.go`
- `internal/adapters/scriptorium/runner_test.go`
- `internal/app/app.go`
Duplicated or near-duplicated behavior:
- `RunResult` and `StructuredRunResult` have the same fields.
- `Runner.Run` and `Runner.StructuredRun` validate the same request fields,
call `execute`, build the same result shape, attach `OutputPath`, and check a
nonzero exit code. The only meaningful differences are request/result types
and error wording.
- `structuredRunArgs` delegates to `runArgs`, so the argv behavior is already
shared.
Why it matters:
- `scriptorium run` is a critical external integration. Any future change to
stdout/stderr truncation, exit-code handling, or output-path recording should
not require repeated edits.
- The duplication is small now, but it is exactly the kind of adapter behavior
that can drift invisibly.
Recommended refactor:
- Keep the public `RunResult` and `StructuredRunResult` types if callers benefit
from distinct names.
- Add an unexported `executeRunLike` helper that validates prompt ID, data
package path, and output path, executes args, and returns a shared internal
result struct.
- Convert the shared result into `RunResult` or `StructuredRunResult` at the
public method boundary so JSON artifact shapes remain unchanged.
Suggested tests:
- Existing Scriptorium runner tests should remain behavior-preserving.
- Add or keep table coverage that `Run` and `StructuredRun` produce the same
argv, output-path field, truncation fields, and nonzero exit behavior.
Risk level: low. Adapter boundary is already narrow and well tested.
## Medium-confidence opportunities
### 1. Reduce repeated state save boilerplate without hiding artifact semantics
Affected files/packages:
- `internal/state/filesystem.go`
- `internal/state/filesystem_test.go`
Duplicated or near-duplicated behavior:
- Most save methods call `Paths`, select one path, write JSON or bytes
atomically, and return the selected path.
- Examples include `SaveModuleSnapshot`, `SavePreflight`,
`SaveDistributorNotification`, `SaveGeneratedTextRaw`,
`SaveGeneratedTextResult`, `SaveGeneratedText`, and `SaveRenderContext`.
Semantic differences that may be intentional:
- Some methods validate payloads before writing.
- `SaveDataPackage` delegates to `promptinput.Save`.
- `SaveMetadata` intentionally writes to the explicit metadata path stored on
`state.Metadata`.
Why it matters:
- Adding a new artifact type requires repeating the same path/write pattern.
- Error context could drift if some methods wrap paths and others do not.
Recommended refactor:
- Consider small private helpers such as `saveJSONArtifact(resolved, pathFn,
value)` and `saveBytesArtifact(resolved, pathFn, data)` only if a new artifact
type is added or the save methods change again.
- Do not create a generic manifest or artifact framework in this cleanup pass.
Suggested tests:
- Keep path tests and artifact save/load tests.
- If helpers are introduced, add one focused test that a representative JSON
artifact and byte artifact still write to the same managed paths.
Risk level: low.
### 2. Extract package-local CLI integration test fixture helpers
Affected files/packages:
- `internal/cli/root_test.go`
- `internal/app/app_test.go`
Duplicated or near-duplicated behavior:
- CLI integration tests carry large fake Scriptorium shell scripts with repeated
prompt-specific JSON cases for today, tomorrow, daily, and hourly.
- Config string construction and test Weather API setup are repeated across
groups of tests.
Semantic differences that may be intentional:
- Some fake scripts exercise markdown-output mode, some structured-output mode,
and some failure behavior.
- These tests are integration-style and intentionally explicit.
Why it matters:
- As report types and prompt schemas grow, the shell fixture blocks become a
brittle place to update prompt IDs, generated text schema shape, and expected
report behavior.
- The large test file makes failures harder to localize.
Recommended refactor:
- Add package-local helper builders for fake Scriptorium behavior, for example a
map from prompt ID to JSON response plus a failure map for specific prompts.
- Keep helpers local to `internal/cli` and `internal/app`; do not introduce a
cross-package test framework.
- Keep a few explicit end-to-end tests that prove real command wiring still
works.
Suggested tests:
- No new behavior tests are required before helper extraction.
- After extraction, run `go test ./internal/cli ./internal/app` and compare key
assertions around generated-text artifacts, CLI output, and batch summaries.
Risk level: low.
### 3. Clarify config-to-briefing registry boundary
Affected files/packages:
- `internal/config/reports.go`
- `internal/briefing/modules.go`
- `internal/module/module.go`
Duplicated or near-duplicated behavior:
- Not primarily duplication. This is a boundary concern: `internal/config`
imports `internal/briefing` to initialize the default module registry and
validate report module composition.
Semantic differences that may be intentional:
- The briefing registry is currently the implemented source of module builders,
supported reports, options, missing-data behavior, and prompt exporters.
- Config validation needs option schemas and composition rules, so using the
registry is pragmatic.
Why it matters:
- `internal/config` now depends on the module-builder package, not only on
stable module option metadata. That is workable today, but it makes the
config package pull in more of the report-building layer than it strictly
needs.
- If modules become more numerous or more expensive to initialize, config
validation may become harder to keep side-effect free.
Recommended refactor:
- Do not split this immediately.
- If module catalog complexity grows, consider separating build-free module
definition metadata from module builders. The metadata/catalog can validate
config options and composition; `internal/briefing` can attach builders and
prompt exporters.
Suggested tests:
- Keep config tests that prove report/module overrides fail during config load.
- If a build-free catalog is introduced later, add tests proving config
validation and runtime module build registry accept the same module IDs,
options, supported reports, and missing-data policies.
Risk level: medium if deferred too long, low today.
### 4. Keep Weather API source fan-out explicit, but watch source metadata drift
Affected files/packages:
- `internal/adapters/weatherapi/client.go`
- `internal/weatherdata/bundle.go`
Duplicated or near-duplicated behavior:
- Each source fetch follows the same rough pattern: declare target, call
`fetchDecodedSource`, attach source timestamps, assign bundle field, and add
source provenance.
Semantic differences that may be intentional:
- Alerts intentionally special-case `data:null`.
- Hourly is required and validates non-empty periods.
- Weather story omits units.
- SPC outlooks use endpoint constants and different issued/updated rules.
Why it matters:
- The current code is readable and not over-abstracted. The risk is future
source additions repeating timestamp/hash/missing-policy decisions by hand.
Recommended refactor:
- Do not introduce a generic source ingestion framework now.
- When the next source is added, consider adding small source-spec helpers for
only the shared provenance fields that have identical semantics.
Suggested tests:
- Maintain focused adapter tests for each source's endpoint, query parameters,
missing-source policy, and source metadata.
Risk level: low.
## Boundary and responsibility concerns
- `internal/config` depending on `internal/briefing` for module registry
validation is the clearest boundary ambiguity. It is currently pragmatic, but
a future build-free module catalog would fit the architecture better if
module option/config complexity grows.
- `internal/app` owns distributor notification request construction, including
template values derived from report metadata. That is acceptable because
orchestration owns the report result and managed report path, while
distributor package types remain inside `internal/adapters/distributor`.
- `internal/generatedtext` currently owns render-context assembly from module
snapshots. That is a reasonable home, but the day-style report duplication
should be reduced inside that package rather than moved into templates,
`internal/app`, or `internal/reporttemplate`.
- `internal/reporttemplate` correctly owns embedded template/schema lookup and
rendering only. It should not absorb generated-text validation or report
module policy.
## Path, key, and naming construction review
Local workspace paths are centralized in `internal/state.FilesystemStore.Paths`.
This is a strong point: module snapshots, metadata, data packages, preflight
artifacts, notification artifacts, generated-text artifacts, render contexts,
and managed reports all derive from a single path function.
Distributor bundle path rendering is centralized in `internal/config` through
`RenderDistributorReportPaths` and related template renderers. `internal/app`
only assembles template values and maps the managed report path to rendered
bundle paths. This is appropriate.
Report identity, artifact groups, batch output names, prompt IDs, template IDs,
and schema IDs are declared in `internal/report` definitions. Current report
definition files are now one report per file for active report types, which is
easy to navigate.
Areas needing cleanup:
- The repeated state save methods can be lightly helperized later, but path
construction itself is centralized enough.
- Day-style template paths/schema IDs are declared in both `internal/report`
definitions and `internal/generatedtext`/`internal/reporttemplate` catalogs.
This is acceptable because those packages own different parts of the
contract, but tests should continue asserting catalog compatibility.
## Resolution and catalog review
Report resolution is consistent:
- CLI command names resolve through `internal/report.IDForCommandName`.
- Config report keys resolve through `internal/report.IDForConfigKey`.
- Batches resolve through `internal/report.BatchReports`.
- Report definitions own valid-period resolvers and module composition.
Module resolution is consistent:
- Report definitions and config overrides use `module.ConfigItem`.
- Config module options are normalized and composition is validated before app
use.
- Runtime module execution uses `internal/briefing.ModuleRegistry`.
- Prompt-input category mapping is centralized in `internal/promptinput`.
Generated-text resolution is mostly consistent:
- `internal/report` declares generation mode, template ID, and generated-text
schema ID.
- `internal/generatedtext.LookupDefinition` checks schema/template support and
pairing.
- `internal/reporttemplate` owns embedded asset lookup.
Recommended centralization:
- Consolidate day-style generated-text validation and common render-context
extraction inside `internal/generatedtext`.
- Keep separate report-specific public IDs, schemas, prompt assets, and
template files.
## Config and command-loading review
Configuration loading is centralized in `internal/config.Load` with the
documented precedence: CLI overrides, config file, built-in defaults.
Environment secrets load through `secrets.directory` after config file parsing
and CLI overrides, before validation completes. CLI commands consistently call
`config.Load` rather than independently applying defaults.
Intentional differences:
- `generate` accepts `--out`; `run` accepts `--out-dir`; inspect commands do
not accept weather overrides.
- `generate daily` requires `--date`; `generate today` accepts optional
`--date`; storm requires `--start`/`--end`.
- Distributor has no CLI flags and is config-only.
Likely accidental or cleanup-worthy differences:
- Report-module config traversal is duplicated across normalization,
validation, and override extraction. This is the main config cleanup target.
## State, manifest, or progress handling review
The application has durable workspace state but no manifest/resume engine.
That is appropriate for the current scope.
State handling is consistent in the implemented flow:
- managed paths are computed by `state.FilesystemStore.Paths`;
- module snapshots, data packages, preflight output, generated-text artifacts,
render context, metadata, and notification debug artifacts are persisted
under managed paths;
- metadata links the relevant artifact paths;
- prior snapshot lookup uses stored metadata and report compatibility policy;
- inspection reads metadata and linked artifacts rather than refetching data.
Potential drift to watch:
- Metadata is saved several times during generated-text-template reports as
artifacts become available. This is operationally useful for diagnosis, but
future artifact additions should preserve the same pattern deliberately.
- There is no retry/resume manifest. Do not add one unless operational
requirements become concrete.
## Refactors to avoid
- Do not introduce a generic workflow engine for generation. The current
explicit app orchestration is readable and well covered.
- Do not migrate to Cobra or redesign the CLI. The standard-library CLI remains
adequate.
- Do not introduce a plugin architecture for reports, modules, or adapters.
- Do not create per-module or per-report Go packages. Recent file-level
separation is sufficient.
- Do not build a broad Weather API source ingestion framework yet. Keep source
fetches explicit until repeated source semantics become materially expensive.
- Do not replace module snapshots with a manifest system in this cleanup pass.
- Do not create a global test helper package. Use package-local helpers where
test setup is noisy.
- Do not consolidate Daily, Today, and Tomorrow into one public report type.
Their public identities and templates are intentionally independent.
## Recommended implementation sequence
1. **GeneratedText day-report helper cleanup**
- Goal: reduce Daily/Today/Tomorrow validation and render-context
duplication while preserving separate public report types and template
files.
- Files: `internal/generatedtext/*.go`, generatedtext tests.
- Validation: `go test ./internal/generatedtext`.
2. **Day-style template duplication cleanup**
- Goal: reduce repeated Daypart Forecast and Precipitation Timing template
logic without eliminating per-report templates.
- Files: `internal/reporttemplate/templates/*.md.tmpl`,
`internal/reporttemplate/reporttemplate.go` if named partial parsing is
used, template tests, `docs/templates.md`.
- Validation: `go test ./internal/reporttemplate ./internal/generatedtext`.
3. **Report-module config traversal cleanup**
- Goal: use one canonical traversal for report override normalization,
validation, and extraction.
- Files: `internal/config/reports.go`, config tests.
- Validation: `go test ./internal/config ./internal/app`.
4. **Scriptorium run-result helper cleanup**
- Goal: share run/structured-run execution result construction while keeping
exported result structs stable.
- Files: `internal/adapters/scriptorium/runner.go`,
`internal/adapters/scriptorium/runner_test.go`.
- Validation: `go test ./internal/adapters/scriptorium`.
5. **Package-local CLI/app test fixture cleanup**
- Goal: reduce repeated fake Scriptorium/config setup.
- Files: `internal/cli/root_test.go`, optionally `internal/app/app_test.go`.
- Validation: `go test ./internal/cli ./internal/app`.
6. **Optional state save helper cleanup**
- Goal: reduce repeated `Paths` plus atomic write boilerplate only if the
prior steps touch state tests or a new artifact type is being added.
- Files: `internal/state/filesystem.go`, `internal/state/filesystem_test.go`.
- Validation: `go test ./internal/state ./internal/app`.
7. **Final documentation and validation**
- Goal: update implemented docs for any changed internal contracts, then run
full validation.
- Files: relevant `docs/internal/*`, `docs/templates.md`,
`docs/policy/development.md` only if workflow changes.
- Validation: `go test ./...`, `go run ./cmd/weatherreporter --help`,
`git diff --check`.
## Test strategy
Tests to add before or during cleanup:
- `internal/generatedtext`: table tests proving Daily/Today/Tomorrow share the
same generated-text required-field and unknown-field behavior.
- `internal/generatedtext`: tests proving common day-style module extraction
still returns report-specific planning modules.
- `internal/reporttemplate`: tests for shared daypart and precipitation
rendering behavior after template cleanup.
- `internal/config`: tests proving `Load`, `Validate`, and
`ReportModuleOverrides` share duplicate alias and invalid module behavior.
- `internal/adapters/scriptorium`: tests proving `Run` and `StructuredRun`
preserve argv, output path, captured output, truncation flags, and nonzero
exit behavior.
- `internal/cli` and `internal/app`: keep workflow tests for generated-text
artifacts, data-package output, notification behavior, and batch summaries.
Validation commands for cleanup work:
```sh
go test ./internal/generatedtext ./internal/reporttemplate
go test ./internal/config ./internal/app
go test ./internal/adapters/scriptorium
go test ./internal/cli ./internal/state
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Lightweight audit validation performed for this report:
```sh
go list ./...
```
## Appendix: findings not worth acting on
- **Weather API source fetch functions look similar but should remain explicit
for now.** Each source has different required/optional behavior, endpoint
query options, timestamp rules, and null-data semantics. A generic framework
would make the current adapter harder to read.
- **Report definition files intentionally repeat field names.** Each report
definition should remain explicit about prompt ID, template ID, artifact
group, batch output name, compatibility, and modules.
- **CLI flag parsing uses repeated `flag.FlagSet` setup.** The current parser is
small and clear. Additional abstraction would not reduce much risk beyond the
existing `addCommonFlags` helper.
- **`internal/app.GenerateReport` is long but linear.** It is the main
orchestration function and currently reads in the same order as the workflow.
Splitting it aggressively would risk hiding stage ordering. Prefer extracting
only small repeated mechanics.
- **Generated Markdown templates are necessarily editable assets.** Do not
replace template wording with Go string builders. Cleanup should preserve the
user's ability to edit report layout and prose structure in template files.

View File

@@ -1,481 +0,0 @@
# Cleanup Roadmap
## Purpose
This roadmap defines the staged cleanup work recommended by
`docs/roadmap/audit.md`. It is written for an LLM coding agent that will
implement each stage in order.
The cleanup sequence is intentionally narrow. It should reduce duplication and
clarify package responsibilities before the next major release without changing
public CLI syntax, user configuration, report identities, managed artifact
paths, or generated report behavior.
This file may describe planned work because it lives under `docs/roadmap/`.
## Cleanup Principles
- Preserve public behavior unless a stage explicitly says otherwise.
- Prefer small behavior-preserving refactors over broad rewrites.
- Keep domain policy in the package that owns the relevant contract.
- Keep external system details behind adapter boundaries.
- Keep report, module, template, schema, path, and artifact identity explicit.
- Add tests before or during cleanup where they protect public behavior or
important internal invariants.
- Update implemented documentation only after code behavior exists.
- Do not use cleanup as an opportunity to introduce new features.
## Locked Decisions
- Daily, Today, and Tomorrow remain separate report IDs, prompt IDs, schemas,
templates, and public generated-text types.
- Cleanup may reduce shared internal day-style plumbing, but must not merge the
public report types.
- Daily, Today, and Tomorrow keep separate top-level template files.
- Named template partials may be introduced for repeated Daypart Forecast and
Precipitation Timing blocks.
- Configuration ownership remains in `internal/config`; config parsing and
validation should not move into `internal/app` or `internal/briefing`.
- Scriptorium and distributor dependency details must remain behind their
adapter packages.
- Existing public CLI syntax, config fields, report IDs, prompt IDs, template
IDs, schema IDs, workspace paths, distributor paths, and generated report
paths remain stable.
- Do not introduce Cobra, a workflow engine, a plugin system,
manifest/resume/progress infrastructure, per-module packages, per-report
packages, or a global test helper package.
## Stage 1: Day-Style GeneratedText Validation Helpers
### Goal
Remove duplicated Daily/Today/Tomorrow generated-text validation while
preserving public types and JSON schema behavior.
### Implementation Guidance
- Add an unexported shared helper in `internal/generatedtext` for the common
day-style shape: `summary`, `forecast_discussion`, optional
`precipitation_timing`, and optional `confidence`.
- Keep exported `Daily`, `Today`, and `Tomorrow` structs.
- Keep exported `ValidateDaily`, `ValidateToday`, and `ValidateTomorrow`
functions.
- Preserve normalized JSON output shape and unknown-field rejection.
- Preserve current error messages except for the expected report-name
substitution.
- Do not change embedded generated-text schemas in this stage except as needed
to keep tests aligned with existing behavior.
### Acceptance Criteria
- Daily, Today, and Tomorrow validation use one shared internal validation path
for common trim, required-field, optional-field, and normalization behavior.
- Public generated-text structs and function names remain unchanged.
- Existing callers do not need to change.
- Existing schema behavior remains unchanged.
### Tests
- Add table coverage proving Daily, Today, and Tomorrow share:
- required `summary` behavior;
- required non-empty `forecast_discussion` behavior;
- trim behavior;
- optional-field omission behavior;
- normalized JSON output behavior;
- unknown-field rejection.
- Run:
```sh
go test ./internal/generatedtext
```
### Prompt Size
Small enough for one implementation prompt.
## Stage 2: Day-Style Render Context And Template Shared Blocks
### Goal
Reduce repeated Daily/Today/Tomorrow render-context and Markdown template logic
without merging the reports.
### Implementation Guidance
- Add shared unexported helpers for common day-style report context fields such
as forecast date, forecast date label, generated timestamp label, valid
period, timezone, collected facts, and derived facts.
- Add a common module snapshot extraction helper for shared day-style modules.
- Keep report-specific planning modules separate:
- Daily uses `DailyPlanning`;
- Today uses `TodayPlanning`;
- Tomorrow uses `TomorrowPlanning`.
- Keep report-specific daypart wrapper types if tests or templates benefit from
explicit names.
- Add named template partial support in `internal/reporttemplate` for repeated
Daypart Forecast and Precipitation Timing blocks.
- Keep `daily.md.tmpl`, `today.md.tmpl`, and `tomorrow.md.tmpl` as separate
top-level templates that opt into shared partials.
- Preserve current rendered Markdown behavior, including Today's omission of
elapsed or missing daypart lines.
- Do not replace editable Markdown templates with Go string builders.
### Acceptance Criteria
- Common day-style render-context setup is shared internally.
- Daily, Today, and Tomorrow still expose their own render context types.
- Shared template partials reduce repeated daypart and precipitation template
logic.
- Report templates remain separately editable.
- Current rendered output remains stable except for whitespace changes that are
covered by updated tests and intentionally accepted.
### Tests
- Update render-context tests to prove:
- common module fields are populated for Daily, Today, and Tomorrow;
- each report still exposes its correct planning module;
- Today still omits missing/elapsed dayparts where current behavior expects
omission.
- Update reporttemplate tests to prove Daily, Today, and Tomorrow output remains
behaviorally stable.
- Run:
```sh
go test ./internal/generatedtext ./internal/reporttemplate
```
### Prompt Size
Likely one implementation prompt. If template partial parsing changes and
render-context helper extraction become difficult to review together, split
this into:
1. render-context helper cleanup;
2. template partial cleanup.
## Stage 3: Report Module Config Traversal Cleanup
### Goal
Make report-module config normalization, validation, and override extraction use
one canonical traversal.
### Implementation Guidance
- Refactor `internal/config/reports.go` around one unexported helper that:
- resolves report config keys through `internal/report`;
- detects duplicate aliases;
- validates report IDs against the report registry;
- normalizes module options when requested;
- builds `module.ConfigItem` values;
- validates module composition through the module registry.
- Reuse that helper from:
- `normalizeReportModules`;
- `validateReportModules`;
- `ReportModuleOverrides`.
- Preserve current configuration precedence and YAML shape.
- Preserve current option normalization behavior.
- Preserve error context such as
`reports.<key>.deterministic_modules[...]`.
- Keep `internal/config` as the owner of config loading, normalization, and
validation.
### Acceptance Criteria
- Report-module config traversal exists in one implementation path.
- Load-time normalization, validation, and override extraction cannot drift on
report-key or module composition policy.
- Existing config files continue to load unchanged.
- Existing config error messages remain materially equivalent and actionable.
### Tests
- Keep or update tests for:
- unknown report keys;
- duplicate report aliases;
- unknown modules;
- duplicate modules;
- incompatible modules;
- invalid module options;
- valid module overrides.
- Add coverage proving loaded config and manually constructed config fail
consistently for the same invalid report/module cases.
- Run:
```sh
go test ./internal/config ./internal/app
```
### Prompt Size
Small enough for one implementation prompt.
## Stage 4: Scriptorium Run Plumbing Cleanup
### Goal
Share `Run` and `StructuredRun` execution mechanics while keeping the
Scriptorium adapter API stable.
### Implementation Guidance
- Add an unexported run-like helper in `internal/adapters/scriptorium` that:
- validates prompt ID;
- validates data package path;
- validates output path;
- executes the resolved argv;
- captures stdout and stderr;
- records truncation flags;
- records output path;
- handles nonzero exit results.
- Keep exported `RunRequest`, `StructuredRunRequest`, `RunResult`, and
`StructuredRunResult`.
- Keep `StructuredRun` using the same argv shape as `Run`; do not add schema or
format flags.
- Preserve argv order.
- Preserve stdout/stderr capture and truncation fields.
- Preserve output-path fields.
- Preserve existing nonzero-exit error wording as closely as possible.
### Acceptance Criteria
- `Run` and `StructuredRun` share validation and result construction mechanics.
- Public adapter request/result types remain stable.
- Existing app-layer Scriptorium calls do not need behavior changes.
- Existing Scriptorium tests still pass with minimal expected-output updates.
### Tests
- Add or update parity tests proving `Run` and `StructuredRun` preserve:
- argv construction;
- output path;
- captured stdout/stderr;
- truncation flags;
- nonzero exit result and error behavior.
- Run:
```sh
go test ./internal/adapters/scriptorium
```
### Prompt Size
Small enough for one implementation prompt.
## Stage 5: CLI Test Fixture Cleanup
### Goal
Reduce noisy repeated CLI integration test setup without creating a
cross-package test framework.
### Implementation Guidance
- Add package-local helpers in `internal/cli` tests for repeated:
- fake Scriptorium script setup;
- generated-text JSON responses;
- config file writing;
- Weather API test server setup;
- artifact path or glob assertions.
- Keep a few explicit CLI workflow tests readable end-to-end.
- Do not add `internal/testutil` or another global test helper package.
- Do not weaken assertions while deduplicating setup.
- Do not change production CLI behavior in this stage.
### Acceptance Criteria
- Test setup repetition is reduced in the largest CLI test file.
- Test behavior and coverage remain equivalent.
- Helpers are local to `internal/cli`.
- No production code changes are required for this stage.
### Tests
- Run:
```sh
go test ./internal/cli
```
### Prompt Size
Small enough for one implementation prompt.
## Stage 6: App Test Fixture Cleanup
### Goal
Reduce noisy repeated app orchestration test setup without creating a
cross-package test framework.
### Implementation Guidance
- Add package-local helpers in `internal/app` tests where repetition is high and
the helper improves readability.
- Good candidates include repeated fake renderer setup, generated-text JSON
responses, config file writing, Weather API test server setup, distributor
notifier setup, recording store setup, and artifact assertions.
- Keep key workflow tests readable end-to-end so generation ordering remains
clear.
- Do not add `internal/testutil` or another global test helper package.
- Do not weaken assertions while deduplicating setup.
- Do not change production app behavior in this stage.
### Acceptance Criteria
- Test setup repetition is reduced in the largest app test file.
- Test behavior and coverage remain equivalent.
- Helpers are local to `internal/app`.
- No production code changes are required for this stage.
### Tests
- Run:
```sh
go test ./internal/app
```
### Prompt Size
Small enough for one implementation prompt.
## Stage 7: State Artifact Save Helper Cleanup
### Goal
Reduce repeated filesystem artifact write boilerplate while keeping artifact
semantics visible.
### Implementation Guidance
- Add small private helpers in `internal/state/filesystem.go` for resolved JSON
and byte artifact writes.
- Keep artifact-specific validation in public save methods before calling any
helper.
- Keep each public save method explicit about which artifact path it writes.
- Leave `SaveDataPackage` with its current special behavior unless a helper
cleanly preserves `promptinput.Save`.
- Leave `SaveMetadata` with its current explicit metadata-path validation and
write behavior unless a helper cleanly preserves it.
- Do not introduce a manifest, artifact registry, resume system, or broad
artifact framework.
### Acceptance Criteria
- Repeated `Paths` plus atomic write mechanics are reduced where the semantics
are identical.
- Artifact-specific validation and path choice remain easy to see.
- Managed artifact paths do not change.
- Metadata JSON shape does not change.
### Tests
- Keep or update state save tests and metadata round-trip tests.
- Run:
```sh
go test ./internal/state ./internal/app
```
### Prompt Size
Small enough for one implementation prompt.
## Stage 8: Documentation And Final Validation
### Goal
Align implemented documentation only where cleanup changes internal contracts or
template editing guidance.
### Implementation Guidance
- Update `docs/templates.md` if named template partial support changes how
report templates should be edited.
- Update relevant `docs/internal/*` files only for implemented internal
contract changes.
- Update `docs/policy/development.md` only if contributor workflow guidance
changes.
- Keep unimplemented or deferred cleanup ideas only under `docs/roadmap/`.
- Do not document future refactors as implemented behavior.
### Acceptance Criteria
- Non-roadmap docs describe only implemented behavior.
- Template-editing guidance matches the final template partial structure, if
partials were added.
- Internal docs remain accurate for generated text, templates, config, state,
and adapters touched by cleanup.
### Validation
Run:
```sh
go test ./internal/generatedtext ./internal/reporttemplate
go test ./internal/config ./internal/app
go test ./internal/adapters/scriptorium
go test ./internal/cli ./internal/state
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
### Prompt Size
Small enough for one implementation prompt.
## Deferred Refactors
The following refactors are out of scope for this cleanup sequence:
- Generic workflow engine.
- Cobra migration or CLI redesign.
- Plugin architecture.
- Per-module or per-report packages.
- Broad Weather API source-ingestion framework.
- Manifest, resume, or progress system.
- Global test helper package.
- Consolidating Daily, Today, and Tomorrow into one public report type.
- Replacing editable Markdown templates with Go string builders.
- Build-free module catalog split.
The build-free module catalog split may be revisited later if
config/module-boundary complexity grows enough to justify separating module
metadata from module builders.
## Global Validation Checklist
Run these checks after completing the full cleanup sequence:
```sh
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Also run focused checks after the relevant stages:
```sh
go test ./internal/generatedtext
go test ./internal/generatedtext ./internal/reporttemplate
go test ./internal/config ./internal/app
go test ./internal/adapters/scriptorium
go test ./internal/cli
go test ./internal/app
go test ./internal/state ./internal/app
```
Manual review checklist:
- Public CLI syntax is unchanged.
- Public config fields and defaults are unchanged.
- Report IDs, prompt IDs, template IDs, and schema IDs are unchanged.
- Managed workspace artifact paths are unchanged.
- Distributor bundle paths and notification behavior are unchanged.
- Generated report Markdown behavior is unchanged except for intentional,
test-covered whitespace differences.
- Scriptorium argv construction is unchanged.
- Non-roadmap docs do not describe unimplemented cleanup work.

View File

@@ -73,8 +73,8 @@ 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

View File

@@ -6,13 +6,22 @@ This guide describes the implemented Markdown report template surface for
`weatherreporter`. It is for maintainers editing embedded report templates, `weatherreporter`. It is for maintainers editing embedded report templates,
especially generated-text-template reports. especially generated-text-template reports.
Templates are Go `text/template` files. The current implemented templates are: Templates are Go `text/template` files. The implemented top-level templates
are:
- `internal/reporttemplate/templates/daily.md.tmpl` - `internal/reporttemplate/templates/daily.md.tmpl`
- `internal/reporttemplate/templates/today.md.tmpl` - `internal/reporttemplate/templates/today.md.tmpl`
- `internal/reporttemplate/templates/tomorrow.md.tmpl` - `internal/reporttemplate/templates/tomorrow.md.tmpl`
- `internal/reporttemplate/templates/hourly.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`, Templates are rendered from structured contexts such as `DailyRenderContext`,
`TodayRenderContext`, `TomorrowRenderContext`, and `HourlyRenderContext`. `TodayRenderContext`, `TomorrowRenderContext`, and `HourlyRenderContext`.
Weather data collection, derivation, module execution, generated text Weather data collection, derivation, module execution, generated text
@@ -26,7 +35,10 @@ validation, and artifact paths are handled before template rendering.
- Do not put weather derivation, source selection, or path construction logic in - Do not put weather derivation, source selection, or path construction logic in
templates. templates.
- Missing template keys are errors. A misspelled variable will fail rendering. - Missing template keys are errors. A misspelled variable will fail rendering.
- No custom template functions are currently registered. - 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 - Optional module stanzas are pointers and should be guarded with
`{{ with .Modules.WeatherStory }}...{{ end }}`. `{{ with .Modules.WeatherStory }}...{{ end }}`.
- Slices can be rendered with `{{ range .Items }}...{{ else }}...{{ end }}`. - Slices can be rendered with `{{ range .Items }}...{{ else }}...{{ end }}`.
@@ -103,6 +115,9 @@ Tomorrow generated text uses the same `.GeneratedText.Summary`,
Hourly. `.GeneratedText.ForecastDiscussion` is a slice of paragraphs and should Hourly. `.GeneratedText.ForecastDiscussion` is a slice of paragraphs and should
be rendered with `range`. be rendered with `range`.
Tomorrow uses the shared `alert_digest`, `daypart_forecast`, and
`precipitation_timing` partials.
Tomorrow modules include the Hourly module fields plus: Tomorrow modules include the Hourly module fields plus:
| Variable | Type | Description | | Variable | Type | Description |
@@ -131,6 +146,9 @@ Daily generated text uses `.GeneratedText.Summary`,
`.GeneratedText.Confidence`. Forecast discussion is a slice of paragraphs and `.GeneratedText.Confidence`. Forecast discussion is a slice of paragraphs and
should be rendered with `range`. 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 Daily uses template ID `daily`, generated-text schema ID `daily`, and prompt
source `internal/reporttemplate/prompts/daily.generated_text.md`. source `internal/reporttemplate/prompts/daily.generated_text.md`.
@@ -162,6 +180,10 @@ Today generated text uses `.GeneratedText.Summary`,
`.GeneratedText.Confidence`. Forecast discussion is a slice of paragraphs and `.GeneratedText.Confidence`. Forecast discussion is a slice of paragraphs and
should be rendered with `range`. 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 Today uses template ID `today`, generated-text schema ID `today`, and prompt
source `internal/reporttemplate/prompts/today.generated_text.md`. source `internal/reporttemplate/prompts/today.generated_text.md`.
@@ -274,6 +296,8 @@ Common fields:
| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopPercent` | *int | Highest precipitation probability inside the window. | | `.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[].MaxPopTime` | string | Friendly local time for the window maximum. |
| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopHourLabel` | string | Friendly hour label 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. | | `.Modules.PrecipTiming.ThunderMentioned` | bool | Whether thunder is mentioned in the forecast text. |
### Daypart Summaries ### Daypart Summaries
@@ -282,6 +306,10 @@ Daily, Today, and Tomorrow templates should use `.Modules.Dayparts` for
ordered daypart rendering. Each item has `Key` and `Summary`; `Summary` is a ordered daypart rendering. Each item has `Key` and `Summary`; `Summary` is a
rich `briefing.DerivedDaypartSummaryModule`. 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: Common rich daypart fields:
| Variable | Type | Description | | Variable | Type | Description |
@@ -322,6 +350,10 @@ YAML.
| `.Modules.AlertDigest.Relevant[].Event` | string | Alert event name. | | `.Modules.AlertDigest.Relevant[].Event` | string | Alert event name. |
| `.Modules.AlertDigest.Relevant[].Headline` | string | Alert headline. | | `.Modules.AlertDigest.Relevant[].Headline` | string | Alert headline. |
| `.Modules.AlertDigest.Relevant[].Severity` | string | Alert severity. | | `.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 ### SPC Outlooks And Discussion
@@ -338,6 +370,11 @@ YAML.
| `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodBegins` | string | Friendly outlook period start. | | `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodBegins` | string | Friendly outlook period start. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodEnds` | string | Friendly outlook period end. | | `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodEnds` | string | Friendly outlook period end. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].ImageURL` | string | Source image URL. | | `.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.IncludedBecause` | string | Criterion used to include discussions. |
| `.Modules.SPCConvectiveDiscussion.Discussions` | []briefing.SPCConvectiveDiscussionRecord | Retained discussion records. | | `.Modules.SPCConvectiveDiscussion.Discussions` | []briefing.SPCConvectiveDiscussionRecord | Retained discussion records. |
| `.Modules.SPCConvectiveDiscussion.Discussions[].Headline` | string | Discussion headline. | | `.Modules.SPCConvectiveDiscussion.Discussions[].Headline` | string | Discussion headline. |

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.
@@ -235,16 +235,108 @@ Relevant docs: [Report Templates](templates.md),
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
@@ -291,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
@@ -314,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
@@ -341,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
@@ -24,8 +24,11 @@ notify:
pipeline_id_template: "weatherreporter.{report_id}" pipeline_id_template: "weatherreporter.{report_id}"
bundle_id_template: "weatherreporter.{location_id}.{report_id}" bundle_id_template: "weatherreporter.{location_id}.{report_id}"
idempotency_key_template: "{bundle_id}.{run_id}" idempotency_key_template: "{bundle_id}.{run_id}"
report_path_templates: batch:
- "{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md" enabled: true
pipeline_id_template: "weatherreporter"
bundle_id_template: "weatherreporter.{location_id}.{batch}"
idempotency_key_template: "{bundle_id}.{batch_run_id}"
missing_source: missing_source:
default: warn default: warn
@@ -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
@@ -81,9 +88,6 @@ reports:
- id: area_forecast_discussion - id: area_forecast_discussion
options: options:
sections: sections:
- product
- key_messages
- short_term
- long_term - long_term
- spc_convective_discussion - spc_convective_discussion
- weather_story - weather_story

View File

@@ -140,61 +140,57 @@ 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) {
if req.PromptID == "" { result, err := r.executeRun(ctx, outputRunRequest{
return nil, fmt.Errorf("prompt id is required") PromptID: req.PromptID,
} DataPackagePath: req.DataPackagePath,
if req.DataPackagePath == "" {
return nil, fmt.Errorf("data package path is required")
}
if req.OutputPath == "" {
return nil, fmt.Errorf("output path is required")
}
execution, err := r.execute(ctx, r.runArgs(req))
if err != nil {
return nil, fmt.Errorf("run scriptorium: %w", err)
}
result := &RunResult{
Command: execution.argv(),
Stdout: string(execution.result.Stdout),
Stderr: string(execution.result.Stderr),
StdoutTruncated: execution.result.StdoutTruncated,
StderrTruncated: execution.result.StderrTruncated,
ExitCode: execution.result.ExitCode,
OutputPath: req.OutputPath, OutputPath: req.OutputPath,
}, "run scriptorium", "scriptorium run")
if err != nil {
if result == nil {
return nil, err
}
return result.runResult(), err
} }
if execution.result.ExitCode != 0 { return result.runResult(), nil
return result, fmt.Errorf("scriptorium run exited with code %d: %s", execution.result.ExitCode, result.Stderr)
}
return result, nil
} }
func (r Runner) StructuredRun(ctx context.Context, req StructuredRunRequest) (*StructuredRunResult, error) { func (r Runner) StructuredRun(ctx context.Context, req StructuredRunRequest) (*StructuredRunResult, error) {
if req.PromptID == "" { result, err := r.executeRun(ctx, outputRunRequest{
return nil, fmt.Errorf("prompt id is required") PromptID: req.PromptID,
} DataPackagePath: req.DataPackagePath,
if req.DataPackagePath == "" {
return nil, fmt.Errorf("data package path is required")
}
if req.OutputPath == "" {
return nil, fmt.Errorf("output path is required")
}
execution, err := r.execute(ctx, r.structuredRunArgs(req))
if err != nil {
return nil, fmt.Errorf("run scriptorium structured output: %w", err)
}
result := &StructuredRunResult{
Command: execution.argv(),
Stdout: string(execution.result.Stdout),
Stderr: string(execution.result.Stderr),
StdoutTruncated: execution.result.StdoutTruncated,
StderrTruncated: execution.result.StderrTruncated,
ExitCode: execution.result.ExitCode,
OutputPath: req.OutputPath, OutputPath: req.OutputPath,
}, "run scriptorium structured output", "scriptorium structured run")
if err != nil {
if result == nil {
return nil, err
}
return result.structuredRunResult(), err
} }
if execution.result.ExitCode != 0 { return result.structuredRunResult(), nil
return result, fmt.Errorf("scriptorium structured run exited with code %d: %s", execution.result.ExitCode, result.Stderr) }
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,
} }
return result, nil
} }
type execution struct { type execution struct {
@@ -203,6 +199,55 @@ type execution struct {
result CommandResult result CommandResult
} }
type outputRunRequest struct {
PromptID string
DataPackagePath string
OutputPath string
}
type outputRunResult struct {
Command []string
Stdout string
Stderr string
StdoutTruncated bool
StderrTruncated bool
ExitCode int
OutputPath string
}
func (r Runner) executeRun(ctx context.Context, req outputRunRequest, executeContext string, exitContext string) (*outputRunResult, error) {
if req.PromptID == "" {
return nil, fmt.Errorf("prompt id is required")
}
if req.DataPackagePath == "" {
return nil, fmt.Errorf("data package path is required")
}
if req.OutputPath == "" {
return nil, fmt.Errorf("output path is required")
}
execution, err := r.execute(ctx, r.runArgs(RunRequest{
PromptID: req.PromptID,
DataPackagePath: req.DataPackagePath,
OutputPath: req.OutputPath,
}))
if err != nil {
return nil, fmt.Errorf("%s: %w", executeContext, err)
}
result := &outputRunResult{
Command: execution.argv(),
Stdout: string(execution.result.Stdout),
Stderr: string(execution.result.Stderr),
StdoutTruncated: execution.result.StdoutTruncated,
StderrTruncated: execution.result.StderrTruncated,
ExitCode: execution.result.ExitCode,
OutputPath: req.OutputPath,
}
if execution.result.ExitCode != 0 {
return result, fmt.Errorf("%s exited with code %d: %s", exitContext, execution.result.ExitCode, result.Stderr)
}
return result, nil
}
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 == "" {
@@ -257,14 +302,6 @@ func (r Runner) runArgs(req RunRequest) []string {
return args return args
} }
func (r Runner) structuredRunArgs(req StructuredRunRequest) []string {
return r.runArgs(RunRequest{
PromptID: req.PromptID,
DataPackagePath: req.DataPackagePath,
OutputPath: req.OutputPath,
})
}
type limitedBuffer struct { type limitedBuffer struct {
data []byte data []byte
limit int limit int

View File

@@ -2,6 +2,7 @@ package scriptorium
import ( import (
"context" "context"
"fmt"
"reflect" "reflect"
"strings" "strings"
"testing" "testing"
@@ -163,8 +164,8 @@ func TestStructuredRunConstructsCommandWithoutSchemaFlags(t *testing.T) {
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{ result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
PromptID: "weather.hourly_generated_text", PromptID: "weather.hourly_generated_text",
DataPackagePath: "/tmp/hourly.data_package.yaml", DataPackagePath: "/tmp/data_package.hourly.yaml",
OutputPath: "/tmp/hourly.generated_text.raw.json", OutputPath: "/tmp/generated_text_raw.hourly.json",
}) })
if err != nil { if err != nil {
t.Fatalf("StructuredRun() error = %v", err) t.Fatalf("StructuredRun() error = %v", err)
@@ -175,8 +176,8 @@ func TestStructuredRunConstructsCommandWithoutSchemaFlags(t *testing.T) {
"--config", "/etc/scriptorium.yml", "--config", "/etc/scriptorium.yml",
"--profile", "weather", "--profile", "weather",
"--prompt", "weather.hourly_generated_text", "--prompt", "weather.hourly_generated_text",
"--input", "data_package=/tmp/hourly.data_package.yaml", "--input", "data_package=/tmp/data_package.hourly.yaml",
"--out", "/tmp/hourly.generated_text.raw.json", "--out", "/tmp/generated_text_raw.hourly.json",
} }
if commands.name != "/usr/local/bin/scriptorium" { if commands.name != "/usr/local/bin/scriptorium" {
t.Fatalf("command name = %q, want custom binary", commands.name) t.Fatalf("command name = %q, want custom binary", commands.name)
@@ -198,7 +199,7 @@ func TestStructuredRunConstructsCommandWithoutSchemaFlags(t *testing.T) {
if result.Stdout != `{"summary":"ok"}` || result.Stderr != "wrote generated text" || !result.StdoutTruncated { if result.Stdout != `{"summary":"ok"}` || result.Stderr != "wrote generated text" || !result.StdoutTruncated {
t.Fatalf("result = %#v, want captured output and truncation flags", result) t.Fatalf("result = %#v, want captured output and truncation flags", result)
} }
if result.OutputPath != "/tmp/hourly.generated_text.raw.json" { if result.OutputPath != "/tmp/generated_text_raw.hourly.json" {
t.Fatalf("OutputPath = %q, want generated text raw path", result.OutputPath) t.Fatalf("OutputPath = %q, want generated text raw path", result.OutputPath)
} }
} }
@@ -216,8 +217,8 @@ func TestStructuredRunReturnsResultForNonzeroExit(t *testing.T) {
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{ result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
PromptID: "weather.hourly_generated_text", PromptID: "weather.hourly_generated_text",
DataPackagePath: "/tmp/hourly.data_package.yaml", DataPackagePath: "/tmp/data_package.hourly.yaml",
OutputPath: "/tmp/hourly.generated_text.raw.json", OutputPath: "/tmp/generated_text_raw.hourly.json",
}) })
if err == nil { if err == nil {
t.Fatal("StructuredRun() error = nil, want nonzero exit error") t.Fatal("StructuredRun() error = nil, want nonzero exit error")
@@ -228,7 +229,7 @@ func TestStructuredRunReturnsResultForNonzeroExit(t *testing.T) {
if result.ExitCode != 3 { if result.ExitCode != 3 {
t.Fatalf("ExitCode = %d, want 3", result.ExitCode) t.Fatalf("ExitCode = %d, want 3", result.ExitCode)
} }
if result.Stdout != `{"summary":"partial"}` || result.OutputPath != "/tmp/hourly.generated_text.raw.json" { if result.Stdout != `{"summary":"partial"}` || result.OutputPath != "/tmp/generated_text_raw.hourly.json" {
t.Fatalf("result = %#v, want captured result fields", result) t.Fatalf("result = %#v, want captured result fields", result)
} }
if !strings.Contains(err.Error(), "structured output failed") { if !strings.Contains(err.Error(), "structured output failed") {
@@ -236,53 +237,281 @@ func TestStructuredRunReturnsResultForNonzeroExit(t *testing.T) {
} }
} }
func TestStructuredRunValidatesRequiredFieldsBeforeExecution(t *testing.T) { func TestOutputRunsPreserveCapturedResultFields(t *testing.T) {
type commonResult struct {
Command []string
Stdout string
Stderr string
StdoutTruncated bool
StderrTruncated bool
ExitCode int
OutputPath string
}
tests := []struct { tests := []struct {
name string name string
req StructuredRunRequest run func(Runner) (*commonResult, error)
want string
}{ }{
{ {
name: "prompt id", name: "Run",
req: StructuredRunRequest{ run: func(runner Runner) (*commonResult, error) {
DataPackagePath: "/tmp/hourly.data_package.yaml", result, err := runner.Run(context.Background(), RunRequest{
OutputPath: "/tmp/hourly.generated_text.raw.json", 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
}, },
want: "prompt id is required",
}, },
{ {
name: "data package path", name: "StructuredRun",
req: StructuredRunRequest{ run: func(runner Runner) (*commonResult, error) {
PromptID: "weather.hourly_generated_text", result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
OutputPath: "/tmp/hourly.generated_text.raw.json", 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
}, },
want: "data package path is required",
},
{
name: "output path",
req: StructuredRunRequest{
PromptID: "weather.hourly_generated_text",
DataPackagePath: "/tmp/hourly.data_package.yaml",
},
want: "output path is required",
}, },
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
commands := &fakeCommands{} commands := &fakeCommands{result: CommandResult{
runner := Runner{Commands: commands} Stdout: []byte("captured stdout"),
result, err := runner.StructuredRun(context.Background(), test.req) 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 { if err == nil {
t.Fatal("StructuredRun() error = nil, want validation error") t.Fatalf("%s error = nil, want nonzero exit error", test.name)
} }
if result != nil { if result == nil {
t.Fatalf("StructuredRun() result = %#v, want nil", result) t.Fatalf("%s result = nil, want captured result", test.name)
} }
if !strings.Contains(err.Error(), test.want) { if err.Error() != test.wantErr {
t.Fatalf("StructuredRun() error = %v, want %q", err, test.want) t.Fatalf("%s error = %q, want %q", test.name, err.Error(), test.wantErr)
} }
if commands.calls != 0 { if result.Stdout != "captured stdout" || result.Stderr != "captured stderr" || !result.StderrTruncated {
t.Fatalf("commands calls = %d, want no subprocess execution", commands.calls) 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)
}
})
} }
}) })
} }

View File

@@ -10,9 +10,9 @@ 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"
@@ -53,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
} }
@@ -61,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
@@ -85,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 {
@@ -114,13 +118,33 @@ type ReportResult struct {
} }
type BatchResult struct { type BatchResult struct {
Batch BatchKind `json:"batch"` Batch BatchKind `json:"batch"`
StartedAt time.Time `json:"startedAt"` StartedAt time.Time `json:"startedAt"`
FinishedAt time.Time `json:"finishedAt"` FinishedAt time.Time `json:"finishedAt"`
Total int `json:"total"` Total int `json:"total"`
Succeeded int `json:"succeeded"` Succeeded int `json:"succeeded"`
Failed int `json:"failed"` Failed int `json:"failed"`
Reports []BatchReportResult `json:"reports"` Notification *BatchNotificationResult `json:"notification,omitempty"`
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 {
@@ -152,15 +176,48 @@ 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) 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 {
Notify(context.Context, NotificationRequest) (*NotificationResult, error) Notify(context.Context, NotificationRequest) (*NotificationResult, error)
} }
@@ -211,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 {
@@ -247,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
} }
@@ -262,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"
@@ -315,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,
@@ -333,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) {
@@ -362,25 +454,6 @@ 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 := report.BatchForCommandName(string(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) {
overrides, err := cfg.ReportModuleOverrides() overrides, err := cfg.ReportModuleOverrides()
if err != nil { if err != nil {
@@ -394,15 +467,28 @@ func reportRegistry(cfg config.Config) (report.Registry, error) {
} }
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) {
@@ -413,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)
@@ -437,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,
@@ -568,28 +655,40 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
OutputPath: req.OutputPath, OutputPath: req.OutputPath,
Notifier: req.Notifier, Notifier: req.Notifier,
GenerationErr: runErr, GenerationErr: runErr,
noNotify: req.noNotify,
}) })
if err != nil { if err != nil {
return nil, err 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 &ReportResult{ return renderedReportResult(reportResultRequest{
ModuleSnapshot: moduleSnapshot, moduleSnapshot: moduleSnapshot,
ModuleSnapshotPath: moduleSnapshotPath, moduleSnapshotPath: moduleSnapshotPath,
DataPackage: dataPackage, dataPackage: dataPackage,
DataPackagePath: dataPackagePath, dataPackagePath: dataPackagePath,
PreflightPath: preflightPath, preflightPath: preflightPath,
ReportPath: reportPath, reportPath: reportPath,
OutputPath: finalized.OutputPath, finalized: finalized,
NotificationPath: finalized.NotificationPath, priorSnapshot: priorSnapshot,
Metadata: finalized.Metadata, recentChanges: recentChanges,
MetadataPath: finalized.MetadataPath, renderResult: renderResult,
PriorSnapshot: priorSnapshot, runResult: runResult,
RecentChanges: recentChanges, }), nil
RenderResult: renderResult,
RunResult: runResult,
Notification: finalized.Notification,
}, nil
} }
type generatedReportRequest struct { type generatedReportRequest struct {
@@ -690,32 +789,100 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
ManagedReportPath: reportPath, ManagedReportPath: reportPath,
OutputPath: req.OutputPath, OutputPath: req.OutputPath,
Notifier: req.Notifier, Notifier: req.Notifier,
noNotify: req.noNotify,
}) })
if err != nil { if err != nil {
return nil, err 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: req.moduleSnapshot, ModuleSnapshot: req.moduleSnapshot,
ModuleSnapshotPath: req.moduleSnapshotPath, ModuleSnapshotPath: req.moduleSnapshotPath,
DataPackage: req.dataPackage, DataPackage: req.dataPackage,
DataPackagePath: req.dataPackagePath, DataPackagePath: req.dataPackagePath,
PreflightPath: req.preflightPath, PreflightPath: req.preflightPath,
ReportPath: reportPath, ReportPath: req.reportPath,
OutputPath: finalized.OutputPath, OutputPath: req.finalized.OutputPath,
NotificationPath: finalized.NotificationPath, NotificationPath: req.finalized.NotificationPath,
Metadata: finalized.Metadata, Metadata: req.finalized.Metadata,
MetadataPath: finalized.MetadataPath, MetadataPath: req.finalized.MetadataPath,
PriorSnapshot: req.priorSnapshot, PriorSnapshot: req.priorSnapshot,
RecentChanges: req.recentChanges, RecentChanges: req.recentChanges,
RenderResult: req.renderResult, RenderResult: req.renderResult,
StructuredRunResult: structuredResult, RunResult: req.runResult,
GeneratedTextRawPath: req.paths.GeneratedTextRaw, StructuredRunResult: req.structuredRunResult,
GeneratedTextResultPath: generatedTextResultPath, GeneratedTextRawPath: req.generatedTextRawPath,
GeneratedTextPath: generatedTextPath, GeneratedTextResultPath: req.generatedTextResultPath,
RenderContextPath: renderContextPath, GeneratedTextPath: req.generatedTextPath,
Notification: finalized.Notification, RenderContextPath: req.renderContextPath,
}, nil Notification: req.finalized.Notification,
}
} }
type finalizeRenderedReportRequest struct { type finalizeRenderedReportRequest struct {
@@ -727,6 +894,7 @@ type finalizeRenderedReportRequest struct {
OutputPath string OutputPath string
Notifier Notifier Notifier Notifier
GenerationErr error GenerationErr error
noNotify bool
} }
type finalizeRenderedReportResult struct { type finalizeRenderedReportResult struct {
@@ -769,6 +937,9 @@ func finalizeRenderedReport(ctx context.Context, req finalizeRenderedReportReque
if req.GenerationErr != nil { if req.GenerationErr != nil {
return result, req.GenerationErr return result, req.GenerationErr
} }
if req.noNotify {
return result, nil
}
notification, notificationPath, err := notifyReport(ctx, req.Config, req.Resolved, req.ManagedReportPath, metadata, req.Notifier, req.Store) notification, notificationPath, err := notifyReport(ctx, req.Config, req.Resolved, req.ManagedReportPath, metadata, req.Notifier, req.Store)
if notificationPath != "" { if notificationPath != "" {
@@ -828,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)
@@ -851,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
} }
@@ -867,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 {
@@ -974,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 {

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

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

View File

@@ -367,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()
@@ -473,6 +506,34 @@ func TestAreaForecastDiscussionModuleUsesHourlyDefaultSections(t *testing.T) {
} }
} }
func TestAreaForecastDiscussionModuleUsesDailyDefaultSections(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Daily)
var item module.ConfigItem
for _, candidate := range ctx.Resolved.Definition.Modules {
if candidate.ID == module.AreaForecastDiscussion {
item = candidate
break
}
}
if item.ID == "" {
t.Fatal("daily default modules missing area_forecast_discussion")
}
output, err := registry.BuildModule(ctx, item)
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
afd := moduleValue[AreaForecastDiscussionModule](t, output)
if afd.LongTerm != "Periodic rain chances continue." {
t.Fatalf("LongTerm = %q, want selected long term section", afd.LongTerm)
}
if afd.Product != "" || len(afd.KeyMessages) != 0 || afd.ShortTerm != "" {
t.Fatalf("AFD = %#v, want only long term section", afd)
}
}
func testModuleContext() ModuleContext { func testModuleContext() ModuleContext {
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00") generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
definition := report.DefaultRegistry().MustLookup(report.Daily) definition := report.DefaultRegistry().MustLookup(report.Daily)

View File

@@ -105,9 +105,15 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
if rainy.PrecipitationWindows[0].PeriodBegins != "2026-05-29 at 8:00 AM" || rainy.PrecipitationWindows[0].PeriodBeginsHourLabel != "8:00 AM" || rainy.PrecipitationWindows[0].PeriodEnds != "2026-05-29 at 9:00 AM" || rainy.PrecipitationWindows[0].PeriodEndsHourLabel != "9:00 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 || rainy.PrecipitationWindows[0].MaxPopHourLabel != "8:00 AM" { if rainy.PrecipitationWindows[0].PeriodBegins != "2026-05-29 at 8:00 AM" || rainy.PrecipitationWindows[0].PeriodBeginsHourLabel != "8:00 AM" || rainy.PrecipitationWindows[0].PeriodEnds != "2026-05-29 at 9:00 AM" || rainy.PrecipitationWindows[0].PeriodEndsHourLabel != "9:00 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 || rainy.PrecipitationWindows[0].MaxPopHourLabel != "8:00 AM" {
t.Fatalf("first precipitation window = %#v, want 8-9 AM at 60%%", rainy.PrecipitationWindows[0]) t.Fatalf("first precipitation window = %#v, want 8-9 AM at 60%%", rainy.PrecipitationWindows[0])
} }
if rainy.PrecipitationWindows[0].PrecipitationType != "showers" || rainy.PrecipitationWindows[0].ExpectationPhrase != "Showers likely." {
t.Fatalf("first precipitation window phrase = %#v, want showers likely", rainy.PrecipitationWindows[0])
}
if rainy.PrecipitationWindows[1].PeriodBegins != "2026-05-29 at 12:00 PM" || rainy.PrecipitationWindows[1].PeriodBeginsHourLabel != "12:00 PM" || rainy.PrecipitationWindows[1].PeriodEnds != "2026-05-29 at 2:00 PM" || rainy.PrecipitationWindows[1].PeriodEndsHourLabel != "2:00 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 || rainy.PrecipitationWindows[1].MaxPopHourLabel != "12:00 PM" { if rainy.PrecipitationWindows[1].PeriodBegins != "2026-05-29 at 12:00 PM" || rainy.PrecipitationWindows[1].PeriodBeginsHourLabel != "12:00 PM" || rainy.PrecipitationWindows[1].PeriodEnds != "2026-05-29 at 2:00 PM" || rainy.PrecipitationWindows[1].PeriodEndsHourLabel != "2:00 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 || rainy.PrecipitationWindows[1].MaxPopHourLabel != "12:00 PM" {
t.Fatalf("second precipitation window = %#v, want noon-2 PM at 80%%", rainy.PrecipitationWindows[1]) t.Fatalf("second precipitation window = %#v, want noon-2 PM at 80%%", rainy.PrecipitationWindows[1])
} }
if rainy.PrecipitationWindows[1].PrecipitationType != "showers and thunderstorms" || rainy.PrecipitationWindows[1].ExpectationPhrase != "Expect showers and thunderstorms." {
t.Fatalf("second precipitation window phrase = %#v, want expect showers and thunderstorms", rainy.PrecipitationWindows[1])
}
data, err := json.Marshal(output.Value) data, err := json.Marshal(output.Value)
if err != nil { if err != nil {
t.Fatalf("marshal precip timing: %v", err) t.Fatalf("marshal precip timing: %v", err)
@@ -115,6 +121,9 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
if !strings.Contains(string(data), "precipitation_windows") || !strings.Contains(string(data), "probability_threshold") || !strings.Contains(string(data), "period_begins_hour_label") || !strings.Contains(string(data), "max_pop_hour_label") { if !strings.Contains(string(data), "precipitation_windows") || !strings.Contains(string(data), "probability_threshold") || !strings.Contains(string(data), "period_begins_hour_label") || !strings.Contains(string(data), "max_pop_hour_label") {
t.Fatalf("precip timing json = %s, want threshold and windows", string(data)) t.Fatalf("precip timing json = %s, want threshold and windows", string(data))
} }
if !strings.Contains(string(data), "precipitation_type") || !strings.Contains(string(data), "expectation_phrase") {
t.Fatalf("precip timing json = %s, want precipitation type and expectation phrase", string(data))
}
if strings.Contains(string(data), `"start"`) || strings.Contains(string(data), `"end"`) { if strings.Contains(string(data), `"start"`) || strings.Contains(string(data), `"end"`) {
t.Fatalf("precip timing json = %s, want period_begins/period_ends instead of start/end", string(data)) t.Fatalf("precip timing json = %s, want period_begins/period_ends instead of start/end", string(data))
} }
@@ -136,6 +145,100 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
} }
} }
func TestPrecipTimingModuleBuildsExpectationPhrases(t *testing.T) {
now := mustParseModuleTime("2026-05-29T08:00:00-05:00")
tests := []struct {
name string
maxPop float64
descriptions []string
wantType string
wantPhrase string
}{
{
name: "chance lower bound",
maxPop: 40,
descriptions: []string{"Scattered showers"},
wantType: "showers",
wantPhrase: "Chance of showers.",
},
{
name: "chance upper bound",
maxPop: 49,
descriptions: []string{"Rain possible"},
wantType: "rain",
wantPhrase: "Chance of rain.",
},
{
name: "likely lower bound",
maxPop: 50,
descriptions: []string{"Drizzle"},
wantType: "drizzle",
wantPhrase: "Drizzle likely.",
},
{
name: "likely upper bound",
maxPop: 69,
descriptions: []string{"Freezing rain"},
wantType: "freezing rain",
wantPhrase: "Freezing rain likely.",
},
{
name: "expect lower bound",
maxPop: 70,
descriptions: []string{"Snow"},
wantType: "snow",
wantPhrase: "Expect snow.",
},
{
name: "showers and thunderstorms preferred",
maxPop: 100,
descriptions: []string{"Showers likely", "Thunderstorms possible"},
wantType: "showers and thunderstorms",
wantPhrase: "Expect showers and thunderstorms.",
},
{
name: "thunderstorms only",
maxPop: 80,
descriptions: []string{"Thunderstorms"},
wantType: "thunderstorms",
wantPhrase: "Expect thunderstorms.",
},
{
name: "unknown fallback",
maxPop: 95,
descriptions: []string{"Unsettled conditions"},
wantType: "precipitation",
wantPhrase: "Expect precipitation.",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
value := precipTimingValue(forecast.PrecipTiming{
ProbabilityThreshold: forecast.DefaultPrecipWindowProbabilityThreshold,
PrecipitationWindows: []forecast.PrecipitationWindow{
{
Start: now,
MaxPrecipitationProbability: forecast.TimedValue{
Value: tt.maxPop,
Time: now,
},
ProbabilityThreshold: forecast.DefaultPrecipWindowProbabilityThreshold,
TextDescriptions: tt.descriptions,
},
},
}, "America/Chicago")
if len(value.PrecipitationWindows) != 1 {
t.Fatalf("PrecipitationWindows = %#v, want one window", value.PrecipitationWindows)
}
window := value.PrecipitationWindows[0]
if window.PrecipitationType != tt.wantType || window.ExpectationPhrase != tt.wantPhrase {
t.Fatalf("window = %#v, want type %q and phrase %q", window, tt.wantType, tt.wantPhrase)
}
})
}
}
func TestPrecipTimingModuleUsesDerivedTimingWithoutDaypartSummaries(t *testing.T) { func TestPrecipTimingModuleUsesDerivedTimingWithoutDaypartSummaries(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.Hourly) ctx := derivedModuleContext(report.Hourly)

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

@@ -122,6 +122,17 @@ func friendlyDateTimeLabel(value time.Time, timezone string) string {
return value.In(location).Format("2006-01-02 at 3:04 PM") return value.In(location).Format("2006-01-02 at 3:04 PM")
} }
func friendlyMonthDayTimeLabel(value time.Time, timezone string) string {
if value.IsZero() {
return ""
}
location, err := timeutil.LoadLocation(timezone)
if err != nil {
location = time.UTC
}
return value.In(location).Format("January 2 at 3:04 PM")
}
func friendlyDateLabel(date string, timezone string) string { func friendlyDateLabel(date string, timezone string) string {
location, err := timeutil.LoadLocation(timezone) location, err := timeutil.LoadLocation(timezone)
if err != nil { if err != nil {

View File

@@ -263,24 +263,56 @@ func TestModuleRegistryPromptValueIsNotPersistedInSnapshotJSON(t *testing.T) {
} }
} }
func TestHourlyDefaultModuleOptions(t *testing.T) { func TestDefaultAreaForecastDiscussionModuleOptions(t *testing.T) {
definition := report.DefaultRegistry().MustLookup(report.Hourly) tests := []struct {
var found bool id report.ID
for _, item := range definition.Modules { wantSections string
if item.ID != module.AreaForecastDiscussion { }{
continue {id: report.Daily, wantSections: "long_term"},
} {id: report.Hourly, wantSections: "key_messages,short_term"},
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, ",") != "key_messages,short_term" {
t.Fatalf("AFD sections = %#v, want key messages and short term", options.Sections)
}
} }
if !found {
t.Fatal("hourly default modules missing area_forecast_discussion") 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")
}
})
} }
} }

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"`
@@ -21,6 +30,8 @@ type PrecipitationWindowModule struct {
MaxPopPercent *int `json:"max_pop_percent,omitempty"` MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"` MaxPopTime string `json:"max_pop_time,omitempty"`
MaxPopHourLabel string `json:"max_pop_hour_label,omitempty"` MaxPopHourLabel string `json:"max_pop_hour_label,omitempty"`
PrecipitationType string `json:"precipitation_type,omitempty"`
ExpectationPhrase string `json:"expectation_phrase,omitempty"`
} }
func buildPrecipTimingModule(ctx ModuleContext, _ any) (*module.Output, error) { func buildPrecipTimingModule(ctx ModuleContext, _ any) (*module.Output, error) {
@@ -49,7 +60,50 @@ func precipTimingValue(timing forecast.PrecipTiming, timezone string) PrecipTimi
item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value) item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value)
item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone) item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone)
item.MaxPopHourLabel = hourMinuteLabel(window.MaxPrecipitationProbability.Time, timezone) item.MaxPopHourLabel = hourMinuteLabel(window.MaxPrecipitationProbability.Time, timezone)
item.PrecipitationType = precipitationWindowType(window.TextDescriptions)
if item.MaxPopPercent != nil {
item.ExpectationPhrase = precipitationWindowExpectationPhrase(*item.MaxPopPercent, item.PrecipitationType)
}
value.PrecipitationWindows = append(value.PrecipitationWindows, item) value.PrecipitationWindows = append(value.PrecipitationWindows, item)
} }
return value return value
} }
func precipitationWindowType(descriptions []string) string {
combined := strings.ToLower(strings.Join(descriptions, " "))
switch {
case (strings.Contains(combined, "thunderstorm") || strings.Contains(combined, "t-storm")) &&
(strings.Contains(combined, "shower") || strings.Contains(combined, "rain")):
return "showers and thunderstorms"
case strings.Contains(combined, "freezing rain"):
return "freezing rain"
case strings.Contains(combined, "thunderstorm") || strings.Contains(combined, "t-storm"):
return "thunderstorms"
case strings.Contains(combined, "shower"):
return "showers"
case strings.Contains(combined, "snow"):
return "snow"
case strings.Contains(combined, "drizzle"):
return "drizzle"
case strings.Contains(combined, "rain"):
return "rain"
default:
return "precipitation"
}
}
func precipitationWindowExpectationPhrase(maxPopPercent int, precipitationType string) string {
if precipitationType == "" {
precipitationType = "precipitation"
}
switch {
case maxPopPercent >= precipTimingExpectLowerBound:
return fmt.Sprintf("Expect %s.", precipitationType)
case maxPopPercent >= precipTimingLikelyLowerBound:
return fmt.Sprintf("%s likely.", sentenceCase(precipitationType))
case maxPopPercent >= precipTimingChanceLowerBound:
return fmt.Sprintf("Chance of %s.", precipitationType)
default:
return ""
}
}

View File

@@ -8,8 +8,8 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
const defaultSPCConvectiveDiscussionMinimumSeverityRank = 3 const defaultSPCConvectiveDiscussionMinimumSeverityRank = defaultSPCRiskDigestMinimumSeverityRank
const spcCategoricalOutlookType = "categorical" const spcCategoricalOutlookType = defaultSPCRiskDigestOutlookType
type SPCConvectiveDiscussionModule struct { type SPCConvectiveDiscussionModule struct {
IncludedBecause string `json:"included_because"` IncludedBecause string `json:"included_because"`

View File

@@ -1,13 +1,18 @@
package briefing package briefing
import ( import (
"strings"
"time" "time"
"unicode"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
const defaultSPCRiskDigestOutlookType = "categorical"
const defaultSPCRiskDigestMinimumSeverityRank = 3
type SPCConvectiveOutlooksModule struct { type SPCConvectiveOutlooksModule struct {
Checked bool `json:"checked"` Checked bool `json:"checked"`
AsOf string `json:"as_of,omitempty"` AsOf string `json:"as_of,omitempty"`
@@ -16,6 +21,7 @@ type SPCConvectiveOutlooksModule struct {
LocationName string `json:"location_name,omitempty"` LocationName string `json:"location_name,omitempty"`
OutlookCount int `json:"outlook_count"` OutlookCount int `json:"outlook_count"`
Outlooks []SPCConvectiveOutlookRecord `json:"outlooks,omitempty"` Outlooks []SPCConvectiveOutlookRecord `json:"outlooks,omitempty"`
RiskDigest []SPCConvectiveOutlookDigest `json:"risk_digest,omitempty"`
} }
type SPCConvectiveOutlookRecord struct { type SPCConvectiveOutlookRecord struct {
@@ -30,6 +36,13 @@ type SPCConvectiveOutlookRecord struct {
ImageURL string `json:"image_url,omitempty"` 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) { func buildSPCConvectiveOutlooksModule(ctx ModuleContext, _ any) (*module.Output, error) {
value := SPCConvectiveOutlooksModule{} value := SPCConvectiveOutlooksModule{}
run := ctx.Collected.SPCConvectiveOutlooks run := ctx.Collected.SPCConvectiveOutlooks
@@ -49,10 +62,23 @@ func buildSPCConvectiveOutlooksModule(ctx ModuleContext, _ any) (*module.Output,
} }
value.Outlooks = spcConvectiveOutlookRecords(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod, ctx.Timezone) value.Outlooks = spcConvectiveOutlookRecords(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod, ctx.Timezone)
value.RiskDigest = spcConvectiveOutlookRiskDigest(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod, ctx.Timezone, defaultSPCRiskDigestPolicy())
value.OutlookCount = len(value.Outlooks) value.OutlookCount = len(value.Outlooks)
return &module.Output{ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), Value: value}, nil return &module.Output{ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), Value: value}, nil
} }
type spcRiskDigestPolicy struct {
OutlookType string
MinimumSeverityRank int
}
func defaultSPCRiskDigestPolicy() spcRiskDigestPolicy {
return spcRiskDigestPolicy{
OutlookType: defaultSPCRiskDigestOutlookType,
MinimumSeverityRank: defaultSPCRiskDigestMinimumSeverityRank,
}
}
func spcConvectiveOutlookRecords(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period, timezone string) []SPCConvectiveOutlookRecord { func spcConvectiveOutlookRecords(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period, timezone string) []SPCConvectiveOutlookRecord {
records := make([]SPCConvectiveOutlookRecord, 0, len(outlooks)) records := make([]SPCConvectiveOutlookRecord, 0, len(outlooks))
for _, outlook := range outlooks { for _, outlook := range outlooks {
@@ -75,6 +101,42 @@ func spcConvectiveOutlookRecords(outlooks []weatherdata.ConvectiveOutlook, repor
return records return records
} }
func spcConvectiveOutlookRiskDigest(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period, timezone string, policy spcRiskDigestPolicy) []SPCConvectiveOutlookDigest {
records := make([]SPCConvectiveOutlookDigest, 0, len(outlooks))
for _, outlook := range outlooks {
if outlook.OutlookType != policy.OutlookType {
continue
}
if outlook.SeverityRank == nil || *outlook.SeverityRank < policy.MinimumSeverityRank {
continue
}
if !outlook.ContainsLocation {
continue
}
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(reportPeriod) {
continue
}
records = append(records, SPCConvectiveOutlookDigest{
LabelText: outlook.LabelText,
RiskLabel: spcRiskDigestLabel(outlook.LabelText),
PeriodBegins: friendlyMonthDayTimeLabel(outlookPeriod.Start, timezone),
PeriodEnds: friendlyMonthDayTimeLabel(outlookPeriod.End, timezone),
})
}
return records
}
func spcRiskDigestLabel(labelText string) string {
label := strings.TrimSpace(labelText)
if label == "" {
return ""
}
runes := []rune(strings.ToLower(label))
runes[0] = unicode.ToUpper(runes[0])
return string(runes)
}
func friendlyOptionalTime(value *time.Time, timezone string) string { func friendlyOptionalTime(value *time.Time, timezone string) string {
if value == nil { if value == nil {
return "" return ""

View File

@@ -71,12 +71,19 @@ func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) {
if !got.ContainsLocation || got.ImageURL == "" { if !got.ContainsLocation || got.ImageURL == "" {
t.Fatalf("outlook = %#v, want location flag and image URL", got) t.Fatalf("outlook = %#v, want location flag and image URL", got)
} }
if len(value.RiskDigest) != 1 {
t.Fatalf("RiskDigest length = %d, want 1", len(value.RiskDigest))
}
digest := value.RiskDigest[0]
if digest.LabelText != "Slight Risk" || digest.RiskLabel != "Slight risk" || digest.PeriodBegins != "May 29 at 11:00 AM" || digest.PeriodEnds != "May 30 at 7:00 AM" {
t.Fatalf("risk digest = %#v, want prompt-facing slight risk record", digest)
}
data, err := json.Marshal(output.Value) data, err := json.Marshal(output.Value)
if err != nil { if err != nil {
t.Fatalf("Marshal() error = %v", err) t.Fatalf("Marshal() error = %v", err)
} }
text := string(data) text := string(data)
for _, field := range []string{"checked", "as_of", "issued_at", "location_id", "location_name", "outlook_count", "outlooks", "period_begins", "period_ends", "contains_location", "image_url"} { for _, field := range []string{"checked", "as_of", "issued_at", "location_id", "location_name", "outlook_count", "outlooks", "risk_digest", "period_begins", "period_ends", "contains_location", "image_url"} {
if !strings.Contains(text, field) { if !strings.Contains(text, field) {
t.Fatalf("json = %s, want field %s", text, field) t.Fatalf("json = %s, want field %s", text, field)
} }
@@ -88,6 +95,71 @@ func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) {
} }
} }
func TestSPCRiskDigestDefaultPolicyConstants(t *testing.T) {
if defaultSPCRiskDigestOutlookType != "categorical" {
t.Fatalf("defaultSPCRiskDigestOutlookType = %q, want categorical", defaultSPCRiskDigestOutlookType)
}
if defaultSPCRiskDigestMinimumSeverityRank != 3 {
t.Fatalf("defaultSPCRiskDigestMinimumSeverityRank = %d, want 3", defaultSPCRiskDigestMinimumSeverityRank)
}
}
func TestSPCConvectiveOutlooksRiskDigestFilters(t *testing.T) {
tests := []struct {
name string
outlook weatherdata.ConvectiveOutlook
wantRisk bool
}{
{
name: "categorical slight risk included",
outlook: spcRiskDigestTestOutlook("categorical", "Slight Risk", 3, true,
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"),
wantRisk: true,
},
{
name: "marginal risk excluded",
outlook: spcRiskDigestTestOutlook("categorical", "Marginal Risk", 2, true,
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"),
},
{
name: "non categorical high rank excluded",
outlook: spcRiskDigestTestOutlook("wind", "30% Wind Risk", 30, true,
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"),
},
{
name: "non overlapping excluded",
outlook: spcRiskDigestTestOutlook("categorical", "Enhanced Risk", 4, true,
"2026-05-30T07:00:00-05:00", "2026-05-31T07:00:00-05:00"),
},
{
name: "location miss excluded",
outlook: spcRiskDigestTestOutlook("categorical", "Moderate Risk", 5, false,
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
Outlooks: []weatherdata.ConvectiveOutlook{tt.outlook},
}
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{tt.outlook}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
gotRisk := len(value.RiskDigest) > 0
if gotRisk != tt.wantRisk {
t.Fatalf("RiskDigest = %#v, want included=%v", value.RiskDigest, tt.wantRisk)
}
})
}
}
func TestSPCConvectiveOutlooksModuleSkipsNonOverlappingOutlooks(t *testing.T) { func TestSPCConvectiveOutlooksModuleSkipsNonOverlappingOutlooks(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := testModuleContext() ctx := testModuleContext()
@@ -117,6 +189,9 @@ func TestSPCConvectiveOutlooksModuleSkipsNonOverlappingOutlooks(t *testing.T) {
if value.OutlookCount != 0 || len(value.Outlooks) != 0 { if value.OutlookCount != 0 || len(value.Outlooks) != 0 {
t.Fatalf("value = %#v, want non-overlapping outlook omitted", value) t.Fatalf("value = %#v, want non-overlapping outlook omitted", value)
} }
if len(value.RiskDigest) != 0 {
t.Fatalf("RiskDigest = %#v, want non-overlapping outlook omitted", value.RiskDigest)
}
} }
func TestSPCConvectiveOutlooksModuleBuildsCheckedEmptyStanza(t *testing.T) { func TestSPCConvectiveOutlooksModuleBuildsCheckedEmptyStanza(t *testing.T) {
@@ -167,3 +242,16 @@ func TestSPCConvectiveOutlooksModuleBuildsUncheckedStanzaForMissingSource(t *tes
t.Fatalf("missing source value = %#v, want unchecked empty stanza", value) t.Fatalf("missing source value = %#v, want unchecked empty stanza", value)
} }
} }
func spcRiskDigestTestOutlook(outlookType string, labelText string, rank int, containsLocation bool, validFrom string, validTo string) weatherdata.ConvectiveOutlook {
return weatherdata.ConvectiveOutlook{
ID: labelText,
Day: 1,
OutlookType: outlookType,
LabelText: labelText,
SeverityRank: &rank,
ValidFrom: mustParseModuleTime(validFrom),
ValidTo: mustParseModuleTime(validTo),
ContainsLocation: containsLocation,
}
}

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,15 +16,15 @@ const helpText = `weatherreporter prepares weather reports from normalized forec
Usage: Usage:
weatherreporter --help weatherreporter --help
weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] [--quiet]
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--quiet]
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate weekend [--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 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] 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] 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
@@ -40,6 +39,7 @@ Options:
--tz NAME Override weather API timezone. --tz NAME Override weather API timezone.
--out PATH Write an extra Markdown report copy where supported by the generate command. --out PATH Write an extra Markdown report copy where supported by the generate command.
--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 {
@@ -51,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{}
} }
@@ -62,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}
} }
} }
@@ -96,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 {
@@ -181,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")
} }
if _, err := report.IDForCommandName(args[0]); err != nil { if _, err := report.IDForCommandName(args[0]); err != nil {
return app.GenerateRequest{}, fmt.Errorf("unknown generate report %q", args[0]) return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("unknown generate report %q", args[0])
} }
reportKind := app.ReportKind(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,
@@ -202,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{
@@ -219,11 +233,11 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
switch reportKind { switch reportKind {
case app.ReportDaily: case app.ReportDaily:
if opts.Date == "" { if opts.Date == "" {
return app.GenerateRequest{}, fmt.Errorf("generate daily requires --date YYYY-MM-DD") return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("generate daily requires --date YYYY-MM-DD")
} }
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.ReportToday: case app.ReportToday:
if opts.Date == "" { if opts.Date == "" {
@@ -231,41 +245,46 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
} 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")
} }
if _, err := report.BatchForCommandName(args[0]); err != nil { if _, err := report.BatchForCommandName(args[0]); err != nil {
return app.BatchRequest{}, fmt.Errorf("unknown run batch %q", args[0]) return app.BatchRequest{}, commonOptions{}, fmt.Errorf("unknown run batch %q", args[0])
} }
batch := app.BatchKind(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,
@@ -273,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) {
@@ -287,6 +306,7 @@ 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)
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress successful action output")
if report == app.ReportDaily || report == app.ReportToday { 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")
} }
@@ -309,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
} }
@@ -351,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")

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

@@ -58,15 +58,22 @@ type NotifyConfig struct {
} }
type DistributorNotifyConfig struct { type DistributorNotifyConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
Endpoint string `yaml:"endpoint"` Endpoint string `yaml:"endpoint"`
TokenEnv string `yaml:"token_env"` TokenEnv string `yaml:"token_env"`
Timeout time.Duration `yaml:"timeout"` Timeout time.Duration `yaml:"timeout"`
FailurePolicy NotifyFailurePolicy `yaml:"failure_policy"` FailurePolicy NotifyFailurePolicy `yaml:"failure_policy"`
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 {
@@ -105,10 +112,16 @@ 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:

View File

@@ -10,6 +10,7 @@ import (
"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"
"gopkg.in/yaml.v3"
) )
func TestDefaults(t *testing.T) { func TestDefaults(t *testing.T) {
@@ -57,11 +58,17 @@ func TestDefaults(t *testing.T) {
if cfg.Notify.Distributor.IdempotencyKeyTemplate != "{bundle_id}.{run_id}" { if cfg.Notify.Distributor.IdempotencyKeyTemplate != "{bundle_id}.{run_id}" {
t.Fatalf("Notify.Distributor.IdempotencyKeyTemplate = %q, want default", cfg.Notify.Distributor.IdempotencyKeyTemplate) t.Fatalf("Notify.Distributor.IdempotencyKeyTemplate = %q, want default", cfg.Notify.Distributor.IdempotencyKeyTemplate)
} }
wantReportPaths := []string{ if !cfg.Notify.Distributor.Batch.Enabled {
"{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md", t.Fatalf("Notify.Distributor.Batch.Enabled = false, want true")
} }
if strings.Join(cfg.Notify.Distributor.ReportPathTemplates, "\n") != strings.Join(wantReportPaths, "\n") { if cfg.Notify.Distributor.Batch.PipelineIDTemplate != "weatherreporter" {
t.Fatalf("Notify.Distributor.ReportPathTemplates = %#v, want %#v", cfg.Notify.Distributor.ReportPathTemplates, wantReportPaths) t.Fatalf("Notify.Distributor.Batch.PipelineIDTemplate = %q, want weatherreporter", cfg.Notify.Distributor.Batch.PipelineIDTemplate)
}
if cfg.Notify.Distributor.Batch.BundleIDTemplate != "weatherreporter.{location_id}.{batch}" {
t.Fatalf("Notify.Distributor.Batch.BundleIDTemplate = %q, want default", cfg.Notify.Distributor.Batch.BundleIDTemplate)
}
if cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate != "{bundle_id}.{batch_run_id}" {
t.Fatalf("Notify.Distributor.Batch.IdempotencyKeyTemplate = %q, want default", cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate)
} }
if cfg.MissingSource.Default != MissingSourceWarn { if cfg.MissingSource.Default != MissingSourceWarn {
t.Fatalf("MissingSource.Default = %q, want warn", cfg.MissingSource.Default) t.Fatalf("MissingSource.Default = %q, want warn", cfg.MissingSource.Default)
@@ -74,7 +81,7 @@ func TestLoadExampleConfig(t *testing.T) {
t.Fatalf("LoadFile() error = %v", err) t.Fatalf("LoadFile() error = %v", err)
} }
if cfg.WeatherAPI.BaseURL != "https://weather.api.rakestrawhome.com/" { if cfg.WeatherAPI.BaseURL != "https://weather.api.example.com/" {
t.Fatalf("BaseURL = %q, want configured example URL", cfg.WeatherAPI.BaseURL) t.Fatalf("BaseURL = %q, want configured example URL", cfg.WeatherAPI.BaseURL)
} }
if cfg.WeatherAPI.Timeout != 15*time.Second { if cfg.WeatherAPI.Timeout != 15*time.Second {
@@ -89,8 +96,17 @@ func TestLoadExampleConfig(t *testing.T) {
if cfg.Notify.Distributor.PipelineIDTemplate != "weatherreporter.{report_id}" { if cfg.Notify.Distributor.PipelineIDTemplate != "weatherreporter.{report_id}" {
t.Fatalf("PipelineIDTemplate = %q, want example pipeline template", cfg.Notify.Distributor.PipelineIDTemplate) t.Fatalf("PipelineIDTemplate = %q, want example pipeline template", cfg.Notify.Distributor.PipelineIDTemplate)
} }
if len(cfg.Notify.Distributor.ReportPathTemplates) != 1 { if !cfg.Notify.Distributor.Batch.Enabled {
t.Fatalf("ReportPathTemplates = %#v, want example archive path", cfg.Notify.Distributor.ReportPathTemplates) t.Fatalf("Notify.Distributor.Batch.Enabled = false, want true")
}
if cfg.Notify.Distributor.Batch.PipelineIDTemplate != "weatherreporter" {
t.Fatalf("Batch PipelineIDTemplate = %q, want weatherreporter", cfg.Notify.Distributor.Batch.PipelineIDTemplate)
}
if cfg.Notify.Distributor.Batch.BundleIDTemplate != "weatherreporter.{location_id}.{batch}" {
t.Fatalf("Batch BundleIDTemplate = %q, want example batch bundle template", cfg.Notify.Distributor.Batch.BundleIDTemplate)
}
if cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate != "{bundle_id}.{batch_run_id}" {
t.Fatalf("Batch IdempotencyKeyTemplate = %q, want example batch idempotency template", cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate)
} }
overrides, err := cfg.ReportModuleOverrides() overrides, err := cfg.ReportModuleOverrides()
if err != nil { if err != nil {
@@ -289,6 +305,102 @@ reports:
} }
} }
func TestLoadReportDistributorPathOverrides(t *testing.T) {
path := writeConfig(t, `
reports:
daily:
distributor:
path_templates:
- "daily/{valid_start_date}/{run_id}.md"
- "daily/{valid_start_date}/index.md"
today:
deterministic_modules:
- metadata
`)
cfg, err := LoadFile(path)
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
daily := cfg.Reports["daily"].Distributor
if !daily.PathTemplatesSet() {
t.Fatal("daily distributor path_templates set = false, want true")
}
want := []string{
"daily/{valid_start_date}/{run_id}.md",
"daily/{valid_start_date}/index.md",
}
if !reflect.DeepEqual(daily.PathTemplates, want) {
t.Fatalf("daily path templates = %#v, want %#v", daily.PathTemplates, want)
}
if cfg.Reports["today"].Distributor.PathTemplatesSet() {
t.Fatal("today distributor path_templates set = true, want false")
}
overrides, err := cfg.ReportDistributorPathOverrides()
if err != nil {
t.Fatalf("ReportDistributorPathOverrides() error = %v", err)
}
if !reflect.DeepEqual(overrides[report.Daily], want) {
t.Fatalf("daily distributor override = %#v, want %#v", overrides[report.Daily], want)
}
if _, ok := overrides[report.Today]; ok {
t.Fatalf("today distributor override = %#v, want omitted override absent", overrides[report.Today])
}
}
func TestReportDistributorPathTemplatesSetTracksExplicitEmptyList(t *testing.T) {
var cfg Config
if err := yaml.Unmarshal([]byte(`
reports:
daily:
distributor:
path_templates: []
today:
distributor: {}
`), &cfg); err != nil {
t.Fatalf("yaml.Unmarshal() error = %v", err)
}
if !cfg.Reports["daily"].Distributor.PathTemplatesSet() {
t.Fatal("daily distributor path_templates set = false, want true")
}
if len(cfg.Reports["daily"].Distributor.PathTemplates) != 0 {
t.Fatalf("daily path templates = %#v, want empty explicit list", cfg.Reports["daily"].Distributor.PathTemplates)
}
if cfg.Reports["today"].Distributor.PathTemplatesSet() {
t.Fatal("today distributor path_templates set = true, want false")
}
}
func TestLoadReportDistributorPathOverrideAliases(t *testing.T) {
path := writeConfig(t, `
reports:
three-day-outlook:
distributor:
path_templates:
- "three-day/{valid_start_date}/index.md"
weekend_outlook:
distributor:
path_templates:
- "weekend/{valid_start_date}/index.md"
`)
cfg, err := LoadFile(path)
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
overrides, err := cfg.ReportDistributorPathOverrides()
if err != nil {
t.Fatalf("ReportDistributorPathOverrides() error = %v", err)
}
if !reflect.DeepEqual(overrides[report.ThreeDay], []string{"three-day/{valid_start_date}/index.md"}) {
t.Fatalf("three-day distributor override = %#v, want alias override", overrides[report.ThreeDay])
}
if !reflect.DeepEqual(overrides[report.Weekend], []string{"weekend/{valid_start_date}/index.md"}) {
t.Fatalf("weekend distributor override = %#v, want alias override", overrides[report.Weekend])
}
}
func TestValidateReportModuleKeysWithoutMutatingOptions(t *testing.T) { func TestValidateReportModuleKeysWithoutMutatingOptions(t *testing.T) {
cfg := Defaults() cfg := Defaults()
rawOptions := map[string]any{ rawOptions := map[string]any{
@@ -313,6 +425,37 @@ func TestValidateReportModuleKeysWithoutMutatingOptions(t *testing.T) {
} }
} }
func TestReportModuleOverridesNormalizesConstructedOptionsWithoutMutatingConfig(t *testing.T) {
cfg := Defaults()
rawOptions := map[string]any{
"sections": []any{"short_term"},
}
cfg.Reports = map[string]ReportConfig{
"daily": {
DeterministicModules: []ModuleConfigItem{
{ID: module.Metadata},
{ID: module.AreaForecastDiscussion, Options: rawOptions},
},
deterministicModulesSet: true,
},
}
overrides, err := cfg.ReportModuleOverrides()
if err != nil {
t.Fatalf("ReportModuleOverrides() error = %v", err)
}
options, ok := overrides[report.Daily][1].Options.(module.AreaForecastDiscussionOptions)
if !ok {
t.Fatalf("override options type = %T, want AreaForecastDiscussionOptions", overrides[report.Daily][1].Options)
}
if strings.Join(options.Sections, ",") != "short_term" {
t.Fatalf("override sections = %#v, want short_term", options.Sections)
}
if got, ok := cfg.Reports["daily"].DeterministicModules[1].Options.(map[string]any); !ok || !reflect.DeepEqual(got, rawOptions) {
t.Fatalf("config options after ReportModuleOverrides = %#v, want original raw map", cfg.Reports["daily"].DeterministicModules[1].Options)
}
}
func TestValidateReportModuleAliasesDirectly(t *testing.T) { func TestValidateReportModuleAliasesDirectly(t *testing.T) {
retiredDailyKey := retiredDailyReportKeyForTest() retiredDailyKey := retiredDailyReportKeyForTest()
tests := []struct { tests := []struct {
@@ -510,6 +653,347 @@ reports:
} }
} }
func TestReportDistributorPathOverrideValidation(t *testing.T) {
tests := []struct {
name string
yaml string
wantErr string
}{
{
name: "UnknownReportField",
yaml: `
reports:
daily:
distributor_paths:
- latest.md
`,
wantErr: `unknown report entry field "distributor_paths"`,
},
{
name: "UnknownDistributorField",
yaml: `
reports:
daily:
distributor:
paths:
- latest.md
`,
wantErr: `unknown report distributor field "paths"`,
},
{
name: "DuplicateReportAlias",
yaml: `
reports:
three-day:
distributor:
path_templates:
- "three-day/{valid_start_date}/index.md"
three_day:
distributor:
path_templates:
- "three-day/latest.md"
`,
wantErr: "duplicates report override",
},
{
name: "UnknownTemplateVariable",
yaml: `
reports:
daily:
distributor:
path_templates:
- "{unknown}.md"
`,
wantErr: `reports.daily.distributor.path_templates[0] contains unknown template variable "unknown"`,
},
{
name: "AbsolutePath",
yaml: `
reports:
daily:
distributor:
path_templates:
- "/daily.md"
`,
wantErr: "reports.daily.distributor.path_templates[0] must render a relative path",
},
{
name: "ParentSegment",
yaml: `
reports:
daily:
distributor:
path_templates:
- "daily/../index.md"
`,
wantErr: "reports.daily.distributor.path_templates[0] must not render . or .. path segments",
},
{
name: "Manifest",
yaml: `
reports:
daily:
distributor:
path_templates:
- "daily/manifest.json"
`,
wantErr: `reports.daily.distributor.path_templates[0] must not render reserved path segment "manifest.json"`,
},
{
name: "DuplicateRenderedPath",
yaml: `
reports:
daily:
distributor:
path_templates:
- "daily/index.md"
- "daily/index.md"
`,
wantErr: `reports.daily.distributor.path_templates renders duplicate path "daily/index.md"`,
},
{
name: "NonStormStormIDEmptyPathSegment",
yaml: `
reports:
daily:
distributor:
path_templates:
- "daily/{storm_id}/index.md"
`,
wantErr: "reports.daily.distributor.path_templates[0] must not render empty path segments",
},
{
name: "EmptyOverrideList",
yaml: `
reports:
daily:
distributor:
path_templates: []
`,
wantErr: "reports.daily.distributor.path_templates must contain at least one entry",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := LoadFile(writeConfig(t, tt.yaml))
if err == nil {
t.Fatal("LoadFile() error = nil, want validation error")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
}
})
}
}
func TestReportDistributorPathOverrideStormIDValidation(t *testing.T) {
_, err := LoadFile(writeConfig(t, `
reports:
daily:
distributor:
path_templates:
- "daily/storm-{storm_id}.md"
storm:
distributor:
path_templates:
- "storm/{storm_id}/index.md"
`))
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
}
func TestReportDistributorPathOverridesConsistentForLoadedAndConstructedConfig(t *testing.T) {
yaml := `
reports:
daily:
distributor:
path_templates:
- "daily/{valid_start_date}/index.md"
`
reports := map[string]ReportConfig{
"daily": {
Distributor: ReportDistributorConfig{
PathTemplates: []string{"daily/{valid_start_date}/index.md"},
pathTemplatesSet: true,
},
},
}
cfg, err := LoadFile(writeConfig(t, yaml))
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
loaded, err := cfg.ReportDistributorPathOverrides()
if err != nil {
t.Fatalf("loaded ReportDistributorPathOverrides() error = %v", err)
}
cfg = Defaults()
cfg.Reports = reports
if err := Validate(cfg); err != nil {
t.Fatalf("Validate() error = %v", err)
}
constructed, err := cfg.ReportDistributorPathOverrides()
if err != nil {
t.Fatalf("constructed ReportDistributorPathOverrides() error = %v", err)
}
if !reflect.DeepEqual(loaded, constructed) {
t.Fatalf("loaded overrides = %#v, constructed = %#v", loaded, constructed)
}
}
func TestReportModuleValidationConsistentForLoadedAndConstructedConfig(t *testing.T) {
tests := []struct {
name string
yaml string
reports map[string]ReportConfig
wantErr string
}{
{
name: "UnknownReport",
yaml: `
reports:
moon:
deterministic_modules:
- metadata
`,
reports: map[string]ReportConfig{
"moon": {
DeterministicModules: []ModuleConfigItem{{ID: module.Metadata}},
deterministicModulesSet: true,
},
},
wantErr: "reports.moon",
},
{
name: "DuplicateReportAlias",
yaml: `
reports:
three-day:
deterministic_modules:
- metadata
three_day:
deterministic_modules:
- metadata
`,
reports: map[string]ReportConfig{
"three-day": {
DeterministicModules: []ModuleConfigItem{{ID: module.Metadata}},
deterministicModulesSet: true,
},
"three_day": {
DeterministicModules: []ModuleConfigItem{{ID: module.Metadata}},
deterministicModulesSet: true,
},
},
wantErr: "duplicates report override",
},
{
name: "UnknownModule",
yaml: `
reports:
daily:
deterministic_modules:
- missing_module
`,
reports: map[string]ReportConfig{
"daily": {
DeterministicModules: []ModuleConfigItem{{ID: module.ID("missing_module")}},
deterministicModulesSet: true,
},
},
wantErr: `unknown module "missing_module"`,
},
{
name: "DuplicateModule",
yaml: `
reports:
daily:
deterministic_modules:
- metadata
- metadata
`,
reports: map[string]ReportConfig{
"daily": {
DeterministicModules: []ModuleConfigItem{
{ID: module.Metadata},
{ID: module.Metadata},
},
deterministicModulesSet: true,
},
},
wantErr: `duplicate module "metadata"`,
},
{
name: "IncompatibleModule",
yaml: `
reports:
daily:
deterministic_modules:
- tomorrow_planning
`,
reports: map[string]ReportConfig{
"daily": {
DeterministicModules: []ModuleConfigItem{{ID: module.TomorrowPlanning}},
deterministicModulesSet: true,
},
},
wantErr: `not compatible with report "daily"`,
},
{
name: "InvalidOptions",
yaml: `
reports:
daily:
deterministic_modules:
- id: metadata
options:
sections:
- short_term
`,
reports: map[string]ReportConfig{
"daily": {
DeterministicModules: []ModuleConfigItem{
{
ID: module.Metadata,
Options: map[string]any{
"sections": []any{"short_term"},
},
},
},
deterministicModulesSet: true,
},
},
wantErr: "options are invalid",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, loadErr := LoadFile(writeConfig(t, tt.yaml))
assertReportModuleError(t, "LoadFile", loadErr, tt.wantErr)
cfg := Defaults()
cfg.Reports = tt.reports
assertReportModuleError(t, "Validate", Validate(cfg), tt.wantErr)
_, overrideErr := cfg.ReportModuleOverrides()
assertReportModuleError(t, "ReportModuleOverrides", overrideErr, tt.wantErr)
})
}
}
func assertReportModuleError(t *testing.T, operation string, err error, want string) {
t.Helper()
if err == nil {
t.Fatalf("%s error = nil, want %q", operation, want)
}
if !strings.Contains(err.Error(), want) {
t.Fatalf("%s error = %q, want %q", operation, err.Error(), want)
}
}
func retiredDailyReportKeyForTest() string { func retiredDailyReportKeyForTest() string {
return strings.Join([]string{"daily", "today"}, "_") return strings.Join([]string{"daily", "today"}, "_")
} }
@@ -600,6 +1084,89 @@ func TestDisabledDistributorNotifyAcceptsOmittedFields(t *testing.T) {
} }
} }
func TestDistributorNotifyRejectsRemovedGlobalReportPaths(t *testing.T) {
removedField := "report_path" + "_templates"
_, err := LoadFile(writeConfig(t, `
notify:
distributor:
`+removedField+`:
- index.md
`))
if err == nil {
t.Fatal("LoadFile() error = nil, want removed global path field error")
}
if !strings.Contains(err.Error(), `unknown notify distributor field "`+removedField+`"`) {
t.Fatalf("error = %q, want removed global path field rejection", err.Error())
}
}
func TestDistributorNotifyRejectsUnknownFields(t *testing.T) {
_, err := LoadFile(writeConfig(t, `
notify:
distributor:
paths:
- index.md
`))
if err == nil {
t.Fatal("LoadFile() error = nil, want unknown distributor field error")
}
if !strings.Contains(err.Error(), `unknown notify distributor field "paths"`) {
t.Fatalf("error = %q, want unknown field rejection", err.Error())
}
}
func TestDistributorBatchNotifyRejectsUnknownFields(t *testing.T) {
_, err := LoadFile(writeConfig(t, `
notify:
distributor:
batch:
paths:
- index.md
`))
if err == nil {
t.Fatal("LoadFile() error = nil, want unknown distributor batch field error")
}
if !strings.Contains(err.Error(), `unknown notify distributor batch field "paths"`) {
t.Fatalf("error = %q, want unknown batch field rejection", err.Error())
}
}
func TestDistributorBatchNotifyPartialConfigPreservesDefaults(t *testing.T) {
cfg, err := LoadFile(writeConfig(t, `
notify:
distributor:
batch:
enabled: false
`))
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
if cfg.Notify.Distributor.Batch.Enabled {
t.Fatalf("Batch.Enabled = true, want false")
}
if cfg.Notify.Distributor.Batch.PipelineIDTemplate != "weatherreporter" {
t.Fatalf("Batch.PipelineIDTemplate = %q, want default", cfg.Notify.Distributor.Batch.PipelineIDTemplate)
}
if cfg.Notify.Distributor.Batch.BundleIDTemplate != "weatherreporter.{location_id}.{batch}" {
t.Fatalf("Batch.BundleIDTemplate = %q, want default", cfg.Notify.Distributor.Batch.BundleIDTemplate)
}
if cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate != "{bundle_id}.{batch_run_id}" {
t.Fatalf("Batch.IdempotencyKeyTemplate = %q, want default", cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate)
}
}
func TestDisabledDistributorNotifyAcceptsMalformedBatchTemplates(t *testing.T) {
cfg := Defaults()
cfg.Notify.Distributor.Enabled = false
cfg.Notify.Distributor.Batch.PipelineIDTemplate = "{unknown}"
cfg.Notify.Distributor.Batch.BundleIDTemplate = "{unknown}"
cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate = "{unknown}"
if err := Validate(cfg); err != nil {
t.Fatalf("Validate() error = %v", err)
}
}
func TestEnabledDistributorNotifyValidation(t *testing.T) { func TestEnabledDistributorNotifyValidation(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -677,32 +1244,11 @@ func TestEnabledDistributorNotifyValidation(t *testing.T) {
wantErr: "notify.distributor.idempotency_key_template", wantErr: "notify.distributor.idempotency_key_template",
}, },
{ {
name: "ReportPathTemplatesEmpty", name: "BatchTemplate",
mutate: func(cfg *Config) { mutate: func(cfg *Config) {
cfg.Notify.Distributor.ReportPathTemplates = nil cfg.Notify.Distributor.Batch.BundleIDTemplate = "{run_id}"
}, },
wantErr: "notify.distributor.report_path_templates", wantErr: "notify.distributor.batch.bundle_id_template",
},
{
name: "ReportPathTemplateUnknown",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.ReportPathTemplates = []string{"{unknown}"}
},
wantErr: "notify.distributor.report_path_templates",
},
{
name: "ReportPathTemplateInvalidPath",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.ReportPathTemplates = []string{"/{batch_output_name}"}
},
wantErr: "notify.distributor.report_path_templates",
},
{
name: "ReportPathTemplateDuplicatePath",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.ReportPathTemplates = []string{"latest.md", "latest.md"}
},
wantErr: "notify.distributor.report_path_templates",
}, },
} }
@@ -724,6 +1270,109 @@ func TestEnabledDistributorNotifyValidation(t *testing.T) {
} }
} }
func TestEnabledDistributorBatchNotifyValidation(t *testing.T) {
tests := []struct {
name string
mutate func(*Config)
wantErr string
}{
{
name: "PipelineTemplateEmpty",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Batch.PipelineIDTemplate = ""
},
wantErr: "notify.distributor.batch.pipeline_id_template",
},
{
name: "PipelineTemplateUnknown",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Batch.PipelineIDTemplate = "{report_id}"
},
wantErr: "notify.distributor.batch.pipeline_id_template",
},
{
name: "PipelineTemplateRenderedEmpty",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Batch.PipelineIDTemplate = " "
},
wantErr: "notify.distributor.batch.pipeline_id_template",
},
{
name: "BundleTemplateEmpty",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Batch.BundleIDTemplate = ""
},
wantErr: "notify.distributor.batch.bundle_id_template",
},
{
name: "BundleTemplateUnknown",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Batch.BundleIDTemplate = "{run_id}"
},
wantErr: "notify.distributor.batch.bundle_id_template",
},
{
name: "BundleTemplateRenderedEmpty",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Batch.BundleIDTemplate = " "
},
wantErr: "notify.distributor.batch.bundle_id_template",
},
{
name: "IdempotencyTemplateEmpty",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate = ""
},
wantErr: "notify.distributor.batch.idempotency_key_template",
},
{
name: "IdempotencyTemplateUnknown",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate = "{report_id}"
},
wantErr: "notify.distributor.batch.idempotency_key_template",
},
{
name: "IdempotencyTemplateRenderedEmpty",
mutate: func(cfg *Config) {
cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate = " "
},
wantErr: "notify.distributor.batch.idempotency_key_template",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := Defaults()
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
tt.mutate(&cfg)
err := Validate(cfg)
if err == nil {
t.Fatal("Validate() error = nil, want error")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
}
})
}
}
func TestDisabledDistributorBatchNotifySkipsBatchTemplateValidation(t *testing.T) {
cfg := Defaults()
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
cfg.Notify.Distributor.Batch.Enabled = false
cfg.Notify.Distributor.Batch.PipelineIDTemplate = "{unknown}"
cfg.Notify.Distributor.Batch.BundleIDTemplate = "{unknown}"
cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate = "{unknown}"
if err := Validate(cfg); err != nil {
t.Fatalf("Validate() error = %v", err)
}
}
func TestDistributorTemplateRendering(t *testing.T) { func TestDistributorTemplateRendering(t *testing.T) {
values := DistributorTemplateValues{ values := DistributorTemplateValues{
LocationID: "home", LocationID: "home",
@@ -737,35 +1386,38 @@ func TestDistributorTemplateRendering(t *testing.T) {
ValidEndTime: "0600", ValidEndTime: "0600",
ValidStartStamp: "2026-06-07T1800", ValidStartStamp: "2026-06-07T1800",
ValidEndStamp: "2026-06-08T0600", ValidEndStamp: "2026-06-08T0600",
StormID: "2026-06-07T1800-2026-06-08T0600",
BundleID: "weatherreporter.home.daily", BundleID: "weatherreporter.home.daily",
} }
bundleID, err := RenderDistributorBundleID("weatherreporter.{location_id}.{report_id}", values) bundleID, err := RenderDistributorBundleID("weatherreporter.{location_id}.{report_id}.{storm_id}", values)
if err != nil { if err != nil {
t.Fatalf("RenderDistributorBundleID() error = %v", err) t.Fatalf("RenderDistributorBundleID() error = %v", err)
} }
if bundleID != "weatherreporter.home.daily" { if bundleID != "weatherreporter.home.daily.2026-06-07T1800-2026-06-08T0600" {
t.Fatalf("bundleID = %q, want rendered value", bundleID) t.Fatalf("bundleID = %q, want rendered value", bundleID)
} }
values.BundleID = bundleID
pipelineID, err := RenderDistributorPipelineID("weatherreporter.{artifact_group}.{bundle_id}", values) pipelineID, err := RenderDistributorPipelineID("weatherreporter.{artifact_group}.{storm_id}.{bundle_id}", values)
if err != nil { if err != nil {
t.Fatalf("RenderDistributorPipelineID() error = %v", err) t.Fatalf("RenderDistributorPipelineID() error = %v", err)
} }
if pipelineID != "weatherreporter.daily.weatherreporter.home.daily" { if pipelineID != "weatherreporter.daily.2026-06-07T1800-2026-06-08T0600.weatherreporter.home.daily.2026-06-07T1800-2026-06-08T0600" {
t.Fatalf("pipelineID = %q, want rendered pipeline ID", pipelineID) t.Fatalf("pipelineID = %q, want rendered pipeline ID", pipelineID)
} }
idempotencyKey, err := RenderDistributorIdempotencyKey("{bundle_id}.{run_id}", values) idempotencyKey, err := RenderDistributorIdempotencyKey("{bundle_id}.{storm_id}.{run_id}", values)
if err != nil { if err != nil {
t.Fatalf("RenderDistributorIdempotencyKey() error = %v", err) t.Fatalf("RenderDistributorIdempotencyKey() error = %v", err)
} }
if idempotencyKey != "weatherreporter.home.daily.20260607T120000Z" { if idempotencyKey != "weatherreporter.home.daily.2026-06-07T1800-2026-06-08T0600.2026-06-07T1800-2026-06-08T0600.20260607T120000Z" {
t.Fatalf("idempotencyKey = %q, want rendered run key", idempotencyKey) t.Fatalf("idempotencyKey = %q, want rendered run key", idempotencyKey)
} }
reportPaths, err := RenderDistributorReportPaths([]string{ reportPaths, err := RenderDistributorReportPaths("reports.daily.distributor.path_templates", []string{
"{valid_start_date}/{artifact_group}/{valid_start_stamp}-{valid_end_stamp}-{run_id}.md", "{valid_start_date}/{artifact_group}/{valid_start_stamp}-{valid_end_stamp}-{run_id}.md",
"storm/{storm_id}/index.md",
"{valid_start_date}/{artifact_group}/latest.md", "{valid_start_date}/{artifact_group}/latest.md",
}, values) }, values)
if err != nil { if err != nil {
@@ -773,6 +1425,7 @@ func TestDistributorTemplateRendering(t *testing.T) {
} }
wantPaths := []string{ wantPaths := []string{
"2026-06-07/daily/2026-06-07T1800-2026-06-08T0600-20260607T120000Z.md", "2026-06-07/daily/2026-06-07T1800-2026-06-08T0600-20260607T120000Z.md",
"storm/2026-06-07T1800-2026-06-08T0600/index.md",
"2026-06-07/daily/latest.md", "2026-06-07/daily/latest.md",
} }
if strings.Join(reportPaths, "\n") != strings.Join(wantPaths, "\n") { if strings.Join(reportPaths, "\n") != strings.Join(wantPaths, "\n") {
@@ -780,6 +1433,113 @@ func TestDistributorTemplateRendering(t *testing.T) {
} }
} }
func TestDistributorReportPathRenderingUsesCallerName(t *testing.T) {
values := DistributorTemplateValues{
BatchOutputName: "report.md",
}
tests := []struct {
name string
templates []string
wantErr string
}{
{
name: "UnknownVariable",
templates: []string{"{unknown}.md"},
wantErr: `report.daily.distributor_path_templates[0] contains unknown template variable "unknown"`,
},
{
name: "InvalidPath",
templates: []string{"/{batch_output_name}"},
wantErr: "report.daily.distributor_path_templates[0] must render a relative path",
},
{
name: "DuplicatePath",
templates: []string{"latest.md", "latest.md"},
wantErr: `report.daily.distributor_path_templates renders duplicate path "latest.md"`,
},
{
name: "Empty",
templates: nil,
wantErr: "report.daily.distributor_path_templates must contain at least one entry",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := RenderDistributorReportPaths("report.daily.distributor_path_templates", tt.templates, values)
if err == nil {
t.Fatal("RenderDistributorReportPaths() error = nil, want error")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
}
})
}
}
func TestDistributorBatchTemplateRendering(t *testing.T) {
values := DistributorBatchTemplateValues{
LocationID: "home",
Batch: "evening",
BatchRunID: "20260617T235037.642224552Z_evening",
BatchStartedDate: "2026-06-17",
}
bundleID, err := RenderDistributorBatchBundleID("weatherreporter.{location_id}.{batch}", values)
if err != nil {
t.Fatalf("RenderDistributorBatchBundleID() error = %v", err)
}
if bundleID != "weatherreporter.home.evening" {
t.Fatalf("bundleID = %q, want batch bundle ID", bundleID)
}
values.BundleID = bundleID
pipelineID, err := RenderDistributorBatchPipelineID("weatherreporter", values)
if err != nil {
t.Fatalf("RenderDistributorBatchPipelineID() error = %v", err)
}
if pipelineID != "weatherreporter" {
t.Fatalf("pipelineID = %q, want weatherreporter", pipelineID)
}
idempotencyKey, err := RenderDistributorBatchIdempotencyKey("{bundle_id}.{batch_run_id}", values)
if err != nil {
t.Fatalf("RenderDistributorBatchIdempotencyKey() error = %v", err)
}
if idempotencyKey != "weatherreporter.home.evening.20260617T235037.642224552Z_evening" {
t.Fatalf("idempotencyKey = %q, want batch retry key", idempotencyKey)
}
bundleID, err = RenderDistributorBatchBundleID("weatherreporter.{batch_started_date}.{batch}", values)
if err != nil {
t.Fatalf("RenderDistributorBatchBundleID() with date error = %v", err)
}
if bundleID != "weatherreporter.2026-06-17.evening" {
t.Fatalf("bundleID = %q, want date-aware batch bundle ID", bundleID)
}
}
func TestDistributorBatchTemplateRejectsUnknownAndMalformedVariables(t *testing.T) {
tests := []struct {
name string
template string
}{
{name: "Unknown", template: "{report_id}"},
{name: "Unclosed", template: "{batch"},
{name: "Unopened", template: "batch}"},
{name: "Empty", template: "{}"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := RenderDistributorBatchBundleID(tt.template, DistributorBatchTemplateValues{})
if err == nil {
t.Fatal("RenderDistributorBatchBundleID() error = nil, want error")
}
})
}
}
func TestDistributorTemplateRejectsUnknownAndMalformedVariables(t *testing.T) { func TestDistributorTemplateRejectsUnknownAndMalformedVariables(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -817,7 +1577,7 @@ func TestDistributorReportPathValidation(t *testing.T) {
{name: "ParentSegment", path: "reports/../daily.md", ok: false}, {name: "ParentSegment", path: "reports/../daily.md", ok: false},
{name: "EmptySegment", path: "reports//daily.md", ok: false}, {name: "EmptySegment", path: "reports//daily.md", ok: false},
{name: "Manifest", path: "reports/manifest.json", ok: false}, {name: "Manifest", path: "reports/manifest.json", ok: false},
{name: "DistributorMetadata", path: "reports/.distributor.json", ok: false}, {name: "DistributorMetadata", path: "reports/" + distributorSidecarBasename(), ok: false},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -844,17 +1604,20 @@ func TestDistributorReportPathRenderingRejectsInvalidValues(t *testing.T) {
{name: "ParentSegment", batchOutputName: "../daily.md"}, {name: "ParentSegment", batchOutputName: "../daily.md"},
{name: "EmptySegment", batchOutputName: "reports//daily.md"}, {name: "EmptySegment", batchOutputName: "reports//daily.md"},
{name: "Manifest", batchOutputName: "manifest.json"}, {name: "Manifest", batchOutputName: "manifest.json"},
{name: "DistributorMetadata", batchOutputName: ".distributor.json"}, {name: "DistributorMetadata", batchOutputName: distributorSidecarBasename()},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
_, err := RenderDistributorReportPaths([]string{"{batch_output_name}"}, DistributorTemplateValues{ _, err := RenderDistributorReportPaths("report.daily.distributor_path_templates", []string{"{batch_output_name}"}, DistributorTemplateValues{
BatchOutputName: tt.batchOutputName, BatchOutputName: tt.batchOutputName,
}) })
if err == nil { if err == nil {
t.Fatal("RenderDistributorReportPaths() error = nil, want error") t.Fatal("RenderDistributorReportPaths() error = nil, want error")
} }
if !strings.Contains(err.Error(), "report.daily.distributor_path_templates[0]") {
t.Fatalf("error = %q, want caller path name", err.Error())
}
}) })
} }
} }

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,9 +18,18 @@ type DistributorTemplateValues struct {
ValidEndTime string ValidEndTime string
ValidStartStamp string ValidStartStamp string
ValidEndStamp string ValidEndStamp string
StormID string
BundleID string BundleID string
} }
type DistributorBatchTemplateValues struct {
LocationID string
Batch string
BatchRunID string
BatchStartedDate string
BundleID string
}
var distributorTemplateVariables = map[string]struct{}{ var distributorTemplateVariables = map[string]struct{}{
"location_id": {}, "location_id": {},
"report_id": {}, "report_id": {},
@@ -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

@@ -12,116 +12,162 @@ import (
) )
func (cfg Config) ReportModuleOverrides() (map[report.ID][]module.ConfigItem, error) { func (cfg Config) ReportModuleOverrides() (map[report.ID][]module.ConfigItem, error) {
overrides := map[report.ID][]module.ConfigItem{} return traverseReportModules(&cfg, reportModuleTraversalOptions{
seenReports := map[report.ID]string{} normalizeOptions: true,
for key, reportCfg := range cfg.Reports { })
id, err := report.IDForConfigKey(key) }
if err != nil {
return nil, fmt.Errorf("reports.%s: %w", key, err) func (cfg Config) ReportDistributorPathOverrides() (map[report.ID][]string, error) {
} return traverseReportDistributorPathOverrides(cfg)
if previous, ok := seenReports[id]; ok {
return nil, fmt.Errorf("reports.%s duplicates report override %q", key, previous)
}
seenReports[id] = key
if !reportCfg.deterministicModulesSet {
continue
}
overrides[id] = moduleItemsFromConfig(reportCfg.DeterministicModules)
}
return overrides, nil
} }
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{}
} }
moduleRegistry, err := briefing.DefaultModuleRegistry() _, err := traverseReportModules(cfg, reportModuleTraversalOptions{
if err != nil { normalizeOptions: true,
return fmt.Errorf("initialize module registry: %w", err) updateConfig: true,
} })
reportRegistry := report.DefaultRegistry() return err
seenReports := map[report.ID]string{}
for key, reportCfg := range cfg.Reports {
reportID, err := report.IDForConfigKey(key)
if err != nil {
return fmt.Errorf("reports.%s: %w", key, err)
}
if previous, ok := seenReports[reportID]; ok {
return fmt.Errorf("reports.%s duplicates report override %q", key, previous)
}
seenReports[reportID] = key
if _, err := reportRegistry.Lookup(reportID); err != nil {
return fmt.Errorf("reports.%s: %w", key, err)
}
if !reportCfg.deterministicModulesSet {
continue
}
for i, rawItem := range reportCfg.DeterministicModules {
options, err := normalizeModuleOptions(moduleRegistry, rawItem.ID, rawItem.Options)
if err != nil {
return fmt.Errorf("reports.%s.deterministic_modules[%d]: %w", key, i, err)
}
reportCfg.DeterministicModules[i].Options = options
}
items := moduleItemsFromConfig(reportCfg.DeterministicModules)
if err := moduleRegistry.ValidateComposition(reportID, items); err != nil {
return fmt.Errorf("reports.%s.deterministic_modules: %w", key, err)
}
cfg.Reports[key] = reportCfg
}
return nil
} }
func validateReportModules(cfg Config) error { 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 { if cfg.Reports == nil {
return 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 := report.IDForConfigKey(key) reportID, err := report.IDForConfigKey(key)
if err != nil { if err != nil {
return fmt.Errorf("reports.%s: %w", key, 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 { if err != nil {
options := rawItem.Options return nil, err
if options != nil {
normalized, err := normalizeModuleOptions(moduleRegistry, rawItem.ID, options)
if err != nil {
return fmt.Errorf("reports.%s.deterministic_modules[%d]: %w", key, i, err)
}
options = normalized
}
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
} }
} }
return nil return overrides, nil
} }
func moduleItemsFromConfig(items []ModuleConfigItem) []module.ConfigItem { func traverseReportDistributorPathOverrides(cfg Config) (map[report.ID][]string, error) {
out := make([]module.ConfigItem, 0, len(items)) overrides := map[report.ID][]string{}
for _, item := range items { if cfg.Reports == nil {
out = append(out, module.ConfigItem{ID: item.ID, Options: item.Options}) return overrides, nil
} }
return out 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) {

View File

@@ -12,6 +12,9 @@ func Validate(cfg Config) error {
if err := validateReportModules(cfg); err != nil { if err := validateReportModules(cfg); err != nil {
return err return err
} }
if err := validateReportDistributorPathOverrides(cfg); err != nil {
return err
}
if cfg.WeatherAPI.BaseURL != "" { if cfg.WeatherAPI.BaseURL != "" {
parsed, err := url.Parse(cfg.WeatherAPI.BaseURL) parsed, err := url.Parse(cfg.WeatherAPI.BaseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" { if err != nil || parsed.Scheme == "" || parsed.Host == "" {
@@ -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

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

@@ -1,10 +1,5 @@
package generatedtext package generatedtext
import (
"fmt"
"strings"
)
type Daily struct { type Daily struct {
Summary string `json:"summary"` Summary string `json:"summary"`
ForecastDiscussion []string `json:"forecast_discussion"` ForecastDiscussion []string `json:"forecast_discussion"`
@@ -13,25 +8,21 @@ type Daily struct {
} }
func ValidateDaily(data []byte) (Daily, []byte, error) { func ValidateDaily(data []byte) (Daily, []byte, error) {
value, err := decodeGeneratedText[Daily](data, "daily") return validateDayStyleGeneratedText[Daily, *Daily](data, "daily")
if err != nil { }
return Daily{}, nil, err
} func (d *Daily) dayStyleFields() dayStyleFields {
return dayStyleFields{
value.Summary = strings.TrimSpace(value.Summary) Summary: d.Summary,
value.PrecipitationTiming = strings.TrimSpace(value.PrecipitationTiming) ForecastDiscussion: d.ForecastDiscussion,
value.Confidence = strings.TrimSpace(value.Confidence) PrecipitationTiming: d.PrecipitationTiming,
value.ForecastDiscussion = trimNonEmpty(value.ForecastDiscussion) Confidence: d.Confidence,
if value.Summary == "" { }
return Daily{}, nil, fmt.Errorf("daily generated text summary is required") }
}
if len(value.ForecastDiscussion) == 0 { func (d *Daily) setDayStyleFields(fields dayStyleFields) {
return Daily{}, nil, fmt.Errorf("daily generated text forecast discussion is required") d.Summary = fields.Summary
} d.ForecastDiscussion = fields.ForecastDiscussion
d.PrecipitationTiming = fields.PrecipitationTiming
normalized, err := normalizeGeneratedText(value, "daily") d.Confidence = fields.Confidence
if err != nil {
return Daily{}, nil, err
}
return value, normalized, nil
} }

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

@@ -165,6 +165,31 @@ type DailyDaypartContext struct {
Summary briefing.DerivedDaypartSummaryModule 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) { func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Hourly, collected facts.CollectedFacts, derived facts.DerivedFacts) (HourlyRenderContext, error) {
location, err := timeutil.LoadLocation(metadata.Timezone) location, err := timeutil.LoadLocation(metadata.Timezone)
if err != nil { if err != nil {
@@ -198,33 +223,18 @@ func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapsh
} }
func BuildDailyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Daily, collected facts.CollectedFacts, derived facts.DerivedFacts) (DailyRenderContext, error) { func BuildDailyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Daily, collected facts.CollectedFacts, derived facts.DerivedFacts) (DailyRenderContext, error) {
location, err := timeutil.LoadLocation(metadata.Timezone) reportContext, err := buildDayStyleReportContext(metadata, "daily", func(dayName string) string {
return dayName + "'s Weather"
})
if err != nil { if err != nil {
return DailyRenderContext{}, fmt.Errorf("build daily render context: %w", err) return DailyRenderContext{}, err
}
if metadata.GeneratedAt.IsZero() {
return DailyRenderContext{}, fmt.Errorf("build daily render context: generatedAt is required")
}
if !metadata.ValidPeriod.IsValid() {
return DailyRenderContext{}, fmt.Errorf("build daily render context: valid period is required")
} }
modules, err := dailyTemplateModules(snapshot, derived) modules, err := dailyTemplateModules(snapshot, derived)
if err != nil { if err != nil {
return DailyRenderContext{}, err return DailyRenderContext{}, err
} }
forecastDate := metadata.ValidPeriod.Start.In(location)
forecastDayName := forecastDate.Format("Monday")
return DailyRenderContext{ return DailyRenderContext{
Report: DailyReportContext{ Report: reportContext.dailyReportContext(),
Title: forecastDayName + "'s Weather",
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,
},
GeneratedText: generated, GeneratedText: generated,
Modules: modules, Modules: modules,
Collected: collected, Collected: collected,
@@ -233,32 +243,18 @@ func BuildDailyRenderContext(metadata briefing.Metadata, snapshot module.Snapsho
} }
func BuildTodayRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Today, collected facts.CollectedFacts, derived facts.DerivedFacts) (TodayRenderContext, error) { func BuildTodayRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Today, collected facts.CollectedFacts, derived facts.DerivedFacts) (TodayRenderContext, error) {
location, err := timeutil.LoadLocation(metadata.Timezone) reportContext, err := buildDayStyleReportContext(metadata, "today", func(string) string {
return "Today's Weather"
})
if err != nil { if err != nil {
return TodayRenderContext{}, fmt.Errorf("build today render context: %w", err) return TodayRenderContext{}, err
}
if metadata.GeneratedAt.IsZero() {
return TodayRenderContext{}, fmt.Errorf("build today render context: generatedAt is required")
}
if !metadata.ValidPeriod.IsValid() {
return TodayRenderContext{}, fmt.Errorf("build today render context: valid period is required")
} }
modules, err := todayTemplateModules(snapshot, derived) modules, err := todayTemplateModules(snapshot, derived)
if err != nil { if err != nil {
return TodayRenderContext{}, err return TodayRenderContext{}, err
} }
forecastDate := metadata.ValidPeriod.Start.In(location)
return TodayRenderContext{ return TodayRenderContext{
Report: TodayReportContext{ Report: reportContext.todayReportContext(),
Title: "Today's Weather",
ForecastDate: forecastDate,
ForecastDateLabel: forecastDate.Format("Monday, January 2, 2006"),
ForecastDayName: forecastDate.Format("Monday"),
GeneratedAt: metadata.GeneratedAt,
GeneratedAtLabel: generatedAtLabel(metadata.GeneratedAt, location),
ValidPeriod: metadata.ValidPeriod,
Timezone: metadata.Timezone,
},
GeneratedText: generated, GeneratedText: generated,
Modules: modules, Modules: modules,
Collected: collected, Collected: collected,
@@ -267,33 +263,18 @@ func BuildTodayRenderContext(metadata briefing.Metadata, snapshot module.Snapsho
} }
func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Tomorrow, collected facts.CollectedFacts, derived facts.DerivedFacts) (TomorrowRenderContext, error) { func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Tomorrow, collected facts.CollectedFacts, derived facts.DerivedFacts) (TomorrowRenderContext, error) {
location, err := timeutil.LoadLocation(metadata.Timezone) reportContext, err := buildDayStyleReportContext(metadata, "tomorrow", func(dayName string) string {
return dayName + "'s Weather"
})
if err != nil { if err != nil {
return TomorrowRenderContext{}, fmt.Errorf("build tomorrow render context: %w", err) return TomorrowRenderContext{}, err
}
if metadata.GeneratedAt.IsZero() {
return TomorrowRenderContext{}, fmt.Errorf("build tomorrow render context: generatedAt is required")
}
if !metadata.ValidPeriod.IsValid() {
return TomorrowRenderContext{}, fmt.Errorf("build tomorrow render context: valid period is required")
} }
modules, err := tomorrowTemplateModules(snapshot, derived) modules, err := tomorrowTemplateModules(snapshot, derived)
if err != nil { if err != nil {
return TomorrowRenderContext{}, err return TomorrowRenderContext{}, err
} }
forecastDate := metadata.ValidPeriod.Start.In(location)
forecastDayName := forecastDate.Format("Monday")
return TomorrowRenderContext{ return TomorrowRenderContext{
Report: TomorrowReportContext{ Report: reportContext.tomorrowReportContext(),
Title: forecastDayName + "'s Weather",
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,
},
GeneratedText: generated, GeneratedText: generated,
Modules: modules, Modules: modules,
Collected: collected, Collected: collected,
@@ -301,49 +282,73 @@ func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snap
}, nil }, 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) { func dailyTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (DailyTemplateModules, error) {
lookup := newModuleSnapshotLookup(snapshot) common, lookup, err := dayStyleTemplateModuleSnapshot(snapshot)
metadata, err := lookup.metadata()
if err != nil {
return DailyTemplateModules{}, err
}
current, err := lookup.currentConditions()
if err != nil {
return DailyTemplateModules{}, err
}
hourly, err := lookup.hourlyForecast()
if err != nil {
return DailyTemplateModules{}, err
}
daily, err := lookup.derivedDailySummary()
if err != nil {
return DailyTemplateModules{}, err
}
dayparts, err := lookup.derivedDaypartSummaries()
if err != nil {
return DailyTemplateModules{}, err
}
precip, err := lookup.precipTiming()
if err != nil {
return DailyTemplateModules{}, err
}
alerts, err := lookup.alertDigest()
if err != nil {
return DailyTemplateModules{}, err
}
outlooks, err := lookup.spcConvectiveOutlooks()
if err != nil {
return DailyTemplateModules{}, err
}
discussion, err := lookup.areaForecastDiscussion()
if err != nil {
return DailyTemplateModules{}, err
}
spcDiscussion, err := lookup.spcConvectiveDiscussion()
if err != nil {
return DailyTemplateModules{}, err
}
story, err := lookup.weatherStory()
if err != nil { if err != nil {
return DailyTemplateModules{}, err return DailyTemplateModules{}, err
} }
@@ -356,66 +361,25 @@ func dailyTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts)
return DailyTemplateModules{}, err return DailyTemplateModules{}, err
} }
return DailyTemplateModules{ return DailyTemplateModules{
Metadata: metadata, Metadata: common.Metadata,
CurrentConditions: current, CurrentConditions: common.CurrentConditions,
HourlyForecast: hourly, HourlyForecast: common.HourlyForecast,
DerivedDailySummary: daily, DerivedDailySummary: common.DerivedDailySummary,
DerivedDaypartSummaries: dayparts, DerivedDaypartSummaries: common.DerivedDaypartSummaries,
Dayparts: orderedDailyDayparts(dayparts, derived.DaypartSummaries), Dayparts: orderedDailyDayparts(common.DerivedDaypartSummaries, derived.DaypartSummaries),
PrecipTiming: precip, PrecipTiming: common.PrecipTiming,
AlertDigest: alerts, AlertDigest: common.AlertDigest,
SPCConvectiveOutlooks: outlooks, SPCConvectiveOutlooks: common.SPCConvectiveOutlooks,
AreaForecastDiscussion: discussion, AreaForecastDiscussion: common.AreaForecastDiscussion,
SPCConvectiveDiscussion: spcDiscussion, SPCConvectiveDiscussion: common.SPCConvectiveDiscussion,
WeatherStory: story, WeatherStory: common.WeatherStory,
OutdoorWindows: outdoor, OutdoorWindows: outdoor,
DailyPlanning: planning, DailyPlanning: planning,
}, nil }, nil
} }
func todayTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TodayTemplateModules, error) { func todayTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TodayTemplateModules, error) {
lookup := newModuleSnapshotLookup(snapshot) common, lookup, err := dayStyleTemplateModuleSnapshot(snapshot)
metadata, err := lookup.metadata()
if err != nil {
return TodayTemplateModules{}, err
}
current, err := lookup.currentConditions()
if err != nil {
return TodayTemplateModules{}, err
}
hourly, err := lookup.hourlyForecast()
if err != nil {
return TodayTemplateModules{}, err
}
daily, err := lookup.derivedDailySummary()
if err != nil {
return TodayTemplateModules{}, err
}
dayparts, err := lookup.derivedDaypartSummaries()
if err != nil {
return TodayTemplateModules{}, err
}
precip, err := lookup.precipTiming()
if err != nil {
return TodayTemplateModules{}, err
}
alerts, err := lookup.alertDigest()
if err != nil {
return TodayTemplateModules{}, err
}
outlooks, err := lookup.spcConvectiveOutlooks()
if err != nil {
return TodayTemplateModules{}, err
}
discussion, err := lookup.areaForecastDiscussion()
if err != nil {
return TodayTemplateModules{}, err
}
spcDiscussion, err := lookup.spcConvectiveDiscussion()
if err != nil {
return TodayTemplateModules{}, err
}
story, err := lookup.weatherStory()
if err != nil { if err != nil {
return TodayTemplateModules{}, err return TodayTemplateModules{}, err
} }
@@ -424,18 +388,18 @@ func todayTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts)
return TodayTemplateModules{}, err return TodayTemplateModules{}, err
} }
return TodayTemplateModules{ return TodayTemplateModules{
Metadata: metadata, Metadata: common.Metadata,
CurrentConditions: current, CurrentConditions: common.CurrentConditions,
HourlyForecast: hourly, HourlyForecast: common.HourlyForecast,
DerivedDailySummary: daily, DerivedDailySummary: common.DerivedDailySummary,
DerivedDaypartSummaries: dayparts, DerivedDaypartSummaries: common.DerivedDaypartSummaries,
Dayparts: orderedTodayDayparts(dayparts, derived.DaypartSummaries), Dayparts: orderedTodayDayparts(common.DerivedDaypartSummaries, derived.DaypartSummaries),
PrecipTiming: precip, PrecipTiming: common.PrecipTiming,
AlertDigest: alerts, AlertDigest: common.AlertDigest,
SPCConvectiveOutlooks: outlooks, SPCConvectiveOutlooks: common.SPCConvectiveOutlooks,
AreaForecastDiscussion: discussion, AreaForecastDiscussion: common.AreaForecastDiscussion,
SPCConvectiveDiscussion: spcDiscussion, SPCConvectiveDiscussion: common.SPCConvectiveDiscussion,
WeatherStory: story, WeatherStory: common.WeatherStory,
TodayPlanning: planning, TodayPlanning: planning,
}, nil }, nil
} }
@@ -492,48 +456,7 @@ func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, err
} }
func tomorrowTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TomorrowTemplateModules, error) { func tomorrowTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TomorrowTemplateModules, error) {
lookup := newModuleSnapshotLookup(snapshot) common, lookup, err := dayStyleTemplateModuleSnapshot(snapshot)
metadata, err := lookup.metadata()
if err != nil {
return TomorrowTemplateModules{}, err
}
current, err := lookup.currentConditions()
if err != nil {
return TomorrowTemplateModules{}, err
}
hourly, err := lookup.hourlyForecast()
if err != nil {
return TomorrowTemplateModules{}, err
}
daily, err := lookup.derivedDailySummary()
if err != nil {
return TomorrowTemplateModules{}, err
}
dayparts, err := lookup.derivedDaypartSummaries()
if err != nil {
return TomorrowTemplateModules{}, err
}
precip, err := lookup.precipTiming()
if err != nil {
return TomorrowTemplateModules{}, err
}
alerts, err := lookup.alertDigest()
if err != nil {
return TomorrowTemplateModules{}, err
}
outlooks, err := lookup.spcConvectiveOutlooks()
if err != nil {
return TomorrowTemplateModules{}, err
}
discussion, err := lookup.areaForecastDiscussion()
if err != nil {
return TomorrowTemplateModules{}, err
}
spcDiscussion, err := lookup.spcConvectiveDiscussion()
if err != nil {
return TomorrowTemplateModules{}, err
}
story, err := lookup.weatherStory()
if err != nil { if err != nil {
return TomorrowTemplateModules{}, err return TomorrowTemplateModules{}, err
} }
@@ -542,20 +465,81 @@ func tomorrowTemplateModules(snapshot module.Snapshot, derived facts.DerivedFact
return TomorrowTemplateModules{}, err return TomorrowTemplateModules{}, err
} }
return TomorrowTemplateModules{ 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, Metadata: metadata,
CurrentConditions: current, CurrentConditions: current,
HourlyForecast: hourly, HourlyForecast: hourly,
DerivedDailySummary: daily, DerivedDailySummary: daily,
DerivedDaypartSummaries: dayparts, DerivedDaypartSummaries: dayparts,
Dayparts: orderedTomorrowDayparts(dayparts, derived.DaypartSummaries),
PrecipTiming: precip, PrecipTiming: precip,
AlertDigest: alerts, AlertDigest: alerts,
SPCConvectiveOutlooks: outlooks, SPCConvectiveOutlooks: outlooks,
AreaForecastDiscussion: discussion, AreaForecastDiscussion: discussion,
SPCConvectiveDiscussion: spcDiscussion, SPCConvectiveDiscussion: spcDiscussion,
WeatherStory: story, WeatherStory: story,
TomorrowPlanning: planning, }, lookup, nil
}, nil
} }
type moduleSnapshotLookup struct { type moduleSnapshotLookup struct {

View File

@@ -91,7 +91,7 @@ func TestBuildHourlyRenderContext(t *testing.T) {
"**Updated:** Friday, May 29, 2026 at 8:30 AM", "**Updated:** Friday, May 29, 2026 at 8:30 AM",
"Storm chances increase through late morning.", "Storm chances increase through late morning.",
"- **10:00 AM:** 75°F and showers. Probability of precipitation is 70%.", "- **10:00 AM:** 75°F and showers. Probability of precipitation is 70%.",
"- **Flood Watch**: Flood Watch until early afternoon", "- **Flood Watch**: Flood Watch in effect from May 29 at 10:00 AM to May 29 at 2:30 PM. Avoid low-water crossings.",
"A cold front is moving into the region.", "A cold front is moving into the region.",
"A front will keep the region unsettled.", "A front will keep the region unsettled.",
} { } {
@@ -219,7 +219,7 @@ func TestBuildTodayRenderContext(t *testing.T) {
"- **Morning:** Partly cloudy, with temperatures in the low 60s.", "- **Morning:** Partly cloudy, with temperatures in the low 60s.",
"- **Afternoon:** Showers, with temperatures in the mid 70s. Chance of precipitation is 70%.", "- **Afternoon:** Showers, with temperatures in the mid 70s. Chance of precipitation is 70%.",
"## Precipitation Timing", "## Precipitation Timing",
"- **3:00 PM** to **6:00 PM**: Precipitation is expected during this period. The peak precipitation chance is 70% at 3:00 PM.", "- **3:00 PM** to **6:00 PM**: Expect showers. The peak precipitation chance is 70% at 3:00 PM.",
"The most likely rain window is from midafternoon into early evening.", "The most likely rain window is from midafternoon into early evening.",
"Morning conditions should stay mostly dry.", "Morning conditions should stay mostly dry.",
"Rain chances increase during the afternoon as deeper moisture arrives.", "Rain chances increase during the afternoon as deeper moisture arrives.",
@@ -272,6 +272,192 @@ func TestBuildTodayRenderContextAllowsOmittedOptionalModules(t *testing.T) {
} }
} }
func TestBuildDayStyleRenderContextsPopulateSharedFieldsAndPlanningModules(t *testing.T) {
type result struct {
title string
forecastDateLabel string
generatedAtLabel string
timezone string
validPeriod timeutil.Period
metadata bool
current bool
hourly bool
dailySummary bool
daypartSummaries bool
dayparts int
precip bool
alerts bool
outlooks bool
discussion bool
spcDiscussion bool
story bool
planning bool
collected bool
derived bool
}
tests := []struct {
name string
wantTitle string
wantGeneratedLabel string
wantDayparts int
build func(t *testing.T) result
}{
{
name: "daily",
wantTitle: "Monday's Weather",
wantGeneratedLabel: "Saturday, June 13, 2026 at 9:14 AM",
wantDayparts: 3,
build: func(t *testing.T) result {
t.Helper()
collected := testCollected()
derived := testDailyDerived()
ctx, err := BuildDailyRenderContext(testDailyMetadata(), testDailySnapshot(t), Daily{
Summary: "Daily summary.",
ForecastDiscussion: []string{"Daily discussion."},
}, collected, derived)
if err != nil {
t.Fatalf("BuildDailyRenderContext() error = %v", err)
}
return result{
title: ctx.Report.Title,
forecastDateLabel: ctx.Report.ForecastDateLabel,
generatedAtLabel: ctx.Report.GeneratedAtLabel,
timezone: ctx.Report.Timezone,
validPeriod: ctx.Report.ValidPeriod,
metadata: ctx.Modules.Metadata != nil && ctx.Modules.Metadata.ReportID == report.Daily,
current: ctx.Modules.CurrentConditions != nil,
hourly: ctx.Modules.HourlyForecast != nil,
dailySummary: ctx.Modules.DerivedDailySummary != nil,
daypartSummaries: ctx.Modules.DerivedDaypartSummaries != nil,
dayparts: len(ctx.Modules.Dayparts),
precip: ctx.Modules.PrecipTiming != nil,
alerts: ctx.Modules.AlertDigest != nil,
outlooks: ctx.Modules.SPCConvectiveOutlooks != nil,
discussion: ctx.Modules.AreaForecastDiscussion != nil,
spcDiscussion: ctx.Modules.SPCConvectiveDiscussion != nil,
story: ctx.Modules.WeatherStory != nil,
planning: ctx.Modules.DailyPlanning != nil,
collected: ctx.Collected.FetchedAt.Equal(collected.FetchedAt),
derived: len(ctx.Derived.DaypartSummaries) == len(derived.DaypartSummaries),
}
},
},
{
name: "today",
wantTitle: "Today's Weather",
wantGeneratedLabel: "Monday, June 15, 2026 at 7:14 AM",
wantDayparts: 2,
build: func(t *testing.T) result {
t.Helper()
collected := testCollected()
derived := testTodayDerived()
ctx, err := BuildTodayRenderContext(testTodayMetadata(), testTodaySnapshot(t), Today{
Summary: "Today summary.",
ForecastDiscussion: []string{"Today discussion."},
}, collected, derived)
if err != nil {
t.Fatalf("BuildTodayRenderContext() error = %v", err)
}
return result{
title: ctx.Report.Title,
forecastDateLabel: ctx.Report.ForecastDateLabel,
generatedAtLabel: ctx.Report.GeneratedAtLabel,
timezone: ctx.Report.Timezone,
validPeriod: ctx.Report.ValidPeriod,
metadata: ctx.Modules.Metadata != nil && ctx.Modules.Metadata.ReportID == report.Today,
current: ctx.Modules.CurrentConditions != nil,
hourly: ctx.Modules.HourlyForecast != nil,
dailySummary: ctx.Modules.DerivedDailySummary != nil,
daypartSummaries: ctx.Modules.DerivedDaypartSummaries != nil,
dayparts: len(ctx.Modules.Dayparts),
precip: ctx.Modules.PrecipTiming != nil,
alerts: ctx.Modules.AlertDigest != nil,
outlooks: ctx.Modules.SPCConvectiveOutlooks != nil,
discussion: ctx.Modules.AreaForecastDiscussion != nil,
spcDiscussion: ctx.Modules.SPCConvectiveDiscussion != nil,
story: ctx.Modules.WeatherStory != nil,
planning: ctx.Modules.TodayPlanning != nil,
collected: ctx.Collected.FetchedAt.Equal(collected.FetchedAt),
derived: len(ctx.Derived.DaypartSummaries) == len(derived.DaypartSummaries),
}
},
},
{
name: "tomorrow",
wantTitle: "Monday's Weather",
wantGeneratedLabel: "Sunday, June 14, 2026 at 9:14 AM",
wantDayparts: 2,
build: func(t *testing.T) result {
t.Helper()
collected := testCollected()
derived := testTomorrowDerived()
ctx, err := BuildTomorrowRenderContext(testTomorrowMetadata(), testTomorrowSnapshot(t), Tomorrow{
Summary: "Tomorrow summary.",
ForecastDiscussion: []string{"Tomorrow discussion."},
}, collected, derived)
if err != nil {
t.Fatalf("BuildTomorrowRenderContext() error = %v", err)
}
return result{
title: ctx.Report.Title,
forecastDateLabel: ctx.Report.ForecastDateLabel,
generatedAtLabel: ctx.Report.GeneratedAtLabel,
timezone: ctx.Report.Timezone,
validPeriod: ctx.Report.ValidPeriod,
metadata: ctx.Modules.Metadata != nil && ctx.Modules.Metadata.ReportID == report.Tomorrow,
current: ctx.Modules.CurrentConditions != nil,
hourly: ctx.Modules.HourlyForecast != nil,
dailySummary: ctx.Modules.DerivedDailySummary != nil,
daypartSummaries: ctx.Modules.DerivedDaypartSummaries != nil,
dayparts: len(ctx.Modules.Dayparts),
precip: ctx.Modules.PrecipTiming != nil,
alerts: ctx.Modules.AlertDigest != nil,
outlooks: ctx.Modules.SPCConvectiveOutlooks != nil,
discussion: ctx.Modules.AreaForecastDiscussion != nil,
spcDiscussion: ctx.Modules.SPCConvectiveDiscussion != nil,
story: ctx.Modules.WeatherStory != nil,
planning: ctx.Modules.TomorrowPlanning != nil,
collected: ctx.Collected.FetchedAt.Equal(collected.FetchedAt),
derived: len(ctx.Derived.DaypartSummaries) == len(derived.DaypartSummaries),
}
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result := test.build(t)
if result.title != test.wantTitle {
t.Fatalf("title = %q, want %q", result.title, test.wantTitle)
}
if result.forecastDateLabel != "Monday, June 15, 2026" {
t.Fatalf("forecastDateLabel = %q, want Monday, June 15, 2026", result.forecastDateLabel)
}
if result.generatedAtLabel != test.wantGeneratedLabel {
t.Fatalf("generatedAtLabel = %q, want %q", result.generatedAtLabel, test.wantGeneratedLabel)
}
if result.timezone != "America/Chicago" {
t.Fatalf("timezone = %q, want America/Chicago", result.timezone)
}
if !result.validPeriod.IsValid() {
t.Fatalf("validPeriod = %#v, want valid period", result.validPeriod)
}
if !result.metadata || !result.current || !result.hourly || !result.dailySummary || !result.daypartSummaries || !result.precip || !result.alerts || !result.outlooks || !result.discussion || !result.spcDiscussion || !result.story {
t.Fatalf("common modules = %#v, want all shared modules populated", result)
}
if result.dayparts != test.wantDayparts {
t.Fatalf("dayparts = %d, want %d", result.dayparts, test.wantDayparts)
}
if !result.planning {
t.Fatalf("planning = false, want %s planning module populated", test.name)
}
if !result.collected || !result.derived {
t.Fatalf("facts passthrough = collected:%t derived:%t, want both true", result.collected, result.derived)
}
})
}
}
func TestBuildDailyRenderContext(t *testing.T) { func TestBuildDailyRenderContext(t *testing.T) {
metadata := testDailyMetadata() metadata := testDailyMetadata()
snapshot := testDailySnapshot(t) snapshot := testDailySnapshot(t)
@@ -356,7 +542,7 @@ func TestBuildDailyRenderContext(t *testing.T) {
"- **Afternoon:** Showers, with temperatures in the mid 70s. Chance of precipitation is 70%.", "- **Afternoon:** Showers, with temperatures in the mid 70s. Chance of precipitation is 70%.",
"- **Evening:** Mostly cloudy, with temperatures in the upper 60s.", "- **Evening:** Mostly cloudy, with temperatures in the upper 60s.",
"## Precipitation Timing", "## Precipitation Timing",
"- **3:00 PM** to **6:00 PM**: Precipitation is expected during this period. The peak precipitation chance is 70% at 3:00 PM.", "- **3:00 PM** to **6:00 PM**: Expect showers. The peak precipitation chance is 70% at 3:00 PM.",
"The most likely rain window is from midafternoon into early evening.", "The most likely rain window is from midafternoon into early evening.",
"Morning conditions should stay mostly dry.", "Morning conditions should stay mostly dry.",
"Rain chances increase during the afternoon as deeper moisture arrives.", "Rain chances increase during the afternoon as deeper moisture arrives.",
@@ -477,7 +663,7 @@ func TestBuildTomorrowRenderContext(t *testing.T) {
"- **Morning:** Partly cloudy, with temperatures in the low 60s.", "- **Morning:** Partly cloudy, with temperatures in the low 60s.",
"- **Afternoon:** Showers, with temperatures in the mid 70s. Chance of precipitation is 70%.", "- **Afternoon:** Showers, with temperatures in the mid 70s. Chance of precipitation is 70%.",
"## Precipitation Timing", "## Precipitation Timing",
"- **3:00 PM** to **6:00 PM**: Precipitation is expected during this period. The peak precipitation chance is 70% at 3:00 PM.", "- **3:00 PM** to **6:00 PM**: Expect showers. The peak precipitation chance is 70% at 3:00 PM.",
"The most likely rain window is from midafternoon into early evening.", "The most likely rain window is from midafternoon into early evening.",
"Morning conditions should stay mostly dry.", "Morning conditions should stay mostly dry.",
"Rain chances increase during the afternoon as deeper moisture arrives.", "Rain chances increase during the afternoon as deeper moisture arrives.",
@@ -724,7 +910,7 @@ func testTodaySnapshot(t *testing.T) module.Snapshot {
MaxPopPercent: intPtr(70), MaxPopPercent: intPtr(70),
MaxPopTime: "3 PM", MaxPopTime: "3 PM",
PrecipitationWindows: []briefing.PrecipitationWindowModule{ PrecipitationWindows: []briefing.PrecipitationWindowModule{
{PeriodBeginsHourLabel: "3:00 PM", PeriodEndsHourLabel: "6:00 PM", MaxPopPercent: intPtr(70), MaxPopHourLabel: "3:00 PM"}, {PeriodBeginsHourLabel: "3:00 PM", PeriodEndsHourLabel: "6:00 PM", MaxPopPercent: intPtr(70), MaxPopHourLabel: "3:00 PM", PrecipitationType: "showers", ExpectationPhrase: "Expect showers."},
}, },
}, },
}, },
@@ -859,7 +1045,7 @@ func testDailySnapshot(t *testing.T) module.Snapshot {
MaxPopPercent: intPtr(70), MaxPopPercent: intPtr(70),
MaxPopTime: "3 PM", MaxPopTime: "3 PM",
PrecipitationWindows: []briefing.PrecipitationWindowModule{ PrecipitationWindows: []briefing.PrecipitationWindowModule{
{PeriodBeginsHourLabel: "3:00 PM", PeriodEndsHourLabel: "6:00 PM", MaxPopPercent: intPtr(70), MaxPopHourLabel: "3:00 PM"}, {PeriodBeginsHourLabel: "3:00 PM", PeriodEndsHourLabel: "6:00 PM", MaxPopPercent: intPtr(70), MaxPopHourLabel: "3:00 PM", PrecipitationType: "showers", ExpectationPhrase: "Expect showers."},
}, },
}, },
}, },
@@ -1004,7 +1190,7 @@ func testSnapshot(t *testing.T) module.Snapshot {
ActiveCount: 1, ActiveCount: 1,
RelevantCount: 1, RelevantCount: 1,
Relevant: []briefing.AlertSummary{ Relevant: []briefing.AlertSummary{
{Event: "Flood Watch", Headline: "Flood Watch until early afternoon", Severity: "Moderate"}, {Event: "Flood Watch", Headline: "Flood Watch until early afternoon", Severity: "Moderate", PeriodBegins: "May 29 at 10:00 AM", PeriodEnds: "May 29 at 2:30 PM", Instruction: "Avoid low-water crossings."},
}, },
}, },
}, },
@@ -1131,7 +1317,7 @@ func testTomorrowSnapshot(t *testing.T) module.Snapshot {
MaxPopPercent: intPtr(70), MaxPopPercent: intPtr(70),
MaxPopTime: "3 PM", MaxPopTime: "3 PM",
PrecipitationWindows: []briefing.PrecipitationWindowModule{ PrecipitationWindows: []briefing.PrecipitationWindowModule{
{PeriodBeginsHourLabel: "3:00 PM", PeriodEndsHourLabel: "6:00 PM", MaxPopPercent: intPtr(70), MaxPopHourLabel: "3:00 PM"}, {PeriodBeginsHourLabel: "3:00 PM", PeriodEndsHourLabel: "6:00 PM", MaxPopPercent: intPtr(70), MaxPopHourLabel: "3:00 PM", PrecipitationType: "showers", ExpectationPhrase: "Expect showers."},
}, },
}, },
}, },

View File

@@ -1,10 +1,5 @@
package generatedtext package generatedtext
import (
"fmt"
"strings"
)
type Today struct { type Today struct {
Summary string `json:"summary"` Summary string `json:"summary"`
ForecastDiscussion []string `json:"forecast_discussion"` ForecastDiscussion []string `json:"forecast_discussion"`
@@ -13,25 +8,21 @@ type Today struct {
} }
func ValidateToday(data []byte) (Today, []byte, error) { func ValidateToday(data []byte) (Today, []byte, error) {
value, err := decodeGeneratedText[Today](data, "today") return validateDayStyleGeneratedText[Today, *Today](data, "today")
if err != nil { }
return Today{}, nil, err
} func (t *Today) dayStyleFields() dayStyleFields {
return dayStyleFields{
value.Summary = strings.TrimSpace(value.Summary) Summary: t.Summary,
value.PrecipitationTiming = strings.TrimSpace(value.PrecipitationTiming) ForecastDiscussion: t.ForecastDiscussion,
value.Confidence = strings.TrimSpace(value.Confidence) PrecipitationTiming: t.PrecipitationTiming,
value.ForecastDiscussion = trimNonEmpty(value.ForecastDiscussion) Confidence: t.Confidence,
if value.Summary == "" { }
return Today{}, nil, fmt.Errorf("today generated text summary is required") }
}
if len(value.ForecastDiscussion) == 0 { func (t *Today) setDayStyleFields(fields dayStyleFields) {
return Today{}, nil, fmt.Errorf("today generated text forecast discussion is required") t.Summary = fields.Summary
} t.ForecastDiscussion = fields.ForecastDiscussion
t.PrecipitationTiming = fields.PrecipitationTiming
normalized, err := normalizeGeneratedText(value, "today") t.Confidence = fields.Confidence
if err != nil {
return Today{}, nil, err
}
return value, normalized, nil
} }

View File

@@ -1,10 +1,5 @@
package generatedtext package generatedtext
import (
"fmt"
"strings"
)
type Tomorrow struct { type Tomorrow struct {
Summary string `json:"summary"` Summary string `json:"summary"`
ForecastDiscussion []string `json:"forecast_discussion"` ForecastDiscussion []string `json:"forecast_discussion"`
@@ -13,36 +8,21 @@ type Tomorrow struct {
} }
func ValidateTomorrow(data []byte) (Tomorrow, []byte, error) { func ValidateTomorrow(data []byte) (Tomorrow, []byte, error) {
value, err := decodeGeneratedText[Tomorrow](data, "tomorrow") return validateDayStyleGeneratedText[Tomorrow, *Tomorrow](data, "tomorrow")
if err != nil {
return Tomorrow{}, nil, err
}
value.Summary = strings.TrimSpace(value.Summary)
value.PrecipitationTiming = strings.TrimSpace(value.PrecipitationTiming)
value.Confidence = strings.TrimSpace(value.Confidence)
value.ForecastDiscussion = trimNonEmpty(value.ForecastDiscussion)
if value.Summary == "" {
return Tomorrow{}, nil, fmt.Errorf("tomorrow generated text summary is required")
}
if len(value.ForecastDiscussion) == 0 {
return Tomorrow{}, nil, fmt.Errorf("tomorrow generated text forecast discussion is required")
}
normalized, err := normalizeGeneratedText(value, "tomorrow")
if err != nil {
return Tomorrow{}, nil, err
}
return value, normalized, nil
} }
func trimNonEmpty(values []string) []string { func (t *Tomorrow) dayStyleFields() dayStyleFields {
out := make([]string, 0, len(values)) return dayStyleFields{
for _, value := range values { Summary: t.Summary,
trimmed := strings.TrimSpace(value) ForecastDiscussion: t.ForecastDiscussion,
if trimmed != "" { PrecipitationTiming: t.PrecipitationTiming,
out = append(out, trimmed) Confidence: t.Confidence,
}
} }
return out }
func (t *Tomorrow) setDayStyleFields(fields dayStyleFields) {
t.Summary = fields.Summary
t.ForecastDiscussion = fields.ForecastDiscussion
t.PrecipitationTiming = fields.PrecipitationTiming
t.Confidence = fields.Confidence
} }

View File

@@ -18,15 +18,20 @@ func dailyDefinition() Definition {
ComparisonStrategy: CompareSameValidDate, ComparisonStrategy: CompareSameValidDate,
ArtifactGroup: "daily", ArtifactGroup: "daily",
BatchOutputName: "daily.md", BatchOutputName: "daily.md",
Generated: true, DistributorPathTemplates: []string{
CompatiblePriorIDs: []ID{Daily}, "daily/{valid_start_date}/{run_id}.md",
Modules: dailyModules(), "daily/{valid_start_date}/index.md",
resolve: resolveDaily, },
Generated: true,
CompatiblePriorIDs: []ID{Daily},
Modules: dailyModules(),
resolve: resolveDaily,
runIDDisambiguator: validStartDateRunIDDisambiguator,
} }
} }
func dailyModules() []module.ConfigItem { func dailyModules() []module.ConfigItem {
return moduleItems( items := moduleItems(
module.Metadata, module.Metadata,
module.CurrentConditions, module.CurrentConditions,
module.NarrativeForecast, module.NarrativeForecast,
@@ -35,13 +40,16 @@ func dailyModules() []module.ConfigItem {
module.PrecipTiming, module.PrecipTiming,
module.AlertDigest, module.AlertDigest,
module.SPCConvectiveOutlooks, module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion, )
items = append(items, areaForecastDiscussionItem("long_term"))
items = append(items, moduleItems(
module.SPCConvectiveDiscussion, module.SPCConvectiveDiscussion,
module.WeatherStory, module.WeatherStory,
module.OutdoorWindows, module.OutdoorWindows,
module.DailyPlanning, module.DailyPlanning,
module.HourlyForecast, module.HourlyForecast,
) )...)
return items
} }
func resolveDaily(req ResolveRequest) (timeutil.Period, error) { func resolveDaily(req ResolveRequest) (timeutil.Period, error) {
@@ -50,3 +58,14 @@ func resolveDaily(req ResolveRequest) (timeutil.Period, error) {
} }
return timeutil.CivilDay(req.Date, req.Location), nil return timeutil.CivilDay(req.Date, req.Location), nil
} }
func validStartDateRunIDDisambiguator(resolved Resolved) string {
if resolved.ValidPeriod.Start.IsZero() {
return ""
}
location, err := timeutil.LoadLocation(resolved.Timezone)
if err != nil {
return resolved.ValidPeriod.Start.Format(timeutil.DateLayout)
}
return resolved.ValidPeriod.Start.In(location).Format(timeutil.DateLayout)
}

View File

@@ -3,6 +3,7 @@ package report
import ( import (
"fmt" "fmt"
"strings"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
@@ -45,21 +46,23 @@ const (
) )
type Definition struct { type Definition struct {
ID ID ID ID
Name string Name string
PromptID string PromptID string
GenerationMode GenerationMode GenerationMode GenerationMode
TemplateID string TemplateID string
GeneratedTextSchemaID string GeneratedTextSchemaID string
ComparisonStrategy ComparisonStrategy ComparisonStrategy ComparisonStrategy
ArtifactGroup string ArtifactGroup string
BatchOutputName string BatchOutputName string
Generated bool DistributorPathTemplates []string
CompatiblePriorIDs []ID Generated bool
Modules []module.ConfigItem CompatiblePriorIDs []ID
Morning bool Modules []module.ConfigItem
Evening bool Morning bool
resolve func(ResolveRequest) (timeutil.Period, error) Evening bool
resolve func(ResolveRequest) (timeutil.Period, error)
runIDDisambiguator func(Resolved) string
} }
func (d Definition) ResolvePeriod(req ResolveRequest) (timeutil.Period, error) { func (d Definition) ResolvePeriod(req ResolveRequest) (timeutil.Period, error) {
@@ -112,7 +115,7 @@ type Metadata struct {
func (r Resolved) Metadata() Metadata { func (r Resolved) Metadata() Metadata {
return Metadata{ return Metadata{
RunID: r.GeneratedAt.UTC().Format("20060102T150405.000000000Z") + "_" + string(r.Definition.ID), RunID: r.runID(),
ReportID: r.Definition.ID, ReportID: r.Definition.ID,
PromptID: r.Definition.PromptID, PromptID: r.Definition.PromptID,
GeneratedAt: r.GeneratedAt, GeneratedAt: r.GeneratedAt,
@@ -120,3 +123,15 @@ func (r Resolved) Metadata() Metadata {
ValidPeriod: r.ValidPeriod, ValidPeriod: r.ValidPeriod,
} }
} }
func (r Resolved) runID() string {
runID := r.GeneratedAt.UTC().Format("20060102T150405.000000000Z") + "_" + string(r.Definition.ID)
if r.Definition.runIDDisambiguator == nil {
return runID
}
disambiguator := strings.TrimSpace(r.Definition.runIDDisambiguator(r))
if disambiguator == "" {
return runID
}
return runID + "_" + disambiguator
}

View File

@@ -20,10 +20,13 @@ func hourlyDefinition() Definition {
ComparisonStrategy: CompareRollingWindow, ComparisonStrategy: CompareRollingWindow,
ArtifactGroup: "hourly", ArtifactGroup: "hourly",
BatchOutputName: "hourly.md", BatchOutputName: "hourly.md",
Generated: true, DistributorPathTemplates: []string{
CompatiblePriorIDs: []ID{Hourly}, "hourly/index.md",
Modules: hourlyModules(), },
resolve: resolveHourly, Generated: true,
CompatiblePriorIDs: []ID{Hourly},
Modules: hourlyModules(),
resolve: resolveHourly,
} }
} }
@@ -35,12 +38,7 @@ func hourlyModules() []module.ConfigItem {
{ID: module.PrecipTiming}, {ID: module.PrecipTiming},
{ID: module.AlertDigest}, {ID: module.AlertDigest},
{ID: module.SPCConvectiveOutlooks}, {ID: module.SPCConvectiveOutlooks},
{ areaForecastDiscussionItem("key_messages", "short_term"),
ID: module.AreaForecastDiscussion,
Options: module.AreaForecastDiscussionOptions{
Sections: []string{"key_messages", "short_term"},
},
},
{ID: module.SPCConvectiveDiscussion}, {ID: module.SPCConvectiveDiscussion},
{ID: module.WeatherStory}, {ID: module.WeatherStory},
} }

View File

@@ -1,9 +1,6 @@
package report package report
import ( import "time"
"fmt"
"time"
)
func Resolve(id ID, req ResolveRequest) (Resolved, error) { func Resolve(id ID, req ResolveRequest) (Resolved, error) {
return DefaultRegistry().Resolve(id, req) return DefaultRegistry().Resolve(id, req)
@@ -17,39 +14,6 @@ func (r Registry) Resolve(id ID, req ResolveRequest) (Resolved, error) {
return r.resolveDefinition(definition, req) return r.resolveDefinition(definition, req)
} }
func (r Registry) BatchReports(batch Batch, req ResolveRequest) ([]Resolved, error) {
if req.Location == nil {
req.Location = time.UTC
}
if req.Now.IsZero() {
req.Now = time.Now()
}
switch batch {
case Morning:
ids := []ID{Today, ThreeDay}
if req.Now.In(req.Location).Weekday() != time.Sunday {
ids = append(ids, Weekend)
}
return r.resolveIDs(ids, req)
case Evening:
return r.resolveIDs([]ID{Tomorrow}, req)
default:
return nil, fmt.Errorf("unknown batch %q", batch)
}
}
func (r Registry) resolveIDs(ids []ID, req ResolveRequest) ([]Resolved, error) {
resolved := make([]Resolved, 0, len(ids))
for _, id := range ids {
item, err := r.Resolve(id, req)
if err != nil {
return nil, err
}
resolved = append(resolved, item)
}
return resolved, nil
}
func (r Registry) resolveDefinition(definition Definition, req ResolveRequest) (Resolved, error) { func (r Registry) resolveDefinition(definition Definition, req ResolveRequest) (Resolved, error) {
if req.Location == nil { if req.Location == nil {
req.Location = time.UTC req.Location = time.UTC

View File

@@ -241,55 +241,6 @@ func TestStormResolve(t *testing.T) {
} }
} }
func TestMorningBatchSkipsWeekendOnSunday(t *testing.T) {
location := mustLoadLocation(t)
resolved, err := DefaultRegistry().BatchReports(Morning, ResolveRequest{
Now: mustParse("2026-05-31T06:00:00-05:00"),
Location: location,
})
if err != nil {
t.Fatalf("BatchReports() error = %v", err)
}
ids := resolvedIDs(resolved)
if strings.Join(ids, ",") != "today,three_day" {
t.Fatalf("ids = %v, want today and three_day", ids)
}
}
func TestEveningBatchIncludesTomorrow(t *testing.T) {
location := mustLoadLocation(t)
resolved, err := DefaultRegistry().BatchReports(Evening, ResolveRequest{
Now: mustParse("2026-05-29T18:00:00-05:00"),
Location: location,
})
if err != nil {
t.Fatalf("BatchReports() error = %v", err)
}
ids := resolvedIDs(resolved)
if strings.Join(ids, ",") != "tomorrow" {
t.Fatalf("ids = %v, want tomorrow", ids)
}
}
func TestBatchesDoNotIncludeManualReports(t *testing.T) {
location := mustLoadLocation(t)
req := ResolveRequest{Now: mustParse("2026-05-29T06:00:00-05:00"), Location: location}
morning, err := DefaultRegistry().BatchReports(Morning, req)
if err != nil {
t.Fatalf("BatchReports(morning) error = %v", err)
}
evening, err := DefaultRegistry().BatchReports(Evening, req)
if err != nil {
t.Fatalf("BatchReports(evening) error = %v", err)
}
for _, resolved := range append(morning, evening...) {
if resolved.Definition.ID == Hourly || resolved.Definition.ID == Daily {
t.Fatalf("batch included %q, want manual reports excluded", resolved.Definition.ID)
}
}
}
func TestIDForCommandName(t *testing.T) { func TestIDForCommandName(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -409,21 +360,6 @@ func TestBatchForCommandName(t *testing.T) {
} }
} }
func TestMorningBatchReportOrder(t *testing.T) {
location := mustLoadLocation(t)
resolved, err := DefaultRegistry().BatchReports(Morning, ResolveRequest{
Now: mustParse("2026-05-29T06:00:00-05:00"),
Location: location,
})
if err != nil {
t.Fatalf("BatchReports() error = %v", err)
}
ids := resolvedIDs(resolved)
if strings.Join(ids, ",") != "today,three_day,weekend" {
t.Fatalf("ids = %v, want morning report order", ids)
}
}
func TestRegistryLookupErrorIsActionable(t *testing.T) { func TestRegistryLookupErrorIsActionable(t *testing.T) {
_, err := DefaultRegistry().Lookup(ID("unknown")) _, err := DefaultRegistry().Lookup(ID("unknown"))
if err == nil { if err == nil {
@@ -584,6 +520,88 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
} }
} }
func TestGeneratedRegistryDefinitionsDeclareDistributorPathDefaults(t *testing.T) {
for _, definition := range DefaultRegistry().All() {
if !definition.Generated {
continue
}
if len(definition.DistributorPathTemplates) == 0 {
t.Fatalf("%s DistributorPathTemplates is empty", definition.ID)
}
}
}
func TestRegistryDefinitionsDeclareDefaultDistributorPathTemplates(t *testing.T) {
tests := []struct {
id ID
want []string
}{
{
id: Hourly,
want: []string{
"hourly/index.md",
},
},
{
id: Daily,
want: []string{
"daily/{valid_start_date}/{run_id}.md",
"daily/{valid_start_date}/index.md",
},
},
{
id: Today,
want: []string{
"daily/{valid_start_date}/{run_id}.md",
"daily/{valid_start_date}/index.md",
"today/index.md",
},
},
{
id: Tomorrow,
want: []string{
"daily/{valid_start_date}/{run_id}.md",
"daily/{valid_start_date}/index.md",
"tomorrow/index.md",
},
},
{
id: ThreeDay,
want: []string{
"three-day/{valid_start_date}/{run_id}.md",
"three-day/{valid_start_date}/index.md",
},
},
{
id: Weekend,
want: []string{
"weekend/{valid_start_date}/{run_id}.md",
"weekend/{valid_start_date}/index.md",
},
},
{
id: Storm,
want: []string{
"storm/{storm_id}/{run_id}.md",
"storm/{storm_id}/index.md",
},
},
}
registry := DefaultRegistry()
for _, tt := range tests {
t.Run(string(tt.id), func(t *testing.T) {
definition, err := registry.Lookup(tt.id)
if err != nil {
t.Fatalf("Lookup() error = %v", err)
}
if !reflect.DeepEqual(definition.DistributorPathTemplates, tt.want) {
t.Fatalf("DistributorPathTemplates = %#v, want %#v", definition.DistributorPathTemplates, tt.want)
}
})
}
}
func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) { func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
tests := []struct { tests := []struct {
id ID id ID
@@ -746,6 +764,9 @@ func TestRegistryAppliesModuleOverridesWithoutChangingDefaults(t *testing.T) {
if len(defaultDefinition.ModuleIDs()) <= len(definition.ModuleIDs()) { if len(defaultDefinition.ModuleIDs()) <= len(definition.ModuleIDs()) {
t.Fatalf("default ModuleIDs() = %#v, want original defaults unchanged", defaultDefinition.ModuleIDs()) t.Fatalf("default ModuleIDs() = %#v, want original defaults unchanged", defaultDefinition.ModuleIDs())
} }
if !reflect.DeepEqual(definition.DistributorPathTemplates, defaultDefinition.DistributorPathTemplates) {
t.Fatalf("overridden DistributorPathTemplates = %#v, want %#v", definition.DistributorPathTemplates, defaultDefinition.DistributorPathTemplates)
}
} }
func TestRegistryRejectsModuleOverrideForUnknownReport(t *testing.T) { func TestRegistryRejectsModuleOverrideForUnknownReport(t *testing.T) {
@@ -777,8 +798,41 @@ func TestResolvedMetadata(t *testing.T) {
if metadata.PromptID != "weather.daily_generated_text" { if metadata.PromptID != "weather.daily_generated_text" {
t.Fatalf("PromptID = %q, want weather.daily_generated_text", metadata.PromptID) t.Fatalf("PromptID = %q, want weather.daily_generated_text", metadata.PromptID)
} }
if !strings.Contains(metadata.RunID, "daily") { if metadata.RunID != "20260529T100000.000000000Z_daily_2026-05-29" {
t.Fatalf("RunID = %q, want report id", metadata.RunID) t.Fatalf("RunID = %q, want dated Daily report id", metadata.RunID)
}
}
func TestDailyRunIDIncludesValidDateDisambiguator(t *testing.T) {
location := mustLoadLocation(t)
now := mustParse("2026-05-29T05:00:00-05:00")
first, err := Resolve(Daily, ResolveRequest{
Now: now,
Location: location,
Date: mustParse("2026-05-31T12:00:00-05:00"),
})
if err != nil {
t.Fatalf("Resolve(first) error = %v", err)
}
second, err := Resolve(Daily, ResolveRequest{
Now: now,
Location: location,
Date: mustParse("2026-06-01T12:00:00-05:00"),
})
if err != nil {
t.Fatalf("Resolve(second) error = %v", err)
}
firstRunID := first.Metadata().RunID
secondRunID := second.Metadata().RunID
if firstRunID == secondRunID {
t.Fatalf("Daily RunIDs both = %q, want distinct valid-date suffixes", firstRunID)
}
if firstRunID != "20260529T100000.000000000Z_daily_2026-05-31" {
t.Fatalf("first RunID = %q, want valid-date suffix", firstRunID)
}
if secondRunID != "20260529T100000.000000000Z_daily_2026-06-01" {
t.Fatalf("second RunID = %q, want valid-date suffix", secondRunID)
} }
} }
@@ -795,8 +849,8 @@ func TestHourlyMetadataRunIDIncludesReportID(t *testing.T) {
if metadata.PromptID != "weather.hourly_generated_text" { if metadata.PromptID != "weather.hourly_generated_text" {
t.Fatalf("PromptID = %q, want weather.hourly_generated_text", metadata.PromptID) t.Fatalf("PromptID = %q, want weather.hourly_generated_text", metadata.PromptID)
} }
if !strings.Contains(metadata.RunID, "hourly") { if metadata.RunID != "20260529T101500.000000000Z_hourly" {
t.Fatalf("RunID = %q, want report id", metadata.RunID) t.Fatalf("RunID = %q, want unchanged report id shape", metadata.RunID)
} }
} }
@@ -813,14 +867,6 @@ func assertPeriod(t *testing.T, period timeutil.Period, wantStart string, wantEn
} }
} }
func resolvedIDs(resolved []Resolved) []string {
ids := make([]string, 0, len(resolved))
for _, item := range resolved {
ids = append(ids, string(item.Definition.ID))
}
return ids
}
func resolvedDefinitionIDs(definitions []Definition) []string { func resolvedDefinitionIDs(definitions []Definition) []string {
ids := make([]string, 0, len(definitions)) ids := make([]string, 0, len(definitions))
for _, definition := range definitions { for _, definition := range definitions {

View File

@@ -30,8 +30,7 @@ func DefaultRegistry() Registry {
func (r Registry) WithModuleOverrides(overrides map[ID][]module.ConfigItem) (Registry, error) { func (r Registry) WithModuleOverrides(overrides map[ID][]module.ConfigItem) (Registry, error) {
next := Registry{definitions: map[ID]Definition{}} next := Registry{definitions: map[ID]Definition{}}
for id, definition := range r.definitions { for id, definition := range r.definitions {
definition.Modules = append([]module.ConfigItem(nil), definition.Modules...) next.definitions[id] = cloneDefinition(definition)
next.definitions[id] = definition
} }
for id, items := range overrides { for id, items := range overrides {
definition, ok := next.definitions[id] definition, ok := next.definitions[id]
@@ -44,6 +43,12 @@ func (r Registry) WithModuleOverrides(overrides map[ID][]module.ConfigItem) (Reg
return next, nil return next, nil
} }
func cloneDefinition(definition Definition) Definition {
definition.Modules = cloneModuleItems(definition.Modules)
definition.DistributorPathTemplates = append([]string(nil), definition.DistributorPathTemplates...)
return definition
}
func moduleItems(ids ...module.ID) []module.ConfigItem { func moduleItems(ids ...module.ID) []module.ConfigItem {
items := make([]module.ConfigItem, 0, len(ids)) items := make([]module.ConfigItem, 0, len(ids))
for _, id := range ids { for _, id := range ids {
@@ -52,6 +57,15 @@ func moduleItems(ids ...module.ID) []module.ConfigItem {
return items return items
} }
func areaForecastDiscussionItem(sections ...string) module.ConfigItem {
return module.ConfigItem{
ID: module.AreaForecastDiscussion,
Options: module.AreaForecastDiscussionOptions{
Sections: append([]string(nil), sections...),
},
}
}
func cloneModuleItems(items []module.ConfigItem) []module.ConfigItem { func cloneModuleItems(items []module.ConfigItem) []module.ConfigItem {
cloned := make([]module.ConfigItem, len(items)) cloned := make([]module.ConfigItem, len(items))
copy(cloned, items) copy(cloned, items)

View File

@@ -17,6 +17,10 @@ func stormDefinition() Definition {
ComparisonStrategy: CompareExplicitWindow, ComparisonStrategy: CompareExplicitWindow,
ArtifactGroup: "storm", ArtifactGroup: "storm",
BatchOutputName: "storm.md", BatchOutputName: "storm.md",
DistributorPathTemplates: []string{
"storm/{storm_id}/{run_id}.md",
"storm/{storm_id}/index.md",
},
Generated: true, Generated: true,
CompatiblePriorIDs: []ID{Storm}, CompatiblePriorIDs: []ID{Storm},
Modules: stormModules(), Modules: stormModules(),

View File

@@ -16,6 +16,10 @@ func threeDayDefinition() Definition {
ComparisonStrategy: CompareSameValidDate, ComparisonStrategy: CompareSameValidDate,
ArtifactGroup: "three-day", ArtifactGroup: "three-day",
BatchOutputName: "three-day.md", BatchOutputName: "three-day.md",
DistributorPathTemplates: []string{
"three-day/{valid_start_date}/{run_id}.md",
"three-day/{valid_start_date}/index.md",
},
Generated: true, Generated: true,
CompatiblePriorIDs: []ID{ThreeDay}, CompatiblePriorIDs: []ID{ThreeDay},
Modules: threeDayModules(), Modules: threeDayModules(),

View File

@@ -16,11 +16,16 @@ func todayDefinition() Definition {
ComparisonStrategy: CompareSameValidDate, ComparisonStrategy: CompareSameValidDate,
ArtifactGroup: "today", ArtifactGroup: "today",
BatchOutputName: "today.md", BatchOutputName: "today.md",
Generated: true, DistributorPathTemplates: []string{
CompatiblePriorIDs: []ID{Today}, "daily/{valid_start_date}/{run_id}.md",
Modules: todayModules(), "daily/{valid_start_date}/index.md",
Morning: true, "today/index.md",
resolve: resolveToday, },
Generated: true,
CompatiblePriorIDs: []ID{Today},
Modules: todayModules(),
Morning: true,
resolve: resolveToday,
} }
} }

View File

@@ -16,11 +16,16 @@ func tomorrowDefinition() Definition {
ComparisonStrategy: CompareSameValidDate, ComparisonStrategy: CompareSameValidDate,
ArtifactGroup: "tomorrow", ArtifactGroup: "tomorrow",
BatchOutputName: "tomorrow.md", BatchOutputName: "tomorrow.md",
Generated: true, DistributorPathTemplates: []string{
CompatiblePriorIDs: []ID{Tomorrow}, "daily/{valid_start_date}/{run_id}.md",
Modules: tomorrowModules(), "daily/{valid_start_date}/index.md",
Evening: true, "tomorrow/index.md",
resolve: resolveTomorrow, },
Generated: true,
CompatiblePriorIDs: []ID{Tomorrow},
Modules: tomorrowModules(),
Evening: true,
resolve: resolveTomorrow,
} }
} }

View File

@@ -17,6 +17,10 @@ func weekendDefinition() Definition {
ComparisonStrategy: CompareWeekendWindow, ComparisonStrategy: CompareWeekendWindow,
ArtifactGroup: "weekend", ArtifactGroup: "weekend",
BatchOutputName: "weekend.md", BatchOutputName: "weekend.md",
DistributorPathTemplates: []string{
"weekend/{valid_start_date}/{run_id}.md",
"weekend/{valid_start_date}/index.md",
},
Generated: true, Generated: true,
CompatiblePriorIDs: []ID{Weekend}, CompatiblePriorIDs: []ID{Weekend},
Modules: weekendModules(), Modules: weekendModules(),

View File

@@ -8,7 +8,7 @@ import (
"text/template" "text/template"
) )
//go:embed templates/*.md.tmpl schemas/*.schema.json //go:embed templates/*.md.tmpl templates/partials/*.md.tmpl schemas/*.schema.json
var assets embed.FS var assets embed.FS
var templates = map[string]string{ var templates = map[string]string{
@@ -18,6 +18,13 @@ var templates = map[string]string{
"tomorrow": "templates/tomorrow.md.tmpl", "tomorrow": "templates/tomorrow.md.tmpl",
} }
var templatePartials = []string{
"templates/partials/alert_digest.md.tmpl",
"templates/partials/daypart_forecast.md.tmpl",
"templates/partials/precipitation_timing.md.tmpl",
"templates/partials/today_daypart_forecast.md.tmpl",
}
var schemas = map[string]string{ var schemas = map[string]string{
"daily": "schemas/daily.generated_text.schema.json", "daily": "schemas/daily.generated_text.schema.json",
"hourly": "schemas/hourly.generated_text.schema.json", "hourly": "schemas/hourly.generated_text.schema.json",
@@ -58,6 +65,16 @@ func Render(id string, data any) ([]byte, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("parse report template %q: %w", id, err) return nil, fmt.Errorf("parse report template %q: %w", id, err)
} }
for _, path := range templatePartials {
partial, err := assets.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read report template partial %q: %w", path, err)
}
tmpl, err = tmpl.Parse(string(partial))
if err != nil {
return nil, fmt.Errorf("parse report template partial %q: %w", path, err)
}
}
var out bytes.Buffer var out bytes.Buffer
if err := tmpl.Execute(&out, data); err != nil { if err := tmpl.Execute(&out, data); err != nil {
return nil, fmt.Errorf("render report template %q: %w", id, err) return nil, fmt.Errorf("render report template %q: %w", id, err)

View File

@@ -12,7 +12,7 @@ func TestTemplateLookup(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Template() error = %v", err) t.Fatalf("Template() error = %v", err)
} }
for _, want := range []string{"# {{ .Report.Title }}", "**Updated:**", "## Current Conditions", "## Hourly Forecast", "## Forecast Discussion"} { for _, want := range []string{"# {{ .Report.Title }}", "**Updated:**", `{{ template "alert_digest" . }}`, "## Current Conditions", "## Hourly Forecast", "## Forecast Discussion"} {
if !strings.Contains(source, want) { if !strings.Contains(source, want) {
t.Fatalf("template missing %q:\n%s", want, source) t.Fatalf("template missing %q:\n%s", want, source)
} }
@@ -24,7 +24,7 @@ func TestTomorrowTemplateLookup(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Template() error = %v", err) t.Fatalf("Template() error = %v", err)
} }
for _, want := range []string{"# {{ .Report.Title }}", "**Forecast date:**", "## Daypart Forecast", "## Precipitation Timing", "## Forecast Discussion"} { for _, want := range []string{"# {{ .Report.Title }}", "**Forecast date:**", `{{ template "alert_digest" . }}`, `{{ template "daypart_forecast" . }}`, `{{ template "precipitation_timing" . }}`, "## Forecast Discussion"} {
if !strings.Contains(source, want) { if !strings.Contains(source, want) {
t.Fatalf("template missing %q:\n%s", want, source) t.Fatalf("template missing %q:\n%s", want, source)
} }
@@ -36,7 +36,7 @@ func TestDailyTemplateLookup(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Template() error = %v", err) t.Fatalf("Template() error = %v", err)
} }
for _, want := range []string{"# {{ .Report.Title }}", "**Forecast date:**", "## Daypart Forecast", "## Precipitation Timing", "## Forecast Discussion"} { for _, want := range []string{"# {{ .Report.Title }}", "**Forecast date:**", `{{ template "alert_digest" . }}`, `{{ template "daypart_forecast" . }}`, `{{ template "precipitation_timing" . }}`, "## Forecast Discussion"} {
if !strings.Contains(source, want) { if !strings.Contains(source, want) {
t.Fatalf("template missing %q:\n%s", want, source) t.Fatalf("template missing %q:\n%s", want, source)
} }
@@ -48,7 +48,7 @@ func TestTodayTemplateLookup(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Template() error = %v", err) t.Fatalf("Template() error = %v", err)
} }
for _, want := range []string{"# {{ .Report.Title }}", "**Forecast date:**", "## Current Conditions", "## Daypart Forecast", "## Forecast Discussion"} { for _, want := range []string{"# {{ .Report.Title }}", "**Forecast date:**", `{{ template "alert_digest" . }}`, "## Current Conditions", `{{ template "today_daypart_forecast" . }}`, `{{ template "precipitation_timing" . }}`, "## Forecast Discussion"} {
if !strings.Contains(source, want) { if !strings.Contains(source, want) {
t.Fatalf("template missing %q:\n%s", want, source) t.Fatalf("template missing %q:\n%s", want, source)
} }
@@ -236,14 +236,15 @@ func TestRenderHourly(t *testing.T) {
MaxPopPercent: intPtr(70), MaxPopPercent: intPtr(70),
MaxPopTime: "10 AM", MaxPopTime: "10 AM",
PrecipitationWindows: []testPrecipWindow{ PrecipitationWindows: []testPrecipWindow{
{PeriodBegins: "10 AM", PeriodBeginsHourLabel: "10:00 AM", PeriodEnds: "12 PM", PeriodEndsHourLabel: "12:00 PM", MaxPopPercent: intPtr(70), MaxPopTime: "10 AM", MaxPopHourLabel: "10:00 AM"}, {PeriodBegins: "10 AM", PeriodBeginsHourLabel: "10:00 AM", PeriodEnds: "12 PM", PeriodEndsHourLabel: "12:00 PM", MaxPopPercent: intPtr(70), MaxPopTime: "10 AM", MaxPopHourLabel: "10:00 AM", ExpectationPhrase: "Expect showers."},
}, },
}, },
AlertDigest: &testAlertDigest{ AlertDigest: &testAlertDigest{
Relevant: []testAlert{{Event: "Flood Watch", Headline: "Flood Watch until 2:30 PM", Severity: "Moderate"}}, Relevant: []testAlert{{Event: "Flood Watch", Headline: "Flood Watch until 2:30 PM", Severity: "Moderate", PeriodBegins: "May 29 at 10:00 AM", PeriodEnds: "May 29 at 2:30 PM", Instruction: "Avoid low-water crossings."}},
}, },
SPCConvectiveOutlooks: &testSPCOutlooks{ SPCConvectiveOutlooks: &testSPCOutlooks{
Outlooks: []testSPCOutlook{{LabelText: "Slight Risk", PeriodBegins: "8 AM", PeriodEnds: "2 PM"}}, Outlooks: []testSPCOutlook{{LabelText: "Slight Risk", PeriodBegins: "8 AM", PeriodEnds: "2 PM"}},
RiskDigest: []testSPCRiskDigest{{LabelText: "Slight Risk", RiskLabel: "Slight risk", PeriodBegins: "May 29 at 8:00 AM", PeriodEnds: "May 29 at 2:00 PM"}},
}, },
SPCConvectiveDiscussion: &testSPCDiscussion{ SPCConvectiveDiscussion: &testSPCDiscussion{
Discussions: []testSPCDiscussionRecord{{Summary: "Strong storms may develop late morning."}}, Discussions: []testSPCDiscussionRecord{{Summary: "Strong storms may develop late morning."}},
@@ -268,11 +269,12 @@ func TestRenderHourly(t *testing.T) {
"**Updated:** Saturday, June 14, 2026 at 9:14 AM", "**Updated:** Saturday, June 14, 2026 at 9:14 AM",
"Storm chances increase through late morning.", "Storm chances increase through late morning.",
"Currently, it is 74°F and partly cloudy. It feels like 76°F, with a relative humidity of 71% and winds from the south at 8 mph.", "Currently, it is 74°F and partly cloudy. It feels like 76°F, with a relative humidity of 71% and winds from the south at 8 mph.",
"## Active Alerts", "## Alert Digest",
"- **Flood Watch**: Flood Watch until 2:30 PM", "- **Flood Watch**: Flood Watch in effect from May 29 at 10:00 AM to May 29 at 2:30 PM. Avoid low-water crossings.",
"- **SPC Convective Outlook**: Slight risk for severe thunderstorms in effect from May 29 at 8:00 AM to May 29 at 2:00 PM.",
"- **9:00 AM:** 74°F and cloudy.", "- **9:00 AM:** 74°F and cloudy.",
"- **10:00 AM:** 75°F and showers. Probability of precipitation is 70%.", "- **10:00 AM:** 75°F and showers. Probability of precipitation is 70%.",
"- **10:00 AM** to **12:00 PM**: Precipitation is expected during this period. The peak precipitation chance is 70% at 10:00 AM.", "- **10:00 AM** to **12:00 PM**: Expect showers. The peak precipitation chance is 70% at 10:00 AM.",
"A cold front is moving into the region.", "A cold front is moving into the region.",
"A front will keep the region unsettled.", "A front will keep the region unsettled.",
} { } {
@@ -285,8 +287,8 @@ func TestRenderHourly(t *testing.T) {
} }
assertOrderedText(t, text, []string{ assertOrderedText(t, text, []string{
"# Hourly Report", "# Hourly Report",
"## Alert Digest",
"## Current Conditions", "## Current Conditions",
"## Active Alerts",
"## Hourly Forecast", "## Hourly Forecast",
"## Precipitation Timing", "## Precipitation Timing",
"## Forecast Discussion", "## Forecast Discussion",
@@ -348,9 +350,15 @@ func TestRenderTomorrow(t *testing.T) {
}, },
PrecipTiming: &testPrecipTiming{ PrecipTiming: &testPrecipTiming{
PrecipitationWindows: []testPrecipWindow{ PrecipitationWindows: []testPrecipWindow{
{PeriodBeginsHourLabel: "3:00 PM", PeriodEndsHourLabel: "6:00 PM", MaxPopPercent: intPtr(70), MaxPopHourLabel: "3:00 PM"}, {PeriodBeginsHourLabel: "3:00 PM", PeriodEndsHourLabel: "6:00 PM", MaxPopPercent: intPtr(70), MaxPopHourLabel: "3:00 PM", ExpectationPhrase: "Expect showers."},
}, },
}, },
AlertDigest: &testAlertDigest{
Relevant: []testAlert{{Event: "Wind Advisory", Headline: "Wind Advisory until 8:00 PM", Severity: "Moderate", PeriodBegins: "June 15 at 1:00 PM", PeriodEnds: "June 15 at 8:00 PM", Instruction: "Secure outdoor objects."}},
},
SPCConvectiveOutlooks: &testSPCOutlooks{
RiskDigest: []testSPCRiskDigest{{LabelText: "Slight Risk", RiskLabel: "Slight risk", PeriodBegins: "June 15 at 7:00 AM", PeriodEnds: "June 16 at 7:00 AM"}},
},
}, },
}) })
if err != nil { if err != nil {
@@ -362,10 +370,13 @@ func TestRenderTomorrow(t *testing.T) {
"**Forecast date:** Monday, June 15, 2026", "**Forecast date:** Monday, June 15, 2026",
"**Updated:** Sunday, June 14, 2026 at 9:14 AM", "**Updated:** Sunday, June 14, 2026 at 9:14 AM",
"Tomorrow starts dry before showers return later in the day.", "Tomorrow starts dry before showers return later in the day.",
"## Alert Digest",
"- **Wind Advisory**: Wind Advisory in effect from June 15 at 1:00 PM to June 15 at 8:00 PM. Secure outdoor objects.",
"- **SPC Convective Outlook**: Slight risk for severe thunderstorms in effect from June 15 at 7:00 AM to June 16 at 7:00 AM.",
"- **Overnight:** Partly cloudy, with temperatures falling from the mid 60s to the upper 50s.", "- **Overnight:** Partly cloudy, with temperatures falling from the mid 60s to the upper 50s.",
"- **Morning:** Sunny, with temperatures rising from the upper 50s to the upper 60s.", "- **Morning:** Sunny, with temperatures rising from the upper 50s to the upper 60s.",
"- **Afternoon:** Sunny, with temperatures in the upper 70s. Chance of precipitation is 70%.", "- **Afternoon:** Sunny, with temperatures in the upper 70s. Chance of precipitation is 70%.",
"- **3:00 PM** to **6:00 PM**: Precipitation is expected during this period. The peak precipitation chance is 70% at 3:00 PM.", "- **3:00 PM** to **6:00 PM**: Expect showers. The peak precipitation chance is 70% at 3:00 PM.",
"A few showers may linger into early evening.", "A few showers may linger into early evening.",
"Clouds increase after sunrise.", "Clouds increase after sunrise.",
"Rain chances peak during the afternoon.", "Rain chances peak during the afternoon.",
@@ -384,6 +395,8 @@ func TestRenderTomorrow(t *testing.T) {
} }
assertOrderedText(t, text, []string{ assertOrderedText(t, text, []string{
"# Monday's Weather", "# Monday's Weather",
"Tomorrow starts dry before showers return later in the day.",
"## Alert Digest",
"## Daypart Forecast", "## Daypart Forecast",
"- **Overnight:**", "- **Overnight:**",
"- **Morning:**", "- **Morning:**",
@@ -439,9 +452,15 @@ func TestRenderDaily(t *testing.T) {
}, },
PrecipTiming: &testPrecipTiming{ PrecipTiming: &testPrecipTiming{
PrecipitationWindows: []testPrecipWindow{ PrecipitationWindows: []testPrecipWindow{
{PeriodBeginsHourLabel: "3:00 PM", PeriodEndsHourLabel: "6:00 PM", MaxPopPercent: intPtr(70), MaxPopHourLabel: "3:00 PM"}, {PeriodBeginsHourLabel: "3:00 PM", PeriodEndsHourLabel: "6:00 PM", MaxPopPercent: intPtr(70), MaxPopHourLabel: "3:00 PM", ExpectationPhrase: "Expect showers."},
}, },
}, },
AlertDigest: &testAlertDigest{
Relevant: []testAlert{{Event: "Flood Watch", Headline: "Flood Watch until 6:00 PM", Severity: "Moderate", PeriodBegins: "June 15 at 3:00 PM", PeriodEnds: "June 15 at 6:00 PM", Description: "Monitor creek levels."}},
},
SPCConvectiveOutlooks: &testSPCOutlooks{
RiskDigest: []testSPCRiskDigest{{LabelText: "Enhanced Risk", RiskLabel: "Enhanced risk", PeriodBegins: "June 15 at 7:00 AM", PeriodEnds: "June 16 at 7:00 AM"}},
},
}, },
}) })
if err != nil { if err != nil {
@@ -453,9 +472,12 @@ func TestRenderDaily(t *testing.T) {
"**Forecast date:** Monday, June 15, 2026", "**Forecast date:** Monday, June 15, 2026",
"**Updated:** Sunday, June 14, 2026 at 9:14 AM", "**Updated:** Sunday, June 14, 2026 at 9:14 AM",
"The selected day starts dry before showers return later in the day.", "The selected day starts dry before showers return later in the day.",
"## Alert Digest",
"- **Flood Watch**: Flood Watch in effect from June 15 at 3:00 PM to June 15 at 6:00 PM. Monitor creek levels.",
"- **SPC Convective Outlook**: Enhanced risk for severe thunderstorms in effect from June 15 at 7:00 AM to June 16 at 7:00 AM.",
"- **Morning:** Sunny, with temperatures rising from the upper 50s to the upper 60s.", "- **Morning:** Sunny, with temperatures rising from the upper 50s to the upper 60s.",
"- **Afternoon:** Showers, with temperatures in the upper 70s. Chance of precipitation is 70%.", "- **Afternoon:** Showers, with temperatures in the upper 70s. Chance of precipitation is 70%.",
"- **3:00 PM** to **6:00 PM**: Precipitation is expected during this period. The peak precipitation chance is 70% at 3:00 PM.", "- **3:00 PM** to **6:00 PM**: Expect showers. The peak precipitation chance is 70% at 3:00 PM.",
"A few showers may linger into early evening.", "A few showers may linger into early evening.",
"Clouds increase after sunrise.", "Clouds increase after sunrise.",
"Rain chances peak during the afternoon.", "Rain chances peak during the afternoon.",
@@ -466,6 +488,8 @@ func TestRenderDaily(t *testing.T) {
} }
assertOrderedText(t, text, []string{ assertOrderedText(t, text, []string{
"# Monday's Weather", "# Monday's Weather",
"The selected day starts dry before showers return later in the day.",
"## Alert Digest",
"## Daypart Forecast", "## Daypart Forecast",
"- **Morning:**", "- **Morning:**",
"- **Afternoon:**", "- **Afternoon:**",
@@ -532,9 +556,15 @@ func TestRenderToday(t *testing.T) {
}, },
PrecipTiming: &testPrecipTiming{ PrecipTiming: &testPrecipTiming{
PrecipitationWindows: []testPrecipWindow{ PrecipitationWindows: []testPrecipWindow{
{PeriodBeginsHourLabel: "3:00 PM", PeriodEndsHourLabel: "6:00 PM", MaxPopPercent: intPtr(70), MaxPopHourLabel: "3:00 PM"}, {PeriodBeginsHourLabel: "3:00 PM", PeriodEndsHourLabel: "6:00 PM", MaxPopPercent: intPtr(70), MaxPopHourLabel: "3:00 PM", ExpectationPhrase: "Expect showers."},
}, },
}, },
AlertDigest: &testAlertDigest{
Relevant: []testAlert{{Event: "Wind Advisory", Headline: "Wind Advisory until 8:00 PM", Severity: "Moderate", PeriodBegins: "June 15 at 1:00 PM", PeriodEnds: "June 15 at 8:00 PM", Instruction: "Secure outdoor objects."}},
},
SPCConvectiveOutlooks: &testSPCOutlooks{
RiskDigest: []testSPCRiskDigest{{LabelText: "Slight Risk", RiskLabel: "Slight risk", PeriodBegins: "June 15 at 7:00 AM", PeriodEnds: "June 16 at 7:00 AM"}},
},
TodayPlanning: &testTodayPlanning{ TodayPlanning: &testTodayPlanning{
MorningReadiness: []string{"Morning weather looks routine."}, MorningReadiness: []string{"Morning weather looks routine."},
LateDayChangeWatch: []string{"Watch late-day shower timing."}, LateDayChangeWatch: []string{"Watch late-day shower timing."},
@@ -550,10 +580,13 @@ func TestRenderToday(t *testing.T) {
"**Forecast date:** Monday, June 15, 2026", "**Forecast date:** Monday, June 15, 2026",
"**Updated:** Monday, June 15, 2026 at 7:14 AM", "**Updated:** Monday, June 15, 2026 at 7:14 AM",
"Today starts dry before showers return later in the day.", "Today starts dry before showers return later in the day.",
"## Alert Digest",
"- **Wind Advisory**: Wind Advisory in effect from June 15 at 1:00 PM to June 15 at 8:00 PM. Secure outdoor objects.",
"- **SPC Convective Outlook**: Slight risk for severe thunderstorms in effect from June 15 at 7:00 AM to June 16 at 7:00 AM.",
"Currently, it is 58°F and clear. It feels like 57°F, with a relative humidity of 61% and winds from the northwest at 9 mph.", "Currently, it is 58°F and clear. It feels like 57°F, with a relative humidity of 61% and winds from the northwest at 9 mph.",
"- **Morning:** Sunny, with temperatures rising from the upper 50s to the upper 60s.", "- **Morning:** Sunny, with temperatures rising from the upper 50s to the upper 60s.",
"- **Afternoon:** Showers, with temperatures in the upper 70s. Chance of precipitation is 70%.", "- **Afternoon:** Showers, with temperatures in the upper 70s. Chance of precipitation is 70%.",
"- **3:00 PM** to **6:00 PM**: Precipitation is expected during this period. The peak precipitation chance is 70% at 3:00 PM.", "- **3:00 PM** to **6:00 PM**: Expect showers. The peak precipitation chance is 70% at 3:00 PM.",
"A few showers may linger into early evening.", "A few showers may linger into early evening.",
"Clouds increase after sunrise.", "Clouds increase after sunrise.",
"Rain chances peak during the afternoon.", "Rain chances peak during the afternoon.",
@@ -564,6 +597,8 @@ func TestRenderToday(t *testing.T) {
} }
assertOrderedText(t, text, []string{ assertOrderedText(t, text, []string{
"# Today's Weather", "# Today's Weather",
"Today starts dry before showers return later in the day.",
"## Alert Digest",
"## Current Conditions", "## Current Conditions",
"## Daypart Forecast", "## Daypart Forecast",
"- **Morning:**", "- **Morning:**",
@@ -652,6 +687,12 @@ func TestRenderDaypartTemplatesUseRichHelperFields(t *testing.T) {
t.Fatalf("rendered template missing %q:\n%s", want, text) t.Fatalf("rendered template missing %q:\n%s", want, text)
} }
} }
if strings.Contains(text, "## Alert Digest") {
t.Fatalf("rendered template includes empty Alerts and Risk Products section:\n%s", text)
}
if strings.Contains(text, "\n\n\n") {
t.Fatalf("rendered template includes excess blank lines:\n%s", text)
}
}) })
} }
} }
@@ -725,11 +766,64 @@ func TestRenderHourlyOmitsConditionalSectionsForClearWeather(t *testing.T) {
t.Fatalf("Render() error = %v", err) t.Fatalf("Render() error = %v", err)
} }
text := string(rendered) text := string(rendered)
for _, unwanted := range []string{"## Active Alerts", "## Precipitation Timing", "Probability of precipitation is 10%", "wind"} { for _, unwanted := range []string{"## Alert Digest", "## Precipitation Timing", "Probability of precipitation is 10%", "wind"} {
if strings.Contains(text, unwanted) { if strings.Contains(text, unwanted) {
t.Fatalf("clear render includes %q:\n%s", unwanted, text) t.Fatalf("clear render includes %q:\n%s", unwanted, text)
} }
} }
if strings.Contains(text, "\n\n\n") {
t.Fatalf("clear render includes excess blank lines:\n%s", text)
}
for _, want := range []string{
"Currently, it is 72°F and cloudy.\n\n## Hourly Forecast",
"- **9:00 AM:** 71°F and mostly cloudy.\n\n## Forecast Discussion",
} {
if !strings.Contains(text, want) {
t.Fatalf("clear render missing spacing %q:\n%s", want, text)
}
}
}
func TestRenderHourlyUsesSharedOpenEndedPrecipitationTiming(t *testing.T) {
rendered, err := Render("hourly", testRenderContext{
Report: testReportContext{
Title: "Hourly Report",
GeneratedAtLabel: "Saturday, June 14, 2026 at 9:14 AM",
},
GeneratedText: testGeneratedText{
Summary: "Rain chances increase through midday.",
ForecastDiscussion: "Showers may continue beyond the report period.",
PrecipitationTiming: "Plan for wet roads through the end of the period.",
},
Modules: testModules{
CurrentConditions: &testCurrentConditions{},
HourlyForecast: &testHourlyForecast{},
AlertDigest: &testAlertDigest{},
PrecipTiming: &testPrecipTiming{
PrecipitationWindows: []testPrecipWindow{
{
PeriodBeginsHourLabel: "8:00 AM",
MaxPopPercent: intPtr(60),
MaxPopHourLabel: "10:00 AM",
ExpectationPhrase: "Showers likely.",
},
},
},
},
})
if err != nil {
t.Fatalf("Render() error = %v", err)
}
text := string(rendered)
for _, want := range []string{
"## Precipitation Timing",
"- **Starting at 8:00 AM**: Showers likely. The peak precipitation chance is 60% at 10:00 AM.",
"Plan for wet roads through the end of the period.",
} {
if !strings.Contains(text, want) {
t.Fatalf("rendered template missing %q:\n%s", want, text)
}
}
} }
func TestUnknownAssetsReturnActionableErrors(t *testing.T) { func TestUnknownAssetsReturnActionableErrors(t *testing.T) {
@@ -848,20 +942,26 @@ type testModules struct {
} }
type testTomorrowModules struct { type testTomorrowModules struct {
Dayparts []testTomorrowDaypart Dayparts []testTomorrowDaypart
PrecipTiming *testPrecipTiming PrecipTiming *testPrecipTiming
AlertDigest *testAlertDigest
SPCConvectiveOutlooks *testSPCOutlooks
} }
type testTodayModules struct { type testTodayModules struct {
CurrentConditions *testCurrentConditions CurrentConditions *testCurrentConditions
Dayparts []testTomorrowDaypart Dayparts []testTomorrowDaypart
PrecipTiming *testPrecipTiming PrecipTiming *testPrecipTiming
TodayPlanning *testTodayPlanning AlertDigest *testAlertDigest
SPCConvectiveOutlooks *testSPCOutlooks
TodayPlanning *testTodayPlanning
} }
type testDailyModules struct { type testDailyModules struct {
Dayparts []testDailyDaypart Dayparts []testDailyDaypart
PrecipTiming *testPrecipTiming PrecipTiming *testPrecipTiming
AlertDigest *testAlertDigest
SPCConvectiveOutlooks *testSPCOutlooks
} }
type testTodayPlanning struct { type testTodayPlanning struct {
@@ -943,6 +1043,8 @@ type testPrecipWindow struct {
MaxPopPercent *int MaxPopPercent *int
MaxPopTime string MaxPopTime string
MaxPopHourLabel string MaxPopHourLabel string
PrecipitationType string
ExpectationPhrase string
} }
type testAlertDigest struct { type testAlertDigest struct {
@@ -951,13 +1053,18 @@ type testAlertDigest struct {
} }
type testAlert struct { type testAlert struct {
Event string Event string
Headline string Headline string
Severity string Severity string
PeriodBegins string
PeriodEnds string
Instruction string
Description string
} }
type testSPCOutlooks struct { type testSPCOutlooks struct {
Outlooks []testSPCOutlook Outlooks []testSPCOutlook
RiskDigest []testSPCRiskDigest
} }
type testSPCOutlook struct { type testSPCOutlook struct {
@@ -968,6 +1075,13 @@ type testSPCOutlook struct {
PeriodEnds string PeriodEnds string
} }
type testSPCRiskDigest struct {
LabelText string
RiskLabel string
PeriodBegins string
PeriodEnds string
}
type testForecastDiscussion struct { type testForecastDiscussion struct {
KeyMessages []string KeyMessages []string
ShortTerm string ShortTerm string

View File

@@ -5,19 +5,7 @@
{{ .GeneratedText.Summary }} {{ .GeneratedText.Summary }}
## Daypart Forecast {{ template "alert_digest" . }}{{ template "daypart_forecast" . }}{{ if and .Modules.PrecipTiming .Modules.PrecipTiming.PrecipitationWindows }}{{ template "precipitation_timing" . }}{{ else }}{{ end -}}
{{ with .Modules.Dayparts }}{{ range . }}{{ $daypart := . }}- **{{ if .Summary.DisplayName }}{{ .Summary.DisplayName }}{{ else }}{{ .Key }}{{ end }}:** {{ with .Summary.DominantConditionDisplay }}{{ . }}{{ else }}{{ with .Summary.DominantCondition }}{{ . }}{{ else }}Forecast details are limited{{ end }}{{ end }}{{ if eq .Summary.TemperatureTrend "rising" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures rising from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "falling" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures falling from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "peaking" }}{{ with .Summary.TemperaturePeakPhraseF }}, with temperatures peaking in the {{ . }}{{ end }}{{ else }}{{ with .Summary.TemperatureSteadyPhraseF }}, with temperatures in the {{ . }}{{ else }}{{ with .Summary.TemperaturePhraseF }}, with temperatures in the {{ . }}{{ end }}{{ end }}{{ end }}.{{ if .Summary.MentionPrecipitation }}{{ with .Summary.MaxPopPercent }} Chance of precipitation is {{ . }}%.{{ end }}{{ end }}
{{ end }}{{ else }}- No daypart forecast details are available.
{{ end }}
{{ with .Modules.PrecipTiming }}{{ with .PrecipitationWindows }}
## Precipitation Timing
{{ range . }}{{ $window := . }}
- **{{ if .PeriodBeginsHourLabel }}{{ .PeriodBeginsHourLabel }}{{ else }}{{ .PeriodBegins }}{{ end }}**{{ with .PeriodEndsHourLabel }} to **{{ . }}**{{ else }}{{ with .PeriodEnds }} to **{{ . }}**{{ end }}{{ end }}: Precipitation is expected during this period.{{ with .MaxPopPercent }} The peak precipitation chance is {{ . }}%{{ with $window.MaxPopHourLabel }} at {{ . }}{{ else }}{{ with $window.MaxPopTime }} at {{ . }}{{ end }}{{ end }}.{{ end }}
{{ end }}{{ with $.GeneratedText.PrecipitationTiming }}
{{ . }}
{{ end }}
{{ end }}{{ end }}
## Forecast Discussion ## Forecast Discussion
{{ range .GeneratedText.ForecastDiscussion }} {{ range .GeneratedText.ForecastDiscussion }}
{{ . }} {{ . }}

View File

@@ -4,36 +4,18 @@
{{ .GeneratedText.Summary }} {{ .GeneratedText.Summary }}
## Current Conditions {{ template "alert_digest" . }}## Current Conditions
{{ with .Modules.CurrentConditions }}
{{ with .TemperatureF }}Currently, it is {{ . }}°F{{ with $.Modules.CurrentConditions.ConditionTextLower }} and {{ . }}{{ end }}.{{ else }}{{ with .ConditionText }}Currently, it is {{ . }}.{{ else }}Current conditions are unavailable.{{ end }}{{ end }}{{ with .ApparentTemperatureF }} It feels like {{ . }}°F{{ with $.Modules.CurrentConditions.RelativeHumidityPercent }}, with a relative humidity of {{ . }}%{{ end }}{{ with $.Modules.CurrentConditions.WindDirectionText }} and winds from the {{ . }}{{ with $.Modules.CurrentConditions.WindSpeedMph }} at {{ . }} mph{{ end }}{{ end }}.{{ else }}{{ with .RelativeHumidityPercent }} Relative humidity is {{ . }}%.{{ end }}{{ with .WindDirectionText }} Winds are from the {{ . }}{{ with $.Modules.CurrentConditions.WindSpeedMph }} at {{ . }} mph{{ end }}.{{ end }}{{ end }} {{ with .Modules.CurrentConditions }}{{ with .TemperatureF }}Currently, it is {{ . }}°F{{ with $.Modules.CurrentConditions.ConditionTextLower }} and {{ . }}{{ end }}.{{ else }}{{ with .ConditionText }}Currently, it is {{ . }}.{{ else }}Current conditions are unavailable.{{ end }}{{ end }}{{ with .ApparentTemperatureF }} It feels like {{ . }}°F{{ with $.Modules.CurrentConditions.RelativeHumidityPercent }}, with a relative humidity of {{ . }}%{{ end }}{{ with $.Modules.CurrentConditions.WindDirectionText }} and winds from the {{ . }}{{ with $.Modules.CurrentConditions.WindSpeedMph }} at {{ . }} mph{{ end }}{{ end }}.{{ else }}{{ with .RelativeHumidityPercent }} Relative humidity is {{ . }}%.{{ end }}{{ with .WindDirectionText }} Winds are from the {{ . }}{{ with $.Modules.CurrentConditions.WindSpeedMph }} at {{ . }} mph{{ end }}.{{ end }}{{ end }}{{ else }}Current conditions are unavailable.{{ end }}
{{ else }}
Current conditions are unavailable.
{{ end }}
{{ with .Modules.AlertDigest }}{{ with .Relevant }}
## Active Alerts
{{ range . }}
- **{{ if .Event }}{{ .Event }}{{ else }}{{ .Headline }}{{ end }}**{{ with .Headline }}: {{ . }}{{ end }}
{{ end }}
{{ end }}{{ end }}
## Hourly Forecast ## Hourly Forecast
{{ with .Modules.HourlyForecast }}{{ range .Periods }} {{ with .Modules.HourlyForecast }}{{ range .Periods -}}
- **{{ if .HourLabel }}{{ .HourLabel }}{{ else }}{{ .Name }}{{ end }}:**{{ with .TemperatureF }} {{ . }}°F{{ end }}{{ with .TextDescriptionLower }} and {{ . }}{{ else }}{{ with .TextDescription }} and {{ . }}{{ end }}{{ end }}.{{ if .MentionPrecipitation }}{{ with .ProbabilityOfPrecipitationPercent }} Probability of precipitation is {{ . }}%.{{ end }}{{ end }} - **{{ if .HourLabel }}{{ .HourLabel }}{{ else }}{{ .Name }}{{ end }}:**{{ with .TemperatureF }} {{ . }}°F{{ end }}{{ with .TextDescriptionLower }} and {{ . }}{{ else }}{{ with .TextDescription }} and {{ . }}{{ end }}{{ end }}.{{ if .MentionPrecipitation }}{{ with .ProbabilityOfPrecipitationPercent }} Probability of precipitation is {{ . }}%.{{ end }}{{ end }}
{{ else }} {{ else }}- No hourly forecast rows are available.
- No hourly forecast rows are available. {{ end }}{{ else }}- No hourly forecast rows are available.
{{ end }}{{ else }} {{ end -}}
- No hourly forecast rows are available. {{ if and .Modules.PrecipTiming .Modules.PrecipTiming.PrecipitationWindows }}{{ template "precipitation_timing" . }}{{ else }}
{{ end }} {{ end -}}
{{ with .Modules.PrecipTiming }}{{ with .PrecipitationWindows }}
## Precipitation Timing
{{ range . }}{{ $window := . }}
- **{{ if .PeriodBeginsHourLabel }}{{ .PeriodBeginsHourLabel }}{{ else }}{{ .PeriodBegins }}{{ end }}**{{ with .PeriodEndsHourLabel }} to **{{ . }}**{{ else }}{{ with .PeriodEnds }} to **{{ . }}**{{ end }}{{ end }}: Precipitation is expected during this period.{{ with .MaxPopPercent }} The peak precipitation chance is {{ . }}%{{ with $window.MaxPopHourLabel }} at {{ . }}{{ else }}{{ with $window.MaxPopTime }} at {{ . }}{{ end }}{{ end }}.{{ end }}
{{ end }}{{ with $.GeneratedText.PrecipitationTiming }}
{{ . }}
{{ end }}
{{ end }}{{ end }}
## Forecast Discussion ## Forecast Discussion
{{ .GeneratedText.ForecastDiscussion }} {{ .GeneratedText.ForecastDiscussion }}

View File

@@ -0,0 +1,8 @@
{{ define "alert_digest" }}{{ if or (and .Modules.AlertDigest .Modules.AlertDigest.Relevant) (and .Modules.SPCConvectiveOutlooks .Modules.SPCConvectiveOutlooks.RiskDigest) }}## Alert Digest
{{ with .Modules.AlertDigest }}{{ range .Relevant -}}
- **{{ if .Event }}{{ .Event }}{{ else }}{{ .Headline }}{{ end }}**: {{ if .Event }}{{ .Event }}{{ else }}{{ .Headline }}{{ end }} in effect{{ with .PeriodBegins }} from {{ . }}{{ end }}{{ with .PeriodEnds }} to {{ . }}{{ end }}.{{ with .Instruction }} {{ . }}{{ else }}{{ with .Description }} {{ . }}{{ end }}{{ end }}
{{ end }}{{ end }}{{ with .Modules.SPCConvectiveOutlooks }}{{ range .RiskDigest -}}
- **SPC Convective Outlook**: {{ with .RiskLabel }}{{ . }}{{ else }}Convective risk{{ end }} for severe thunderstorms in effect{{ with .PeriodBegins }} from {{ . }}{{ end }}{{ with .PeriodEnds }} to {{ . }}{{ end }}.
{{ end }}{{ end }}
{{ end }}{{ end }}

View File

@@ -0,0 +1,5 @@
{{ define "daypart_forecast" }}## Daypart Forecast
{{ with .Modules.Dayparts }}{{ range . }}{{ $daypart := . }}- **{{ if .Summary.DisplayName }}{{ .Summary.DisplayName }}{{ else }}{{ .Key }}{{ end }}:** {{ with .Summary.DominantConditionDisplay }}{{ . }}{{ else }}{{ with .Summary.DominantCondition }}{{ . }}{{ else }}Forecast details are limited{{ end }}{{ end }}{{ if eq .Summary.TemperatureTrend "rising" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures rising from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "falling" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures falling from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "peaking" }}{{ with .Summary.TemperaturePeakPhraseF }}, with temperatures peaking in the {{ . }}{{ end }}{{ else }}{{ with .Summary.TemperatureSteadyPhraseF }}, with temperatures in the {{ . }}{{ else }}{{ with .Summary.TemperaturePhraseF }}, with temperatures in the {{ . }}{{ end }}{{ end }}{{ end }}.{{ if .Summary.MentionPrecipitation }}{{ with .Summary.MaxPopPercent }} Chance of precipitation is {{ . }}%.{{ end }}{{ end }}
{{ end }}{{ else }}- No daypart forecast details are available.
{{ end }}
{{ end }}

View File

@@ -0,0 +1,8 @@
{{ define "precipitation_timing" }}{{ with .Modules.PrecipTiming }}{{ with .PrecipitationWindows }}
## Precipitation Timing
{{ range . }}{{ $window := . }}
- **{{ if or .PeriodEndsHourLabel .PeriodEnds }}{{ if .PeriodBeginsHourLabel }}{{ .PeriodBeginsHourLabel }}{{ else }}{{ .PeriodBegins }}{{ end }}{{ else }}Starting at {{ if .PeriodBeginsHourLabel }}{{ .PeriodBeginsHourLabel }}{{ else }}{{ .PeriodBegins }}{{ end }}{{ end }}**{{ with .PeriodEndsHourLabel }} to **{{ . }}**{{ else }}{{ with .PeriodEnds }} to **{{ . }}**{{ end }}{{ end }}: {{ with .ExpectationPhrase }}{{ . }}{{ else }}Chance of precipitation.{{ end }}{{ with .MaxPopPercent }} The peak precipitation chance is {{ . }}%{{ with $window.MaxPopHourLabel }} at {{ . }}{{ else }}{{ with $window.MaxPopTime }} at {{ . }}{{ end }}{{ end }}.{{ end }}
{{ end }}{{ with $.GeneratedText.PrecipitationTiming }}
{{ . }}
{{ end }}
{{ end }}{{ end }}{{ end }}

View File

@@ -0,0 +1,5 @@
{{ define "today_daypart_forecast" }}## Daypart Forecast
{{ with .Modules.Dayparts }}{{ range . }}{{ $daypart := . }}{{ if or .Summary.DominantConditionDisplay .Summary.DominantCondition }}- **{{ if .Summary.DisplayName }}{{ .Summary.DisplayName }}{{ else }}{{ .Key }}{{ end }}:** {{ with .Summary.DominantConditionDisplay }}{{ . }}{{ else }}{{ with .Summary.DominantCondition }}{{ . }}{{ end }}{{ end }}{{ if eq .Summary.TemperatureTrend "rising" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures rising from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "falling" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures falling from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "peaking" }}{{ with .Summary.TemperaturePeakPhraseF }}, with temperatures peaking in the {{ . }}{{ end }}{{ else }}{{ with .Summary.TemperatureSteadyPhraseF }}, with temperatures in the {{ . }}{{ else }}{{ with .Summary.TemperaturePhraseF }}, with temperatures in the {{ . }}{{ end }}{{ end }}{{ end }}.{{ if .Summary.MentionPrecipitation }}{{ with .Summary.MaxPopPercent }} Chance of precipitation is {{ . }}%.{{ end }}{{ end }}
{{ end }}{{ end }}{{ else }}- No daypart forecast details are available.
{{ end }}
{{ end }}

View File

@@ -5,24 +5,12 @@
{{ .GeneratedText.Summary }} {{ .GeneratedText.Summary }}
{{ with .Modules.CurrentConditions }} {{ template "alert_digest" . }}{{ with .Modules.CurrentConditions }}
## Current Conditions ## Current Conditions
{{ with .TemperatureF }}Currently, it is {{ . }}°F{{ with $.Modules.CurrentConditions.ConditionTextLower }} and {{ . }}{{ end }}.{{ else }}{{ with .ConditionText }}Currently, it is {{ . }}.{{ else }}Current conditions are unavailable.{{ end }}{{ end }}{{ with .ApparentTemperatureF }} It feels like {{ . }}°F{{ with $.Modules.CurrentConditions.RelativeHumidityPercent }}, with a relative humidity of {{ . }}%{{ end }}{{ with $.Modules.CurrentConditions.WindDirectionText }} and winds from the {{ . }}{{ with $.Modules.CurrentConditions.WindSpeedMph }} at {{ . }} mph{{ end }}{{ end }}.{{ else }}{{ with .RelativeHumidityPercent }} Relative humidity is {{ . }}%.{{ end }}{{ with .WindDirectionText }} Winds are from the {{ . }}{{ with $.Modules.CurrentConditions.WindSpeedMph }} at {{ . }} mph{{ end }}.{{ end }}{{ end }} {{ with .TemperatureF }}Currently, it is {{ . }}°F{{ with $.Modules.CurrentConditions.ConditionTextLower }} and {{ . }}{{ end }}.{{ else }}{{ with .ConditionText }}Currently, it is {{ . }}.{{ else }}Current conditions are unavailable.{{ end }}{{ end }}{{ with .ApparentTemperatureF }} It feels like {{ . }}°F{{ with $.Modules.CurrentConditions.RelativeHumidityPercent }}, with a relative humidity of {{ . }}%{{ end }}{{ with $.Modules.CurrentConditions.WindDirectionText }} and winds from the {{ . }}{{ with $.Modules.CurrentConditions.WindSpeedMph }} at {{ . }} mph{{ end }}{{ end }}.{{ else }}{{ with .RelativeHumidityPercent }} Relative humidity is {{ . }}%.{{ end }}{{ with .WindDirectionText }} Winds are from the {{ . }}{{ with $.Modules.CurrentConditions.WindSpeedMph }} at {{ . }} mph{{ end }}.{{ end }}{{ end }}
{{ end }} {{ end -}}
## Daypart Forecast {{ template "today_daypart_forecast" . }}{{ if and .Modules.PrecipTiming .Modules.PrecipTiming.PrecipitationWindows }}{{ template "precipitation_timing" . }}{{ else }}{{ end -}}
{{ with .Modules.Dayparts }}{{ range . }}{{ $daypart := . }}{{ if or .Summary.DominantConditionDisplay .Summary.DominantCondition }}- **{{ if .Summary.DisplayName }}{{ .Summary.DisplayName }}{{ else }}{{ .Key }}{{ end }}:** {{ with .Summary.DominantConditionDisplay }}{{ . }}{{ else }}{{ with .Summary.DominantCondition }}{{ . }}{{ end }}{{ end }}{{ if eq .Summary.TemperatureTrend "rising" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures rising from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "falling" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures falling from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "peaking" }}{{ with .Summary.TemperaturePeakPhraseF }}, with temperatures peaking in the {{ . }}{{ end }}{{ else }}{{ with .Summary.TemperatureSteadyPhraseF }}, with temperatures in the {{ . }}{{ else }}{{ with .Summary.TemperaturePhraseF }}, with temperatures in the {{ . }}{{ end }}{{ end }}{{ end }}.{{ if .Summary.MentionPrecipitation }}{{ with .Summary.MaxPopPercent }} Chance of precipitation is {{ . }}%.{{ end }}{{ end }}
{{ end }}{{ end }}{{ else }}- No daypart forecast details are available.
{{ end }}
{{ with .Modules.PrecipTiming }}{{ with .PrecipitationWindows }}
## Precipitation Timing
{{ range . }}{{ $window := . }}
- **{{ if .PeriodBeginsHourLabel }}{{ .PeriodBeginsHourLabel }}{{ else }}{{ .PeriodBegins }}{{ end }}**{{ with .PeriodEndsHourLabel }} to **{{ . }}**{{ else }}{{ with .PeriodEnds }} to **{{ . }}**{{ end }}{{ end }}: Precipitation is expected during this period.{{ with .MaxPopPercent }} The peak precipitation chance is {{ . }}%{{ with $window.MaxPopHourLabel }} at {{ . }}{{ else }}{{ with $window.MaxPopTime }} at {{ . }}{{ end }}{{ end }}.{{ end }}
{{ end }}{{ with $.GeneratedText.PrecipitationTiming }}
{{ . }}
{{ end }}
{{ end }}{{ end }}
## Forecast Discussion ## Forecast Discussion
{{ range .GeneratedText.ForecastDiscussion }} {{ range .GeneratedText.ForecastDiscussion }}
{{ . }} {{ . }}

View File

@@ -5,19 +5,7 @@
{{ .GeneratedText.Summary }} {{ .GeneratedText.Summary }}
## Daypart Forecast {{ template "alert_digest" . }}{{ template "daypart_forecast" . }}{{ if and .Modules.PrecipTiming .Modules.PrecipTiming.PrecipitationWindows }}{{ template "precipitation_timing" . }}{{ else }}{{ end -}}
{{ with .Modules.Dayparts }}{{ range . }}{{ $daypart := . }}- **{{ if .Summary.DisplayName }}{{ .Summary.DisplayName }}{{ else }}{{ .Key }}{{ end }}:** {{ with .Summary.DominantConditionDisplay }}{{ . }}{{ else }}{{ with .Summary.DominantCondition }}{{ . }}{{ else }}Forecast details are limited{{ end }}{{ end }}{{ if eq .Summary.TemperatureTrend "rising" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures rising from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "falling" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures falling from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "peaking" }}{{ with .Summary.TemperaturePeakPhraseF }}, with temperatures peaking in the {{ . }}{{ end }}{{ else }}{{ with .Summary.TemperatureSteadyPhraseF }}, with temperatures in the {{ . }}{{ else }}{{ with .Summary.TemperaturePhraseF }}, with temperatures in the {{ . }}{{ end }}{{ end }}{{ end }}.{{ if .Summary.MentionPrecipitation }}{{ with .Summary.MaxPopPercent }} Chance of precipitation is {{ . }}%.{{ end }}{{ end }}
{{ end }}{{ else }}- No daypart forecast details are available.
{{ end }}
{{ with .Modules.PrecipTiming }}{{ with .PrecipitationWindows }}
## Precipitation Timing
{{ range . }}{{ $window := . }}
- **{{ if .PeriodBeginsHourLabel }}{{ .PeriodBeginsHourLabel }}{{ else }}{{ .PeriodBegins }}{{ end }}**{{ with .PeriodEndsHourLabel }} to **{{ . }}**{{ else }}{{ with .PeriodEnds }} to **{{ . }}**{{ end }}{{ end }}: Precipitation is expected during this period.{{ with .MaxPopPercent }} The peak precipitation chance is {{ . }}%{{ with $window.MaxPopHourLabel }} at {{ . }}{{ else }}{{ with $window.MaxPopTime }} at {{ . }}{{ end }}{{ end }}.{{ end }}
{{ end }}{{ with $.GeneratedText.PrecipitationTiming }}
{{ . }}
{{ end }}
{{ end }}{{ end }}
## Forecast Discussion ## Forecast Discussion
{{ range .GeneratedText.ForecastDiscussion }} {{ range .GeneratedText.ForecastDiscussion }}
{{ . }} {{ . }}

View File

@@ -83,41 +83,35 @@ func (s *FilesystemStore) Paths(resolved report.Resolved) (ArtifactPaths, error)
return ArtifactPaths{}, fmt.Errorf("state store is required") return ArtifactPaths{}, fmt.Errorf("state store is required")
} }
metadata := resolved.Metadata() metadata := resolved.Metadata()
if metadata.RunID == "" { if err := validatePathSegment("run id", metadata.RunID); err != nil {
return ArtifactPaths{}, fmt.Errorf("run id is required") return ArtifactPaths{}, err
} }
group := resolved.Definition.ArtifactGroup group := resolved.Definition.ArtifactGroup
if group == "" { if group == "" {
return ArtifactPaths{}, fmt.Errorf("report %q has no artifact group", resolved.Definition.ID) return ArtifactPaths{}, fmt.Errorf("report %q has no artifact group", resolved.Definition.ID)
} }
validDate := resolved.ValidPeriod.Start.Format("2006-01-02") validDate := resolved.ValidPeriod.Start.Format("2006-01-02")
filenameBase := metadata.RunID
return ArtifactPaths{ return ArtifactPaths{
ModuleSnapshot: s.join(s.snapshotsDir, group, validDate, filenameBase+".modules.json"), ModuleSnapshot: s.join(s.snapshotsDir, group, validDate, "modules."+metadata.RunID+".json"),
Metadata: s.join(s.snapshotsDir, group, validDate, filenameBase+".metadata.json"), Metadata: s.join(s.snapshotsDir, group, validDate, "metadata."+metadata.RunID+".json"),
DataPackage: s.join(s.dataPackagesDir, group, validDate, filenameBase+".data_package.yaml"), DataPackage: s.join(s.dataPackagesDir, group, validDate, "data_package."+metadata.RunID+".yaml"),
Preflight: s.join(s.preflightDir, group, validDate, filenameBase+".render.json"), Preflight: s.join(s.preflightDir, group, validDate, "render."+metadata.RunID+".json"),
Notification: s.join(s.notificationsDir, group, validDate, filenameBase+".distributor.json"), Notification: s.join(s.notificationsDir, group, validDate, "distributor."+metadata.RunID+".json"),
RenderedReport: s.join(s.reportsDir, group, filenameBase+".md"), RenderedReport: s.join(s.reportsDir, group, validDate, "report."+metadata.RunID+".md"),
GeneratedTextRaw: s.join(s.snapshotsDir, group, validDate, filenameBase+".generated_text.raw.json"), GeneratedTextRaw: s.join(s.snapshotsDir, group, validDate, "generated_text_raw."+metadata.RunID+".json"),
GeneratedTextResult: s.join(s.snapshotsDir, group, validDate, filenameBase+".generated_text.run.json"), GeneratedTextResult: s.join(s.snapshotsDir, group, validDate, "generated_text_result."+metadata.RunID+".json"),
GeneratedText: s.join(s.snapshotsDir, group, validDate, filenameBase+".generated_text.json"), GeneratedText: s.join(s.snapshotsDir, group, validDate, "generated_text."+metadata.RunID+".json"),
RenderContext: s.join(s.snapshotsDir, group, validDate, filenameBase+".render_context.json"), RenderContext: s.join(s.snapshotsDir, group, validDate, "render_context."+metadata.RunID+".json"),
}, nil }, nil
} }
func (s *FilesystemStore) SaveModuleSnapshot(_ context.Context, resolved report.Resolved, snapshot module.Snapshot) (string, error) { func (s *FilesystemStore) SaveModuleSnapshot(_ context.Context, resolved report.Resolved, snapshot module.Snapshot) (string, error) {
paths, err := s.Paths(resolved)
if err != nil {
return "", err
}
if err := snapshot.Validate(); err != nil { if err := snapshot.Validate(); err != nil {
return "", err return "", err
} }
if err := fileutil.WriteJSONAtomic(paths.ModuleSnapshot, snapshot); err != nil { return s.saveResolvedJSON(resolved, func(paths ArtifactPaths) string {
return "", err return paths.ModuleSnapshot
} }, snapshot)
return paths.ModuleSnapshot, nil
} }
func (s *FilesystemStore) SaveDataPackage(_ context.Context, resolved report.Resolved, pkg promptinput.Package) (string, error) { func (s *FilesystemStore) SaveDataPackage(_ context.Context, resolved report.Resolved, pkg promptinput.Package) (string, error) {
@@ -132,72 +126,93 @@ func (s *FilesystemStore) SaveDataPackage(_ context.Context, resolved report.Res
} }
func (s *FilesystemStore) SavePreflight(_ context.Context, resolved report.Resolved, artifact PreflightArtifact) (string, error) { func (s *FilesystemStore) SavePreflight(_ context.Context, resolved report.Resolved, artifact PreflightArtifact) (string, error) {
paths, err := s.Paths(resolved) return s.saveResolvedJSON(resolved, func(paths ArtifactPaths) string {
if err != nil { return paths.Preflight
return "", err }, artifact)
}
if err := fileutil.WriteJSONAtomic(paths.Preflight, artifact); err != nil {
return "", err
}
return paths.Preflight, nil
} }
func (s *FilesystemStore) SaveDistributorNotification(_ context.Context, resolved report.Resolved, artifact DistributorNotificationArtifact) (string, error) { func (s *FilesystemStore) SaveDistributorNotification(_ context.Context, resolved report.Resolved, artifact DistributorNotificationArtifact) (string, error) {
paths, err := s.Paths(resolved) if artifact.SchemaVersion == "" {
artifact.SchemaVersion = DistributorNotificationSchemaVersion
}
return s.saveResolvedJSON(resolved, func(paths ArtifactPaths) string {
return paths.Notification
}, artifact)
}
func (s *FilesystemStore) BatchDistributorNotificationPath(ref BatchDistributorNotificationRef) (string, error) {
if s == nil {
return "", fmt.Errorf("state store is required")
}
if err := validateBatchNotificationRef(ref); err != nil {
return "", err
}
localDate := ref.StartedAt.In(ref.Location).Format("2006-01-02")
return s.join(s.notificationsDir, "batches", ref.Batch, localDate, "distributor."+ref.BatchRunID+".json"), nil
}
func (s *FilesystemStore) SaveBatchDistributorNotification(_ context.Context, ref BatchDistributorNotificationRef, artifact BatchDistributorNotificationArtifact) (string, error) {
path, err := s.BatchDistributorNotificationPath(ref)
if err != nil { if err != nil {
return "", err return "", err
} }
if artifact.SchemaVersion == "" { if artifact.SchemaVersion == "" {
artifact.SchemaVersion = DistributorNotificationSchemaVersion artifact.SchemaVersion = BatchDistributorNotificationSchemaVersion
} }
if err := fileutil.WriteJSONAtomic(paths.Notification, artifact); err != nil { artifact.Batch = ref.Batch
artifact.BatchRunID = ref.BatchRunID
if err := fileutil.WriteJSONAtomic(path, artifact); err != nil {
return "", err return "", err
} }
return paths.Notification, nil return path, nil
} }
func (s *FilesystemStore) SaveGeneratedTextRaw(_ context.Context, resolved report.Resolved, data []byte) (string, error) { func (s *FilesystemStore) SaveGeneratedTextRaw(_ context.Context, resolved report.Resolved, data []byte) (string, error) {
paths, err := s.Paths(resolved) return s.saveResolvedBytes(resolved, func(paths ArtifactPaths) string {
if err != nil { return paths.GeneratedTextRaw
return "", err }, data)
}
if err := fileutil.WriteFileAtomic(paths.GeneratedTextRaw, data); err != nil {
return "", err
}
return paths.GeneratedTextRaw, nil
} }
func (s *FilesystemStore) SaveGeneratedTextResult(_ context.Context, resolved report.Resolved, value any) (string, error) { func (s *FilesystemStore) SaveGeneratedTextResult(_ context.Context, resolved report.Resolved, value any) (string, error) {
paths, err := s.Paths(resolved) return s.saveResolvedJSON(resolved, func(paths ArtifactPaths) string {
if err != nil { return paths.GeneratedTextResult
return "", err }, value)
}
if err := fileutil.WriteJSONAtomic(paths.GeneratedTextResult, value); err != nil {
return "", err
}
return paths.GeneratedTextResult, nil
} }
func (s *FilesystemStore) SaveGeneratedText(_ context.Context, resolved report.Resolved, data []byte) (string, error) { func (s *FilesystemStore) SaveGeneratedText(_ context.Context, resolved report.Resolved, data []byte) (string, error) {
paths, err := s.Paths(resolved) return s.saveResolvedBytes(resolved, func(paths ArtifactPaths) string {
if err != nil { return paths.GeneratedText
return "", err }, data)
}
if err := fileutil.WriteFileAtomic(paths.GeneratedText, data); err != nil {
return "", err
}
return paths.GeneratedText, nil
} }
func (s *FilesystemStore) SaveRenderContext(_ context.Context, resolved report.Resolved, value any) (string, error) { func (s *FilesystemStore) SaveRenderContext(_ context.Context, resolved report.Resolved, value any) (string, error) {
return s.saveResolvedJSON(resolved, func(paths ArtifactPaths) string {
return paths.RenderContext
}, value)
}
func (s *FilesystemStore) saveResolvedJSON(resolved report.Resolved, selectPath func(ArtifactPaths) string, value any) (string, error) {
paths, err := s.Paths(resolved) paths, err := s.Paths(resolved)
if err != nil { if err != nil {
return "", err return "", err
} }
if err := fileutil.WriteJSONAtomic(paths.RenderContext, value); err != nil { path := selectPath(paths)
if err := fileutil.WriteJSONAtomic(path, value); err != nil {
return "", err return "", err
} }
return paths.RenderContext, nil return path, nil
}
func (s *FilesystemStore) saveResolvedBytes(resolved report.Resolved, selectPath func(ArtifactPaths) string, data []byte) (string, error) {
paths, err := s.Paths(resolved)
if err != nil {
return "", err
}
path := selectPath(paths)
if err := fileutil.WriteFileAtomic(path, data); err != nil {
return "", err
}
return path, nil
} }
func (s *FilesystemStore) PrepareRenderedReport(_ context.Context, resolved report.Resolved) (string, error) { func (s *FilesystemStore) PrepareRenderedReport(_ context.Context, resolved report.Resolved) (string, error) {
@@ -256,7 +271,7 @@ func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.R
return nil, fmt.Errorf("read snapshot metadata directory %q: %w", dir, err) return nil, fmt.Errorf("read snapshot metadata directory %q: %w", dir, err)
} }
for _, entry := range entries { for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".metadata.json") { if entry.IsDir() || !isMetadataFilename(entry.Name()) {
continue continue
} }
path := filepath.Join(dir, entry.Name()) path := filepath.Join(dir, entry.Name())
@@ -307,7 +322,7 @@ func (s *FilesystemStore) ListReports(_ context.Context, limit int) ([]ReportRec
if err != nil { if err != nil {
return fmt.Errorf("inspect %q: %w", path, err) return fmt.Errorf("inspect %q: %w", path, err)
} }
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".metadata.json") { if entry.IsDir() || !isMetadataFilename(entry.Name()) {
return nil return nil
} }
record, err := s.reportRecord(path) record, err := s.reportRecord(path)
@@ -472,6 +487,43 @@ func validateRelativeDir(name string, value string) error {
return nil return nil
} }
func validateBatchNotificationRef(ref BatchDistributorNotificationRef) error {
if err := validatePathSegment("batch kind", ref.Batch); err != nil {
return err
}
if err := validatePathSegment("batch run id", ref.BatchRunID); err != nil {
return err
}
if ref.StartedAt.IsZero() {
return fmt.Errorf("batch started time is required")
}
if ref.Location == nil {
return fmt.Errorf("batch location is required")
}
return nil
}
func validatePathSegment(name string, value string) error {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("%s is required", name)
}
if strings.ContainsAny(value, `/\`) {
return fmt.Errorf("%s must not contain path separators", name)
}
if value == "." || value == ".." {
return fmt.Errorf("%s must be a safe path segment", name)
}
return nil
}
func isMetadataFilename(name string) bool {
if !strings.HasPrefix(name, "metadata.") || !strings.HasSuffix(name, ".json") {
return false
}
runID := strings.TrimSuffix(strings.TrimPrefix(name, "metadata."), ".json")
return strings.TrimSpace(runID) != "" && !strings.ContainsAny(runID, `/\`) && runID != "." && runID != ".."
}
func readJSON(path string, target any) error { func readJSON(path string, target any) error {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {

View File

@@ -27,12 +27,12 @@ func TestPathsUseRunIDAndWorkspace(t *testing.T) {
} }
for _, want := range []string{ for _, want := range []string{
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily.modules.json"), filepath.Join("snapshots", "daily", "2026-05-29", "modules.20260529T100000.000000000Z_daily_2026-05-29.json"),
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily.metadata.json"), filepath.Join("snapshots", "daily", "2026-05-29", "metadata.20260529T100000.000000000Z_daily_2026-05-29.json"),
filepath.Join("data-packages", "daily", "2026-05-29", "20260529T100000.000000000Z_daily.data_package.yaml"), filepath.Join("data-packages", "daily", "2026-05-29", "data_package.20260529T100000.000000000Z_daily_2026-05-29.yaml"),
filepath.Join("preflight", "daily", "2026-05-29", "20260529T100000.000000000Z_daily.render.json"), filepath.Join("preflight", "daily", "2026-05-29", "render.20260529T100000.000000000Z_daily_2026-05-29.json"),
filepath.Join("notifications", "daily", "2026-05-29", "20260529T100000.000000000Z_daily.distributor.json"), filepath.Join("notifications", "daily", "2026-05-29", "distributor.20260529T100000.000000000Z_daily_2026-05-29.json"),
filepath.Join("reports", "daily", "20260529T100000.000000000Z_daily.md"), filepath.Join("reports", "daily", "2026-05-29", "report.20260529T100000.000000000Z_daily_2026-05-29.md"),
} { } {
if !strings.Contains(pathsString(paths), want) { if !strings.Contains(pathsString(paths), want) {
t.Fatalf("paths = %#v, want component %q", paths, want) t.Fatalf("paths = %#v, want component %q", paths, want)
@@ -40,6 +40,236 @@ func TestPathsUseRunIDAndWorkspace(t *testing.T) {
} }
} }
func TestDailyPathsUseRunIDValidDateDisambiguator(t *testing.T) {
store := newTestStore(t)
first := resolveDailyForDateAt(t, "2026-05-29T05:00:00-05:00", "2026-05-31T12:00:00-05:00")
second := resolveDailyForDateAt(t, "2026-05-29T05:00:00-05:00", "2026-06-01T12:00:00-05:00")
firstPaths, err := store.Paths(first)
if err != nil {
t.Fatalf("Paths(first) error = %v", err)
}
secondPaths, err := store.Paths(second)
if err != nil {
t.Fatalf("Paths(second) error = %v", err)
}
if first.Metadata().RunID == second.Metadata().RunID {
t.Fatalf("RunIDs both = %q, want distinct daily run ids", first.Metadata().RunID)
}
for name, values := range map[string][2]string{
"RenderedReport": {firstPaths.RenderedReport, secondPaths.RenderedReport},
"Metadata": {firstPaths.Metadata, secondPaths.Metadata},
"DataPackage": {firstPaths.DataPackage, secondPaths.DataPackage},
"Notification": {firstPaths.Notification, secondPaths.Notification},
} {
if values[0] == values[1] {
t.Fatalf("%s paths both = %q, want distinct daily artifact paths", name, values[0])
}
}
}
func TestPathsRejectRunIDPathSeparators(t *testing.T) {
store := newTestStore(t)
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
resolved.Definition.ID = report.ID("daily/bad")
_, err := store.Paths(resolved)
if err == nil {
t.Fatal("Paths() error = nil, want invalid run id error")
}
if !strings.Contains(err.Error(), "run id must not contain path separators") {
t.Fatalf("error = %q, want run id path separator context", err.Error())
}
}
func TestBatchDistributorNotificationPathUsesWorkspaceBatchDateAndRunID(t *testing.T) {
store := newTestStore(t)
location := mustLoadStateLocation(t, "America/Chicago")
startedAt := time.Date(2026, 6, 18, 3, 30, 0, 123456789, time.UTC)
path, err := store.BatchDistributorNotificationPath(BatchDistributorNotificationRef{
Batch: "evening",
BatchRunID: "20260618T033000.123456789Z_evening",
StartedAt: startedAt,
Location: location,
})
if err != nil {
t.Fatalf("BatchDistributorNotificationPath() error = %v", err)
}
want := filepath.Join("notifications", "batches", "evening", "2026-06-17", "distributor.20260618T033000.123456789Z_evening.json")
if !strings.Contains(path, want) {
t.Fatalf("path = %q, want component %q", path, want)
}
if !strings.HasPrefix(path, store.root) {
t.Fatalf("path = %q, want workspace root prefix %q", path, store.root)
}
}
func TestSaveBatchDistributorNotificationRoundTrip(t *testing.T) {
store := newTestStore(t)
location := mustLoadStateLocation(t, "America/Chicago")
startedAt := time.Date(2026, 6, 17, 12, 0, 0, 0, time.UTC)
bundleCreated := startedAt.Add(2 * time.Second)
attemptedAt := startedAt.Add(3 * time.Second)
acceptedAt := startedAt.Add(4 * time.Second)
finishedAt := startedAt.Add(5 * time.Second)
ref := BatchDistributorNotificationRef{
Batch: "morning",
BatchRunID: "20260617T120000.000000000Z_morning",
StartedAt: startedAt,
Location: location,
}
path, err := store.SaveBatchDistributorNotification(context.Background(), ref, BatchDistributorNotificationArtifact{
AttemptedAt: attemptedAt,
Endpoint: "https://distributor.example.test",
PipelineID: "weatherreporter",
BundleID: "weatherreporter.home.morning",
IdempotencyKey: "weatherreporter.home.morning.20260617T120000.000000000Z_morning",
BundleCreated: bundleCreated,
Reports: []BatchDistributorNotificationReportArtifact{
{
ReportID: report.Today,
RunID: "20260617T120000.000000000Z_today",
SourcePath: "/workspace/reports/today/2026-06-17/report.20260617T120000.000000000Z_today.md",
BundlePaths: []string{"2026-06-17/today/report.md"},
},
{
ReportID: report.Daily,
RunID: "20260617T120000.000000000Z_daily_2026-06-19",
SourcePath: "/workspace/reports/daily/2026-06-19/report.20260617T120000.000000000Z_daily_2026-06-19.md",
BundlePaths: []string{"2026-06-19/daily/report.md"},
},
},
Status: "failed",
Upload: &DistributorUploadResult{
RunID: "distributor-run",
Status: "accepted",
},
RunStatus: &DistributorRunStatus{
RunID: "distributor-run",
PipelineID: "weatherreporter",
Status: "failed",
AcceptedAt: acceptedAt,
FinishedAt: &finishedAt,
Report: json.RawMessage(`{"actions":[{"action":"failed"}]}`),
Error: "destination conflict",
},
StatusError: "status lookup failed",
Error: "batch upload failed",
})
if err != nil {
t.Fatalf("SaveBatchDistributorNotification() error = %v", err)
}
wantPath := filepath.Join("notifications", "batches", "morning", "2026-06-17", "distributor.20260617T120000.000000000Z_morning.json")
if !strings.Contains(path, wantPath) {
t.Fatalf("path = %q, want component %q", path, wantPath)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read batch notification: %v", err)
}
var artifact BatchDistributorNotificationArtifact
if err := json.Unmarshal(data, &artifact); err != nil {
t.Fatalf("decode batch notification: %v", err)
}
if artifact.SchemaVersion != BatchDistributorNotificationSchemaVersion {
t.Fatalf("SchemaVersion = %q, want %q", artifact.SchemaVersion, BatchDistributorNotificationSchemaVersion)
}
if artifact.Batch != "morning" || artifact.BatchRunID != ref.BatchRunID {
t.Fatalf("artifact batch identity = %q/%q, want ref values", artifact.Batch, artifact.BatchRunID)
}
if artifact.Endpoint != "https://distributor.example.test" || artifact.PipelineID != "weatherreporter" || artifact.BundleID != "weatherreporter.home.morning" || artifact.IdempotencyKey == "" {
t.Fatalf("artifact identity = %#v, want distributor identity", artifact)
}
if len(artifact.Reports) != 2 || artifact.Reports[0].ReportID != report.Today || strings.Join(artifact.Reports[1].BundlePaths, ",") != "2026-06-19/daily/report.md" {
t.Fatalf("Reports = %#v, want included report records", artifact.Reports)
}
if artifact.Upload == nil || artifact.Upload.RunID != "distributor-run" {
t.Fatalf("Upload = %#v, want accepted upload result", artifact.Upload)
}
if artifact.RunStatus == nil || artifact.RunStatus.Status != "failed" || !strings.Contains(string(artifact.RunStatus.Report), "failed") || artifact.RunStatus.FinishedAt == nil {
t.Fatalf("RunStatus = %#v, want failed run status with raw report", artifact.RunStatus)
}
if artifact.StatusError != "status lookup failed" || artifact.Error != "batch upload failed" {
t.Fatalf("errors = %q/%q, want persisted error fields", artifact.StatusError, artifact.Error)
}
}
func TestBatchDistributorNotificationPathRejectsInvalidIdentity(t *testing.T) {
store := newTestStore(t)
location := mustLoadStateLocation(t, "America/Chicago")
valid := BatchDistributorNotificationRef{
Batch: "morning",
BatchRunID: "20260617T120000.000000000Z_morning",
StartedAt: time.Date(2026, 6, 17, 12, 0, 0, 0, time.UTC),
Location: location,
}
tests := []struct {
name string
mutate func(*BatchDistributorNotificationRef)
wantErr string
}{
{
name: "Batch",
mutate: func(ref *BatchDistributorNotificationRef) {
ref.Batch = ""
},
wantErr: "batch kind is required",
},
{
name: "BatchSeparator",
mutate: func(ref *BatchDistributorNotificationRef) {
ref.Batch = "../morning"
},
wantErr: "batch kind must not contain path separators",
},
{
name: "BatchRunID",
mutate: func(ref *BatchDistributorNotificationRef) {
ref.BatchRunID = ""
},
wantErr: "batch run id is required",
},
{
name: "BatchRunIDSeparator",
mutate: func(ref *BatchDistributorNotificationRef) {
ref.BatchRunID = "nested/run"
},
wantErr: "batch run id must not contain path separators",
},
{
name: "StartedAt",
mutate: func(ref *BatchDistributorNotificationRef) {
ref.StartedAt = time.Time{}
},
wantErr: "batch started time is required",
},
{
name: "Location",
mutate: func(ref *BatchDistributorNotificationRef) {
ref.Location = nil
},
wantErr: "batch location is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ref := valid
tt.mutate(&ref)
_, err := store.BatchDistributorNotificationPath(ref)
if err == nil {
t.Fatal("BatchDistributorNotificationPath() error = nil, want error")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
}
})
}
}
func TestGeneratedTextArtifactPathsUseSnapshotTree(t *testing.T) { func TestGeneratedTextArtifactPathsUseSnapshotTree(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
tests := []struct { tests := []struct {
@@ -54,7 +284,7 @@ func TestGeneratedTextArtifactPathsUseSnapshotTree(t *testing.T) {
resolved: resolveDailyAt(t, "2026-05-29T05:00:00-05:00"), resolved: resolveDailyAt(t, "2026-05-29T05:00:00-05:00"),
group: "daily", group: "daily",
validDate: "2026-05-29", validDate: "2026-05-29",
runID: "20260529T100000.000000000Z_daily", runID: "20260529T100000.000000000Z_daily_2026-05-29",
}, },
{ {
name: "hourly", name: "hourly",
@@ -86,15 +316,23 @@ func TestGeneratedTextArtifactPathsUseSnapshotTree(t *testing.T) {
t.Fatalf("Paths() error = %v", err) t.Fatalf("Paths() error = %v", err)
} }
wants := map[string]string{ wants := map[string]string{
"DataPackage": filepath.Join("data-packages", tt.group, tt.validDate, tt.runID+".data_package.yaml"), "ModuleSnapshot": filepath.Join("snapshots", tt.group, tt.validDate, "modules."+tt.runID+".json"),
"RenderedReport": filepath.Join("reports", tt.group, tt.runID+".md"), "Metadata": filepath.Join("snapshots", tt.group, tt.validDate, "metadata."+tt.runID+".json"),
"GeneratedTextRaw": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".generated_text.raw.json"), "DataPackage": filepath.Join("data-packages", tt.group, tt.validDate, "data_package."+tt.runID+".yaml"),
"GeneratedTextResult": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".generated_text.run.json"), "Preflight": filepath.Join("preflight", tt.group, tt.validDate, "render."+tt.runID+".json"),
"GeneratedText": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".generated_text.json"), "Notification": filepath.Join("notifications", tt.group, tt.validDate, "distributor."+tt.runID+".json"),
"RenderContext": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".render_context.json"), "RenderedReport": filepath.Join("reports", tt.group, tt.validDate, "report."+tt.runID+".md"),
"GeneratedTextRaw": filepath.Join("snapshots", tt.group, tt.validDate, "generated_text_raw."+tt.runID+".json"),
"GeneratedTextResult": filepath.Join("snapshots", tt.group, tt.validDate, "generated_text_result."+tt.runID+".json"),
"GeneratedText": filepath.Join("snapshots", tt.group, tt.validDate, "generated_text."+tt.runID+".json"),
"RenderContext": filepath.Join("snapshots", tt.group, tt.validDate, "render_context."+tt.runID+".json"),
} }
got := map[string]string{ got := map[string]string{
"ModuleSnapshot": paths.ModuleSnapshot,
"Metadata": paths.Metadata,
"DataPackage": paths.DataPackage, "DataPackage": paths.DataPackage,
"Preflight": paths.Preflight,
"Notification": paths.Notification,
"RenderedReport": paths.RenderedReport, "RenderedReport": paths.RenderedReport,
"GeneratedTextRaw": paths.GeneratedTextRaw, "GeneratedTextRaw": paths.GeneratedTextRaw,
"GeneratedTextResult": paths.GeneratedTextResult, "GeneratedTextResult": paths.GeneratedTextResult,
@@ -394,7 +632,7 @@ func TestSaveMetadataUsesExplicitMetadataPath(t *testing.T) {
t.Fatalf("Paths() error = %v", err) t.Fatalf("Paths() error = %v", err)
} }
otherDir := filepath.Join(t.TempDir(), "other-artifacts") otherDir := filepath.Join(t.TempDir(), "other-artifacts")
derivedMetadataPath := filepath.Join(otherDir, resolved.Metadata().RunID+".metadata.json") derivedMetadataPath := filepath.Join(otherDir, "metadata."+resolved.Metadata().RunID+".json")
metadata := BuildMetadataFromBriefingMetadata(resolved, briefingMetadata, ArtifactPaths{ metadata := BuildMetadataFromBriefingMetadata(resolved, briefingMetadata, ArtifactPaths{
ModuleSnapshot: paths.ModuleSnapshot, ModuleSnapshot: paths.ModuleSnapshot,
@@ -418,11 +656,79 @@ func TestSaveMetadataUsesExplicitMetadataPath(t *testing.T) {
} }
} }
func TestListReportsDiscoversNewMetadataFilename(t *testing.T) {
store := newTestStore(t)
older := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
newer := resolveTodayAt(t, "2026-05-29T08:00:00-05:00")
olderPaths := savePriorMetadata(t, store, older, stateBriefingMetadata(older))
newerPaths := savePriorMetadata(t, store, newer, stateBriefingMetadata(newer))
records, err := store.ListReports(context.Background(), 0)
if err != nil {
t.Fatalf("ListReports() error = %v", err)
}
if len(records) != 2 {
t.Fatalf("ListReports() len = %d, want 2: %#v", len(records), records)
}
if records[0].RunID != newer.Metadata().RunID || records[0].MetadataPath != newerPaths.Metadata {
t.Fatalf("first record = %#v, want newer metadata path %q", records[0], newerPaths.Metadata)
}
if records[1].RunID != older.Metadata().RunID || records[1].MetadataPath != olderPaths.Metadata {
t.Fatalf("second record = %#v, want older metadata path %q", records[1], olderPaths.Metadata)
}
metadata, metadataPath, err := store.LoadMetadataByRunID(context.Background(), older.Metadata().RunID)
if err != nil {
t.Fatalf("LoadMetadataByRunID() error = %v", err)
}
if metadata.RunID != older.Metadata().RunID || metadataPath != olderPaths.Metadata {
t.Fatalf("loaded metadata = %#v path %q, want run %q path %q", metadata, metadataPath, older.Metadata().RunID, olderPaths.Metadata)
}
if want := "metadata." + older.Metadata().RunID + ".json"; filepath.Base(metadataPath) != want {
t.Fatalf("metadata filename = %q, want %q", filepath.Base(metadataPath), want)
}
}
func TestListReportsIgnoresNonMetadataJSON(t *testing.T) {
store := newTestStore(t)
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
paths := savePriorMetadata(t, store, resolved, stateBriefingMetadata(resolved))
for _, path := range []string{
paths.ModuleSnapshot,
paths.GeneratedTextRaw,
paths.GeneratedTextResult,
paths.GeneratedText,
paths.RenderContext,
filepath.Join(filepath.Dir(paths.Metadata), resolved.Metadata().RunID+"."+"metadata.json"),
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("create non-metadata artifact directory: %v", err)
}
if err := os.WriteFile(path, []byte(`{not json`), 0o600); err != nil {
t.Fatalf("write non-metadata artifact %q: %v", path, err)
}
}
records, err := store.ListReports(context.Background(), 0)
if err != nil {
t.Fatalf("ListReports() error = %v", err)
}
if len(records) != 1 {
t.Fatalf("ListReports() len = %d, want only metadata record: %#v", len(records), records)
}
if records[0].MetadataPath != paths.Metadata {
t.Fatalf("MetadataPath = %q, want %q", records[0].MetadataPath, paths.Metadata)
}
}
func TestFindPriorSnapshot(t *testing.T) { func TestFindPriorSnapshot(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
first := resolveDailyAt(t, "2026-05-29T05:00:00-05:00") first := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
second := resolveDailyAt(t, "2026-05-29T08:00:00-05:00") second := resolveDailyAt(t, "2026-05-29T08:00:00-05:00")
paths := savePriorMetadata(t, store, first, stateBriefingMetadata(first)) paths := savePriorMetadata(t, store, first, stateBriefingMetadata(first))
if want := "metadata." + first.Metadata().RunID + ".json"; filepath.Base(paths.Metadata) != want {
t.Fatalf("metadata filename = %q, want %q", filepath.Base(paths.Metadata), want)
}
prior, err := store.FindPriorSnapshot(context.Background(), second) prior, err := store.FindPriorSnapshot(context.Background(), second)
if err != nil { if err != nil {
@@ -566,20 +872,38 @@ func newTestStore(t *testing.T) *FilesystemStore {
return store return store
} }
func mustLoadStateLocation(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 resolveDailyAt(t *testing.T, value string) report.Resolved { func resolveDailyAt(t *testing.T, value string) report.Resolved {
t.Helper()
return resolveDailyForDateAt(t, value, value)
}
func resolveDailyForDateAt(t *testing.T, nowValue string, dateValue string) report.Resolved {
t.Helper() t.Helper()
location, err := timeutil.LoadLocation("America/Chicago") location, err := timeutil.LoadLocation("America/Chicago")
if err != nil { if err != nil {
t.Fatalf("LoadLocation() error = %v", err) t.Fatalf("LoadLocation() error = %v", err)
} }
now, err := time.Parse(time.RFC3339, value) now, err := time.Parse(time.RFC3339, nowValue)
if err != nil { if err != nil {
t.Fatalf("parse time: %v", err) t.Fatalf("parse now time: %v", err)
}
date, err := time.Parse(time.RFC3339, dateValue)
if err != nil {
t.Fatalf("parse date time: %v", err)
} }
resolved, err := report.DefaultRegistry().Resolve(report.Daily, report.ResolveRequest{ resolved, err := report.DefaultRegistry().Resolve(report.Daily, report.ResolveRequest{
Now: now, Now: now,
Location: location, Location: location,
Date: now, Date: date,
}) })
if err != nil { if err != nil {
t.Fatalf("Resolve() error = %v", err) t.Fatalf("Resolve() error = %v", err)

View File

@@ -17,6 +17,7 @@ type Store interface {
SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error) SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error)
SavePreflight(context.Context, report.Resolved, PreflightArtifact) (string, error) SavePreflight(context.Context, report.Resolved, PreflightArtifact) (string, error)
SaveDistributorNotification(context.Context, report.Resolved, DistributorNotificationArtifact) (string, error) SaveDistributorNotification(context.Context, report.Resolved, DistributorNotificationArtifact) (string, error)
SaveBatchDistributorNotification(context.Context, BatchDistributorNotificationRef, BatchDistributorNotificationArtifact) (string, error)
SaveGeneratedTextRaw(context.Context, report.Resolved, []byte) (string, error) SaveGeneratedTextRaw(context.Context, report.Resolved, []byte) (string, error)
SaveGeneratedTextResult(context.Context, report.Resolved, any) (string, error) SaveGeneratedTextResult(context.Context, report.Resolved, any) (string, error)
SaveGeneratedText(context.Context, report.Resolved, []byte) (string, error) SaveGeneratedText(context.Context, report.Resolved, []byte) (string, error)
@@ -45,6 +46,14 @@ type PreflightArtifact struct {
} }
const DistributorNotificationSchemaVersion = "weatherreporter.distributor_notification.v1" const DistributorNotificationSchemaVersion = "weatherreporter.distributor_notification.v1"
const BatchDistributorNotificationSchemaVersion = "weatherreporter.batch_distributor_notification.v1"
type BatchDistributorNotificationRef struct {
Batch string
BatchRunID string
StartedAt time.Time
Location *time.Location
}
type DistributorNotificationArtifact struct { type DistributorNotificationArtifact struct {
SchemaVersion string `json:"schemaVersion"` SchemaVersion string `json:"schemaVersion"`
@@ -80,3 +89,28 @@ type DistributorRunStatus struct {
Report json.RawMessage `json:"report,omitempty"` Report json.RawMessage `json:"report,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
type BatchDistributorNotificationArtifact struct {
SchemaVersion string `json:"schemaVersion"`
Batch string `json:"batch"`
BatchRunID string `json:"batchRunId"`
AttemptedAt time.Time `json:"attemptedAt"`
Endpoint string `json:"endpoint"`
PipelineID string `json:"pipelineId,omitempty"`
BundleID string `json:"bundleId,omitempty"`
IdempotencyKey string `json:"idempotencyKey,omitempty"`
BundleCreated time.Time `json:"bundleCreated,omitempty"`
Reports []BatchDistributorNotificationReportArtifact `json:"includedReports,omitempty"`
Status string `json:"status"`
Upload *DistributorUploadResult `json:"upload,omitempty"`
RunStatus *DistributorRunStatus `json:"runStatus,omitempty"`
StatusError string `json:"statusError,omitempty"`
Error string `json:"error,omitempty"`
}
type BatchDistributorNotificationReportArtifact struct {
ReportID report.ID `json:"reportId"`
RunID string `json:"runId"`
SourcePath string `json:"sourcePath"`
BundlePaths []string `json:"bundlePaths"`
}