Compare commits
65 Commits
d90801cff5
...
v0.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 8dd604afb4 | |||
| 52bb17c8fa | |||
| 7952e4fb25 | |||
| 0281327365 | |||
| bf76eae301 | |||
| 0d47662cf9 | |||
| f4f009b904 | |||
| 3c1b753952 | |||
| bdbab48d10 | |||
| 16cc4b3f63 | |||
| 0ef861ed8f | |||
| 6ae7eb44cf | |||
| 8f6aa8aa8b | |||
| b8e889ad13 | |||
| 15ee4af1a1 | |||
| 4c606eb39f | |||
| 8d2ac163ae | |||
| 8709b5f4d8 | |||
| fd48ebecb8 | |||
| 021e5dd8b1 | |||
| 7adf5e1b08 | |||
| 455cc67d4c | |||
| dd3133ee2a | |||
| b3637cddd6 | |||
| 662db5e511 | |||
| 2ef91cf1b1 | |||
| 1d2f176977 | |||
| 2b3bcdd4f1 | |||
| a82f03feb8 | |||
| f1d4e38414 | |||
| 32060bd370 | |||
| 133f83f4ce | |||
| 42f0e16b02 | |||
| a2f0a2fc36 | |||
| 9f552cff6b | |||
| b913194fb4 | |||
| 3eccafad6b | |||
| a9d87bdbaa | |||
| 6f9255105d | |||
| c04e3c5599 | |||
| f15315f1b9 | |||
| 0ef6cd567e | |||
| b308ff4d6b | |||
| 5ecbc06c85 | |||
| 21e97f5d4e | |||
| 3900b3313b | |||
| 5d416cfc4a | |||
| 6532e8824a | |||
| d321492995 | |||
| b57110e5c8 | |||
| 482e83903c | |||
| 21a7748b2c | |||
| b36e198bfe | |||
| f9d6d42b1b | |||
| d9ab1e47ec | |||
| 4f755704d9 | |||
| a13f04fce5 | |||
| 1f5b347cd2 | |||
| 3639636813 | |||
| ca27d81163 | |||
| d74ba0f259 | |||
| 121f28fd29 | |||
| e3bcecc5c1 | |||
| 0884eb0ce5 | |||
| a27e870522 |
153
docs/cli.md
153
docs/cli.md
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
67
docs/internal/cli.md
Normal 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
58
docs/internal/collect.md
Normal 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`.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,348 +0,0 @@
|
|||||||
# Daily Report Roadmap
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
This roadmap defines the target state and policy choices for replacing the
|
|
||||||
existing `daily` report with a new generated-text-template `daily` report.
|
|
||||||
|
|
||||||
The new report is not implemented yet. Current report behavior remains
|
|
||||||
documented outside `docs/roadmap/`.
|
|
||||||
|
|
||||||
## Intent
|
|
||||||
|
|
||||||
Daily should be an independent generated-text-template report for a user-chosen
|
|
||||||
local civil day. Its rendered Markdown should initially match the Tomorrow
|
|
||||||
Report format exactly, but its identity, prompt, template, schema, valid-period
|
|
||||||
resolver, config key, and planning module should be separate from Tomorrow from
|
|
||||||
the start.
|
|
||||||
|
|
||||||
The important distinction is date selection:
|
|
||||||
|
|
||||||
- `tomorrow` always targets the next local civil day.
|
|
||||||
- `daily` targets the local civil day explicitly supplied by the user with
|
|
||||||
`--date YYYY-MM-DD`.
|
|
||||||
|
|
||||||
This is a clean breaking replacement of the current Daily implementation:
|
|
||||||
|
|
||||||
- The existing direct-Markdown `daily` report is removed.
|
|
||||||
- The legacy `daily_today` report ID is removed from active report definitions.
|
|
||||||
- `weatherreporter generate daily` remains the public command name, but it now
|
|
||||||
runs the new generated-text-template Daily report.
|
|
||||||
- `weatherreporter generate daily` requires `--date YYYY-MM-DD`.
|
|
||||||
- Existing historical `daily_today` workspace artifacts do not need migration.
|
|
||||||
|
|
||||||
## Locked Decisions
|
|
||||||
|
|
||||||
- New report ID: `daily`.
|
|
||||||
- Public command: `generate daily`.
|
|
||||||
- `generate daily` must require `--date YYYY-MM-DD`.
|
|
||||||
- The provided date is interpreted as a civil date in the effective report
|
|
||||||
timezone.
|
|
||||||
- The valid period is the selected local civil day, `[00:00, next 00:00)`.
|
|
||||||
- Daily should not be an alias for Today or Tomorrow.
|
|
||||||
- Today remains the current-day scheduled morning product.
|
|
||||||
- Tomorrow remains the next-day scheduled evening product.
|
|
||||||
- Daily is manually targeted by date and is not added to morning or evening
|
|
||||||
batch membership in this roadmap.
|
|
||||||
- Daily must have its own template, generated-text schema, prompt asset,
|
|
||||||
render-context type, and planning module.
|
|
||||||
- Daily may share private helper functions with Tomorrow where mechanics are
|
|
||||||
identical, but it must not expose Tomorrow-specific public types or stanzas.
|
|
||||||
- The initial Daily output format should match Tomorrow's rendered Markdown
|
|
||||||
format.
|
|
||||||
|
|
||||||
## Target Report Shape
|
|
||||||
|
|
||||||
Daily should render the same Markdown structure as Tomorrow:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# Monday's Weather
|
|
||||||
|
|
||||||
**Forecast date:** Monday, June 15, 2026
|
|
||||||
**Updated:** Sunday, June 14, 2026 at 9:14 AM
|
|
||||||
|
|
||||||
<GeneratedText summary>
|
|
||||||
|
|
||||||
## Daypart Forecast
|
|
||||||
|
|
||||||
- **Morning:** <deterministic daypart line>
|
|
||||||
- **Midday:** <deterministic daypart line>
|
|
||||||
- **Afternoon:** <deterministic daypart line>
|
|
||||||
- **Evening:** <deterministic daypart line>
|
|
||||||
|
|
||||||
## Precipitation Timing
|
|
||||||
|
|
||||||
- **1:00 PM** to **5:00 PM**: Precipitation is expected during this period.
|
|
||||||
The peak precipitation chance is 59% at 2:00 PM.
|
|
||||||
- <optional GeneratedText precipitation_timing>
|
|
||||||
|
|
||||||
## Forecast Discussion
|
|
||||||
|
|
||||||
<GeneratedText forecast_discussion paragraphs>
|
|
||||||
```
|
|
||||||
|
|
||||||
The precipitation section should render only when precipitation windows exist
|
|
||||||
for the selected valid period.
|
|
||||||
|
|
||||||
The title should follow Tomorrow's day-name style, for example:
|
|
||||||
|
|
||||||
- `Monday's Weather`
|
|
||||||
- `Tuesday's Weather`
|
|
||||||
- `Sunday's Weather`
|
|
||||||
|
|
||||||
## Report Identity
|
|
||||||
|
|
||||||
Replace the existing active Daily report with:
|
|
||||||
|
|
||||||
- report ID: `daily`
|
|
||||||
- public generate command: `daily`
|
|
||||||
- prompt ID: `weather.daily_generated_text`
|
|
||||||
- generation mode: `generated_text_template`
|
|
||||||
- template ID: `daily`
|
|
||||||
- generated-text schema ID: `daily`
|
|
||||||
- artifact group: `daily`
|
|
||||||
- batch output name: `daily.md`
|
|
||||||
- prior compatibility: Daily only
|
|
||||||
- comparison strategy: same valid local date
|
|
||||||
- valid period: selected local civil day in the effective report timezone,
|
|
||||||
`[00:00, next 00:00)`
|
|
||||||
|
|
||||||
Remove the legacy active report identity:
|
|
||||||
|
|
||||||
- remove active report ID `daily_today`
|
|
||||||
- remove prompt ID `weather.daily_report` from the current Daily path
|
|
||||||
- remove direct-Markdown generation mode from the Daily report definition
|
|
||||||
- remove `daily_today` config-key support unless a separate migration roadmap
|
|
||||||
explicitly reintroduces it
|
|
||||||
|
|
||||||
Historical artifacts with `daily_today` metadata may remain on disk. Do not
|
|
||||||
migrate or rewrite old workspace files in this feature.
|
|
||||||
|
|
||||||
## CLI Behavior
|
|
||||||
|
|
||||||
`weatherreporter generate daily` should require:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
weatherreporter generate daily --date YYYY-MM-DD
|
|
||||||
```
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- `--date` is required for `generate daily`.
|
|
||||||
- `--date` accepts only `YYYY-MM-DD`.
|
|
||||||
- The date is interpreted in the effective report timezone after config and
|
|
||||||
`--tz` overrides are applied.
|
|
||||||
- Omitting `--date` is an error.
|
|
||||||
- A malformed date is an error.
|
|
||||||
- The command should continue supporting the existing global generation flags:
|
|
||||||
`--config`, `--units`, `--tz`, and `--out`.
|
|
||||||
- Do not default `daily` to today or tomorrow.
|
|
||||||
|
|
||||||
## Batch Behavior
|
|
||||||
|
|
||||||
Daily should not be added to scheduled batches in this roadmap.
|
|
||||||
|
|
||||||
Current intended scheduled behavior:
|
|
||||||
|
|
||||||
- Morning batch: `today`, `three_day`, and conditional `weekend`.
|
|
||||||
- Evening batch: `tomorrow`.
|
|
||||||
|
|
||||||
Daily is a manually targeted report. A future roadmap may add scheduled Daily
|
|
||||||
behavior if a concrete operational need appears.
|
|
||||||
|
|
||||||
## GeneratedText Contract
|
|
||||||
|
|
||||||
Daily should use the same structured prose shape as Tomorrow:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"summary": "string",
|
|
||||||
"forecast_discussion": ["string"],
|
|
||||||
"precipitation_timing": "string",
|
|
||||||
"confidence": "string"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Required:
|
|
||||||
|
|
||||||
- `summary`
|
|
||||||
- `forecast_discussion`
|
|
||||||
|
|
||||||
Optional:
|
|
||||||
|
|
||||||
- `precipitation_timing`
|
|
||||||
- `confidence`
|
|
||||||
|
|
||||||
Validation should match Tomorrow semantics:
|
|
||||||
|
|
||||||
- reject malformed JSON and unknown fields
|
|
||||||
- reject trailing JSON values
|
|
||||||
- trim `summary`, `precipitation_timing`, and `confidence`
|
|
||||||
- trim each `forecast_discussion` paragraph
|
|
||||||
- drop blank discussion paragraphs
|
|
||||||
- require at least one nonblank discussion paragraph
|
|
||||||
- return canonical normalized JSON with the same public field names
|
|
||||||
|
|
||||||
Add a dedicated prompt asset:
|
|
||||||
|
|
||||||
- `internal/reporttemplate/prompts/daily.generated_text.md`
|
|
||||||
|
|
||||||
Scriptorium registration remains out of band. Weatherreporter should invoke the
|
|
||||||
Daily prompt by prompt ID and pass the data package as it does for other
|
|
||||||
generated-text reports.
|
|
||||||
|
|
||||||
## Template Context
|
|
||||||
|
|
||||||
Add dedicated Daily types under `internal/generatedtext`, rather than reusing
|
|
||||||
Tomorrow types directly:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type DailyRenderContext struct {
|
|
||||||
Report DailyReportContext
|
|
||||||
GeneratedText Daily
|
|
||||||
Modules DailyTemplateModules
|
|
||||||
Collected facts.CollectedFacts
|
|
||||||
Derived facts.DerivedFacts
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`DailyReportContext` should include:
|
|
||||||
|
|
||||||
- `Title`, for example `Monday's Weather`
|
|
||||||
- `ForecastDate`
|
|
||||||
- `ForecastDateLabel`, for example `Monday, June 15, 2026`
|
|
||||||
- `ForecastDayName`, for example `Monday`
|
|
||||||
- `GeneratedAt`
|
|
||||||
- `GeneratedAtLabel`
|
|
||||||
- `ValidPeriod`
|
|
||||||
- `Timezone`
|
|
||||||
|
|
||||||
`DailyTemplateModules` should expose the same categories the Daily template
|
|
||||||
needs:
|
|
||||||
|
|
||||||
- `Metadata`
|
|
||||||
- `CurrentConditions`
|
|
||||||
- `HourlyForecast`
|
|
||||||
- `DerivedDailySummary`
|
|
||||||
- `DerivedDaypartSummaries`
|
|
||||||
- ordered daypart rows
|
|
||||||
- `PrecipTiming`
|
|
||||||
- `AlertDigest`
|
|
||||||
- `SPCConvectiveOutlooks`
|
|
||||||
- `AreaForecastDiscussion`
|
|
||||||
- `SPCConvectiveDiscussion`
|
|
||||||
- `WeatherStory`
|
|
||||||
- `OutdoorWindows`
|
|
||||||
- `DailyPlanning`
|
|
||||||
|
|
||||||
Daily may share private helper functions with Tomorrow render-context
|
|
||||||
construction when the helper represents identical mechanics. Do not expose
|
|
||||||
Tomorrow-specific types through the Daily template context.
|
|
||||||
|
|
||||||
## Module Composition
|
|
||||||
|
|
||||||
The default module composition should initially mirror Tomorrow where the same
|
|
||||||
facts are useful for a dated daily report, with a Daily-specific planning
|
|
||||||
module:
|
|
||||||
|
|
||||||
- `metadata`
|
|
||||||
- `current_conditions`
|
|
||||||
- `narrative_forecast`
|
|
||||||
- `derived_daily_summary`
|
|
||||||
- `derived_daypart_summaries`
|
|
||||||
- `precip_timing`
|
|
||||||
- `alert_digest`
|
|
||||||
- `spc_convective_outlooks`
|
|
||||||
- `area_forecast_discussion`
|
|
||||||
- `spc_convective_discussion`
|
|
||||||
- `weather_story`
|
|
||||||
- `outdoor_windows`
|
|
||||||
- `daily_planning`
|
|
||||||
- `hourly_forecast`
|
|
||||||
|
|
||||||
The module order should match the intended data-package order unless tests show
|
|
||||||
a stronger reason to mirror Tomorrow's exact current order.
|
|
||||||
|
|
||||||
## Daily Planning Module
|
|
||||||
|
|
||||||
Add a Daily-specific deterministic planning module:
|
|
||||||
|
|
||||||
- module ID: `daily_planning`
|
|
||||||
- stanza name: `daily_planning`
|
|
||||||
- options type: `DailyPlanningOptions`
|
|
||||||
- output type: `DailyPlanningModule`
|
|
||||||
- supported report: `daily`
|
|
||||||
|
|
||||||
The module should be initially equivalent to `TomorrowPlanning`, but independent
|
|
||||||
from it:
|
|
||||||
|
|
||||||
- do not reuse the public `TomorrowPlanningModule` type
|
|
||||||
- do not emit the `tomorrow_planning` stanza
|
|
||||||
- do not use `module.TomorrowPlanning` in the Daily default composition
|
|
||||||
|
|
||||||
Recommended initial fields should match Tomorrow planning:
|
|
||||||
|
|
||||||
- `morning_readiness`
|
|
||||||
- `commute_school_workday_concerns`
|
|
||||||
- `overnight_change_watch`
|
|
||||||
|
|
||||||
Private helper functions may be shared with Tomorrow planning when the
|
|
||||||
underlying logic is truly identical.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
The feature is complete when:
|
|
||||||
|
|
||||||
- `weatherreporter generate daily --date YYYY-MM-DD` runs the new Daily report.
|
|
||||||
- `weatherreporter generate daily` without `--date` fails with an actionable
|
|
||||||
error.
|
|
||||||
- `daily` report metadata, RunID content, artifact paths, data-package paths,
|
|
||||||
distributor template variables, generated-text assets, and rendered Markdown
|
|
||||||
all use report ID `daily`.
|
|
||||||
- The active report registry includes `daily` and does not include
|
|
||||||
`daily_today`.
|
|
||||||
- `reports.daily` is the implemented config override key for Daily.
|
|
||||||
- `reports.daily_today` is rejected rather than treated as an alias.
|
|
||||||
- Daily uses generated-text-template generation with prompt ID
|
|
||||||
`weather.daily_generated_text`.
|
|
||||||
- Daily uses dedicated schema, prompt, template, generated-text type,
|
|
||||||
render-context type, and planning-module surfaces.
|
|
||||||
- Daily rendered Markdown initially matches Tomorrow's report format.
|
|
||||||
- Daily data packages include `daily_planning`, not `tomorrow_planning`.
|
|
||||||
- Daily Recent Changes compare against prior Daily snapshots for the same valid
|
|
||||||
local date.
|
|
||||||
- Daily is not included in morning or evening scheduled batches.
|
|
||||||
- Existing Today and Tomorrow report semantics remain unchanged.
|
|
||||||
- Historical `daily_today` workspace artifacts are left untouched.
|
|
||||||
- Non-roadmap documentation is updated after implementation to describe only
|
|
||||||
implemented Daily behavior.
|
|
||||||
|
|
||||||
## Implementation Plan Reference
|
|
||||||
|
|
||||||
Use `docs/roadmap/implementation.md` for the staged implementation plan. This
|
|
||||||
feature roadmap intentionally does not define implementation stages, file-by-file
|
|
||||||
work packages, or validation commands so that implementing agents have a single
|
|
||||||
sequencing authority.
|
|
||||||
|
|
||||||
## Ambiguities Addressed
|
|
||||||
|
|
||||||
- Replacement scope: new `daily` replaces and removes old active
|
|
||||||
`daily_today`.
|
|
||||||
- Date behavior: `--date` is required; no default date is used.
|
|
||||||
- Output format: initial rendered Markdown matches Tomorrow.
|
|
||||||
- Internal separation: Daily has its own template, schema, prompt, generated
|
|
||||||
text type, render context, and planning module.
|
|
||||||
- Batch behavior: Daily is not scheduled; Today remains the morning current-day
|
|
||||||
scheduled product.
|
|
||||||
- Historical artifacts: old `daily_today` workspace files are not migrated.
|
|
||||||
|
|
||||||
## Open Decisions
|
|
||||||
|
|
||||||
No open decisions remain that block implementation.
|
|
||||||
|
|
||||||
Future decisions that should not be resolved in this roadmap:
|
|
||||||
|
|
||||||
- Whether Daily should eventually support recurring scheduled generation.
|
|
||||||
- Whether Daily should diverge from Tomorrow's template or planning logic.
|
|
||||||
- Whether old `daily_today` workspace artifacts should ever receive a migration
|
|
||||||
or inspection compatibility layer.
|
|
||||||
@@ -1,361 +0,0 @@
|
|||||||
# Data Package Export Roadmap
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
This roadmap defines the target state for cleaning up module fields exposed in
|
|
||||||
YAML data packages. The goal is to keep report templates composable while making
|
|
||||||
LLM prompt inputs concise, readable, and free of template-only helper fields.
|
|
||||||
|
|
||||||
This feature is implemented. Current data-package behavior is documented in
|
|
||||||
`docs/internal/prompt-input.md`; the rich module and template boundary is
|
|
||||||
documented in `docs/internal/module.md` and `docs/templates.md`.
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
Module output structs currently serve two different consumers:
|
|
||||||
|
|
||||||
- deterministic report templates, which benefit from presentation helpers such
|
|
||||||
as lower-case text, display labels, trend phrases, and hour labels;
|
|
||||||
- Scriptorium data packages, which should expose the clearest useful weather
|
|
||||||
facts to the LLM with minimal redundancy.
|
|
||||||
|
|
||||||
Those consumers now need different surfaces. Examples include:
|
|
||||||
|
|
||||||
- `current_conditions` exposes both `condition_text` and
|
|
||||||
`condition_text_lower`, plus both abbreviated and long-form wind-direction
|
|
||||||
fields.
|
|
||||||
- `hourly_forecast.periods[]` exposes both `period_begins` and `hour_label`,
|
|
||||||
and both `text_description` and `text_description_lower`.
|
|
||||||
- `derived_daypart_summaries` exposes numerous temperature and condition phrase
|
|
||||||
fields that are useful for deterministic template wording but noisy in the
|
|
||||||
prompt data package.
|
|
||||||
|
|
||||||
The cleanup should not weaken template composability. Templates should still be
|
|
||||||
able to use rich module values and helper fields.
|
|
||||||
|
|
||||||
The cleanup applies to every report that consumes these modules, including the
|
|
||||||
generated-template `today`, `tomorrow`, and `daily` reports. The same
|
|
||||||
`derived_daypart_summaries` prompt export should serve all three reports while
|
|
||||||
their templates continue to use rich daypart helper fields.
|
|
||||||
|
|
||||||
## Intent
|
|
||||||
|
|
||||||
Data packages should be curated prompt inputs, not a raw dump of every field
|
|
||||||
available to Go templates.
|
|
||||||
|
|
||||||
The intended architecture is:
|
|
||||||
|
|
||||||
- module builders produce rich internal/template module values;
|
|
||||||
- each module may define a prompt-facing export value for data-package use;
|
|
||||||
- prompt input construction serializes the prompt-facing export value;
|
|
||||||
- template rendering continues to use the full rich module value.
|
|
||||||
|
|
||||||
The result should let `weatherreporter` optimize separately for:
|
|
||||||
|
|
||||||
- precise deterministic Markdown rendering;
|
|
||||||
- compact, readable LLM input;
|
|
||||||
- stable internal module contracts.
|
|
||||||
|
|
||||||
## Locked Decisions
|
|
||||||
|
|
||||||
- Do not make the existing module structs smaller solely to clean up data
|
|
||||||
packages.
|
|
||||||
- Do not use per-module string field allowlists as the primary mechanism.
|
|
||||||
- Do not rely on reflection-heavy field filtering for nested module shapes.
|
|
||||||
- Do not use `json:"-"` or `yaml:"-"` on rich template fields as the main
|
|
||||||
boundary.
|
|
||||||
- Keep rich module outputs available for template rendering, inspection, tests,
|
|
||||||
and internal use.
|
|
||||||
- Add an explicit prompt/data-package export layer for module outputs.
|
|
||||||
- Simple modules may use default pass-through export behavior.
|
|
||||||
- No compatibility aliases are needed for removed prompt-facing fields because
|
|
||||||
the prompt schema is still pre-release.
|
|
||||||
- Bump the data-package schema version when implementing this change.
|
|
||||||
- Compute prompt export values during module snapshot construction and store the
|
|
||||||
runtime-only prompt value on `module.Output` alongside the rich `Value`.
|
|
||||||
- Do not persist prompt export values in module snapshot JSON; module snapshots
|
|
||||||
should continue to preserve rich module values.
|
|
||||||
|
|
||||||
## Target Architecture
|
|
||||||
|
|
||||||
Each module definition should be able to declare how its output is represented
|
|
||||||
in prompt data packages.
|
|
||||||
|
|
||||||
A possible shape is:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type ModuleDefinition struct {
|
|
||||||
// existing fields...
|
|
||||||
PromptExporter ModulePromptExporter
|
|
||||||
}
|
|
||||||
|
|
||||||
type ModulePromptExporter func(value any) (any, error)
|
|
||||||
```
|
|
||||||
|
|
||||||
The exact API may differ if implementation discovers a cleaner fit, but the
|
|
||||||
contract should preserve these properties:
|
|
||||||
|
|
||||||
- the exporter is owned near the module definition or module builder;
|
|
||||||
- the exporter receives the rich module value and returns a prompt-facing value;
|
|
||||||
- missing exporters default to pass-through for modules whose rich value is
|
|
||||||
already prompt-appropriate;
|
|
||||||
- exporter errors include module ID and stanza context;
|
|
||||||
- promptinput uses exported prompt values instead of rich values;
|
|
||||||
- render contexts and templates continue using rich values.
|
|
||||||
|
|
||||||
The preferred implementation should avoid making `internal/promptinput` import
|
|
||||||
`internal/briefing` directly. If prompt export needs registry knowledge, either:
|
|
||||||
|
|
||||||
- record the prompt-facing value in `module.Output` when the module snapshot is
|
|
||||||
built; or
|
|
||||||
- pass an explicit export map/registry into prompt-input construction without
|
|
||||||
creating a package cycle.
|
|
||||||
|
|
||||||
The implementation should keep package boundaries consistent with existing
|
|
||||||
architecture: module output policy belongs with module definitions, and data
|
|
||||||
package serialization belongs in `internal/promptinput`.
|
|
||||||
|
|
||||||
## Prompt Export Contract
|
|
||||||
|
|
||||||
A module prompt export should be:
|
|
||||||
|
|
||||||
- **curated:** include fields useful to the LLM, omit fields used only for
|
|
||||||
deterministic sentence construction;
|
|
||||||
- **typed:** use small prompt-facing structs for modules that need reshaping;
|
|
||||||
- **stable:** keep field names intentional and avoid duplicating equivalent
|
|
||||||
facts under multiple names;
|
|
||||||
- **readable:** prefer fields that explain themselves in YAML;
|
|
||||||
- **loss-aware:** do not omit facts that the LLM needs to reason about timing,
|
|
||||||
severity, uncertainty, or practical impact;
|
|
||||||
- **module-owned:** keep each module responsible for its own prompt-facing
|
|
||||||
contract.
|
|
||||||
|
|
||||||
Prompt-facing structs may live next to the module that owns them, for example:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type CurrentConditionsPromptExport struct {
|
|
||||||
ConditionText string `json:"condition_text,omitempty"`
|
|
||||||
TemperatureF *int `json:"temperature_f,omitempty"`
|
|
||||||
ApparentTemperatureF *int `json:"apparent_temperature_f,omitempty"`
|
|
||||||
RelativeHumidityPercent *int `json:"relative_humidity_percent,omitempty"`
|
|
||||||
WindSpeedMph *int `json:"wind_speed_mph,omitempty"`
|
|
||||||
WindDirection string `json:"wind_direction,omitempty"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The names do not need to include `PromptExport` if implementation finds a
|
|
||||||
clearer convention, but they should distinguish data-package shape from
|
|
||||||
template-rendering shape.
|
|
||||||
|
|
||||||
## Initial Cleanup Targets
|
|
||||||
|
|
||||||
### Current Conditions
|
|
||||||
|
|
||||||
Keep prompt-facing fields that express current observed conditions directly:
|
|
||||||
|
|
||||||
- `condition_text`
|
|
||||||
- `is_day`
|
|
||||||
- temperature fields
|
|
||||||
- apparent temperature fields
|
|
||||||
- dewpoint fields
|
|
||||||
- relative humidity
|
|
||||||
- wind speed
|
|
||||||
- one wind direction field
|
|
||||||
|
|
||||||
Remove prompt-facing fields that are template-only duplicates:
|
|
||||||
|
|
||||||
- `condition_text_lower`
|
|
||||||
- duplicate wind-direction text when an equivalent `wind_direction` field is
|
|
||||||
present
|
|
||||||
|
|
||||||
The template surface may keep those helper fields.
|
|
||||||
|
|
||||||
### Hourly Forecast
|
|
||||||
|
|
||||||
Keep prompt-facing period fields that carry facts:
|
|
||||||
|
|
||||||
- `period_begins`
|
|
||||||
- `period_ends`
|
|
||||||
- `name`
|
|
||||||
- `is_day`
|
|
||||||
- condition code, if useful
|
|
||||||
- `text_description`
|
|
||||||
- temperature fields
|
|
||||||
- dewpoint, apparent temperature, humidity, wind, gust, pressure, visibility,
|
|
||||||
cloud cover, precipitation probability, precipitation amount, snowfall depth,
|
|
||||||
and UV index when provided by upstream data
|
|
||||||
|
|
||||||
Remove prompt-facing fields that duplicate or encode template logic:
|
|
||||||
|
|
||||||
- `hour_label`, because `period_begins` already gives the time in a friendly
|
|
||||||
local label;
|
|
||||||
- `text_description_lower`, because the LLM can interpret
|
|
||||||
`text_description`;
|
|
||||||
- `mention_precipitation`, because it is a template threshold helper when the
|
|
||||||
underlying precipitation probability is present.
|
|
||||||
|
|
||||||
The template surface may keep these helper fields.
|
|
||||||
|
|
||||||
### Derived Daypart Summaries
|
|
||||||
|
|
||||||
Keep prompt-facing fields that describe the daypart:
|
|
||||||
|
|
||||||
- `date`
|
|
||||||
- `display_name`
|
|
||||||
- `period_begins`
|
|
||||||
- `period_ends`
|
|
||||||
- temperature range or the best single temperature phrase
|
|
||||||
- apparent temperature range when useful
|
|
||||||
- maximum precipitation probability and time
|
|
||||||
- maximum wind gust and time
|
|
||||||
- dominant condition
|
|
||||||
- temperature trend
|
|
||||||
- notable conditions
|
|
||||||
- weather indicator booleans
|
|
||||||
- relevant alert count
|
|
||||||
|
|
||||||
Remove prompt-facing fields that mainly support deterministic sentence
|
|
||||||
construction:
|
|
||||||
|
|
||||||
- duplicate lower-case/display variants of the same dominant condition;
|
|
||||||
- multiple temperature phrase fragments when a smaller set can express the same
|
|
||||||
trend;
|
|
||||||
- duplicate time labels where one friendly time field is enough.
|
|
||||||
|
|
||||||
The exact retained daypart temperature fields should be chosen during
|
|
||||||
implementation with template needs and LLM readability in mind. The prompt
|
|
||||||
export should preserve the facts needed to understand whether temperatures are
|
|
||||||
rising, falling, peaking, or steady, but it does not need every phrase fragment
|
|
||||||
used by the Markdown template.
|
|
||||||
|
|
||||||
### Other Modules
|
|
||||||
|
|
||||||
Most existing modules may initially use pass-through export unless they expose
|
|
||||||
clear template-only helpers. During implementation, review at least:
|
|
||||||
|
|
||||||
- `narrative_forecast`
|
|
||||||
- `precip_timing`
|
|
||||||
- `outdoor_windows`
|
|
||||||
- `alert_digest`
|
|
||||||
- `spc_convective_outlooks`
|
|
||||||
- `spc_convective_discussion`
|
|
||||||
- `area_forecast_discussion`
|
|
||||||
- `weather_story`
|
|
||||||
- planning modules
|
|
||||||
|
|
||||||
Do not remove fields merely because they are verbose. Remove or reshape fields
|
|
||||||
when they are redundant, template-specific, or confusing in the context of LLM
|
|
||||||
input.
|
|
||||||
|
|
||||||
## Data Package Behavior
|
|
||||||
|
|
||||||
After implementation:
|
|
||||||
|
|
||||||
- saved YAML data packages should use prompt-facing module exports;
|
|
||||||
- saved module snapshots should continue preserving rich module output values;
|
|
||||||
- generated-text render contexts should continue preserving rich module values;
|
|
||||||
- Recent Changes should continue using structured module snapshots unless a
|
|
||||||
specific comparison should intentionally move to prompt-facing fields;
|
|
||||||
- inspection commands should make clear whether they are showing rich module
|
|
||||||
snapshots or prompt data packages.
|
|
||||||
- generated-template reports, including `today`, `tomorrow`, and `daily`, should
|
|
||||||
continue rendering from rich module values.
|
|
||||||
|
|
||||||
This roadmap does not require changing source warnings, report metadata,
|
|
||||||
collected facts, derived facts, or generated report artifacts.
|
|
||||||
|
|
||||||
## Schema And Versioning
|
|
||||||
|
|
||||||
This is a prompt-input schema cleanup. Because the project is pre-release, the
|
|
||||||
implementation may make a clean break in data-package field names without
|
|
||||||
compatibility aliases.
|
|
||||||
|
|
||||||
The data-package schema version should be bumped when this feature is
|
|
||||||
implemented because persisted data-package fields will be removed or renamed.
|
|
||||||
This makes artifact shape changes explicit and helps inspection tooling
|
|
||||||
distinguish old and new data packages.
|
|
||||||
|
|
||||||
## Documentation Guidance
|
|
||||||
|
|
||||||
After implementation, update implemented documentation only:
|
|
||||||
|
|
||||||
- `docs/internal/module.md`: describe the distinction between rich module output
|
|
||||||
and prompt-facing export values.
|
|
||||||
- `docs/internal/prompt-input.md`: document that data packages use curated
|
|
||||||
prompt exports, not full template module structs.
|
|
||||||
- `docs/templates.md`: clarify that templates may have richer fields than the
|
|
||||||
data package.
|
|
||||||
- Any module field examples in implemented docs should match the new
|
|
||||||
prompt-facing data package shape.
|
|
||||||
|
|
||||||
Do not document future module fields or unimplemented exporters outside
|
|
||||||
`docs/roadmap/`.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
The feature is complete when:
|
|
||||||
|
|
||||||
- prompt data packages serialize curated module exports instead of blindly
|
|
||||||
serializing rich module values;
|
|
||||||
- templates still render from rich module values without losing helper fields;
|
|
||||||
- `current_conditions` no longer exposes lower-case condition text or duplicate
|
|
||||||
wind-direction fields in data packages;
|
|
||||||
- `hourly_forecast.periods[]` no longer exposes `hour_label`,
|
|
||||||
`text_description_lower`, or `mention_precipitation` in data packages;
|
|
||||||
- `derived_daypart_summaries` no longer exposes redundant condition and
|
|
||||||
temperature phrase variants in data packages;
|
|
||||||
- `today`, `tomorrow`, and `daily` rendered reports continue to have access to
|
|
||||||
rich daypart helper fields for deterministic template wording;
|
|
||||||
- simple modules that do not need cleanup still export correctly through default
|
|
||||||
pass-through behavior;
|
|
||||||
- exporter errors include module/stanza context;
|
|
||||||
- YAML category ordering remains unchanged;
|
|
||||||
- module snapshot artifacts remain rich enough for templates, inspection, and
|
|
||||||
regression diagnosis;
|
|
||||||
- tests prove that removed prompt-facing fields are absent from saved YAML data
|
|
||||||
packages and still available to templates where needed.
|
|
||||||
|
|
||||||
## Testing Expectations
|
|
||||||
|
|
||||||
Implementation should add or update focused tests for:
|
|
||||||
|
|
||||||
- module registry validation for prompt exporters, if exporters are registered
|
|
||||||
there;
|
|
||||||
- promptinput construction using exported prompt values;
|
|
||||||
- pass-through behavior for simple modules;
|
|
||||||
- custom exports for current conditions, hourly forecast, and daypart summaries;
|
|
||||||
- data-package YAML output rejecting stale fields;
|
|
||||||
- template render tests proving template-only helper fields remain available for
|
|
||||||
`today`, `tomorrow`, and `daily`;
|
|
||||||
- app workflow tests proving saved data packages use curated exports while
|
|
||||||
render contexts keep rich values.
|
|
||||||
|
|
||||||
Suggested validation after implementation:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/module ./internal/briefing ./internal/promptinput
|
|
||||||
go test ./internal/generatedtext ./internal/reporttemplate ./internal/app
|
|
||||||
go test ./...
|
|
||||||
go run ./cmd/weatherreporter --help
|
|
||||||
git diff --check
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deferred Work
|
|
||||||
|
|
||||||
Do not include these in the initial cleanup unless implementation reveals they
|
|
||||||
are necessary:
|
|
||||||
|
|
||||||
- user-configurable data-package field selection;
|
|
||||||
- per-prompt custom field profiles;
|
|
||||||
- reflection-based generic include/exclude lists;
|
|
||||||
- automatic schema generation for data-package exports;
|
|
||||||
- changing collected facts or derived facts contracts;
|
|
||||||
- changing Scriptorium invocation behavior;
|
|
||||||
- changing generated Markdown templates beyond preserving their current output.
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
No open questions block implementation.
|
|
||||||
|
|
||||||
The implementation plan in `docs/roadmap/implementation.md` is the sequencing
|
|
||||||
authority for this feature.
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,657 +0,0 @@
|
|||||||
# Data Package Export Implementation Roadmap
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
This roadmap defines the staged implementation plan for
|
|
||||||
`docs/roadmap/data-package-exports.md`. It is written for an LLM coding agent
|
|
||||||
that will implement the curated data-package export feature in order.
|
|
||||||
|
|
||||||
The goal is to separate rich module values used by templates from curated
|
|
||||||
prompt-facing values serialized into Scriptorium data packages. The change
|
|
||||||
should make YAML data packages smaller and clearer without reducing deterministic
|
|
||||||
template composability.
|
|
||||||
|
|
||||||
This is a planning document only. It may describe unimplemented behavior because
|
|
||||||
it lives under `docs/roadmap/`.
|
|
||||||
|
|
||||||
## Source Roadmap
|
|
||||||
|
|
||||||
Use `docs/roadmap/data-package-exports.md` as the feature roadmap and target
|
|
||||||
state authority. That document defines the user intent, module/export boundary,
|
|
||||||
initial cleanup targets, and non-goals.
|
|
||||||
|
|
||||||
If this implementation plan and the feature roadmap conflict, update the feature
|
|
||||||
roadmap first so it remains the conceptual source of truth, then update this
|
|
||||||
file.
|
|
||||||
|
|
||||||
## Locked Decisions
|
|
||||||
|
|
||||||
- Keep rich module values available for templates, module snapshots, inspection,
|
|
||||||
Recent Changes, and render contexts.
|
|
||||||
- Serialize curated prompt-facing module values into data packages.
|
|
||||||
- Store prompt-facing values on `module.Output` as runtime-only values.
|
|
||||||
- Do not persist prompt-facing values in module snapshot JSON.
|
|
||||||
- Keep `internal/promptinput` independent from `internal/briefing`.
|
|
||||||
- Add prompt export behavior to the module registry/definition path where module
|
|
||||||
output policy already lives.
|
|
||||||
- Default modules without custom exporters to pass-through prompt values.
|
|
||||||
- Bump the data-package schema version from `weatherreporter.data_package.v2` to
|
|
||||||
`weatherreporter.data_package.v3`.
|
|
||||||
- No compatibility aliases are required for removed prompt-facing fields.
|
|
||||||
- Do not add user-configurable field selection, field allowlists, reflection
|
|
||||||
filters, or prompt-specific field profiles in this implementation.
|
|
||||||
|
|
||||||
## Implementation Principles
|
|
||||||
|
|
||||||
- Preserve existing generated Markdown output.
|
|
||||||
- Preserve existing template render-context richness.
|
|
||||||
- Preserve existing module snapshot richness and JSON shape except for any
|
|
||||||
unavoidable schema-version-only change. The preferred approach is to omit
|
|
||||||
prompt values from module snapshot JSON entirely.
|
|
||||||
- Keep exporter code near the module that owns the shape.
|
|
||||||
- Use typed prompt-facing structs for modules that need reshaping.
|
|
||||||
- Keep simple modules on default pass-through behavior.
|
|
||||||
- Make exporter errors actionable and include module ID/stanza context.
|
|
||||||
- Keep YAML category ordering unchanged.
|
|
||||||
- Treat generated-template `today`, `tomorrow`, and `daily` reports as equal
|
|
||||||
consumers of rich template module values.
|
|
||||||
- Update implemented docs only after code behavior exists.
|
|
||||||
|
|
||||||
## Stage 1: Runtime Prompt Value Contract
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
|
|
||||||
Add a runtime-only prompt value to module outputs while preserving rich module
|
|
||||||
snapshot persistence.
|
|
||||||
|
|
||||||
### Files To Inspect
|
|
||||||
|
|
||||||
- `internal/module/module.go`
|
|
||||||
- `internal/module/module_test.go`
|
|
||||||
- `internal/state/filesystem.go`
|
|
||||||
- `internal/state/filesystem_test.go`
|
|
||||||
- `internal/app/app.go`
|
|
||||||
- `internal/app/app_test.go`
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
|
|
||||||
- Add a runtime-only field to `module.Output`, for example:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type Output struct {
|
|
||||||
ID ID `json:"id"`
|
|
||||||
StanzaName string `json:"stanzaName"`
|
|
||||||
Value any `json:"value"`
|
|
||||||
PromptValue any `json:"-"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- Add a small helper in `internal/module`, for example:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (o Output) DataPackageValue() any
|
|
||||||
```
|
|
||||||
|
|
||||||
The helper should return `PromptValue` when non-nil and fall back to `Value`
|
|
||||||
otherwise. This fallback keeps tests and any manually built snapshots simple.
|
|
||||||
|
|
||||||
- Do not require `PromptValue` in `Snapshot.Validate`.
|
|
||||||
- Do not persist `PromptValue` to module snapshot JSON.
|
|
||||||
- Do not change `StanzaValue`; it should continue decoding rich `Value`.
|
|
||||||
|
|
||||||
### Acceptance Criteria
|
|
||||||
|
|
||||||
- Module snapshots still serialize rich module values under `value`.
|
|
||||||
- Module snapshots do not serialize `promptValue` or any equivalent field.
|
|
||||||
- Existing rich-module snapshot lookup and `StanzaValue` behavior remain
|
|
||||||
unchanged.
|
|
||||||
- Code has a single helper for choosing the data-package value from an output.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
- Add module tests proving:
|
|
||||||
- `DataPackageValue` uses `PromptValue` when set;
|
|
||||||
- `DataPackageValue` falls back to `Value`;
|
|
||||||
- marshaled snapshot JSON omits `PromptValue`;
|
|
||||||
- `StanzaValue` continues decoding rich `Value`.
|
|
||||||
|
|
||||||
Suggested focused command:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/module ./internal/state
|
|
||||||
```
|
|
||||||
|
|
||||||
### Prompt Size
|
|
||||||
|
|
||||||
Small enough for one implementation prompt.
|
|
||||||
|
|
||||||
## Stage 2: Module Registry Prompt Exporters
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
|
|
||||||
Teach the module registry to attach prompt-facing values to outputs as modules
|
|
||||||
are built.
|
|
||||||
|
|
||||||
### Files To Inspect
|
|
||||||
|
|
||||||
- `internal/briefing/modules.go`
|
|
||||||
- `internal/briefing/modules_test.go`
|
|
||||||
- `internal/briefing/*_module.go`
|
|
||||||
- `internal/module/module.go`
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
|
|
||||||
- Add a prompt exporter type in `internal/briefing`, for example:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type ModulePromptExporter func(value any) (any, error)
|
|
||||||
```
|
|
||||||
|
|
||||||
- Add `PromptExporter ModulePromptExporter` to `ModuleDefinition`.
|
|
||||||
- In `ModuleRegistry.BuildModule`, after builder output ID and stanza validation:
|
|
||||||
- if `PromptExporter` is nil, set `output.PromptValue = output.Value`;
|
|
||||||
- if `PromptExporter` is present, call it with `output.Value`;
|
|
||||||
- set `output.PromptValue` to the returned value;
|
|
||||||
- wrap exporter errors with module ID and stanza context.
|
|
||||||
- Add a typed exporter helper if it keeps module exporters concise, for example:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func promptExporter[T any](fn func(T) (any, error)) ModulePromptExporter
|
|
||||||
```
|
|
||||||
|
|
||||||
The helper should convert `any` through JSON marshal/unmarshal or direct type
|
|
||||||
assertion only if it meaningfully reduces boilerplate without hiding errors.
|
|
||||||
|
|
||||||
- Do not make `internal/promptinput` import `internal/briefing`.
|
|
||||||
- Do not move module-specific export policy into `internal/promptinput`.
|
|
||||||
|
|
||||||
### Acceptance Criteria
|
|
||||||
|
|
||||||
- Every built module output has a non-nil data-package value.
|
|
||||||
- Modules without custom exporters pass through rich values.
|
|
||||||
- Custom exporter errors identify the module and stanza.
|
|
||||||
- Registry validation remains focused on definitions, duplicate IDs/stanzas,
|
|
||||||
supported reports, options, builders, and missing-data policy.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
- Add or update module registry tests for:
|
|
||||||
- default pass-through prompt values;
|
|
||||||
- custom exporter prompt values;
|
|
||||||
- exporter error wrapping;
|
|
||||||
- output ID/stanza validation still runs before or around export behavior;
|
|
||||||
- output `PromptValue` is not persisted in snapshot JSON.
|
|
||||||
|
|
||||||
Suggested focused command:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/briefing ./internal/module
|
|
||||||
```
|
|
||||||
|
|
||||||
### Prompt Size
|
|
||||||
|
|
||||||
Small enough for one implementation prompt.
|
|
||||||
|
|
||||||
## Stage 3: Promptinput Uses Exported Values And Schema V3
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
|
|
||||||
Make data-package construction serialize prompt-facing values and bump the data
|
|
||||||
package schema version.
|
|
||||||
|
|
||||||
### Files To Inspect
|
|
||||||
|
|
||||||
- `internal/promptinput/package.go`
|
|
||||||
- `internal/promptinput/package_test.go`
|
|
||||||
- `internal/state/filesystem_test.go`
|
|
||||||
- `internal/app/app.go`
|
|
||||||
- `internal/app/app_test.go`
|
|
||||||
- `docs/roadmap/data-package-exports.md`
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
|
|
||||||
- Change `promptinput.SchemaVersion` to:
|
|
||||||
|
|
||||||
```go
|
|
||||||
const SchemaVersion = "weatherreporter.data_package.v3"
|
|
||||||
```
|
|
||||||
|
|
||||||
- Update `stanzasFromSnapshot` to use `output.DataPackageValue()` rather than
|
|
||||||
`output.Value`.
|
|
||||||
- Keep `BriefingStanzas` category grouping and ordering unchanged.
|
|
||||||
- Keep `LoadYAML` validation strict for the current schema version.
|
|
||||||
- Update tests and fixtures that assert `weatherreporter.data_package.v2`.
|
|
||||||
|
|
||||||
### Acceptance Criteria
|
|
||||||
|
|
||||||
- Saved data packages use schema version `weatherreporter.data_package.v3`.
|
|
||||||
- Data package stanzas use `PromptValue` when present.
|
|
||||||
- Data package stanzas fall back to rich `Value` when `PromptValue` is absent,
|
|
||||||
which keeps hand-built test snapshots and loaded rich snapshots usable.
|
|
||||||
- YAML category ordering remains unchanged.
|
|
||||||
- Module snapshots remain rich and unaffected by prompt export serialization.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
- Update promptinput tests for:
|
|
||||||
- schema version `v3`;
|
|
||||||
- prompt value preferred over rich value;
|
|
||||||
- fallback to rich value;
|
|
||||||
- deterministic categorized YAML output unchanged apart from stanza values and
|
|
||||||
schema version;
|
|
||||||
- unknown/uncategorized stanza behavior unchanged.
|
|
||||||
- Update state tests that load data packages.
|
|
||||||
|
|
||||||
Suggested focused command:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/promptinput ./internal/state
|
|
||||||
```
|
|
||||||
|
|
||||||
### Prompt Size
|
|
||||||
|
|
||||||
Small enough for one implementation prompt.
|
|
||||||
|
|
||||||
## Stage 4: Current Conditions And Hourly Forecast Exports
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
|
|
||||||
Add custom prompt exports for the clearest noisy raw-data modules:
|
|
||||||
`current_conditions` and `hourly_forecast`.
|
|
||||||
|
|
||||||
### Files To Inspect
|
|
||||||
|
|
||||||
- `internal/briefing/current_conditions_module.go`
|
|
||||||
- `internal/briefing/hourly_forecast_module.go`
|
|
||||||
- `internal/briefing/base_modules_test.go`
|
|
||||||
- `internal/generatedtext/render_context.go`
|
|
||||||
- `internal/reporttemplate/templates/*.md.tmpl`
|
|
||||||
- `internal/app/app_test.go`
|
|
||||||
|
|
||||||
### Current Conditions Export Shape
|
|
||||||
|
|
||||||
The prompt-facing `current_conditions` export should keep:
|
|
||||||
|
|
||||||
- `condition_text`
|
|
||||||
- `is_day`
|
|
||||||
- `temperature_c`
|
|
||||||
- `temperature_f`
|
|
||||||
- `apparent_temperature_c`
|
|
||||||
- `apparent_temperature_f`
|
|
||||||
- `dewpoint_c`
|
|
||||||
- `dewpoint_f`
|
|
||||||
- `relative_humidity_percent`
|
|
||||||
- `wind_speed_kmh`
|
|
||||||
- `wind_speed_mph`
|
|
||||||
- `wind_direction`
|
|
||||||
|
|
||||||
It should remove:
|
|
||||||
|
|
||||||
- `condition_text_lower`
|
|
||||||
- `wind_direction_text`
|
|
||||||
|
|
||||||
The rich `CurrentConditionsModule` should keep those helper fields for
|
|
||||||
templates.
|
|
||||||
|
|
||||||
### Hourly Forecast Export Shape
|
|
||||||
|
|
||||||
The prompt-facing `hourly_forecast` export should keep module-level fields:
|
|
||||||
|
|
||||||
- `product`
|
|
||||||
- `issued_at`
|
|
||||||
- `updated_at`
|
|
||||||
- `source_location`
|
|
||||||
- `source_location_id`
|
|
||||||
- `periods`
|
|
||||||
|
|
||||||
Each prompt-facing hourly period should keep:
|
|
||||||
|
|
||||||
- `period_begins`
|
|
||||||
- `period_ends`
|
|
||||||
- `name`
|
|
||||||
- `is_day`
|
|
||||||
- `condition_code`
|
|
||||||
- `text_description`
|
|
||||||
- temperature fields
|
|
||||||
- dewpoint fields
|
|
||||||
- wind speed and gust fields
|
|
||||||
- `wind_direction`
|
|
||||||
- pressure fields
|
|
||||||
- visibility fields
|
|
||||||
- apparent temperature fields
|
|
||||||
- `cloud_cover_percent`
|
|
||||||
- `probability_of_precipitation_percent`
|
|
||||||
- precipitation amount fields
|
|
||||||
- snowfall depth fields
|
|
||||||
- `uv_index`
|
|
||||||
- `relative_humidity_percent`
|
|
||||||
|
|
||||||
Each prompt-facing hourly period should remove:
|
|
||||||
|
|
||||||
- `hour_label`
|
|
||||||
- `text_description_lower`
|
|
||||||
- `mention_precipitation`
|
|
||||||
|
|
||||||
The rich `HourlyForecastPeriod` should keep those helper fields for templates.
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
|
|
||||||
- Add prompt export structs near each module.
|
|
||||||
- Add exporter functions near each module.
|
|
||||||
- Register exporters in the default module definitions.
|
|
||||||
- Prefer straightforward field copying over reflection.
|
|
||||||
- Preserve `omitempty` behavior.
|
|
||||||
|
|
||||||
### Acceptance Criteria
|
|
||||||
|
|
||||||
- Saved data packages no longer include removed fields for current conditions or
|
|
||||||
hourly forecast.
|
|
||||||
- Rich module snapshots and render contexts still include template helper
|
|
||||||
fields.
|
|
||||||
- Existing hourly, today, tomorrow, and daily template output remains unchanged
|
|
||||||
wherever those templates consume current conditions or hourly forecast values.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
- Update module tests to prove prompt exports omit stale fields and keep
|
|
||||||
expected factual fields.
|
|
||||||
- Update template/render-context tests to prove helper fields remain available
|
|
||||||
to templates.
|
|
||||||
- Update app workflow tests to inspect saved YAML and reject:
|
|
||||||
- `condition_text_lower`;
|
|
||||||
- `wind_direction_text`;
|
|
||||||
- `hour_label`;
|
|
||||||
- `text_description_lower`;
|
|
||||||
- `mention_precipitation`.
|
|
||||||
|
|
||||||
Suggested focused command:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/briefing ./internal/generatedtext ./internal/reporttemplate ./internal/app
|
|
||||||
```
|
|
||||||
|
|
||||||
### Prompt Size
|
|
||||||
|
|
||||||
Medium. Suitable for one implementation prompt.
|
|
||||||
|
|
||||||
## Stage 5: Derived Daypart Summary Export
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
|
|
||||||
Add a curated prompt export for `derived_daypart_summaries` while preserving the
|
|
||||||
rich daypart fields used by Daily, Today, and Tomorrow templates.
|
|
||||||
|
|
||||||
### Files To Inspect
|
|
||||||
|
|
||||||
- `internal/briefing/derived_daypart_summaries_module.go`
|
|
||||||
- `internal/briefing/derived_modules_test.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/reporttemplate_test.go`
|
|
||||||
- `internal/app/app_test.go`
|
|
||||||
|
|
||||||
### Export Shape
|
|
||||||
|
|
||||||
For each daypart, the prompt-facing export should keep:
|
|
||||||
|
|
||||||
- `date`
|
|
||||||
- `display_name`
|
|
||||||
- `period_begins`
|
|
||||||
- `period_ends`
|
|
||||||
- `temp_range_f`
|
|
||||||
- `apparent_temp_range_f`
|
|
||||||
- `max_pop_percent`
|
|
||||||
- `max_pop_time`
|
|
||||||
- `mention_precipitation`
|
|
||||||
- `max_wind_gust_mph`
|
|
||||||
- `max_wind_gust_time`
|
|
||||||
- `dominant_condition`
|
|
||||||
- `temperature_trend`
|
|
||||||
- `temperature_start_phrase_f`
|
|
||||||
- `temperature_end_phrase_f`
|
|
||||||
- `temperature_peak_phrase_f`
|
|
||||||
- `temperature_steady_phrase_f`
|
|
||||||
- `notable_conditions`
|
|
||||||
- `snow`
|
|
||||||
- `ice`
|
|
||||||
- `fog`
|
|
||||||
- `heat`
|
|
||||||
- `cold`
|
|
||||||
- `wind`
|
|
||||||
- `relevant_alert_count`
|
|
||||||
|
|
||||||
The prompt-facing export should remove:
|
|
||||||
|
|
||||||
- `temperature_phrase_f`
|
|
||||||
- `dominant_condition_lower`
|
|
||||||
- `dominant_condition_display`
|
|
||||||
- `max_pop_time_label`
|
|
||||||
|
|
||||||
For `max_pop_time`, use the most readable existing time label. Prefer
|
|
||||||
`MaxPopTimeLabel` when present, falling back to `MaxPopTime`, while keeping the
|
|
||||||
prompt-facing field name `max_pop_time`.
|
|
||||||
|
|
||||||
Keep the temperature trend phrase fields even though only some are populated for
|
|
||||||
each trend. They are not duplicates when used according to the trend:
|
|
||||||
|
|
||||||
- rising/falling use start and end phrases;
|
|
||||||
- peaking uses peak phrase;
|
|
||||||
- steady uses steady phrase.
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
|
|
||||||
- Add prompt export structs near the daypart module.
|
|
||||||
- Add an exporter for the `map[string]DerivedDaypartSummaryModule` value.
|
|
||||||
- Preserve map keys and values for all emitted dayparts.
|
|
||||||
- Register the exporter in the default module definitions.
|
|
||||||
- Do not alter rich `DerivedDaypartSummaryModule` fields used by templates.
|
|
||||||
|
|
||||||
### Acceptance Criteria
|
|
||||||
|
|
||||||
- Saved data packages no longer include the removed daypart fields.
|
|
||||||
- The prompt-facing daypart export still gives the LLM enough information to
|
|
||||||
understand condition, temperature trend, precipitation, wind, notable
|
|
||||||
conditions, and alert relevance.
|
|
||||||
- Daily, Today, and Tomorrow template output remains unchanged.
|
|
||||||
- Render contexts still expose rich daypart helper fields.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
- Update daypart module tests for:
|
|
||||||
- prompt export field presence;
|
|
||||||
- removed field absence;
|
|
||||||
- rising, falling, peaking, and steady trend values;
|
|
||||||
- max PoP time using the friendly label under the stable `max_pop_time` key.
|
|
||||||
- Update template tests to prove rich helper fields still render Daily, Today,
|
|
||||||
and Tomorrow daypart wording.
|
|
||||||
- Update app data-package tests to reject stale daypart fields.
|
|
||||||
|
|
||||||
Suggested focused command:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/briefing ./internal/generatedtext ./internal/reporttemplate ./internal/app
|
|
||||||
```
|
|
||||||
|
|
||||||
### Prompt Size
|
|
||||||
|
|
||||||
Medium. Suitable for one implementation prompt.
|
|
||||||
|
|
||||||
## Stage 6: Pass-Through Review And Workflow Regression Tests
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
|
|
||||||
Confirm all other modules export correctly through pass-through behavior and add
|
|
||||||
workflow-level coverage proving the new boundary.
|
|
||||||
|
|
||||||
### Files To Inspect
|
|
||||||
|
|
||||||
- `internal/briefing/*_module.go`
|
|
||||||
- `internal/briefing/modules.go`
|
|
||||||
- `internal/promptinput/package_test.go`
|
|
||||||
- `internal/app/app_test.go`
|
|
||||||
- `internal/generatedtext/render_context_test.go`
|
|
||||||
- `internal/reporttemplate/reporttemplate_test.go`
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
|
|
||||||
- Review remaining modules:
|
|
||||||
- `metadata`
|
|
||||||
- `narrative_forecast`
|
|
||||||
- `derived_daily_summary`
|
|
||||||
- `precip_timing`
|
|
||||||
- `outdoor_windows`
|
|
||||||
- `alert_digest`
|
|
||||||
- `spc_convective_outlooks`
|
|
||||||
- `spc_convective_discussion`
|
|
||||||
- `area_forecast_discussion`
|
|
||||||
- `weather_story`
|
|
||||||
- planning modules
|
|
||||||
- Keep pass-through behavior for modules that are already prompt-appropriate.
|
|
||||||
- Add custom exporters only if a module contains clear template-only helpers or
|
|
||||||
confusing duplicate fields.
|
|
||||||
- Do not broaden this stage into a general prompt-schema redesign.
|
|
||||||
- Add workflow assertions that:
|
|
||||||
- module snapshots contain rich values;
|
|
||||||
- render contexts contain rich values;
|
|
||||||
- data packages contain curated values;
|
|
||||||
- Scriptorium receives the curated data package path exactly as before.
|
|
||||||
- Include workflow coverage for generated-template `today`, `tomorrow`, and
|
|
||||||
`daily` reports when asserting daypart and render-context behavior.
|
|
||||||
|
|
||||||
### Acceptance Criteria
|
|
||||||
|
|
||||||
- Every default module either has a custom exporter or intentionally uses
|
|
||||||
pass-through.
|
|
||||||
- App workflow tests prove saved data packages omit the cleaned fields.
|
|
||||||
- App workflow tests prove rich helper fields remain available where templates
|
|
||||||
use them.
|
|
||||||
- App workflow or render-context tests include `daily` alongside `today` and
|
|
||||||
`tomorrow` for daypart helper coverage.
|
|
||||||
- Existing report generation behavior remains unchanged except for data-package
|
|
||||||
YAML content and schema version.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
Suggested focused command:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/briefing ./internal/promptinput ./internal/generatedtext ./internal/reporttemplate ./internal/app
|
|
||||||
```
|
|
||||||
|
|
||||||
### Prompt Size
|
|
||||||
|
|
||||||
Small to medium. Suitable for one implementation prompt.
|
|
||||||
|
|
||||||
## Stage 7: Documentation And Final Validation
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
|
|
||||||
Update implemented documentation after the code exists and run full validation.
|
|
||||||
|
|
||||||
### Files To Inspect
|
|
||||||
|
|
||||||
- `docs/internal/module.md`
|
|
||||||
- `docs/internal/prompt-input.md`
|
|
||||||
- `docs/templates.md`
|
|
||||||
- `docs/operations.md`
|
|
||||||
- `docs/troubleshooting.md`
|
|
||||||
- `docs/roadmap/data-package-exports.md`
|
|
||||||
- tests that assert documentation examples or data-package snippets
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
|
|
||||||
- Update `docs/internal/module.md` to describe:
|
|
||||||
- rich module output;
|
|
||||||
- runtime prompt export values;
|
|
||||||
- pass-through exporters;
|
|
||||||
- module-owned prompt export policy.
|
|
||||||
- Update `docs/internal/prompt-input.md` to document:
|
|
||||||
- schema version `weatherreporter.data_package.v3`;
|
|
||||||
- curated module export behavior;
|
|
||||||
- category ordering unchanged;
|
|
||||||
- data packages are not full template render contexts.
|
|
||||||
- Update `docs/templates.md` to clarify that templates can use richer fields
|
|
||||||
than the data package exposes.
|
|
||||||
- Update any Daily template-variable documentation alongside Today and Tomorrow
|
|
||||||
references where the same module fields are discussed.
|
|
||||||
- Update any implemented docs containing stale examples of removed data-package
|
|
||||||
fields.
|
|
||||||
- Do not document deferred configurable field profiles or reflection filters as
|
|
||||||
implemented behavior.
|
|
||||||
|
|
||||||
### Acceptance Criteria
|
|
||||||
|
|
||||||
- Non-roadmap docs describe only implemented data-package export behavior.
|
|
||||||
- Docs do not imply data packages contain template-only helper fields.
|
|
||||||
- Examples and snippets use schema version `v3` when they show data packages.
|
|
||||||
|
|
||||||
### Validation Commands
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/module ./internal/briefing ./internal/promptinput
|
|
||||||
go test ./internal/generatedtext ./internal/reporttemplate ./internal/app
|
|
||||||
go test ./...
|
|
||||||
go run ./cmd/weatherreporter --help
|
|
||||||
git diff --check
|
|
||||||
```
|
|
||||||
|
|
||||||
### Stale Field Greps
|
|
||||||
|
|
||||||
Run focused checks against docs and expected saved data-package fixtures/snippets:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
rg -n "condition_text_lower|wind_direction_text|hour_label|text_description_lower|mention_precipitation|dominant_condition_lower|dominant_condition_display|max_pop_time_label|temperature_phrase_f" docs internal/*/*_test.go
|
|
||||||
```
|
|
||||||
|
|
||||||
Review matches manually. Some matches should remain in rich module structs,
|
|
||||||
template tests, and render-context tests; they should not remain as expected
|
|
||||||
data-package fields.
|
|
||||||
|
|
||||||
### Prompt Size
|
|
||||||
|
|
||||||
Medium. Suitable for one implementation prompt.
|
|
||||||
|
|
||||||
## Deferred Work
|
|
||||||
|
|
||||||
Do not include these in this implementation sequence:
|
|
||||||
|
|
||||||
- user-configurable data-package field selection;
|
|
||||||
- per-prompt custom field profiles;
|
|
||||||
- reflection-based generic include/exclude lists;
|
|
||||||
- automatic schema generation for data-package exports;
|
|
||||||
- changing collected facts or derived facts contracts;
|
|
||||||
- changing Scriptorium invocation behavior;
|
|
||||||
- changing generated Markdown templates beyond preserving their output;
|
|
||||||
- migrating old workspace data-package artifacts.
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
No open questions block implementation.
|
|
||||||
|
|
||||||
The implementation plan intentionally locks in the recommended choices from the
|
|
||||||
feature roadmap: schema version bump to `weatherreporter.data_package.v3`, and
|
|
||||||
runtime-only prompt export values stored on `module.Output`.
|
|
||||||
|
|
||||||
## Global Validation Checklist
|
|
||||||
|
|
||||||
- `go test ./...` passes.
|
|
||||||
- `go run ./cmd/weatherreporter --help` is accurate.
|
|
||||||
- `git diff --check` passes.
|
|
||||||
- Saved data packages use schema version `weatherreporter.data_package.v3`.
|
|
||||||
- Saved data packages serialize prompt-facing module exports.
|
|
||||||
- Saved module snapshots serialize rich module values and do not persist
|
|
||||||
prompt-facing values.
|
|
||||||
- Render contexts continue to expose rich module values.
|
|
||||||
- Hourly, Today, Tomorrow, and Daily templates continue to render the same
|
|
||||||
Markdown output.
|
|
||||||
- YAML briefing category order remains unchanged.
|
|
||||||
- `current_conditions` data packages omit `condition_text_lower` and
|
|
||||||
`wind_direction_text`.
|
|
||||||
- `hourly_forecast.periods[]` data packages omit `hour_label`,
|
|
||||||
`text_description_lower`, and `mention_precipitation`.
|
|
||||||
- `derived_daypart_summaries` data packages omit `temperature_phrase_f`,
|
|
||||||
`dominant_condition_lower`, `dominant_condition_display`, and
|
|
||||||
`max_pop_time_label`.
|
|
||||||
- Non-roadmap docs describe only implemented behavior.
|
|
||||||
@@ -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. |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
379
internal/app/batch_notification.go
Normal file
379
internal/app/batch_notification.go
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
const runIDTimestampLayout = "20060102T150405.000000000Z"
|
||||||
|
|
||||||
|
type batchNotificationIdentity struct {
|
||||||
|
PipelineID string
|
||||||
|
BundleID string
|
||||||
|
IdempotencyKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
type batchNotificationRequest struct {
|
||||||
|
Batch BatchKind
|
||||||
|
RunID string
|
||||||
|
PipelineID string
|
||||||
|
BundleID string
|
||||||
|
IdempotencyKey string
|
||||||
|
Files []batchNotificationFile
|
||||||
|
IncludedReports []BatchNotificationReport
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type batchNotificationFile struct {
|
||||||
|
ReportID report.ID
|
||||||
|
RunID string
|
||||||
|
SourcePath string
|
||||||
|
BundlePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type batchNotifier interface {
|
||||||
|
NotifyBatch(context.Context, batchNotificationRequest) (*NotificationResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchRunID(startedAt time.Time, batch BatchKind) string {
|
||||||
|
return startedAt.UTC().Format(runIDTimestampLayout) + "_" + string(batch)
|
||||||
|
}
|
||||||
|
|
||||||
|
func notifyBatch(ctx context.Context, cfg config.Config, batch BatchKind, runID string, startedAt time.Time, result *BatchResult, planned []plannedBatchReport, store state.Store, notifier Notifier) (*BatchNotificationResult, error) {
|
||||||
|
if !cfg.Notify.Distributor.Enabled {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if !cfg.Notify.Distributor.Batch.Enabled {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
return nil, fmt.Errorf("batch result is required")
|
||||||
|
}
|
||||||
|
if result.Failed > 0 {
|
||||||
|
return &BatchNotificationResult{
|
||||||
|
Status: "skipped",
|
||||||
|
Reason: "one or more reports failed",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := buildBatchNotificationRequest(cfg, batch, runID, startedAt, result.Reports, planned)
|
||||||
|
if err != nil {
|
||||||
|
path, saveErr := saveBatchNotificationArtifact(ctx, store, cfg, batch, runID, startedAt, batchNotificationRequest{}, nil, err)
|
||||||
|
if saveErr != nil {
|
||||||
|
return nil, saveErr
|
||||||
|
}
|
||||||
|
return failedBatchNotificationResult(batchNotificationRequest{}, path, err), err
|
||||||
|
}
|
||||||
|
|
||||||
|
batchNotifier, err := resolveBatchNotifier(cfg, notifier)
|
||||||
|
if err != nil {
|
||||||
|
path, saveErr := saveBatchNotificationArtifact(ctx, store, cfg, batch, runID, startedAt, req, nil, err)
|
||||||
|
if saveErr != nil {
|
||||||
|
return nil, saveErr
|
||||||
|
}
|
||||||
|
return failedBatchNotificationResult(req, path, err), err
|
||||||
|
}
|
||||||
|
|
||||||
|
notification, notifyErr := batchNotifier.NotifyBatch(ctx, req)
|
||||||
|
wrappedErr := notifyErr
|
||||||
|
if notifyErr != nil {
|
||||||
|
wrappedErr = fmt.Errorf("notify batch %q run %q bundle %q: %w", batch, runID, req.BundleID, notifyErr)
|
||||||
|
}
|
||||||
|
path, saveErr := saveBatchNotificationArtifact(ctx, store, cfg, batch, runID, startedAt, req, notification, wrappedErr)
|
||||||
|
if saveErr != nil {
|
||||||
|
return nil, saveErr
|
||||||
|
}
|
||||||
|
|
||||||
|
batchResult := batchNotificationResult(req, notification, path)
|
||||||
|
if wrappedErr != nil {
|
||||||
|
batchResult.Status = "failed"
|
||||||
|
batchResult.Error = wrappedErr.Error()
|
||||||
|
return batchResult, wrappedErr
|
||||||
|
}
|
||||||
|
return batchResult, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveBatchNotifier(cfg config.Config, notifier Notifier) (batchNotifier, error) {
|
||||||
|
if notifier != nil {
|
||||||
|
if batchNotifier, ok := notifier.(batchNotifier); ok {
|
||||||
|
return batchNotifier, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("batch distributor notifier is required")
|
||||||
|
}
|
||||||
|
return distributorNotifier{
|
||||||
|
client: distributoradapter.New(cfg.Notify.Distributor),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildBatchNotificationRequest(cfg config.Config, batch BatchKind, runID string, startedAt time.Time, reports []BatchReportResult, planned []plannedBatchReport) (batchNotificationRequest, error) {
|
||||||
|
if len(reports) == 0 {
|
||||||
|
return batchNotificationRequest{}, fmt.Errorf("batch notification requires at least one report")
|
||||||
|
}
|
||||||
|
|
||||||
|
identity, err := renderBatchNotificationIdentity(cfg, batch, runID, startedAt)
|
||||||
|
if err != nil {
|
||||||
|
return batchNotificationRequest{}, err
|
||||||
|
}
|
||||||
|
if identity.PipelineID == "" {
|
||||||
|
return batchNotificationRequest{}, fmt.Errorf("batch notification pipeline id is required")
|
||||||
|
}
|
||||||
|
if identity.BundleID == "" {
|
||||||
|
return batchNotificationRequest{}, fmt.Errorf("batch notification bundle id is required")
|
||||||
|
}
|
||||||
|
if identity.IdempotencyKey == "" {
|
||||||
|
return batchNotificationRequest{}, fmt.Errorf("batch notification idempotency key is required for bundle %q", identity.BundleID)
|
||||||
|
}
|
||||||
|
|
||||||
|
plannedByRunID, err := plannedReportsByRunID(planned)
|
||||||
|
if err != nil {
|
||||||
|
return batchNotificationRequest{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req := batchNotificationRequest{
|
||||||
|
Batch: batch,
|
||||||
|
RunID: runID,
|
||||||
|
PipelineID: identity.PipelineID,
|
||||||
|
BundleID: identity.BundleID,
|
||||||
|
IdempotencyKey: identity.IdempotencyKey,
|
||||||
|
CreatedAt: startedAt,
|
||||||
|
}
|
||||||
|
seenBundlePaths := map[string]batchNotificationFile{}
|
||||||
|
for _, item := range reports {
|
||||||
|
plannedReport, ok := plannedByRunID[item.RunID]
|
||||||
|
if !ok {
|
||||||
|
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q has no matching planned report", item.ReportID, item.RunID)
|
||||||
|
}
|
||||||
|
if item.ReportID != plannedReport.Resolved.Definition.ID {
|
||||||
|
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q does not match planned report %q", item.ReportID, item.RunID, plannedReport.Resolved.Definition.ID)
|
||||||
|
}
|
||||||
|
if item.ReportPath == "" {
|
||||||
|
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q is missing managed report path", item.ReportID, item.RunID)
|
||||||
|
}
|
||||||
|
|
||||||
|
values, err := distributorTemplateValuesForReport(cfg, plannedReport.Resolved, item.RunID, plannedReport.OutputCopyName)
|
||||||
|
if err != nil {
|
||||||
|
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q source path %q: %w", item.ReportID, item.RunID, item.ReportPath, err)
|
||||||
|
}
|
||||||
|
bundlePaths, err := renderDistributorReportBundlePaths(cfg, plannedReport.Resolved, item.RunID, item.ReportPath, values)
|
||||||
|
if err != nil {
|
||||||
|
return batchNotificationRequest{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
included := BatchNotificationReport{
|
||||||
|
ReportID: item.ReportID,
|
||||||
|
RunID: item.RunID,
|
||||||
|
SourcePath: item.ReportPath,
|
||||||
|
BundlePaths: append([]string(nil), bundlePaths...),
|
||||||
|
}
|
||||||
|
for _, bundlePath := range bundlePaths {
|
||||||
|
file := batchNotificationFile{
|
||||||
|
ReportID: item.ReportID,
|
||||||
|
RunID: item.RunID,
|
||||||
|
SourcePath: item.ReportPath,
|
||||||
|
BundlePath: bundlePath,
|
||||||
|
}
|
||||||
|
if previous, ok := seenBundlePaths[bundlePath]; ok {
|
||||||
|
return batchNotificationRequest{}, fmt.Errorf("batch notification duplicate bundle path %q for report %q run %q source path %q; already used by report %q run %q source path %q", bundlePath, item.ReportID, item.RunID, item.ReportPath, previous.ReportID, previous.RunID, previous.SourcePath)
|
||||||
|
}
|
||||||
|
seenBundlePaths[bundlePath] = file
|
||||||
|
req.Files = append(req.Files, file)
|
||||||
|
}
|
||||||
|
req.IncludedReports = append(req.IncludedReports, included)
|
||||||
|
}
|
||||||
|
if len(req.Files) == 0 {
|
||||||
|
return batchNotificationRequest{}, fmt.Errorf("batch notification requires at least one file mapping")
|
||||||
|
}
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func plannedReportsByRunID(planned []plannedBatchReport) (map[string]plannedBatchReport, error) {
|
||||||
|
byRunID := make(map[string]plannedBatchReport, len(planned))
|
||||||
|
for _, item := range planned {
|
||||||
|
runID := item.Resolved.Metadata().RunID
|
||||||
|
if runID == "" {
|
||||||
|
return nil, fmt.Errorf("planned report %q has empty run id", item.Resolved.Definition.ID)
|
||||||
|
}
|
||||||
|
if previous, ok := byRunID[runID]; ok {
|
||||||
|
return nil, fmt.Errorf("planned reports %q and %q share run id %q", previous.Resolved.Definition.ID, item.Resolved.Definition.ID, runID)
|
||||||
|
}
|
||||||
|
byRunID[runID] = item
|
||||||
|
}
|
||||||
|
return byRunID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchDistributorUploadRequest(req batchNotificationRequest) distributoradapter.UploadRequest {
|
||||||
|
files := make([]distributoradapter.UploadFile, 0, len(req.Files))
|
||||||
|
for _, file := range req.Files {
|
||||||
|
files = append(files, distributoradapter.UploadFile{
|
||||||
|
SourcePath: file.SourcePath,
|
||||||
|
BundlePath: file.BundlePath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return distributoradapter.UploadRequest{
|
||||||
|
PipelineID: req.PipelineID,
|
||||||
|
BundleID: req.BundleID,
|
||||||
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
|
Files: files,
|
||||||
|
CreatedAt: req.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchNotificationResult(req batchNotificationRequest, result *NotificationResult, path string) *BatchNotificationResult {
|
||||||
|
notification := &BatchNotificationResult{
|
||||||
|
Status: "unknown",
|
||||||
|
PipelineID: req.PipelineID,
|
||||||
|
BundleID: req.BundleID,
|
||||||
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
|
Path: path,
|
||||||
|
IncludedReports: append([]BatchNotificationReport(nil), req.IncludedReports...),
|
||||||
|
}
|
||||||
|
if result != nil {
|
||||||
|
notification.Status = result.Status
|
||||||
|
notification.RunID = result.RunID
|
||||||
|
if result.PipelineID != "" {
|
||||||
|
notification.PipelineID = result.PipelineID
|
||||||
|
}
|
||||||
|
if result.BundleID != "" {
|
||||||
|
notification.BundleID = result.BundleID
|
||||||
|
}
|
||||||
|
if result.IdempotencyKey != "" {
|
||||||
|
notification.IdempotencyKey = result.IdempotencyKey
|
||||||
|
}
|
||||||
|
if result.Error != "" {
|
||||||
|
notification.Error = result.Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if notification.Status == "" {
|
||||||
|
notification.Status = "unknown"
|
||||||
|
}
|
||||||
|
return notification
|
||||||
|
}
|
||||||
|
|
||||||
|
func failedBatchNotificationResult(req batchNotificationRequest, path string, err error) *BatchNotificationResult {
|
||||||
|
notification := batchNotificationResult(req, nil, path)
|
||||||
|
notification.Status = "failed"
|
||||||
|
if err != nil {
|
||||||
|
notification.Error = err.Error()
|
||||||
|
}
|
||||||
|
return notification
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveBatchNotificationArtifact(ctx context.Context, store state.Store, cfg config.Config, batch BatchKind, runID string, startedAt time.Time, req batchNotificationRequest, result *NotificationResult, notifyErr error) (string, error) {
|
||||||
|
if store == nil {
|
||||||
|
return "", fmt.Errorf("state store is required")
|
||||||
|
}
|
||||||
|
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("load batch notification timezone: %w", err)
|
||||||
|
}
|
||||||
|
artifact := state.BatchDistributorNotificationArtifact{
|
||||||
|
SchemaVersion: state.BatchDistributorNotificationSchemaVersion,
|
||||||
|
Batch: string(batch),
|
||||||
|
BatchRunID: runID,
|
||||||
|
AttemptedAt: time.Now(),
|
||||||
|
Endpoint: cfg.Notify.Distributor.Endpoint,
|
||||||
|
PipelineID: req.PipelineID,
|
||||||
|
BundleID: req.BundleID,
|
||||||
|
IdempotencyKey: req.IdempotencyKey,
|
||||||
|
BundleCreated: req.CreatedAt,
|
||||||
|
Reports: batchNotificationReportArtifacts(req.IncludedReports),
|
||||||
|
Status: "attempted",
|
||||||
|
}
|
||||||
|
if result != nil {
|
||||||
|
artifact.Status = result.Status
|
||||||
|
artifact.Upload = &state.DistributorUploadResult{
|
||||||
|
RunID: result.RunID,
|
||||||
|
Status: result.UploadStatus,
|
||||||
|
}
|
||||||
|
if result.PipelineID != "" || !result.AcceptedAt.IsZero() || result.StartedAt != nil || result.FinishedAt != nil || len(result.Report) > 0 || result.Error != "" {
|
||||||
|
artifact.RunStatus = &state.DistributorRunStatus{
|
||||||
|
RunID: result.RunID,
|
||||||
|
PipelineID: result.PipelineID,
|
||||||
|
Status: result.Status,
|
||||||
|
AcceptedAt: result.AcceptedAt,
|
||||||
|
StartedAt: result.StartedAt,
|
||||||
|
FinishedAt: result.FinishedAt,
|
||||||
|
Report: append([]byte(nil), result.Report...),
|
||||||
|
Error: result.Error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
artifact.StatusError = result.StatusError
|
||||||
|
}
|
||||||
|
if notifyErr != nil {
|
||||||
|
artifact.Status = "failed"
|
||||||
|
artifact.Error = notifyErr.Error()
|
||||||
|
}
|
||||||
|
if artifact.Status == "" {
|
||||||
|
artifact.Status = "unknown"
|
||||||
|
}
|
||||||
|
return store.SaveBatchDistributorNotification(ctx, state.BatchDistributorNotificationRef{
|
||||||
|
Batch: string(batch),
|
||||||
|
BatchRunID: runID,
|
||||||
|
StartedAt: startedAt,
|
||||||
|
Location: location,
|
||||||
|
}, artifact)
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchNotificationReportArtifacts(reports []BatchNotificationReport) []state.BatchDistributorNotificationReportArtifact {
|
||||||
|
if len(reports) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
artifacts := make([]state.BatchDistributorNotificationReportArtifact, 0, len(reports))
|
||||||
|
for _, item := range reports {
|
||||||
|
artifacts = append(artifacts, state.BatchDistributorNotificationReportArtifact{
|
||||||
|
ReportID: item.ReportID,
|
||||||
|
RunID: item.RunID,
|
||||||
|
SourcePath: item.SourcePath,
|
||||||
|
BundlePaths: append([]string(nil), item.BundlePaths...),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return artifacts
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderBatchNotificationIdentity(cfg config.Config, batch BatchKind, runID string, startedAt time.Time) (batchNotificationIdentity, error) {
|
||||||
|
values, err := batchNotificationTemplateValues(cfg, batch, runID, startedAt)
|
||||||
|
if err != nil {
|
||||||
|
return batchNotificationIdentity{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
bundleID, err := config.RenderDistributorBatchBundleID(cfg.Notify.Distributor.Batch.BundleIDTemplate, values)
|
||||||
|
if err != nil {
|
||||||
|
return batchNotificationIdentity{}, err
|
||||||
|
}
|
||||||
|
values.BundleID = bundleID
|
||||||
|
|
||||||
|
pipelineID, err := config.RenderDistributorBatchPipelineID(cfg.Notify.Distributor.Batch.PipelineIDTemplate, values)
|
||||||
|
if err != nil {
|
||||||
|
return batchNotificationIdentity{}, err
|
||||||
|
}
|
||||||
|
idempotencyKey, err := config.RenderDistributorBatchIdempotencyKey(cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate, values)
|
||||||
|
if err != nil {
|
||||||
|
return batchNotificationIdentity{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return batchNotificationIdentity{
|
||||||
|
PipelineID: pipelineID,
|
||||||
|
BundleID: bundleID,
|
||||||
|
IdempotencyKey: idempotencyKey,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchNotificationTemplateValues(cfg config.Config, batch BatchKind, runID string, startedAt time.Time) (config.DistributorBatchTemplateValues, error) {
|
||||||
|
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return config.DistributorBatchTemplateValues{}, fmt.Errorf("load batch notification timezone: %w", err)
|
||||||
|
}
|
||||||
|
return config.DistributorBatchTemplateValues{
|
||||||
|
LocationID: cfg.Location.ID,
|
||||||
|
Batch: string(batch),
|
||||||
|
BatchRunID: runID,
|
||||||
|
BatchStartedDate: startedAt.In(location).Format(timeutil.DateLayout),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
139
internal/app/batch_plan.go
Normal file
139
internal/app/batch_plan.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type plannedBatchReport struct {
|
||||||
|
Resolved report.Resolved
|
||||||
|
OutputCopyName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func planBatchRun(req BatchRequest, now time.Time, collection collect.Result) ([]plannedBatchReport, error) {
|
||||||
|
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
batch, err := report.BatchForCommandName(string(req.Batch))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
registry, err := reportRegistry(req.Config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveReq := report.ResolveRequest{
|
||||||
|
Now: now,
|
||||||
|
Location: location,
|
||||||
|
}
|
||||||
|
var planned []plannedBatchReport
|
||||||
|
switch batch {
|
||||||
|
case report.Morning:
|
||||||
|
planned, err = appendPlannedReport(planned, registry, report.Today, resolveReq, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
case report.Evening:
|
||||||
|
planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown batch %q", batch)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hourly *weatherdata.ForecastRun
|
||||||
|
if collection.Bundle != nil {
|
||||||
|
hourly = collection.Bundle.Hourly
|
||||||
|
}
|
||||||
|
for _, date := range eligibleDailyDates(hourly, now, location) {
|
||||||
|
dailyReq := resolveReq
|
||||||
|
dailyReq.Date = date
|
||||||
|
outputCopyName := "daily-" + date.In(location).Format(timeutil.DateLayout) + ".md"
|
||||||
|
planned, err = appendPlannedReport(planned, registry, report.Daily, dailyReq, outputCopyName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return planned, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendPlannedReport(planned []plannedBatchReport, registry report.Registry, id report.ID, req report.ResolveRequest, outputCopyName string) ([]plannedBatchReport, error) {
|
||||||
|
resolved, err := registry.Resolve(id, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return append(planned, plannedBatchReport{
|
||||||
|
Resolved: resolved,
|
||||||
|
OutputCopyName: outputCopyName,
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func eligibleDailyDates(hourly *weatherdata.ForecastRun, now time.Time, location *time.Location) []time.Time {
|
||||||
|
if hourly == nil || location == nil || hourly.Product != "hourly" || len(hourly.Periods) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hourlyStarts := make(map[time.Time]struct{}, len(hourly.Periods))
|
||||||
|
var maxLocalDate time.Time
|
||||||
|
for _, period := range hourly.Periods {
|
||||||
|
if !isHourlyPeriod(period) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
start := period.StartTime
|
||||||
|
hourlyStarts[instantKey(start)] = struct{}{}
|
||||||
|
localDate := localDateStart(start, location)
|
||||||
|
if maxLocalDate.IsZero() || localDate.After(maxLocalDate) {
|
||||||
|
maxLocalDate = localDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(hourlyStarts) == 0 || maxLocalDate.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
startDate := localDateStart(now.In(location).AddDate(0, 0, 2), location)
|
||||||
|
var dates []time.Time
|
||||||
|
for candidate := startDate; !candidate.After(maxLocalDate); candidate = candidate.AddDate(0, 0, 1) {
|
||||||
|
if hasFullHourlyCoverage(candidate, location, hourlyStarts) {
|
||||||
|
dates = append(dates, candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dates
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHourlyPeriod(period weatherdata.ForecastPeriod) bool {
|
||||||
|
if period.StartTime.IsZero() || period.EndTime.IsZero() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return period.EndTime.Equal(period.StartTime.Add(time.Hour))
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasFullHourlyCoverage(date time.Time, location *time.Location, hourlyStarts map[time.Time]struct{}) bool {
|
||||||
|
day := timeutil.CivilDay(date, location)
|
||||||
|
for required := day.Start; required.Before(day.End); required = required.Add(time.Hour) {
|
||||||
|
if _, ok := hourlyStarts[instantKey(required)]; !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func instantKey(value time.Time) time.Time {
|
||||||
|
return value.UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
func localDateStart(value time.Time, location *time.Location) time.Time {
|
||||||
|
local := value.In(location)
|
||||||
|
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location)
|
||||||
|
}
|
||||||
352
internal/app/batch_plan_test.go
Normal file
352
internal/app/batch_plan_test.go
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlanBatchRunMorningOrder(t *testing.T) {
|
||||||
|
location := mustLoadTestLocation(t, "America/Chicago")
|
||||||
|
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
|
||||||
|
|
||||||
|
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchMorning}, mustParse("2026-05-29T08:00:00-05:00"), collectionWithHourly(hourly))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("planBatchRun() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertPlannedReportIDs(t, planned, report.Today, report.Tomorrow, report.Daily)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanBatchRunEveningOrder(t *testing.T) {
|
||||||
|
location := mustLoadTestLocation(t, "America/Chicago")
|
||||||
|
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
|
||||||
|
|
||||||
|
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchEvening}, mustParse("2026-05-29T18:00:00-05:00"), collectionWithHourly(hourly))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("planBatchRun() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertPlannedReportIDs(t, planned, report.Tomorrow, report.Daily)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanBatchRunDynamicDailyDatesStartAfterTomorrow(t *testing.T) {
|
||||||
|
location := mustLoadTestLocation(t, "America/Chicago")
|
||||||
|
periods := fullDayPeriods(t, "2026-05-30", location)
|
||||||
|
periods = append(periods, fullDayPeriods(t, "2026-05-31", location)...)
|
||||||
|
periods = append(periods, fullDayPeriods(t, "2026-06-01", location)...)
|
||||||
|
|
||||||
|
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchMorning}, mustParse("2026-05-29T08:00:00-05:00"), collectionWithHourly(hourlyRun(periods...)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("planBatchRun() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
daily := plannedDailyReports(planned)
|
||||||
|
if len(daily) != 2 {
|
||||||
|
t.Fatalf("daily reports = %#v, want two future Daily reports", daily)
|
||||||
|
}
|
||||||
|
assertPlanningPeriod(t, daily[0].Resolved.ValidPeriod, "2026-05-31T00:00:00-05:00", "2026-06-01T00:00:00-05:00")
|
||||||
|
assertPlanningPeriod(t, daily[1].Resolved.ValidPeriod, "2026-06-01T00:00:00-05:00", "2026-06-02T00:00:00-05:00")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanBatchRunMorningExcludesLegacyStaticReports(t *testing.T) {
|
||||||
|
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchMorning}, mustParse("2026-05-29T08:00:00-05:00"), collect.Result{Bundle: &weatherdata.Bundle{}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("planBatchRun() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range planned {
|
||||||
|
if item.Resolved.Definition.ID == report.ThreeDay || item.Resolved.Definition.ID == report.Weekend {
|
||||||
|
t.Fatalf("morning plan includes %s, want no 3-Day or Weekend", item.Resolved.Definition.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanBatchRunDynamicDailyOutputCopyNames(t *testing.T) {
|
||||||
|
location := mustLoadTestLocation(t, "America/Chicago")
|
||||||
|
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
|
||||||
|
|
||||||
|
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchEvening}, mustParse("2026-05-29T18:00:00-05:00"), collectionWithHourly(hourly))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("planBatchRun() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
daily := plannedDailyReports(planned)
|
||||||
|
if len(daily) != 1 {
|
||||||
|
t.Fatalf("daily reports = %#v, want one Daily report", daily)
|
||||||
|
}
|
||||||
|
if daily[0].OutputCopyName != "daily-2026-05-31.md" {
|
||||||
|
t.Fatalf("OutputCopyName = %q, want date-qualified Daily name", daily[0].OutputCopyName)
|
||||||
|
}
|
||||||
|
if planned[0].OutputCopyName != "" {
|
||||||
|
t.Fatalf("Tomorrow OutputCopyName = %q, want definition batch output name to apply later", planned[0].OutputCopyName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanBatchRunRejectsUnknownBatch(t *testing.T) {
|
||||||
|
_, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchKind("hourly")}, mustParse("2026-05-29T08:00:00-05:00"), collect.Result{Bundle: &weatherdata.Bundle{}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `unknown batch command "hourly"`) {
|
||||||
|
t.Fatalf("planBatchRun() error = %v, want unknown batch command", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEligibleDailyDatesRequiresFullOrdinaryLocalDay(t *testing.T) {
|
||||||
|
location := mustLoadTestLocation(t, "America/Chicago")
|
||||||
|
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
|
||||||
|
|
||||||
|
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||||
|
assertLocalDates(t, got, location, "2026-05-31")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEligibleDailyDatesMatchesFixedOffsetStartInstants(t *testing.T) {
|
||||||
|
location := mustLoadTestLocation(t, "America/Chicago")
|
||||||
|
hourly := hourlyRun(fixedOffsetPeriods(t, fullDayPeriods(t, "2026-05-31", location))...)
|
||||||
|
|
||||||
|
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||||
|
assertLocalDates(t, got, location, "2026-05-31")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEligibleDailyDatesSkipsDayWithMissingRequiredHour(t *testing.T) {
|
||||||
|
location := mustLoadTestLocation(t, "America/Chicago")
|
||||||
|
periods := fullDayPeriods(t, "2026-05-31", location)
|
||||||
|
periods = append(periods[:12], periods[13:]...)
|
||||||
|
hourly := hourlyRun(periods...)
|
||||||
|
|
||||||
|
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||||
|
assertLocalDates(t, got, location)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEligibleDailyDatesSkipsPartialFinalDay(t *testing.T) {
|
||||||
|
location := mustLoadTestLocation(t, "America/Chicago")
|
||||||
|
periods := fullDayPeriods(t, "2026-05-31", location)
|
||||||
|
periods = append(periods, partialDayPeriods(t, "2026-06-01", location, 12)...)
|
||||||
|
hourly := hourlyRun(periods...)
|
||||||
|
|
||||||
|
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||||
|
assertLocalDates(t, got, location, "2026-05-31")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEligibleDailyDatesStartsAfterTomorrow(t *testing.T) {
|
||||||
|
location := mustLoadTestLocation(t, "America/Chicago")
|
||||||
|
periods := fullDayPeriods(t, "2026-05-29", location)
|
||||||
|
periods = append(periods, fullDayPeriods(t, "2026-05-30", location)...)
|
||||||
|
periods = append(periods, fullDayPeriods(t, "2026-05-31", location)...)
|
||||||
|
hourly := hourlyRun(periods...)
|
||||||
|
|
||||||
|
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||||
|
assertLocalDates(t, got, location, "2026-05-31")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEligibleDailyDatesReturnsMultipleFutureDatesInOrder(t *testing.T) {
|
||||||
|
location := mustLoadTestLocation(t, "America/Chicago")
|
||||||
|
periods := fullDayPeriods(t, "2026-05-31", location)
|
||||||
|
periods = append(periods, fullDayPeriods(t, "2026-06-01", location)...)
|
||||||
|
hourly := hourlyRun(periods...)
|
||||||
|
|
||||||
|
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||||
|
assertLocalDates(t, got, location, "2026-05-31", "2026-06-01")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEligibleDailyDatesIgnoresNonHourlyAndInvalidPeriods(t *testing.T) {
|
||||||
|
location := mustLoadTestLocation(t, "America/Chicago")
|
||||||
|
day := timeutil.CivilDay(mustParseLocalDate(t, "2026-05-31", location), location)
|
||||||
|
periods := []weatherdata.ForecastPeriod{
|
||||||
|
{StartTime: day.Start, EndTime: day.Start.Add(2 * time.Hour)},
|
||||||
|
{StartTime: day.Start.Add(time.Hour), EndTime: day.Start.Add(time.Hour)},
|
||||||
|
{StartTime: time.Time{}, EndTime: day.Start.Add(3 * time.Hour)},
|
||||||
|
}
|
||||||
|
periods = append(periods, fullDayPeriods(t, "2026-06-01", location)...)
|
||||||
|
hourly := hourlyRun(periods...)
|
||||||
|
|
||||||
|
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||||
|
assertLocalDates(t, got, location, "2026-06-01")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEligibleDailyDatesUsesDSTCivilDayInstants(t *testing.T) {
|
||||||
|
location := mustLoadTestLocation(t, "America/New_York")
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
now string
|
||||||
|
date string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "spring forward",
|
||||||
|
now: "2026-03-06T08:00:00-05:00",
|
||||||
|
date: "2026-03-08",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fall back",
|
||||||
|
now: "2026-10-30T08:00:00-04:00",
|
||||||
|
date: "2026-11-01",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
hourly := hourlyRun(fullDayPeriods(t, tt.date, location)...)
|
||||||
|
|
||||||
|
got := eligibleDailyDates(hourly, mustParse(tt.now), location)
|
||||||
|
assertLocalDates(t, got, location, tt.date)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEligibleDailyDatesReturnsNoneWithoutHourlyForecast(t *testing.T) {
|
||||||
|
location := mustLoadTestLocation(t, "America/Chicago")
|
||||||
|
fullDay := fullDayPeriods(t, "2026-05-31", location)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
hourly *weatherdata.ForecastRun
|
||||||
|
}{
|
||||||
|
{name: "nil run"},
|
||||||
|
{name: "empty periods", hourly: hourlyRun()},
|
||||||
|
{name: "non-hourly product", hourly: forecastRun("narrative", fullDay...)},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := eligibleDailyDates(tt.hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||||
|
assertLocalDates(t, got, location)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hourlyRun(periods ...weatherdata.ForecastPeriod) *weatherdata.ForecastRun {
|
||||||
|
return forecastRun("hourly", periods...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func forecastRun(product string, periods ...weatherdata.ForecastPeriod) *weatherdata.ForecastRun {
|
||||||
|
return &weatherdata.ForecastRun{
|
||||||
|
Product: product,
|
||||||
|
Periods: periods,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionWithHourly(hourly *weatherdata.ForecastRun) collect.Result {
|
||||||
|
return collect.Result{Bundle: &weatherdata.Bundle{Hourly: hourly}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func planningConfig() config.Config {
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertPlannedReportIDs(t *testing.T, got []plannedBatchReport, want ...report.ID) {
|
||||||
|
t.Helper()
|
||||||
|
gotIDs := make([]string, 0, len(got))
|
||||||
|
for _, item := range got {
|
||||||
|
gotIDs = append(gotIDs, string(item.Resolved.Definition.ID))
|
||||||
|
}
|
||||||
|
wantIDs := make([]string, 0, len(want))
|
||||||
|
for _, id := range want {
|
||||||
|
wantIDs = append(wantIDs, string(id))
|
||||||
|
}
|
||||||
|
if strings.Join(gotIDs, ",") != strings.Join(wantIDs, ",") {
|
||||||
|
t.Fatalf("planned report IDs = [%s], want [%s]", strings.Join(gotIDs, ","), strings.Join(wantIDs, ","))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func plannedDailyReports(planned []plannedBatchReport) []plannedBatchReport {
|
||||||
|
var daily []plannedBatchReport
|
||||||
|
for _, item := range planned {
|
||||||
|
if item.Resolved.Definition.ID == report.Daily {
|
||||||
|
daily = append(daily, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return daily
|
||||||
|
}
|
||||||
|
|
||||||
|
func fullDayPeriods(t *testing.T, date string, location *time.Location) []weatherdata.ForecastPeriod {
|
||||||
|
t.Helper()
|
||||||
|
day := timeutil.CivilDay(mustParseLocalDate(t, date, location), location)
|
||||||
|
var periods []weatherdata.ForecastPeriod
|
||||||
|
for start := day.Start; start.Before(day.End); start = start.Add(time.Hour) {
|
||||||
|
periods = append(periods, weatherdata.ForecastPeriod{
|
||||||
|
StartTime: start,
|
||||||
|
EndTime: start.Add(time.Hour),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return periods
|
||||||
|
}
|
||||||
|
|
||||||
|
func partialDayPeriods(t *testing.T, date string, location *time.Location, count int) []weatherdata.ForecastPeriod {
|
||||||
|
t.Helper()
|
||||||
|
periods := fullDayPeriods(t, date, location)
|
||||||
|
if count > len(periods) {
|
||||||
|
count = len(periods)
|
||||||
|
}
|
||||||
|
return periods[:count]
|
||||||
|
}
|
||||||
|
|
||||||
|
func fixedOffsetPeriods(t *testing.T, periods []weatherdata.ForecastPeriod) []weatherdata.ForecastPeriod {
|
||||||
|
t.Helper()
|
||||||
|
out := make([]weatherdata.ForecastPeriod, 0, len(periods))
|
||||||
|
for _, period := range periods {
|
||||||
|
start, err := time.Parse(time.RFC3339, period.StartTime.Format(time.RFC3339))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse fixed-offset start: %v", err)
|
||||||
|
}
|
||||||
|
end, err := time.Parse(time.RFC3339, period.EndTime.Format(time.RFC3339))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse fixed-offset end: %v", err)
|
||||||
|
}
|
||||||
|
out = append(out, weatherdata.ForecastPeriod{StartTime: start, EndTime: end})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertLocalDates(t *testing.T, got []time.Time, location *time.Location, want ...string) {
|
||||||
|
t.Helper()
|
||||||
|
gotDates := make([]string, 0, len(got))
|
||||||
|
for _, date := range got {
|
||||||
|
gotDates = append(gotDates, date.In(location).Format(timeutil.DateLayout))
|
||||||
|
}
|
||||||
|
if strings.Join(gotDates, ",") != strings.Join(want, ",") {
|
||||||
|
t.Fatalf("eligibleDailyDates() = [%s], want [%s]", strings.Join(gotDates, ","), strings.Join(want, ","))
|
||||||
|
}
|
||||||
|
for _, date := range got {
|
||||||
|
day := timeutil.CivilDay(date, location)
|
||||||
|
if !date.Equal(day.Start) {
|
||||||
|
t.Fatalf("eligible date %s is not local civil day start %s", date, day.Start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertPlanningPeriod(t *testing.T, period timeutil.Period, wantStart string, wantEnd string) {
|
||||||
|
t.Helper()
|
||||||
|
if !period.IsValid() {
|
||||||
|
t.Fatalf("period = %#v, want valid", period)
|
||||||
|
}
|
||||||
|
if got := period.Start.Format(time.RFC3339); got != wantStart {
|
||||||
|
t.Fatalf("Start = %s, want %s", got, wantStart)
|
||||||
|
}
|
||||||
|
if got := period.End.Format(time.RFC3339); got != wantEnd {
|
||||||
|
t.Fatalf("End = %s, want %s", got, wantEnd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustLoadTestLocation(t *testing.T, name string) *time.Location {
|
||||||
|
t.Helper()
|
||||||
|
location, err := time.LoadLocation(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLocation(%q) error = %v", name, err)
|
||||||
|
}
|
||||||
|
return location
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustParseLocalDate(t *testing.T, value string, location *time.Location) time.Time {
|
||||||
|
t.Helper()
|
||||||
|
parsed, err := timeutil.ParseLocalDate(value, location)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseLocalDate(%q) error = %v", value, err)
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
package briefing
|
package briefing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
precipTimingChanceLowerBound = 40
|
||||||
|
precipTimingLikelyLowerBound = 50
|
||||||
|
precipTimingExpectLowerBound = 70
|
||||||
|
)
|
||||||
|
|
||||||
type PrecipTimingModule struct {
|
type PrecipTimingModule struct {
|
||||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||||
@@ -21,6 +30,8 @@ type PrecipitationWindowModule struct {
|
|||||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||||
MaxPopHourLabel string `json:"max_pop_hour_label,omitempty"`
|
MaxPopHourLabel string `json:"max_pop_hour_label,omitempty"`
|
||||||
|
PrecipitationType string `json:"precipitation_type,omitempty"`
|
||||||
|
ExpectationPhrase string `json:"expectation_phrase,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildPrecipTimingModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
func buildPrecipTimingModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
@@ -49,7 +60,50 @@ func precipTimingValue(timing forecast.PrecipTiming, timezone string) PrecipTimi
|
|||||||
item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value)
|
item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value)
|
||||||
item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone)
|
item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone)
|
||||||
item.MaxPopHourLabel = hourMinuteLabel(window.MaxPrecipitationProbability.Time, timezone)
|
item.MaxPopHourLabel = hourMinuteLabel(window.MaxPrecipitationProbability.Time, timezone)
|
||||||
|
item.PrecipitationType = precipitationWindowType(window.TextDescriptions)
|
||||||
|
if item.MaxPopPercent != nil {
|
||||||
|
item.ExpectationPhrase = precipitationWindowExpectationPhrase(*item.MaxPopPercent, item.PrecipitationType)
|
||||||
|
}
|
||||||
value.PrecipitationWindows = append(value.PrecipitationWindows, item)
|
value.PrecipitationWindows = append(value.PrecipitationWindows, item)
|
||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func precipitationWindowType(descriptions []string) string {
|
||||||
|
combined := strings.ToLower(strings.Join(descriptions, " "))
|
||||||
|
switch {
|
||||||
|
case (strings.Contains(combined, "thunderstorm") || strings.Contains(combined, "t-storm")) &&
|
||||||
|
(strings.Contains(combined, "shower") || strings.Contains(combined, "rain")):
|
||||||
|
return "showers and thunderstorms"
|
||||||
|
case strings.Contains(combined, "freezing rain"):
|
||||||
|
return "freezing rain"
|
||||||
|
case strings.Contains(combined, "thunderstorm") || strings.Contains(combined, "t-storm"):
|
||||||
|
return "thunderstorms"
|
||||||
|
case strings.Contains(combined, "shower"):
|
||||||
|
return "showers"
|
||||||
|
case strings.Contains(combined, "snow"):
|
||||||
|
return "snow"
|
||||||
|
case strings.Contains(combined, "drizzle"):
|
||||||
|
return "drizzle"
|
||||||
|
case strings.Contains(combined, "rain"):
|
||||||
|
return "rain"
|
||||||
|
default:
|
||||||
|
return "precipitation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func precipitationWindowExpectationPhrase(maxPopPercent int, precipitationType string) string {
|
||||||
|
if precipitationType == "" {
|
||||||
|
precipitationType = "precipitation"
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case maxPopPercent >= precipTimingExpectLowerBound:
|
||||||
|
return fmt.Sprintf("Expect %s.", precipitationType)
|
||||||
|
case maxPopPercent >= precipTimingLikelyLowerBound:
|
||||||
|
return fmt.Sprintf("%s likely.", sentenceCase(precipitationType))
|
||||||
|
case maxPopPercent >= precipTimingChanceLowerBound:
|
||||||
|
return fmt.Sprintf("Chance of %s.", precipitationType)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultSPCConvectiveDiscussionMinimumSeverityRank = 3
|
const defaultSPCConvectiveDiscussionMinimumSeverityRank = defaultSPCRiskDigestMinimumSeverityRank
|
||||||
const spcCategoricalOutlookType = "categorical"
|
const spcCategoricalOutlookType = defaultSPCRiskDigestOutlookType
|
||||||
|
|
||||||
type SPCConvectiveDiscussionModule struct {
|
type SPCConvectiveDiscussionModule struct {
|
||||||
IncludedBecause string `json:"included_because"`
|
IncludedBecause string `json:"included_because"`
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -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
75
internal/cli/output.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
type outputOptions struct {
|
||||||
|
Quiet bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeActionResult(stdout, stderr io.Writer, value any, opts outputOptions, writeStatus func(io.Writer)) error {
|
||||||
|
if opts.Quiet {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if writeStatus != nil && stderr != nil {
|
||||||
|
writeStatus(stderr)
|
||||||
|
}
|
||||||
|
return writeJSON(stdout, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(stdout io.Writer, value any) error {
|
||||||
|
encoder := json.NewEncoder(stdout)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
return encoder.Encode(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeBatchStatus(stderr io.Writer, result *app.BatchResult) {
|
||||||
|
if stderr == nil || result == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, item := range result.Reports {
|
||||||
|
notificationFields := ""
|
||||||
|
if item.NotificationStatus != "" {
|
||||||
|
notificationFields += fmt.Sprintf(" notificationStatus=%q", item.NotificationStatus)
|
||||||
|
}
|
||||||
|
if item.NotificationRunID != "" {
|
||||||
|
notificationFields += fmt.Sprintf(" notificationRunId=%q", item.NotificationRunID)
|
||||||
|
}
|
||||||
|
if item.NotificationError != "" {
|
||||||
|
notificationFields += fmt.Sprintf(" notificationError=%q", item.NotificationError)
|
||||||
|
}
|
||||||
|
if item.Status == "failed" {
|
||||||
|
_, _ = fmt.Fprintf(stderr, "report=%s status=failed error=%q%s\n", item.ReportID, item.Error, notificationFields)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(stderr, "report=%s status=succeeded output=%q%s\n", item.ReportID, item.OutputPath, notificationFields)
|
||||||
|
}
|
||||||
|
if result.Notification != nil {
|
||||||
|
_, _ = fmt.Fprintf(stderr, "batchNotification status=%q", result.Notification.Status)
|
||||||
|
if result.Notification.Reason != "" {
|
||||||
|
_, _ = fmt.Fprintf(stderr, " reason=%q", result.Notification.Reason)
|
||||||
|
}
|
||||||
|
if result.Notification.RunID != "" {
|
||||||
|
_, _ = fmt.Fprintf(stderr, " runId=%q", result.Notification.RunID)
|
||||||
|
}
|
||||||
|
if result.Notification.PipelineID != "" {
|
||||||
|
_, _ = fmt.Fprintf(stderr, " pipelineId=%q", result.Notification.PipelineID)
|
||||||
|
}
|
||||||
|
if result.Notification.BundleID != "" {
|
||||||
|
_, _ = fmt.Fprintf(stderr, " bundleId=%q", result.Notification.BundleID)
|
||||||
|
}
|
||||||
|
if result.Notification.Path != "" {
|
||||||
|
_, _ = fmt.Fprintf(stderr, " path=%q", result.Notification.Path)
|
||||||
|
}
|
||||||
|
if result.Notification.Error != "" {
|
||||||
|
_, _ = fmt.Fprintf(stderr, " error=%q", result.Notification.Error)
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintln(stderr)
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(stderr, "batch=%s total=%d succeeded=%d failed=%d\n", result.Batch, result.Total, result.Succeeded, result.Failed)
|
||||||
|
}
|
||||||
59
internal/cli/output_test.go
Normal file
59
internal/cli/output_test.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWriteActionResultWritesStatusBeforeJSON(t *testing.T) {
|
||||||
|
var output bytes.Buffer
|
||||||
|
|
||||||
|
err := writeActionResult(&output, &output, map[string]string{"status": "succeeded"}, outputOptions{}, func(w io.Writer) {
|
||||||
|
_, _ = w.Write([]byte("status line\n"))
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writeActionResult() error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(output.String(), "status line\n") {
|
||||||
|
t.Fatalf("output = %q, want status before JSON", output.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(output.String(), `"status": "succeeded"`) {
|
||||||
|
t.Fatalf("output missing JSON result:\n%s", output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteActionResultQuietSuppressesOutput(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
|
err := writeActionResult(&stdout, &stderr, map[string]string{"status": "succeeded"}, outputOptions{Quiet: true}, func(w io.Writer) {
|
||||||
|
_, _ = w.Write([]byte("status line\n"))
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writeActionResult() error = %v", err)
|
||||||
|
}
|
||||||
|
if stdout.Len() != 0 || stderr.Len() != 0 {
|
||||||
|
t.Fatalf("stdout/stderr = %q/%q, want no output", stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteActionResultToleratesNilStderr(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
statusCalled := false
|
||||||
|
|
||||||
|
err := writeActionResult(&stdout, nil, map[string]string{"status": "succeeded"}, outputOptions{}, func(w io.Writer) {
|
||||||
|
statusCalled = true
|
||||||
|
_, _ = w.Write([]byte("status line\n"))
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writeActionResult() error = %v", err)
|
||||||
|
}
|
||||||
|
if statusCalled {
|
||||||
|
t.Fatal("status writer was called with nil stderr")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), `"status": "succeeded"`) {
|
||||||
|
t.Fatalf("stdout missing JSON result:\n%s", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
162
internal/cli/result.go
Normal file
162
internal/cli/result.go
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
commandGenerate = "generate"
|
||||||
|
commandRun = "run"
|
||||||
|
|
||||||
|
summaryStatusSucceeded = "succeeded"
|
||||||
|
summaryStatusFailed = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type generateSummary struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
ReportID report.ID `json:"reportId"`
|
||||||
|
ReportName string `json:"reportName"`
|
||||||
|
PromptID string `json:"promptId"`
|
||||||
|
RunID string `json:"runId"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
GeneratedAt time.Time `json:"generatedAt"`
|
||||||
|
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||||
|
ReportPath string `json:"reportPath,omitempty"`
|
||||||
|
OutputPath string `json:"outputPath,omitempty"`
|
||||||
|
MetadataPath string `json:"metadataPath,omitempty"`
|
||||||
|
DataPackagePath string `json:"dataPackagePath,omitempty"`
|
||||||
|
PreflightPath string `json:"preflightPath,omitempty"`
|
||||||
|
GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"`
|
||||||
|
GeneratedTextResultPath string `json:"generatedTextResultPath,omitempty"`
|
||||||
|
GeneratedTextPath string `json:"generatedTextPath,omitempty"`
|
||||||
|
RenderContextPath string `json:"renderContextPath,omitempty"`
|
||||||
|
NotificationPath string `json:"notificationPath,omitempty"`
|
||||||
|
Notification *generateNotificationSummary `json:"notification,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type generateNotificationSummary struct {
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
UploadStatus string `json:"uploadStatus,omitempty"`
|
||||||
|
StatusError string `json:"statusError,omitempty"`
|
||||||
|
RunID string `json:"runId,omitempty"`
|
||||||
|
PipelineID string `json:"pipelineId,omitempty"`
|
||||||
|
BundleID string `json:"bundleId,omitempty"`
|
||||||
|
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
||||||
|
AcceptedAt *time.Time `json:"acceptedAt,omitempty"`
|
||||||
|
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||||
|
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type batchSummary struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
Batch app.BatchKind `json:"batch"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StartedAt time.Time `json:"startedAt"`
|
||||||
|
FinishedAt time.Time `json:"finishedAt"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Succeeded int `json:"succeeded"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
Notification *app.BatchNotificationResult `json:"notification,omitempty"`
|
||||||
|
Reports []app.BatchReportResult `json:"reports"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGenerateSummary(result *app.ReportResult, err error) generateSummary {
|
||||||
|
summary := generateSummary{Command: commandGenerate}
|
||||||
|
if result == nil {
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := result.Metadata
|
||||||
|
summary.ReportID = metadata.ReportID
|
||||||
|
summary.ReportName = reportName(metadata.ReportID)
|
||||||
|
summary.PromptID = metadata.PromptID
|
||||||
|
summary.RunID = metadata.RunID
|
||||||
|
summary.Status = summaryStatusSucceeded
|
||||||
|
summary.GeneratedAt = metadata.GeneratedAt
|
||||||
|
summary.ValidPeriod = metadata.ValidPeriod
|
||||||
|
summary.ReportPath = result.ReportPath
|
||||||
|
summary.OutputPath = result.OutputPath
|
||||||
|
summary.MetadataPath = result.MetadataPath
|
||||||
|
summary.DataPackagePath = result.DataPackagePath
|
||||||
|
summary.PreflightPath = result.PreflightPath
|
||||||
|
summary.GeneratedTextRawPath = result.GeneratedTextRawPath
|
||||||
|
summary.GeneratedTextResultPath = result.GeneratedTextResultPath
|
||||||
|
summary.GeneratedTextPath = result.GeneratedTextPath
|
||||||
|
summary.RenderContextPath = result.RenderContextPath
|
||||||
|
summary.NotificationPath = result.NotificationPath
|
||||||
|
summary.Notification = newGenerateNotificationSummary(result.Notification)
|
||||||
|
if err != nil {
|
||||||
|
summary.Status = summaryStatusFailed
|
||||||
|
summary.Error = err.Error()
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGenerateNotificationSummary(result *app.NotificationResult) *generateNotificationSummary {
|
||||||
|
if result == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
summary := &generateNotificationSummary{
|
||||||
|
Status: result.Status,
|
||||||
|
UploadStatus: result.UploadStatus,
|
||||||
|
StatusError: result.StatusError,
|
||||||
|
RunID: result.RunID,
|
||||||
|
PipelineID: result.PipelineID,
|
||||||
|
BundleID: result.BundleID,
|
||||||
|
IdempotencyKey: result.IdempotencyKey,
|
||||||
|
StartedAt: result.StartedAt,
|
||||||
|
FinishedAt: result.FinishedAt,
|
||||||
|
Error: result.Error,
|
||||||
|
}
|
||||||
|
if !result.AcceptedAt.IsZero() {
|
||||||
|
acceptedAt := result.AcceptedAt
|
||||||
|
summary.AcceptedAt = &acceptedAt
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBatchSummary(result *app.BatchResult) batchSummary {
|
||||||
|
summary := batchSummary{Command: commandRun}
|
||||||
|
if result == nil {
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.Batch = result.Batch
|
||||||
|
summary.Status = batchSummaryStatus(result)
|
||||||
|
summary.StartedAt = result.StartedAt
|
||||||
|
summary.FinishedAt = result.FinishedAt
|
||||||
|
summary.Total = result.Total
|
||||||
|
summary.Succeeded = result.Succeeded
|
||||||
|
summary.Failed = result.Failed
|
||||||
|
summary.Notification = result.Notification
|
||||||
|
summary.Reports = append([]app.BatchReportResult(nil), result.Reports...)
|
||||||
|
if summary.Status == summaryStatusFailed {
|
||||||
|
summary.Error = app.BatchError{Result: result}.Error()
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchSummaryStatus(result *app.BatchResult) string {
|
||||||
|
if result == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if result.Failed > 0 || (result.Notification != nil && result.Notification.Status == summaryStatusFailed) {
|
||||||
|
return summaryStatusFailed
|
||||||
|
}
|
||||||
|
return summaryStatusSucceeded
|
||||||
|
}
|
||||||
|
|
||||||
|
func reportName(id report.ID) string {
|
||||||
|
definition, err := report.DefaultRegistry().Lookup(id)
|
||||||
|
if err != nil {
|
||||||
|
return string(id)
|
||||||
|
}
|
||||||
|
return definition.Name
|
||||||
|
}
|
||||||
231
internal/cli/result_test.go
Normal file
231
internal/cli/result_test.go
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewGenerateSummaryForGeneratedTextReport(t *testing.T) {
|
||||||
|
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
|
||||||
|
acceptedAt := generatedAt.Add(time.Minute)
|
||||||
|
startedAt := acceptedAt.Add(time.Minute)
|
||||||
|
finishedAt := startedAt.Add(time.Minute)
|
||||||
|
result := &app.ReportResult{
|
||||||
|
DataPackagePath: "/runs/hourly/data_package.yaml",
|
||||||
|
PreflightPath: "/runs/hourly/preflight.json",
|
||||||
|
ReportPath: "/runs/hourly/report.md",
|
||||||
|
OutputPath: "/copies/hourly.md",
|
||||||
|
MetadataPath: "/runs/hourly/metadata.json",
|
||||||
|
GeneratedTextRawPath: "/runs/hourly/generated_text_raw.json",
|
||||||
|
GeneratedTextResultPath: "/runs/hourly/generated_text_result.json",
|
||||||
|
GeneratedTextPath: "/runs/hourly/generated_text.json",
|
||||||
|
RenderContextPath: "/runs/hourly/render_context.json",
|
||||||
|
NotificationPath: "/runs/hourly/notification.json",
|
||||||
|
Metadata: state.Metadata{
|
||||||
|
ReportID: report.Hourly,
|
||||||
|
PromptID: "weather.hourly_generated_text",
|
||||||
|
RunID: "20260529T133000Z_hourly",
|
||||||
|
GeneratedAt: generatedAt,
|
||||||
|
ValidPeriod: testSummaryPeriod(generatedAt),
|
||||||
|
},
|
||||||
|
Notification: &app.NotificationResult{
|
||||||
|
Status: "succeeded",
|
||||||
|
UploadStatus: "accepted",
|
||||||
|
RunID: "distributor-run",
|
||||||
|
PipelineID: "weatherreporter.hourly",
|
||||||
|
BundleID: "weatherreporter.home.hourly",
|
||||||
|
IdempotencyKey: "weatherreporter.home.hourly.20260529T133000Z_hourly",
|
||||||
|
AcceptedAt: acceptedAt,
|
||||||
|
StartedAt: &startedAt,
|
||||||
|
FinishedAt: &finishedAt,
|
||||||
|
Report: []byte(`{"actions":[{"action":"replace_older"}]}`),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := newGenerateSummary(result, nil)
|
||||||
|
|
||||||
|
if summary.Command != "generate" || summary.Status != "succeeded" {
|
||||||
|
t.Fatalf("summary command/status = %q/%q, want generate/succeeded", summary.Command, summary.Status)
|
||||||
|
}
|
||||||
|
if summary.ReportID != report.Hourly || summary.ReportName != "Hourly Report" || summary.PromptID != "weather.hourly_generated_text" || summary.RunID != "20260529T133000Z_hourly" {
|
||||||
|
t.Fatalf("summary identity = %#v, want hourly report identity", summary)
|
||||||
|
}
|
||||||
|
if summary.GeneratedTextRawPath == "" || summary.GeneratedTextResultPath == "" || summary.GeneratedTextPath == "" || summary.RenderContextPath == "" {
|
||||||
|
t.Fatalf("generated-text paths = %#v, want generated-text artifact paths", summary)
|
||||||
|
}
|
||||||
|
if summary.Notification == nil || summary.Notification.RunID != "distributor-run" || summary.Notification.AcceptedAt == nil || !summary.Notification.AcceptedAt.Equal(acceptedAt) {
|
||||||
|
t.Fatalf("notification = %#v, want summarized distributor result", summary.Notification)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(summary)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), "replace_older") || strings.Contains(string(data), "actions") {
|
||||||
|
t.Fatalf("summary JSON includes raw distributor report payload:\n%s", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewGenerateSummaryForMarkdownReportOmitsGeneratedTextAndNotification(t *testing.T) {
|
||||||
|
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
|
||||||
|
result := &app.ReportResult{
|
||||||
|
DataPackagePath: "/runs/three-day/data_package.yaml",
|
||||||
|
PreflightPath: "/runs/three-day/preflight.json",
|
||||||
|
ReportPath: "/runs/three-day/report.md",
|
||||||
|
OutputPath: "/copies/three-day.md",
|
||||||
|
MetadataPath: "/runs/three-day/metadata.json",
|
||||||
|
Metadata: state.Metadata{
|
||||||
|
ReportID: report.ThreeDay,
|
||||||
|
PromptID: "weather.three_day_outlook",
|
||||||
|
RunID: "20260529T133000Z_three_day",
|
||||||
|
GeneratedAt: generatedAt,
|
||||||
|
ValidPeriod: testSummaryPeriod(generatedAt),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := newGenerateSummary(result, nil)
|
||||||
|
|
||||||
|
if summary.ReportID != report.ThreeDay || summary.ReportName != "3-Day Outlook" || summary.Status != "succeeded" {
|
||||||
|
t.Fatalf("summary = %#v, want successful 3-day summary", summary)
|
||||||
|
}
|
||||||
|
if summary.Notification != nil || summary.NotificationPath != "" {
|
||||||
|
t.Fatalf("notification summary/path = %#v/%q, want omitted", summary.Notification, summary.NotificationPath)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(summary)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
for _, omitted := range []string{"generatedTextRawPath", "generatedTextResultPath", "generatedTextPath", "renderContextPath", "notification"} {
|
||||||
|
if strings.Contains(string(data), omitted) {
|
||||||
|
t.Fatalf("summary JSON contains %q, want omitted:\n%s", omitted, string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewGenerateSummaryForNotificationFailure(t *testing.T) {
|
||||||
|
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
|
||||||
|
result := &app.ReportResult{
|
||||||
|
DataPackagePath: "/runs/hourly/data_package.yaml",
|
||||||
|
PreflightPath: "/runs/hourly/preflight.json",
|
||||||
|
ReportPath: "/runs/hourly/report.md",
|
||||||
|
OutputPath: "/copies/hourly.md",
|
||||||
|
MetadataPath: "/runs/hourly/metadata.json",
|
||||||
|
NotificationPath: "/runs/hourly/notification.json",
|
||||||
|
Metadata: state.Metadata{
|
||||||
|
ReportID: report.Hourly,
|
||||||
|
PromptID: "weather.hourly_generated_text",
|
||||||
|
RunID: "20260529T133000Z_hourly",
|
||||||
|
GeneratedAt: generatedAt,
|
||||||
|
ValidPeriod: testSummaryPeriod(generatedAt),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := errors.New(`notify report "hourly" run "20260529T133000Z_hourly": upload rejected`)
|
||||||
|
|
||||||
|
summary := newGenerateSummary(result, err)
|
||||||
|
|
||||||
|
if summary.Status != "failed" || summary.Error != err.Error() {
|
||||||
|
t.Fatalf("status/error = %q/%q, want failed notification error", summary.Status, summary.Error)
|
||||||
|
}
|
||||||
|
if summary.NotificationPath != "/runs/hourly/notification.json" || summary.ReportPath == "" || summary.MetadataPath == "" {
|
||||||
|
t.Fatalf("artifact paths = report %q metadata %q notification %q, want inspectable paths", summary.ReportPath, summary.MetadataPath, summary.NotificationPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewBatchSummaryStatusDerivation(t *testing.T) {
|
||||||
|
startedAt := time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)
|
||||||
|
finishedAt := startedAt.Add(2 * time.Minute)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
result *app.BatchResult
|
||||||
|
wantStatus string
|
||||||
|
wantError string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "success",
|
||||||
|
result: &app.BatchResult{
|
||||||
|
Batch: app.BatchMorning,
|
||||||
|
StartedAt: startedAt,
|
||||||
|
FinishedAt: finishedAt,
|
||||||
|
Total: 1,
|
||||||
|
Succeeded: 1,
|
||||||
|
Reports: []app.BatchReportResult{{ReportID: report.Today, Status: "succeeded"}},
|
||||||
|
},
|
||||||
|
wantStatus: "succeeded",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "report failure",
|
||||||
|
result: &app.BatchResult{
|
||||||
|
Batch: app.BatchMorning,
|
||||||
|
Total: 2,
|
||||||
|
Succeeded: 1,
|
||||||
|
Failed: 1,
|
||||||
|
Reports: []app.BatchReportResult{
|
||||||
|
{ReportID: report.Today, Status: "succeeded"},
|
||||||
|
{ReportID: report.Tomorrow, Status: "failed", Error: "render failed"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStatus: "failed",
|
||||||
|
wantError: "batch morning failed: 1 of 2 reports failed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "skipped notification",
|
||||||
|
result: &app.BatchResult{
|
||||||
|
Batch: app.BatchEvening,
|
||||||
|
Total: 2,
|
||||||
|
Succeeded: 1,
|
||||||
|
Failed: 1,
|
||||||
|
Reports: []app.BatchReportResult{{ReportID: report.Tomorrow, Status: "failed"}},
|
||||||
|
Notification: &app.BatchNotificationResult{
|
||||||
|
Status: "skipped",
|
||||||
|
Reason: "one or more reports failed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStatus: "failed",
|
||||||
|
wantError: "batch evening failed: 1 of 2 reports failed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "failed notification",
|
||||||
|
result: &app.BatchResult{
|
||||||
|
Batch: app.BatchEvening,
|
||||||
|
Total: 1,
|
||||||
|
Succeeded: 1,
|
||||||
|
Reports: []app.BatchReportResult{{ReportID: report.Tomorrow, Status: "succeeded"}},
|
||||||
|
Notification: &app.BatchNotificationResult{
|
||||||
|
Status: "failed",
|
||||||
|
Error: "notify batch evening: upload rejected",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStatus: "failed",
|
||||||
|
wantError: "batch evening notification failed: notify batch evening: upload rejected",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
summary := newBatchSummary(tt.result)
|
||||||
|
if summary.Command != "run" || summary.Status != tt.wantStatus {
|
||||||
|
t.Fatalf("command/status = %q/%q, want run/%s", summary.Command, summary.Status, tt.wantStatus)
|
||||||
|
}
|
||||||
|
if summary.Error != tt.wantError {
|
||||||
|
t.Fatalf("error = %q, want %q", summary.Error, tt.wantError)
|
||||||
|
}
|
||||||
|
if len(summary.Reports) != len(tt.result.Reports) {
|
||||||
|
t.Fatalf("reports = %#v, want copied report list", summary.Reports)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSummaryPeriod(start time.Time) timeutil.Period {
|
||||||
|
return timeutil.Period{
|
||||||
|
Start: start,
|
||||||
|
End: start.Add(6 * time.Hour),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ package cli
|
|||||||
|
|
||||||
import (
|
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
31
internal/collect/collect.go
Normal file
31
internal/collect/collect.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Package collect owns upstream weather source collection for application use.
|
||||||
|
package collect
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/weatherapi"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Request struct {
|
||||||
|
Config config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
Bundle *weatherdata.Bundle
|
||||||
|
}
|
||||||
|
|
||||||
|
func Run(ctx context.Context, req Request) (*Result, error) {
|
||||||
|
client, err := weatherapi.New(req.Config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("prepare weather collection: %w", err)
|
||||||
|
}
|
||||||
|
bundle, err := client.FetchBundle(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("collect weather bundle: %w", err)
|
||||||
|
}
|
||||||
|
return &Result{Bundle: bundle}, nil
|
||||||
|
}
|
||||||
99
internal/collect/collect_test.go
Normal file
99
internal/collect/collect_test.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package collect
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunFetchesBundle(t *testing.T) {
|
||||||
|
server := collectionTestServer(t, nil)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||||
|
|
||||||
|
result, err := Run(context.Background(), Request{Config: cfg})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if result == nil || result.Bundle == nil {
|
||||||
|
t.Fatal("Run() result bundle = nil, want fetched bundle")
|
||||||
|
}
|
||||||
|
if result.Bundle.Hourly == nil {
|
||||||
|
t.Fatal("Hourly = nil, want fetched hourly forecast")
|
||||||
|
}
|
||||||
|
if result.Bundle.WeatherStory == nil || result.Bundle.WeatherStory.Title != "Several Chances for Rain Through Monday" {
|
||||||
|
t.Fatalf("WeatherStory = %#v, want fetched weather story", result.Bundle.WeatherStory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunWrapsAdapterConstructionError(t *testing.T) {
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.WeatherAPI.BaseURL = ""
|
||||||
|
cfg.Secrets.Directory = "super-secret-directory"
|
||||||
|
|
||||||
|
_, err := Run(context.Background(), Request{Config: cfg})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Run() error = nil, want adapter construction error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "prepare weather collection") {
|
||||||
|
t.Fatalf("error = %q, want collection setup context", err.Error())
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), cfg.Secrets.Directory) {
|
||||||
|
t.Fatalf("error = %q, want no secret path leakage", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunWrapsFetchError(t *testing.T) {
|
||||||
|
server := collectionTestServer(t, map[string]int{"/observations": http.StatusBadGateway})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||||
|
|
||||||
|
_, err := Run(context.Background(), Request{Config: cfg})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Run() error = nil, want fetch error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "collect weather bundle") {
|
||||||
|
t.Fatalf("error = %q, want collection fetch context", err.Error())
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "/observations") {
|
||||||
|
t.Fatalf("error = %q, want source endpoint context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionTestServer(t *testing.T, statusByPath map[string]int) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if status := statusByPath[r.URL.Path]; status != 0 {
|
||||||
|
http.Error(w, "upstream failure", status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/observations":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`))
|
||||||
|
case "/conditions/current":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`))
|
||||||
|
case "/forecast/hourly":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T13:00:00-05:00","endTime":"2026-05-29T14:00:00-05:00"}]}}`))
|
||||||
|
case "/forecast/narrative":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[]}}`))
|
||||||
|
case "/alerts/active":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"alerts":[]}}`))
|
||||||
|
case "/discussion":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":[],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for saved bundle."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for saved bundle."}}}`))
|
||||||
|
case "/weatherstories/latest":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
|
||||||
|
case "/outlooks/convective":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
63
internal/generatedtext/day_style.go
Normal file
63
internal/generatedtext/day_style.go
Normal 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
|
||||||
|
}
|
||||||
131
internal/generatedtext/day_style_test.go
Normal file
131
internal/generatedtext/day_style_test.go
Normal 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{}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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."},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }}
|
||||||
{{ . }}
|
{{ . }}
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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 }}
|
||||||
@@ -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 }}
|
||||||
@@ -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 }}
|
||||||
@@ -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 }}
|
||||||
@@ -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 }}
|
||||||
{{ . }}
|
{{ . }}
|
||||||
|
|||||||
@@ -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 }}
|
||||||
{{ . }}
|
{{ . }}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user