Compare commits
98 Commits
v0.7.0
...
d90801cff5
| Author | SHA1 | Date | |
|---|---|---|---|
| d90801cff5 | |||
| 0f63159482 | |||
| 2792933833 | |||
| 9261431329 | |||
| 1bfd865333 | |||
| e5af7477af | |||
| 92fcbfcc05 | |||
| ff6aade42c | |||
| 4fac69c9f0 | |||
| 90ab6973e6 | |||
| 90a502f50e | |||
| 273f462e09 | |||
| 5361d5647b | |||
| d2e90da148 | |||
| 047ce32ac6 | |||
| 5896168a93 | |||
| fe9c40741a | |||
| 88004a1827 | |||
| a515b7e7d9 | |||
| 696454cf34 | |||
| 8c97788682 | |||
| 5203440ba0 | |||
| 3d452a120a | |||
| 4eece7cc8a | |||
| d0d0b698f9 | |||
| 4c4b01f265 | |||
| 58fe794227 | |||
| 63dfc0b55a | |||
| 1ddc33eb17 | |||
| 4a0238909b | |||
| 3d5f71e72d | |||
| 8ff5c44324 | |||
| 4e704e4f51 | |||
| 7b4c73d1e6 | |||
| 7efd8b5855 | |||
| 7cbc59d8a7 | |||
| 67b30dbad6 | |||
| cd8d77b37c | |||
| bb79232e3e | |||
| b4e0aadbef | |||
| 4fe0f40cef | |||
| 40b42f4bf3 | |||
| e8f1aa5caf | |||
| a02af0bce0 | |||
| ff2f8c16a3 | |||
| ace5577402 | |||
| 473f252aee | |||
| 74bd69834c | |||
| 98cab70b53 | |||
| dc0172ff82 | |||
| 0b41773017 | |||
| ddda42453f | |||
| 295f06915f | |||
| 120bce3391 | |||
| 386263784c | |||
| afe6803a63 | |||
| 5344c7880a | |||
| 74e32eb18e | |||
| ceb00ad45e | |||
| 7cd07c429b | |||
| 28b8391d53 | |||
| bb8de054dc | |||
| 9d6502460e | |||
| fcf1108641 | |||
| 185605fbf0 | |||
| 662d906997 | |||
| f6e20d1412 | |||
| 9e14a9b7c7 | |||
| 422430613c | |||
| 2a4ce64d6f | |||
| 316ab8f3fc | |||
| f6a68426e1 | |||
| bed2b84100 | |||
| b39ec1e4d3 | |||
| dd92e9b061 | |||
| 8d737395dc | |||
| b3f7c9c1f2 | |||
| d425132ae7 | |||
| 925d351341 | |||
| c4107490df | |||
| b38230bc35 | |||
| 1f5f9964e0 | |||
| b0d8c6983d | |||
| 55f0180599 | |||
| 5284033fb8 | |||
| 0b1423c90d | |||
| e6a4bb2d16 | |||
| c3a051d372 | |||
| 3482551360 | |||
| d7a72f8581 | |||
| c09b7410ce | |||
| 96ce0edd46 | |||
| 7cd68ff222 | |||
| 16680e3f61 | |||
| 3389d4fa93 | |||
| 0041845935 | |||
| 3bcccb4a7b | |||
| 2aba52f552 |
@@ -9,7 +9,7 @@ Markdown reports to a configured `distributor` HTTP upload endpoint.
|
|||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
weatherreporter generate today --out ./today.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|||||||
57
docs/cli.md
57
docs/cli.md
@@ -6,21 +6,24 @@ batches, and inspects stored artifacts.
|
|||||||
## Shortest Useful Command
|
## Shortest Useful Command
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
weatherreporter generate today --out ./today.md
|
||||||
```
|
```
|
||||||
|
|
||||||
This loads configuration, fetches weather data, writes managed workspace
|
This loads configuration, fetches weather data, writes managed workspace
|
||||||
artifacts, runs `scriptorium render` as a preflight check, runs
|
artifacts, runs `scriptorium render` as a preflight check, runs structured
|
||||||
`scriptorium run`, and writes an extra Markdown copy to `./daily.md`. If
|
`scriptorium run`, validates generated text, renders the embedded Today
|
||||||
distributor notification is enabled in configuration, the command also uploads
|
template, and writes an extra Markdown copy to `./today.md`. If distributor
|
||||||
the managed Markdown report after final metadata is saved.
|
notification is enabled in configuration, the command also uploads the managed
|
||||||
|
Markdown report after final metadata is saved.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```text
|
```text
|
||||||
weatherreporter --help
|
weatherreporter --help
|
||||||
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
|
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
||||||
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
|
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
|
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
|
||||||
@@ -34,14 +37,21 @@ weatherreporter inspect prior [--config PATH] RUN_ID
|
|||||||
weatherreporter inspect sources [--config PATH] RUN_ID
|
weatherreporter inspect sources [--config PATH] RUN_ID
|
||||||
```
|
```
|
||||||
|
|
||||||
`generate` commands write a JSON module snapshot, YAML data package, preflight
|
Implemented `generate` commands write a JSON module snapshot, YAML data package,
|
||||||
artifact, managed Markdown report, and metadata under the configured workspace.
|
preflight artifact, managed Markdown report, and metadata under the configured
|
||||||
`--out` writes an extra Markdown copy for the operator; distributor
|
workspace. `--out` writes an extra Markdown copy for the operator; distributor
|
||||||
notification uses the managed report path, not the extra copy. `generate storm`
|
notification uses the managed report path, not the extra copy. `generate daily`,
|
||||||
requires explicit event-window bounds with `--start` and `--end`.
|
`generate today`, `generate tomorrow`, and `generate hourly` write managed
|
||||||
|
generated-text artifacts, validate structured text from Scriptorium, and render
|
||||||
|
the managed Markdown report from embedded templates. `generate daily` requires
|
||||||
|
`--date YYYY-MM-DD` for the selected local civil day; omitting `--date` is a
|
||||||
|
command error and stops before weather data is fetched. `generate hourly`
|
||||||
|
covers the next six hours in the effective report timezone and does not accept
|
||||||
|
date or event window flags. `generate storm` requires explicit event-window
|
||||||
|
bounds with `--start` and `--end`.
|
||||||
|
|
||||||
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
`run morning` generates Today Report and the 3-Day Outlook, plus Weekend Outlook
|
||||||
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch
|
except on Sunday. `run evening` generates the Tomorrow Report. Batch
|
||||||
runs continue independent reports after a failure, print a JSON summary to
|
runs continue independent reports after a failure, print a JSON summary to
|
||||||
stdout, write compact status lines to stderr, and return nonzero when any report
|
stdout, write compact status lines to stderr, and return nonzero when any report
|
||||||
failed. `--out-dir` writes extra Markdown copies for the operator; distributor
|
failed. `--out-dir` writes extra Markdown copies for the operator; distributor
|
||||||
@@ -50,6 +60,9 @@ notification is enabled, batch summaries and status lines include notification
|
|||||||
status, accepted distributor run ID, or notification error fields for each
|
status, accepted distributor run ID, or notification error fields for each
|
||||||
attempted report.
|
attempted report.
|
||||||
|
|
||||||
|
Hourly Report is explicit only; it is 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 JSON to stdout.
|
||||||
They do not fetch weather data or invoke `scriptorium`.
|
They do not fetch weather data or invoke `scriptorium`.
|
||||||
|
|
||||||
@@ -59,9 +72,9 @@ They do not fetch weather data or invoke `scriptorium`.
|
|||||||
- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`.
|
- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`.
|
||||||
- `--units VALUE`: override configured Weather API units for `generate` and `run`.
|
- `--units VALUE`: override configured Weather API units for `generate` and `run`.
|
||||||
- `--tz NAME`: override configured Weather API timezone for `generate` and `run`.
|
- `--tz NAME`: override configured Weather API timezone for `generate` and `run`.
|
||||||
- `--out PATH`: write an extra Markdown report copy for `generate` commands.
|
- `--out PATH`: write an extra Markdown report copy where supported by the `generate` command.
|
||||||
- `--out-dir PATH`: write extra Markdown report copies for `run morning` and `run evening`.
|
- `--out-dir PATH`: write extra Markdown report copies for `run morning` and `run evening`.
|
||||||
- `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone.
|
- `--date YYYY-MM-DD`: required date for `generate daily`; optional date for `generate today`, defaulting to the current local date in the configured timezone.
|
||||||
- `--start TIME`: required start time for `generate storm`.
|
- `--start TIME`: required start time for `generate storm`.
|
||||||
- `--end TIME`: required end time for `generate storm`.
|
- `--end TIME`: required end time for `generate storm`.
|
||||||
- `--limit N`: maximum records for `inspect reports`; defaults to `20`, and `0` means no limit.
|
- `--limit N`: maximum records for `inspect reports`; defaults to `20`, and `0` means no limit.
|
||||||
@@ -75,7 +88,10 @@ are no distributor-specific CLI flags.
|
|||||||
## Common Workflows
|
## Common Workflows
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
weatherreporter generate today --out ./today.md
|
||||||
|
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
||||||
weatherreporter generate tomorrow --out ./tomorrow.md
|
weatherreporter generate tomorrow --out ./tomorrow.md
|
||||||
|
weatherreporter generate hourly
|
||||||
weatherreporter generate three-day --out ./three-day.md
|
weatherreporter generate three-day --out ./three-day.md
|
||||||
weatherreporter generate weekend --out ./weekend.md
|
weatherreporter generate weekend --out ./weekend.md
|
||||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
|
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
|
||||||
@@ -87,11 +103,12 @@ weatherreporter run evening --out-dir ./reports
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
weatherreporter inspect reports --limit 10
|
weatherreporter inspect reports --limit 10
|
||||||
weatherreporter inspect metadata 20260529T100000.000000000Z_daily_today
|
weatherreporter inspect metadata 20260529T100000.000000000Z_today
|
||||||
weatherreporter inspect modules 20260529T100000.000000000Z_daily_today
|
weatherreporter inspect modules 20260529T100000.000000000Z_today
|
||||||
weatherreporter inspect data-package 20260529T100000.000000000Z_daily_today
|
weatherreporter inspect data-package 20260529T100000.000000000Z_today
|
||||||
weatherreporter inspect prior 20260529T100000.000000000Z_daily_today
|
weatherreporter inspect prior 20260529T100000.000000000Z_today
|
||||||
weatherreporter inspect sources 20260529T100000.000000000Z_daily_today
|
weatherreporter inspect sources 20260529T100000.000000000Z_today
|
||||||
|
weatherreporter inspect metadata 20260529T100000.000000000Z_daily
|
||||||
```
|
```
|
||||||
|
|
||||||
`inspect reports` lists recent generated runs with artifact paths and source
|
`inspect reports` lists recent generated runs with artifact paths and source
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ missing directories, and unreadable files fail config loading.
|
|||||||
`notify.distributor` controls distributor notification after successful report
|
`notify.distributor` controls distributor notification after successful report
|
||||||
generation. It is disabled by default and does not add CLI flags. When enabled,
|
generation. It is disabled by default and does not add CLI flags. When enabled,
|
||||||
weatherreporter uploads one distributor bundle per generated report after
|
weatherreporter uploads one distributor bundle per generated report after
|
||||||
`scriptorium run` succeeds and final metadata is saved.
|
report rendering succeeds and final metadata is saved.
|
||||||
|
|
||||||
- `enabled`: whether distributor notification config is active. Default:
|
- `enabled`: whether distributor notification config is active. Default:
|
||||||
`false`.
|
`false`.
|
||||||
@@ -130,7 +130,9 @@ name the variable only; they should not contain the token value.
|
|||||||
- `sources`: optional map of source-specific overrides, using the same policy values.
|
- `sources`: optional map of source-specific overrides, using the same policy values.
|
||||||
|
|
||||||
Hourly forecast data is required for generated reports. Optional sources use
|
Hourly forecast data is required for generated reports. Optional sources use
|
||||||
the missing-source policy.
|
the missing-source policy. Source override keys include `observations`,
|
||||||
|
`current`, `narrative`, `alerts`, `discussion`, `weather_story`, and
|
||||||
|
`spc_convective_outlooks`.
|
||||||
|
|
||||||
### `scriptorium`
|
### `scriptorium`
|
||||||
|
|
||||||
@@ -179,9 +181,14 @@ snapshot exists and a threshold is crossed.
|
|||||||
`reports` optionally overrides the ordered deterministic modules declared by
|
`reports` optionally overrides the ordered deterministic modules declared by
|
||||||
report definitions. Omit a report entry to use its default module order.
|
report definitions. Omit a report entry to use its default module order.
|
||||||
|
|
||||||
Supported report keys are `daily`, `tomorrow`, `three_day`, `weekend`, and
|
Supported report keys are `daily`, `today`, `tomorrow`, `hourly`,
|
||||||
`storm`. Canonical report IDs such as `daily_today` and `daily_tomorrow` are
|
`three_day`, `weekend`, and `storm`. Canonical report IDs and accepted aliases
|
||||||
also accepted.
|
are also valid, including `three_day_outlook`, `weekend_outlook`, and
|
||||||
|
`storm_report`. Hyphens and underscores are treated equivalently in report
|
||||||
|
keys. Retired report keys are not supported.
|
||||||
|
|
||||||
|
`reports.today` applies only to the Today Report. `reports.daily` applies only
|
||||||
|
to the dated Daily Report.
|
||||||
|
|
||||||
Each report entry supports:
|
Each report entry supports:
|
||||||
|
|
||||||
@@ -197,11 +204,46 @@ reports:
|
|||||||
- metadata
|
- metadata
|
||||||
- current_conditions
|
- current_conditions
|
||||||
- narrative_forecast
|
- narrative_forecast
|
||||||
|
- alert_digest
|
||||||
|
- spc_convective_outlooks
|
||||||
- id: area_forecast_discussion
|
- id: area_forecast_discussion
|
||||||
options:
|
options:
|
||||||
sections:
|
sections:
|
||||||
- short_term
|
- short_term
|
||||||
|
- spc_convective_discussion
|
||||||
|
- daily_planning
|
||||||
- hourly_forecast
|
- hourly_forecast
|
||||||
|
today:
|
||||||
|
deterministic_modules:
|
||||||
|
- metadata
|
||||||
|
- current_conditions
|
||||||
|
- narrative_forecast
|
||||||
|
- derived_daily_summary
|
||||||
|
- derived_daypart_summaries
|
||||||
|
- precip_timing
|
||||||
|
- alert_digest
|
||||||
|
- spc_convective_outlooks
|
||||||
|
- area_forecast_discussion
|
||||||
|
- spc_convective_discussion
|
||||||
|
- weather_story
|
||||||
|
- outdoor_windows
|
||||||
|
- hourly_forecast
|
||||||
|
- today_planning
|
||||||
|
hourly:
|
||||||
|
deterministic_modules:
|
||||||
|
- metadata
|
||||||
|
- current_conditions
|
||||||
|
- hourly_forecast
|
||||||
|
- precip_timing
|
||||||
|
- alert_digest
|
||||||
|
- spc_convective_outlooks
|
||||||
|
- id: area_forecast_discussion
|
||||||
|
options:
|
||||||
|
sections:
|
||||||
|
- key_messages
|
||||||
|
- short_term
|
||||||
|
- spc_convective_discussion
|
||||||
|
- weather_story
|
||||||
```
|
```
|
||||||
|
|
||||||
Unknown reports, unknown modules, duplicate modules, incompatible report/module
|
Unknown reports, unknown modules, duplicate modules, incompatible report/module
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ This document describes the external Scriptorium CLI contract used by
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`weatherreporter` invokes Scriptorium as a subprocess to preflight prompt input
|
`weatherreporter` invokes Scriptorium as a subprocess to preflight prompt input
|
||||||
and generate Markdown reports. This page documents the CLI surface the adapter
|
and generate report artifacts. This page documents the CLI surface the adapter
|
||||||
uses, not the full Scriptorium product.
|
uses, not the full Scriptorium product.
|
||||||
|
|
||||||
## Commands Used
|
## Commands Used
|
||||||
@@ -29,11 +29,25 @@ scriptorium run \
|
|||||||
--out <artifact_path>
|
--out <artifact_path>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Structured generated-text report generation uses the same command shape:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scriptorium run \
|
||||||
|
--prompt <prompt_id> \
|
||||||
|
--input data_package=<path> \
|
||||||
|
--out <generated_text_raw_path>
|
||||||
|
```
|
||||||
|
|
||||||
`weatherreporter` always passes prompt input as
|
`weatherreporter` always passes prompt input as
|
||||||
`--input data_package=<path>`. The data package is structured YAML created by
|
`--input data_package=<path>`. The data package is structured YAML created by
|
||||||
`internal/promptinput`; module snapshots remain separate JSON artifacts for
|
`internal/promptinput`; module snapshots remain separate JSON artifacts for
|
||||||
inspection and Recent Changes.
|
inspection and Recent Changes.
|
||||||
|
|
||||||
|
For generated-text reports, Scriptorium selects the structured output schema
|
||||||
|
from the prompt configuration associated with the prompt ID. `weatherreporter`
|
||||||
|
does not pass `--format`, schema path, or JSON Schema flags for structured
|
||||||
|
generation.
|
||||||
|
|
||||||
## Configured Arguments
|
## Configured Arguments
|
||||||
|
|
||||||
The adapter can prepend configured flags before prompt-specific arguments:
|
The adapter can prepend configured flags before prompt-specific arguments:
|
||||||
@@ -68,11 +82,17 @@ Render results include:
|
|||||||
- exit code
|
- exit code
|
||||||
- truncation flags when applicable
|
- truncation flags when applicable
|
||||||
|
|
||||||
Run results include the same fields plus the requested output path.
|
Run results include the same fields plus the requested output path. Structured
|
||||||
|
generated-text run results use the same captured fields and output-path
|
||||||
|
recording, with the output path pointing at the raw generated-text JSON
|
||||||
|
artifact.
|
||||||
|
|
||||||
`weatherreporter` persists render preflight JSON when orchestration reaches the
|
`weatherreporter` persists render preflight JSON when orchestration reaches the
|
||||||
preflight save point. The final Markdown artifact is written by Scriptorium to
|
preflight save point. For direct Markdown reports, Scriptorium writes the
|
||||||
the `--out` path.
|
managed Markdown artifact to the `--out` path. For generated-text-template
|
||||||
|
reports, Scriptorium writes raw JSON to the `--out` path; later
|
||||||
|
weatherreporter workflow steps validate those bytes and render Markdown from an
|
||||||
|
embedded template.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
@@ -80,7 +100,7 @@ The adapter validates required request fields before starting Scriptorium:
|
|||||||
|
|
||||||
- prompt ID
|
- prompt ID
|
||||||
- data package path
|
- data package path
|
||||||
- output path for `run`
|
- output path for `run` and structured generated-text `run`
|
||||||
|
|
||||||
Nonzero exits return both the captured result and an error containing the exit
|
Nonzero exits return both the captured result and an error containing the exit
|
||||||
code and stderr. A `run` exit code such as `2` is still treated as an error by
|
code and stderr. A `run` exit code such as `2` is still treated as an error by
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ generation.
|
|||||||
means the endpoint was checked and there are no current active alerts. The
|
means the endpoint was checked and there are no current active alerts. The
|
||||||
adapter records a non-missing alerts source and an empty alert run.
|
adapter records a non-missing alerts source and an empty alert run.
|
||||||
|
|
||||||
|
For `/outlooks/convective`, `data: null` means no latest run is available and
|
||||||
|
follows missing-source policy. A non-null run with empty `outlooks` and
|
||||||
|
`discussions` arrays is checked empty data, not a missing source.
|
||||||
|
|
||||||
Malformed JSON envelopes, non-2xx statuses, and response read failures include
|
Malformed JSON envelopes, non-2xx statuses, and response read failures include
|
||||||
endpoint context in returned errors. Decode errors include source context when
|
endpoint context in returned errors. Decode errors include source context when
|
||||||
they fail the fetch; optional malformed sources follow the missing-source policy.
|
they fail the fetch; optional malformed sources follow the missing-source policy.
|
||||||
@@ -49,11 +53,66 @@ The adapter sends these query parameters:
|
|||||||
- `units`: from `weather_api.units`
|
- `units`: from `weather_api.units`
|
||||||
- `precision`: from `weather_api.precision` on observations, current
|
- `precision`: from `weather_api.precision` on observations, current
|
||||||
conditions, hourly forecast, and narrative forecast requests
|
conditions, hourly forecast, and narrative forecast requests
|
||||||
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast, and
|
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast,
|
||||||
discussion requests
|
discussion, and SPC convective outlook requests
|
||||||
|
|
||||||
Alerts do not receive `precision` or `tz`. Weather story requests receive only
|
Alerts do not receive `precision` or `tz`. Weather story requests receive only
|
||||||
`format=json`.
|
`format=json`. SPC convective outlook requests receive only `format=json` and
|
||||||
|
`tz`; they do not receive `units` or `precision`.
|
||||||
|
|
||||||
|
## SPC Convective Outlooks
|
||||||
|
|
||||||
|
The adapter fetches SPC convective outlook data from:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /outlooks/convective?format=json&tz=<weather_api.timezone>
|
||||||
|
```
|
||||||
|
|
||||||
|
The response uses the standard `data` envelope. `data: null` means no latest
|
||||||
|
run is available and follows missing-source policy. A non-null object with
|
||||||
|
empty `outlooks` and `discussions` arrays is accepted as checked empty data.
|
||||||
|
|
||||||
|
Run fields consumed by weatherreporter:
|
||||||
|
|
||||||
|
- `locationId`
|
||||||
|
- `locationName`
|
||||||
|
- `asOf`
|
||||||
|
- `issuedAt`
|
||||||
|
- `updatedAt`
|
||||||
|
- `product`
|
||||||
|
- `outlooks`
|
||||||
|
- `discussions`
|
||||||
|
|
||||||
|
Outlook fields consumed:
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `provider`
|
||||||
|
- `product`
|
||||||
|
- `day`
|
||||||
|
- `outlookType`
|
||||||
|
- `label`
|
||||||
|
- `labelText`
|
||||||
|
- `forecaster`
|
||||||
|
- `severityRank`
|
||||||
|
- `validFrom`
|
||||||
|
- `validTo`
|
||||||
|
- `issuedAt`
|
||||||
|
- `expiresAt`
|
||||||
|
- `sourceUrl`
|
||||||
|
- `imageUrl`
|
||||||
|
- `containsLocation`
|
||||||
|
- `geometry`
|
||||||
|
|
||||||
|
Discussion fields consumed:
|
||||||
|
|
||||||
|
- `day`
|
||||||
|
- `headline`
|
||||||
|
- `summary`
|
||||||
|
- `discussion`
|
||||||
|
- `updatedAt`
|
||||||
|
|
||||||
|
GeoJSON `geometry` is decoded into collected weather facts and persisted in
|
||||||
|
bundle/debug artifacts, but prompt-facing SPC module output omits geometry.
|
||||||
|
|
||||||
## Endpoints Used
|
## Endpoints Used
|
||||||
|
|
||||||
@@ -66,6 +125,7 @@ The adapter fetches these endpoints once per bundle:
|
|||||||
- `/alerts/active`
|
- `/alerts/active`
|
||||||
- `/discussion`
|
- `/discussion`
|
||||||
- `/weatherstories/latest`
|
- `/weatherstories/latest`
|
||||||
|
- `/outlooks/convective`
|
||||||
|
|
||||||
`weatherreporter` does not call day-slice forecast endpoints or discussion
|
`weatherreporter` does not call day-slice forecast endpoints or discussion
|
||||||
subsection endpoints. Report-period selection and daypart summarization happen
|
subsection endpoints. Report-period selection and daypart summarization happen
|
||||||
@@ -88,6 +148,7 @@ source-specific `missing_source.sources` policy:
|
|||||||
- `alerts` for `/alerts/active`
|
- `alerts` for `/alerts/active`
|
||||||
- `discussion` for `/discussion`
|
- `discussion` for `/discussion`
|
||||||
- `weather_story` for `/weatherstories/latest`
|
- `weather_story` for `/weatherstories/latest`
|
||||||
|
- `spc_convective_outlooks` for `/outlooks/convective`
|
||||||
|
|
||||||
Policy behavior:
|
Policy behavior:
|
||||||
|
|
||||||
@@ -99,6 +160,9 @@ For `/alerts/active`, an HTTP error or missing `data` field still fails or
|
|||||||
follows the relevant error path, but explicit `data: null` is not a
|
follows the relevant error path, but explicit `data: null` is not a
|
||||||
missing-source condition.
|
missing-source condition.
|
||||||
|
|
||||||
|
For `/outlooks/convective`, a non-null data object with empty outlook and
|
||||||
|
discussion arrays is accepted as checked empty data.
|
||||||
|
|
||||||
## Source Identity
|
## Source Identity
|
||||||
|
|
||||||
For source payloads accepted into the bundle, including the explicit `null`
|
For source payloads accepted into the bundle, including the explicit `null`
|
||||||
@@ -116,8 +180,8 @@ warnings list.
|
|||||||
|
|
||||||
## Compatibility Assumptions
|
## Compatibility Assumptions
|
||||||
|
|
||||||
The adapter expects payload fields compatible with the internal forecast bundle
|
The adapter expects payload fields compatible with the internal weather data
|
||||||
types in `internal/forecast/bundle.go`, including:
|
bundle types in `internal/weatherdata/bundle.go`, including:
|
||||||
|
|
||||||
- observation timestamps and observation values
|
- observation timestamps and observation values
|
||||||
- current condition values
|
- current condition values
|
||||||
@@ -126,6 +190,8 @@ types in `internal/forecast/bundle.go`, including:
|
|||||||
- discussion metadata, key messages, and short/long-term section text
|
- discussion metadata, key messages, and short/long-term section text
|
||||||
- latest weather story title, description, timing, priority, order, alt text,
|
- latest weather story title, description, timing, priority, order, alt text,
|
||||||
and download URL
|
and download URL
|
||||||
|
- SPC convective outlook run metadata, outlooks, discussions, and GeoJSON
|
||||||
|
geometry
|
||||||
|
|
||||||
The adapter intentionally keeps upstream transport and envelope details inside
|
The adapter intentionally keeps upstream transport and envelope details inside
|
||||||
`internal/adapters/weatherapi`; downstream packages consume the normalized
|
`internal/adapters/weatherapi`; downstream packages consume the normalized
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ Outputs:
|
|||||||
|
|
||||||
- generated report results with JSON module snapshot, YAML data package,
|
- generated report results with JSON module snapshot, YAML data package,
|
||||||
preflight, report, metadata, prior snapshot, Recent Changes, Scriptorium
|
preflight, report, metadata, prior snapshot, Recent Changes, Scriptorium
|
||||||
result details, and notification result when attempted
|
result details, generated-text artifact paths when applicable, and
|
||||||
|
notification result when attempted
|
||||||
- batch summaries with per-report status, artifact paths, error text, and
|
- batch summaries with per-report status, artifact paths, error text, and
|
||||||
notification outcome when attempted
|
notification outcome when attempted
|
||||||
- saved Weather API bundle JSON for fetch workflows
|
- saved Weather API bundle JSON for fetch workflows
|
||||||
@@ -49,6 +50,19 @@ subprocess behavior stays in `internal/adapters/scriptorium`. Distributor
|
|||||||
upload behavior stays in `internal/adapters/distributor`. Filesystem layout and
|
upload behavior stays in `internal/adapters/distributor`. Filesystem layout and
|
||||||
persisted metadata stay in `internal/state`.
|
persisted metadata stay in `internal/state`.
|
||||||
|
|
||||||
|
## Data Flow Terms
|
||||||
|
|
||||||
|
- `CollectedFacts` are normalized source facts fetched once from Weather API
|
||||||
|
and made available to derivation and module builders.
|
||||||
|
- `DerivedFacts` are deterministic calculations over collected facts, the
|
||||||
|
resolved valid period, daypart configuration, and report-specific windows.
|
||||||
|
- `module.Output` values are ordered deterministic stanzas built from collected
|
||||||
|
and derived facts for prompt input and inspection.
|
||||||
|
- `GeneratedText` is structured prose returned by Scriptorium for
|
||||||
|
generated-text-template reports and validated by `internal/generatedtext`.
|
||||||
|
- `RenderContext` is the typed template input built from report metadata,
|
||||||
|
module outputs, and validated generated text before Markdown rendering.
|
||||||
|
|
||||||
## Config Fields Used
|
## Config Fields Used
|
||||||
|
|
||||||
- `weather_api.*` for Weather API client construction and module metadata
|
- `weather_api.*` for Weather API client construction and module metadata
|
||||||
@@ -63,7 +77,7 @@ defaults.
|
|||||||
|
|
||||||
## Generation Workflow
|
## Generation Workflow
|
||||||
|
|
||||||
Single-report generation follows this order:
|
Single-report generation shares this setup:
|
||||||
|
|
||||||
1. Resolve the command report to a `report.Resolved` value.
|
1. Resolve the command report to a `report.Resolved` value.
|
||||||
2. Create or use a filesystem store.
|
2. Create or use a filesystem store.
|
||||||
@@ -76,13 +90,31 @@ Single-report generation follows this order:
|
|||||||
9. Run Scriptorium render preflight.
|
9. Run Scriptorium render preflight.
|
||||||
10. Save preflight JSON when a render result is available.
|
10. Save preflight JSON when a render result is available.
|
||||||
11. Save metadata for inspection.
|
11. Save metadata for inspection.
|
||||||
12. Run Scriptorium report generation to the managed report path.
|
|
||||||
13. Copy the managed report to the requested `--out` path when provided.
|
For `scriptorium_markdown` reports, generation then:
|
||||||
14. Save metadata with the managed report path.
|
|
||||||
15. If distributor notification is enabled, notify using the managed report
|
12. Runs Scriptorium report generation to the managed report path.
|
||||||
|
|
||||||
|
For `generated_text_template` reports, generation then:
|
||||||
|
|
||||||
|
12. Looks up the generated-text catalog entry for the report schema/template
|
||||||
|
IDs.
|
||||||
|
13. Runs structured Scriptorium generation to the raw generated-text JSON path.
|
||||||
|
14. Saves the structured Scriptorium run result.
|
||||||
|
15. Validates and saves normalized generated text.
|
||||||
|
16. Builds and saves a typed render context.
|
||||||
|
17. Renders Markdown from the embedded template to the managed report path.
|
||||||
|
|
||||||
|
After either mode has produced a managed Markdown report, shared finalization:
|
||||||
|
|
||||||
|
1. Copies the managed report to the requested `--out` or `--out-dir` path when
|
||||||
|
provided.
|
||||||
|
2. Saves final metadata with the managed report path and any generated-text
|
||||||
|
artifact paths already produced.
|
||||||
|
3. If distributor notification is enabled, notifies using the managed report
|
||||||
path as the source file.
|
path as the source file.
|
||||||
16. Save a distributor notification debug artifact and update metadata with its
|
4. Saves a distributor notification debug artifact and updates metadata with
|
||||||
path.
|
its path.
|
||||||
|
|
||||||
If render preflight returns both a result and an error, preflight JSON and
|
If render preflight returns both a result and an error, preflight JSON and
|
||||||
metadata are persisted before the error is returned. If Scriptorium report
|
metadata are persisted before the error is returned. If Scriptorium report
|
||||||
@@ -90,6 +122,10 @@ 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 Weather API,
|
||||||
module snapshot, prompt input, render, Scriptorium run, or metadata-save
|
module snapshot, prompt input, render, Scriptorium run, or metadata-save
|
||||||
failures.
|
failures.
|
||||||
|
Generated-text report failures are returned with report ID, RunID, and the
|
||||||
|
failed operation. When available, the app preserves the latest generated-text
|
||||||
|
artifacts already reached by the workflow: preflight output, structured run
|
||||||
|
result, raw generated text, validated generated text, and render context.
|
||||||
When notification is attempted, the debug artifact records request identity,
|
When notification is attempted, the debug artifact records request identity,
|
||||||
including rendered pipeline ID, bundle paths, accepted upload fields,
|
including rendered pipeline ID, bundle paths, accepted upload fields,
|
||||||
distributor status fields, raw status report JSON when available, and redacted
|
distributor status fields, raw status report JSON when available, and redacted
|
||||||
@@ -98,15 +134,17 @@ failure context.
|
|||||||
|
|
||||||
## Batch Workflow
|
## Batch Workflow
|
||||||
|
|
||||||
`run morning` resolves Daily Today, 3-Day Outlook, and Weekend Outlook except
|
`run morning` resolves Today Report, 3-Day Outlook, and Weekend Outlook except
|
||||||
on Sunday. `run evening` resolves Daily Tomorrow. Batch output copy names come
|
on Sunday. `run evening` resolves Tomorrow Report. Daily Report is generated
|
||||||
from report definitions. Batch generation continues independent reports after a
|
only through `generate daily --date YYYY-MM-DD`; it is not part of scheduled
|
||||||
failure, records each result, writes compact status lines to stderr, emits a
|
batches. Batch output copy names come from report definitions. Batch generation
|
||||||
JSON summary to stdout, and returns an aggregate error when any report failed.
|
continues independent reports after a failure, records each result, writes
|
||||||
When notification is enabled, each successfully generated report is notified
|
compact status lines to stderr, emits a JSON summary to stdout, and returns an
|
||||||
independently. Notification failure marks that report failed, records
|
aggregate error when any report failed. When notification is enabled, each
|
||||||
notification fields in the batch result, and does not stop later reports.
|
successfully generated report is notified independently. Notification failure
|
||||||
`--out-dir` copies are never used as notification source files.
|
marks that report failed, records notification fields in the batch result, and
|
||||||
|
does not stop later reports. `--out-dir` copies are never used as notification
|
||||||
|
source files.
|
||||||
|
|
||||||
## Inspection Workflow
|
## Inspection Workflow
|
||||||
|
|
||||||
@@ -122,6 +160,8 @@ inspection view.
|
|||||||
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
|
||||||
|
not create extra output copies.
|
||||||
- Notification errors are wrapped with report ID, RunID, and managed report path
|
- Notification errors are wrapped with report ID, RunID, and managed report path
|
||||||
context and are recorded separately in batch results.
|
context and are recorded separately in batch results.
|
||||||
- Metadata and artifact path errors include filesystem context.
|
- Metadata and artifact path errors include filesystem context.
|
||||||
@@ -142,6 +182,8 @@ Inspect:
|
|||||||
- Generated reports use the same app request and result types regardless of
|
- Generated reports use the same app request and result types regardless of
|
||||||
report ID.
|
report ID.
|
||||||
- Render preflight precedes Scriptorium report generation.
|
- Render preflight precedes Scriptorium report generation.
|
||||||
|
- Generated-text reports render Markdown from a curated render context, not from
|
||||||
|
a raw data package.
|
||||||
- Recent Changes are computed from structured module snapshots.
|
- Recent Changes are computed from structured module snapshots.
|
||||||
- Metadata links artifacts produced for a run.
|
- Metadata links artifacts produced for a run.
|
||||||
- Distributor notification maps the managed Markdown report path to configured
|
- Distributor notification maps the managed Markdown report path to configured
|
||||||
|
|||||||
@@ -28,14 +28,46 @@ Outputs:
|
|||||||
supported reports, fact requirements, missing-data behavior, and builder
|
supported reports, fact requirements, missing-data behavior, and builder
|
||||||
- `module.Output` values for source-oriented stanzas:
|
- `module.Output` values for source-oriented stanzas:
|
||||||
`metadata`, `current_conditions`, `narrative_forecast`, `hourly_forecast`,
|
`metadata`, `current_conditions`, `narrative_forecast`, `hourly_forecast`,
|
||||||
`alert_digest`, `area_forecast_discussion`, and `weather_story`
|
`alert_digest`, `spc_convective_outlooks`,
|
||||||
|
`area_forecast_discussion`, `spc_convective_discussion`, and
|
||||||
|
`weather_story`
|
||||||
- `module.Output` values for derived stanzas:
|
- `module.Output` values for derived stanzas:
|
||||||
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
|
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
|
||||||
`outdoor_windows`, and `tomorrow_planning`
|
`outdoor_windows`, `today_planning`, `tomorrow_planning`, and
|
||||||
|
`daily_planning`
|
||||||
|
|
||||||
Every registered composition entry has a builder. Unknown or unimplemented
|
Every registered composition entry has a builder. Unknown or unimplemented
|
||||||
module IDs fail validation instead of being skipped.
|
module IDs fail validation instead of being skipped.
|
||||||
|
|
||||||
|
Daily Report supports the Daily-style civil-day modules plus `daily_planning`
|
||||||
|
and `hourly_forecast`; those outputs feed the dated Daily GeneratedText prompt
|
||||||
|
package and embedded Markdown template.
|
||||||
|
|
||||||
|
Tomorrow Report supports the Daily-style civil-day modules plus
|
||||||
|
`tomorrow_planning` and `hourly_forecast`; those outputs feed the Tomorrow
|
||||||
|
GeneratedText prompt package and embedded Markdown template.
|
||||||
|
|
||||||
|
Today Report supports the Daily-style civil-day modules plus `today_planning`
|
||||||
|
and `hourly_forecast`; those outputs feed the Today GeneratedText prompt
|
||||||
|
package and embedded Markdown template.
|
||||||
|
|
||||||
|
`today_planning` is a Today-specific deterministic planning stanza with
|
||||||
|
morning readiness, commute/school/workday concerns, outdoor planning, and
|
||||||
|
late-day change-watch fields. It is compatible with `report.Today` only.
|
||||||
|
|
||||||
|
`daily_planning` is a dated Daily deterministic planning stanza with morning
|
||||||
|
readiness, commute/school/workday concerns, and overnight change-watch fields.
|
||||||
|
It is compatible only with the `daily` report ID value. The default Daily
|
||||||
|
Report composition includes it.
|
||||||
|
|
||||||
|
Hourly Report supports source and valid-period modules that operate over its
|
||||||
|
rolling six-hour period: `metadata`, `current_conditions`, `hourly_forecast`,
|
||||||
|
`precip_timing`, `alert_digest`, `spc_convective_outlooks`,
|
||||||
|
`area_forecast_discussion`, `spc_convective_discussion`, and `weather_story`.
|
||||||
|
It does not support daily/daypart-only modules such as
|
||||||
|
`derived_daily_summary`, `derived_daypart_summaries`, `outdoor_windows`,
|
||||||
|
`today_planning`, `tomorrow_planning`, or `daily_planning`.
|
||||||
|
|
||||||
Prompt-facing module values use local, human-readable date and time labels
|
Prompt-facing module values use local, human-readable date and time labels
|
||||||
where the LLM is expected to reason about report content. Canonical timestamps
|
where the LLM is expected to reason about report content. Canonical timestamps
|
||||||
remain in report metadata, source provenance, and integration artifacts.
|
remain in report metadata, source provenance, and integration artifacts.
|
||||||
@@ -57,7 +89,24 @@ builders run. Configured `location` values are prompt context only; Weather API
|
|||||||
`sourceLocationId` and `sourceLocation` remain source provenance.
|
`sourceLocationId` and `sourceLocation` remain source provenance.
|
||||||
|
|
||||||
`area_forecast_discussion` uses optional `sections` configuration to include a
|
`area_forecast_discussion` uses optional `sections` configuration to include a
|
||||||
subset of discussion fields.
|
subset of discussion fields. Hourly Report defaults this module to
|
||||||
|
`key_messages` and `short_term`.
|
||||||
|
|
||||||
|
`spc_convective_outlooks` uses collected SPC run metadata and derived
|
||||||
|
report-period outlooks. It emits `checked: true` for a successfully fetched
|
||||||
|
empty run, reports `outlook_count`, and includes prompt-facing outlook fields
|
||||||
|
such as risk label, `period_begins`, `period_ends`, image URL, and whether the
|
||||||
|
outlook contains the configured location. It does not emit GeoJSON geometry,
|
||||||
|
source URL, expiration time, or severity rank.
|
||||||
|
|
||||||
|
Prompt-facing module intervals use friendly local `period_begins` and
|
||||||
|
`period_ends` labels. Canonical report metadata, source provenance,
|
||||||
|
`issued_at`, `updated_at`, and point-in-time fields remain separate.
|
||||||
|
|
||||||
|
`spc_convective_discussion` uses the same derived report-period outlooks and
|
||||||
|
discussion records. It is omitted unless at least one retained categorical
|
||||||
|
outlook for the same SPC day has severity rank `3` or higher and matching
|
||||||
|
discussion text exists.
|
||||||
|
|
||||||
## External Adapters Used
|
## External Adapters Used
|
||||||
|
|
||||||
@@ -86,6 +135,11 @@ return an error for invalid required inputs.
|
|||||||
forecast discussion, and weather story stanzas.
|
forecast discussion, and weather story stanzas.
|
||||||
- Alert digest output distinguishes checked empty alert data from missing alert
|
- Alert digest output distinguishes checked empty alert data from missing alert
|
||||||
source data.
|
source data.
|
||||||
|
- SPC convective outlook output distinguishes checked empty outlook data from
|
||||||
|
missing outlook source data and omits GeoJSON geometry from prompt-facing
|
||||||
|
fields.
|
||||||
|
- SPC convective discussion output is omitted unless a retained outlook has
|
||||||
|
severity rank `3` or higher and matching discussion text is available.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
@@ -20,14 +20,29 @@ Inputs:
|
|||||||
Outputs:
|
Outputs:
|
||||||
|
|
||||||
- `facts.CollectedFacts` with normalized source facts plus separate source
|
- `facts.CollectedFacts` with normalized source facts plus separate source
|
||||||
provenance and warnings
|
provenance and warnings. SPC convective outlook source data is carried
|
||||||
|
through when present in the bundle, including upstream geometry and source
|
||||||
|
provenance.
|
||||||
- `facts.DerivedFacts` with valid-period forecast slices, alert overlaps,
|
- `facts.DerivedFacts` with valid-period forecast slices, alert overlaps,
|
||||||
daily summaries, daypart summaries, and Storm Report window summary
|
report-period SPC convective outlooks and discussions, daily summaries,
|
||||||
|
daypart summaries, and Storm Report window summary
|
||||||
|
|
||||||
|
Hourly Report uses the generic valid-period hourly and narrative selection
|
||||||
|
for its rolling six-hour window. Its derived facts include precipitation timing
|
||||||
|
from the selected hourly periods, alert overlaps for the six-hour period, and
|
||||||
|
SPC outlooks/discussions overlapping that period. It does not build daily
|
||||||
|
summaries, daypart summaries, or a storm-window summary.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
- This package owns fact assembly and reusable deterministic derivation for a
|
- This package owns fact assembly and reusable deterministic derivation for a
|
||||||
report run.
|
report run.
|
||||||
|
- SPC convective outlook derivation selects already-collected outlooks whose
|
||||||
|
half-open valid intervals overlap the resolved report period and retains
|
||||||
|
discussions for represented outlook days.
|
||||||
|
- Derived SPC outlook records preserve the collected outlook fields, including
|
||||||
|
geometry, for downstream components that need source-level facts. Prompt
|
||||||
|
modules decide which fields are exposed to Scriptorium.
|
||||||
- It does not fetch upstream data, build prompt wording, compare prior
|
- It does not fetch upstream data, build prompt wording, compare prior
|
||||||
snapshots, write workflow state, invoke Scriptorium, or define modules.
|
snapshots, write workflow state, invoke Scriptorium, or define modules.
|
||||||
|
|
||||||
@@ -53,8 +68,13 @@ and inspection.
|
|||||||
- Invalid timezone names return an error.
|
- Invalid timezone names return an error.
|
||||||
- Missing required hourly forecast data returns the underlying forecast
|
- Missing required hourly forecast data returns the underlying forecast
|
||||||
derivation error for reports that require daily summaries.
|
derivation error for reports that require daily summaries.
|
||||||
|
- Hourly Report can derive its default module facts without daily or
|
||||||
|
daypart summaries.
|
||||||
- Missing optional narrative, alert, discussion, daily, or weather story data
|
- Missing optional narrative, alert, discussion, daily, or weather story data
|
||||||
produces empty or nil derived fields.
|
produces empty or nil derived fields.
|
||||||
|
- Missing optional SPC convective outlook data produces a nil collected field.
|
||||||
|
- A present SPC convective outlook source with no report-period matches
|
||||||
|
produces non-nil empty derived outlook and discussion slices.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@@ -67,6 +87,8 @@ Inspect:
|
|||||||
|
|
||||||
- Collected facts are built once from a fetched bundle.
|
- Collected facts are built once from a fetched bundle.
|
||||||
- Derived facts are scoped to one resolved report.
|
- Derived facts are scoped to one resolved report.
|
||||||
|
- SPC convective outlook selection uses the resolved report period and the
|
||||||
|
already-collected outlook run.
|
||||||
- Source provenance and warnings stay separate from ordinary fact fields.
|
- Source provenance and warnings stay separate from ordinary fact fields.
|
||||||
- Prompt-specific wording and one-off presentation decisions stay outside this
|
- Prompt-specific wording and one-off presentation decisions stay outside this
|
||||||
package.
|
package.
|
||||||
|
|||||||
140
docs/internal/generatedtext.md
Normal file
140
docs/internal/generatedtext.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# Generated Text Internals
|
||||||
|
|
||||||
|
This document describes structured generated-text handling in
|
||||||
|
`internal/generatedtext`.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
`internal/generatedtext` validates structured text returned for
|
||||||
|
generated-text-template reports and builds curated render contexts for
|
||||||
|
templates. It also owns the generated-text catalog that connects report
|
||||||
|
definitions to validators, render-context builders, schema assets, and template
|
||||||
|
assets. The implemented contracts are Daily render context, Today Report,
|
||||||
|
Tomorrow Report, and Hourly Report.
|
||||||
|
|
||||||
|
## Inputs And Outputs
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
|
||||||
|
- raw GeneratedText JSON for Daily, Today, Tomorrow Report, or Hourly Report
|
||||||
|
- report metadata from `internal/briefing`
|
||||||
|
- a module snapshot from `internal/module`
|
||||||
|
- validated generated text
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
|
||||||
|
- typed `Daily` generated text
|
||||||
|
- typed `Today` generated text
|
||||||
|
- typed `Tomorrow` generated text
|
||||||
|
- typed `Hourly` generated text
|
||||||
|
- normalized stable JSON for validated generated text
|
||||||
|
- typed `DailyRenderContext` values for `internal/reporttemplate`
|
||||||
|
- typed `TodayRenderContext` values for `internal/reporttemplate`
|
||||||
|
- typed `TomorrowRenderContext` values for `internal/reporttemplate`
|
||||||
|
- typed `HourlyRenderContext` values for `internal/reporttemplate`
|
||||||
|
- generated-text catalog handlers for report definitions that use
|
||||||
|
`generated_text_template`
|
||||||
|
|
||||||
|
The Daily generated text JSON accepts the same public fields and validation
|
||||||
|
rules as Tomorrow. Its catalog entry is selected through schema ID `daily` and
|
||||||
|
template ID `daily`, with prompt ID `weather.daily_generated_text`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"summary": "string",
|
||||||
|
"forecast_discussion": ["string"],
|
||||||
|
"precipitation_timing": "string",
|
||||||
|
"confidence": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The hourly generated text JSON accepts:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"summary": "string",
|
||||||
|
"forecast_discussion": "string",
|
||||||
|
"precipitation_timing": "string",
|
||||||
|
"confidence": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`summary` and `forecast_discussion` are required after trimming whitespace.
|
||||||
|
`precipitation_timing` and `confidence` are optional and omitted from normalized
|
||||||
|
JSON when blank.
|
||||||
|
|
||||||
|
The Today generated text JSON accepts the same public fields and validation
|
||||||
|
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
|
||||||
|
{
|
||||||
|
"summary": "string",
|
||||||
|
"forecast_discussion": ["string"],
|
||||||
|
"precipitation_timing": "string",
|
||||||
|
"confidence": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The Tomorrow generated text JSON accepts:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"summary": "string",
|
||||||
|
"forecast_discussion": ["string"],
|
||||||
|
"precipitation_timing": "string",
|
||||||
|
"confidence": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`summary` is required 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.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- This package owns typed generated-text validation and render-context shaping.
|
||||||
|
- It owns generated-text catalog lookup for schema/template combinations.
|
||||||
|
- It uses typed module snapshot decoding through `module.StanzaValue`.
|
||||||
|
- It does not invoke Scriptorium, write state artifacts, choose report
|
||||||
|
definitions, compare snapshots, or own embedded template/schema files.
|
||||||
|
- It renders through `internal/reporttemplate`; embedded asset lookup remains
|
||||||
|
in `internal/reporttemplate`.
|
||||||
|
- It does not use a Go JSON Schema dependency; schema enforcement in Go is
|
||||||
|
limited to typed JSON decoding, unknown-field rejection, and required-field
|
||||||
|
checks.
|
||||||
|
|
||||||
|
## Failure Behavior
|
||||||
|
|
||||||
|
- Malformed generated-text JSON fails with decode context.
|
||||||
|
- Unknown generated-text JSON fields fail during decoding.
|
||||||
|
- Empty required fields fail after trimming whitespace.
|
||||||
|
- Daily, Today, and Tomorrow forecast discussion fails when no nonblank
|
||||||
|
paragraphs remain.
|
||||||
|
- Missing optional render-context stanzas become nil module pointers.
|
||||||
|
- Invalid render metadata, including missing timezone, missing generated time,
|
||||||
|
or invalid valid period, fails before template rendering.
|
||||||
|
- Unsupported generated-text schema IDs, template IDs, or schema/template
|
||||||
|
combinations fail during catalog lookup with report ID context.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Inspect:
|
||||||
|
|
||||||
|
- `internal/generatedtext/hourly_test.go`
|
||||||
|
- `internal/generatedtext/daily_test.go`
|
||||||
|
- `internal/generatedtext/today_test.go`
|
||||||
|
- `internal/generatedtext/tomorrow_test.go`
|
||||||
|
- `internal/generatedtext/catalog_test.go`
|
||||||
|
- `internal/generatedtext/render_context_test.go`
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
- Render contexts are curated structs, not raw prompt-input packages.
|
||||||
|
- Required generated text is normalized before downstream artifact storage.
|
||||||
|
- Generated-text-template reports must have one catalog entry matching their
|
||||||
|
report definition schema and template IDs.
|
||||||
|
- Missing optional weather narrative stanzas produce empty or fallback render
|
||||||
|
context fields rather than forcing raw module data into templates.
|
||||||
@@ -5,9 +5,9 @@ This document describes the module contract in `internal/module`.
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/module` defines the shared identifiers and data envelopes used for
|
`internal/module` defines the shared identifiers and data envelopes used for
|
||||||
prompt-facing modules. Report definitions use module IDs for composition,
|
report modules. Report definitions use module IDs for composition, module
|
||||||
module builders produce outputs with stanza names, prompt input packages consume
|
builders produce rich outputs with stanza names, prompt input packages consume
|
||||||
snapshots, and Recent Changes compares snapshot stanzas.
|
runtime prompt export values, and Recent Changes compares snapshot stanzas.
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
@@ -23,8 +23,38 @@ Outputs:
|
|||||||
- typed option structs for registered modules
|
- typed option structs for registered modules
|
||||||
- `module.Snapshot` with schema version `weatherreporter.modules.v1`
|
- `module.Snapshot` with schema version `weatherreporter.modules.v1`
|
||||||
- ordered snapshot outputs with module ID, stanza name, and typed value
|
- ordered snapshot outputs with module ID, stanza name, and typed value
|
||||||
|
- runtime-only prompt export values on module outputs
|
||||||
|
- `module.Output.DataPackageValue`, which selects the prompt export value and
|
||||||
|
falls back to the rich value for hand-built or loaded snapshots
|
||||||
- typed stanza lookup through `module.StanzaValue`
|
- typed stanza lookup through `module.StanzaValue`
|
||||||
|
|
||||||
|
## Rich Values And Prompt Exports
|
||||||
|
|
||||||
|
Each `module.Output` has two value surfaces:
|
||||||
|
|
||||||
|
- `Value`: the rich module value used by templates, module snapshots,
|
||||||
|
inspection, Recent Changes, and render contexts.
|
||||||
|
- `PromptValue`: the runtime-only prompt export used when building Scriptorium
|
||||||
|
data packages.
|
||||||
|
|
||||||
|
`PromptValue` is deliberately excluded from module snapshot JSON. Persisted
|
||||||
|
module snapshots keep only the rich `value` field so inspection and
|
||||||
|
render-context reconstruction keep the full deterministic template surface.
|
||||||
|
|
||||||
|
The `internal/briefing` module registry attaches prompt export values when it
|
||||||
|
builds module outputs. Modules without a custom exporter use default
|
||||||
|
pass-through behavior, so their prompt value is the same as their rich value.
|
||||||
|
Modules that need cleanup own typed prompt export structs near the module
|
||||||
|
builder. Current custom prompt exports are:
|
||||||
|
|
||||||
|
- `current_conditions`
|
||||||
|
- `hourly_forecast`
|
||||||
|
- `derived_daypart_summaries`
|
||||||
|
|
||||||
|
Custom exporters remove template-only helpers or confusing duplicates from the
|
||||||
|
data package without shrinking the rich module structs used by templates.
|
||||||
|
Exporter failures include module ID and stanza context.
|
||||||
|
|
||||||
## Registered Module IDs
|
## Registered Module IDs
|
||||||
|
|
||||||
The registry recognizes these IDs:
|
The registry recognizes these IDs:
|
||||||
@@ -37,17 +67,140 @@ The registry recognizes these IDs:
|
|||||||
- `derived_daypart_summaries`
|
- `derived_daypart_summaries`
|
||||||
- `precip_timing`
|
- `precip_timing`
|
||||||
- `alert_digest`
|
- `alert_digest`
|
||||||
|
- `spc_convective_outlooks`
|
||||||
- `area_forecast_discussion`
|
- `area_forecast_discussion`
|
||||||
|
- `spc_convective_discussion`
|
||||||
- `weather_story`
|
- `weather_story`
|
||||||
- `outdoor_windows`
|
- `outdoor_windows`
|
||||||
|
- `today_planning`
|
||||||
- `tomorrow_planning`
|
- `tomorrow_planning`
|
||||||
|
- `daily_planning`
|
||||||
|
|
||||||
Every registered module has a builder. Report composition entries that refer to
|
Every registered module has a builder. Report composition entries that refer to
|
||||||
unknown or unimplemented module IDs fail validation instead of being skipped.
|
unknown or unimplemented module IDs fail validation instead of being skipped.
|
||||||
|
|
||||||
|
## Daily Composition
|
||||||
|
|
||||||
|
The default Daily Report module order is:
|
||||||
|
|
||||||
|
1. `metadata`
|
||||||
|
2. `current_conditions`
|
||||||
|
3. `narrative_forecast`
|
||||||
|
4. `derived_daily_summary`
|
||||||
|
5. `derived_daypart_summaries`
|
||||||
|
6. `precip_timing`
|
||||||
|
7. `alert_digest`
|
||||||
|
8. `spc_convective_outlooks`
|
||||||
|
9. `area_forecast_discussion`
|
||||||
|
10. `spc_convective_discussion`
|
||||||
|
11. `weather_story`
|
||||||
|
12. `outdoor_windows`
|
||||||
|
13. `daily_planning`
|
||||||
|
14. `hourly_forecast`
|
||||||
|
|
||||||
|
The embedded Daily template uses selected deterministic fields from these
|
||||||
|
module outputs after GeneratedText validation.
|
||||||
|
|
||||||
|
## Today Composition
|
||||||
|
|
||||||
|
The default Today Report module order is:
|
||||||
|
|
||||||
|
1. `metadata`
|
||||||
|
2. `current_conditions`
|
||||||
|
3. `narrative_forecast`
|
||||||
|
4. `derived_daily_summary`
|
||||||
|
5. `derived_daypart_summaries`
|
||||||
|
6. `precip_timing`
|
||||||
|
7. `alert_digest`
|
||||||
|
8. `spc_convective_outlooks`
|
||||||
|
9. `area_forecast_discussion`
|
||||||
|
10. `spc_convective_discussion`
|
||||||
|
11. `weather_story`
|
||||||
|
12. `outdoor_windows`
|
||||||
|
13. `hourly_forecast`
|
||||||
|
14. `today_planning`
|
||||||
|
|
||||||
|
The embedded Today template uses selected deterministic fields from these
|
||||||
|
module outputs after GeneratedText validation.
|
||||||
|
|
||||||
|
## Tomorrow Composition
|
||||||
|
|
||||||
|
The default Tomorrow Report module order is:
|
||||||
|
|
||||||
|
1. `metadata`
|
||||||
|
2. `current_conditions`
|
||||||
|
3. `narrative_forecast`
|
||||||
|
4. `derived_daily_summary`
|
||||||
|
5. `derived_daypart_summaries`
|
||||||
|
6. `precip_timing`
|
||||||
|
7. `alert_digest`
|
||||||
|
8. `spc_convective_outlooks`
|
||||||
|
9. `area_forecast_discussion`
|
||||||
|
10. `spc_convective_discussion`
|
||||||
|
11. `weather_story`
|
||||||
|
12. `outdoor_windows`
|
||||||
|
13. `tomorrow_planning`
|
||||||
|
14. `hourly_forecast`
|
||||||
|
|
||||||
|
The embedded Tomorrow template uses selected deterministic fields from these
|
||||||
|
module outputs after GeneratedText validation.
|
||||||
|
|
||||||
|
## Daily Planning
|
||||||
|
|
||||||
|
`daily_planning` emits dated daily planning facts for the `daily` report ID.
|
||||||
|
Its output stanza is also named `daily_planning`. The module is supported only
|
||||||
|
by that report ID and depends on daily summaries for the selected local civil
|
||||||
|
day. The default Daily Report composition includes it.
|
||||||
|
|
||||||
|
The output uses this shape:
|
||||||
|
|
||||||
|
- `morning_readiness`
|
||||||
|
- `commute_school_workday_concerns`
|
||||||
|
- `overnight_change_watch`
|
||||||
|
|
||||||
|
The type is `briefing.DailyPlanningModule`; it is independent from
|
||||||
|
`briefing.TomorrowPlanningModule`.
|
||||||
|
|
||||||
|
## Today Planning
|
||||||
|
|
||||||
|
`today_planning` emits current-day planning facts for Today Report. Its output
|
||||||
|
stanza is also named `today_planning`. The module is supported only by Today
|
||||||
|
Report and depends on daily and daypart summaries for the current local civil
|
||||||
|
day.
|
||||||
|
|
||||||
|
The output uses this shape:
|
||||||
|
|
||||||
|
- `morning_readiness`
|
||||||
|
- `commute_school_workday_concerns`
|
||||||
|
- `outdoor_planning`
|
||||||
|
- `late_day_change_watch`
|
||||||
|
|
||||||
|
The type is `briefing.TodayPlanningModule`; it is independent from
|
||||||
|
`briefing.TomorrowPlanningModule`.
|
||||||
|
|
||||||
|
## Hourly Composition
|
||||||
|
|
||||||
|
The default Hourly Report module order is:
|
||||||
|
|
||||||
|
1. `metadata`
|
||||||
|
2. `current_conditions`
|
||||||
|
3. `hourly_forecast`
|
||||||
|
4. `precip_timing`
|
||||||
|
5. `alert_digest`
|
||||||
|
6. `spc_convective_outlooks`
|
||||||
|
7. `area_forecast_discussion`
|
||||||
|
8. `spc_convective_discussion`
|
||||||
|
9. `weather_story`
|
||||||
|
|
||||||
|
Hourly Report does not include daily or daypart summary modules by default.
|
||||||
|
Its `area_forecast_discussion` item is configured to include only
|
||||||
|
`key_messages` and `short_term`.
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
Most modules use an empty options struct. `area_forecast_discussion` accepts:
|
Most modules use an empty options struct, including
|
||||||
|
`spc_convective_outlooks` and `spc_convective_discussion`.
|
||||||
|
`area_forecast_discussion` accepts:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
sections:
|
sections:
|
||||||
@@ -61,6 +214,31 @@ An omitted or empty `sections` list includes all available discussion sections.
|
|||||||
Invalid option shapes fail during config normalization or composition
|
Invalid option shapes fail during config normalization or composition
|
||||||
validation.
|
validation.
|
||||||
|
|
||||||
|
## SPC Convective Module Outputs
|
||||||
|
|
||||||
|
`spc_convective_outlooks` emits a risk-product stanza with:
|
||||||
|
|
||||||
|
- `checked`
|
||||||
|
- `as_of`
|
||||||
|
- `issued_at`
|
||||||
|
- `location_id`
|
||||||
|
- `location_name`
|
||||||
|
- `outlook_count`
|
||||||
|
- `outlooks`
|
||||||
|
|
||||||
|
Each outlook entry may include `day`, `outlook_type`, `label`, `label_text`,
|
||||||
|
`period_begins`, `period_ends`, `issued_at`, `contains_location`, and
|
||||||
|
`image_url`. It omits GeoJSON geometry, source URL, expiration time, and
|
||||||
|
severity rank.
|
||||||
|
|
||||||
|
`spc_convective_discussion` emits a narrative stanza only when a retained
|
||||||
|
report-period categorical outlook has severity rank `3` or higher and matching
|
||||||
|
discussion text is available. Its output includes `included_because` and
|
||||||
|
`discussions`; each discussion may include `day`, `period_begins`,
|
||||||
|
`period_ends`, `headline`, `summary`, `discussion`, and `updated_at`.
|
||||||
|
Discussions are included only for SPC days whose retained categorical outlooks
|
||||||
|
meet the severity threshold.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
- This package owns module identifiers, config item envelopes, output
|
- This package owns module identifiers, config item envelopes, output
|
||||||
@@ -73,7 +251,8 @@ validation.
|
|||||||
`module.Snapshot` values are persisted by `internal/state` as JSON. Snapshot
|
`module.Snapshot` values are persisted by `internal/state` as JSON. Snapshot
|
||||||
validation rejects missing schema version, missing module IDs, missing stanza
|
validation rejects missing schema version, missing module IDs, missing stanza
|
||||||
names, duplicate module outputs, and duplicate stanza names while preserving
|
names, duplicate module outputs, and duplicate stanza names while preserving
|
||||||
output order.
|
output order. Snapshot JSON contains rich module values only; runtime prompt
|
||||||
|
export values are not persisted.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
@@ -94,5 +273,8 @@ Inspect:
|
|||||||
|
|
||||||
- `internal/module` does not import `internal/report`.
|
- `internal/module` does not import `internal/report`.
|
||||||
- Module IDs are stable strings.
|
- Module IDs are stable strings.
|
||||||
- Each emitted module output has exactly one stanza name and one typed value.
|
- Each emitted module output has exactly one stanza name and one rich typed
|
||||||
|
value.
|
||||||
|
- Built module outputs have a data-package value, either from a custom prompt
|
||||||
|
exporter or from default pass-through behavior.
|
||||||
- Snapshot output order is caller-owned and preserved.
|
- Snapshot output order is caller-owned and preserved.
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ This document describes YAML prompt data package construction in
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/promptinput` converts report metadata, an ordered module snapshot,
|
`internal/promptinput` converts report metadata, ordered module outputs, Recent
|
||||||
Recent Changes, and source warnings into the `data_package` file passed to
|
Changes, and source warnings into the `data_package` file passed to
|
||||||
Scriptorium.
|
Scriptorium.
|
||||||
|
|
||||||
The persisted data package is YAML with schema version
|
The persisted data package is YAML with schema version
|
||||||
`weatherreporter.data_package.v2`. It is separate from the JSON module snapshot
|
`weatherreporter.data_package.v3`. It is separate from the JSON module snapshot
|
||||||
used for inspection and comparison.
|
used for inspection and comparison. Data packages serialize each module
|
||||||
|
output's prompt export value, not necessarily the full rich module value saved
|
||||||
|
in the module snapshot.
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
@@ -32,7 +34,7 @@ Outputs:
|
|||||||
The YAML shape includes:
|
The YAML shape includes:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
schema_version: weatherreporter.data_package.v2
|
schema_version: weatherreporter.data_package.v3
|
||||||
run_id: <run_id>
|
run_id: <run_id>
|
||||||
report:
|
report:
|
||||||
id: <report_id>
|
id: <report_id>
|
||||||
@@ -41,6 +43,7 @@ briefing:
|
|||||||
metadata: {}
|
metadata: {}
|
||||||
applicable_risk_products:
|
applicable_risk_products:
|
||||||
alert_digest: {}
|
alert_digest: {}
|
||||||
|
spc_convective_outlooks: {}
|
||||||
derived_summaries:
|
derived_summaries:
|
||||||
derived_daily_summary: {}
|
derived_daily_summary: {}
|
||||||
derived_daypart_summaries: {}
|
derived_daypart_summaries: {}
|
||||||
@@ -49,6 +52,7 @@ briefing:
|
|||||||
narrative_products:
|
narrative_products:
|
||||||
narrative_forecast: {}
|
narrative_forecast: {}
|
||||||
area_forecast_discussion: {}
|
area_forecast_discussion: {}
|
||||||
|
spc_convective_discussion: {}
|
||||||
weather_story: {}
|
weather_story: {}
|
||||||
raw_data:
|
raw_data:
|
||||||
current_conditions: {}
|
current_conditions: {}
|
||||||
@@ -62,13 +66,62 @@ weather module stanzas under prompt-facing categories. This grouping is a YAML
|
|||||||
presentation concern only: module snapshots remain flat, and loaded
|
presentation concern only: module snapshots remain flat, and loaded
|
||||||
`promptinput.Package` values expose flat stanza names in `Briefing.Values`.
|
`promptinput.Package` values expose flat stanza names in `Briefing.Values`.
|
||||||
Within each category, stanza order follows the module snapshot output order.
|
Within each category, stanza order follows the module snapshot output order.
|
||||||
|
Prompt-facing module intervals use local `period_begins` and `period_ends`
|
||||||
|
labels; canonical report metadata and source timestamps remain structured
|
||||||
|
timestamps where applicable.
|
||||||
|
|
||||||
|
## Module Export Boundary
|
||||||
|
|
||||||
|
Data packages are curated prompt inputs. They are not full template render
|
||||||
|
contexts and should not be treated as a dump of every field available to Go
|
||||||
|
templates.
|
||||||
|
|
||||||
|
When module outputs are built by `internal/briefing`, the registry attaches a
|
||||||
|
runtime prompt export value. `internal/promptinput` serializes
|
||||||
|
`output.DataPackageValue()` for each stanza. That helper prefers the runtime
|
||||||
|
prompt export and falls back to the rich `Value` when no prompt export is set,
|
||||||
|
which keeps loaded snapshots and hand-built tests usable.
|
||||||
|
|
||||||
|
Modules without custom export policy use pass-through behavior. Modules with
|
||||||
|
custom exports currently include:
|
||||||
|
|
||||||
|
- `current_conditions`: omits lower-case condition text and duplicate
|
||||||
|
wind-direction text.
|
||||||
|
- `hourly_forecast`: omits hour labels, lower-case description text, and the
|
||||||
|
template precipitation-mention helper while keeping forecast facts.
|
||||||
|
- `derived_daypart_summaries`: omits deterministic sentence-construction
|
||||||
|
helpers while keeping daypart period, condition, temperature trend,
|
||||||
|
precipitation, wind, notable-condition, hazard, and alert-relevance facts.
|
||||||
|
|
||||||
|
The rich module snapshot and generated-template render context still contain
|
||||||
|
the helper fields used by deterministic Markdown templates.
|
||||||
|
|
||||||
|
Daily Report, Today Report, Tomorrow Report, and Hourly Report module snapshots
|
||||||
|
use the same package schema and categories when converted into prompt input.
|
||||||
|
The default hourly module list places
|
||||||
|
`precip_timing` under `derived_summaries`, alert and SPC outlooks under
|
||||||
|
`applicable_risk_products`, AFD/SPC discussion/weather story under
|
||||||
|
`narrative_products`, and current/hourly data under `raw_data`. It does not
|
||||||
|
include civil-day summary stanzas. Generated-text and render context artifacts
|
||||||
|
are produced later in app orchestration and are not part of the YAML data
|
||||||
|
package.
|
||||||
|
|
||||||
|
The default Daily, Today, and Tomorrow module lists include civil-day summary
|
||||||
|
stanzas, planning stanzas, and `hourly_forecast` in the data package before
|
||||||
|
structured GeneratedText is requested from Scriptorium. Daily uses
|
||||||
|
`daily_planning`, Today uses `today_planning`, and Tomorrow uses
|
||||||
|
`tomorrow_planning`.
|
||||||
|
|
||||||
Current categories are:
|
Current categories are:
|
||||||
|
|
||||||
- `applicable_risk_products`: location-applicable alerts, warnings, outlooks,
|
- `applicable_risk_products`: location-applicable alerts, warnings, outlooks,
|
||||||
discussions, and similar risk products.
|
and similar risk products. Current stanzas include `alert_digest` and
|
||||||
|
`spc_convective_outlooks`.
|
||||||
- `derived_summaries`: deterministic summaries and calculated report facts.
|
- `derived_summaries`: deterministic summaries and calculated report facts.
|
||||||
- `narrative_products`: official narrative text products and forecast stories.
|
- `narrative_products`: official narrative text products and forecast stories.
|
||||||
|
Current stanzas include `narrative_forecast`,
|
||||||
|
`area_forecast_discussion`, `spc_convective_discussion`, and
|
||||||
|
`weather_story`.
|
||||||
- `raw_data`: minimally transformed underlying weather data.
|
- `raw_data`: minimally transformed underlying weather data.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
@@ -76,8 +129,8 @@ 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 fetch weather data, derive forecast summaries, execute modules,
|
||||||
find prior snapshots, compare changes, choose artifact paths, or invoke
|
choose module prompt export shapes, find prior snapshots, compare changes,
|
||||||
Scriptorium.
|
choose artifact paths, or invoke Scriptorium.
|
||||||
|
|
||||||
## Config Fields Used
|
## Config Fields Used
|
||||||
|
|
||||||
@@ -117,5 +170,8 @@ Inspect:
|
|||||||
- Scriptorium receives structured YAML through `--input data_package=<path>`.
|
- Scriptorium receives structured YAML through `--input data_package=<path>`.
|
||||||
- Module stanza order is deterministic within each prompt-facing category.
|
- Module stanza order is deterministic within each prompt-facing category.
|
||||||
- Every non-metadata module stanza has exactly one prompt-input category.
|
- Every non-metadata module stanza has exactly one prompt-input category.
|
||||||
|
- Data-package stanzas use curated module prompt exports when present and rich
|
||||||
|
values only as pass-through or fallback values.
|
||||||
|
- Data packages are narrower than generated-template render contexts.
|
||||||
- Recent Changes are provided by `internal/changes`; this package does not
|
- Recent Changes are provided by `internal/changes`; this package does not
|
||||||
infer changes from rendered report text.
|
infer changes from rendered report text.
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ membership, output naming, artifact grouping, and comparison declarations in
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/report` is the canonical source for report definitions. App, state,
|
`internal/report` is the canonical source for report definitions, public
|
||||||
module building, and CLI wiring consume resolved definitions instead of owning
|
command names, config-key aliases, and batch command names. App, config, state,
|
||||||
report identity policy themselves.
|
module building, and CLI wiring consume report-owned helpers and resolved
|
||||||
|
definitions instead of owning report identity policy themselves.
|
||||||
|
|
||||||
## Definition Fields
|
## Definition Fields
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ Each report definition declares:
|
|||||||
|
|
||||||
- report ID and display name
|
- report ID and display name
|
||||||
- Scriptorium prompt ID
|
- Scriptorium prompt ID
|
||||||
|
- generation mode
|
||||||
- valid-period resolver
|
- valid-period resolver
|
||||||
- comparison strategy
|
- comparison strategy
|
||||||
- managed artifact group
|
- managed artifact group
|
||||||
@@ -25,23 +27,46 @@ Each report definition declares:
|
|||||||
- morning or evening batch membership
|
- morning or evening batch membership
|
||||||
- default ordered module composition
|
- default ordered module composition
|
||||||
|
|
||||||
|
Report-owned helpers map public command names and config keys to report IDs.
|
||||||
|
The generate command names are `daily`, `today`, `tomorrow`, `hourly`,
|
||||||
|
`three-day`, `weekend`, and `storm`. Config keys also accept selected
|
||||||
|
underscore and descriptive aliases such as `three_day_outlook`,
|
||||||
|
`weekend_outlook`, and `storm_report`.
|
||||||
|
|
||||||
|
`daily` resolves to the dated Daily Report ID `daily`. `today` resolves to the
|
||||||
|
independent Today report ID `today`. `reports.today` is not an alias for
|
||||||
|
`reports.daily`, and retired report keys are not supported.
|
||||||
|
|
||||||
|
Markdown report definitions use the `scriptorium_markdown` generation mode.
|
||||||
|
Their template and structured-text schema identifiers are empty. Daily Report,
|
||||||
|
Today Report, Tomorrow Report, and Hourly Report declare
|
||||||
|
`generated_text_template`; the app uses their template and schema identifiers
|
||||||
|
to validate generated text and render embedded Markdown templates.
|
||||||
|
|
||||||
## Reports
|
## Reports
|
||||||
|
|
||||||
| Report | ID | Prompt | Artifact group | Batch copy | Prior compatibility |
|
| Report | ID | Prompt | Generation mode | Artifact group | Batch copy | Prior compatibility |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
| Daily Today | `daily_today` | `weather.daily_report` | `daily` | `daily.md` | Daily Today, Daily Tomorrow |
|
| Daily Report | `daily` | `weather.daily_generated_text` | `generated_text_template` | `daily` | `daily.md` | Daily Report |
|
||||||
| Daily Tomorrow | `daily_tomorrow` | `weather.daily_report` | `daily` | `tomorrow.md` | Daily Today, Daily Tomorrow |
|
| Today Report | `today` | `weather.today_generated_text` | `generated_text_template` | `today` | `today.md` | Today Report |
|
||||||
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `three-day` | `three-day.md` | 3-Day Outlook |
|
| Tomorrow Report | `tomorrow` | `weather.tomorrow_generated_text` | `generated_text_template` | `tomorrow` | `tomorrow.md` | Tomorrow Report |
|
||||||
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `weekend` | `weekend.md` | Weekend Outlook |
|
| Hourly Report | `hourly` | `weather.hourly_generated_text` | `generated_text_template` | `hourly` | `hourly.md` | Hourly Report |
|
||||||
| Storm Report | `storm` | `weather.storm_report` | `storm` | `storm.md` | Storm Report |
|
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `scriptorium_markdown` | `three-day` | `three-day.md` | 3-Day Outlook |
|
||||||
|
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `scriptorium_markdown` | `weekend` | `weekend.md` | Weekend Outlook |
|
||||||
|
| Storm Report | `storm` | `weather.storm_report` | `scriptorium_markdown` | `storm` | `storm.md` | Storm Report |
|
||||||
|
|
||||||
All report definitions are eligible for generation.
|
All report definitions are eligible for generation.
|
||||||
|
|
||||||
## Valid Periods
|
## Valid Periods
|
||||||
|
|
||||||
- Daily Today covers the selected local civil day, or the current local civil
|
- Daily Report covers the selected local civil day and requires an explicit
|
||||||
|
date.
|
||||||
|
- Today Report covers the selected local civil day, or the current local civil
|
||||||
day when no date override is supplied.
|
day when no date override is supplied.
|
||||||
- Daily Tomorrow covers the next local civil day from generation time.
|
- Tomorrow Report covers the next local civil day from generation time.
|
||||||
|
- Hourly Report covers the half-open six-hour period from generation time in
|
||||||
|
the effective report timezone. The duration is an internal report constant,
|
||||||
|
not a configuration field.
|
||||||
- 3-Day Outlook covers the interval from generation time through local midnight
|
- 3-Day Outlook covers the interval from generation time through local midnight
|
||||||
three days later.
|
three days later.
|
||||||
- Weekend Outlook covers the upcoming weekend window and is not scheduled for
|
- Weekend Outlook covers the upcoming weekend window and is not scheduled for
|
||||||
@@ -54,17 +79,26 @@ must be after start time.
|
|||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
`internal/report` defines report metadata and time coverage. It does not fetch
|
`internal/report` defines report metadata, public report names, batch command
|
||||||
weather data, build module values, compare snapshot contents, write state,
|
names, and time coverage. It does not fetch weather data, build module values,
|
||||||
parse CLI flags, or invoke Scriptorium.
|
compare snapshot contents, write state, parse CLI flags, or invoke Scriptorium.
|
||||||
|
|
||||||
The CLI owns public command names. The app maps those command names to report
|
The CLI parses flags and command structure, then uses report-owned helpers for
|
||||||
IDs, then uses the registry for report policy.
|
report and batch command names. Config loading uses report-owned helpers for
|
||||||
|
report override keys.
|
||||||
|
|
||||||
## Config Fields Used
|
## Config Fields Used
|
||||||
|
|
||||||
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
|
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
|
||||||
output path copying uses batch output names from report definitions.
|
output path copying uses batch output names from report definitions. Report
|
||||||
|
module overrides can use short keys such as `daily`, `today`, `tomorrow`, and
|
||||||
|
`hourly`, or descriptive names such as `three_day_outlook`.
|
||||||
|
|
||||||
|
## Batch Membership
|
||||||
|
|
||||||
|
Morning batches include Today Report, 3-Day Outlook, and Weekend Outlook
|
||||||
|
except on Sunday. Evening batches include Tomorrow Report. Daily Report and
|
||||||
|
Hourly Report are not part of a scheduled batch.
|
||||||
|
|
||||||
## State And App Usage
|
## State And App Usage
|
||||||
|
|
||||||
@@ -92,7 +126,11 @@ Inspect:
|
|||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
- Report selection goes through the registry.
|
- Report selection goes through the registry.
|
||||||
- Daily Today and Daily Tomorrow both use `weather.daily_report`.
|
- Public command names, config-key aliases, and batch command names are owned
|
||||||
|
by `internal/report`.
|
||||||
|
- Direct Markdown reports have empty template and generated-text schema IDs.
|
||||||
|
- Generated-text-template reports declare prompt, template, and schema IDs in
|
||||||
|
their report definition.
|
||||||
- Valid periods are half-open intervals independent of rendered report text.
|
- Valid periods are half-open intervals independent of rendered report text.
|
||||||
- Artifact grouping, batch output filenames, generated-report eligibility,
|
- Artifact grouping, batch output filenames, generated-report eligibility,
|
||||||
default module composition, comparison compatibility, and comparison strategy
|
default module composition, comparison compatibility, and comparison strategy
|
||||||
|
|||||||
113
docs/internal/reporttemplate.md
Normal file
113
docs/internal/reporttemplate.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# Report Template Internals
|
||||||
|
|
||||||
|
This document describes embedded Markdown templates and GeneratedText schemas
|
||||||
|
in `internal/reporttemplate`.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
`internal/reporttemplate` owns repository-native report templates and companion
|
||||||
|
GeneratedText JSON schemas. The implemented template assets are Daily, Today,
|
||||||
|
Tomorrow, and Hourly.
|
||||||
|
|
||||||
|
The package embeds assets from:
|
||||||
|
|
||||||
|
- `internal/reporttemplate/templates/*.md.tmpl`
|
||||||
|
- `internal/reporttemplate/schemas/*.schema.json`
|
||||||
|
|
||||||
|
Generated-text prompt source files live under
|
||||||
|
`internal/reporttemplate/prompts/`. They are repository assets for prompt
|
||||||
|
registration, not embedded lookup APIs.
|
||||||
|
|
||||||
|
## Inputs And Outputs
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
|
||||||
|
- template ID from a report definition
|
||||||
|
- typed render context built by `internal/generatedtext`
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
|
||||||
|
- template source for inspection and tests
|
||||||
|
- GeneratedText schema bytes for prompt/schema configuration
|
||||||
|
- rendered Markdown bytes for app orchestration to persist
|
||||||
|
|
||||||
|
The implemented template IDs are `daily`, `today`, `tomorrow`, and `hourly`.
|
||||||
|
The implemented schema IDs are also `daily`, `today`, `tomorrow`, and
|
||||||
|
`hourly`, backed by matching `*.generated_text.schema.json` files.
|
||||||
|
|
||||||
|
Generated-text prompt sources are maintained under
|
||||||
|
`internal/reporttemplate/prompts/`, including Daily's
|
||||||
|
`daily.generated_text.md` source for prompt ID `weather.daily_generated_text`.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
This package owns embedded asset lookup, Go template parsing, and Markdown
|
||||||
|
template execution. It does not fetch weather data, build module outputs,
|
||||||
|
validate GeneratedText, construct render contexts, choose report definitions,
|
||||||
|
write artifacts, invoke Scriptorium, or notify distributor.
|
||||||
|
|
||||||
|
GeneratedText validation is owned by `internal/generatedtext`. App
|
||||||
|
orchestration uses `internal/generatedtext` catalog lookup to connect
|
||||||
|
`internal/report` definition schema/template IDs to the matching validator,
|
||||||
|
render-context builder, and embedded assets.
|
||||||
|
|
||||||
|
## Template Contracts
|
||||||
|
|
||||||
|
Daily, Today, Tomorrow, and Hourly rendering use typed render contexts with:
|
||||||
|
|
||||||
|
- report metadata labels such as title, location, valid period, and generation
|
||||||
|
time
|
||||||
|
- validated GeneratedText prose slots
|
||||||
|
- deterministic labels derived from module outputs, including current
|
||||||
|
conditions, hourly forecast rows, precipitation timing, alerts, SPC outlooks,
|
||||||
|
forecast discussion, SPC discussion, and weather story
|
||||||
|
|
||||||
|
Daily, Today, and Tomorrow additionally expose forecast-date labels, ordered
|
||||||
|
daypart forecast rows, daily/daypart summaries, planning facts, and a
|
||||||
|
multi-paragraph forecast discussion generated-text slot. The ordered daypart
|
||||||
|
slice is built in Go so templates do not range over maps.
|
||||||
|
|
||||||
|
The Daily template asset uses the same Markdown structure as Tomorrow's
|
||||||
|
template and renders from `generatedtext.DailyRenderContext`.
|
||||||
|
|
||||||
|
Templates use `text/template` with `missingkey=error`, so missing context fields
|
||||||
|
fail rendering instead of producing incomplete Markdown.
|
||||||
|
|
||||||
|
## Schema Contract
|
||||||
|
|
||||||
|
The GeneratedText schemas describe the structured prose Scriptorium is expected
|
||||||
|
to write for each generated-text prompt. Hourly requires:
|
||||||
|
|
||||||
|
- `summary`
|
||||||
|
- `forecast_discussion`
|
||||||
|
|
||||||
|
Daily, Today, and Tomorrow require `summary` and a nonempty
|
||||||
|
`forecast_discussion` array. All generated-text schemas allow optional
|
||||||
|
`precipitation_timing` and `confidence`, and reject additional properties.
|
||||||
|
Weather truth remains in module outputs; GeneratedText is limited to prose
|
||||||
|
slots consumed by the template.
|
||||||
|
|
||||||
|
## Failure Behavior
|
||||||
|
|
||||||
|
- Unknown template IDs return actionable lookup errors.
|
||||||
|
- Unknown schema IDs return actionable lookup errors.
|
||||||
|
- Template parse errors include the template ID.
|
||||||
|
- Template execution errors include the template ID and usually identify the
|
||||||
|
missing context field.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Inspect:
|
||||||
|
|
||||||
|
- `internal/reporttemplate/reporttemplate_test.go`
|
||||||
|
- `internal/generatedtext/render_context_test.go`
|
||||||
|
- `internal/app/app_test.go`
|
||||||
|
- `internal/cli/root_test.go`
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
- Embedded templates and schemas live as separate files, not inline Go strings.
|
||||||
|
- Report definitions select templates by ID.
|
||||||
|
- Templates render from curated render contexts, not raw data packages.
|
||||||
|
- GeneratedText schemas describe LLM prose slots, not deterministic weather
|
||||||
|
facts.
|
||||||
@@ -6,9 +6,9 @@ This document describes the subprocess adapter in
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
The adapter runs `scriptorium render` for prompt preflight and `scriptorium run`
|
The adapter runs `scriptorium render` for prompt preflight and `scriptorium run`
|
||||||
for Markdown report generation. It isolates subprocess execution, argv
|
for Markdown report generation or structured generated-text output. It isolates
|
||||||
construction, timeout handling, output capture, and exit-code interpretation
|
subprocess execution, argv construction, timeout handling, output capture, and
|
||||||
from app and domain packages.
|
exit-code interpretation from app and domain packages.
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ Inputs:
|
|||||||
- prompt ID
|
- prompt ID
|
||||||
- YAML prompt input data package path
|
- YAML prompt input data package path
|
||||||
- report output path for `run`
|
- report output path for `run`
|
||||||
|
- raw generated-text output path for structured `run`
|
||||||
- configured binary, config path, profile, timeout, and extra arguments
|
- configured binary, config path, profile, timeout, and extra arguments
|
||||||
- context for cancellation
|
- context for cancellation
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ Outputs:
|
|||||||
- truncation flags for captured output
|
- truncation flags for captured output
|
||||||
- exit code
|
- exit code
|
||||||
- report output path for `run`
|
- report output path for `run`
|
||||||
|
- raw generated-text output path for structured `run`
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
@@ -34,9 +36,9 @@ Outputs:
|
|||||||
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.
|
fetch weather data, decide workflow order, or persist workflow metadata.
|
||||||
|
|
||||||
The adapter exposes request and result structs for render and run operations.
|
The adapter exposes request and result structs for render, Markdown run, and
|
||||||
State persistence uses a state-owned preflight artifact shape; app
|
structured generated-text run operations. State persistence uses state-owned
|
||||||
orchestration converts render results before saving.
|
artifact shapes; app orchestration converts adapter results before saving.
|
||||||
|
|
||||||
## Config Fields Used
|
## Config Fields Used
|
||||||
|
|
||||||
@@ -60,6 +62,12 @@ Report generation argv starts with:
|
|||||||
scriptorium run --prompt <prompt_id> --input data_package=<path> --out <path>
|
scriptorium run --prompt <prompt_id> --input data_package=<path> --out <path>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Structured generated-text argv uses the same `scriptorium run` form, with the
|
||||||
|
`--out` value set to the raw generated-text JSON artifact path. The adapter
|
||||||
|
does not add `--format`, schema path, or JSON Schema flags for structured
|
||||||
|
generation; Scriptorium selects the structured output schema from prompt
|
||||||
|
configuration.
|
||||||
|
|
||||||
Configured `--config` and `--profile` flags are inserted after the subcommand
|
Configured `--config` and `--profile` flags are inserted after the subcommand
|
||||||
and before prompt-specific arguments. Extra arguments are appended after the
|
and before prompt-specific arguments. Extra arguments are appended after the
|
||||||
built-in arguments.
|
built-in arguments.
|
||||||
@@ -67,8 +75,8 @@ built-in arguments.
|
|||||||
## Execution Behavior
|
## Execution Behavior
|
||||||
|
|
||||||
The adapter runs commands without shell interpolation. The same private
|
The adapter runs commands without shell interpolation. The same private
|
||||||
execution path is used by render and run after command-specific request
|
execution path is used by render, Markdown run, and structured run after
|
||||||
validation and argv construction.
|
command-specific request validation and argv construction.
|
||||||
|
|
||||||
When `scriptorium.timeout` is greater than zero, each subprocess call uses a
|
When `scriptorium.timeout` is greater than zero, each subprocess call uses a
|
||||||
context with that timeout. Stdout and stderr are captured separately, capped at
|
context with that timeout. Stdout and stderr are captured separately, capped at
|
||||||
@@ -81,8 +89,8 @@ context with that timeout. Stdout and stderr are captured separately, capped at
|
|||||||
- Missing run output path returns an error before subprocess execution.
|
- Missing run output path returns an error before subprocess execution.
|
||||||
- Subprocess start errors, context cancellation, and timeouts are wrapped with
|
- Subprocess start errors, context cancellation, and timeouts are wrapped with
|
||||||
operation context by the caller-facing method.
|
operation context by the caller-facing method.
|
||||||
- Nonzero render and run exits return the captured result plus an error
|
- Nonzero render, Markdown run, and structured run exits return the captured
|
||||||
containing the exit code and stderr.
|
result plus an error containing the exit code and stderr.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@@ -97,5 +105,6 @@ Inspect:
|
|||||||
- No shell interpolation is used.
|
- No shell interpolation is used.
|
||||||
- The Scriptorium input name is `data_package`.
|
- The Scriptorium input name is `data_package`.
|
||||||
- The file at the data package path is YAML produced by `internal/promptinput`.
|
- The file at the data package path is YAML produced by `internal/promptinput`.
|
||||||
- Render and run preserve command-specific result structs.
|
- Render, Markdown run, and structured run preserve command-specific result
|
||||||
|
structs.
|
||||||
- Scriptorium-specific flags stay inside adapter and config boundaries.
|
- Scriptorium-specific flags stay inside adapter and config boundaries.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ Inputs:
|
|||||||
- module snapshot
|
- module snapshot
|
||||||
- prompt input data package
|
- prompt input data package
|
||||||
- preflight artifact
|
- preflight artifact
|
||||||
|
- generated-text raw, run-result, validated text, and render-context artifacts
|
||||||
- rendered report path preparation request
|
- rendered report path preparation request
|
||||||
- RunID for inspection lookups
|
- RunID for inspection lookups
|
||||||
|
|
||||||
@@ -24,10 +25,15 @@ Outputs:
|
|||||||
- module snapshot JSON path
|
- module snapshot JSON path
|
||||||
- prompt input data package YAML path
|
- prompt input data package YAML path
|
||||||
- render preflight JSON path
|
- render preflight JSON path
|
||||||
|
- generated-text raw JSON path
|
||||||
|
- generated-text run-result JSON path
|
||||||
|
- validated generated-text JSON path
|
||||||
|
- render context JSON path
|
||||||
- managed Markdown report path
|
- managed Markdown report path
|
||||||
- metadata JSON path
|
- metadata JSON path
|
||||||
- prior comparable snapshot metadata
|
- prior comparable snapshot metadata
|
||||||
- loaded module snapshot or data package
|
- loaded module snapshot, data package, generated text, generated-text run
|
||||||
|
result, or render context
|
||||||
- recent report records for inspection
|
- recent report records for inspection
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
@@ -61,6 +67,10 @@ valid-period start date for dated artifacts, and the RunID.
|
|||||||
<workspace.root>/
|
<workspace.root>/
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.modules.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.modules.json
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.metadata.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.metadata.json
|
||||||
|
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.raw.json
|
||||||
|
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.run.json
|
||||||
|
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.json
|
||||||
|
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.render_context.json
|
||||||
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.yaml
|
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.yaml
|
||||||
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json
|
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json
|
||||||
notifications/<artifact_group>/<YYYY-MM-DD>/<run_id>.distributor.json
|
notifications/<artifact_group>/<YYYY-MM-DD>/<run_id>.distributor.json
|
||||||
@@ -69,8 +79,11 @@ valid-period start date for dated artifacts, and the RunID.
|
|||||||
|
|
||||||
Metadata is stored beside module snapshots and links the module snapshot, data
|
Metadata is stored beside module snapshots and links the module snapshot, data
|
||||||
package, preflight, report paths, notification path when attempted, and
|
package, preflight, report paths, notification path when attempted, and
|
||||||
configured prompt location. Report listing walks metadata files under the
|
configured prompt location. For generated-text-template reports, metadata also
|
||||||
snapshots directory.
|
records the generated text schema ID and links the raw generated text,
|
||||||
|
Scriptorium run result, validated generated text, and render context artifacts.
|
||||||
|
Markdown-report metadata omits those generated-text fields. Report listing
|
||||||
|
walks metadata files under the snapshots directory.
|
||||||
|
|
||||||
## Prior Lookup
|
## Prior Lookup
|
||||||
|
|
||||||
@@ -78,28 +91,37 @@ Prior snapshot lookup reads stored metadata through the shared lookup path and
|
|||||||
selects the latest earlier snapshot whose report ID is compatible with the
|
selects the latest earlier snapshot whose report ID is compatible with the
|
||||||
current report definition.
|
current report definition.
|
||||||
|
|
||||||
- Daily Today and Daily Tomorrow are compatible with each other for the same
|
- Daily Report compares with prior Daily Report snapshots for the same valid
|
||||||
|
local date.
|
||||||
|
- Today Report compares with prior Today Report snapshots for the same valid
|
||||||
|
local date.
|
||||||
|
- Tomorrow Report compares with prior Tomorrow Report snapshots for the same
|
||||||
valid local date.
|
valid local date.
|
||||||
- 3-Day Outlook compares with prior 3-Day snapshots for the same valid local
|
- 3-Day Outlook compares with prior 3-Day snapshots for the same valid local
|
||||||
date.
|
date.
|
||||||
- Weekend Outlook compares with prior Weekend snapshots for the same weekend
|
- Weekend Outlook compares with prior Weekend snapshots for the same weekend
|
||||||
window.
|
window.
|
||||||
|
- Hourly Report uses the rolling-window comparison strategy and currently
|
||||||
|
returns no prior snapshot from filesystem lookup.
|
||||||
- Storm Report has no prior lookup because explicit event-window comparison is
|
- Storm Report has no prior lookup because explicit event-window comparison is
|
||||||
not searched by the filesystem store.
|
not searched by the filesystem store.
|
||||||
|
|
||||||
## Writes And Inspection
|
## Writes And Inspection
|
||||||
|
|
||||||
Durable JSON writes use shared atomic file helpers. Managed Markdown reports are
|
Durable JSON writes use shared atomic file helpers. Generated-text raw and
|
||||||
prepared by creating their parent directory; Scriptorium writes the report body
|
validated JSON artifacts are written atomically as bytes; generated-text run
|
||||||
to the prepared path. Extra Markdown copies are handled by app orchestration.
|
result and render context artifacts are written atomically as JSON. Managed
|
||||||
Distributor notification debug artifacts are written atomically when
|
Markdown reports are prepared by creating their parent directory; Scriptorium
|
||||||
notification is attempted and include rendered distributor pipeline ID, bundle
|
writes the report body to the prepared path. Extra Markdown copies are handled
|
||||||
ID, idempotency key, bundle paths, upload status, latest run status, and
|
by app orchestration. Distributor notification debug artifacts are written
|
||||||
redacted errors.
|
atomically when notification is attempted and include rendered distributor
|
||||||
|
pipeline ID, bundle ID, idempotency key, bundle paths, upload status, latest
|
||||||
|
run status, and redacted errors.
|
||||||
|
|
||||||
Inspection helpers read existing metadata, module snapshot, and data package
|
Inspection helpers read existing metadata, module snapshot, data package,
|
||||||
files. Missing metadata directories return no inspection records or no prior
|
generated text, generated-text run result, and render context files. Missing
|
||||||
snapshot rather than creating state.
|
metadata directories return no inspection records or no prior snapshot rather
|
||||||
|
than creating state.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
@@ -121,4 +143,6 @@ Inspect:
|
|||||||
- Managed paths stay under the configured workspace root.
|
- Managed paths stay under the configured workspace root.
|
||||||
- Artifact grouping comes from report definitions.
|
- Artifact grouping comes from report definitions.
|
||||||
- Metadata links artifacts produced for a run.
|
- Metadata links artifacts produced for a run.
|
||||||
|
- Generated-text artifacts live under the snapshots tree beside module
|
||||||
|
snapshots and metadata.
|
||||||
- Prior lookup is based on structured metadata, not rendered report text.
|
- Prior lookup is based on structured metadata, not rendered report text.
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ This document describes Weather API ingestion into `weatherdata.Bundle`.
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`internal/adapters/weatherapi` fetches normalized weather data from one
|
`internal/adapters/weatherapi` fetches normalized weather data from the
|
||||||
configured Weather API endpoint and assembles the bundle consumed by forecast
|
configured Weather API and assembles the bundle consumed by forecast derivation
|
||||||
derivation and module builders. Module builders expose normalized current
|
and module builders. Module builders expose normalized current conditions and
|
||||||
conditions and weather story context when those sources are available.
|
weather story context when those sources are available.
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
@@ -21,7 +21,8 @@ Outputs:
|
|||||||
|
|
||||||
- `weatherdata.Bundle` with observation, current conditions, hourly forecast,
|
- `weatherdata.Bundle` with observation, current conditions, hourly forecast,
|
||||||
narrative forecast, active alerts, discussion, latest weather story, source
|
narrative forecast, active alerts, discussion, latest weather story, source
|
||||||
records, and source warnings
|
records, source warnings, and typed SPC convective outlook data when that
|
||||||
|
optional source is available
|
||||||
- optional saved bundle JSON through app fetch helpers
|
- optional saved bundle JSON through app fetch helpers
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
@@ -54,9 +55,19 @@ contract used by this project.
|
|||||||
The adapter records source name, endpoint, query, fetch time, source timestamps
|
The adapter records source name, endpoint, query, fetch time, source timestamps
|
||||||
when available, SHA-256 hash over compact raw `data` JSON, missing status, and
|
when available, SHA-256 hash over compact raw `data` JSON, missing status, and
|
||||||
source warnings. Successful `data: null` responses from `/alerts/active`
|
source warnings. Successful `data: null` responses from `/alerts/active`
|
||||||
represent a checked empty active-alert list, not a missing source.
|
represent a checked empty active-alert list, not a missing source. Successful
|
||||||
|
non-null `/outlooks/convective` responses with empty outlook and discussion
|
||||||
|
arrays represent checked empty outlook data.
|
||||||
`app.FetchAndSaveBundle` can write bundle JSON atomically for inspection.
|
`app.FetchAndSaveBundle` can write bundle JSON atomically for inspection.
|
||||||
|
|
||||||
|
SPC convective outlook data is stored on
|
||||||
|
`weatherdata.Bundle.SPCConvectiveOutlooks`. The collected run keeps upstream
|
||||||
|
run metadata, location identifiers, ordered outlook records, discussion
|
||||||
|
records, and each outlook's raw GeoJSON geometry. Source provenance for this
|
||||||
|
payload uses the `spc_convective_outlooks` source name, endpoint
|
||||||
|
`/outlooks/convective`, the query sent by the adapter, timestamps, and a hash
|
||||||
|
of the raw `data` object.
|
||||||
|
|
||||||
## Skip And Resume Behavior
|
## Skip And Resume Behavior
|
||||||
|
|
||||||
No resume behavior. Optional missing or malformed sources may be omitted,
|
No resume behavior. Optional missing or malformed sources may be omitted,
|
||||||
@@ -72,6 +83,8 @@ data is required and cannot be skipped.
|
|||||||
- Optional sources follow missing-source policy.
|
- Optional sources follow missing-source policy.
|
||||||
- Explicit `data: null` from `/alerts/active` produces an empty, non-missing
|
- Explicit `data: null` from `/alerts/active` produces an empty, non-missing
|
||||||
alert run.
|
alert run.
|
||||||
|
- Explicit `data: null` from `/outlooks/convective` follows optional
|
||||||
|
missing-source policy.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
@@ -10,20 +10,34 @@ Generation commands:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
weatherreporter generate daily --date 2026-05-29
|
weatherreporter generate daily --date 2026-05-29
|
||||||
|
weatherreporter generate today
|
||||||
weatherreporter generate tomorrow
|
weatherreporter generate tomorrow
|
||||||
|
weatherreporter generate hourly
|
||||||
weatherreporter generate three-day
|
weatherreporter generate three-day
|
||||||
weatherreporter generate weekend
|
weatherreporter generate weekend
|
||||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
|
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
|
||||||
```
|
```
|
||||||
|
|
||||||
Each command resolves a report period, fetches a Weather API bundle, builds a
|
Generation commands resolve a report period, fetch a Weather API bundle, build
|
||||||
JSON module snapshot, builds a YAML prompt input data package, runs
|
a rich JSON module snapshot, build a curated YAML prompt input data package, run
|
||||||
`scriptorium render`, runs `scriptorium run`, and writes managed artifacts under
|
`scriptorium render`, and write managed artifacts under the configured
|
||||||
the configured workspace.
|
workspace. Markdown-path reports then run `scriptorium run` directly to the
|
||||||
|
managed Markdown report path.
|
||||||
|
|
||||||
|
`generate daily`, `generate today`, `generate tomorrow`, and `generate hourly`
|
||||||
|
use the generated-text-template workflow. They run structured `scriptorium run`
|
||||||
|
to raw GeneratedText JSON, validate the structured text, save a render context,
|
||||||
|
and render the managed Markdown report from embedded templates. `generate
|
||||||
|
daily` requires `--date YYYY-MM-DD` for the selected local civil day.
|
||||||
|
`generate today` covers the selected or current local civil day. `generate
|
||||||
|
hourly` covers the six-hour rolling period from generation time in the
|
||||||
|
effective report timezone and is not part of scheduled morning or evening
|
||||||
|
batches.
|
||||||
|
|
||||||
When distributor notification is enabled, weatherreporter uploads the managed
|
When distributor notification is enabled, weatherreporter uploads the managed
|
||||||
Markdown report after `scriptorium run` succeeds and final metadata is saved.
|
Markdown report after report rendering succeeds and final metadata is saved.
|
||||||
`--out PATH` writes an extra Markdown copy for the current generated report; it
|
`--out PATH` writes an extra Markdown copy for generated reports; it is not used
|
||||||
is not used as the distributor upload source.
|
as the distributor upload source.
|
||||||
|
|
||||||
Batch commands:
|
Batch commands:
|
||||||
|
|
||||||
@@ -32,14 +46,14 @@ weatherreporter run morning
|
|||||||
weatherreporter run evening
|
weatherreporter run evening
|
||||||
```
|
```
|
||||||
|
|
||||||
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
`run morning` generates Today Report and the 3-Day Outlook, plus Weekend Outlook
|
||||||
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch
|
except on Sunday. `run evening` generates the Tomorrow Report. Batch
|
||||||
commands print a JSON summary to stdout, write compact per-report status lines
|
commands print a JSON summary to stdout, write compact per-report status lines
|
||||||
to stderr, continue independent reports after one report fails, and return
|
to stderr, continue independent reports after one report fails, and return
|
||||||
nonzero when any report failed. When notification is configured, the summary and
|
nonzero when any report failed. When notification is configured, the summary and
|
||||||
status lines include notification status, accepted distributor run ID, or
|
status lines include notification status, accepted distributor run ID, or
|
||||||
notification error fields for each attempted report. `--out-dir PATH` writes
|
notification error fields for each attempted report. `--out-dir PATH` writes
|
||||||
extra Markdown copies using report default filenames such as `daily.md`,
|
extra Markdown copies using report default filenames such as `today.md`,
|
||||||
`three-day.md`, `weekend.md`, and `tomorrow.md`; these copies are not used as
|
`three-day.md`, `weekend.md`, and `tomorrow.md`; these copies are not used as
|
||||||
distributor upload sources.
|
distributor upload sources.
|
||||||
|
|
||||||
@@ -54,6 +68,18 @@ workspace/
|
|||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.modules.json
|
<run_id>.modules.json
|
||||||
<run_id>.metadata.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
|
||||||
|
today/
|
||||||
|
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
|
||||||
three-day/
|
three-day/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.modules.json
|
<run_id>.modules.json
|
||||||
@@ -62,6 +88,22 @@ workspace/
|
|||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.modules.json
|
<run_id>.modules.json
|
||||||
<run_id>.metadata.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/
|
storm/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.modules.json
|
<run_id>.modules.json
|
||||||
@@ -70,12 +112,21 @@ workspace/
|
|||||||
daily/
|
daily/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.data_package.yaml
|
<run_id>.data_package.yaml
|
||||||
|
today/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.data_package.yaml
|
||||||
three-day/
|
three-day/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.data_package.yaml
|
<run_id>.data_package.yaml
|
||||||
weekend/
|
weekend/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.data_package.yaml
|
<run_id>.data_package.yaml
|
||||||
|
hourly/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.data_package.yaml
|
||||||
|
tomorrow/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.data_package.yaml
|
||||||
storm/
|
storm/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.data_package.yaml
|
<run_id>.data_package.yaml
|
||||||
@@ -83,12 +134,21 @@ workspace/
|
|||||||
daily/
|
daily/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.render.json
|
<run_id>.render.json
|
||||||
|
today/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.render.json
|
||||||
three-day/
|
three-day/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.render.json
|
<run_id>.render.json
|
||||||
weekend/
|
weekend/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.render.json
|
<run_id>.render.json
|
||||||
|
hourly/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.render.json
|
||||||
|
tomorrow/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.render.json
|
||||||
storm/
|
storm/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.render.json
|
<run_id>.render.json
|
||||||
@@ -96,22 +156,37 @@ workspace/
|
|||||||
daily/
|
daily/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.distributor.json
|
<run_id>.distributor.json
|
||||||
|
today/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.distributor.json
|
||||||
three-day/
|
three-day/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.distributor.json
|
<run_id>.distributor.json
|
||||||
weekend/
|
weekend/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.distributor.json
|
<run_id>.distributor.json
|
||||||
|
hourly/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.distributor.json
|
||||||
|
tomorrow/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.distributor.json
|
||||||
storm/
|
storm/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.distributor.json
|
<run_id>.distributor.json
|
||||||
reports/
|
reports/
|
||||||
daily/
|
daily/
|
||||||
<run_id>.md
|
<run_id>.md
|
||||||
|
today/
|
||||||
|
<run_id>.md
|
||||||
three-day/
|
three-day/
|
||||||
<run_id>.md
|
<run_id>.md
|
||||||
weekend/
|
weekend/
|
||||||
<run_id>.md
|
<run_id>.md
|
||||||
|
hourly/
|
||||||
|
<run_id>.md
|
||||||
|
tomorrow/
|
||||||
|
<run_id>.md
|
||||||
storm/
|
storm/
|
||||||
<run_id>.md
|
<run_id>.md
|
||||||
```
|
```
|
||||||
@@ -124,7 +199,8 @@ period do not overwrite each other.
|
|||||||
RunIDs are based on generation time plus report ID:
|
RunIDs are based on generation time plus report ID:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
20260529T100000.123456789Z_daily_today
|
20260529T100000.123456789Z_daily
|
||||||
|
20260529T100000.123456789Z_today
|
||||||
```
|
```
|
||||||
|
|
||||||
Each generated report writes metadata that links:
|
Each generated report writes metadata that links:
|
||||||
@@ -136,6 +212,8 @@ Each generated report writes metadata that links:
|
|||||||
- prompt input data package path
|
- prompt input data package path
|
||||||
- preflight output path
|
- preflight output path
|
||||||
- managed Markdown report path
|
- managed Markdown report path
|
||||||
|
- generated text schema ID and generated-text artifact paths for
|
||||||
|
generated-text-template reports
|
||||||
- distributor notification debug artifact path, when notification is attempted
|
- distributor notification debug artifact path, when notification is attempted
|
||||||
|
|
||||||
Batch summaries include report status, error text when applicable, notification
|
Batch summaries include report status, error text when applicable, notification
|
||||||
@@ -164,9 +242,14 @@ The default idempotency key appends RunID to the rendered bundle ID so each
|
|||||||
report generation has a distinct retry identity. The default bundle path uses
|
report generation has a distinct retry identity. The default bundle path uses
|
||||||
the valid-period start date, artifact group, and RunID. Distributor owns
|
the valid-period start date, artifact group, and RunID. Distributor owns
|
||||||
destination merge, retention, and derived snapshot behavior such as `latest`.
|
destination merge, retention, and derived snapshot behavior such as `latest`.
|
||||||
|
For Daily, the default report ID and artifact group values are both `daily`,
|
||||||
|
and the default output filename value is `daily.md`.
|
||||||
|
For Today, the default report ID and artifact group values are both `today`,
|
||||||
|
and the batch output filename value is `today.md`.
|
||||||
|
|
||||||
Notification happens after final metadata save. Weather API, module snapshot,
|
Notification happens after final metadata save for generated reports. Weather
|
||||||
data-package, render preflight, Scriptorium run, and metadata-save failures do
|
API, module snapshot, data-package, render preflight, Scriptorium run,
|
||||||
|
generated-text validation, template rendering, and metadata-save failures do
|
||||||
not trigger notification. A notification failure fails that report.
|
not trigger notification. A notification failure fails that report.
|
||||||
In a batch, other reports continue, the failed report includes notification
|
In a batch, other reports continue, the failed report includes notification
|
||||||
fields in the JSON summary, and the batch returns nonzero.
|
fields in the JSON summary, and the batch returns nonzero.
|
||||||
@@ -204,22 +287,25 @@ weatherreporter inspect sources RUN_ID
|
|||||||
|
|
||||||
Use `inspect reports` to find RunIDs and artifact paths. Use
|
Use `inspect reports` to find RunIDs and artifact paths. Use
|
||||||
`inspect metadata` to see the artifact links recorded for a run. Use
|
`inspect metadata` to see the artifact links recorded for a run. Use
|
||||||
`inspect modules` to review the persisted ordered module snapshot, and
|
`inspect modules` to review the persisted ordered module snapshot with rich
|
||||||
`inspect data-package` to review the structured prompt package used for
|
template-facing values, and `inspect data-package` to review the curated prompt
|
||||||
rendering. Use `inspect prior` to see the prior comparable snapshot selected for
|
package passed to Scriptorium. Use `inspect prior` to see the prior comparable
|
||||||
Recent Changes, or `null` when none exists. Use `inspect sources` to review
|
snapshot selected for Recent Changes, or `null` when none exists. Use
|
||||||
source provenance and warnings without dumping full weather payloads.
|
`inspect sources` to review source provenance and warnings without dumping full
|
||||||
|
weather payloads.
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
|
||||||
Recent Changes are computed from structured module snapshots, not rendered
|
Recent Changes are computed from structured module snapshots, not rendered
|
||||||
Markdown or YAML text.
|
Markdown or YAML text.
|
||||||
|
|
||||||
Daily Today and Daily Tomorrow can compare with each other when they cover the
|
Daily Report compares with prior Daily Report snapshots for the same valid
|
||||||
same valid local date. 3-Day Outlook compares with prior compatible 3-Day
|
local date. Today Report compares with prior Today Report snapshots for the
|
||||||
snapshots for the same valid local date. Weekend Outlook compares with prior
|
same valid local date. Tomorrow Report compares with prior Tomorrow Report
|
||||||
compatible Weekend snapshots for the same weekend window. Storm Report leaves
|
snapshots for the same valid local date. 3-Day Outlook compares with prior
|
||||||
Recent Changes empty.
|
compatible 3-Day snapshots for the same valid local date. Weekend Outlook
|
||||||
|
compares with prior compatible Weekend snapshots for the same weekend window.
|
||||||
|
Hourly Report and Storm Report leave Recent Changes empty.
|
||||||
|
|
||||||
When no prior comparable snapshot exists, or no configured threshold is crossed,
|
When no prior comparable snapshot exists, or no configured threshold is crossed,
|
||||||
`recentChanges.items` is empty.
|
`recentChanges.items` is empty.
|
||||||
@@ -232,6 +318,10 @@ A failed generation run may still leave useful artifacts:
|
|||||||
preflight JSON and metadata are written for inspection.
|
preflight JSON and metadata are written for inspection.
|
||||||
- If `scriptorium run` exits nonzero after writing a report, the managed report
|
- If `scriptorium run` exits nonzero after writing a report, the managed report
|
||||||
and metadata remain available.
|
and metadata remain available.
|
||||||
|
- Generated-text failures for Daily, Today, Tomorrow, and Hourly reports preserve
|
||||||
|
available intermediate artifacts, such as the structured run result, raw
|
||||||
|
generated-text JSON, validated generated text, and render context. Metadata
|
||||||
|
links those paths when it can be safely written.
|
||||||
- If distributor notification fails, report artifacts and final metadata remain
|
- If distributor notification fails, report artifacts and final metadata remain
|
||||||
available, but the report or batch command returns nonzero.
|
available, but the report or 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
|
||||||
|
|||||||
348
docs/roadmap/daily.md
Normal file
348
docs/roadmap/daily.md
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
# 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.
|
||||||
361
docs/roadmap/data-package-exports.md
Normal file
361
docs/roadmap/data-package-exports.md
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
# 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.
|
||||||
@@ -5,18 +5,22 @@ documented outside `docs/roadmap/`.
|
|||||||
|
|
||||||
## Automatic Storm Monitoring
|
## Automatic Storm Monitoring
|
||||||
|
|
||||||
Manual Storm Report generation is implemented through
|
Manual Storm Report generation is available through:
|
||||||
`weatherreporter generate storm --start TIME --end TIME`. Automatic storm-event
|
|
||||||
evaluation remains deferred.
|
|
||||||
|
|
||||||
Proposed direction:
|
```sh
|
||||||
|
weatherreporter generate storm --start TIME --end TIME
|
||||||
|
```
|
||||||
|
|
||||||
1. detect candidate events deterministically from alerts, forecast discussion,
|
Automatic storm-event evaluation is not implemented.
|
||||||
weather story context, hourly thresholds, and material forecast changes;
|
|
||||||
2. evaluate candidates through Scriptorium or another narrow evaluator adapter;
|
Possible direction:
|
||||||
3. persist storm lifecycle state;
|
|
||||||
4. generate or update Storm Reports only when a meaningful event is present;
|
1. Detect candidate storm events from alerts, forecast discussion, weather
|
||||||
5. suppress ordinary low-impact thunder or rain chances.
|
story context, hourly thresholds, and material forecast changes.
|
||||||
|
2. Evaluate candidates through Scriptorium or another narrow evaluator adapter.
|
||||||
|
3. Persist storm lifecycle state.
|
||||||
|
4. Generate or update Storm Reports only when a meaningful event is present.
|
||||||
|
5. Suppress ordinary low-impact thunder or rain chances.
|
||||||
|
|
||||||
Possible lifecycle states:
|
Possible lifecycle states:
|
||||||
|
|
||||||
@@ -27,40 +31,41 @@ Possible lifecycle states:
|
|||||||
- `deescalating`
|
- `deescalating`
|
||||||
- `resolved`
|
- `resolved`
|
||||||
|
|
||||||
Acceptance criteria before implementation:
|
Before implementation, the design must preserve scheduled report behavior,
|
||||||
|
manual Storm Report generation, inspectable evaluator failures, and fixture
|
||||||
|
coverage for deterministic candidate detection.
|
||||||
|
|
||||||
- scheduled reports and manual Storm Reports remain stable;
|
## Future Report Types
|
||||||
- candidate detection has fixture coverage;
|
|
||||||
- evaluator failures are inspectable and do not create noisy report output;
|
|
||||||
- manual Storm Report generation remains available.
|
|
||||||
|
|
||||||
## Future Report Types And Modules
|
|
||||||
|
|
||||||
The module-based prompt package architecture is implemented. Future work should
|
|
||||||
add only modules backed by implemented upstream facts and clear report needs.
|
|
||||||
|
|
||||||
Possible future report types:
|
Possible future report types:
|
||||||
|
|
||||||
- `next_6_hours` or another short-fuse planning report;
|
- a short-fuse planning report distinct from the implemented Hourly Report, if
|
||||||
- event-specific reports with stable event IDs;
|
a separate product is needed
|
||||||
- storm review or yesterday-style reports using historical observations;
|
- event-specific reports with stable event IDs
|
||||||
|
- storm review or yesterday-style reports using historical observations
|
||||||
- archive-focused report variants if generated report history becomes a
|
- archive-focused report variants if generated report history becomes a
|
||||||
first-class product.
|
first-class product
|
||||||
|
|
||||||
|
New reports should keep report identity, prompt IDs, templates, valid-period
|
||||||
|
resolution, artifact grouping, batch output names, and comparison policy inside
|
||||||
|
`internal/report`.
|
||||||
|
|
||||||
|
## Future Modules
|
||||||
|
|
||||||
Possible future modules:
|
Possible future modules:
|
||||||
|
|
||||||
- `hourly_table` for compact valid-period hourly facts;
|
- `hourly_table` for compact valid-period hourly facts
|
||||||
- `forecast_delta` if a separate stanza is useful beyond current Recent
|
- `forecast_delta` if a separate stanza is useful beyond current Recent
|
||||||
Changes;
|
Changes
|
||||||
- `weekend_planning` if weekend-specific planning guidance needs a dedicated
|
- `weekend_planning` if weekend-specific planning guidance needs a dedicated
|
||||||
deterministic stanza;
|
deterministic stanza
|
||||||
- `storm_window_summary` if manual or automatic Storm Reports need a dedicated
|
- `storm_window_summary` if manual or automatic Storm Reports need a dedicated
|
||||||
prompt-facing storm-window module;
|
prompt-facing storm-window module
|
||||||
- separate AFD section aliases, such as `afd_key_messages`,
|
- separate AFD section aliases, such as `afd_key_messages`,
|
||||||
`afd_short_term_text`, and `afd_long_term_text`, if separate stanzas prove
|
`afd_short_term_text`, and `afd_long_term_text`, if separate stanzas prove
|
||||||
more useful than `area_forecast_discussion.options.sections`;
|
more useful than `area_forecast_discussion.options.sections`
|
||||||
- SPC, radar, QPF, snow/rain total, or historical-observation modules once
|
- SPC, radar, QPF, snow/rain total, or historical-observation modules once
|
||||||
upstream sources and report requirements exist.
|
upstream sources and report requirements exist
|
||||||
|
|
||||||
QPF fields such as `measurable_qpf_total_in` and `max_hourly_qpf_in` should
|
QPF fields such as `measurable_qpf_total_in` and `max_hourly_qpf_in` should
|
||||||
remain omitted until a real upstream quantitative precipitation source is
|
remain omitted until a real upstream quantitative precipitation source is
|
||||||
@@ -68,80 +73,61 @@ represented in `CollectedFacts`.
|
|||||||
|
|
||||||
Future module work should preserve these boundaries:
|
Future module work should preserve these boundaries:
|
||||||
|
|
||||||
- collect upstream facts once per report run;
|
- collect upstream facts once per report run
|
||||||
- keep upstream fetching out of modules;
|
- keep upstream fetching out of modules
|
||||||
- keep broad reusable calculations in `DerivedFacts`;
|
- keep broad reusable calculations in `DerivedFacts`
|
||||||
- keep prompt-facing field shape inside module builders;
|
- keep prompt-facing field shape inside module builders
|
||||||
- use typed options for configurable module behavior;
|
- use typed options for configurable module behavior
|
||||||
- keep module snapshots structured and deterministic for Recent Changes.
|
- keep module snapshots structured and deterministic for Recent Changes
|
||||||
|
|
||||||
## Distributor Notification Enhancements
|
## Distributor Notification Enhancements
|
||||||
|
|
||||||
Distributor notification currently uploads one managed Markdown report per
|
Distributor notification uploads one managed Markdown report per successful
|
||||||
successful generated report through the configured HTTP upload pipeline.
|
generated report through the configured HTTP upload pipeline. The following
|
||||||
|
enhancements are not implemented:
|
||||||
|
|
||||||
These enhancements are not current behavior:
|
- `failure_policy: warn`
|
||||||
|
- uploading metadata, module snapshots, data packages, or preflight artifacts
|
||||||
|
- durable upload retry queues
|
||||||
|
- distributor-specific CLI flags
|
||||||
|
- distributor workspace scanning
|
||||||
|
- destination routing, Markdown-to-HTML transformation, public URLs, or nginx
|
||||||
|
layout inside weatherreporter
|
||||||
|
|
||||||
- `failure_policy: warn`;
|
Any distributor enhancement should preserve the adapter boundary:
|
||||||
- uploading metadata, module snapshots, data packages, or preflight artifacts;
|
|
||||||
- polling distributor status after upload acceptance;
|
|
||||||
- durable upload retry queues;
|
|
||||||
- distributor-specific CLI flags;
|
|
||||||
- making distributor scan the weatherreporter workspace;
|
|
||||||
- handling destination routing, Markdown-to-HTML transformation, public URLs,
|
|
||||||
or nginx layout inside weatherreporter.
|
|
||||||
|
|
||||||
Any distributor enhancement should preserve the existing adapter boundary:
|
|
||||||
weatherreporter selects explicit generated files and submits source bundles,
|
weatherreporter selects explicit generated files and submits source bundles,
|
||||||
while distributor owns destination routing and publication behavior.
|
while distributor owns destination routing and publication behavior.
|
||||||
|
|
||||||
## Alternate Runtime Integrations
|
## Alternate Runtime Integrations
|
||||||
|
|
||||||
These ideas are not current behavior:
|
These ideas are not implemented:
|
||||||
|
|
||||||
- native LLM client inside `weatherreporter`;
|
- native LLM client inside `weatherreporter`
|
||||||
- database-backed state;
|
- database-backed state
|
||||||
- public HTTP API;
|
- public HTTP API
|
||||||
- multi-location selection;
|
- multi-location selection
|
||||||
- daemon mode;
|
- daemon mode
|
||||||
- multi-user authorization;
|
- multi-user authorization
|
||||||
- plugin system;
|
- plugin system
|
||||||
- dynamic module loading;
|
- dynamic module loading
|
||||||
- user-defined module code;
|
- user-defined module code
|
||||||
- YAML-defined module schemas;
|
- YAML-defined module schemas
|
||||||
- module-owned Weather API fetching.
|
- module-owned Weather API fetching
|
||||||
|
|
||||||
Each item needs its own design note before implementation. Non-roadmap docs
|
Each item needs its own design note before implementation. Non-roadmap docs
|
||||||
must not describe these as available behavior.
|
must not describe these as available behavior.
|
||||||
|
|
||||||
## Cleanup Refactors
|
## Deferred Refactors
|
||||||
|
|
||||||
The initial cleanup pass intentionally left these refactors out because the
|
These refactors should remain deferred until new requirements or recurring
|
||||||
current implementation does not yet make them worth the added abstraction.
|
maintenance costs make the added abstraction worthwhile:
|
||||||
|
|
||||||
Revisit these only when new source types, report types, operational
|
- broad briefing weather-signal consolidation
|
||||||
requirements, or recurring maintenance costs make the duplication materially
|
- generic workflow engine
|
||||||
more expensive:
|
- Cobra migration
|
||||||
|
- manifest, resume, or progress system
|
||||||
- Weather API optional-source specification/helper refactor: consider when
|
- global test helper package
|
||||||
additional Weather API sources make per-source fan-out, policy handling, and
|
- logging subsystem
|
||||||
provenance wiring repetitive enough to obscure adapter behavior.
|
|
||||||
- Broad briefing weather-signal consolidation: consider when multiple module
|
|
||||||
builders repeatedly derive the same weather signals and tests begin to need
|
|
||||||
coordinated fixture updates.
|
|
||||||
- Generic workflow engine: defer unless generation, inspection, recovery, or
|
|
||||||
future background workflows gain enough shared step semantics to justify a
|
|
||||||
declared execution model.
|
|
||||||
- Cobra migration: defer while the standard-library CLI remains small,
|
|
||||||
explicit, and covered by parser tests.
|
|
||||||
- Manifest, resume, or progress system: defer until operators need resumable
|
|
||||||
runs, checkpoint recovery, or richer audit trails than the current durable
|
|
||||||
artifacts and metadata provide.
|
|
||||||
- Global test helper package: defer while package-local helpers keep tests
|
|
||||||
clear; revisit only if setup duplication starts to hide behavior.
|
|
||||||
- Logging subsystem: defer until there are recurring operator diagnostics that
|
|
||||||
cannot be handled with current errors, metadata, inspection commands, and
|
|
||||||
artifact output.
|
|
||||||
|
|
||||||
Any future implementation should preserve the existing public CLI, artifact
|
Any future implementation should preserve the existing public CLI, artifact
|
||||||
paths, report identities, module boundaries, and adapter boundaries unless a
|
paths, report identities, module boundaries, and adapter boundaries unless a
|
||||||
|
|||||||
657
docs/roadmap/implementation.md
Normal file
657
docs/roadmap/implementation.md
Normal file
@@ -0,0 +1,657 @@
|
|||||||
|
# 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.
|
||||||
405
docs/templates.md
Normal file
405
docs/templates.md
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
# Report Templates
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This guide describes the implemented Markdown report template surface for
|
||||||
|
`weatherreporter`. It is for maintainers editing embedded report templates,
|
||||||
|
especially generated-text-template reports.
|
||||||
|
|
||||||
|
Templates are Go `text/template` files. The current implemented templates are:
|
||||||
|
|
||||||
|
- `internal/reporttemplate/templates/daily.md.tmpl`
|
||||||
|
- `internal/reporttemplate/templates/today.md.tmpl`
|
||||||
|
- `internal/reporttemplate/templates/tomorrow.md.tmpl`
|
||||||
|
- `internal/reporttemplate/templates/hourly.md.tmpl`
|
||||||
|
|
||||||
|
Templates are rendered from structured contexts such as `DailyRenderContext`,
|
||||||
|
`TodayRenderContext`, `TomorrowRenderContext`, and `HourlyRenderContext`.
|
||||||
|
Weather data collection, derivation, module execution, generated text
|
||||||
|
validation, and artifact paths are handled before template rendering.
|
||||||
|
|
||||||
|
## Editing Rules
|
||||||
|
|
||||||
|
- Use Go `text/template` syntax.
|
||||||
|
- Keep templates focused on Markdown layout, headings, ordering, and simple
|
||||||
|
conditional display.
|
||||||
|
- Do not put weather derivation, source selection, or path construction logic in
|
||||||
|
templates.
|
||||||
|
- Missing template keys are errors. A misspelled variable will fail rendering.
|
||||||
|
- No custom template functions are currently registered.
|
||||||
|
- Optional module stanzas are pointers and should be guarded with
|
||||||
|
`{{ with .Modules.WeatherStory }}...{{ end }}`.
|
||||||
|
- Slices can be rendered with `{{ range .Items }}...{{ else }}...{{ end }}`.
|
||||||
|
|
||||||
|
## Hourly Context
|
||||||
|
|
||||||
|
The hourly template receives five top-level values:
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Report` | HourlyReportContext | Display metadata and friendly labels for the rendered report. |
|
||||||
|
| `.GeneratedText` | Hourly | Structured text returned by Scriptorium. |
|
||||||
|
| `.Modules` | HourlyTemplateModules | Preferred deterministic template surface, keyed by module purpose. |
|
||||||
|
| `.Collected` | facts.CollectedFacts | Normalized upstream facts for advanced template use. |
|
||||||
|
| `.Derived` | facts.DerivedFacts | Shared derived facts for advanced template use. |
|
||||||
|
|
||||||
|
Prefer `.Modules` for normal template edits. `.Collected` and `.Derived` are
|
||||||
|
available when a template needs lower-level facts, but templates should still
|
||||||
|
avoid nontrivial derivation.
|
||||||
|
|
||||||
|
## Report
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Report.Title` | string | Display title. Currently `Hourly Report`. |
|
||||||
|
| `.Report.LocationName` | string | Prompt/report location label, such as `Brentwood, MO`. |
|
||||||
|
| `.Report.GeneratedAt` | time.Time | Canonical generation timestamp. |
|
||||||
|
| `.Report.GeneratedAtLabel` | string | Friendly local generation time label. |
|
||||||
|
| `.Report.ValidPeriod` | timeutil.Period | Canonical valid period. |
|
||||||
|
| `.Report.ValidPeriodLabel` | string | Friendly local valid period label, such as `2026-05-29 at 8:30 AM to 2026-05-29 at 2:30 PM`. |
|
||||||
|
| `.Report.Timezone` | string | Effective report timezone. |
|
||||||
|
|
||||||
|
## GeneratedText
|
||||||
|
|
||||||
|
These fields are written by Scriptorium as structured JSON, validated by
|
||||||
|
weatherreporter, and then inserted into the render context.
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.GeneratedText.Summary` | string | Required short prose summary. |
|
||||||
|
| `.GeneratedText.ForecastDiscussion` | string | Required prose for the Forecast Discussion section. |
|
||||||
|
| `.GeneratedText.PrecipitationTiming` | string | Optional prose rendered after deterministic precipitation windows. |
|
||||||
|
| `.GeneratedText.Confidence` | string | Optional confidence or uncertainty note. Empty when omitted by the LLM; not rendered by the current hourly template. |
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```gotemplate
|
||||||
|
{{ .GeneratedText.Summary }}
|
||||||
|
|
||||||
|
## Forecast Discussion
|
||||||
|
|
||||||
|
{{ .GeneratedText.ForecastDiscussion }}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tomorrow Context
|
||||||
|
|
||||||
|
The Tomorrow template receives five top-level values:
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Report` | TomorrowReportContext | Display metadata and friendly labels for the rendered report. |
|
||||||
|
| `.GeneratedText` | Tomorrow | Structured text returned by Scriptorium. |
|
||||||
|
| `.Modules` | TomorrowTemplateModules | Preferred deterministic template surface, keyed by module purpose. |
|
||||||
|
| `.Collected` | facts.CollectedFacts | Normalized upstream facts for advanced template use. |
|
||||||
|
| `.Derived` | facts.DerivedFacts | Shared derived facts for advanced template use. |
|
||||||
|
|
||||||
|
Tomorrow report metadata includes `.Report.Title`, `.Report.ForecastDate`,
|
||||||
|
`.Report.ForecastDateLabel`, `.Report.ForecastDayName`,
|
||||||
|
`.Report.GeneratedAt`, `.Report.GeneratedAtLabel`, `.Report.ValidPeriod`, and
|
||||||
|
`.Report.Timezone`.
|
||||||
|
|
||||||
|
Tomorrow generated text uses the same `.GeneratedText.Summary`,
|
||||||
|
`.GeneratedText.PrecipitationTiming`, and `.GeneratedText.Confidence` fields as
|
||||||
|
Hourly. `.GeneratedText.ForecastDiscussion` is a slice of paragraphs and should
|
||||||
|
be rendered with `range`.
|
||||||
|
|
||||||
|
Tomorrow modules include the Hourly module fields plus:
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Modules.DerivedDailySummary` | *briefing.DerivedDailySummaryModule | Daily summary facts for the forecast date. |
|
||||||
|
| `.Modules.DerivedDaypartSummaries` | *map[string]briefing.DerivedDaypartSummaryModule | Raw daypart summary map, when direct keyed access is needed. |
|
||||||
|
| `.Modules.Dayparts` | []generatedtext.TomorrowDaypartContext | Ordered daypart summaries for deterministic template rendering. |
|
||||||
|
| `.Modules.TomorrowPlanning` | *briefing.TomorrowPlanningModule | Planning facts for the next local civil day. |
|
||||||
|
|
||||||
|
Prefer `.Modules.Dayparts` over ranging through
|
||||||
|
`.Modules.DerivedDaypartSummaries`; it follows configured daypart order and
|
||||||
|
falls back to sorted keys for any unmatched entries.
|
||||||
|
|
||||||
|
## Daily Context
|
||||||
|
|
||||||
|
The Daily template receives the same five top-level values as Tomorrow, using
|
||||||
|
`DailyReportContext`, `Daily`, and `DailyTemplateModules`.
|
||||||
|
|
||||||
|
Daily report metadata includes `.Report.Title`, `.Report.ForecastDate`,
|
||||||
|
`.Report.ForecastDateLabel`, `.Report.ForecastDayName`,
|
||||||
|
`.Report.GeneratedAt`, `.Report.GeneratedAtLabel`, `.Report.ValidPeriod`, and
|
||||||
|
`.Report.Timezone`.
|
||||||
|
|
||||||
|
Daily generated text uses `.GeneratedText.Summary`,
|
||||||
|
`.GeneratedText.ForecastDiscussion`, `.GeneratedText.PrecipitationTiming`, and
|
||||||
|
`.GeneratedText.Confidence`. Forecast discussion is a slice of paragraphs and
|
||||||
|
should be rendered with `range`.
|
||||||
|
|
||||||
|
Daily uses template ID `daily`, generated-text schema ID `daily`, and prompt
|
||||||
|
source `internal/reporttemplate/prompts/daily.generated_text.md`.
|
||||||
|
|
||||||
|
Daily modules include the Hourly module fields plus:
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Modules.DerivedDailySummary` | *briefing.DerivedDailySummaryModule | Daily summary facts for the forecast date. |
|
||||||
|
| `.Modules.DerivedDaypartSummaries` | *map[string]briefing.DerivedDaypartSummaryModule | Raw daypart summary map, when direct keyed access is needed. |
|
||||||
|
| `.Modules.Dayparts` | []generatedtext.DailyDaypartContext | Ordered daypart summaries for deterministic template rendering. |
|
||||||
|
| `.Modules.DailyPlanning` | *briefing.DailyPlanningModule | Planning facts for the selected local civil day. |
|
||||||
|
|
||||||
|
Prefer `.Modules.Dayparts` over ranging through
|
||||||
|
`.Modules.DerivedDaypartSummaries`; it follows configured daypart order and
|
||||||
|
falls back to sorted keys for any unmatched entries.
|
||||||
|
|
||||||
|
## Today Context
|
||||||
|
|
||||||
|
The Today template receives the same five top-level values as Tomorrow, using
|
||||||
|
`TodayReportContext`, `Today`, and `TodayTemplateModules`.
|
||||||
|
|
||||||
|
Today report metadata includes `.Report.Title`, `.Report.ForecastDate`,
|
||||||
|
`.Report.ForecastDateLabel`, `.Report.ForecastDayName`,
|
||||||
|
`.Report.GeneratedAt`, `.Report.GeneratedAtLabel`, `.Report.ValidPeriod`, and
|
||||||
|
`.Report.Timezone`.
|
||||||
|
|
||||||
|
Today generated text uses `.GeneratedText.Summary`,
|
||||||
|
`.GeneratedText.ForecastDiscussion`, `.GeneratedText.PrecipitationTiming`, and
|
||||||
|
`.GeneratedText.Confidence`. Forecast discussion is a slice of paragraphs and
|
||||||
|
should be rendered with `range`.
|
||||||
|
|
||||||
|
Today uses template ID `today`, generated-text schema ID `today`, and prompt
|
||||||
|
source `internal/reporttemplate/prompts/today.generated_text.md`.
|
||||||
|
|
||||||
|
Today modules include the Hourly module fields plus:
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Modules.DerivedDailySummary` | *briefing.DerivedDailySummaryModule | Daily summary facts for the forecast date. |
|
||||||
|
| `.Modules.DerivedDaypartSummaries` | *map[string]briefing.DerivedDaypartSummaryModule | Raw daypart summary map, when direct keyed access is needed. |
|
||||||
|
| `.Modules.Dayparts` | []generatedtext.TodayDaypartContext | Ordered daypart summaries for deterministic template rendering. |
|
||||||
|
| `.Modules.TodayPlanning` | *briefing.TodayPlanningModule | Planning facts for the current local civil day. |
|
||||||
|
|
||||||
|
Prefer `.Modules.Dayparts` over ranging through
|
||||||
|
`.Modules.DerivedDaypartSummaries`; it follows configured daypart order and
|
||||||
|
falls back to sorted keys for any unmatched entries.
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
`.Modules` exposes typed outputs from the same module pipeline used for the
|
||||||
|
prompt data package. Module fields are pointers because missing-data policy may
|
||||||
|
omit a stanza.
|
||||||
|
|
||||||
|
Templates render from rich module values, not from the curated YAML data
|
||||||
|
package. Some fields documented below are deterministic wording helpers for
|
||||||
|
Markdown templates and are intentionally omitted from data packages passed to
|
||||||
|
Scriptorium. The data package is a prompt input, while the render context is the
|
||||||
|
template surface.
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Modules.Metadata` | *briefing.MetadataModule | Report metadata module output, when present. |
|
||||||
|
| `.Modules.CurrentConditions` | *briefing.CurrentConditionsModule | Current conditions from `/conditions/current`. |
|
||||||
|
| `.Modules.HourlyForecast` | *briefing.HourlyForecastModule | Hourly forecast periods overlapping the report valid period. |
|
||||||
|
| `.Modules.PrecipTiming` | *briefing.PrecipTimingModule | Derived precipitation timing facts and threshold windows. |
|
||||||
|
| `.Modules.AlertDigest` | *briefing.AlertDigestModule | Active alert status and relevant alert overlaps. |
|
||||||
|
| `.Modules.SPCConvectiveOutlooks` | *briefing.SPCConvectiveOutlooksModule | SPC outlooks that overlap the report valid period. |
|
||||||
|
| `.Modules.AreaForecastDiscussion` | *briefing.AreaForecastDiscussionModule | AFD key messages and configured discussion sections. |
|
||||||
|
| `.Modules.SPCConvectiveDiscussion` | *briefing.SPCConvectiveDiscussionModule | SPC discussions retained for qualifying overlapping categorical risk days. |
|
||||||
|
| `.Modules.WeatherStory` | *briefing.WeatherStoryModule | Latest NWS weather story, when available. |
|
||||||
|
|
||||||
|
### Current Conditions
|
||||||
|
|
||||||
|
Common fields:
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Modules.CurrentConditions.ConditionText` | string | Current condition text. |
|
||||||
|
| `.Modules.CurrentConditions.ConditionTextLower` | string | Lower-case current condition text for inline sentences. |
|
||||||
|
| `.Modules.CurrentConditions.TemperatureF` | *int | Rounded current temperature. |
|
||||||
|
| `.Modules.CurrentConditions.ApparentTemperatureF` | *int | Rounded apparent temperature. |
|
||||||
|
| `.Modules.CurrentConditions.RelativeHumidityPercent` | *int | Rounded relative humidity. |
|
||||||
|
| `.Modules.CurrentConditions.WindDirection` | string | 16-point compass wind direction. |
|
||||||
|
| `.Modules.CurrentConditions.WindDirectionText` | string | Lower-case full wind direction text, such as `northwest`. |
|
||||||
|
| `.Modules.CurrentConditions.WindSpeedMph` | *int | Rounded wind speed. |
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```gotemplate
|
||||||
|
{{ with .Modules.CurrentConditions }}
|
||||||
|
{{ .ConditionText }}{{ with .TemperatureF }}; {{ . }} F{{ end }}{{ with .WindDirection }}; wind {{ . }}{{ end }}{{ with .WindSpeedMph }} {{ . }} mph{{ end }}
|
||||||
|
{{ else }}
|
||||||
|
No current conditions available.
|
||||||
|
{{ end }}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hourly Forecast
|
||||||
|
|
||||||
|
Common period fields:
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Modules.HourlyForecast.Periods` | []briefing.HourlyForecastPeriod | Ordered periods for the hourly report valid period. |
|
||||||
|
| `.Modules.HourlyForecast.Periods[].HourLabel` | string | Friendly hour label such as `4:00 PM`. |
|
||||||
|
| `.Modules.HourlyForecast.Periods[].PeriodBegins` | string | Friendly local period start label. |
|
||||||
|
| `.Modules.HourlyForecast.Periods[].PeriodEnds` | string | Friendly local period end label. |
|
||||||
|
| `.Modules.HourlyForecast.Periods[].Name` | string | Source period name. |
|
||||||
|
| `.Modules.HourlyForecast.Periods[].TextDescription` | string | Hourly forecast text. |
|
||||||
|
| `.Modules.HourlyForecast.Periods[].TextDescriptionLower` | string | Lower-case hourly forecast text for inline sentences. |
|
||||||
|
| `.Modules.HourlyForecast.Periods[].TemperatureF` | *float64 | Forecast temperature. |
|
||||||
|
| `.Modules.HourlyForecast.Periods[].ProbabilityOfPrecipitationPercent` | *float64 | Forecast precipitation probability. |
|
||||||
|
| `.Modules.HourlyForecast.Periods[].MentionPrecipitation` | bool | True when precipitation probability meets the hourly mention threshold. |
|
||||||
|
| `.Modules.HourlyForecast.Periods[].WindDirection` | string | 16-point compass wind direction. |
|
||||||
|
| `.Modules.HourlyForecast.Periods[].WindSpeedMph` | *float64 | Wind speed. |
|
||||||
|
| `.Modules.HourlyForecast.Periods[].WindGustMph` | *float64 | Wind gust. |
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```gotemplate
|
||||||
|
{{ with .Modules.HourlyForecast }}{{ range .Periods }}
|
||||||
|
- **{{ .HourLabel }}:**{{ with .TemperatureF }} {{ . }}°F{{ end }} and {{ .TextDescriptionLower }}.{{ if .MentionPrecipitation }}{{ with .ProbabilityOfPrecipitationPercent }} Probability of precipitation is {{ . }}%.{{ end }}{{ end }}
|
||||||
|
{{ else }}
|
||||||
|
- No hourly forecast rows available.
|
||||||
|
{{ end }}{{ end }}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Precipitation Timing
|
||||||
|
|
||||||
|
Common fields:
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Modules.PrecipTiming.MaxPopPercent` | *int | Highest hourly precipitation probability in the valid period. |
|
||||||
|
| `.Modules.PrecipTiming.MaxPopTime` | string | Friendly local time for the highest hourly precipitation probability. |
|
||||||
|
| `.Modules.PrecipTiming.ProbabilityThreshold` | float64 | Threshold used to define precipitation windows. |
|
||||||
|
| `.Modules.PrecipTiming.PrecipitationWindows` | []briefing.PrecipitationWindowModule | One or more threshold precipitation windows. |
|
||||||
|
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodBegins` | string | Friendly local window start. |
|
||||||
|
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodBeginsHourLabel` | string | Friendly window start hour, such as `4:00 PM`. |
|
||||||
|
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodEnds` | string | Friendly local window end; omitted for open windows. |
|
||||||
|
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodEndsHourLabel` | string | Friendly window end hour; omitted for open windows. |
|
||||||
|
| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopPercent` | *int | Highest precipitation probability inside the window. |
|
||||||
|
| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopTime` | string | Friendly local time for the window maximum. |
|
||||||
|
| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopHourLabel` | string | Friendly hour label for the window maximum. |
|
||||||
|
| `.Modules.PrecipTiming.ThunderMentioned` | bool | Whether thunder is mentioned in the forecast text. |
|
||||||
|
|
||||||
|
### Daypart Summaries
|
||||||
|
|
||||||
|
Daily, Today, and Tomorrow templates should use `.Modules.Dayparts` for
|
||||||
|
ordered daypart rendering. Each item has `Key` and `Summary`; `Summary` is a
|
||||||
|
rich `briefing.DerivedDaypartSummaryModule`.
|
||||||
|
|
||||||
|
Common rich daypart fields:
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Modules.Dayparts[].Summary.DisplayName` | string | Human-readable daypart label. |
|
||||||
|
| `.Modules.Dayparts[].Summary.PeriodBegins` | string | Friendly local daypart start. |
|
||||||
|
| `.Modules.Dayparts[].Summary.PeriodEnds` | string | Friendly local daypart end. |
|
||||||
|
| `.Modules.Dayparts[].Summary.TempRangeF` | string | Rounded temperature range or single temperature. |
|
||||||
|
| `.Modules.Dayparts[].Summary.TemperaturePhraseF` | string | Temperature phrase used for steady template wording. |
|
||||||
|
| `.Modules.Dayparts[].Summary.TemperatureTrend` | string | Trend category such as `rising`, `falling`, `peaking`, or `steady`. |
|
||||||
|
| `.Modules.Dayparts[].Summary.TemperatureStartPhraseF` | string | Starting temperature phrase for rising/falling wording. |
|
||||||
|
| `.Modules.Dayparts[].Summary.TemperatureEndPhraseF` | string | Ending temperature phrase for rising/falling wording. |
|
||||||
|
| `.Modules.Dayparts[].Summary.TemperaturePeakPhraseF` | string | Peak temperature phrase for peaking wording. |
|
||||||
|
| `.Modules.Dayparts[].Summary.TemperatureSteadyPhraseF` | string | Steady temperature phrase. |
|
||||||
|
| `.Modules.Dayparts[].Summary.MaxPopPercent` | *int | Highest precipitation probability in the daypart. |
|
||||||
|
| `.Modules.Dayparts[].Summary.MaxPopTime` | string | Friendly local time for the highest precipitation probability. |
|
||||||
|
| `.Modules.Dayparts[].Summary.MaxPopTimeLabel` | string | Clock-style label for deterministic precipitation timing text. |
|
||||||
|
| `.Modules.Dayparts[].Summary.MentionPrecipitation` | bool | True when precipitation probability should be mentioned by the template. |
|
||||||
|
| `.Modules.Dayparts[].Summary.DominantCondition` | string | Dominant condition text. |
|
||||||
|
| `.Modules.Dayparts[].Summary.DominantConditionLower` | string | Lower-case condition text for inline sentences. |
|
||||||
|
| `.Modules.Dayparts[].Summary.DominantConditionDisplay` | string | Display-case condition text for bullet starts. |
|
||||||
|
| `.Modules.Dayparts[].Summary.NotableConditions` | []string | Notable condition labels retained for the daypart. |
|
||||||
|
|
||||||
|
Template-only daypart helpers such as `TemperaturePhraseF`,
|
||||||
|
`DominantConditionLower`, `DominantConditionDisplay`, and `MaxPopTimeLabel`
|
||||||
|
remain available here even though they are not serialized into data-package
|
||||||
|
YAML.
|
||||||
|
|
||||||
|
### Alert Digest
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Modules.AlertDigest.Checked` | bool | Whether alert data was checked successfully. |
|
||||||
|
| `.Modules.AlertDigest.ActiveCount` | int | Active alert count from the source. |
|
||||||
|
| `.Modules.AlertDigest.RelevantCount` | int | Alert count overlapping the report period. |
|
||||||
|
| `.Modules.AlertDigest.Missing` | bool | True when alert data is unavailable. |
|
||||||
|
| `.Modules.AlertDigest.Relevant` | []briefing.AlertSummary | Relevant alert summaries. |
|
||||||
|
| `.Modules.AlertDigest.Relevant[].Event` | string | Alert event name. |
|
||||||
|
| `.Modules.AlertDigest.Relevant[].Headline` | string | Alert headline. |
|
||||||
|
| `.Modules.AlertDigest.Relevant[].Severity` | string | Alert severity. |
|
||||||
|
|
||||||
|
### SPC Outlooks And Discussion
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Modules.SPCConvectiveOutlooks.Checked` | bool | Whether SPC outlook data was checked successfully. |
|
||||||
|
| `.Modules.SPCConvectiveOutlooks.AsOf` | string | Friendly source as-of time. |
|
||||||
|
| `.Modules.SPCConvectiveOutlooks.IssuedAt` | string | Friendly source issue time. |
|
||||||
|
| `.Modules.SPCConvectiveOutlooks.Outlooks` | []briefing.SPCConvectiveOutlookRecord | Overlapping outlook records. |
|
||||||
|
| `.Modules.SPCConvectiveOutlooks.Outlooks[].Day` | int | SPC day number. |
|
||||||
|
| `.Modules.SPCConvectiveOutlooks.Outlooks[].OutlookType` | string | Outlook type, such as `categorical`. |
|
||||||
|
| `.Modules.SPCConvectiveOutlooks.Outlooks[].Label` | string | Short outlook label. |
|
||||||
|
| `.Modules.SPCConvectiveOutlooks.Outlooks[].LabelText` | string | Human-readable outlook label. |
|
||||||
|
| `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodBegins` | string | Friendly outlook period start. |
|
||||||
|
| `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodEnds` | string | Friendly outlook period end. |
|
||||||
|
| `.Modules.SPCConvectiveOutlooks.Outlooks[].ImageURL` | string | Source image URL. |
|
||||||
|
| `.Modules.SPCConvectiveDiscussion.IncludedBecause` | string | Criterion used to include discussions. |
|
||||||
|
| `.Modules.SPCConvectiveDiscussion.Discussions` | []briefing.SPCConvectiveDiscussionRecord | Retained discussion records. |
|
||||||
|
| `.Modules.SPCConvectiveDiscussion.Discussions[].Headline` | string | Discussion headline. |
|
||||||
|
| `.Modules.SPCConvectiveDiscussion.Discussions[].Summary` | string | Discussion summary. |
|
||||||
|
| `.Modules.SPCConvectiveDiscussion.Discussions[].Discussion` | string | Full discussion text. |
|
||||||
|
|
||||||
|
### Area Forecast Discussion
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Modules.AreaForecastDiscussion.Product` | string | Source product identifier. |
|
||||||
|
| `.Modules.AreaForecastDiscussion.KeyMessages` | []string | AFD key messages. |
|
||||||
|
| `.Modules.AreaForecastDiscussion.ShortTerm` | string | AFD short-term section text. |
|
||||||
|
| `.Modules.AreaForecastDiscussion.LongTerm` | string | AFD long-term section text. |
|
||||||
|
|
||||||
|
### Weather Story
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.Modules.WeatherStory.Available` | bool | True when a story is available. |
|
||||||
|
| `.Modules.WeatherStory.OfficeID` | string | Source office ID. |
|
||||||
|
| `.Modules.WeatherStory.PeriodBegins` | string | Friendly story period start. |
|
||||||
|
| `.Modules.WeatherStory.PeriodEnds` | string | Friendly story period end. |
|
||||||
|
| `.Modules.WeatherStory.UpdatedAt` | *time.Time | Canonical update timestamp. |
|
||||||
|
| `.Modules.WeatherStory.Title` | string | Story title. |
|
||||||
|
| `.Modules.WeatherStory.Description` | string | Story description. |
|
||||||
|
| `.Modules.WeatherStory.AltText` | string | Story image alt text. |
|
||||||
|
| `.Modules.WeatherStory.Priority` | bool | Source priority flag. |
|
||||||
|
| `.Modules.WeatherStory.Order` | int | Source order. |
|
||||||
|
| `.Modules.WeatherStory.DownloadURL` | string | Source download URL. |
|
||||||
|
|
||||||
|
## Collected And Derived Facts
|
||||||
|
|
||||||
|
The template also receives the full `facts.CollectedFacts` and
|
||||||
|
`facts.DerivedFacts` structs:
|
||||||
|
|
||||||
|
- `.Collected` contains normalized source data and provenance from upstream
|
||||||
|
Weather API fetches.
|
||||||
|
- `.Derived` contains shared slices and calculations used across modules, such
|
||||||
|
as valid-period hourly periods, precipitation timing, alert overlaps, and SPC
|
||||||
|
filtering inputs.
|
||||||
|
|
||||||
|
These values are intentionally lower-level than `.Modules`. Use them when a
|
||||||
|
template needs a specific field that is not exposed by a module, but keep
|
||||||
|
calculation-heavy changes in Go.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
After editing a template, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/reporttemplate ./internal/generatedtext ./internal/app
|
||||||
|
```
|
||||||
|
|
||||||
|
For a full check, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./...
|
||||||
|
go run ./cmd/weatherreporter --help
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Template render tests exercise the Daily, Today, Tomorrow, and Hourly
|
||||||
|
templates through `internal/generatedtext/render_context_test.go` and
|
||||||
|
`internal/reporttemplate/reporttemplate_test.go`.
|
||||||
@@ -185,6 +185,52 @@ prompt, profile, model configuration, or validation issue.
|
|||||||
Relevant docs: [Operations guide](operations.md),
|
Relevant docs: [Operations guide](operations.md),
|
||||||
[Scriptorium integration](integrations/scriptorium.md).
|
[Scriptorium integration](integrations/scriptorium.md).
|
||||||
|
|
||||||
|
## Generated Text Validation Fails
|
||||||
|
|
||||||
|
Symptom: Daily, Today, Tomorrow, or Hourly generation fails with generated-text
|
||||||
|
decode, unknown-field, required-field, or multiple-JSON-values context.
|
||||||
|
|
||||||
|
Likely cause: Scriptorium wrote structured JSON that does not match the
|
||||||
|
GeneratedText contract for the selected report.
|
||||||
|
|
||||||
|
Diagnostic:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
weatherreporter inspect metadata RUN_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
Then inspect the generated-text raw path recorded in metadata, if present.
|
||||||
|
|
||||||
|
Safe fix: update the Scriptorium prompt or schema configuration so the prompt
|
||||||
|
writes the expected structured JSON for the report.
|
||||||
|
|
||||||
|
Relevant docs: [Operations guide](operations.md),
|
||||||
|
[Generated Text internals](internal/generatedtext.md),
|
||||||
|
[Scriptorium integration](integrations/scriptorium.md).
|
||||||
|
|
||||||
|
## Template Rendering Fails
|
||||||
|
|
||||||
|
Symptom: Daily, Today, Tomorrow, or Hourly generation fails with report template
|
||||||
|
parsing or execution context after generated text validation succeeds.
|
||||||
|
|
||||||
|
Likely cause: an embedded template references a missing context field or
|
||||||
|
receives a value shape that does not match its typed render context.
|
||||||
|
|
||||||
|
Diagnostic:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
weatherreporter inspect metadata RUN_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
If metadata records generated-text and render-context paths, inspect those
|
||||||
|
artifacts along with the template named by the report definition.
|
||||||
|
|
||||||
|
Safe fix: update the embedded template or render-context builder so the
|
||||||
|
template uses the implemented typed context.
|
||||||
|
|
||||||
|
Relevant docs: [Report Templates](templates.md),
|
||||||
|
[Report Template internals](internal/reporttemplate.md).
|
||||||
|
|
||||||
## Batch Command Returns Nonzero
|
## Batch Command Returns Nonzero
|
||||||
|
|
||||||
Symptom: `run morning` or `run evening` returns nonzero.
|
Symptom: `run morning` or `run evening` returns nonzero.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ notify:
|
|||||||
token_env: DISTRIBUTOR_UPLOAD_TOKEN
|
token_env: DISTRIBUTOR_UPLOAD_TOKEN
|
||||||
timeout: 30s
|
timeout: 30s
|
||||||
failure_policy: error
|
failure_policy: error
|
||||||
pipeline_id_template: "weatherreporter.{artifact_group}"
|
pipeline_id_template: "weatherreporter.{report_id}"
|
||||||
bundle_id_template: "weatherreporter.{location_id}.{report_id}"
|
bundle_id_template: "weatherreporter.{location_id}.{report_id}"
|
||||||
idempotency_key_template: "{bundle_id}.{run_id}"
|
idempotency_key_template: "{bundle_id}.{run_id}"
|
||||||
report_path_templates:
|
report_path_templates:
|
||||||
@@ -77,6 +77,7 @@ reports:
|
|||||||
- derived_daypart_summaries
|
- derived_daypart_summaries
|
||||||
- precip_timing
|
- precip_timing
|
||||||
- alert_digest
|
- alert_digest
|
||||||
|
- spc_convective_outlooks
|
||||||
- id: area_forecast_discussion
|
- id: area_forecast_discussion
|
||||||
options:
|
options:
|
||||||
sections:
|
sections:
|
||||||
@@ -84,6 +85,45 @@ reports:
|
|||||||
- key_messages
|
- key_messages
|
||||||
- short_term
|
- short_term
|
||||||
- long_term
|
- long_term
|
||||||
|
- spc_convective_discussion
|
||||||
|
- weather_story
|
||||||
|
- outdoor_windows
|
||||||
|
- daily_planning
|
||||||
|
- hourly_forecast
|
||||||
|
today:
|
||||||
|
deterministic_modules:
|
||||||
|
- metadata
|
||||||
|
- current_conditions
|
||||||
|
- narrative_forecast
|
||||||
|
- derived_daily_summary
|
||||||
|
- derived_daypart_summaries
|
||||||
|
- precip_timing
|
||||||
|
- alert_digest
|
||||||
|
- spc_convective_outlooks
|
||||||
|
- id: area_forecast_discussion
|
||||||
|
options:
|
||||||
|
sections:
|
||||||
|
- product
|
||||||
|
- key_messages
|
||||||
|
- short_term
|
||||||
|
- long_term
|
||||||
|
- spc_convective_discussion
|
||||||
- weather_story
|
- weather_story
|
||||||
- outdoor_windows
|
- outdoor_windows
|
||||||
- hourly_forecast
|
- hourly_forecast
|
||||||
|
- today_planning
|
||||||
|
hourly:
|
||||||
|
deterministic_modules:
|
||||||
|
- metadata
|
||||||
|
- current_conditions
|
||||||
|
- hourly_forecast
|
||||||
|
- precip_timing
|
||||||
|
- alert_digest
|
||||||
|
- spc_convective_outlooks
|
||||||
|
- id: area_forecast_discussion
|
||||||
|
options:
|
||||||
|
sections:
|
||||||
|
- key_messages
|
||||||
|
- short_term
|
||||||
|
- spc_convective_discussion
|
||||||
|
- weather_story
|
||||||
|
|||||||
@@ -79,6 +79,12 @@ type RunRequest struct {
|
|||||||
OutputPath string
|
OutputPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StructuredRunRequest struct {
|
||||||
|
PromptID string
|
||||||
|
DataPackagePath string
|
||||||
|
OutputPath string
|
||||||
|
}
|
||||||
|
|
||||||
type RenderResult struct {
|
type RenderResult struct {
|
||||||
Command []string `json:"command"`
|
Command []string `json:"command"`
|
||||||
Stdout string `json:"stdout"`
|
Stdout string `json:"stdout"`
|
||||||
@@ -98,6 +104,16 @@ type RunResult struct {
|
|||||||
OutputPath string `json:"outputPath"`
|
OutputPath string `json:"outputPath"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StructuredRunResult struct {
|
||||||
|
Command []string `json:"command"`
|
||||||
|
Stdout string `json:"stdout"`
|
||||||
|
Stderr string `json:"stderr"`
|
||||||
|
StdoutTruncated bool `json:"stdoutTruncated,omitempty"`
|
||||||
|
StderrTruncated bool `json:"stderrTruncated,omitempty"`
|
||||||
|
ExitCode int `json:"exitCode"`
|
||||||
|
OutputPath string `json:"outputPath"`
|
||||||
|
}
|
||||||
|
|
||||||
func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, error) {
|
func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, error) {
|
||||||
if req.PromptID == "" {
|
if req.PromptID == "" {
|
||||||
return nil, fmt.Errorf("prompt id is required")
|
return nil, fmt.Errorf("prompt id is required")
|
||||||
@@ -152,6 +168,35 @@ func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r Runner) StructuredRun(ctx context.Context, req StructuredRunRequest) (*StructuredRunResult, 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.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,
|
||||||
|
}
|
||||||
|
if execution.result.ExitCode != 0 {
|
||||||
|
return result, fmt.Errorf("scriptorium structured run exited with code %d: %s", execution.result.ExitCode, result.Stderr)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
type execution struct {
|
type execution struct {
|
||||||
binary string
|
binary string
|
||||||
args []string
|
args []string
|
||||||
@@ -212,6 +257,14 @@ 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
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func TestRenderConstructsCommand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result, err := runner.Render(context.Background(), RenderRequest{
|
result, err := runner.Render(context.Background(), RenderRequest{
|
||||||
PromptID: "weather.daily_report",
|
PromptID: "weather.markdown_report",
|
||||||
DataPackagePath: "/tmp/data_package.yaml",
|
DataPackagePath: "/tmp/data_package.yaml",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -30,7 +30,7 @@ func TestRenderConstructsCommand(t *testing.T) {
|
|||||||
"render",
|
"render",
|
||||||
"--config", "/etc/scriptorium.yml",
|
"--config", "/etc/scriptorium.yml",
|
||||||
"--profile", "weather",
|
"--profile", "weather",
|
||||||
"--prompt", "weather.daily_report",
|
"--prompt", "weather.markdown_report",
|
||||||
"--input", "data_package=/tmp/data_package.yaml",
|
"--input", "data_package=/tmp/data_package.yaml",
|
||||||
"--format", "json",
|
"--format", "json",
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,7 @@ func TestRenderReturnsResultForNonzeroExit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result, err := runner.Render(context.Background(), RenderRequest{
|
result, err := runner.Render(context.Background(), RenderRequest{
|
||||||
PromptID: "weather.daily_report",
|
PromptID: "weather.markdown_report",
|
||||||
DataPackagePath: "/tmp/data_package.yaml",
|
DataPackagePath: "/tmp/data_package.yaml",
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -84,7 +84,7 @@ func TestRunConstructsCommand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result, err := runner.Run(context.Background(), RunRequest{
|
result, err := runner.Run(context.Background(), RunRequest{
|
||||||
PromptID: "weather.daily_report",
|
PromptID: "weather.markdown_report",
|
||||||
DataPackagePath: "/tmp/data_package.yaml",
|
DataPackagePath: "/tmp/data_package.yaml",
|
||||||
OutputPath: "/tmp/daily.md",
|
OutputPath: "/tmp/daily.md",
|
||||||
})
|
})
|
||||||
@@ -96,7 +96,7 @@ func TestRunConstructsCommand(t *testing.T) {
|
|||||||
"run",
|
"run",
|
||||||
"--config", "/etc/scriptorium.yml",
|
"--config", "/etc/scriptorium.yml",
|
||||||
"--profile", "weather",
|
"--profile", "weather",
|
||||||
"--prompt", "weather.daily_report",
|
"--prompt", "weather.markdown_report",
|
||||||
"--input", "data_package=/tmp/data_package.yaml",
|
"--input", "data_package=/tmp/data_package.yaml",
|
||||||
"--out", "/tmp/daily.md",
|
"--out", "/tmp/daily.md",
|
||||||
}
|
}
|
||||||
@@ -129,7 +129,7 @@ func TestRunReturnsResultForValidationExit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result, err := runner.Run(context.Background(), RunRequest{
|
result, err := runner.Run(context.Background(), RunRequest{
|
||||||
PromptID: "weather.daily_report",
|
PromptID: "weather.markdown_report",
|
||||||
DataPackagePath: "/tmp/data_package.yaml",
|
DataPackagePath: "/tmp/data_package.yaml",
|
||||||
OutputPath: "/tmp/daily.md",
|
OutputPath: "/tmp/daily.md",
|
||||||
})
|
})
|
||||||
@@ -147,17 +147,169 @@ func TestRunReturnsResultForValidationExit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStructuredRunConstructsCommandWithoutSchemaFlags(t *testing.T) {
|
||||||
|
commands := &fakeCommands{result: CommandResult{
|
||||||
|
Stdout: []byte(`{"summary":"ok"}`),
|
||||||
|
Stderr: []byte("wrote generated text"),
|
||||||
|
StdoutTruncated: true,
|
||||||
|
}}
|
||||||
|
runner := Runner{
|
||||||
|
Binary: "/usr/local/bin/scriptorium",
|
||||||
|
ConfigPath: "/etc/scriptorium.yml",
|
||||||
|
Profile: "weather",
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
Commands: commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
|
||||||
|
PromptID: "weather.hourly_generated_text",
|
||||||
|
DataPackagePath: "/tmp/hourly.data_package.yaml",
|
||||||
|
OutputPath: "/tmp/hourly.generated_text.raw.json",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StructuredRun() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantArgs := []string{
|
||||||
|
"run",
|
||||||
|
"--config", "/etc/scriptorium.yml",
|
||||||
|
"--profile", "weather",
|
||||||
|
"--prompt", "weather.hourly_generated_text",
|
||||||
|
"--input", "data_package=/tmp/hourly.data_package.yaml",
|
||||||
|
"--out", "/tmp/hourly.generated_text.raw.json",
|
||||||
|
}
|
||||||
|
if commands.name != "/usr/local/bin/scriptorium" {
|
||||||
|
t.Fatalf("command name = %q, want custom binary", commands.name)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(commands.args, wantArgs) {
|
||||||
|
t.Fatalf("args = %#v, want %#v", commands.args, wantArgs)
|
||||||
|
}
|
||||||
|
for _, disallowed := range []string{"--format", "--schema", "--schema-path", "--json-schema"} {
|
||||||
|
if containsArg(commands.args, disallowed) {
|
||||||
|
t.Fatalf("args = %#v, should not include %q", commands.args, disallowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if commands.timeout != 30*time.Second {
|
||||||
|
t.Fatalf("timeout = %s, want 30s", commands.timeout)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(result.Command, append([]string{"/usr/local/bin/scriptorium"}, wantArgs...)) {
|
||||||
|
t.Fatalf("result command = %#v, want full argv", result.Command)
|
||||||
|
}
|
||||||
|
if result.Stdout != `{"summary":"ok"}` || result.Stderr != "wrote generated text" || !result.StdoutTruncated {
|
||||||
|
t.Fatalf("result = %#v, want captured output and truncation flags", result)
|
||||||
|
}
|
||||||
|
if result.OutputPath != "/tmp/hourly.generated_text.raw.json" {
|
||||||
|
t.Fatalf("OutputPath = %q, want generated text raw path", result.OutputPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStructuredRunReturnsResultForNonzeroExit(t *testing.T) {
|
||||||
|
runner := Runner{
|
||||||
|
Commands: &fakeCommands{
|
||||||
|
result: CommandResult{
|
||||||
|
Stdout: []byte(`{"summary":"partial"}`),
|
||||||
|
Stderr: []byte("structured output failed"),
|
||||||
|
ExitCode: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
|
||||||
|
PromptID: "weather.hourly_generated_text",
|
||||||
|
DataPackagePath: "/tmp/hourly.data_package.yaml",
|
||||||
|
OutputPath: "/tmp/hourly.generated_text.raw.json",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("StructuredRun() error = nil, want nonzero exit error")
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("StructuredRun() result = nil, want captured result")
|
||||||
|
}
|
||||||
|
if result.ExitCode != 3 {
|
||||||
|
t.Fatalf("ExitCode = %d, want 3", result.ExitCode)
|
||||||
|
}
|
||||||
|
if result.Stdout != `{"summary":"partial"}` || result.OutputPath != "/tmp/hourly.generated_text.raw.json" {
|
||||||
|
t.Fatalf("result = %#v, want captured result fields", result)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "structured output failed") {
|
||||||
|
t.Fatalf("error = %q, want stderr context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStructuredRunValidatesRequiredFieldsBeforeExecution(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
req StructuredRunRequest
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "prompt id",
|
||||||
|
req: StructuredRunRequest{
|
||||||
|
DataPackagePath: "/tmp/hourly.data_package.yaml",
|
||||||
|
OutputPath: "/tmp/hourly.generated_text.raw.json",
|
||||||
|
},
|
||||||
|
want: "prompt id is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "data package path",
|
||||||
|
req: StructuredRunRequest{
|
||||||
|
PromptID: "weather.hourly_generated_text",
|
||||||
|
OutputPath: "/tmp/hourly.generated_text.raw.json",
|
||||||
|
},
|
||||||
|
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 {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
commands := &fakeCommands{}
|
||||||
|
runner := Runner{Commands: commands}
|
||||||
|
result, err := runner.StructuredRun(context.Background(), test.req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("StructuredRun() error = nil, want validation error")
|
||||||
|
}
|
||||||
|
if result != nil {
|
||||||
|
t.Fatalf("StructuredRun() result = %#v, want nil", result)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("StructuredRun() error = %v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
if commands.calls != 0 {
|
||||||
|
t.Fatalf("commands calls = %d, want no subprocess execution", commands.calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type fakeCommands struct {
|
type fakeCommands struct {
|
||||||
name string
|
name string
|
||||||
args []string
|
args []string
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
result CommandResult
|
result CommandResult
|
||||||
err error
|
err error
|
||||||
|
calls int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeCommands) Run(_ context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) {
|
func (f *fakeCommands) Run(_ context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) {
|
||||||
|
f.calls++
|
||||||
f.name = name
|
f.name = name
|
||||||
f.args = append([]string{}, args...)
|
f.args = append([]string{}, args...)
|
||||||
f.timeout = timeout
|
f.timeout = timeout
|
||||||
return f.result, f.err
|
return f.result, f.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func containsArg(args []string, want string) bool {
|
||||||
|
for _, arg := range args {
|
||||||
|
if arg == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
convectiveOutlooksEndpoint = "/outlooks/convective"
|
||||||
|
sourceSPCConvectiveOutlooks = "spc_convective_outlooks"
|
||||||
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
baseURL *url.URL
|
baseURL *url.URL
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
@@ -112,6 +117,9 @@ func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
|
|||||||
if err := builder.fetchWeatherStory(ctx); err != nil {
|
if err := builder.fetchWeatherStory(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := builder.fetchSPCConvectiveOutlooks(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return builder.bundle, nil
|
return builder.bundle, nil
|
||||||
}
|
}
|
||||||
@@ -122,18 +130,32 @@ type bundleBuilder struct {
|
|||||||
fetchedAt time.Time
|
fetchedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type sourceRequest struct {
|
||||||
|
name string
|
||||||
|
endpoint string
|
||||||
|
query queryOptions
|
||||||
|
missingMessage string
|
||||||
|
required bool
|
||||||
|
decodeLabel string
|
||||||
|
}
|
||||||
|
|
||||||
|
type fetchedSource struct {
|
||||||
|
raw json.RawMessage
|
||||||
|
source weatherdata.Source
|
||||||
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
|
func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
|
||||||
raw, source, err := b.client.fetch(ctx, "observations", "/observations", queryOptions{precision: true})
|
var observation weatherdata.Observation
|
||||||
if err != nil {
|
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||||
|
name: "observations",
|
||||||
|
endpoint: "/observations",
|
||||||
|
query: queryOptions{precision: true},
|
||||||
|
missingMessage: "observation data is missing",
|
||||||
|
}, &observation)
|
||||||
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if raw == nil {
|
source := fetched.source
|
||||||
return b.handleMissing(&source, "observation data is missing", false)
|
|
||||||
}
|
|
||||||
var observation weatherdata.Observation
|
|
||||||
if err := decodeSource(raw, &observation); err != nil {
|
|
||||||
return b.handleMalformed(&source, err, false)
|
|
||||||
}
|
|
||||||
source.IssuedAt = &observation.Timestamp
|
source.IssuedAt = &observation.Timestamp
|
||||||
b.bundle.Observation = &observation
|
b.bundle.Observation = &observation
|
||||||
b.addSource(source)
|
b.addSource(source)
|
||||||
@@ -141,34 +163,36 @@ func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
|
func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
|
||||||
raw, source, err := b.client.fetch(ctx, "current", "/conditions/current", queryOptions{precision: true})
|
var current weatherdata.Current
|
||||||
if err != nil {
|
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||||
|
name: "current",
|
||||||
|
endpoint: "/conditions/current",
|
||||||
|
query: queryOptions{precision: true},
|
||||||
|
missingMessage: "current conditions data is missing",
|
||||||
|
}, ¤t)
|
||||||
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if raw == nil {
|
source := fetched.source
|
||||||
return b.handleMissing(&source, "current conditions data is missing", false)
|
|
||||||
}
|
|
||||||
var current weatherdata.Current
|
|
||||||
if err := decodeSource(raw, ¤t); err != nil {
|
|
||||||
return b.handleMalformed(&source, err, false)
|
|
||||||
}
|
|
||||||
b.bundle.Current = ¤t
|
b.bundle.Current = ¤t
|
||||||
b.addSource(source)
|
b.addSource(source)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
|
func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
|
||||||
raw, source, err := b.client.fetch(ctx, "hourly", "/forecast/hourly", queryOptions{precision: true, timezone: true})
|
var hourly weatherdata.ForecastRun
|
||||||
if err != nil {
|
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||||
|
name: "hourly",
|
||||||
|
endpoint: "/forecast/hourly",
|
||||||
|
query: queryOptions{precision: true, timezone: true},
|
||||||
|
missingMessage: "hourly forecast data is missing",
|
||||||
|
required: true,
|
||||||
|
decodeLabel: "hourly forecast",
|
||||||
|
}, &hourly)
|
||||||
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if raw == nil {
|
source := fetched.source
|
||||||
return b.handleMissing(&source, "hourly forecast data is missing", true)
|
|
||||||
}
|
|
||||||
var hourly weatherdata.ForecastRun
|
|
||||||
if err := decodeSource(raw, &hourly); err != nil {
|
|
||||||
return fmt.Errorf("decode hourly forecast from %s: %w", source.Endpoint, err)
|
|
||||||
}
|
|
||||||
if len(hourly.Periods) == 0 {
|
if len(hourly.Periods) == 0 {
|
||||||
return fmt.Errorf("hourly forecast from %s contains no periods", source.Endpoint)
|
return fmt.Errorf("hourly forecast from %s contains no periods", source.Endpoint)
|
||||||
}
|
}
|
||||||
@@ -180,17 +204,17 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
|
func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
|
||||||
raw, source, err := b.client.fetch(ctx, "narrative", "/forecast/narrative", queryOptions{precision: true, timezone: true})
|
var narrative weatherdata.ForecastRun
|
||||||
if err != nil {
|
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||||
|
name: "narrative",
|
||||||
|
endpoint: "/forecast/narrative",
|
||||||
|
query: queryOptions{precision: true, timezone: true},
|
||||||
|
missingMessage: "narrative forecast data is missing",
|
||||||
|
}, &narrative)
|
||||||
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if raw == nil {
|
source := fetched.source
|
||||||
return b.handleMissing(&source, "narrative forecast data is missing", false)
|
|
||||||
}
|
|
||||||
var narrative weatherdata.ForecastRun
|
|
||||||
if err := decodeSource(raw, &narrative); err != nil {
|
|
||||||
return b.handleMalformed(&source, err, false)
|
|
||||||
}
|
|
||||||
source.IssuedAt = &narrative.IssuedAt
|
source.IssuedAt = &narrative.IssuedAt
|
||||||
source.UpdatedAt = narrative.UpdatedAt
|
source.UpdatedAt = narrative.UpdatedAt
|
||||||
b.bundle.Narrative = &narrative
|
b.bundle.Narrative = &narrative
|
||||||
@@ -213,7 +237,7 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
var alerts weatherdata.AlertRun
|
var alerts weatherdata.AlertRun
|
||||||
if err := decodeSource(raw, &alerts); err != nil {
|
if err := decodeSource(raw, &alerts); err != nil {
|
||||||
return b.handleMalformed(&source, err, false)
|
return b.handleMalformed(&source, err, sourceRequest{name: "alerts"})
|
||||||
}
|
}
|
||||||
alerts.Raw = append(json.RawMessage(nil), raw...)
|
alerts.Raw = append(json.RawMessage(nil), raw...)
|
||||||
if alerts.AsOf != nil {
|
if alerts.AsOf != nil {
|
||||||
@@ -225,17 +249,17 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
||||||
raw, source, err := b.client.fetch(ctx, "discussion", "/discussion", queryOptions{timezone: true})
|
var discussion weatherdata.Discussion
|
||||||
if err != nil {
|
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||||
|
name: "discussion",
|
||||||
|
endpoint: "/discussion",
|
||||||
|
query: queryOptions{timezone: true},
|
||||||
|
missingMessage: "forecast discussion data is missing",
|
||||||
|
}, &discussion)
|
||||||
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if raw == nil {
|
source := fetched.source
|
||||||
return b.handleMissing(&source, "forecast discussion data is missing", false)
|
|
||||||
}
|
|
||||||
var discussion weatherdata.Discussion
|
|
||||||
if err := decodeSource(raw, &discussion); err != nil {
|
|
||||||
return b.handleMalformed(&source, err, false)
|
|
||||||
}
|
|
||||||
source.IssuedAt = &discussion.IssuedAt
|
source.IssuedAt = &discussion.IssuedAt
|
||||||
source.UpdatedAt = discussion.UpdatedAt
|
source.UpdatedAt = discussion.UpdatedAt
|
||||||
b.bundle.Discussion = &discussion
|
b.bundle.Discussion = &discussion
|
||||||
@@ -244,17 +268,17 @@ func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
||||||
raw, source, err := b.client.fetch(ctx, "weather_story", "/weatherstories/latest", queryOptions{omitUnits: true})
|
var story weatherdata.WeatherStory
|
||||||
if err != nil {
|
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||||
|
name: "weather_story",
|
||||||
|
endpoint: "/weatherstories/latest",
|
||||||
|
query: queryOptions{omitUnits: true},
|
||||||
|
missingMessage: "NWS weather story data is missing",
|
||||||
|
}, &story)
|
||||||
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if raw == nil {
|
source := fetched.source
|
||||||
return b.handleMissing(&source, "NWS weather story data is missing", false)
|
|
||||||
}
|
|
||||||
var story weatherdata.WeatherStory
|
|
||||||
if err := decodeSource(raw, &story); err != nil {
|
|
||||||
return b.handleMalformed(&source, err, false)
|
|
||||||
}
|
|
||||||
if !story.StartTime.IsZero() {
|
if !story.StartTime.IsZero() {
|
||||||
source.IssuedAt = &story.StartTime
|
source.IssuedAt = &story.StartTime
|
||||||
}
|
}
|
||||||
@@ -264,6 +288,51 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *bundleBuilder) fetchSPCConvectiveOutlooks(ctx context.Context) error {
|
||||||
|
var run weatherdata.ConvectiveOutlookRun
|
||||||
|
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||||
|
name: sourceSPCConvectiveOutlooks,
|
||||||
|
endpoint: convectiveOutlooksEndpoint,
|
||||||
|
query: queryOptions{timezone: true, omitUnits: true},
|
||||||
|
missingMessage: "SPC convective outlook data is missing",
|
||||||
|
}, &run)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
source := fetched.source
|
||||||
|
if run.IssuedAt != nil {
|
||||||
|
source.IssuedAt = run.IssuedAt
|
||||||
|
} else {
|
||||||
|
source.IssuedAt = run.AsOf
|
||||||
|
}
|
||||||
|
source.UpdatedAt = run.UpdatedAt
|
||||||
|
b.bundle.SPCConvectiveOutlooks = &run
|
||||||
|
b.addSource(source)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bundleBuilder) fetchDecodedSource(ctx context.Context, request sourceRequest, target any) (fetchedSource, bool, error) {
|
||||||
|
fetched, ok, err := b.fetchSource(ctx, request)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return fetchedSource{}, false, err
|
||||||
|
}
|
||||||
|
if err := decodeSource(fetched.raw, target); err != nil {
|
||||||
|
return fetchedSource{}, false, b.handleMalformed(&fetched.source, err, request)
|
||||||
|
}
|
||||||
|
return fetched, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bundleBuilder) fetchSource(ctx context.Context, request sourceRequest) (fetchedSource, bool, error) {
|
||||||
|
raw, source, err := b.client.fetch(ctx, request.name, request.endpoint, request.query)
|
||||||
|
if err != nil {
|
||||||
|
return fetchedSource{}, false, err
|
||||||
|
}
|
||||||
|
if raw == nil {
|
||||||
|
return fetchedSource{}, false, b.handleMissing(&source, request.missingMessage, request.required)
|
||||||
|
}
|
||||||
|
return fetchedSource{raw: raw, source: source}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error {
|
func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error {
|
||||||
source.Missing = true
|
source.Missing = true
|
||||||
if required {
|
if required {
|
||||||
@@ -272,9 +341,13 @@ func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string
|
|||||||
return b.applyMissingPolicy(source, "missing_source", message)
|
return b.applyMissingPolicy(source, "missing_source", message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) handleMalformed(source *weatherdata.Source, err error, required bool) error {
|
func (b *bundleBuilder) handleMalformed(source *weatherdata.Source, err error, request sourceRequest) error {
|
||||||
if required {
|
if request.required {
|
||||||
return fmt.Errorf("decode %s from %s: %w", source.Name, source.Endpoint, err)
|
label := request.name
|
||||||
|
if request.decodeLabel != "" {
|
||||||
|
label = request.decodeLabel
|
||||||
|
}
|
||||||
|
return fmt.Errorf("decode %s from %s: %w", label, source.Endpoint, err)
|
||||||
}
|
}
|
||||||
source.Missing = true
|
source.Missing = true
|
||||||
return b.applyMissingPolicy(source, "malformed_source", fmt.Sprintf("malformed %s data: %v", source.Name, err))
|
return b.applyMissingPolicy(source, "malformed_source", fmt.Sprintf("malformed %s data: %v", source.Name, err))
|
||||||
|
|||||||
@@ -55,12 +55,39 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
|||||||
if bundle.WeatherStory.UpdatedAt == nil {
|
if bundle.WeatherStory.UpdatedAt == nil {
|
||||||
t.Fatalf("WeatherStory.UpdatedAt = nil, want update timestamp")
|
t.Fatalf("WeatherStory.UpdatedAt = nil, want update timestamp")
|
||||||
}
|
}
|
||||||
if len(bundle.Sources) != 7 {
|
if bundle.SPCConvectiveOutlooks == nil || len(bundle.SPCConvectiveOutlooks.Outlooks) != 1 {
|
||||||
t.Fatalf("Sources length = %d, want 7", len(bundle.Sources))
|
t.Fatalf("SPCConvectiveOutlooks = %#v, want one outlook", bundle.SPCConvectiveOutlooks)
|
||||||
|
}
|
||||||
|
if len(bundle.SPCConvectiveOutlooks.Outlooks[0].Geometry) == 0 {
|
||||||
|
t.Fatalf("SPCConvectiveOutlooks.Outlooks[0].Geometry is empty, want GeoJSON")
|
||||||
|
}
|
||||||
|
if len(bundle.SPCConvectiveOutlooks.Discussions) != 1 || bundle.SPCConvectiveOutlooks.Discussions[0].Headline != "Severe storms possible" {
|
||||||
|
t.Fatalf("SPCConvectiveOutlooks.Discussions = %#v, want one discussion", bundle.SPCConvectiveOutlooks.Discussions)
|
||||||
|
}
|
||||||
|
if len(bundle.Sources) != 8 {
|
||||||
|
t.Fatalf("Sources length = %d, want 8", len(bundle.Sources))
|
||||||
}
|
}
|
||||||
if len(bundle.Warnings) != 0 {
|
if len(bundle.Warnings) != 0 {
|
||||||
t.Fatalf("Warnings length = %d, want no warnings", len(bundle.Warnings))
|
t.Fatalf("Warnings length = %d, want no warnings", len(bundle.Warnings))
|
||||||
}
|
}
|
||||||
|
wantPaths := []string{
|
||||||
|
"/observations",
|
||||||
|
"/conditions/current",
|
||||||
|
"/forecast/hourly",
|
||||||
|
"/forecast/narrative",
|
||||||
|
"/alerts/active",
|
||||||
|
"/discussion",
|
||||||
|
"/weatherstories/latest",
|
||||||
|
convectiveOutlooksEndpoint,
|
||||||
|
}
|
||||||
|
if len(requested) != len(wantPaths) {
|
||||||
|
t.Fatalf("requested paths = %v, want %d source endpoints", requested, len(wantPaths))
|
||||||
|
}
|
||||||
|
for _, want := range wantPaths {
|
||||||
|
if !containsPath(requested, want) {
|
||||||
|
t.Fatalf("requested paths = %v, want %s", requested, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
|
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
|
||||||
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
|
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
|
||||||
}
|
}
|
||||||
@@ -70,6 +97,9 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
|||||||
if !containsPath(requested, "/weatherstories/latest") {
|
if !containsPath(requested, "/weatherstories/latest") {
|
||||||
t.Fatalf("requested paths = %v, want weather story endpoint", requested)
|
t.Fatalf("requested paths = %v, want weather story endpoint", requested)
|
||||||
}
|
}
|
||||||
|
if !containsPath(requested, convectiveOutlooksEndpoint) {
|
||||||
|
t.Fatalf("requested paths = %v, want convective outlook endpoint", requested)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
||||||
@@ -92,6 +122,12 @@ func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if strings.HasPrefix(rawURL, convectiveOutlooksEndpoint) {
|
||||||
|
if strings.Contains(rawURL, "units=") || strings.Contains(rawURL, "precision=") || !strings.Contains(rawURL, "tz=America%2FChicago") {
|
||||||
|
t.Fatalf("convective outlook request %q should use format and tz only", rawURL)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
if !strings.Contains(rawURL, "units=us") {
|
if !strings.Contains(rawURL, "units=us") {
|
||||||
t.Fatalf("request %q missing units=us", rawURL)
|
t.Fatalf("request %q missing units=us", rawURL)
|
||||||
}
|
}
|
||||||
@@ -127,6 +163,25 @@ func TestFetchBundleRecordsSourceHash(t *testing.T) {
|
|||||||
if story.IssuedAt == nil || story.UpdatedAt == nil {
|
if story.IssuedAt == nil || story.UpdatedAt == nil {
|
||||||
t.Fatalf("weather story source timestamps = issued %#v updated %#v, want both", story.IssuedAt, story.UpdatedAt)
|
t.Fatalf("weather story source timestamps = issued %#v updated %#v, want both", story.IssuedAt, story.UpdatedAt)
|
||||||
}
|
}
|
||||||
|
outlooks := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
|
||||||
|
if outlooks.Endpoint != convectiveOutlooksEndpoint {
|
||||||
|
t.Fatalf("convective outlook endpoint = %q, want %s", outlooks.Endpoint, convectiveOutlooksEndpoint)
|
||||||
|
}
|
||||||
|
if outlooks.Query["format"] != "json" || outlooks.Query["tz"] != "America/Chicago" || outlooks.Query["units"] != "" || outlooks.Query["precision"] != "" {
|
||||||
|
t.Fatalf("convective outlook query = %#v, want format and tz only", outlooks.Query)
|
||||||
|
}
|
||||||
|
if outlooks.DataSHA256 != hashFixtureData(t, "convective_outlooks.json") {
|
||||||
|
t.Fatalf("convective outlook DataSHA256 = %q, want fixture hash", outlooks.DataSHA256)
|
||||||
|
}
|
||||||
|
if outlooks.Missing {
|
||||||
|
t.Fatal("convective outlook source Missing = true, want false")
|
||||||
|
}
|
||||||
|
if outlooks.IssuedAt == nil || outlooks.IssuedAt.Format(time.RFC3339) != "2026-05-29T15:45:00Z" {
|
||||||
|
t.Fatalf("convective outlook IssuedAt = %#v, want run issuedAt", outlooks.IssuedAt)
|
||||||
|
}
|
||||||
|
if outlooks.UpdatedAt == nil || outlooks.UpdatedAt.Format(time.RFC3339) != "2026-05-29T16:05:00Z" {
|
||||||
|
t.Fatalf("convective outlook UpdatedAt = %#v, want run updatedAt", outlooks.UpdatedAt)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHTTPErrorIsActionable(t *testing.T) {
|
func TestHTTPErrorIsActionable(t *testing.T) {
|
||||||
@@ -189,6 +244,59 @@ func TestNullAlertsMeansNoActiveAlerts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMissingSPCConvectiveOutlooksUsesPolicy(t *testing.T) {
|
||||||
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
|
convectiveOutlooksEndpoint: {status: http.StatusOK, body: `{"data": null}`},
|
||||||
|
}, nil)
|
||||||
|
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
|
||||||
|
sourceSPCConvectiveOutlooks: config.MissingSourceWarn,
|
||||||
|
})
|
||||||
|
|
||||||
|
bundle, err := client.FetchBundle(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchBundle() error = %v", err)
|
||||||
|
}
|
||||||
|
if bundle.SPCConvectiveOutlooks != nil {
|
||||||
|
t.Fatalf("SPCConvectiveOutlooks = %#v, want nil for missing source", bundle.SPCConvectiveOutlooks)
|
||||||
|
}
|
||||||
|
source := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
|
||||||
|
if !source.Missing || len(source.Warnings) != 1 {
|
||||||
|
t.Fatalf("convective outlook source = %#v, want missing source warning", source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptySPCConvectiveOutlooksAreCheckedData(t *testing.T) {
|
||||||
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
|
convectiveOutlooksEndpoint: {status: http.StatusOK, body: `{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`},
|
||||||
|
}, nil)
|
||||||
|
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
|
||||||
|
sourceSPCConvectiveOutlooks: config.MissingSourceWarn,
|
||||||
|
})
|
||||||
|
|
||||||
|
bundle, err := client.FetchBundle(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchBundle() error = %v", err)
|
||||||
|
}
|
||||||
|
if bundle.SPCConvectiveOutlooks == nil {
|
||||||
|
t.Fatal("SPCConvectiveOutlooks = nil, want checked empty run")
|
||||||
|
}
|
||||||
|
if len(bundle.SPCConvectiveOutlooks.Outlooks) != 0 || len(bundle.SPCConvectiveOutlooks.Discussions) != 0 {
|
||||||
|
t.Fatalf("SPCConvectiveOutlooks = %#v, want empty arrays", bundle.SPCConvectiveOutlooks)
|
||||||
|
}
|
||||||
|
source := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
|
||||||
|
if source.Missing || len(source.Warnings) != 0 {
|
||||||
|
t.Fatalf("convective outlook source = %#v, want non-missing source without warnings", source)
|
||||||
|
}
|
||||||
|
if source.IssuedAt == nil || source.IssuedAt.Format(time.RFC3339) != "2026-05-29T16:00:00Z" {
|
||||||
|
t.Fatalf("convective outlook IssuedAt = %#v, want fallback to asOf", source.IssuedAt)
|
||||||
|
}
|
||||||
|
for _, warning := range bundle.Warnings {
|
||||||
|
if warning.Source == sourceSPCConvectiveOutlooks {
|
||||||
|
t.Fatalf("warnings = %#v, want no convective outlook warning", bundle.Warnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
|
func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -370,6 +478,7 @@ func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested
|
|||||||
"/alerts/active": "alerts.json",
|
"/alerts/active": "alerts.json",
|
||||||
"/discussion": "discussion.json",
|
"/discussion": "discussion.json",
|
||||||
"/weatherstories/latest": "weather_story.json",
|
"/weatherstories/latest": "weather_story.json",
|
||||||
|
convectiveOutlooksEndpoint: "convective_outlooks.json",
|
||||||
}
|
}
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if requested != nil {
|
if requested != nil {
|
||||||
|
|||||||
50
internal/adapters/weatherapi/testdata/convective_outlooks.json
vendored
Normal file
50
internal/adapters/weatherapi/testdata/convective_outlooks.json
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"locationId": "nws-lsx-grid-90-74",
|
||||||
|
"locationName": "St. Louis, MO",
|
||||||
|
"asOf": "2026-05-29T16:00:00Z",
|
||||||
|
"issuedAt": "2026-05-29T15:45:00Z",
|
||||||
|
"updatedAt": "2026-05-29T16:05:00Z",
|
||||||
|
"product": "convective_outlook",
|
||||||
|
"outlooks": [
|
||||||
|
{
|
||||||
|
"id": "day1-categorical-slight",
|
||||||
|
"provider": "spc",
|
||||||
|
"product": "convective_outlook",
|
||||||
|
"day": 1,
|
||||||
|
"outlookType": "categorical",
|
||||||
|
"label": "SLGT",
|
||||||
|
"labelText": "Slight Risk",
|
||||||
|
"forecaster": "Smith",
|
||||||
|
"severityRank": 3,
|
||||||
|
"validFrom": "2026-05-29T13:00:00-05:00",
|
||||||
|
"validTo": "2026-05-30T07:00:00-05:00",
|
||||||
|
"issuedAt": "2026-05-29T15:45:00Z",
|
||||||
|
"expiresAt": "2026-05-30T07:00:00-05:00",
|
||||||
|
"sourceUrl": "https://www.spc.noaa.gov/products/outlook/day1otlk.html",
|
||||||
|
"imageUrl": "https://www.spc.noaa.gov/products/outlook/day1probotlk_2000_torn.gif",
|
||||||
|
"containsLocation": true,
|
||||||
|
"geometry": {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [
|
||||||
|
[
|
||||||
|
[-91.0, 38.0],
|
||||||
|
[-90.0, 38.5],
|
||||||
|
[-89.5, 37.8],
|
||||||
|
[-91.0, 38.0]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"discussions": [
|
||||||
|
{
|
||||||
|
"day": 1,
|
||||||
|
"headline": "Severe storms possible",
|
||||||
|
"summary": "Scattered severe storms are possible.",
|
||||||
|
"discussion": "A few storms may become severe during the afternoon.",
|
||||||
|
"updatedAt": "2026-05-29T16:05:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
@@ -28,18 +29,20 @@ import (
|
|||||||
type ReportKind string
|
type ReportKind string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ReportDaily ReportKind = "daily"
|
ReportDaily ReportKind = ReportKind(report.CommandNameDaily)
|
||||||
ReportTomorrow ReportKind = "tomorrow"
|
ReportToday ReportKind = ReportKind(report.CommandNameToday)
|
||||||
ReportThreeDay ReportKind = "three-day"
|
ReportTomorrow ReportKind = ReportKind(report.CommandNameTomorrow)
|
||||||
ReportWeekend ReportKind = "weekend"
|
ReportHourly ReportKind = ReportKind(report.CommandNameHourly)
|
||||||
ReportStorm ReportKind = "storm"
|
ReportThreeDay ReportKind = ReportKind(report.CommandNameThreeDay)
|
||||||
|
ReportWeekend ReportKind = ReportKind(report.CommandNameWeekend)
|
||||||
|
ReportStorm ReportKind = ReportKind(report.CommandNameStorm)
|
||||||
)
|
)
|
||||||
|
|
||||||
type BatchKind string
|
type BatchKind string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
BatchMorning BatchKind = "morning"
|
BatchMorning BatchKind = BatchKind(report.BatchNameMorning)
|
||||||
BatchEvening BatchKind = "evening"
|
BatchEvening BatchKind = BatchKind(report.BatchNameEvening)
|
||||||
)
|
)
|
||||||
|
|
||||||
type GenerateRequest struct {
|
type GenerateRequest struct {
|
||||||
@@ -102,6 +105,11 @@ type ReportResult struct {
|
|||||||
RecentChanges []changes.Change
|
RecentChanges []changes.Change
|
||||||
RenderResult *scriptorium.RenderResult
|
RenderResult *scriptorium.RenderResult
|
||||||
RunResult *scriptorium.RunResult
|
RunResult *scriptorium.RunResult
|
||||||
|
StructuredRunResult *scriptorium.StructuredRunResult
|
||||||
|
GeneratedTextRawPath string
|
||||||
|
GeneratedTextResultPath string
|
||||||
|
GeneratedTextPath string
|
||||||
|
RenderContextPath string
|
||||||
Notification *NotificationResult
|
Notification *NotificationResult
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +158,7 @@ func (e BatchError) Error() string {
|
|||||||
type Renderer interface {
|
type Renderer interface {
|
||||||
Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error)
|
Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error)
|
||||||
Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error)
|
Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error)
|
||||||
|
StructuredRun(context.Context, scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Notifier interface {
|
type Notifier interface {
|
||||||
@@ -336,7 +345,7 @@ func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return report.Resolved{}, err
|
return report.Resolved{}, err
|
||||||
}
|
}
|
||||||
id, err := reportIDForCommand(req.Report)
|
id, err := report.IDForCommandName(string(req.Report))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return report.Resolved{}, err
|
return report.Resolved{}, err
|
||||||
}
|
}
|
||||||
@@ -358,7 +367,7 @@ func ResolveBatch(req BatchRequest, now time.Time) ([]report.Resolved, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
batch, err := reportBatchForCommand(req.Batch)
|
batch, err := report.BatchForCommandName(string(req.Batch))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -373,41 +382,17 @@ func ResolveBatch(req BatchRequest, now time.Time) ([]report.Resolved, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func reportRegistry(cfg config.Config) (report.Registry, error) {
|
func reportRegistry(cfg config.Config) (report.Registry, error) {
|
||||||
registry, err := report.DefaultRegistry().WithModuleOverrides(cfg.ReportModuleOverrides())
|
overrides, err := cfg.ReportModuleOverrides()
|
||||||
|
if err != nil {
|
||||||
|
return report.Registry{}, err
|
||||||
|
}
|
||||||
|
registry, err := report.DefaultRegistry().WithModuleOverrides(overrides)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return report.Registry{}, err
|
return report.Registry{}, err
|
||||||
}
|
}
|
||||||
return registry, nil
|
return registry, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func reportIDForCommand(kind ReportKind) (report.ID, error) {
|
|
||||||
switch kind {
|
|
||||||
case ReportDaily:
|
|
||||||
return report.DailyToday, nil
|
|
||||||
case ReportTomorrow:
|
|
||||||
return report.DailyTomorrow, nil
|
|
||||||
case ReportThreeDay:
|
|
||||||
return report.ThreeDay, nil
|
|
||||||
case ReportWeekend:
|
|
||||||
return report.Weekend, nil
|
|
||||||
case ReportStorm:
|
|
||||||
return report.Storm, nil
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("unknown report command %q", kind)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func reportBatchForCommand(kind BatchKind) (report.Batch, error) {
|
|
||||||
switch kind {
|
|
||||||
case BatchMorning:
|
|
||||||
return report.Morning, nil
|
|
||||||
case BatchEvening:
|
|
||||||
return report.Evening, nil
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("unknown batch command %q", kind)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func FetchBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) {
|
func FetchBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) {
|
||||||
client, err := weatherapi.New(req.Config)
|
client, err := weatherapi.New(req.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -487,6 +472,10 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
|||||||
DataPackage: paths.DataPackage,
|
DataPackage: paths.DataPackage,
|
||||||
Preflight: paths.Preflight,
|
Preflight: paths.Preflight,
|
||||||
RenderedReport: paths.RenderedReport,
|
RenderedReport: paths.RenderedReport,
|
||||||
|
GeneratedTextRaw: paths.GeneratedTextRaw,
|
||||||
|
GeneratedTextResult: paths.GeneratedTextResult,
|
||||||
|
GeneratedText: paths.GeneratedText,
|
||||||
|
RenderContext: paths.RenderContext,
|
||||||
})
|
})
|
||||||
dataPackage, err := promptinput.Build(promptinput.BuildRequest{
|
dataPackage, err := promptinput.Build(promptinput.BuildRequest{
|
||||||
Metadata: promptMetadata(metadata),
|
Metadata: promptMetadata(metadata),
|
||||||
@@ -531,9 +520,36 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
|||||||
return nil, metadataErr
|
return nil, metadataErr
|
||||||
}
|
}
|
||||||
if renderErr != nil {
|
if renderErr != nil {
|
||||||
|
if req.Resolved.Definition.GenerationMode == report.GenerationModeGeneratedTextTemplate {
|
||||||
|
return nil, generatedReportError(req.Resolved, metadata.RunID, "render preflight", renderErr)
|
||||||
|
}
|
||||||
return nil, renderErr
|
return nil, renderErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.Resolved.Definition.GenerationMode == report.GenerationModeGeneratedTextTemplate {
|
||||||
|
return generateTextTemplateReport(ctx, generatedReportRequest{
|
||||||
|
ReportRequest: req,
|
||||||
|
store: store,
|
||||||
|
paths: paths,
|
||||||
|
moduleSnapshot: moduleSnapshot,
|
||||||
|
moduleSnapshotPath: moduleSnapshotPath,
|
||||||
|
reportFacts: reportFacts,
|
||||||
|
dataPackage: dataPackage,
|
||||||
|
dataPackagePath: dataPackagePath,
|
||||||
|
briefingMetadata: briefingMetadata,
|
||||||
|
metadata: metadata,
|
||||||
|
metadataPath: metadataPath,
|
||||||
|
preflightPath: preflightPath,
|
||||||
|
priorSnapshot: priorSnapshot,
|
||||||
|
recentChanges: recentChanges,
|
||||||
|
renderResult: renderResult,
|
||||||
|
renderer: renderer,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if req.Resolved.Definition.GenerationMode != report.GenerationModeScriptoriumMarkdown {
|
||||||
|
return nil, fmt.Errorf("generation mode %q is not supported for report %q", req.Resolved.Definition.GenerationMode, req.Resolved.Definition.ID)
|
||||||
|
}
|
||||||
|
|
||||||
reportPath, err := store.PrepareRenderedReport(ctx, req.Resolved)
|
reportPath, err := store.PrepareRenderedReport(ctx, req.Resolved)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -543,32 +559,16 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
|||||||
DataPackagePath: dataPackagePath,
|
DataPackagePath: dataPackagePath,
|
||||||
OutputPath: reportPath,
|
OutputPath: reportPath,
|
||||||
})
|
})
|
||||||
if runErr == nil && req.OutputPath != "" && req.OutputPath != reportPath {
|
finalized, err := finalizeRenderedReport(ctx, finalizeRenderedReportRequest{
|
||||||
if err := fileutil.CopyFileAtomic(reportPath, req.OutputPath); err != nil {
|
Config: req.Config,
|
||||||
return nil, err
|
Store: store,
|
||||||
}
|
Resolved: req.Resolved,
|
||||||
}
|
Metadata: metadata,
|
||||||
outputPath := reportPath
|
ManagedReportPath: reportPath,
|
||||||
if req.OutputPath != "" {
|
OutputPath: req.OutputPath,
|
||||||
outputPath = req.OutputPath
|
Notifier: req.Notifier,
|
||||||
}
|
GenerationErr: runErr,
|
||||||
metadata.RenderedReportPath = reportPath
|
})
|
||||||
metadataPath, metadataErr = store.SaveMetadata(ctx, metadata)
|
|
||||||
if metadataErr != nil {
|
|
||||||
return nil, metadataErr
|
|
||||||
}
|
|
||||||
if runErr != nil {
|
|
||||||
return nil, runErr
|
|
||||||
}
|
|
||||||
|
|
||||||
notification, notificationPath, err := notifyReport(ctx, req.Config, req.Resolved, reportPath, metadata, req.Notifier, store)
|
|
||||||
if notificationPath != "" {
|
|
||||||
metadata.NotificationPath = notificationPath
|
|
||||||
metadataPath, metadataErr = store.SaveMetadata(ctx, metadata)
|
|
||||||
if metadataErr != nil {
|
|
||||||
return nil, metadataErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -580,18 +580,214 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
|||||||
DataPackagePath: dataPackagePath,
|
DataPackagePath: dataPackagePath,
|
||||||
PreflightPath: preflightPath,
|
PreflightPath: preflightPath,
|
||||||
ReportPath: reportPath,
|
ReportPath: reportPath,
|
||||||
OutputPath: outputPath,
|
OutputPath: finalized.OutputPath,
|
||||||
NotificationPath: notificationPath,
|
NotificationPath: finalized.NotificationPath,
|
||||||
Metadata: metadata,
|
Metadata: finalized.Metadata,
|
||||||
MetadataPath: metadataPath,
|
MetadataPath: finalized.MetadataPath,
|
||||||
PriorSnapshot: priorSnapshot,
|
PriorSnapshot: priorSnapshot,
|
||||||
RecentChanges: recentChanges,
|
RecentChanges: recentChanges,
|
||||||
RenderResult: renderResult,
|
RenderResult: renderResult,
|
||||||
RunResult: runResult,
|
RunResult: runResult,
|
||||||
Notification: notification,
|
Notification: finalized.Notification,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type generatedReportRequest struct {
|
||||||
|
ReportRequest
|
||||||
|
store state.Store
|
||||||
|
paths state.ArtifactPaths
|
||||||
|
moduleSnapshot module.Snapshot
|
||||||
|
moduleSnapshotPath string
|
||||||
|
reportFacts ReportFacts
|
||||||
|
dataPackage promptinput.Package
|
||||||
|
dataPackagePath string
|
||||||
|
briefingMetadata briefing.Metadata
|
||||||
|
metadata state.Metadata
|
||||||
|
metadataPath string
|
||||||
|
preflightPath string
|
||||||
|
priorSnapshot *state.PriorSnapshot
|
||||||
|
recentChanges []changes.Change
|
||||||
|
renderResult *scriptorium.RenderResult
|
||||||
|
renderer Renderer
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateTextTemplateReport(ctx context.Context, req generatedReportRequest) (*ReportResult, error) {
|
||||||
|
handler, err := generatedtext.LookupDefinition(req.Resolved.Definition)
|
||||||
|
if err != nil {
|
||||||
|
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "lookup generated text catalog", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
structuredResult, runErr := req.renderer.StructuredRun(ctx, scriptorium.StructuredRunRequest{
|
||||||
|
PromptID: req.Resolved.Definition.PromptID,
|
||||||
|
DataPackagePath: req.dataPackagePath,
|
||||||
|
OutputPath: req.paths.GeneratedTextRaw,
|
||||||
|
})
|
||||||
|
generatedTextResultPath := req.paths.GeneratedTextResult
|
||||||
|
if structuredResult != nil {
|
||||||
|
var err error
|
||||||
|
generatedTextResultPath, err = req.store.SaveGeneratedTextResult(ctx, req.Resolved, structuredResult)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.metadata.GeneratedTextResultPath = generatedTextResultPath
|
||||||
|
req.metadataPath, err = req.store.SaveMetadata(ctx, req.metadata)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if runErr != nil {
|
||||||
|
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "structured generated text", runErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
rawGeneratedText, err := req.store.LoadGeneratedText(ctx, req.paths.GeneratedTextRaw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "load raw generated text", err)
|
||||||
|
}
|
||||||
|
generatedText, normalizedGeneratedText, err := handler.Validate(rawGeneratedText)
|
||||||
|
if err != nil {
|
||||||
|
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "validate generated text", err)
|
||||||
|
}
|
||||||
|
generatedTextPath, err := req.store.SaveGeneratedText(ctx, req.Resolved, normalizedGeneratedText)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.metadata.GeneratedTextPath = generatedTextPath
|
||||||
|
req.metadataPath, err = req.store.SaveMetadata(ctx, req.metadata)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
renderContext, err := handler.BuildRenderContext(req.briefingMetadata, req.moduleSnapshot, req.reportFacts.Collected, req.reportFacts.Derived, generatedText)
|
||||||
|
if err != nil {
|
||||||
|
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "build render context", err)
|
||||||
|
}
|
||||||
|
renderContextPath, err := req.store.SaveRenderContext(ctx, req.Resolved, renderContext)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.metadata.RenderContextPath = renderContextPath
|
||||||
|
req.metadataPath, err = req.store.SaveMetadata(ctx, req.metadata)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered, err := handler.Render(renderContext)
|
||||||
|
if err != nil {
|
||||||
|
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "render template", err)
|
||||||
|
}
|
||||||
|
reportPath, err := req.store.PrepareRenderedReport(ctx, req.Resolved)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := fileutil.WriteFileAtomic(reportPath, rendered); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
finalized, err := finalizeRenderedReport(ctx, finalizeRenderedReportRequest{
|
||||||
|
Config: req.Config,
|
||||||
|
Store: req.store,
|
||||||
|
Resolved: req.Resolved,
|
||||||
|
Metadata: req.metadata,
|
||||||
|
ManagedReportPath: reportPath,
|
||||||
|
OutputPath: req.OutputPath,
|
||||||
|
Notifier: req.Notifier,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ReportResult{
|
||||||
|
ModuleSnapshot: req.moduleSnapshot,
|
||||||
|
ModuleSnapshotPath: req.moduleSnapshotPath,
|
||||||
|
DataPackage: req.dataPackage,
|
||||||
|
DataPackagePath: req.dataPackagePath,
|
||||||
|
PreflightPath: req.preflightPath,
|
||||||
|
ReportPath: reportPath,
|
||||||
|
OutputPath: finalized.OutputPath,
|
||||||
|
NotificationPath: finalized.NotificationPath,
|
||||||
|
Metadata: finalized.Metadata,
|
||||||
|
MetadataPath: finalized.MetadataPath,
|
||||||
|
PriorSnapshot: req.priorSnapshot,
|
||||||
|
RecentChanges: req.recentChanges,
|
||||||
|
RenderResult: req.renderResult,
|
||||||
|
StructuredRunResult: structuredResult,
|
||||||
|
GeneratedTextRawPath: req.paths.GeneratedTextRaw,
|
||||||
|
GeneratedTextResultPath: generatedTextResultPath,
|
||||||
|
GeneratedTextPath: generatedTextPath,
|
||||||
|
RenderContextPath: renderContextPath,
|
||||||
|
Notification: finalized.Notification,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type finalizeRenderedReportRequest struct {
|
||||||
|
Config config.Config
|
||||||
|
Store state.Store
|
||||||
|
Resolved report.Resolved
|
||||||
|
Metadata state.Metadata
|
||||||
|
ManagedReportPath string
|
||||||
|
OutputPath string
|
||||||
|
Notifier Notifier
|
||||||
|
GenerationErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
type finalizeRenderedReportResult struct {
|
||||||
|
OutputPath string
|
||||||
|
NotificationPath string
|
||||||
|
Metadata state.Metadata
|
||||||
|
MetadataPath string
|
||||||
|
Notification *NotificationResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizeRenderedReport(ctx context.Context, req finalizeRenderedReportRequest) (finalizeRenderedReportResult, error) {
|
||||||
|
if req.Store == nil {
|
||||||
|
return finalizeRenderedReportResult{}, fmt.Errorf("state store is required")
|
||||||
|
}
|
||||||
|
if req.ManagedReportPath == "" {
|
||||||
|
return finalizeRenderedReportResult{}, fmt.Errorf("managed report path is required for report %q", req.Resolved.Definition.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := req.Metadata
|
||||||
|
metadata.RenderedReportPath = req.ManagedReportPath
|
||||||
|
outputPath := req.ManagedReportPath
|
||||||
|
if req.OutputPath != "" {
|
||||||
|
outputPath = req.OutputPath
|
||||||
|
if req.GenerationErr == nil && req.OutputPath != req.ManagedReportPath {
|
||||||
|
if err := fileutil.CopyFileAtomic(req.ManagedReportPath, req.OutputPath); err != nil {
|
||||||
|
return finalizeRenderedReportResult{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataPath, err := req.Store.SaveMetadata(ctx, metadata)
|
||||||
|
if err != nil {
|
||||||
|
return finalizeRenderedReportResult{}, err
|
||||||
|
}
|
||||||
|
result := finalizeRenderedReportResult{
|
||||||
|
OutputPath: outputPath,
|
||||||
|
Metadata: metadata,
|
||||||
|
MetadataPath: metadataPath,
|
||||||
|
}
|
||||||
|
if req.GenerationErr != nil {
|
||||||
|
return result, req.GenerationErr
|
||||||
|
}
|
||||||
|
|
||||||
|
notification, notificationPath, err := notifyReport(ctx, req.Config, req.Resolved, req.ManagedReportPath, metadata, req.Notifier, req.Store)
|
||||||
|
if notificationPath != "" {
|
||||||
|
metadata.NotificationPath = notificationPath
|
||||||
|
metadataPath, saveErr := req.Store.SaveMetadata(ctx, metadata)
|
||||||
|
if saveErr != nil {
|
||||||
|
return finalizeRenderedReportResult{}, saveErr
|
||||||
|
}
|
||||||
|
result.Metadata = metadata
|
||||||
|
result.MetadataPath = metadataPath
|
||||||
|
result.NotificationPath = notificationPath
|
||||||
|
}
|
||||||
|
result.Notification = notification
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func notifyReport(ctx context.Context, cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata, notifier Notifier, store state.Store) (*NotificationResult, string, error) {
|
func notifyReport(ctx context.Context, cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata, notifier Notifier, store state.Store) (*NotificationResult, string, error) {
|
||||||
notifier, enabled := reportNotifier(cfg, notifier)
|
notifier, enabled := reportNotifier(cfg, notifier)
|
||||||
if !enabled {
|
if !enabled {
|
||||||
@@ -911,7 +1107,7 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.
|
|||||||
PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes,
|
PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes,
|
||||||
}
|
}
|
||||||
switch reportID {
|
switch reportID {
|
||||||
case report.DailyToday, report.DailyTomorrow:
|
case report.Daily, report.Today, report.Tomorrow:
|
||||||
return changes.CompareDaily(previous, current, thresholds)
|
return changes.CompareDaily(previous, current, thresholds)
|
||||||
case report.ThreeDay:
|
case report.ThreeDay:
|
||||||
return changes.CompareThreeDay(previous, current, thresholds)
|
return changes.CompareThreeDay(previous, current, thresholds)
|
||||||
@@ -935,3 +1131,10 @@ func preflightArtifact(result *scriptorium.RenderResult) state.PreflightArtifact
|
|||||||
ExitCode: result.ExitCode,
|
ExitCode: result.ExitCode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func generatedReportError(resolved report.Resolved, runID string, operation string, err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("generate report %q run %q: %s: %w", resolved.Definition.ID, runID, operation, err)
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -59,21 +59,24 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
|
|||||||
t.Fatalf("HourlyForecast = %#v, want hourly metadata and one valid-period period", value)
|
t.Fatalf("HourlyForecast = %#v, want hourly metadata and one valid-period period", value)
|
||||||
}
|
}
|
||||||
period := value.Periods[0]
|
period := value.Periods[0]
|
||||||
if period.TextDescription != "Showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 {
|
if period.HourLabel != "8:00 AM" || period.TextDescription != "Showers likely." || period.TextDescriptionLower != "showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 {
|
||||||
t.Fatalf("HourlyForecast period = %#v, want hourly period facts", period)
|
t.Fatalf("HourlyForecast period = %#v, want hourly period facts", period)
|
||||||
}
|
}
|
||||||
if period.StartTime != "2026-05-29 at 8:00 AM" || period.EndTime != "2026-05-29 at 9:00 AM" {
|
if period.PeriodBegins != "2026-05-29 at 8:00 AM" || period.PeriodEnds != "2026-05-29 at 9:00 AM" {
|
||||||
t.Fatalf("HourlyForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime)
|
t.Fatalf("HourlyForecast period times = %q/%q, want friendly local time labels", period.PeriodBegins, period.PeriodEnds)
|
||||||
}
|
}
|
||||||
if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
|
if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
|
||||||
t.Fatalf("HourlyForecast period = %#v, want compass wind and precip chance", period)
|
t.Fatalf("HourlyForecast period = %#v, want compass wind and precip chance", period)
|
||||||
}
|
}
|
||||||
|
if !period.MentionPrecipitation {
|
||||||
|
t.Fatalf("MentionPrecipitation = false, want true for default threshold")
|
||||||
|
}
|
||||||
data, err := json.Marshal(output.Value)
|
data, err := json.Marshal(output.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Marshal hourly forecast: %v", err)
|
t.Fatalf("Marshal hourly forecast: %v", err)
|
||||||
}
|
}
|
||||||
jsonText := string(data)
|
jsonText := string(data)
|
||||||
for _, field := range []string{"source_location_id", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} {
|
for _, field := range []string{"source_location_id", "hour_label", "period_begins", "period_ends", "text_description", "text_description_lower", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "mention_precipitation", "relative_humidity_percent"} {
|
||||||
if !strings.Contains(jsonText, field) {
|
if !strings.Contains(jsonText, field) {
|
||||||
t.Fatalf("hourly json = %s, want field %s", jsonText, field)
|
t.Fatalf("hourly json = %s, want field %s", jsonText, field)
|
||||||
}
|
}
|
||||||
@@ -81,8 +84,71 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
|
|||||||
if strings.Contains(jsonText, "wind_direction_degrees") || strings.Contains(jsonText, "Tomorrow") {
|
if strings.Contains(jsonText, "wind_direction_degrees") || strings.Contains(jsonText, "Tomorrow") {
|
||||||
t.Fatalf("hourly json = %s, want valid-period prompt fields only", jsonText)
|
t.Fatalf("hourly json = %s, want valid-period prompt fields only", jsonText)
|
||||||
}
|
}
|
||||||
if strings.Contains(jsonText, `"start_time":"2026-05-29T`) || strings.Contains(jsonText, `"end_time":"2026-05-29T`) {
|
if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) {
|
||||||
t.Fatalf("hourly json = %s, want friendly local start/end times", jsonText)
|
t.Fatalf("hourly json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHourlyForecastPromptExportOmitsTemplateHelpers(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
richText := mustMarshalModuleJSON(t, output.Value)
|
||||||
|
for _, field := range []string{"hour_label", "text_description_lower", "mention_precipitation"} {
|
||||||
|
if !strings.Contains(richText, field) {
|
||||||
|
t.Fatalf("rich hourly json = %s, want helper field %s", richText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt := moduleDataPackageValue[HourlyForecastPromptExport](t, output)
|
||||||
|
if prompt.Product != "hourly" || prompt.SourceLocationID != "test-grid" || len(prompt.Periods) != 1 {
|
||||||
|
t.Fatalf("hourly prompt export = %#v, want hourly metadata and one period", prompt)
|
||||||
|
}
|
||||||
|
period := prompt.Periods[0]
|
||||||
|
if period.PeriodBegins != "2026-05-29 at 8:00 AM" || period.PeriodEnds != "2026-05-29 at 9:00 AM" || period.TextDescription != "Showers likely." {
|
||||||
|
t.Fatalf("hourly prompt period = %#v, want factual period fields", period)
|
||||||
|
}
|
||||||
|
if period.TemperatureF == nil || *period.TemperatureF != 76 || period.WindSpeedMph == nil || *period.WindSpeedMph != 14 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
|
||||||
|
t.Fatalf("hourly prompt period = %#v, want temperature, wind, and precip fields", period)
|
||||||
|
}
|
||||||
|
if period.WindDirection != "S" || period.RelativeHumidityPercent == nil || *period.RelativeHumidityPercent != 66 {
|
||||||
|
t.Fatalf("hourly prompt period = %#v, want wind direction and humidity", period)
|
||||||
|
}
|
||||||
|
promptText := mustMarshalModuleJSON(t, output.DataPackageValue())
|
||||||
|
for _, field := range []string{"period_begins", "period_ends", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} {
|
||||||
|
if !strings.Contains(promptText, field) {
|
||||||
|
t.Fatalf("hourly prompt json = %s, want field %s", promptText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, field := range []string{"hour_label", "text_description_lower", "mention_precipitation"} {
|
||||||
|
if strings.Contains(promptText, field) {
|
||||||
|
t.Fatalf("hourly prompt json = %s, want omitted helper field %s", promptText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHourlyForecastPrecipMentionThreshold(t *testing.T) {
|
||||||
|
periods := []weatherdata.ForecastPeriod{
|
||||||
|
{StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"), ProbabilityOfPrecipitationPercent: floatPtr(19)},
|
||||||
|
{StartTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"), ProbabilityOfPrecipitationPercent: floatPtr(20)},
|
||||||
|
{StartTime: mustParseModuleTime("2026-05-29T10:00:00-05:00")},
|
||||||
|
}
|
||||||
|
value := hourlyForecastPeriodsWithPrecipMentionThreshold(periods, "America/Chicago", DefaultHourlyForecastPrecipMentionProbabilityThreshold)
|
||||||
|
if len(value) != 3 {
|
||||||
|
t.Fatalf("periods length = %d, want 3", len(value))
|
||||||
|
}
|
||||||
|
if value[0].MentionPrecipitation {
|
||||||
|
t.Fatalf("19%% MentionPrecipitation = true, want false")
|
||||||
|
}
|
||||||
|
if !value[1].MentionPrecipitation {
|
||||||
|
t.Fatalf("20%% MentionPrecipitation = false, want true")
|
||||||
|
}
|
||||||
|
if value[2].MentionPrecipitation {
|
||||||
|
t.Fatalf("nil MentionPrecipitation = true, want false")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +163,21 @@ func TestHourlyForecastModuleRejectsUnsupportedReports(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHourlyForecastModuleBuildsForHourly(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Hourly)
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
value := moduleValue[HourlyForecastModule](t, output)
|
||||||
|
if len(value.Periods) != 1 || value.Periods[0].TextDescription != "Showers likely." {
|
||||||
|
t.Fatalf("HourlyForecast = %#v, want hourly report period", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := testModuleContext()
|
ctx := testModuleContext()
|
||||||
@@ -113,8 +194,8 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
|||||||
if period.Name != "Today" || period.TextDescription != "Morning storms, then partly sunny." {
|
if period.Name != "Today" || period.TextDescription != "Morning storms, then partly sunny." {
|
||||||
t.Fatalf("NarrativeForecast period = %#v, want Today narrative", period)
|
t.Fatalf("NarrativeForecast period = %#v, want Today narrative", period)
|
||||||
}
|
}
|
||||||
if period.StartTime != "2026-05-29 at 6:00 AM" || period.EndTime != "2026-05-29 at 6:00 PM" {
|
if period.PeriodBegins != "2026-05-29 at 6:00 AM" || period.PeriodEnds != "2026-05-29 at 6:00 PM" {
|
||||||
t.Fatalf("NarrativeForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime)
|
t.Fatalf("NarrativeForecast period times = %q/%q, want friendly local time labels", period.PeriodBegins, period.PeriodEnds)
|
||||||
}
|
}
|
||||||
if period.IsDay == nil || !*period.IsDay || period.TemperatureF == nil || *period.TemperatureF != 81 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 60 {
|
if period.IsDay == nil || !*period.IsDay || period.TemperatureF == nil || *period.TemperatureF != 81 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 60 {
|
||||||
t.Fatalf("NarrativeForecast period = %#v, want day, temperature, and precip values", period)
|
t.Fatalf("NarrativeForecast period = %#v, want day, temperature, and precip values", period)
|
||||||
@@ -127,7 +208,7 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
|||||||
t.Fatalf("Marshal narrative forecast: %v", err)
|
t.Fatalf("Marshal narrative forecast: %v", err)
|
||||||
}
|
}
|
||||||
jsonText := string(data)
|
jsonText := string(data)
|
||||||
for _, field := range []string{"source_location_id", "text_description", "temperature_f", "wind_speed_mph", "wind_direction", "probability_of_precipitation_percent"} {
|
for _, field := range []string{"source_location_id", "period_begins", "period_ends", "text_description", "temperature_f", "wind_speed_mph", "wind_direction", "probability_of_precipitation_percent"} {
|
||||||
if !strings.Contains(jsonText, field) {
|
if !strings.Contains(jsonText, field) {
|
||||||
t.Fatalf("narrative json = %s, want field %s", jsonText, field)
|
t.Fatalf("narrative json = %s, want field %s", jsonText, field)
|
||||||
}
|
}
|
||||||
@@ -135,8 +216,8 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
|||||||
if strings.Contains(jsonText, "wind_direction_degrees") {
|
if strings.Contains(jsonText, "wind_direction_degrees") {
|
||||||
t.Fatalf("narrative json = %s, want compass wind_direction without degrees field", jsonText)
|
t.Fatalf("narrative json = %s, want compass wind_direction without degrees field", jsonText)
|
||||||
}
|
}
|
||||||
if strings.Contains(jsonText, `"start_time":"2026-05-29T`) || strings.Contains(jsonText, `"end_time":"2026-05-29T`) {
|
if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) {
|
||||||
t.Fatalf("narrative json = %s, want friendly local start/end times", jsonText)
|
t.Fatalf("narrative json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText)
|
||||||
}
|
}
|
||||||
if strings.Contains(jsonText, "Tomorrow night") {
|
if strings.Contains(jsonText, "Tomorrow night") {
|
||||||
t.Fatalf("narrative json = %s, want only valid-period narrative periods", jsonText)
|
t.Fatalf("narrative json = %s, want only valid-period narrative periods", jsonText)
|
||||||
@@ -163,7 +244,7 @@ func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
|
|||||||
t.Fatalf("BuildModule() error = %v", err)
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
}
|
}
|
||||||
value := moduleValue[MetadataModule](t, output)
|
value := moduleValue[MetadataModule](t, output)
|
||||||
if value.RunID == "" || value.ReportID != report.DailyToday || value.PromptID != "weather.daily_report" {
|
if value.RunID == "" || value.ReportID != report.Daily || value.PromptID != "weather.daily_generated_text" {
|
||||||
t.Fatalf("metadata = %#v, want report identity", value)
|
t.Fatalf("metadata = %#v, want report identity", value)
|
||||||
}
|
}
|
||||||
if value.Location == nil || value.Location.Name != "Brentwood" {
|
if value.Location == nil || value.Location.Name != "Brentwood" {
|
||||||
@@ -194,18 +275,24 @@ func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) {
|
|||||||
t.Fatalf("BuildModule() error = %v", err)
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
}
|
}
|
||||||
value := moduleValue[CurrentConditionsModule](t, output)
|
value := moduleValue[CurrentConditionsModule](t, output)
|
||||||
if value.ConditionText != "Partly cloudy" || value.TemperatureF == nil || *value.TemperatureF != 74 {
|
if value.ConditionText != "Partly cloudy" || value.ConditionTextLower != "partly cloudy" || value.TemperatureF == nil || *value.TemperatureF != 74 {
|
||||||
t.Fatalf("CurrentConditions = %#v, want current condition facts", value)
|
t.Fatalf("CurrentConditions = %#v, want rounded current condition facts", value)
|
||||||
|
}
|
||||||
|
if value.ApparentTemperatureF == nil || *value.ApparentTemperatureF != 76 || value.RelativeHumidityPercent == nil || *value.RelativeHumidityPercent != 71 || value.WindSpeedMph == nil || *value.WindSpeedMph != 8 {
|
||||||
|
t.Fatalf("CurrentConditions rounded values = %#v, want apparent 76, humidity 71, wind 8", value)
|
||||||
}
|
}
|
||||||
if value.WindDirection != "S" {
|
if value.WindDirection != "S" {
|
||||||
t.Fatalf("WindDirection = %q, want S", value.WindDirection)
|
t.Fatalf("WindDirection = %q, want S", value.WindDirection)
|
||||||
}
|
}
|
||||||
|
if value.WindDirectionText != "south" {
|
||||||
|
t.Fatalf("WindDirectionText = %q, want south", value.WindDirectionText)
|
||||||
|
}
|
||||||
data, err := json.Marshal(output.Value)
|
data, err := json.Marshal(output.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Marshal current conditions: %v", err)
|
t.Fatalf("Marshal current conditions: %v", err)
|
||||||
}
|
}
|
||||||
jsonText := string(data)
|
jsonText := string(data)
|
||||||
for _, field := range []string{"condition_text", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction"} {
|
for _, field := range []string{"condition_text", "condition_text_lower", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction", "wind_direction_text"} {
|
||||||
if !strings.Contains(jsonText, field) {
|
if !strings.Contains(jsonText, field) {
|
||||||
t.Fatalf("current json = %s, want field %s", jsonText, field)
|
t.Fatalf("current json = %s, want field %s", jsonText, field)
|
||||||
}
|
}
|
||||||
@@ -215,6 +302,44 @@ func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCurrentConditionsPromptExportOmitsTemplateHelpers(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.CurrentConditions})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
richText := mustMarshalModuleJSON(t, output.Value)
|
||||||
|
for _, field := range []string{"condition_text_lower", "wind_direction_text"} {
|
||||||
|
if !strings.Contains(richText, field) {
|
||||||
|
t.Fatalf("rich current conditions json = %s, want helper field %s", richText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt := moduleDataPackageValue[CurrentConditionsPromptExport](t, output)
|
||||||
|
if prompt.ConditionText != "Partly cloudy" || prompt.TemperatureF == nil || *prompt.TemperatureF != 74 {
|
||||||
|
t.Fatalf("current prompt export = %#v, want condition text and temperature", prompt)
|
||||||
|
}
|
||||||
|
if prompt.ApparentTemperatureF == nil || *prompt.ApparentTemperatureF != 76 || prompt.RelativeHumidityPercent == nil || *prompt.RelativeHumidityPercent != 71 || prompt.WindSpeedMph == nil || *prompt.WindSpeedMph != 8 {
|
||||||
|
t.Fatalf("current prompt export = %#v, want apparent temperature, humidity, and wind speed", prompt)
|
||||||
|
}
|
||||||
|
if prompt.WindDirection != "S" {
|
||||||
|
t.Fatalf("current prompt wind direction = %q, want S", prompt.WindDirection)
|
||||||
|
}
|
||||||
|
promptText := mustMarshalModuleJSON(t, output.DataPackageValue())
|
||||||
|
for _, field := range []string{"condition_text", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction"} {
|
||||||
|
if !strings.Contains(promptText, field) {
|
||||||
|
t.Fatalf("current prompt json = %s, want field %s", promptText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, field := range []string{"condition_text_lower", "wind_direction_text"} {
|
||||||
|
if strings.Contains(promptText, field) {
|
||||||
|
t.Fatalf("current prompt json = %s, want omitted helper field %s", promptText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) {
|
func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := testModuleContext()
|
ctx := testModuleContext()
|
||||||
@@ -285,13 +410,19 @@ func TestAreaForecastDiscussionAndWeatherStoryModules(t *testing.T) {
|
|||||||
if !story.Available || story.Title != "Rain Chances" || story.Description != "Scattered showers are possible." {
|
if !story.Available || story.Title != "Rain Chances" || story.Description != "Scattered showers are possible." {
|
||||||
t.Fatalf("WeatherStory = %#v, want structured story fields", story)
|
t.Fatalf("WeatherStory = %#v, want structured story fields", story)
|
||||||
}
|
}
|
||||||
|
if story.PeriodBegins != "2026-05-29 at 6:00 AM" || story.PeriodEnds != "2026-05-29 at 6:00 PM" {
|
||||||
|
t.Fatalf("WeatherStory period = %q/%q, want friendly local period labels", story.PeriodBegins, story.PeriodEnds)
|
||||||
|
}
|
||||||
data, err := json.Marshal(storyOutput.Value)
|
data, err := json.Marshal(storyOutput.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Marshal weather story: %v", err)
|
t.Fatalf("Marshal weather story: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(data), "download_url") {
|
if !strings.Contains(string(data), "download_url") || !strings.Contains(string(data), "period_begins") || !strings.Contains(string(data), "period_ends") {
|
||||||
t.Fatalf("weather story json = %s, want snake_case download_url", string(data))
|
t.Fatalf("weather story json = %s, want snake_case download_url", string(data))
|
||||||
}
|
}
|
||||||
|
if strings.Contains(string(data), "start_time") || strings.Contains(string(data), "end_time") {
|
||||||
|
t.Fatalf("weather story json = %s, want period_begins/period_ends instead of start_time/end_time", string(data))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
|
func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
|
||||||
@@ -314,9 +445,37 @@ func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAreaForecastDiscussionModuleUsesHourlyDefaultSections(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Hourly)
|
||||||
|
var item module.ConfigItem
|
||||||
|
for _, candidate := range ctx.Resolved.Definition.Modules {
|
||||||
|
if candidate.ID == module.AreaForecastDiscussion {
|
||||||
|
item = candidate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if item.ID == "" {
|
||||||
|
t.Fatal("hourly default modules missing area_forecast_discussion")
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, item)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
afd := moduleValue[AreaForecastDiscussionModule](t, output)
|
||||||
|
if len(afd.KeyMessages) != 1 || afd.ShortTerm != "Showers increase this afternoon." {
|
||||||
|
t.Fatalf("AFD = %#v, want key messages and short term", afd)
|
||||||
|
}
|
||||||
|
if afd.Product != "" || afd.LongTerm != "" {
|
||||||
|
t.Fatalf("AFD = %#v, want product and long term omitted", afd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testModuleContext() ModuleContext {
|
func testModuleContext() ModuleContext {
|
||||||
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
|
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
|
||||||
definition := report.DefaultRegistry().MustLookup(report.DailyToday)
|
definition := report.DefaultRegistry().MustLookup(report.Daily)
|
||||||
resolved := report.Resolved{
|
resolved := report.Resolved{
|
||||||
Definition: definition,
|
Definition: definition,
|
||||||
GeneratedAt: generatedAt,
|
GeneratedAt: generatedAt,
|
||||||
@@ -327,10 +486,10 @@ func testModuleContext() ModuleContext {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
isDay := true
|
isDay := true
|
||||||
tempF := 74.0
|
tempF := 74.4
|
||||||
apparentF := 76.0
|
apparentF := 75.6
|
||||||
humidity := 71.0
|
humidity := 70.6
|
||||||
windMph := 8.0
|
windMph := 8.4
|
||||||
windDirection := 190.0
|
windDirection := 190.0
|
||||||
narrativeTempF := 81.0
|
narrativeTempF := 81.0
|
||||||
narrativePop := 60.0
|
narrativePop := 60.0
|
||||||
@@ -484,6 +643,28 @@ func moduleValue[T any](t *testing.T, output *module.Output) T {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func moduleDataPackageValue[T any](t *testing.T, output *module.Output) T {
|
||||||
|
t.Helper()
|
||||||
|
var value T
|
||||||
|
data, err := json.Marshal(output.DataPackageValue())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal module data package value: %v", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &value); err != nil {
|
||||||
|
t.Fatalf("decode module data package value: %v", err)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustMarshalModuleJSON(t *testing.T, value any) string {
|
||||||
|
t.Helper()
|
||||||
|
data, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal module value: %v", err)
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
func mustParseModuleTime(value string) time.Time {
|
func mustParseModuleTime(value string) time.Time {
|
||||||
parsed, err := time.Parse(time.RFC3339, value)
|
parsed, err := time.Parse(time.RFC3339, value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,19 +1,40 @@
|
|||||||
package briefing
|
package briefing
|
||||||
|
|
||||||
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
)
|
||||||
|
|
||||||
type CurrentConditionsModule struct {
|
type CurrentConditionsModule struct {
|
||||||
ConditionText string `json:"condition_text,omitempty"`
|
ConditionText string `json:"condition_text,omitempty"`
|
||||||
|
ConditionTextLower string `json:"condition_text_lower,omitempty"`
|
||||||
IsDay *bool `json:"is_day,omitempty"`
|
IsDay *bool `json:"is_day,omitempty"`
|
||||||
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
TemperatureC *int `json:"temperature_c,omitempty"`
|
||||||
TemperatureF *float64 `json:"temperature_f,omitempty"`
|
TemperatureF *int `json:"temperature_f,omitempty"`
|
||||||
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"`
|
ApparentTemperatureC *int `json:"apparent_temperature_c,omitempty"`
|
||||||
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"`
|
ApparentTemperatureF *int `json:"apparent_temperature_f,omitempty"`
|
||||||
DewpointC *float64 `json:"dewpoint_c,omitempty"`
|
DewpointC *int `json:"dewpoint_c,omitempty"`
|
||||||
DewpointF *float64 `json:"dewpoint_f,omitempty"`
|
DewpointF *int `json:"dewpoint_f,omitempty"`
|
||||||
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
|
RelativeHumidityPercent *int `json:"relative_humidity_percent,omitempty"`
|
||||||
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
|
WindSpeedKmh *int `json:"wind_speed_kmh,omitempty"`
|
||||||
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
|
WindSpeedMph *int `json:"wind_speed_mph,omitempty"`
|
||||||
|
WindDirection string `json:"wind_direction,omitempty"`
|
||||||
|
WindDirectionText string `json:"wind_direction_text,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CurrentConditionsPromptExport struct {
|
||||||
|
ConditionText string `json:"condition_text,omitempty"`
|
||||||
|
IsDay *bool `json:"is_day,omitempty"`
|
||||||
|
TemperatureC *int `json:"temperature_c,omitempty"`
|
||||||
|
TemperatureF *int `json:"temperature_f,omitempty"`
|
||||||
|
ApparentTemperatureC *int `json:"apparent_temperature_c,omitempty"`
|
||||||
|
ApparentTemperatureF *int `json:"apparent_temperature_f,omitempty"`
|
||||||
|
DewpointC *int `json:"dewpoint_c,omitempty"`
|
||||||
|
DewpointF *int `json:"dewpoint_f,omitempty"`
|
||||||
|
RelativeHumidityPercent *int `json:"relative_humidity_percent,omitempty"`
|
||||||
|
WindSpeedKmh *int `json:"wind_speed_kmh,omitempty"`
|
||||||
|
WindSpeedMph *int `json:"wind_speed_mph,omitempty"`
|
||||||
WindDirection string `json:"wind_direction,omitempty"`
|
WindDirection string `json:"wind_direction,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,17 +45,19 @@ func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, err
|
|||||||
}
|
}
|
||||||
value := CurrentConditionsModule{
|
value := CurrentConditionsModule{
|
||||||
ConditionText: current.ConditionText,
|
ConditionText: current.ConditionText,
|
||||||
|
ConditionTextLower: strings.ToLower(current.ConditionText),
|
||||||
IsDay: copyBool(current.IsDay),
|
IsDay: copyBool(current.IsDay),
|
||||||
TemperatureC: copyFloat(current.TemperatureC),
|
TemperatureC: roundedInt(current.TemperatureC),
|
||||||
TemperatureF: copyFloat(current.TemperatureF),
|
TemperatureF: roundedInt(current.TemperatureF),
|
||||||
ApparentTemperatureC: copyFloat(current.ApparentTemperatureC),
|
ApparentTemperatureC: roundedInt(current.ApparentTemperatureC),
|
||||||
ApparentTemperatureF: copyFloat(current.ApparentTemperatureF),
|
ApparentTemperatureF: roundedInt(current.ApparentTemperatureF),
|
||||||
DewpointC: copyFloat(current.DewpointC),
|
DewpointC: roundedInt(current.DewpointC),
|
||||||
DewpointF: copyFloat(current.DewpointF),
|
DewpointF: roundedInt(current.DewpointF),
|
||||||
RelativeHumidityPercent: copyFloat(current.RelativeHumidityPercent),
|
RelativeHumidityPercent: roundedInt(current.RelativeHumidityPercent),
|
||||||
WindSpeedKmh: copyFloat(current.WindSpeedKmh),
|
WindSpeedKmh: roundedInt(current.WindSpeedKmh),
|
||||||
WindSpeedMph: copyFloat(current.WindSpeedMph),
|
WindSpeedMph: roundedInt(current.WindSpeedMph),
|
||||||
WindDirection: windDirectionLabel(current.WindDirectionDegrees),
|
WindDirection: windDirectionLabel(current.WindDirectionDegrees),
|
||||||
|
WindDirectionText: windDirectionTextLabel(current.WindDirectionDegrees),
|
||||||
}
|
}
|
||||||
if value.isEmpty() {
|
if value.isEmpty() {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -42,8 +65,30 @@ func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, err
|
|||||||
return &module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: value}, nil
|
return &module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: value}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func exportCurrentConditionsPromptValue(value any) (any, error) {
|
||||||
|
rich, ok := value.(CurrentConditionsModule)
|
||||||
|
if !ok {
|
||||||
|
return nil, unexpectedPromptExportValue(value, CurrentConditionsModule{})
|
||||||
|
}
|
||||||
|
return CurrentConditionsPromptExport{
|
||||||
|
ConditionText: rich.ConditionText,
|
||||||
|
IsDay: copyBool(rich.IsDay),
|
||||||
|
TemperatureC: copyInt(rich.TemperatureC),
|
||||||
|
TemperatureF: copyInt(rich.TemperatureF),
|
||||||
|
ApparentTemperatureC: copyInt(rich.ApparentTemperatureC),
|
||||||
|
ApparentTemperatureF: copyInt(rich.ApparentTemperatureF),
|
||||||
|
DewpointC: copyInt(rich.DewpointC),
|
||||||
|
DewpointF: copyInt(rich.DewpointF),
|
||||||
|
RelativeHumidityPercent: copyInt(rich.RelativeHumidityPercent),
|
||||||
|
WindSpeedKmh: copyInt(rich.WindSpeedKmh),
|
||||||
|
WindSpeedMph: copyInt(rich.WindSpeedMph),
|
||||||
|
WindDirection: rich.WindDirection,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (v CurrentConditionsModule) isEmpty() bool {
|
func (v CurrentConditionsModule) isEmpty() bool {
|
||||||
return v.ConditionText == "" &&
|
return v.ConditionText == "" &&
|
||||||
|
v.ConditionTextLower == "" &&
|
||||||
v.IsDay == nil &&
|
v.IsDay == nil &&
|
||||||
v.TemperatureC == nil &&
|
v.TemperatureC == nil &&
|
||||||
v.TemperatureF == nil &&
|
v.TemperatureF == nil &&
|
||||||
@@ -54,5 +99,6 @@ func (v CurrentConditionsModule) isEmpty() bool {
|
|||||||
v.RelativeHumidityPercent == nil &&
|
v.RelativeHumidityPercent == nil &&
|
||||||
v.WindSpeedKmh == nil &&
|
v.WindSpeedKmh == nil &&
|
||||||
v.WindSpeedMph == nil &&
|
v.WindSpeedMph == nil &&
|
||||||
v.WindDirection == ""
|
v.WindDirection == "" &&
|
||||||
|
v.WindDirectionText == ""
|
||||||
}
|
}
|
||||||
|
|||||||
24
internal/briefing/daily_planning_module.go
Normal file
24
internal/briefing/daily_planning_module.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
|
||||||
|
type DailyPlanningModule struct {
|
||||||
|
MorningReadiness []string `json:"morning_readiness,omitempty"`
|
||||||
|
CommuteSchoolWorkdayConcerns []string `json:"commute_school_workday_concerns,omitempty"`
|
||||||
|
OvernightChangeWatch []string `json:"overnight_change_watch,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDailyPlanningModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
summary := ctx.Derived.FirstDailySummary()
|
||||||
|
if summary == nil {
|
||||||
|
return &module.Output{ID: module.DailyPlanning, StanzaName: "daily_planning", Value: DailyPlanningModule{}}, nil
|
||||||
|
}
|
||||||
|
planning := buildMorningCommuteOvernightPlanning(summary)
|
||||||
|
value := DailyPlanningModule{}
|
||||||
|
if planning != nil {
|
||||||
|
value.MorningReadiness = append([]string(nil), planning.MorningReadiness...)
|
||||||
|
value.CommuteSchoolWorkdayConcerns = append([]string(nil), planning.CommuteSchoolWorkdayConcerns...)
|
||||||
|
value.OvernightChangeWatch = append([]string(nil), planning.OvernightChangeWatch...)
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.DailyPlanning, StanzaName: "daily_planning", Value: value}, nil
|
||||||
|
}
|
||||||
@@ -2,24 +2,66 @@ package briefing
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DerivedDaypartSummaryModule struct {
|
type DerivedDaypartSummaryModule struct {
|
||||||
Date string `json:"date,omitempty"`
|
Date string `json:"date,omitempty"`
|
||||||
Period string `json:"period,omitempty"`
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
|
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||||
|
TemperaturePhraseF string `json:"temperature_phrase_f,omitempty"`
|
||||||
|
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
||||||
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||||
|
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||||
|
MaxPopTimeLabel string `json:"max_pop_time_label,omitempty"`
|
||||||
|
MentionPrecipitation bool `json:"mention_precipitation,omitempty"`
|
||||||
|
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||||
|
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
|
||||||
|
DominantCondition string `json:"dominant_condition,omitempty"`
|
||||||
|
DominantConditionLower string `json:"dominant_condition_lower,omitempty"`
|
||||||
|
DominantConditionDisplay string `json:"dominant_condition_display,omitempty"`
|
||||||
|
TemperatureTrend string `json:"temperature_trend,omitempty"`
|
||||||
|
TemperatureStartPhraseF string `json:"temperature_start_phrase_f,omitempty"`
|
||||||
|
TemperatureEndPhraseF string `json:"temperature_end_phrase_f,omitempty"`
|
||||||
|
TemperaturePeakPhraseF string `json:"temperature_peak_phrase_f,omitempty"`
|
||||||
|
TemperatureSteadyPhraseF string `json:"temperature_steady_phrase_f,omitempty"`
|
||||||
|
NotableConditions []string `json:"notable_conditions,omitempty"`
|
||||||
|
Snow bool `json:"snow,omitempty"`
|
||||||
|
Ice bool `json:"ice,omitempty"`
|
||||||
|
Fog bool `json:"fog,omitempty"`
|
||||||
|
Heat bool `json:"heat,omitempty"`
|
||||||
|
Cold bool `json:"cold,omitempty"`
|
||||||
|
Wind bool `json:"wind,omitempty"`
|
||||||
|
RelevantAlertCount int `json:"relevant_alert_count,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DerivedDaypartSummaryPromptExport struct {
|
||||||
|
Date string `json:"date,omitempty"`
|
||||||
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
TempRangeF string `json:"temp_range_f,omitempty"`
|
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||||
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
||||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||||
|
MentionPrecipitation bool `json:"mention_precipitation,omitempty"`
|
||||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||||
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
|
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
|
||||||
DominantCondition string `json:"dominant_condition,omitempty"`
|
DominantCondition string `json:"dominant_condition,omitempty"`
|
||||||
|
TemperatureTrend string `json:"temperature_trend,omitempty"`
|
||||||
|
TemperatureStartPhraseF string `json:"temperature_start_phrase_f,omitempty"`
|
||||||
|
TemperatureEndPhraseF string `json:"temperature_end_phrase_f,omitempty"`
|
||||||
|
TemperaturePeakPhraseF string `json:"temperature_peak_phrase_f,omitempty"`
|
||||||
|
TemperatureSteadyPhraseF string `json:"temperature_steady_phrase_f,omitempty"`
|
||||||
NotableConditions []string `json:"notable_conditions,omitempty"`
|
NotableConditions []string `json:"notable_conditions,omitempty"`
|
||||||
Snow bool `json:"snow,omitempty"`
|
Snow bool `json:"snow,omitempty"`
|
||||||
Ice bool `json:"ice,omitempty"`
|
Ice bool `json:"ice,omitempty"`
|
||||||
@@ -43,13 +85,70 @@ func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Outpu
|
|||||||
return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil
|
return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func exportDerivedDaypartSummariesPromptValue(value any) (any, error) {
|
||||||
|
rich, ok := value.(map[string]DerivedDaypartSummaryModule)
|
||||||
|
if !ok {
|
||||||
|
return nil, unexpectedPromptExportValue(value, map[string]DerivedDaypartSummaryModule{})
|
||||||
|
}
|
||||||
|
out := make(map[string]DerivedDaypartSummaryPromptExport, len(rich))
|
||||||
|
for key, daypart := range rich {
|
||||||
|
out[key] = derivedDaypartSummaryPromptValue(daypart)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func derivedDaypartSummaryPromptValue(rich DerivedDaypartSummaryModule) DerivedDaypartSummaryPromptExport {
|
||||||
|
maxPopTime := rich.MaxPopTime
|
||||||
|
if rich.MaxPopTimeLabel != "" {
|
||||||
|
maxPopTime = rich.MaxPopTimeLabel
|
||||||
|
}
|
||||||
|
return DerivedDaypartSummaryPromptExport{
|
||||||
|
Date: rich.Date,
|
||||||
|
DisplayName: rich.DisplayName,
|
||||||
|
PeriodBegins: rich.PeriodBegins,
|
||||||
|
PeriodEnds: rich.PeriodEnds,
|
||||||
|
TempRangeF: rich.TempRangeF,
|
||||||
|
ApparentTempRangeF: rich.ApparentTempRangeF,
|
||||||
|
MaxPopPercent: copyInt(rich.MaxPopPercent),
|
||||||
|
MaxPopTime: maxPopTime,
|
||||||
|
MentionPrecipitation: rich.MentionPrecipitation,
|
||||||
|
MaxWindGustMph: copyInt(rich.MaxWindGustMph),
|
||||||
|
MaxWindGustTime: rich.MaxWindGustTime,
|
||||||
|
DominantCondition: rich.DominantCondition,
|
||||||
|
TemperatureTrend: rich.TemperatureTrend,
|
||||||
|
TemperatureStartPhraseF: rich.TemperatureStartPhraseF,
|
||||||
|
TemperatureEndPhraseF: rich.TemperatureEndPhraseF,
|
||||||
|
TemperaturePeakPhraseF: rich.TemperaturePeakPhraseF,
|
||||||
|
TemperatureSteadyPhraseF: rich.TemperatureSteadyPhraseF,
|
||||||
|
NotableConditions: append([]string(nil), rich.NotableConditions...),
|
||||||
|
Snow: rich.Snow,
|
||||||
|
Ice: rich.Ice,
|
||||||
|
Fog: rich.Fog,
|
||||||
|
Heat: rich.Heat,
|
||||||
|
Cold: rich.Cold,
|
||||||
|
Wind: rich.Wind,
|
||||||
|
RelevantAlertCount: rich.RelevantAlertCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
|
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
|
||||||
|
temperature := daypartTemperatureDisplay(daypart)
|
||||||
value := DerivedDaypartSummaryModule{
|
value := DerivedDaypartSummaryModule{
|
||||||
Date: localDateLabel(daypart.Period.Start, timezone),
|
Date: localDateLabel(daypart.Period.Start, timezone),
|
||||||
Period: friendlyPeriodLabel(daypart.Period, timezone),
|
DisplayName: titleWord(strings.TrimSpace(daypart.Name)),
|
||||||
|
PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone),
|
||||||
|
PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone),
|
||||||
TempRangeF: rangeLabel(daypart.Temperature),
|
TempRangeF: rangeLabel(daypart.Temperature),
|
||||||
|
TemperaturePhraseF: temperaturePhraseF(daypart.Temperature),
|
||||||
|
TemperatureTrend: temperature.Trend,
|
||||||
|
TemperatureStartPhraseF: temperature.StartPhrase,
|
||||||
|
TemperatureEndPhraseF: temperature.EndPhrase,
|
||||||
|
TemperaturePeakPhraseF: temperature.PeakPhrase,
|
||||||
|
TemperatureSteadyPhraseF: temperature.SteadyPhrase,
|
||||||
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
|
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
|
||||||
DominantCondition: daypart.DominantCondition,
|
DominantCondition: daypart.DominantCondition,
|
||||||
|
DominantConditionLower: strings.ToLower(daypart.DominantCondition),
|
||||||
|
DominantConditionDisplay: sentenceCase(daypart.DominantCondition),
|
||||||
NotableConditions: append([]string(nil), daypart.NotableConditions...),
|
NotableConditions: append([]string(nil), daypart.NotableConditions...),
|
||||||
Snow: daypart.Indicators.Snow,
|
Snow: daypart.Indicators.Snow,
|
||||||
Ice: daypart.Indicators.Ice,
|
Ice: daypart.Indicators.Ice,
|
||||||
@@ -62,6 +161,8 @@ func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string
|
|||||||
if daypart.MaxPrecipitationProbability != nil {
|
if daypart.MaxPrecipitationProbability != nil {
|
||||||
value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value)
|
value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value)
|
||||||
value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone)
|
value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone)
|
||||||
|
value.MaxPopTimeLabel = hourMinuteLabel(daypart.MaxPrecipitationProbability.Time, timezone)
|
||||||
|
value.MentionPrecipitation = mentionHourlyForecastPrecipitation(&daypart.MaxPrecipitationProbability.Value, DefaultHourlyForecastPrecipMentionProbabilityThreshold)
|
||||||
}
|
}
|
||||||
if daypart.PeakWindGust != nil {
|
if daypart.PeakWindGust != nil {
|
||||||
value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value)
|
value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value)
|
||||||
@@ -70,6 +171,208 @@ func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type daypartTemperaturePresentation struct {
|
||||||
|
Trend string
|
||||||
|
StartPhrase string
|
||||||
|
EndPhrase string
|
||||||
|
PeakPhrase string
|
||||||
|
SteadyPhrase string
|
||||||
|
}
|
||||||
|
|
||||||
|
type temperaturePoint struct {
|
||||||
|
value int
|
||||||
|
bandIndex int
|
||||||
|
phrase string
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
temperatureTrendRising = "rising"
|
||||||
|
temperatureTrendFalling = "falling"
|
||||||
|
temperatureTrendPeaking = "peaking"
|
||||||
|
temperatureTrendSteady = "steady"
|
||||||
|
)
|
||||||
|
|
||||||
|
func daypartTemperatureDisplay(daypart forecast.DaypartSummary) daypartTemperaturePresentation {
|
||||||
|
points := daypartTemperaturePoints(daypart.HourlyPeriods)
|
||||||
|
if len(points) == 0 {
|
||||||
|
return daypartSteadyTemperatureDisplay(temperaturePhraseF(daypart.Temperature))
|
||||||
|
}
|
||||||
|
if len(points) == 1 {
|
||||||
|
return daypartSteadyTemperatureDisplay(points[0].phrase)
|
||||||
|
}
|
||||||
|
|
||||||
|
first := points[0]
|
||||||
|
last := points[len(points)-1]
|
||||||
|
peak, peakIndex := peakTemperaturePoint(points)
|
||||||
|
if peakIndex > 0 && peakIndex < len(points)-1 && peak.bandIndex > first.bandIndex && peak.bandIndex > last.bandIndex {
|
||||||
|
return daypartTemperaturePresentation{
|
||||||
|
Trend: temperatureTrendPeaking,
|
||||||
|
PeakPhrase: peak.phrase,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case first.bandIndex < last.bandIndex:
|
||||||
|
return daypartTemperaturePresentation{
|
||||||
|
Trend: temperatureTrendRising,
|
||||||
|
StartPhrase: first.phrase,
|
||||||
|
EndPhrase: last.phrase,
|
||||||
|
}
|
||||||
|
case first.bandIndex > last.bandIndex:
|
||||||
|
return daypartTemperaturePresentation{
|
||||||
|
Trend: temperatureTrendFalling,
|
||||||
|
StartPhrase: first.phrase,
|
||||||
|
EndPhrase: last.phrase,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return daypartSteadyTemperatureDisplay(temperaturePhraseF(daypart.Temperature))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func daypartSteadyTemperatureDisplay(phrase string) daypartTemperaturePresentation {
|
||||||
|
if phrase == "" {
|
||||||
|
return daypartTemperaturePresentation{}
|
||||||
|
}
|
||||||
|
return daypartTemperaturePresentation{
|
||||||
|
Trend: temperatureTrendSteady,
|
||||||
|
SteadyPhrase: phrase,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func daypartTemperaturePoints(periods []weatherdata.ForecastPeriod) []temperaturePoint {
|
||||||
|
sorted := append([]weatherdata.ForecastPeriod(nil), periods...)
|
||||||
|
sort.SliceStable(sorted, func(i int, j int) bool {
|
||||||
|
return sorted[i].StartTime.Before(sorted[j].StartTime)
|
||||||
|
})
|
||||||
|
points := make([]temperaturePoint, 0, len(sorted))
|
||||||
|
for _, period := range sorted {
|
||||||
|
temperature := forecastPeriodTemperatureF(period)
|
||||||
|
if temperature == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rounded := roundedInt(temperature)
|
||||||
|
if rounded == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
points = append(points, temperaturePoint{
|
||||||
|
value: *rounded,
|
||||||
|
bandIndex: temperatureBandIndex(*rounded),
|
||||||
|
phrase: temperatureBandPhrase(*rounded),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return points
|
||||||
|
}
|
||||||
|
|
||||||
|
func peakTemperaturePoint(points []temperaturePoint) (temperaturePoint, int) {
|
||||||
|
peak := points[0]
|
||||||
|
peakIndex := 0
|
||||||
|
for index, point := range points[1:] {
|
||||||
|
if point.value > peak.value {
|
||||||
|
peak = point
|
||||||
|
peakIndex = index + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return peak, peakIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
func forecastPeriodTemperatureF(period weatherdata.ForecastPeriod) *float64 {
|
||||||
|
switch {
|
||||||
|
case period.TemperatureF != nil:
|
||||||
|
return period.TemperatureF
|
||||||
|
case period.TemperatureFMax != nil:
|
||||||
|
return period.TemperatureFMax
|
||||||
|
case period.TemperatureFMin != nil:
|
||||||
|
return period.TemperatureFMin
|
||||||
|
case period.TemperatureC != nil:
|
||||||
|
value := celsiusToFahrenheit(*period.TemperatureC)
|
||||||
|
return &value
|
||||||
|
case period.TemperatureCMax != nil:
|
||||||
|
value := celsiusToFahrenheit(*period.TemperatureCMax)
|
||||||
|
return &value
|
||||||
|
case period.TemperatureCMin != nil:
|
||||||
|
value := celsiusToFahrenheit(*period.TemperatureCMin)
|
||||||
|
return &value
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func celsiusToFahrenheit(value float64) float64 {
|
||||||
|
return value*9/5 + 32
|
||||||
|
}
|
||||||
|
|
||||||
|
func temperatureBandIndex(value int) int {
|
||||||
|
decade := (value / 10) * 10
|
||||||
|
remainder := value - decade
|
||||||
|
if remainder < 0 {
|
||||||
|
remainder = -remainder
|
||||||
|
}
|
||||||
|
band := 1
|
||||||
|
switch {
|
||||||
|
case remainder <= 3:
|
||||||
|
band = 0
|
||||||
|
case remainder >= 7:
|
||||||
|
band = 2
|
||||||
|
}
|
||||||
|
return decade*3 + band
|
||||||
|
}
|
||||||
|
|
||||||
|
func temperaturePhraseF(value forecast.Range) string {
|
||||||
|
if value.Min == nil && value.Max == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if value.Min != nil && value.Max != nil {
|
||||||
|
low := roundedInt(value.Min)
|
||||||
|
high := roundedInt(value.Max)
|
||||||
|
if low == nil || high == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
lowPhrase := temperatureBandPhrase(*low)
|
||||||
|
highPhrase := temperatureBandPhrase(*high)
|
||||||
|
if lowPhrase == highPhrase {
|
||||||
|
return lowPhrase
|
||||||
|
}
|
||||||
|
return lowPhrase + " to " + highPhrase
|
||||||
|
}
|
||||||
|
if value.Min != nil {
|
||||||
|
low := roundedInt(value.Min)
|
||||||
|
if low == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return temperatureBandPhrase(*low)
|
||||||
|
}
|
||||||
|
high := roundedInt(value.Max)
|
||||||
|
if high == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return temperatureBandPhrase(*high)
|
||||||
|
}
|
||||||
|
|
||||||
|
func temperatureBandPhrase(value int) string {
|
||||||
|
decade := (value / 10) * 10
|
||||||
|
remainder := value - decade
|
||||||
|
if remainder < 0 {
|
||||||
|
remainder = -remainder
|
||||||
|
}
|
||||||
|
qualifier := "mid"
|
||||||
|
switch {
|
||||||
|
case remainder <= 3:
|
||||||
|
qualifier = "low"
|
||||||
|
case remainder >= 7:
|
||||||
|
qualifier = "upper"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s %ds", qualifier, decade)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sentenceCase(value string) string {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
runes := []rune(trimmed)
|
||||||
|
runes[0] = unicode.ToUpper(runes[0])
|
||||||
|
return string(runes)
|
||||||
|
}
|
||||||
|
|
||||||
func multipleSummaryDates(summaries []forecast.DailySummary) bool {
|
func multipleSummaryDates(summaries []forecast.DailySummary) bool {
|
||||||
seen := map[string]struct{}{}
|
seen := map[string]struct{}{}
|
||||||
for _, summary := range summaries {
|
for _, summary := range summaries {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
|
|
||||||
func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.DailyToday)
|
ctx := derivedModuleContext(report.Daily)
|
||||||
|
|
||||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -67,7 +67,7 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
|||||||
|
|
||||||
func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
|
func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.DailyToday)
|
ctx := derivedModuleContext(report.Daily)
|
||||||
ctx.Derived.DailySummaries[0].NarrativePeriods = nil
|
ctx.Derived.DailySummaries[0].NarrativePeriods = nil
|
||||||
|
|
||||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
|
||||||
@@ -89,7 +89,7 @@ func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
|
|||||||
|
|
||||||
func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.DailyToday)
|
ctx := derivedModuleContext(report.Daily)
|
||||||
|
|
||||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -102,19 +102,22 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
|||||||
if len(rainy.PrecipitationWindows) != 2 {
|
if len(rainy.PrecipitationWindows) != 2 {
|
||||||
t.Fatalf("rainy precipitation windows = %#v, want two windows", rainy.PrecipitationWindows)
|
t.Fatalf("rainy precipitation windows = %#v, want two windows", rainy.PrecipitationWindows)
|
||||||
}
|
}
|
||||||
if rainy.PrecipitationWindows[0].Start != "8 AM" || rainy.PrecipitationWindows[0].End != "9 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 {
|
if rainy.PrecipitationWindows[0].PeriodBegins != "2026-05-29 at 8:00 AM" || rainy.PrecipitationWindows[0].PeriodBeginsHourLabel != "8:00 AM" || rainy.PrecipitationWindows[0].PeriodEnds != "2026-05-29 at 9:00 AM" || rainy.PrecipitationWindows[0].PeriodEndsHourLabel != "9:00 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 || rainy.PrecipitationWindows[0].MaxPopHourLabel != "8:00 AM" {
|
||||||
t.Fatalf("first precipitation window = %#v, want 8-9 AM at 60%%", rainy.PrecipitationWindows[0])
|
t.Fatalf("first precipitation window = %#v, want 8-9 AM at 60%%", rainy.PrecipitationWindows[0])
|
||||||
}
|
}
|
||||||
if rainy.PrecipitationWindows[1].Start != "12 PM" || rainy.PrecipitationWindows[1].End != "2 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 {
|
if rainy.PrecipitationWindows[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])
|
||||||
}
|
}
|
||||||
data, err := json.Marshal(output.Value)
|
data, err := json.Marshal(output.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("marshal precip timing: %v", err)
|
t.Fatalf("marshal precip timing: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(data), "precipitation_windows") || !strings.Contains(string(data), "probability_threshold") {
|
if !strings.Contains(string(data), "precipitation_windows") || !strings.Contains(string(data), "probability_threshold") || !strings.Contains(string(data), "period_begins_hour_label") || !strings.Contains(string(data), "max_pop_hour_label") {
|
||||||
t.Fatalf("precip timing json = %s, want threshold and windows", string(data))
|
t.Fatalf("precip timing json = %s, want threshold and windows", string(data))
|
||||||
}
|
}
|
||||||
|
if strings.Contains(string(data), `"start"`) || strings.Contains(string(data), `"end"`) {
|
||||||
|
t.Fatalf("precip timing json = %s, want period_begins/period_ends instead of start/end", string(data))
|
||||||
|
}
|
||||||
if strings.Contains(string(data), "first_precip_hour") || strings.Contains(string(data), "last_precip_hour") {
|
if strings.Contains(string(data), "first_precip_hour") || strings.Contains(string(data), "last_precip_hour") {
|
||||||
t.Fatalf("precip timing json = %s, want no ambiguous first/last fields", string(data))
|
t.Fatalf("precip timing json = %s, want no ambiguous first/last fields", string(data))
|
||||||
}
|
}
|
||||||
@@ -133,9 +136,25 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPrecipTimingModuleUsesDerivedTimingWithoutDaypartSummaries(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := derivedModuleContext(report.Hourly)
|
||||||
|
ctx.Derived.DailySummaries = nil
|
||||||
|
ctx.Derived.DaypartSummaries = nil
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
value := moduleValue[PrecipTimingModule](t, output)
|
||||||
|
if value.MaxPopPercent == nil || *value.MaxPopPercent != 80 || len(value.PrecipitationWindows) != 2 {
|
||||||
|
t.Fatalf("precip timing = %#v, want derived timing without daypart summaries", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.DailyToday)
|
ctx := derivedModuleContext(report.Daily)
|
||||||
|
|
||||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -150,14 +169,20 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
|||||||
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 {
|
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 {
|
||||||
t.Fatalf("morning = %#v, want temp range and precip peak", morning)
|
t.Fatalf("morning = %#v, want temp range and precip peak", morning)
|
||||||
}
|
}
|
||||||
if morning.Date != "2026-05-29" || morning.Period != "2026-05-29 at 6:00 AM to 2026-05-29 at 12:00 PM" {
|
if morning.DisplayName != "Morning" || morning.DominantConditionLower != "showers" || morning.DominantConditionDisplay != "Showers" || morning.TemperaturePhraseF != "upper 50s" || morning.TemperatureTrend != "steady" || morning.TemperatureSteadyPhraseF != "upper 50s" || !morning.MentionPrecipitation || morning.MaxPopTimeLabel != "6:00 AM" {
|
||||||
t.Fatalf("morning period = %q/%q, want friendly local date and period labels", morning.Date, morning.Period)
|
t.Fatalf("morning presentation fields = %#v, want display facts for template composition", morning)
|
||||||
|
}
|
||||||
|
if morning.Date != "2026-05-29" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" {
|
||||||
|
t.Fatalf("morning period = %q/%q/%q, want friendly local date and period labels", morning.Date, morning.PeriodBegins, morning.PeriodEnds)
|
||||||
|
}
|
||||||
|
overnight := value["overnight"]
|
||||||
|
if overnight.MentionPrecipitation {
|
||||||
|
t.Fatalf("overnight MentionPrecipitation = true, want false below threshold")
|
||||||
}
|
}
|
||||||
afternoon := value["afternoon"]
|
afternoon := value["afternoon"]
|
||||||
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 {
|
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 {
|
||||||
t.Fatalf("afternoon = %#v, want heat and wind hazard values", afternoon)
|
t.Fatalf("afternoon = %#v, want heat and wind hazard values", afternoon)
|
||||||
}
|
}
|
||||||
overnight := value["overnight"]
|
|
||||||
if !overnight.Cold {
|
if !overnight.Cold {
|
||||||
t.Fatalf("overnight = %#v, want cold hazard", overnight)
|
t.Fatalf("overnight = %#v, want cold hazard", overnight)
|
||||||
}
|
}
|
||||||
@@ -166,19 +191,236 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
|||||||
t.Fatalf("marshal daypart summaries: %v", err)
|
t.Fatalf("marshal daypart summaries: %v", err)
|
||||||
}
|
}
|
||||||
jsonText := string(data)
|
jsonText := string(data)
|
||||||
for _, field := range []string{"date", "period", "temp_range_f", "max_pop_percent", "max_wind_gust_mph", "dominant_condition"} {
|
for _, field := range []string{"date", "display_name", "period_begins", "period_ends", "temp_range_f", "temperature_phrase_f", "temperature_trend", "temperature_steady_phrase_f", "max_pop_percent", "max_pop_time_label", "mention_precipitation", "max_wind_gust_mph", "dominant_condition", "dominant_condition_lower", "dominant_condition_display"} {
|
||||||
if !strings.Contains(jsonText, field) {
|
if !strings.Contains(jsonText, field) {
|
||||||
t.Fatalf("daypart json = %s, want field %s", jsonText, field)
|
t.Fatalf("daypart json = %s, want field %s", jsonText, field)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.Contains(jsonText, `"period":{"start"`) || strings.Contains(jsonText, `T06:00:00`) {
|
if strings.Contains(jsonText, `"period":`) || strings.Contains(jsonText, `T06:00:00`) {
|
||||||
t.Fatalf("daypart json = %s, want friendly period label instead of raw timestamps", jsonText)
|
t.Fatalf("daypart json = %s, want friendly period label instead of raw timestamps", jsonText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDerivedDaypartSummariesPromptExportOmitsTemplateHelpers(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := derivedModuleContext(report.Daily)
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
richText := mustMarshalModuleJSON(t, output.Value)
|
||||||
|
for _, field := range []string{"temperature_phrase_f", "dominant_condition_lower", "dominant_condition_display", "max_pop_time_label"} {
|
||||||
|
if !strings.Contains(richText, field) {
|
||||||
|
t.Fatalf("rich daypart json = %s, want helper field %s", richText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt := moduleDataPackageValue[map[string]DerivedDaypartSummaryPromptExport](t, output)
|
||||||
|
morning, ok := prompt["morning"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("daypart prompt keys = %#v, want morning", prompt)
|
||||||
|
}
|
||||||
|
if morning.Date != "2026-05-29" || morning.DisplayName != "Morning" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" {
|
||||||
|
t.Fatalf("morning prompt period = %#v, want date/display/period labels", morning)
|
||||||
|
}
|
||||||
|
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 || morning.MaxPopTime != "6:00 AM" || !morning.MentionPrecipitation {
|
||||||
|
t.Fatalf("morning prompt precip/temp = %#v, want factual prompt fields with friendly max pop time", morning)
|
||||||
|
}
|
||||||
|
if morning.DominantCondition != "Showers" || morning.TemperatureTrend != "steady" || morning.TemperatureSteadyPhraseF != "upper 50s" {
|
||||||
|
t.Fatalf("morning prompt condition/trend = %#v, want condition and trend fields", morning)
|
||||||
|
}
|
||||||
|
if len(morning.NotableConditions) == 0 || morning.NotableConditions[0] != "Showers" {
|
||||||
|
t.Fatalf("morning prompt notable conditions = %#v, want copied conditions", morning.NotableConditions)
|
||||||
|
}
|
||||||
|
afternoon := prompt["afternoon"]
|
||||||
|
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 || afternoon.RelevantAlertCount != 1 {
|
||||||
|
t.Fatalf("afternoon prompt = %#v, want hazard, wind, and alert fields", afternoon)
|
||||||
|
}
|
||||||
|
|
||||||
|
promptText := mustMarshalModuleJSON(t, output.DataPackageValue())
|
||||||
|
for _, field := range []string{"date", "display_name", "period_begins", "period_ends", "temp_range_f", "max_pop_percent", "max_pop_time", "mention_precipitation", "max_wind_gust_mph", "dominant_condition", "temperature_trend", "temperature_steady_phrase_f", "notable_conditions", "relevant_alert_count"} {
|
||||||
|
if !strings.Contains(promptText, field) {
|
||||||
|
t.Fatalf("daypart prompt json = %s, want field %s", promptText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, field := range []string{"temperature_phrase_f", "dominant_condition_lower", "dominant_condition_display", "max_pop_time_label"} {
|
||||||
|
if strings.Contains(promptText, field) {
|
||||||
|
t.Fatalf("daypart prompt json = %s, want omitted helper field %s", promptText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDerivedDaypartPromptExportTemperatureTrends(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
temps []float64
|
||||||
|
wantTrend string
|
||||||
|
wantStart string
|
||||||
|
wantEnd string
|
||||||
|
wantPeak string
|
||||||
|
wantSteady string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "rising",
|
||||||
|
temps: []float64{58, 68},
|
||||||
|
wantTrend: "rising",
|
||||||
|
wantStart: "upper 50s",
|
||||||
|
wantEnd: "upper 60s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "falling",
|
||||||
|
temps: []float64{65, 58},
|
||||||
|
wantTrend: "falling",
|
||||||
|
wantStart: "mid 60s",
|
||||||
|
wantEnd: "upper 50s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "peaking",
|
||||||
|
temps: []float64{62, 78, 65},
|
||||||
|
wantTrend: "peaking",
|
||||||
|
wantPeak: "upper 70s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "steady",
|
||||||
|
temps: []float64{77, 78},
|
||||||
|
wantTrend: "steady",
|
||||||
|
wantSteady: "upper 70s",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
rich := derivedDaypartSummaryValue(derivedDaypartWithTemperatures(test.name, "2026-05-29T12:00:00-05:00", "sunny", test.temps...), "America/Chicago")
|
||||||
|
prompt := derivedDaypartSummaryPromptValue(rich)
|
||||||
|
if prompt.TemperatureTrend != test.wantTrend ||
|
||||||
|
prompt.TemperatureStartPhraseF != test.wantStart ||
|
||||||
|
prompt.TemperatureEndPhraseF != test.wantEnd ||
|
||||||
|
prompt.TemperaturePeakPhraseF != test.wantPeak ||
|
||||||
|
prompt.TemperatureSteadyPhraseF != test.wantSteady {
|
||||||
|
t.Fatalf("prompt temperature presentation = %#v", prompt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDerivedDaypartPromptExportMaxPopTimeFallback(t *testing.T) {
|
||||||
|
withLabel := derivedDaypartSummaryPromptValue(DerivedDaypartSummaryModule{
|
||||||
|
MaxPopTime: "6 AM",
|
||||||
|
MaxPopTimeLabel: "6:00 AM",
|
||||||
|
})
|
||||||
|
if withLabel.MaxPopTime != "6:00 AM" {
|
||||||
|
t.Fatalf("MaxPopTime with label = %q, want friendly label", withLabel.MaxPopTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
withoutLabel := derivedDaypartSummaryPromptValue(DerivedDaypartSummaryModule{
|
||||||
|
MaxPopTime: "6 AM",
|
||||||
|
})
|
||||||
|
if withoutLabel.MaxPopTime != "6 AM" {
|
||||||
|
t.Fatalf("MaxPopTime without label = %q, want fallback time", withoutLabel.MaxPopTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDerivedDaypartTemperaturePresentationFields(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
temps []float64
|
||||||
|
wantTrend string
|
||||||
|
wantStart string
|
||||||
|
wantEnd string
|
||||||
|
wantPeak string
|
||||||
|
wantSteady string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "rising",
|
||||||
|
temps: []float64{58, 68},
|
||||||
|
wantTrend: "rising",
|
||||||
|
wantStart: "upper 50s",
|
||||||
|
wantEnd: "upper 60s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "falling",
|
||||||
|
temps: []float64{65, 58},
|
||||||
|
wantTrend: "falling",
|
||||||
|
wantStart: "mid 60s",
|
||||||
|
wantEnd: "upper 50s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "peaking",
|
||||||
|
temps: []float64{62, 78, 65},
|
||||||
|
wantTrend: "peaking",
|
||||||
|
wantPeak: "upper 70s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "steady same band",
|
||||||
|
temps: []float64{77, 78},
|
||||||
|
wantTrend: "steady",
|
||||||
|
wantSteady: "upper 70s",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
summary := derivedDaypartWithTemperatures("afternoon", "2026-05-29T12:00:00-05:00", "sunny", test.temps...)
|
||||||
|
value := derivedDaypartSummaryValue(summary, "America/Chicago")
|
||||||
|
if value.DominantConditionDisplay != "Sunny" {
|
||||||
|
t.Fatalf("DominantConditionDisplay = %q, want Sunny", value.DominantConditionDisplay)
|
||||||
|
}
|
||||||
|
if value.TemperatureTrend != test.wantTrend ||
|
||||||
|
value.TemperatureStartPhraseF != test.wantStart ||
|
||||||
|
value.TemperatureEndPhraseF != test.wantEnd ||
|
||||||
|
value.TemperaturePeakPhraseF != test.wantPeak ||
|
||||||
|
value.TemperatureSteadyPhraseF != test.wantSteady {
|
||||||
|
t.Fatalf("temperature presentation = %#v", value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemperaturePhraseF(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value forecast.Range
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "single low band",
|
||||||
|
value: forecast.Range{Min: floatPtr(71), Max: floatPtr(73)},
|
||||||
|
want: "low 70s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single upper value",
|
||||||
|
value: forecast.Range{Min: floatPtr(68), Max: floatPtr(68)},
|
||||||
|
want: "upper 60s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "range across bands",
|
||||||
|
value: forecast.Range{Min: floatPtr(68), Max: floatPtr(75)},
|
||||||
|
want: "upper 60s to mid 70s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "max only",
|
||||||
|
value: forecast.Range{Max: floatPtr(84)},
|
||||||
|
want: "mid 80s",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
value: forecast.Range{},
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if got := temperaturePhraseF(test.value); got != test.want {
|
||||||
|
t.Fatalf("temperaturePhraseF() = %q, want %q", got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {
|
func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.DailyTomorrow)
|
ctx := derivedModuleContext(report.Tomorrow)
|
||||||
|
|
||||||
outdoorOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows})
|
outdoorOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -191,6 +433,12 @@ func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing
|
|||||||
if outdoor.Best.Daypart != "overnight" || outdoor.Worst.Daypart != "afternoon" {
|
if outdoor.Best.Daypart != "overnight" || outdoor.Worst.Daypart != "afternoon" {
|
||||||
t.Fatalf("outdoor windows = %#v, want quiet overnight and stormy afternoon", outdoor)
|
t.Fatalf("outdoor windows = %#v, want quiet overnight and stormy afternoon", outdoor)
|
||||||
}
|
}
|
||||||
|
if outdoor.Best.PeriodBegins != "2026-05-29 at 12:00 AM" || outdoor.Best.PeriodEnds != "2026-05-29 at 6:00 AM" {
|
||||||
|
t.Fatalf("best outdoor period = %#v, want overnight period labels", outdoor.Best)
|
||||||
|
}
|
||||||
|
if outdoor.Worst.PeriodBegins != "2026-05-29 at 12:00 PM" || outdoor.Worst.PeriodEnds != "2026-05-29 at 6:00 PM" {
|
||||||
|
t.Fatalf("worst outdoor period = %#v, want afternoon period labels", outdoor.Worst)
|
||||||
|
}
|
||||||
|
|
||||||
planningOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TomorrowPlanning})
|
planningOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TomorrowPlanning})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -209,9 +457,154 @@ func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDailyPlanningModulePackagesPlanningFields(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := dailyModuleContext()
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(daily planning) error = %v", err)
|
||||||
|
}
|
||||||
|
if output.ID != module.DailyPlanning || output.StanzaName != "daily_planning" {
|
||||||
|
t.Fatalf("output = %#v, want daily planning stanza", output)
|
||||||
|
}
|
||||||
|
planning := moduleValue[DailyPlanningModule](t, output)
|
||||||
|
if len(planning.MorningReadiness) == 0 ||
|
||||||
|
len(planning.CommuteSchoolWorkdayConcerns) == 0 ||
|
||||||
|
len(planning.OvernightChangeWatch) == 0 {
|
||||||
|
t.Fatalf("daily planning = %#v, want populated planning fields", planning)
|
||||||
|
}
|
||||||
|
if !containsString(planning.MorningReadiness, "Morning precipitation chance peaks near 60%.") {
|
||||||
|
t.Fatalf("MorningReadiness = %#v, want precipitation readiness note", planning.MorningReadiness)
|
||||||
|
}
|
||||||
|
if !containsString(planning.CommuteSchoolWorkdayConcerns, "Afternoon alert overlap needs attention.") {
|
||||||
|
t.Fatalf("CommuteSchoolWorkdayConcerns = %#v, want alert-overlap concern", planning.CommuteSchoolWorkdayConcerns)
|
||||||
|
}
|
||||||
|
if !containsString(planning.OvernightChangeWatch, "Watch for forecast timing or intensity adjustments overnight.") {
|
||||||
|
t.Fatalf("OvernightChangeWatch = %#v, want overnight fallback note", planning.OvernightChangeWatch)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(output.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal daily planning: %v", err)
|
||||||
|
}
|
||||||
|
jsonText := string(data)
|
||||||
|
for _, field := range []string{"morning_readiness", "commute_school_workday_concerns", "overnight_change_watch"} {
|
||||||
|
if !strings.Contains(jsonText, field) {
|
||||||
|
t.Fatalf("daily planning json = %s, want field %s", jsonText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(jsonText, "tomorrow_planning") || strings.Contains(jsonText, "morningReadiness") {
|
||||||
|
t.Fatalf("daily planning json = %s, want daily snake_case fields only", jsonText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDailyPlanningModuleRejectsUnsupportedReports(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
for _, id := range []report.ID{report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm} {
|
||||||
|
t.Run(string(id), func(t *testing.T) {
|
||||||
|
ctx := derivedModuleContext(id)
|
||||||
|
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `module "daily_planning" is not compatible with report`) {
|
||||||
|
t.Fatalf("BuildModule(%s) error = %v, want incompatible report", id, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDailyPlanningModuleHandlesMissingDailySummary(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := dailyModuleContext()
|
||||||
|
ctx.Derived.DailySummaries = nil
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(daily planning) error = %v", err)
|
||||||
|
}
|
||||||
|
planning := moduleValue[DailyPlanningModule](t, output)
|
||||||
|
if len(planning.MorningReadiness) != 0 ||
|
||||||
|
len(planning.CommuteSchoolWorkdayConcerns) != 0 ||
|
||||||
|
len(planning.OvernightChangeWatch) != 0 {
|
||||||
|
t.Fatalf("daily planning = %#v, want empty output without daily summary", planning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTodayPlanningModulePackagesPlanningFields(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := todayModuleContext()
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(today planning) error = %v", err)
|
||||||
|
}
|
||||||
|
if output.ID != module.TodayPlanning || output.StanzaName != "today_planning" {
|
||||||
|
t.Fatalf("output = %#v, want today planning stanza", output)
|
||||||
|
}
|
||||||
|
planning := moduleValue[TodayPlanningModule](t, output)
|
||||||
|
if len(planning.MorningReadiness) == 0 ||
|
||||||
|
len(planning.CommuteSchoolWorkdayConcerns) == 0 ||
|
||||||
|
len(planning.OutdoorPlanning) == 0 ||
|
||||||
|
len(planning.LateDayChangeWatch) == 0 {
|
||||||
|
t.Fatalf("today planning = %#v, want populated planning fields", planning)
|
||||||
|
}
|
||||||
|
if !containsString(planning.MorningReadiness, "Morning precipitation chance peaks near 60%.") {
|
||||||
|
t.Fatalf("MorningReadiness = %#v, want precipitation readiness note", planning.MorningReadiness)
|
||||||
|
}
|
||||||
|
if !containsString(planning.OutdoorPlanning, "Best outdoor window: Overnight (cold risk).") ||
|
||||||
|
!containsString(planning.OutdoorPlanning, "Toughest outdoor window: Afternoon (high precipitation chance, gusty wind, alert overlap, heat risk).") {
|
||||||
|
t.Fatalf("OutdoorPlanning = %#v, want deterministic best and toughest windows", planning.OutdoorPlanning)
|
||||||
|
}
|
||||||
|
if !containsString(planning.LateDayChangeWatch, "Afternoon precipitation timing may shift; current peak is near 80%.") {
|
||||||
|
t.Fatalf("LateDayChangeWatch = %#v, want late-day change note", planning.LateDayChangeWatch)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(output.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal today planning: %v", err)
|
||||||
|
}
|
||||||
|
jsonText := string(data)
|
||||||
|
for _, field := range []string{"morning_readiness", "commute_school_workday_concerns", "outdoor_planning", "late_day_change_watch"} {
|
||||||
|
if !strings.Contains(jsonText, field) {
|
||||||
|
t.Fatalf("today planning json = %s, want field %s", jsonText, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(jsonText, "morningReadiness") || strings.Contains(jsonText, "lateDayChangeWatch") {
|
||||||
|
t.Fatalf("today planning json = %s, want snake_case fields", jsonText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTodayPlanningModuleRejectsUnsupportedReports(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
for _, id := range []report.ID{report.Tomorrow, report.Daily} {
|
||||||
|
t.Run(string(id), func(t *testing.T) {
|
||||||
|
ctx := derivedModuleContext(id)
|
||||||
|
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `module "today_planning" is not compatible with report`) {
|
||||||
|
t.Fatalf("BuildModule(%s) error = %v, want incompatible report", id, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTodayPlanningModuleHandlesMissingDailySummary(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := todayModuleContext()
|
||||||
|
ctx.Derived.DailySummaries = nil
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(today planning) error = %v", err)
|
||||||
|
}
|
||||||
|
planning := moduleValue[TodayPlanningModule](t, output)
|
||||||
|
if len(planning.MorningReadiness) != 0 ||
|
||||||
|
len(planning.CommuteSchoolWorkdayConcerns) != 0 ||
|
||||||
|
len(planning.OutdoorPlanning) != 0 ||
|
||||||
|
len(planning.LateDayChangeWatch) != 0 {
|
||||||
|
t.Fatalf("today planning = %#v, want empty output without daily summary", planning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDerivedModulesHandleMissingData(t *testing.T) {
|
func TestDerivedModulesHandleMissingData(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.DailyToday)
|
ctx := derivedModuleContext(report.Daily)
|
||||||
ctx.Derived.DailySummaries = nil
|
ctx.Derived.DailySummaries = nil
|
||||||
ctx.Derived.DaypartSummaries = nil
|
ctx.Derived.DaypartSummaries = nil
|
||||||
|
|
||||||
@@ -302,6 +695,26 @@ func derivedModuleContext(id report.ID) ModuleContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func todayModuleContext() ModuleContext {
|
||||||
|
ctx := derivedModuleContext(report.Daily)
|
||||||
|
ctx.Resolved.Definition = report.Definition{
|
||||||
|
ID: report.Today,
|
||||||
|
Name: "Today Report",
|
||||||
|
PromptID: "weather.today_generated_text",
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func dailyModuleContext() ModuleContext {
|
||||||
|
ctx := derivedModuleContext(report.Tomorrow)
|
||||||
|
ctx.Resolved.Definition = report.Definition{
|
||||||
|
ID: report.Daily,
|
||||||
|
Name: "Daily Report",
|
||||||
|
PromptID: "weather.daily_generated_text",
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
func derivedDaypart(name string, start string, end string, text string, temperature float64, apparent *float64, precip float64, gust float64) forecast.DaypartSummary {
|
func derivedDaypart(name string, start string, end string, text string, temperature float64, apparent *float64, precip float64, gust float64) forecast.DaypartSummary {
|
||||||
hour := derivedHour(start, text, precip, temperature, apparent, gust)
|
hour := derivedHour(start, text, precip, temperature, apparent, gust)
|
||||||
return forecast.SummarizeDaypart(name, timeutil.Period{
|
return forecast.SummarizeDaypart(name, timeutil.Period{
|
||||||
@@ -310,6 +723,24 @@ func derivedDaypart(name string, start string, end string, text string, temperat
|
|||||||
}, []weatherdata.ForecastPeriod{hour})
|
}, []weatherdata.ForecastPeriod{hour})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func derivedDaypartWithTemperatures(name string, start string, text string, temperatures ...float64) forecast.DaypartSummary {
|
||||||
|
startTime := mustParseModuleTime(start)
|
||||||
|
periods := make([]weatherdata.ForecastPeriod, 0, len(temperatures))
|
||||||
|
for index, temperature := range temperatures {
|
||||||
|
periodStart := startTime.Add(time.Duration(index) * time.Hour)
|
||||||
|
periods = append(periods, weatherdata.ForecastPeriod{
|
||||||
|
StartTime: periodStart,
|
||||||
|
EndTime: periodStart.Add(time.Hour),
|
||||||
|
TextDescription: text,
|
||||||
|
TemperatureF: floatPtr(temperature),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return forecast.SummarizeDaypart(name, timeutil.Period{
|
||||||
|
Start: startTime,
|
||||||
|
End: startTime.Add(time.Duration(len(temperatures)) * time.Hour),
|
||||||
|
}, periods)
|
||||||
|
}
|
||||||
|
|
||||||
func derivedHour(start string, text string, precip float64, temperature float64, apparent *float64, gust float64) weatherdata.ForecastPeriod {
|
func derivedHour(start string, text string, precip float64, temperature float64, apparent *float64, gust float64) weatherdata.ForecastPeriod {
|
||||||
startTime := mustParseModuleTime(start)
|
startTime := mustParseModuleTime(start)
|
||||||
endTime := startTime.Add(time.Hour)
|
endTime := startTime.Add(time.Hour)
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
package briefing
|
package briefing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const DefaultHourlyForecastPrecipMentionProbabilityThreshold = 20
|
||||||
|
|
||||||
type HourlyForecastModule struct {
|
type HourlyForecastModule struct {
|
||||||
Product string `json:"product,omitempty"`
|
Product string `json:"product,omitempty"`
|
||||||
IssuedAt time.Time `json:"issued_at,omitempty"`
|
IssuedAt time.Time `json:"issued_at,omitempty"`
|
||||||
@@ -16,9 +20,57 @@ type HourlyForecastModule struct {
|
|||||||
Periods []HourlyForecastPeriod `json:"periods,omitempty"`
|
Periods []HourlyForecastPeriod `json:"periods,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HourlyForecastPromptExport struct {
|
||||||
|
Product string `json:"product,omitempty"`
|
||||||
|
IssuedAt time.Time `json:"issued_at,omitempty"`
|
||||||
|
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||||
|
SourceLocation string `json:"source_location,omitempty"`
|
||||||
|
SourceLocationID string `json:"source_location_id,omitempty"`
|
||||||
|
Periods []HourlyForecastPromptPeriod `json:"periods,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type HourlyForecastPeriod struct {
|
type HourlyForecastPeriod struct {
|
||||||
StartTime string `json:"start_time,omitempty"`
|
HourLabel string `json:"hour_label,omitempty"`
|
||||||
EndTime string `json:"end_time,omitempty"`
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
IsDay *bool `json:"is_day,omitempty"`
|
||||||
|
ConditionCode *int `json:"condition_code,omitempty"`
|
||||||
|
TextDescription string `json:"text_description,omitempty"`
|
||||||
|
TextDescriptionLower string `json:"text_description_lower,omitempty"`
|
||||||
|
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||||
|
TemperatureF *float64 `json:"temperature_f,omitempty"`
|
||||||
|
TemperatureCMin *float64 `json:"temperature_c_min,omitempty"`
|
||||||
|
TemperatureFMin *float64 `json:"temperature_f_min,omitempty"`
|
||||||
|
TemperatureCMax *float64 `json:"temperature_c_max,omitempty"`
|
||||||
|
TemperatureFMax *float64 `json:"temperature_f_max,omitempty"`
|
||||||
|
DewpointC *float64 `json:"dewpoint_c,omitempty"`
|
||||||
|
DewpointF *float64 `json:"dewpoint_f,omitempty"`
|
||||||
|
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
|
||||||
|
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
|
||||||
|
WindGustKmh *float64 `json:"wind_gust_kmh,omitempty"`
|
||||||
|
WindGustMph *float64 `json:"wind_gust_mph,omitempty"`
|
||||||
|
WindDirection string `json:"wind_direction,omitempty"`
|
||||||
|
BarometricPressurePa *float64 `json:"barometric_pressure_pa,omitempty"`
|
||||||
|
BarometricPressureInHg *float64 `json:"barometric_pressure_in_hg,omitempty"`
|
||||||
|
VisibilityMeters *float64 `json:"visibility_meters,omitempty"`
|
||||||
|
VisibilityMiles *float64 `json:"visibility_miles,omitempty"`
|
||||||
|
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"`
|
||||||
|
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"`
|
||||||
|
CloudCoverPercent *float64 `json:"cloud_cover_percent,omitempty"`
|
||||||
|
ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"`
|
||||||
|
MentionPrecipitation bool `json:"mention_precipitation,omitempty"`
|
||||||
|
PrecipitationAmountMm *float64 `json:"precipitation_amount_mm,omitempty"`
|
||||||
|
PrecipitationAmountIn *float64 `json:"precipitation_amount_in,omitempty"`
|
||||||
|
SnowfallDepthMM *float64 `json:"snowfall_depth_mm,omitempty"`
|
||||||
|
SnowfallDepthIn *float64 `json:"snowfall_depth_in,omitempty"`
|
||||||
|
UVIndex *float64 `json:"uv_index,omitempty"`
|
||||||
|
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HourlyForecastPromptPeriod struct {
|
||||||
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
IsDay *bool `json:"is_day,omitempty"`
|
IsDay *bool `json:"is_day,omitempty"`
|
||||||
ConditionCode *int `json:"condition_code,omitempty"`
|
ConditionCode *int `json:"condition_code,omitempty"`
|
||||||
@@ -71,12 +123,30 @@ func buildHourlyForecastModule(ctx ModuleContext, _ any) (*module.Output, error)
|
|||||||
return &module.Output{ID: module.HourlyForecast, StanzaName: "hourly_forecast", Value: value}, nil
|
return &module.Output{ID: module.HourlyForecast, StanzaName: "hourly_forecast", Value: value}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod {
|
func exportHourlyForecastPromptValue(value any) (any, error) {
|
||||||
out := make([]HourlyForecastPeriod, 0, len(periods))
|
rich, ok := value.(HourlyForecastModule)
|
||||||
|
if !ok {
|
||||||
|
return nil, unexpectedPromptExportValue(value, HourlyForecastModule{})
|
||||||
|
}
|
||||||
|
return HourlyForecastPromptExport{
|
||||||
|
Product: rich.Product,
|
||||||
|
IssuedAt: rich.IssuedAt,
|
||||||
|
UpdatedAt: copyTime(rich.UpdatedAt),
|
||||||
|
SourceLocation: rich.SourceLocation,
|
||||||
|
SourceLocationID: rich.SourceLocationID,
|
||||||
|
Periods: hourlyForecastPromptPeriods(rich.Periods),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hourlyForecastPromptPeriods(periods []HourlyForecastPeriod) []HourlyForecastPromptPeriod {
|
||||||
|
if len(periods) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]HourlyForecastPromptPeriod, 0, len(periods))
|
||||||
for _, period := range periods {
|
for _, period := range periods {
|
||||||
out = append(out, HourlyForecastPeriod{
|
out = append(out, HourlyForecastPromptPeriod{
|
||||||
StartTime: friendlyDateTimeLabel(period.StartTime, timezone),
|
PeriodBegins: period.PeriodBegins,
|
||||||
EndTime: friendlyDateTimeLabel(period.EndTime, timezone),
|
PeriodEnds: period.PeriodEnds,
|
||||||
Name: period.Name,
|
Name: period.Name,
|
||||||
IsDay: copyBool(period.IsDay),
|
IsDay: copyBool(period.IsDay),
|
||||||
ConditionCode: copyInt(period.ConditionCode),
|
ConditionCode: copyInt(period.ConditionCode),
|
||||||
@@ -93,6 +163,55 @@ func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string
|
|||||||
WindSpeedMph: copyFloat(period.WindSpeedMph),
|
WindSpeedMph: copyFloat(period.WindSpeedMph),
|
||||||
WindGustKmh: copyFloat(period.WindGustKmh),
|
WindGustKmh: copyFloat(period.WindGustKmh),
|
||||||
WindGustMph: copyFloat(period.WindGustMph),
|
WindGustMph: copyFloat(period.WindGustMph),
|
||||||
|
WindDirection: period.WindDirection,
|
||||||
|
BarometricPressurePa: copyFloat(period.BarometricPressurePa),
|
||||||
|
BarometricPressureInHg: copyFloat(period.BarometricPressureInHg),
|
||||||
|
VisibilityMeters: copyFloat(period.VisibilityMeters),
|
||||||
|
VisibilityMiles: copyFloat(period.VisibilityMiles),
|
||||||
|
ApparentTemperatureC: copyFloat(period.ApparentTemperatureC),
|
||||||
|
ApparentTemperatureF: copyFloat(period.ApparentTemperatureF),
|
||||||
|
CloudCoverPercent: copyFloat(period.CloudCoverPercent),
|
||||||
|
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
|
||||||
|
PrecipitationAmountMm: copyFloat(period.PrecipitationAmountMm),
|
||||||
|
PrecipitationAmountIn: copyFloat(period.PrecipitationAmountIn),
|
||||||
|
SnowfallDepthMM: copyFloat(period.SnowfallDepthMM),
|
||||||
|
SnowfallDepthIn: copyFloat(period.SnowfallDepthIn),
|
||||||
|
UVIndex: copyFloat(period.UVIndex),
|
||||||
|
RelativeHumidityPercent: copyFloat(period.RelativeHumidityPercent),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod {
|
||||||
|
return hourlyForecastPeriodsWithPrecipMentionThreshold(periods, timezone, DefaultHourlyForecastPrecipMentionProbabilityThreshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hourlyForecastPeriodsWithPrecipMentionThreshold(periods []weatherdata.ForecastPeriod, timezone string, threshold float64) []HourlyForecastPeriod {
|
||||||
|
out := make([]HourlyForecastPeriod, 0, len(periods))
|
||||||
|
for _, period := range periods {
|
||||||
|
validPeriod := timeutil.Period{Start: period.StartTime, End: period.EndTime}
|
||||||
|
out = append(out, HourlyForecastPeriod{
|
||||||
|
HourLabel: hourMinuteLabel(period.StartTime, timezone),
|
||||||
|
PeriodBegins: friendlyPeriodBeginsLabel(validPeriod, timezone),
|
||||||
|
PeriodEnds: friendlyPeriodEndsLabel(validPeriod, timezone),
|
||||||
|
Name: period.Name,
|
||||||
|
IsDay: copyBool(period.IsDay),
|
||||||
|
ConditionCode: copyInt(period.ConditionCode),
|
||||||
|
TextDescription: period.TextDescription,
|
||||||
|
TextDescriptionLower: strings.ToLower(period.TextDescription),
|
||||||
|
TemperatureC: copyFloat(period.TemperatureC),
|
||||||
|
TemperatureF: copyFloat(period.TemperatureF),
|
||||||
|
TemperatureCMin: copyFloat(period.TemperatureCMin),
|
||||||
|
TemperatureFMin: copyFloat(period.TemperatureFMin),
|
||||||
|
TemperatureCMax: copyFloat(period.TemperatureCMax),
|
||||||
|
TemperatureFMax: copyFloat(period.TemperatureFMax),
|
||||||
|
DewpointC: copyFloat(period.DewpointC),
|
||||||
|
DewpointF: copyFloat(period.DewpointF),
|
||||||
|
WindSpeedKmh: copyFloat(period.WindSpeedKmh),
|
||||||
|
WindSpeedMph: copyFloat(period.WindSpeedMph),
|
||||||
|
WindGustKmh: copyFloat(period.WindGustKmh),
|
||||||
|
WindGustMph: copyFloat(period.WindGustMph),
|
||||||
WindDirection: windDirectionLabel(period.WindDirectionDegrees),
|
WindDirection: windDirectionLabel(period.WindDirectionDegrees),
|
||||||
BarometricPressurePa: copyFloat(period.BarometricPressurePa),
|
BarometricPressurePa: copyFloat(period.BarometricPressurePa),
|
||||||
BarometricPressureInHg: copyFloat(period.BarometricPressureInHg),
|
BarometricPressureInHg: copyFloat(period.BarometricPressureInHg),
|
||||||
@@ -102,6 +221,7 @@ func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string
|
|||||||
ApparentTemperatureF: copyFloat(period.ApparentTemperatureF),
|
ApparentTemperatureF: copyFloat(period.ApparentTemperatureF),
|
||||||
CloudCoverPercent: copyFloat(period.CloudCoverPercent),
|
CloudCoverPercent: copyFloat(period.CloudCoverPercent),
|
||||||
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
|
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
|
||||||
|
MentionPrecipitation: mentionHourlyForecastPrecipitation(period.ProbabilityOfPrecipitationPercent, threshold),
|
||||||
PrecipitationAmountMm: copyFloat(period.PrecipitationAmountMm),
|
PrecipitationAmountMm: copyFloat(period.PrecipitationAmountMm),
|
||||||
PrecipitationAmountIn: copyFloat(period.PrecipitationAmountIn),
|
PrecipitationAmountIn: copyFloat(period.PrecipitationAmountIn),
|
||||||
SnowfallDepthMM: copyFloat(period.SnowfallDepthMM),
|
SnowfallDepthMM: copyFloat(period.SnowfallDepthMM),
|
||||||
@@ -113,6 +233,10 @@ func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mentionHourlyForecastPrecipitation(probability *float64, threshold float64) bool {
|
||||||
|
return probability != nil && *probability >= threshold
|
||||||
|
}
|
||||||
|
|
||||||
func (v HourlyForecastModule) isEmpty() bool {
|
func (v HourlyForecastModule) isEmpty() bool {
|
||||||
return v.Product == "" &&
|
return v.Product == "" &&
|
||||||
v.IssuedAt.IsZero() &&
|
v.IssuedAt.IsZero() &&
|
||||||
|
|||||||
@@ -60,6 +60,36 @@ func windDirectionLabel(degrees *float64) string {
|
|||||||
return labels[sector]
|
return labels[sector]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func windDirectionTextLabel(degrees *float64) string {
|
||||||
|
if degrees == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
labels := []string{
|
||||||
|
"north",
|
||||||
|
"north-northeast",
|
||||||
|
"northeast",
|
||||||
|
"east-northeast",
|
||||||
|
"east",
|
||||||
|
"east-southeast",
|
||||||
|
"southeast",
|
||||||
|
"south-southeast",
|
||||||
|
"south",
|
||||||
|
"south-southwest",
|
||||||
|
"southwest",
|
||||||
|
"west-southwest",
|
||||||
|
"west",
|
||||||
|
"west-northwest",
|
||||||
|
"northwest",
|
||||||
|
"north-northwest",
|
||||||
|
}
|
||||||
|
normalized := math.Mod(*degrees, 360)
|
||||||
|
if normalized < 0 {
|
||||||
|
normalized += 360
|
||||||
|
}
|
||||||
|
sector := int(math.Floor((normalized+11.25)/22.5)) % len(labels)
|
||||||
|
return labels[sector]
|
||||||
|
}
|
||||||
|
|
||||||
func timedClockLabel(value *forecast.TimedValue, timezone string) string {
|
func timedClockLabel(value *forecast.TimedValue, timezone string) string {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -67,18 +97,18 @@ func timedClockLabel(value *forecast.TimedValue, timezone string) string {
|
|||||||
return clockLabel(value.Time, timezone)
|
return clockLabel(value.Time, timezone)
|
||||||
}
|
}
|
||||||
|
|
||||||
func periodClockLabel(period timeutil.Period, timezone string) string {
|
func friendlyPeriodBeginsLabel(period timeutil.Period, timezone string) string {
|
||||||
if !period.IsValid() {
|
if !period.IsValid() {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return clockLabel(period.Start, timezone) + "-" + clockLabel(period.End, timezone)
|
return friendlyDateTimeLabel(period.Start, timezone)
|
||||||
}
|
}
|
||||||
|
|
||||||
func friendlyPeriodLabel(period timeutil.Period, timezone string) string {
|
func friendlyPeriodEndsLabel(period timeutil.Period, timezone string) string {
|
||||||
if !period.IsValid() {
|
if !period.IsValid() {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return friendlyDateTimeLabel(period.Start, timezone) + " to " + friendlyDateTimeLabel(period.End, timezone)
|
return friendlyDateTimeLabel(period.End, timezone)
|
||||||
}
|
}
|
||||||
|
|
||||||
func friendlyDateTimeLabel(value time.Time, timezone string) string {
|
func friendlyDateTimeLabel(value time.Time, timezone string) string {
|
||||||
@@ -126,3 +156,11 @@ func clockLabel(value time.Time, timezone string) string {
|
|||||||
}
|
}
|
||||||
return label
|
return label
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hourMinuteLabel(value time.Time, timezone string) string {
|
||||||
|
location, err := timeutil.LoadLocation(timezone)
|
||||||
|
if err != nil {
|
||||||
|
location = time.UTC
|
||||||
|
}
|
||||||
|
return value.In(location).Format("3:04 PM")
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,3 +27,25 @@ func TestWindDirectionLabelUsesSixteenPointCompass(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWindDirectionTextLabelUsesLowercaseCompassText(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
degrees *float64
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "nil", degrees: nil, want: ""},
|
||||||
|
{name: "north", degrees: floatPtr(0), want: "north"},
|
||||||
|
{name: "north northeast", degrees: floatPtr(11.25), want: "north-northeast"},
|
||||||
|
{name: "northwest", degrees: floatPtr(315), want: "northwest"},
|
||||||
|
{name: "negative normalizes", degrees: floatPtr(-45), want: "northwest"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := windDirectionTextLabel(tt.degrees); got != tt.want {
|
||||||
|
t.Fatalf("windDirectionTextLabel(%v) = %q, want %q", tt.degrees, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ type ModuleContext struct {
|
|||||||
|
|
||||||
type ModuleBuilder func(ModuleContext, any) (*module.Output, error)
|
type ModuleBuilder func(ModuleContext, any) (*module.Output, error)
|
||||||
|
|
||||||
|
type ModulePromptExporter func(value any) (any, error)
|
||||||
|
|
||||||
type ModuleDefinition struct {
|
type ModuleDefinition struct {
|
||||||
ID module.ID
|
ID module.ID
|
||||||
StanzaName string
|
StanzaName string
|
||||||
@@ -31,12 +33,17 @@ type ModuleDefinition struct {
|
|||||||
MissingData module.MissingDataBehavior
|
MissingData module.MissingDataBehavior
|
||||||
AllowDuplicate bool
|
AllowDuplicate bool
|
||||||
Builder ModuleBuilder
|
Builder ModuleBuilder
|
||||||
|
PromptExporter ModulePromptExporter
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModuleRegistry struct {
|
type ModuleRegistry struct {
|
||||||
definitions map[module.ID]ModuleDefinition
|
definitions map[module.ID]ModuleDefinition
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func unexpectedPromptExportValue(got any, want any) error {
|
||||||
|
return fmt.Errorf("value has type %T, want %T", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
func DefaultModuleRegistry() (ModuleRegistry, error) {
|
func DefaultModuleRegistry() (ModuleRegistry, error) {
|
||||||
return NewModuleRegistry(defaultModuleDefinitions())
|
return NewModuleRegistry(defaultModuleDefinitions())
|
||||||
}
|
}
|
||||||
@@ -130,6 +137,15 @@ func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (
|
|||||||
if output.StanzaName != definition.StanzaName {
|
if output.StanzaName != definition.StanzaName {
|
||||||
return nil, fmt.Errorf("module %q produced stanza %q, want %q", definition.ID, output.StanzaName, definition.StanzaName)
|
return nil, fmt.Errorf("module %q produced stanza %q, want %q", definition.ID, output.StanzaName, definition.StanzaName)
|
||||||
}
|
}
|
||||||
|
if definition.PromptExporter == nil {
|
||||||
|
output.PromptValue = output.Value
|
||||||
|
return output, nil
|
||||||
|
}
|
||||||
|
promptValue, err := definition.PromptExporter(output.Value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("module %q stanza %q prompt export: %w", definition.ID, definition.StanzaName, err)
|
||||||
|
}
|
||||||
|
output.PromptValue = promptValue
|
||||||
return output, nil
|
return output, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +178,8 @@ func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContex
|
|||||||
return ctx.Collected.Discussion != nil
|
return ctx.Collected.Discussion != nil
|
||||||
case module.CollectedWeatherStory:
|
case module.CollectedWeatherStory:
|
||||||
return ctx.Collected.WeatherStory != nil
|
return ctx.Collected.WeatherStory != nil
|
||||||
|
case module.CollectedSPCConvectiveOutlooks:
|
||||||
|
return ctx.Collected.SPCConvectiveOutlooks != nil
|
||||||
case module.CollectedSourceMetadata:
|
case module.CollectedSourceMetadata:
|
||||||
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
|
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
|
||||||
default:
|
default:
|
||||||
@@ -183,6 +201,8 @@ func derivedFactAvailable(requirement module.FactRequirement, ctx ModuleContext)
|
|||||||
return len(ctx.Derived.DaypartSummaries) > 0
|
return len(ctx.Derived.DaypartSummaries) > 0
|
||||||
case module.RequiresDerivedPrecipTiming:
|
case module.RequiresDerivedPrecipTiming:
|
||||||
return true
|
return true
|
||||||
|
case module.RequiresDerivedSPCConvectiveOutlooks:
|
||||||
|
return ctx.Derived.SPCConvectiveOutlooks != nil
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -245,8 +265,8 @@ func (d ModuleDefinition) ValidateOptions(options any) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func defaultModuleDefinitions() []ModuleDefinition {
|
func defaultModuleDefinitions() []ModuleDefinition {
|
||||||
allReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend, report.Storm}
|
allReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm}
|
||||||
daypartReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend}
|
daypartReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.ThreeDay, report.Weekend}
|
||||||
return []ModuleDefinition{
|
return []ModuleDefinition{
|
||||||
{
|
{
|
||||||
ID: module.Metadata,
|
ID: module.Metadata,
|
||||||
@@ -265,6 +285,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
SupportedReports: allReports,
|
SupportedReports: allReports,
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildCurrentConditionsModule,
|
Builder: buildCurrentConditionsModule,
|
||||||
|
PromptExporter: exportCurrentConditionsPromptValue,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: module.NarrativeForecast,
|
ID: module.NarrativeForecast,
|
||||||
@@ -272,7 +293,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
DefaultOptions: module.NarrativeForecastOptions{},
|
DefaultOptions: module.NarrativeForecastOptions{},
|
||||||
RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast},
|
RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods},
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods},
|
||||||
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow},
|
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildNarrativeForecastModule,
|
Builder: buildNarrativeForecastModule,
|
||||||
},
|
},
|
||||||
@@ -282,16 +303,17 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
DefaultOptions: module.HourlyForecastOptions{},
|
DefaultOptions: module.HourlyForecastOptions{},
|
||||||
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
|
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
|
||||||
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow},
|
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly},
|
||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildHourlyForecastModule,
|
Builder: buildHourlyForecastModule,
|
||||||
|
PromptExporter: exportHourlyForecastPromptValue,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: module.DerivedDailySummary,
|
ID: module.DerivedDailySummary,
|
||||||
StanzaName: "derived_daily_summary",
|
StanzaName: "derived_daily_summary",
|
||||||
DefaultOptions: module.DerivedDailySummaryOptions{},
|
DefaultOptions: module.DerivedDailySummaryOptions{},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming},
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming},
|
||||||
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow},
|
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
|
||||||
MissingData: module.MissingDataError,
|
MissingData: module.MissingDataError,
|
||||||
Builder: buildDerivedDailySummaryModule,
|
Builder: buildDerivedDailySummaryModule,
|
||||||
},
|
},
|
||||||
@@ -303,12 +325,13 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
SupportedReports: daypartReports,
|
SupportedReports: daypartReports,
|
||||||
MissingData: module.MissingDataError,
|
MissingData: module.MissingDataError,
|
||||||
Builder: buildDerivedDaypartSummariesModule,
|
Builder: buildDerivedDaypartSummariesModule,
|
||||||
|
PromptExporter: exportDerivedDaypartSummariesPromptValue,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: module.PrecipTiming,
|
ID: module.PrecipTiming,
|
||||||
StanzaName: "precip_timing",
|
StanzaName: "precip_timing",
|
||||||
DefaultOptions: module.PrecipTimingOptions{},
|
DefaultOptions: module.PrecipTimingOptions{},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedPrecipTiming},
|
||||||
SupportedReports: allReports,
|
SupportedReports: allReports,
|
||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildPrecipTimingModule,
|
Builder: buildPrecipTimingModule,
|
||||||
@@ -323,6 +346,16 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildAlertDigestModule,
|
Builder: buildAlertDigestModule,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: module.SPCConvectiveOutlooks,
|
||||||
|
StanzaName: string(module.SPCConvectiveOutlooks),
|
||||||
|
DefaultOptions: module.SPCConvectiveOutlooksOptions{},
|
||||||
|
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
|
||||||
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
|
||||||
|
SupportedReports: allReports,
|
||||||
|
MissingData: module.MissingDataEmpty,
|
||||||
|
Builder: buildSPCConvectiveOutlooksModule,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ID: module.AreaForecastDiscussion,
|
ID: module.AreaForecastDiscussion,
|
||||||
StanzaName: "area_forecast_discussion",
|
StanzaName: "area_forecast_discussion",
|
||||||
@@ -332,6 +365,16 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
MissingData: module.MissingDataOmit,
|
MissingData: module.MissingDataOmit,
|
||||||
Builder: buildAreaForecastDiscussionModule,
|
Builder: buildAreaForecastDiscussionModule,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: module.SPCConvectiveDiscussion,
|
||||||
|
StanzaName: string(module.SPCConvectiveDiscussion),
|
||||||
|
DefaultOptions: module.SPCConvectiveDiscussionOptions{},
|
||||||
|
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
|
||||||
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
|
||||||
|
SupportedReports: allReports,
|
||||||
|
MissingData: module.MissingDataOmit,
|
||||||
|
Builder: buildSPCConvectiveDiscussionModule,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ID: module.WeatherStory,
|
ID: module.WeatherStory,
|
||||||
StanzaName: "weather_story",
|
StanzaName: "weather_story",
|
||||||
@@ -350,14 +393,32 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
|||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildOutdoorWindowsModule,
|
Builder: buildOutdoorWindowsModule,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: module.TodayPlanning,
|
||||||
|
StanzaName: "today_planning",
|
||||||
|
DefaultOptions: module.TodayPlanningOptions{},
|
||||||
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
||||||
|
SupportedReports: []report.ID{report.Today},
|
||||||
|
MissingData: module.MissingDataEmpty,
|
||||||
|
Builder: buildTodayPlanningModule,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ID: module.TomorrowPlanning,
|
ID: module.TomorrowPlanning,
|
||||||
StanzaName: "tomorrow_planning",
|
StanzaName: "tomorrow_planning",
|
||||||
DefaultOptions: module.TomorrowPlanningOptions{},
|
DefaultOptions: module.TomorrowPlanningOptions{},
|
||||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
||||||
SupportedReports: []report.ID{report.DailyTomorrow},
|
SupportedReports: []report.ID{report.Tomorrow},
|
||||||
MissingData: module.MissingDataEmpty,
|
MissingData: module.MissingDataEmpty,
|
||||||
Builder: buildTomorrowPlanningModule,
|
Builder: buildTomorrowPlanningModule,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: module.DailyPlanning,
|
||||||
|
StanzaName: "daily_planning",
|
||||||
|
DefaultOptions: module.DailyPlanningOptions{},
|
||||||
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
||||||
|
SupportedReports: []report.ID{report.Daily},
|
||||||
|
MissingData: module.MissingDataEmpty,
|
||||||
|
Builder: buildDailyPlanningModule,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
package briefing
|
package briefing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) {
|
func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) {
|
||||||
@@ -38,6 +42,9 @@ func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
|
|||||||
t.Fatalf("BuildModule(%s) error = %v", item.ID, err)
|
t.Fatalf("BuildModule(%s) error = %v", item.ID, err)
|
||||||
}
|
}
|
||||||
if output != nil {
|
if output != nil {
|
||||||
|
if output.DataPackageValue() == nil {
|
||||||
|
t.Fatalf("BuildModule(%s) data package value = nil", item.ID)
|
||||||
|
}
|
||||||
outputs = append(outputs, *output)
|
outputs = append(outputs, *output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,9 +59,234 @@ func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDefaultModuleDefinitionsDeclarePromptExportPolicy(t *testing.T) {
|
||||||
|
customExporters := map[module.ID]struct{}{
|
||||||
|
module.CurrentConditions: {},
|
||||||
|
module.HourlyForecast: {},
|
||||||
|
module.DerivedDaypartSummaries: {},
|
||||||
|
}
|
||||||
|
passThroughExporters := map[module.ID]struct{}{
|
||||||
|
module.Metadata: {},
|
||||||
|
module.NarrativeForecast: {},
|
||||||
|
module.DerivedDailySummary: {},
|
||||||
|
module.PrecipTiming: {},
|
||||||
|
module.AlertDigest: {},
|
||||||
|
module.SPCConvectiveOutlooks: {},
|
||||||
|
module.AreaForecastDiscussion: {},
|
||||||
|
module.SPCConvectiveDiscussion: {},
|
||||||
|
module.WeatherStory: {},
|
||||||
|
module.OutdoorWindows: {},
|
||||||
|
module.TodayPlanning: {},
|
||||||
|
module.TomorrowPlanning: {},
|
||||||
|
module.DailyPlanning: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, definition := range defaultModuleDefinitions() {
|
||||||
|
_, custom := customExporters[definition.ID]
|
||||||
|
_, passThrough := passThroughExporters[definition.ID]
|
||||||
|
if custom == passThrough {
|
||||||
|
t.Fatalf("module %q exporter policy custom=%v passThrough=%v, want exactly one policy", definition.ID, custom, passThrough)
|
||||||
|
}
|
||||||
|
if custom && definition.PromptExporter == nil {
|
||||||
|
t.Fatalf("module %q PromptExporter = nil, want custom prompt exporter", definition.ID)
|
||||||
|
}
|
||||||
|
if passThrough && definition.PromptExporter != nil {
|
||||||
|
t.Fatalf("module %q PromptExporter is set, want default pass-through", definition.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryAddsPassThroughPromptValue(t *testing.T) {
|
||||||
|
registry, err := NewModuleRegistry([]ModuleDefinition{
|
||||||
|
{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Builder: func(ModuleContext, any) (*module.Output, error) {
|
||||||
|
return &module.Output{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Value: testRegistryValue{Message: "rich"},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewModuleRegistry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
if output == nil {
|
||||||
|
t.Fatal("BuildModule() output = nil, want output")
|
||||||
|
}
|
||||||
|
if output.PromptValue != output.Value {
|
||||||
|
t.Fatalf("PromptValue = %#v, want pass-through rich value %#v", output.PromptValue, output.Value)
|
||||||
|
}
|
||||||
|
if output.DataPackageValue() != output.Value {
|
||||||
|
t.Fatalf("DataPackageValue() = %#v, want rich value", output.DataPackageValue())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryAddsCustomPromptValue(t *testing.T) {
|
||||||
|
registry, err := NewModuleRegistry([]ModuleDefinition{
|
||||||
|
{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Builder: func(ModuleContext, any) (*module.Output, error) {
|
||||||
|
return &module.Output{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Value: testRegistryValue{Message: "rich"},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
PromptExporter: func(value any) (any, error) {
|
||||||
|
rich, ok := value.(testRegistryValue)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("unexpected rich value type")
|
||||||
|
}
|
||||||
|
return testRegistryValue{Message: rich.Message + " prompt"}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewModuleRegistry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
got, ok := output.PromptValue.(testRegistryValue)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("PromptValue type = %T, want testRegistryValue", output.PromptValue)
|
||||||
|
}
|
||||||
|
if got.Message != "rich prompt" {
|
||||||
|
t.Fatalf("PromptValue = %#v, want custom prompt value", got)
|
||||||
|
}
|
||||||
|
if output.DataPackageValue() != output.PromptValue {
|
||||||
|
t.Fatalf("DataPackageValue() = %#v, want custom prompt value", output.DataPackageValue())
|
||||||
|
}
|
||||||
|
if output.Value.(testRegistryValue).Message != "rich" {
|
||||||
|
t.Fatalf("Value = %#v, want rich value unchanged", output.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryWrapsPromptExporterErrors(t *testing.T) {
|
||||||
|
registry, err := NewModuleRegistry([]ModuleDefinition{
|
||||||
|
{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Builder: func(ModuleContext, any) (*module.Output, error) {
|
||||||
|
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: testRegistryValue{Message: "rich"}}, nil
|
||||||
|
},
|
||||||
|
PromptExporter: func(any) (any, error) {
|
||||||
|
return nil, errors.New("unsupported value")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewModuleRegistry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
||||||
|
if err == nil ||
|
||||||
|
!strings.Contains(err.Error(), `module "metadata" stanza "metadata" prompt export`) ||
|
||||||
|
!strings.Contains(err.Error(), "unsupported value") {
|
||||||
|
t.Fatalf("BuildModule() error = %v, want wrapped exporter error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryValidatesOutputBeforePromptExport(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
registry, err := NewModuleRegistry([]ModuleDefinition{
|
||||||
|
{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Builder: func(ModuleContext, any) (*module.Output, error) {
|
||||||
|
return &module.Output{ID: module.CurrentConditions, StanzaName: "metadata", Value: testRegistryValue{Message: "rich"}}, nil
|
||||||
|
},
|
||||||
|
PromptExporter: func(any) (any, error) {
|
||||||
|
called = true
|
||||||
|
return testRegistryValue{Message: "prompt"}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewModuleRegistry() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `module "metadata" produced output id "current_conditions"`) {
|
||||||
|
t.Fatalf("BuildModule() error = %v, want output id validation error", err)
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Fatal("PromptExporter called before output validation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryPromptValueIsNotPersistedInSnapshotJSON(t *testing.T) {
|
||||||
|
registry, err := NewModuleRegistry([]ModuleDefinition{
|
||||||
|
{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Builder: func(ModuleContext, any) (*module.Output, error) {
|
||||||
|
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: testRegistryValue{Message: "rich"}}, nil
|
||||||
|
},
|
||||||
|
PromptExporter: func(any) (any, error) {
|
||||||
|
return testRegistryValue{Message: "prompt-only"}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewModuleRegistry() error = %v", err)
|
||||||
|
}
|
||||||
|
output, err := registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
snapshot, err := module.NewSnapshot([]module.Output{*output})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSnapshot() error = %v", err)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(snapshot)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
if !strings.Contains(text, `"message":"rich"`) {
|
||||||
|
t.Fatalf("snapshot JSON missing rich value: %s", text)
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "prompt-only") || strings.Contains(text, "promptValue") || strings.Contains(text, "PromptValue") {
|
||||||
|
t.Fatalf("snapshot JSON includes runtime-only prompt value: %s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHourlyDefaultModuleOptions(t *testing.T) {
|
||||||
|
definition := report.DefaultRegistry().MustLookup(report.Hourly)
|
||||||
|
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, ",") != "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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
|
func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.ID("unknown")}})
|
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.ID("unknown")}})
|
||||||
if err == nil || !strings.Contains(err.Error(), `unknown module "unknown"`) {
|
if err == nil || !strings.Contains(err.Error(), `unknown module "unknown"`) {
|
||||||
t.Fatalf("error = %v, want unknown module", err)
|
t.Fatalf("error = %v, want unknown module", err)
|
||||||
}
|
}
|
||||||
@@ -62,7 +294,7 @@ func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
|
|||||||
|
|
||||||
func TestModuleRegistryRejectsDuplicateModuleIDs(t *testing.T) {
|
func TestModuleRegistryRejectsDuplicateModuleIDs(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{
|
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
|
||||||
{ID: module.Metadata},
|
{ID: module.Metadata},
|
||||||
{ID: module.Metadata},
|
{ID: module.Metadata},
|
||||||
})
|
})
|
||||||
@@ -83,12 +315,84 @@ func TestModuleRegistryRejectsDuplicateStanzaNames(t *testing.T) {
|
|||||||
|
|
||||||
func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
|
func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.TomorrowPlanning}})
|
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.TomorrowPlanning}})
|
||||||
if err == nil || !strings.Contains(err.Error(), `module "tomorrow_planning" is not compatible with report "daily_today"`) {
|
if err == nil || !strings.Contains(err.Error(), `module "tomorrow_planning" is not compatible with report "daily"`) {
|
||||||
t.Fatalf("error = %v, want incompatible report", err)
|
t.Fatalf("error = %v, want incompatible report", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryValidatesTodayPlanningSupport(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
if err := registry.ValidateComposition(report.Today, []module.ConfigItem{{ID: module.TodayPlanning}}); err != nil {
|
||||||
|
t.Fatalf("ValidateComposition(today) error = %v", err)
|
||||||
|
}
|
||||||
|
for _, id := range []report.ID{report.Tomorrow, report.Daily} {
|
||||||
|
t.Run(string(id), func(t *testing.T) {
|
||||||
|
err := registry.ValidateComposition(id, []module.ConfigItem{{ID: module.TodayPlanning}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `module "today_planning" is not compatible with report`) {
|
||||||
|
t.Fatalf("ValidateComposition(%s) error = %v, want incompatible report", id, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryValidatesDailyPlanningSupport(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
if err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.DailyPlanning}}); err != nil {
|
||||||
|
t.Fatalf("ValidateComposition(daily) error = %v", err)
|
||||||
|
}
|
||||||
|
for _, id := range []report.ID{report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm} {
|
||||||
|
t.Run(string(id), func(t *testing.T) {
|
||||||
|
err := registry.ValidateComposition(id, []module.ConfigItem{{ID: module.DailyPlanning}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `module "daily_planning" is not compatible with report`) {
|
||||||
|
t.Fatalf("ValidateComposition(%s) error = %v, want incompatible report", id, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistrySupportsTodayEligibleModules(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
err := registry.ValidateComposition(report.Today, []module.ConfigItem{
|
||||||
|
{ID: module.Metadata},
|
||||||
|
{ID: module.CurrentConditions},
|
||||||
|
{ID: module.NarrativeForecast},
|
||||||
|
{ID: module.HourlyForecast},
|
||||||
|
{ID: module.DerivedDailySummary},
|
||||||
|
{ID: module.DerivedDaypartSummaries},
|
||||||
|
{ID: module.PrecipTiming},
|
||||||
|
{ID: module.AlertDigest},
|
||||||
|
{ID: module.SPCConvectiveOutlooks},
|
||||||
|
{ID: module.AreaForecastDiscussion},
|
||||||
|
{ID: module.SPCConvectiveDiscussion},
|
||||||
|
{ID: module.WeatherStory},
|
||||||
|
{ID: module.OutdoorWindows},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateComposition(today eligible modules) error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleRegistryRejectsHourlyIncompatibleModules(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
for _, id := range []module.ID{
|
||||||
|
module.NarrativeForecast,
|
||||||
|
module.DerivedDailySummary,
|
||||||
|
module.DerivedDaypartSummaries,
|
||||||
|
module.OutdoorWindows,
|
||||||
|
module.TodayPlanning,
|
||||||
|
module.TomorrowPlanning,
|
||||||
|
module.DailyPlanning,
|
||||||
|
} {
|
||||||
|
t.Run(string(id), func(t *testing.T) {
|
||||||
|
err := registry.ValidateComposition(report.Hourly, []module.ConfigItem{{ID: id}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `not compatible with report "hourly"`) {
|
||||||
|
t.Fatalf("ValidateComposition() error = %v, want incompatible hourly module", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestModuleRegistryRejectsDefinitionsWithoutBuilders(t *testing.T) {
|
func TestModuleRegistryRejectsDefinitionsWithoutBuilders(t *testing.T) {
|
||||||
_, err := NewModuleRegistry([]ModuleDefinition{
|
_, err := NewModuleRegistry([]ModuleDefinition{
|
||||||
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}},
|
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}},
|
||||||
@@ -109,7 +413,7 @@ func TestModuleRegistryRejectsUnsupportedMissingDataWarn(t *testing.T) {
|
|||||||
|
|
||||||
func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) {
|
func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{
|
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
|
||||||
{ID: module.Metadata, Options: module.CurrentConditionsOptions{}},
|
{ID: module.Metadata, Options: module.CurrentConditionsOptions{}},
|
||||||
})
|
})
|
||||||
if err == nil || !strings.Contains(err.Error(), `module "metadata" options have type module.CurrentConditionsOptions, want module.MetadataOptions`) {
|
if err == nil || !strings.Contains(err.Error(), `module "metadata" options have type module.CurrentConditionsOptions, want module.MetadataOptions`) {
|
||||||
@@ -119,7 +423,7 @@ func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) {
|
|||||||
|
|
||||||
func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
|
func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{
|
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
|
||||||
{ID: module.Metadata, Options: module.MetadataOptions{}},
|
{ID: module.Metadata, Options: module.MetadataOptions{}},
|
||||||
{ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}},
|
{ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}},
|
||||||
})
|
})
|
||||||
@@ -128,6 +432,42 @@ func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveOutlookCollectedRequirementAvailability(t *testing.T) {
|
||||||
|
ctx := ModuleContext{}
|
||||||
|
if collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
|
||||||
|
t.Fatal("collectedFactAvailable() = true, want false without source")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Collected = facts.CollectedFacts{SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{}}
|
||||||
|
if !collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
|
||||||
|
t.Fatal("collectedFactAvailable() = false, want true with checked source")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveOutlookDerivedRequirementAvailability(t *testing.T) {
|
||||||
|
ctx := ModuleContext{}
|
||||||
|
if derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
|
||||||
|
t.Fatal("derivedFactAvailable() = true, want false without derived outlooks")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Derived = facts.DerivedFacts{SPCConvectiveOutlooks: []weatherdata.ConvectiveOutlook{}}
|
||||||
|
if !derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
|
||||||
|
t.Fatal("derivedFactAvailable() = false, want true for checked empty derived outlooks")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func noopModuleBuilder(ModuleContext, any) (*module.Output, error) {
|
func noopModuleBuilder(ModuleContext, any) (*module.Output, error) {
|
||||||
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil
|
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type testRegistryValue struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRegistryModuleContext() ModuleContext {
|
||||||
|
return ModuleContext{
|
||||||
|
Resolved: report.Resolved{
|
||||||
|
Definition: report.DefaultRegistry().MustLookup(report.Daily),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,8 +19,8 @@ type NarrativeForecastModule struct {
|
|||||||
|
|
||||||
type NarrativeForecastPeriod struct {
|
type NarrativeForecastPeriod struct {
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
StartTime string `json:"start_time,omitempty"`
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
EndTime string `json:"end_time,omitempty"`
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
IsDay *bool `json:"is_day,omitempty"`
|
IsDay *bool `json:"is_day,omitempty"`
|
||||||
TextDescription string `json:"text_description,omitempty"`
|
TextDescription string `json:"text_description,omitempty"`
|
||||||
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||||
@@ -58,10 +59,11 @@ func buildNarrativeForecastModule(ctx ModuleContext, _ any) (*module.Output, err
|
|||||||
func narrativeForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []NarrativeForecastPeriod {
|
func narrativeForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []NarrativeForecastPeriod {
|
||||||
out := make([]NarrativeForecastPeriod, 0, len(periods))
|
out := make([]NarrativeForecastPeriod, 0, len(periods))
|
||||||
for _, period := range periods {
|
for _, period := range periods {
|
||||||
|
validPeriod := timeutil.Period{Start: period.StartTime, End: period.EndTime}
|
||||||
out = append(out, NarrativeForecastPeriod{
|
out = append(out, NarrativeForecastPeriod{
|
||||||
Name: period.Name,
|
Name: period.Name,
|
||||||
StartTime: friendlyDateTimeLabel(period.StartTime, timezone),
|
PeriodBegins: friendlyPeriodBeginsLabel(validPeriod, timezone),
|
||||||
EndTime: friendlyDateTimeLabel(period.EndTime, timezone),
|
PeriodEnds: friendlyPeriodEndsLabel(validPeriod, timezone),
|
||||||
IsDay: copyBool(period.IsDay),
|
IsDay: copyBool(period.IsDay),
|
||||||
TextDescription: period.TextDescription,
|
TextDescription: period.TextDescription,
|
||||||
TemperatureC: copyFloat(period.TemperatureC),
|
TemperatureC: copyFloat(period.TemperatureC),
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ type OutdoorWindowsModule struct {
|
|||||||
|
|
||||||
type OutdoorWindowModule struct {
|
type OutdoorWindowModule struct {
|
||||||
Daypart string `json:"daypart"`
|
Daypart string `json:"daypart"`
|
||||||
Start string `json:"start"`
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
End string `json:"end"`
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
Reasons []string `json:"reasons,omitempty"`
|
Reasons []string `json:"reasons,omitempty"`
|
||||||
Score float64 `json:"score"`
|
Score float64 `json:"score"`
|
||||||
}
|
}
|
||||||
@@ -18,20 +18,20 @@ type OutdoorWindowModule struct {
|
|||||||
func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries)
|
windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries)
|
||||||
value := OutdoorWindowsModule{
|
value := OutdoorWindowsModule{
|
||||||
Best: outdoorWindowValue(windows.Best),
|
Best: outdoorWindowValue(windows.Best, ctx.Timezone),
|
||||||
Worst: outdoorWindowValue(windows.Worst),
|
Worst: outdoorWindowValue(windows.Worst, ctx.Timezone),
|
||||||
}
|
}
|
||||||
return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil
|
return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func outdoorWindowValue(window *OutdoorWindow) *OutdoorWindowModule {
|
func outdoorWindowValue(window *OutdoorWindow, timezone string) *OutdoorWindowModule {
|
||||||
if window == nil {
|
if window == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &OutdoorWindowModule{
|
return &OutdoorWindowModule{
|
||||||
Daypart: window.Daypart,
|
Daypart: window.Daypart,
|
||||||
Start: window.Start,
|
PeriodBegins: friendlyPeriodBeginsLabel(window.Period, timezone),
|
||||||
End: window.End,
|
PeriodEnds: friendlyPeriodEndsLabel(window.Period, timezone),
|
||||||
Reasons: append([]string(nil), window.Reasons...),
|
Reasons: append([]string(nil), window.Reasons...),
|
||||||
Score: window.Score,
|
Score: window.Score,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,9 +185,9 @@ func alertStatus(bundle *weatherdata.Bundle) *AlertStatus {
|
|||||||
|
|
||||||
func variantForReport(id report.ID) string {
|
func variantForReport(id report.ID) string {
|
||||||
switch id {
|
switch id {
|
||||||
case report.DailyToday:
|
case report.Daily, report.Today:
|
||||||
return "today"
|
return "today"
|
||||||
case report.DailyTomorrow:
|
case report.Tomorrow:
|
||||||
return "tomorrow"
|
return "tomorrow"
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -14,10 +14,13 @@ type PrecipTimingModule struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PrecipitationWindowModule struct {
|
type PrecipitationWindowModule struct {
|
||||||
Start string `json:"start"`
|
PeriodBegins string `json:"period_begins"`
|
||||||
End string `json:"end,omitempty"`
|
PeriodBeginsHourLabel string `json:"period_begins_hour_label,omitempty"`
|
||||||
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
|
PeriodEndsHourLabel string `json:"period_ends_hour_label,omitempty"`
|
||||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||||
|
MaxPopHourLabel string `json:"max_pop_hour_label,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildPrecipTimingModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
func buildPrecipTimingModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
@@ -36,13 +39,16 @@ func precipTimingValue(timing forecast.PrecipTiming, timezone string) PrecipTimi
|
|||||||
}
|
}
|
||||||
for _, window := range timing.PrecipitationWindows {
|
for _, window := range timing.PrecipitationWindows {
|
||||||
item := PrecipitationWindowModule{
|
item := PrecipitationWindowModule{
|
||||||
Start: clockLabel(window.Start, timezone),
|
PeriodBegins: friendlyDateTimeLabel(window.Start, timezone),
|
||||||
|
PeriodBeginsHourLabel: hourMinuteLabel(window.Start, timezone),
|
||||||
}
|
}
|
||||||
if window.End != nil {
|
if window.End != nil {
|
||||||
item.End = clockLabel(*window.End, timezone)
|
item.PeriodEnds = friendlyDateTimeLabel(*window.End, timezone)
|
||||||
|
item.PeriodEndsHourLabel = hourMinuteLabel(*window.End, timezone)
|
||||||
}
|
}
|
||||||
item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value)
|
item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value)
|
||||||
item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone)
|
item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone)
|
||||||
|
item.MaxPopHourLabel = hourMinuteLabel(window.MaxPrecipitationProbability.Time, timezone)
|
||||||
value.PrecipitationWindows = append(value.PrecipitationWindows, item)
|
value.PrecipitationWindows = append(value.PrecipitationWindows, item)
|
||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
|
|||||||
94
internal/briefing/spc_convective_discussion_module.go
Normal file
94
internal/briefing/spc_convective_discussion_module.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultSPCConvectiveDiscussionMinimumSeverityRank = 3
|
||||||
|
const spcCategoricalOutlookType = "categorical"
|
||||||
|
|
||||||
|
type SPCConvectiveDiscussionModule struct {
|
||||||
|
IncludedBecause string `json:"included_because"`
|
||||||
|
Discussions []SPCConvectiveDiscussionRecord `json:"discussions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SPCConvectiveDiscussionRecord struct {
|
||||||
|
Day int `json:"day,omitempty"`
|
||||||
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
|
Headline string `json:"headline,omitempty"`
|
||||||
|
Summary string `json:"summary,omitempty"`
|
||||||
|
Discussion string `json:"discussion,omitempty"`
|
||||||
|
UpdatedAt string `json:"updated_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSPCConvectiveDiscussionModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
qualifyingPeriods := spcConvectiveDiscussionQualifyingPeriods(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod)
|
||||||
|
if len(qualifyingPeriods) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
records := spcConvectiveDiscussionRecords(qualifyingPeriods, ctx.Derived.SPCConvectiveDiscussions, ctx.Timezone)
|
||||||
|
if len(records) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
value := SPCConvectiveDiscussionModule{
|
||||||
|
IncludedBecause: fmt.Sprintf("%s severity_rank >= %d", spcCategoricalOutlookType, defaultSPCConvectiveDiscussionMinimumSeverityRank),
|
||||||
|
Discussions: records,
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.SPCConvectiveDiscussion, StanzaName: string(module.SPCConvectiveDiscussion), Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func spcConvectiveDiscussionQualifyingPeriods(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period) map[int]timeutil.Period {
|
||||||
|
periods := map[int]timeutil.Period{}
|
||||||
|
for _, outlook := range outlooks {
|
||||||
|
if outlook.OutlookType != spcCategoricalOutlookType {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if outlook.SeverityRank == nil || *outlook.SeverityRank < defaultSPCConvectiveDiscussionMinimumSeverityRank {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
|
||||||
|
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(reportPeriod) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if existing, ok := periods[outlook.Day]; ok {
|
||||||
|
if outlookPeriod.Start.Before(existing.Start) {
|
||||||
|
existing.Start = outlookPeriod.Start
|
||||||
|
}
|
||||||
|
if outlookPeriod.End.After(existing.End) {
|
||||||
|
existing.End = outlookPeriod.End
|
||||||
|
}
|
||||||
|
periods[outlook.Day] = existing
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
periods[outlook.Day] = outlookPeriod
|
||||||
|
}
|
||||||
|
return periods
|
||||||
|
}
|
||||||
|
|
||||||
|
func spcConvectiveDiscussionRecords(qualifyingPeriods map[int]timeutil.Period, discussions []weatherdata.ConvectiveOutlookDiscussion, timezone string) []SPCConvectiveDiscussionRecord {
|
||||||
|
records := make([]SPCConvectiveDiscussionRecord, 0, len(discussions))
|
||||||
|
for _, discussion := range discussions {
|
||||||
|
period, ok := qualifyingPeriods[discussion.Day]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if discussion.Discussion == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
records = append(records, SPCConvectiveDiscussionRecord{
|
||||||
|
Day: discussion.Day,
|
||||||
|
PeriodBegins: friendlyPeriodBeginsLabel(period, timezone),
|
||||||
|
PeriodEnds: friendlyPeriodEndsLabel(period, timezone),
|
||||||
|
Headline: discussion.Headline,
|
||||||
|
Summary: discussion.Summary,
|
||||||
|
Discussion: discussion.Discussion,
|
||||||
|
UpdatedAt: friendlyOptionalTime(discussion.UpdatedAt, timezone),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
317
internal/briefing/spc_convective_discussion_module_test.go
Normal file
317
internal/briefing/spc_convective_discussion_module_test.go
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSPCConvectiveDiscussionModuleOmitsBelowThresholdButOutlookRemains(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := spcConvectiveDiscussionContext(2, []weatherdata.ConvectiveOutlookDiscussion{
|
||||||
|
spcDiscussion(1, "Lower risk", "General thunderstorms.", "No organized severe weather is expected.", "2026-05-29T08:30:00-05:00"),
|
||||||
|
})
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(discussion) error = %v", err)
|
||||||
|
}
|
||||||
|
if output != nil {
|
||||||
|
t.Fatalf("discussion output = %#v, want omitted below threshold", output)
|
||||||
|
}
|
||||||
|
|
||||||
|
outlookOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule(outlooks) error = %v", err)
|
||||||
|
}
|
||||||
|
outlookValue := moduleValue[SPCConvectiveOutlooksModule](t, outlookOutput)
|
||||||
|
if !outlookValue.Checked || outlookValue.OutlookCount != 1 {
|
||||||
|
t.Fatalf("outlook value = %#v, want lower-risk outlook still emitted", outlookValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveDiscussionModuleIncludesEqualThresholdDiscussion(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := spcConvectiveDiscussionContext(3, []weatherdata.ConvectiveOutlookDiscussion{
|
||||||
|
spcDiscussion(1, "Severe storms possible", "Scattered severe storms are possible.", "A few storms may become severe during the afternoon.", "2026-05-29T08:30:00-05:00"),
|
||||||
|
})
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
if output == nil || output.ID != module.SPCConvectiveDiscussion || output.StanzaName != "spc_convective_discussion" {
|
||||||
|
t.Fatalf("output = %#v, want spc convective discussion stanza", output)
|
||||||
|
}
|
||||||
|
value := moduleValue[SPCConvectiveDiscussionModule](t, output)
|
||||||
|
if value.IncludedBecause != "categorical severity_rank >= 3" || len(value.Discussions) != 1 {
|
||||||
|
t.Fatalf("value = %#v, want threshold reason and one discussion", value)
|
||||||
|
}
|
||||||
|
discussion := value.Discussions[0]
|
||||||
|
if discussion.Day != 1 || discussion.Headline != "Severe storms possible" || discussion.Summary == "" || discussion.Discussion == "" {
|
||||||
|
t.Fatalf("discussion = %#v, want prompt-facing discussion fields", discussion)
|
||||||
|
}
|
||||||
|
if discussion.PeriodBegins != "2026-05-29 at 11:00 AM" || discussion.PeriodEnds != "2026-05-30 at 7:00 AM" {
|
||||||
|
t.Fatalf("Period = %q/%q, want qualifying outlook valid period", discussion.PeriodBegins, discussion.PeriodEnds)
|
||||||
|
}
|
||||||
|
if discussion.UpdatedAt != "2026-05-29 at 8:30 AM" {
|
||||||
|
t.Fatalf("UpdatedAt = %q, want friendly local time", discussion.UpdatedAt)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(output.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
for _, field := range []string{"included_because", "discussions", "period_begins", "period_ends", "headline", "summary", "discussion", "updated_at"} {
|
||||||
|
if !strings.Contains(text, field) {
|
||||||
|
t.Fatalf("json = %s, want field %s", text, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(text, `"period":`) {
|
||||||
|
t.Fatalf("json = %s, want period_begins/period_ends instead of period", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveDiscussionModuleIncludesAboveThresholdDiscussion(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := spcConvectiveDiscussionContext(4, []weatherdata.ConvectiveOutlookDiscussion{
|
||||||
|
spcDiscussion(1, "Enhanced severe risk", "Numerous severe storms are possible.", "Severe storms may produce damaging winds.", "2026-05-29T09:15:00-05:00"),
|
||||||
|
})
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
value := moduleValue[SPCConvectiveDiscussionModule](t, output)
|
||||||
|
if len(value.Discussions) != 1 || value.Discussions[0].Headline != "Enhanced severe risk" {
|
||||||
|
t.Fatalf("value = %#v, want above-threshold discussion", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveDiscussionModuleIgnoresHighRankNonCategoricalOutlook(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
rank := 30
|
||||||
|
ctx := testModuleContext()
|
||||||
|
outlook := weatherdata.ConvectiveOutlook{
|
||||||
|
ID: "day1-wind-30",
|
||||||
|
Day: 1,
|
||||||
|
OutlookType: "wind",
|
||||||
|
Label: "30%",
|
||||||
|
LabelText: "30% Wind Risk",
|
||||||
|
SeverityRank: &rank,
|
||||||
|
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||||
|
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||||
|
}
|
||||||
|
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||||
|
}
|
||||||
|
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||||
|
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
|
||||||
|
spcDiscussion(1, "Wind risk discussion", "High wind probabilities.", "This discussion should not be emitted from wind severity rank.", "2026-05-29T08:30:00-05:00"),
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
if output != nil {
|
||||||
|
t.Fatalf("output = %#v, want non-categorical outlook ignored for discussion threshold", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveDiscussionModuleRequiresCategoricalThresholdForMixedSameDayOutlooks(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
rank2 := 2
|
||||||
|
rank30 := 30
|
||||||
|
ctx := testModuleContext()
|
||||||
|
categorical := weatherdata.ConvectiveOutlook{
|
||||||
|
ID: "day1-marginal",
|
||||||
|
Day: 1,
|
||||||
|
OutlookType: "categorical",
|
||||||
|
Label: "MRGL",
|
||||||
|
LabelText: "Marginal Risk",
|
||||||
|
SeverityRank: &rank2,
|
||||||
|
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||||
|
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||||
|
}
|
||||||
|
wind := weatherdata.ConvectiveOutlook{
|
||||||
|
ID: "day1-wind-30",
|
||||||
|
Day: 1,
|
||||||
|
OutlookType: "wind",
|
||||||
|
Label: "30%",
|
||||||
|
LabelText: "30% Wind Risk",
|
||||||
|
SeverityRank: &rank30,
|
||||||
|
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||||
|
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||||
|
}
|
||||||
|
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{categorical, wind},
|
||||||
|
}
|
||||||
|
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{categorical, wind}
|
||||||
|
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
|
||||||
|
spcDiscussion(1, "Mixed risk discussion", "Only wind is high.", "This discussion should not be emitted without categorical threshold.", "2026-05-29T08:30:00-05:00"),
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
if output != nil {
|
||||||
|
t.Fatalf("output = %#v, want mixed day omitted when categorical outlook is below threshold", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveDiscussionModuleIncludesOnlyQualifyingDays(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
rank2 := 2
|
||||||
|
rank4 := 4
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Resolved.ValidPeriod.Start = mustParseModuleTime("2026-05-30T00:00:00-05:00")
|
||||||
|
ctx.Resolved.ValidPeriod.End = mustParseModuleTime("2026-05-31T00:00:00-05:00")
|
||||||
|
day1Outlook := weatherdata.ConvectiveOutlook{
|
||||||
|
ID: "day1-marginal",
|
||||||
|
Day: 1,
|
||||||
|
OutlookType: "categorical",
|
||||||
|
Label: "MRGL",
|
||||||
|
LabelText: "Marginal Risk",
|
||||||
|
SeverityRank: &rank2,
|
||||||
|
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||||
|
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||||
|
}
|
||||||
|
day2Outlook := weatherdata.ConvectiveOutlook{
|
||||||
|
ID: "day2-enhanced",
|
||||||
|
Day: 2,
|
||||||
|
OutlookType: "categorical",
|
||||||
|
Label: "ENH",
|
||||||
|
LabelText: "Enhanced Risk",
|
||||||
|
SeverityRank: &rank4,
|
||||||
|
ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||||
|
ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"),
|
||||||
|
}
|
||||||
|
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{day1Outlook, day2Outlook},
|
||||||
|
}
|
||||||
|
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{day1Outlook, day2Outlook}
|
||||||
|
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
|
||||||
|
spcDiscussion(1, "Day 1 regional discussion", "Marginal risk discussion.", "Day 1 text should not be emitted.", "2026-05-29T08:30:00-05:00"),
|
||||||
|
spcDiscussion(2, "Day 2 regional discussion", "Enhanced risk discussion.", "Day 2 text should be emitted.", "2026-05-30T08:30:00-05:00"),
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
value := moduleValue[SPCConvectiveDiscussionModule](t, output)
|
||||||
|
if len(value.Discussions) != 1 {
|
||||||
|
t.Fatalf("Discussions = %#v, want only the qualifying day discussion", value.Discussions)
|
||||||
|
}
|
||||||
|
if value.Discussions[0].Day != 2 || value.Discussions[0].Headline != "Day 2 regional discussion" {
|
||||||
|
t.Fatalf("Discussions[0] = %#v, want day 2 discussion only", value.Discussions[0])
|
||||||
|
}
|
||||||
|
if value.Discussions[0].PeriodBegins != "2026-05-30 at 7:00 AM" || value.Discussions[0].PeriodEnds != "2026-05-31 at 7:00 AM" {
|
||||||
|
t.Fatalf("Period = %q/%q, want qualifying day 2 outlook period", value.Discussions[0].PeriodBegins, value.Discussions[0].PeriodEnds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveDiscussionModuleOmitsNonOverlappingQualifyingOutlook(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
rank := 5
|
||||||
|
ctx := testModuleContext()
|
||||||
|
outlook := weatherdata.ConvectiveOutlook{
|
||||||
|
ID: "day2-enhanced",
|
||||||
|
Day: 2,
|
||||||
|
OutlookType: "categorical",
|
||||||
|
Label: "ENH",
|
||||||
|
LabelText: "Enhanced Risk",
|
||||||
|
SeverityRank: &rank,
|
||||||
|
ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||||
|
ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"),
|
||||||
|
}
|
||||||
|
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||||
|
}
|
||||||
|
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||||
|
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
|
||||||
|
spcDiscussion(2, "Day 2 regional discussion", "Enhanced risk discussion.", "Day 2 text should not be emitted for today.", "2026-05-30T08:30:00-05:00"),
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
if output != nil {
|
||||||
|
t.Fatalf("output = %#v, want non-overlapping discussion omitted", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveDiscussionModuleOmitsMissingDiscussionText(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := spcConvectiveDiscussionContext(3, []weatherdata.ConvectiveOutlookDiscussion{
|
||||||
|
{Day: 1, Headline: "Severe storms possible", Summary: "Scattered severe storms are possible.", UpdatedAt: ptrModuleTime("2026-05-29T08:30:00-05:00")},
|
||||||
|
})
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
if output != nil {
|
||||||
|
t.Fatalf("output = %#v, want omitted when discussion text is missing", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveDiscussionModuleOmitsMissingSource(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Collected.SPCConvectiveOutlooks = nil
|
||||||
|
ctx.Derived.SPCConvectiveOutlooks = nil
|
||||||
|
ctx.Derived.SPCConvectiveDiscussions = nil
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
if output != nil {
|
||||||
|
t.Fatalf("output = %#v, want omitted for missing optional source", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func spcConvectiveDiscussionContext(rank int, discussions []weatherdata.ConvectiveOutlookDiscussion) ModuleContext {
|
||||||
|
ctx := testModuleContext()
|
||||||
|
outlook := weatherdata.ConvectiveOutlook{
|
||||||
|
ID: "day1-categorical",
|
||||||
|
Day: 1,
|
||||||
|
OutlookType: "categorical",
|
||||||
|
Label: "SLGT",
|
||||||
|
LabelText: "Slight Risk",
|
||||||
|
SeverityRank: &rank,
|
||||||
|
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||||
|
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||||
|
}
|
||||||
|
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||||
|
AsOf: ptrModuleTime("2026-05-29T08:00:00-05:00"),
|
||||||
|
IssuedAt: ptrModuleTime("2026-05-29T07:45:00-05:00"),
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||||
|
}
|
||||||
|
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||||
|
ctx.Derived.SPCConvectiveDiscussions = discussions
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func spcDiscussion(day int, headline string, summary string, discussion string, updatedAt string) weatherdata.ConvectiveOutlookDiscussion {
|
||||||
|
return weatherdata.ConvectiveOutlookDiscussion{
|
||||||
|
Day: day,
|
||||||
|
Headline: headline,
|
||||||
|
Summary: summary,
|
||||||
|
Discussion: discussion,
|
||||||
|
UpdatedAt: ptrModuleTime(updatedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptrModuleTime(value string) *time.Time {
|
||||||
|
parsed := mustParseModuleTime(value)
|
||||||
|
return &parsed
|
||||||
|
}
|
||||||
92
internal/briefing/spc_convective_outlooks_module.go
Normal file
92
internal/briefing/spc_convective_outlooks_module.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SPCConvectiveOutlooksModule struct {
|
||||||
|
Checked bool `json:"checked"`
|
||||||
|
AsOf string `json:"as_of,omitempty"`
|
||||||
|
IssuedAt string `json:"issued_at,omitempty"`
|
||||||
|
LocationID string `json:"location_id,omitempty"`
|
||||||
|
LocationName string `json:"location_name,omitempty"`
|
||||||
|
OutlookCount int `json:"outlook_count"`
|
||||||
|
Outlooks []SPCConvectiveOutlookRecord `json:"outlooks,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SPCConvectiveOutlookRecord struct {
|
||||||
|
Day int `json:"day,omitempty"`
|
||||||
|
OutlookType string `json:"outlook_type,omitempty"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
LabelText string `json:"label_text,omitempty"`
|
||||||
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
|
IssuedAt string `json:"issued_at,omitempty"`
|
||||||
|
ContainsLocation bool `json:"contains_location"`
|
||||||
|
ImageURL string `json:"image_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSPCConvectiveOutlooksModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
value := SPCConvectiveOutlooksModule{}
|
||||||
|
run := ctx.Collected.SPCConvectiveOutlooks
|
||||||
|
if run != nil {
|
||||||
|
value.Checked = true
|
||||||
|
value.AsOf = friendlyOptionalTime(run.AsOf, ctx.Timezone)
|
||||||
|
value.IssuedAt = friendlyOptionalTime(run.IssuedAt, ctx.Timezone)
|
||||||
|
value.LocationID = run.LocationID
|
||||||
|
value.LocationName = run.LocationName
|
||||||
|
}
|
||||||
|
source, ok := sourceByName(ctx.Collected.SourceProvenance, string(module.SPCConvectiveOutlooks))
|
||||||
|
if value.Checked && value.AsOf == "" && ok && !source.FetchedAt.IsZero() {
|
||||||
|
value.AsOf = friendlyDateTimeLabel(source.FetchedAt, ctx.Timezone)
|
||||||
|
}
|
||||||
|
if value.Checked && value.IssuedAt == "" && ok {
|
||||||
|
value.IssuedAt = friendlyOptionalTime(source.IssuedAt, ctx.Timezone)
|
||||||
|
}
|
||||||
|
|
||||||
|
value.Outlooks = spcConvectiveOutlookRecords(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod, ctx.Timezone)
|
||||||
|
value.OutlookCount = len(value.Outlooks)
|
||||||
|
return &module.Output{ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), Value: value}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func spcConvectiveOutlookRecords(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period, timezone string) []SPCConvectiveOutlookRecord {
|
||||||
|
records := make([]SPCConvectiveOutlookRecord, 0, len(outlooks))
|
||||||
|
for _, outlook := range outlooks {
|
||||||
|
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
|
||||||
|
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(reportPeriod) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
records = append(records, SPCConvectiveOutlookRecord{
|
||||||
|
Day: outlook.Day,
|
||||||
|
OutlookType: outlook.OutlookType,
|
||||||
|
Label: outlook.Label,
|
||||||
|
LabelText: outlook.LabelText,
|
||||||
|
PeriodBegins: friendlyPeriodBeginsLabel(outlookPeriod, timezone),
|
||||||
|
PeriodEnds: friendlyPeriodEndsLabel(outlookPeriod, timezone),
|
||||||
|
IssuedAt: friendlyOptionalTime(outlook.IssuedAt, timezone),
|
||||||
|
ContainsLocation: outlook.ContainsLocation,
|
||||||
|
ImageURL: outlook.ImageURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
func friendlyOptionalTime(value *time.Time, timezone string) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return friendlyDateTimeLabel(*value, timezone)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceByName(sources []weatherdata.Source, name string) (weatherdata.Source, bool) {
|
||||||
|
for _, source := range sources {
|
||||||
|
if source.Name == name {
|
||||||
|
return source, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return weatherdata.Source{}, false
|
||||||
|
}
|
||||||
169
internal/briefing/spc_convective_outlooks_module_test.go
Normal file
169
internal/briefing/spc_convective_outlooks_module_test.go
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
rank := 3
|
||||||
|
asOf := mustParseModuleTime("2026-05-29T14:00:00Z")
|
||||||
|
issuedAt := mustParseModuleTime("2026-05-29T13:45:00Z")
|
||||||
|
expiresAt := mustParseModuleTime("2026-05-30T07:00:00-05:00")
|
||||||
|
outlook := weatherdata.ConvectiveOutlook{
|
||||||
|
ID: "day1-categorical-slight",
|
||||||
|
Provider: "spc",
|
||||||
|
Product: "convective_outlook",
|
||||||
|
Day: 1,
|
||||||
|
OutlookType: "categorical",
|
||||||
|
Label: "SLGT",
|
||||||
|
LabelText: "Slight Risk",
|
||||||
|
Forecaster: "Smith",
|
||||||
|
SeverityRank: &rank,
|
||||||
|
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||||
|
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||||
|
IssuedAt: &issuedAt,
|
||||||
|
ExpiresAt: &expiresAt,
|
||||||
|
SourceURL: "https://www.spc.noaa.gov/products/outlook/day1otlk.html",
|
||||||
|
ImageURL: "https://www.spc.noaa.gov/products/outlook/day1probotlk_2000_torn.gif",
|
||||||
|
ContainsLocation: true,
|
||||||
|
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[]}`),
|
||||||
|
}
|
||||||
|
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||||
|
LocationID: "nws-lsx-grid-90-74",
|
||||||
|
LocationName: "St. Louis, MO",
|
||||||
|
AsOf: &asOf,
|
||||||
|
IssuedAt: &issuedAt,
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||||
|
}
|
||||||
|
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
if output == nil || output.ID != module.SPCConvectiveOutlooks || output.StanzaName != "spc_convective_outlooks" {
|
||||||
|
t.Fatalf("output = %#v, want spc convective outlook output", output)
|
||||||
|
}
|
||||||
|
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
|
||||||
|
if !value.Checked || value.OutlookCount != 1 || value.AsOf != "2026-05-29 at 9:00 AM" || value.IssuedAt != "2026-05-29 at 8:45 AM" {
|
||||||
|
t.Fatalf("SPCConvectiveOutlooksModule = %#v, want checked source timing and one outlook", value)
|
||||||
|
}
|
||||||
|
if value.LocationID != "nws-lsx-grid-90-74" || value.LocationName != "St. Louis, MO" {
|
||||||
|
t.Fatalf("source location = %q/%q, want Weather API location", value.LocationID, value.LocationName)
|
||||||
|
}
|
||||||
|
if len(value.Outlooks) != 1 {
|
||||||
|
t.Fatalf("Outlooks length = %d, want 1", len(value.Outlooks))
|
||||||
|
}
|
||||||
|
got := value.Outlooks[0]
|
||||||
|
if got.Day != 1 || got.OutlookType != "categorical" || got.Label != "SLGT" || got.LabelText != "Slight Risk" {
|
||||||
|
t.Fatalf("outlook = %#v, want categorical slight risk fields", got)
|
||||||
|
}
|
||||||
|
if got.PeriodBegins != "2026-05-29 at 11:00 AM" || got.PeriodEnds != "2026-05-30 at 7:00 AM" || got.IssuedAt != "2026-05-29 at 8:45 AM" {
|
||||||
|
t.Fatalf("outlook times = %#v, want friendly local labels", got)
|
||||||
|
}
|
||||||
|
if !got.ContainsLocation || got.ImageURL == "" {
|
||||||
|
t.Fatalf("outlook = %#v, want location flag and image URL", got)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(output.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
for _, field := range []string{"checked", "as_of", "issued_at", "location_id", "location_name", "outlook_count", "outlooks", "period_begins", "period_ends", "contains_location", "image_url"} {
|
||||||
|
if !strings.Contains(text, field) {
|
||||||
|
t.Fatalf("json = %s, want field %s", text, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, omitted := range []string{"geometry", "coordinates", "forecaster", "provider", "severity_rank", "expires_at", "source_url", "valid_start", "valid_end", `"period":`} {
|
||||||
|
if strings.Contains(text, omitted) {
|
||||||
|
t.Fatalf("json = %s, want prompt-safe outlook without %s", text, omitted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveOutlooksModuleSkipsNonOverlappingOutlooks(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
rank := 5
|
||||||
|
outlook := weatherdata.ConvectiveOutlook{
|
||||||
|
ID: "tomorrow-enhanced",
|
||||||
|
Day: 2,
|
||||||
|
OutlookType: "categorical",
|
||||||
|
Label: "ENH",
|
||||||
|
LabelText: "Enhanced Risk",
|
||||||
|
SeverityRank: &rank,
|
||||||
|
ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||||
|
ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"),
|
||||||
|
ContainsLocation: true,
|
||||||
|
}
|
||||||
|
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||||
|
AsOf: ptrModuleTime("2026-05-29T14:00:00Z"),
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||||
|
}
|
||||||
|
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
|
||||||
|
if value.OutlookCount != 0 || len(value.Outlooks) != 0 {
|
||||||
|
t.Fatalf("value = %#v, want non-overlapping outlook omitted", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveOutlooksModuleBuildsCheckedEmptyStanza(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
asOf := mustParseModuleTime("2026-05-29T14:00:00Z")
|
||||||
|
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||||
|
LocationID: "nws-lsx-grid-90-74",
|
||||||
|
LocationName: "St. Louis, MO",
|
||||||
|
AsOf: &asOf,
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{},
|
||||||
|
}
|
||||||
|
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
|
||||||
|
if !value.Checked || value.OutlookCount != 0 || len(value.Outlooks) != 0 {
|
||||||
|
t.Fatalf("checked empty value = %#v, want checked source with no retained outlooks", value)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(output.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), "outlooks") {
|
||||||
|
t.Fatalf("json = %s, want empty outlook list omitted", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveOutlooksModuleBuildsUncheckedStanzaForMissingSource(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Collected.SPCConvectiveOutlooks = nil
|
||||||
|
ctx.Collected.SourceProvenance = []weatherdata.Source{{
|
||||||
|
Name: string(module.SPCConvectiveOutlooks),
|
||||||
|
Missing: true,
|
||||||
|
}}
|
||||||
|
ctx.Derived.SPCConvectiveOutlooks = nil
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
|
||||||
|
if value.Checked || value.OutlookCount != 0 || value.AsOf != "" || value.IssuedAt != "" {
|
||||||
|
t.Fatalf("missing source value = %#v, want unchecked empty stanza", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
type OutdoorWindows struct {
|
type OutdoorWindows struct {
|
||||||
@@ -16,8 +17,7 @@ type OutdoorWindows struct {
|
|||||||
|
|
||||||
type OutdoorWindow struct {
|
type OutdoorWindow struct {
|
||||||
Daypart string
|
Daypart string
|
||||||
Start string
|
Period timeutil.Period
|
||||||
End string
|
|
||||||
Reasons []string
|
Reasons []string
|
||||||
Score float64
|
Score float64
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,19 @@ type TomorrowPlanning struct {
|
|||||||
OvernightChangeWatch []string
|
OvernightChangeWatch []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type morningCommuteOvernightPlanning struct {
|
||||||
|
MorningReadiness []string
|
||||||
|
CommuteSchoolWorkdayConcerns []string
|
||||||
|
OvernightChangeWatch []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TodayPlanning struct {
|
||||||
|
MorningReadiness []string
|
||||||
|
CommuteSchoolWorkdayConcerns []string
|
||||||
|
OutdoorPlanning []string
|
||||||
|
LateDayChangeWatch []string
|
||||||
|
}
|
||||||
|
|
||||||
func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
|
func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
|
||||||
var best *OutdoorWindow
|
var best *OutdoorWindow
|
||||||
var worst *OutdoorWindow
|
var worst *OutdoorWindow
|
||||||
@@ -48,8 +61,60 @@ func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
|
|||||||
return OutdoorWindows{Best: best, Worst: worst}
|
return OutdoorWindows{Best: best, Worst: worst}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildTodayPlanning(summary *forecast.DailySummary) *TodayPlanning {
|
||||||
|
planning := &TodayPlanning{}
|
||||||
|
morning := daypartNamed(summary.Dayparts, "morning")
|
||||||
|
if morning != nil {
|
||||||
|
planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...)
|
||||||
|
}
|
||||||
|
if len(planning.MorningReadiness) == 0 {
|
||||||
|
planning.MorningReadiness = append(planning.MorningReadiness, "Morning weather looks routine based on the available hourly forecast.")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, daypart := range summary.Dayparts {
|
||||||
|
if daypart.Name == "overnight" || daypart.Name == "evening" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, concernNotes(daypart)...)
|
||||||
|
}
|
||||||
|
for _, alert := range summary.AlertOverlaps {
|
||||||
|
if alert.Event != "" {
|
||||||
|
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, "Active alert to plan around: "+alert.Event+".")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(planning.CommuteSchoolWorkdayConcerns) == 0 {
|
||||||
|
planning.CommuteSchoolWorkdayConcerns = append(planning.CommuteSchoolWorkdayConcerns, "No major commute, school, or workday weather concerns stand out in the available forecast.")
|
||||||
|
}
|
||||||
|
|
||||||
|
planning.OutdoorPlanning = append(planning.OutdoorPlanning, outdoorPlanningNotes(summary.Dayparts)...)
|
||||||
|
if len(planning.OutdoorPlanning) == 0 {
|
||||||
|
planning.OutdoorPlanning = append(planning.OutdoorPlanning, "No standout outdoor weather constraints are evident in the available forecast.")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range []string{"afternoon", "evening"} {
|
||||||
|
daypart := daypartNamed(summary.Dayparts, name)
|
||||||
|
if daypart != nil {
|
||||||
|
planning.LateDayChangeWatch = appendUnique(planning.LateDayChangeWatch, lateDayWatchNotes(*daypart)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(planning.LateDayChangeWatch) == 0 {
|
||||||
|
planning.LateDayChangeWatch = append(planning.LateDayChangeWatch, "Watch for forecast timing or intensity adjustments later today.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return planning
|
||||||
|
}
|
||||||
|
|
||||||
func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning {
|
func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning {
|
||||||
planning := &TomorrowPlanning{}
|
base := buildMorningCommuteOvernightPlanning(summary)
|
||||||
|
return &TomorrowPlanning{
|
||||||
|
MorningReadiness: append([]string(nil), base.MorningReadiness...),
|
||||||
|
CommuteSchoolWorkdayConcerns: append([]string(nil), base.CommuteSchoolWorkdayConcerns...),
|
||||||
|
OvernightChangeWatch: append([]string(nil), base.OvernightChangeWatch...),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMorningCommuteOvernightPlanning(summary *forecast.DailySummary) *morningCommuteOvernightPlanning {
|
||||||
|
planning := &morningCommuteOvernightPlanning{}
|
||||||
morning := daypartNamed(summary.Dayparts, "morning")
|
morning := daypartNamed(summary.Dayparts, "morning")
|
||||||
if morning != nil {
|
if morning != nil {
|
||||||
planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...)
|
planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...)
|
||||||
@@ -84,6 +149,18 @@ func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning {
|
|||||||
return planning
|
return planning
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func outdoorPlanningNotes(dayparts []forecast.DaypartSummary) []string {
|
||||||
|
windows := buildOutdoorWindows(dayparts)
|
||||||
|
var notes []string
|
||||||
|
if windows.Best != nil {
|
||||||
|
notes = append(notes, fmt.Sprintf("Best outdoor window: %s (%s).", titleWord(windows.Best.Daypart), strings.Join(windows.Best.Reasons, ", ")))
|
||||||
|
}
|
||||||
|
if windows.Worst != nil && (windows.Best == nil || windows.Worst.Daypart != windows.Best.Daypart) {
|
||||||
|
notes = append(notes, fmt.Sprintf("Toughest outdoor window: %s (%s).", titleWord(windows.Worst.Daypart), strings.Join(windows.Worst.Reasons, ", ")))
|
||||||
|
}
|
||||||
|
return appendUnique(nil, notes...)
|
||||||
|
}
|
||||||
|
|
||||||
func readinessNotes(daypart forecast.DaypartSummary) []string {
|
func readinessNotes(daypart forecast.DaypartSummary) []string {
|
||||||
notes := []string{}
|
notes := []string{}
|
||||||
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 50 {
|
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 50 {
|
||||||
@@ -104,6 +181,27 @@ func readinessNotes(daypart forecast.DaypartSummary) []string {
|
|||||||
return appendUnique(nil, notes...)
|
return appendUnique(nil, notes...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func lateDayWatchNotes(daypart forecast.DaypartSummary) []string {
|
||||||
|
notes := []string{}
|
||||||
|
prefix := titleWord(daypart.Name)
|
||||||
|
if prefix == "" {
|
||||||
|
prefix = "Late-day"
|
||||||
|
}
|
||||||
|
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 {
|
||||||
|
notes = append(notes, fmt.Sprintf("%s precipitation timing may shift; current peak is near %.0f%%.", prefix, daypart.MaxPrecipitationProbability.Value))
|
||||||
|
}
|
||||||
|
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
||||||
|
notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", prefix, daypart.PeakWindGust.Value))
|
||||||
|
}
|
||||||
|
if daypart.Indicators.Snow || daypart.Indicators.Ice {
|
||||||
|
notes = append(notes, prefix+" wintry weather could affect late-day travel.")
|
||||||
|
}
|
||||||
|
if len(daypart.AlertOverlaps) > 0 {
|
||||||
|
notes = append(notes, prefix+" alert timing could affect late-day plans.")
|
||||||
|
}
|
||||||
|
return appendUnique(nil, notes...)
|
||||||
|
}
|
||||||
|
|
||||||
func concernNotes(daypart forecast.DaypartSummary) []string {
|
func concernNotes(daypart forecast.DaypartSummary) []string {
|
||||||
notes := []string{}
|
notes := []string{}
|
||||||
prefix := titleWord(daypart.Name)
|
prefix := titleWord(daypart.Name)
|
||||||
@@ -190,8 +288,7 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
|
|||||||
}
|
}
|
||||||
return OutdoorWindow{
|
return OutdoorWindow{
|
||||||
Daypart: daypart.Name,
|
Daypart: daypart.Name,
|
||||||
Start: daypart.Period.Start.Format("15:04"),
|
Period: daypart.Period,
|
||||||
End: daypart.Period.End.Format("15:04"),
|
|
||||||
Reasons: dedupe(reasons),
|
Reasons: dedupe(reasons),
|
||||||
Score: math.Round(score*10) / 10,
|
Score: math.Round(score*10) / 10,
|
||||||
}
|
}
|
||||||
|
|||||||
26
internal/briefing/today_planning_module.go
Normal file
26
internal/briefing/today_planning_module.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
|
||||||
|
type TodayPlanningModule struct {
|
||||||
|
MorningReadiness []string `json:"morning_readiness,omitempty"`
|
||||||
|
CommuteSchoolWorkdayConcerns []string `json:"commute_school_workday_concerns,omitempty"`
|
||||||
|
OutdoorPlanning []string `json:"outdoor_planning,omitempty"`
|
||||||
|
LateDayChangeWatch []string `json:"late_day_change_watch,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTodayPlanningModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
|
summary := ctx.Derived.FirstDailySummary()
|
||||||
|
if summary == nil {
|
||||||
|
return &module.Output{ID: module.TodayPlanning, StanzaName: "today_planning", Value: TodayPlanningModule{}}, nil
|
||||||
|
}
|
||||||
|
planning := buildTodayPlanning(summary)
|
||||||
|
value := TodayPlanningModule{}
|
||||||
|
if planning != nil {
|
||||||
|
value.MorningReadiness = append([]string(nil), planning.MorningReadiness...)
|
||||||
|
value.CommuteSchoolWorkdayConcerns = append([]string(nil), planning.CommuteSchoolWorkdayConcerns...)
|
||||||
|
value.OutdoorPlanning = append([]string(nil), planning.OutdoorPlanning...)
|
||||||
|
value.LateDayChangeWatch = append([]string(nil), planning.LateDayChangeWatch...)
|
||||||
|
}
|
||||||
|
return &module.Output{ID: module.TodayPlanning, StanzaName: "today_planning", Value: value}, nil
|
||||||
|
}
|
||||||
@@ -4,13 +4,14 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WeatherStoryModule struct {
|
type WeatherStoryModule struct {
|
||||||
Available bool `json:"available"`
|
Available bool `json:"available"`
|
||||||
OfficeID string `json:"office_id,omitempty"`
|
OfficeID string `json:"office_id,omitempty"`
|
||||||
StartTime time.Time `json:"start_time"`
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
EndTime time.Time `json:"end_time"`
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
@@ -25,11 +26,12 @@ func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
|||||||
if story == nil {
|
if story == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
period := timeutil.Period{Start: story.StartTime, End: story.EndTime}
|
||||||
value := WeatherStoryModule{
|
value := WeatherStoryModule{
|
||||||
Available: true,
|
Available: true,
|
||||||
OfficeID: story.OfficeID,
|
OfficeID: story.OfficeID,
|
||||||
StartTime: story.StartTime,
|
PeriodBegins: friendlyPeriodBeginsLabel(period, ctx.Timezone),
|
||||||
EndTime: story.EndTime,
|
PeriodEnds: friendlyPeriodEndsLabel(period, ctx.Timezone),
|
||||||
UpdatedAt: copyTime(story.UpdatedAt),
|
UpdatedAt: copyTime(story.UpdatedAt),
|
||||||
Title: story.Title,
|
Title: story.Title,
|
||||||
Description: story.Description,
|
Description: story.Description,
|
||||||
|
|||||||
@@ -84,7 +84,8 @@ type dailySummaryStanza struct {
|
|||||||
|
|
||||||
type daypartSummaryStanza struct {
|
type daypartSummaryStanza struct {
|
||||||
Date string `json:"date,omitempty"`
|
Date string `json:"date,omitempty"`
|
||||||
Period string `json:"period,omitempty"`
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
TempRangeF string `json:"temp_range_f,omitempty"`
|
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||||
|
|||||||
@@ -96,7 +96,13 @@ func dailySnapshot(t *testing.T, low int, high int, precip int, precipTime strin
|
|||||||
DailyPrecipitationProbability: &precip,
|
DailyPrecipitationProbability: &precip,
|
||||||
}},
|
}},
|
||||||
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
|
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
|
||||||
"morning": {Date: "2026-05-29", Period: "2026-05-29 at 6:00 AM to 2026-05-29 at 10:00 AM", TempRangeF: "60-70", Snow: snow},
|
"morning": {
|
||||||
|
Date: "2026-05-29",
|
||||||
|
PeriodBegins: "2026-05-29 at 6:00 AM",
|
||||||
|
PeriodEnds: "2026-05-29 at 10:00 AM",
|
||||||
|
TempRangeF: "60-70",
|
||||||
|
Snow: snow,
|
||||||
|
},
|
||||||
}},
|
}},
|
||||||
module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: precipTimingStanza{MaxPopPercent: &precip, MaxPopTime: precipTime}},
|
module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: precipTimingStanza{MaxPopPercent: &precip, MaxPopTime: precipTime}},
|
||||||
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: alertDigestStanza{Relevant: relevant}},
|
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: alertDigestStanza{Relevant: relevant}},
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ func outlookSnapshot(t *testing.T, date string, tempRange string, precip int, pr
|
|||||||
return snapshot(t, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
|
return snapshot(t, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
|
||||||
date + "_morning": {
|
date + "_morning": {
|
||||||
Date: date,
|
Date: date,
|
||||||
Period: date + " at 6:00 AM to " + date + " at 10:00 AM",
|
PeriodBegins: date + " at 6:00 AM",
|
||||||
|
PeriodEnds: date + " at 10:00 AM",
|
||||||
TempRangeF: tempRange,
|
TempRangeF: tempRange,
|
||||||
MaxPopPercent: &precip,
|
MaxPopPercent: &precip,
|
||||||
MaxPopTime: precipTime,
|
MaxPopTime: precipTime,
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ const helpText = `weatherreporter prepares weather reports from normalized forec
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
weatherreporter --help
|
weatherreporter --help
|
||||||
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
|
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
||||||
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
|
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||||
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
|
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
|
||||||
@@ -36,7 +38,7 @@ Options:
|
|||||||
--config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml.
|
--config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml.
|
||||||
--units VALUE Override weather API units.
|
--units VALUE Override weather API units.
|
||||||
--tz NAME Override weather API timezone.
|
--tz NAME Override weather API timezone.
|
||||||
--out PATH Write an extra Markdown report copy for generate commands.
|
--out PATH Write an extra Markdown report copy where supported by the generate command.
|
||||||
--out-dir PATH Write extra Markdown report copies for run commands.
|
--out-dir PATH Write extra Markdown report copies for run commands.
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -185,10 +187,10 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
|||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return app.GenerateRequest{}, fmt.Errorf("generate requires a report name")
|
return app.GenerateRequest{}, fmt.Errorf("generate requires a report name")
|
||||||
}
|
}
|
||||||
reportKind, ok := reportKind(args[0])
|
if _, err := report.IDForCommandName(args[0]); err != nil {
|
||||||
if !ok {
|
|
||||||
return app.GenerateRequest{}, fmt.Errorf("unknown generate report %q", args[0])
|
return app.GenerateRequest{}, fmt.Errorf("unknown generate report %q", args[0])
|
||||||
}
|
}
|
||||||
|
reportKind := app.ReportKind(args[0])
|
||||||
|
|
||||||
opts, err := parseGenerateFlags(reportKind, args[1:])
|
opts, err := parseGenerateFlags(reportKind, args[1:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -216,6 +218,14 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
|||||||
|
|
||||||
switch reportKind {
|
switch reportKind {
|
||||||
case app.ReportDaily:
|
case app.ReportDaily:
|
||||||
|
if opts.Date == "" {
|
||||||
|
return app.GenerateRequest{}, fmt.Errorf("generate daily requires --date YYYY-MM-DD")
|
||||||
|
}
|
||||||
|
req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
|
||||||
|
if err != nil {
|
||||||
|
return app.GenerateRequest{}, err
|
||||||
|
}
|
||||||
|
case app.ReportToday:
|
||||||
if opts.Date == "" {
|
if opts.Date == "" {
|
||||||
req.Date = timeutil.LocalDate(r.Clock.Now(), location)
|
req.Date = timeutil.LocalDate(r.Clock.Now(), location)
|
||||||
} else {
|
} else {
|
||||||
@@ -249,10 +259,10 @@ func (r Runner) resolveRun(args []string) (app.BatchRequest, error) {
|
|||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return app.BatchRequest{}, fmt.Errorf("run requires a batch name")
|
return app.BatchRequest{}, fmt.Errorf("run requires a batch name")
|
||||||
}
|
}
|
||||||
batch, ok := batchKind(args[0])
|
if _, err := report.BatchForCommandName(args[0]); err != nil {
|
||||||
if !ok {
|
|
||||||
return app.BatchRequest{}, fmt.Errorf("unknown run batch %q", args[0])
|
return app.BatchRequest{}, fmt.Errorf("unknown run batch %q", args[0])
|
||||||
}
|
}
|
||||||
|
batch := app.BatchKind(args[0])
|
||||||
opts, err := parseRunFlags(args[1:])
|
opts, err := parseRunFlags(args[1:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return app.BatchRequest{}, err
|
return app.BatchRequest{}, err
|
||||||
@@ -277,7 +287,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)
|
||||||
if report == app.ReportDaily {
|
if report == app.ReportDaily || report == app.ReportToday {
|
||||||
fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD")
|
fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD")
|
||||||
}
|
}
|
||||||
if report == app.ReportStorm {
|
if report == app.ReportStorm {
|
||||||
@@ -379,31 +389,3 @@ func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) {
|
|||||||
fs.StringVar(&opts.Output, "out", "", "extra Markdown report copy path")
|
fs.StringVar(&opts.Output, "out", "", "extra Markdown report copy path")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func reportKind(value string) (app.ReportKind, bool) {
|
|
||||||
switch value {
|
|
||||||
case string(app.ReportDaily):
|
|
||||||
return app.ReportDaily, true
|
|
||||||
case string(app.ReportTomorrow):
|
|
||||||
return app.ReportTomorrow, true
|
|
||||||
case string(app.ReportThreeDay):
|
|
||||||
return app.ReportThreeDay, true
|
|
||||||
case string(app.ReportWeekend):
|
|
||||||
return app.ReportWeekend, true
|
|
||||||
case string(app.ReportStorm):
|
|
||||||
return app.ReportStorm, true
|
|
||||||
default:
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func batchKind(value string) (app.BatchKind, bool) {
|
|
||||||
switch value {
|
|
||||||
case string(app.BatchMorning):
|
|
||||||
return app.BatchMorning, true
|
|
||||||
case string(app.BatchEvening):
|
|
||||||
return app.BatchEvening, true
|
|
||||||
default:
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -85,12 +86,31 @@ func TestLoadExampleConfig(t *testing.T) {
|
|||||||
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
|
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
|
||||||
t.Fatalf("Location = %#v, want example location", cfg.Location)
|
t.Fatalf("Location = %#v, want example location", cfg.Location)
|
||||||
}
|
}
|
||||||
if cfg.Notify.Distributor.PipelineIDTemplate != "weatherreporter.{artifact_group}" {
|
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 len(cfg.Notify.Distributor.ReportPathTemplates) != 1 {
|
||||||
t.Fatalf("ReportPathTemplates = %#v, want example archive path", cfg.Notify.Distributor.ReportPathTemplates)
|
t.Fatalf("ReportPathTemplates = %#v, want example archive path", cfg.Notify.Distributor.ReportPathTemplates)
|
||||||
}
|
}
|
||||||
|
overrides, err := cfg.ReportModuleOverrides()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReportModuleOverrides() error = %v", err)
|
||||||
|
}
|
||||||
|
hourly := overrides[report.Hourly]
|
||||||
|
if len(hourly) != 9 {
|
||||||
|
t.Fatalf("hourly example override length = %d, want 9", len(hourly))
|
||||||
|
}
|
||||||
|
if hourly[0].ID != module.Metadata ||
|
||||||
|
hourly[1].ID != module.CurrentConditions ||
|
||||||
|
hourly[2].ID != module.HourlyForecast ||
|
||||||
|
hourly[3].ID != module.PrecipTiming ||
|
||||||
|
hourly[4].ID != module.AlertDigest ||
|
||||||
|
hourly[5].ID != module.SPCConvectiveOutlooks ||
|
||||||
|
hourly[6].ID != module.AreaForecastDiscussion ||
|
||||||
|
hourly[7].ID != module.SPCConvectiveDiscussion ||
|
||||||
|
hourly[8].ID != module.WeatherStory {
|
||||||
|
t.Fatalf("hourly example override = %#v, want configured module order", hourly)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadMinimalExampleConfig(t *testing.T) {
|
func TestLoadMinimalExampleConfig(t *testing.T) {
|
||||||
@@ -126,34 +146,243 @@ reports:
|
|||||||
deterministic_modules:
|
deterministic_modules:
|
||||||
- metadata
|
- metadata
|
||||||
- current_conditions
|
- current_conditions
|
||||||
|
- alert_digest
|
||||||
|
- spc_convective_outlooks
|
||||||
- id: area_forecast_discussion
|
- id: area_forecast_discussion
|
||||||
options:
|
options:
|
||||||
sections:
|
sections:
|
||||||
- short_term
|
- short_term
|
||||||
|
- spc_convective_discussion
|
||||||
`)
|
`)
|
||||||
|
|
||||||
cfg, err := LoadFile(path)
|
cfg, err := LoadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadFile() error = %v", err)
|
t.Fatalf("LoadFile() error = %v", err)
|
||||||
}
|
}
|
||||||
overrides := cfg.ReportModuleOverrides()
|
overrides, err := cfg.ReportModuleOverrides()
|
||||||
items := overrides[report.DailyToday]
|
if err != nil {
|
||||||
if len(items) != 3 {
|
t.Fatalf("ReportModuleOverrides() error = %v", err)
|
||||||
t.Fatalf("daily override length = %d, want 3", len(items))
|
|
||||||
}
|
}
|
||||||
if items[0].ID != module.Metadata || items[1].ID != module.CurrentConditions || items[2].ID != module.AreaForecastDiscussion {
|
items := overrides[report.Daily]
|
||||||
|
if len(items) != 6 {
|
||||||
|
t.Fatalf("daily override length = %d, want 6", len(items))
|
||||||
|
}
|
||||||
|
if items[0].ID != module.Metadata ||
|
||||||
|
items[1].ID != module.CurrentConditions ||
|
||||||
|
items[2].ID != module.AlertDigest ||
|
||||||
|
items[3].ID != module.SPCConvectiveOutlooks ||
|
||||||
|
items[4].ID != module.AreaForecastDiscussion ||
|
||||||
|
items[5].ID != module.SPCConvectiveDiscussion {
|
||||||
t.Fatalf("daily override = %#v, want configured module order", items)
|
t.Fatalf("daily override = %#v, want configured module order", items)
|
||||||
}
|
}
|
||||||
options, ok := items[2].Options.(module.AreaForecastDiscussionOptions)
|
options, ok := items[4].Options.(module.AreaForecastDiscussionOptions)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", items[2].Options)
|
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", items[4].Options)
|
||||||
}
|
}
|
||||||
if strings.Join(options.Sections, ",") != "short_term" {
|
if strings.Join(options.Sections, ",") != "short_term" {
|
||||||
t.Fatalf("AFD sections = %#v, want short_term", options.Sections)
|
t.Fatalf("AFD sections = %#v, want short_term", options.Sections)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadTodayReportModuleOverrides(t *testing.T) {
|
||||||
|
path := writeConfig(t, `
|
||||||
|
reports:
|
||||||
|
today:
|
||||||
|
deterministic_modules:
|
||||||
|
- metadata
|
||||||
|
- current_conditions
|
||||||
|
- derived_daily_summary
|
||||||
|
- derived_daypart_summaries
|
||||||
|
- today_planning
|
||||||
|
`)
|
||||||
|
|
||||||
|
cfg, err := LoadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadFile() error = %v", err)
|
||||||
|
}
|
||||||
|
overrides, err := cfg.ReportModuleOverrides()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReportModuleOverrides() error = %v", err)
|
||||||
|
}
|
||||||
|
items := overrides[report.Today]
|
||||||
|
if len(items) != 5 {
|
||||||
|
t.Fatalf("today override length = %d, want 5", len(items))
|
||||||
|
}
|
||||||
|
want := []module.ID{
|
||||||
|
module.Metadata,
|
||||||
|
module.CurrentConditions,
|
||||||
|
module.DerivedDailySummary,
|
||||||
|
module.DerivedDaypartSummaries,
|
||||||
|
module.TodayPlanning,
|
||||||
|
}
|
||||||
|
for i, id := range want {
|
||||||
|
if items[i].ID != id {
|
||||||
|
t.Fatalf("today override[%d] = %s, want %s", i, items[i].ID, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := overrides[report.Daily]; ok {
|
||||||
|
t.Fatalf("daily override = %#v, want today override to stay distinct", overrides[report.Daily])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadHourlyReportModuleOverrides(t *testing.T) {
|
||||||
|
path := writeConfig(t, `
|
||||||
|
reports:
|
||||||
|
hourly:
|
||||||
|
deterministic_modules:
|
||||||
|
- metadata
|
||||||
|
- hourly_forecast
|
||||||
|
- precip_timing
|
||||||
|
- alert_digest
|
||||||
|
`)
|
||||||
|
|
||||||
|
cfg, err := LoadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadFile() error = %v", err)
|
||||||
|
}
|
||||||
|
overrides, err := cfg.ReportModuleOverrides()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReportModuleOverrides() error = %v", err)
|
||||||
|
}
|
||||||
|
items := overrides[report.Hourly]
|
||||||
|
if len(items) != 4 {
|
||||||
|
t.Fatalf("hourly override length = %d, want 4", len(items))
|
||||||
|
}
|
||||||
|
if items[0].ID != module.Metadata ||
|
||||||
|
items[1].ID != module.HourlyForecast ||
|
||||||
|
items[2].ID != module.PrecipTiming ||
|
||||||
|
items[3].ID != module.AlertDigest {
|
||||||
|
t.Fatalf("hourly override = %#v, want configured module order", items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadReportModuleOverrideAliases(t *testing.T) {
|
||||||
|
path := writeConfig(t, `
|
||||||
|
reports:
|
||||||
|
three-day-outlook:
|
||||||
|
deterministic_modules:
|
||||||
|
- metadata
|
||||||
|
weekend_outlook:
|
||||||
|
deterministic_modules:
|
||||||
|
- metadata
|
||||||
|
storm_report:
|
||||||
|
deterministic_modules:
|
||||||
|
- metadata
|
||||||
|
`)
|
||||||
|
|
||||||
|
cfg, err := LoadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadFile() error = %v", err)
|
||||||
|
}
|
||||||
|
overrides, err := cfg.ReportModuleOverrides()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReportModuleOverrides() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(overrides[report.ThreeDay]) != 1 || overrides[report.ThreeDay][0].ID != module.Metadata {
|
||||||
|
t.Fatalf("three-day alias override = %#v, want metadata override", overrides[report.ThreeDay])
|
||||||
|
}
|
||||||
|
if len(overrides[report.Weekend]) != 1 || overrides[report.Weekend][0].ID != module.Metadata {
|
||||||
|
t.Fatalf("weekend alias override = %#v, want metadata override", overrides[report.Weekend])
|
||||||
|
}
|
||||||
|
if len(overrides[report.Storm]) != 1 || overrides[report.Storm][0].ID != module.Metadata {
|
||||||
|
t.Fatalf("storm alias override = %#v, want metadata override", overrides[report.Storm])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateReportModuleKeysWithoutMutatingOptions(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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Validate(cfg); err != nil {
|
||||||
|
t.Fatalf("Validate() error = %v", err)
|
||||||
|
}
|
||||||
|
got, ok := cfg.Reports["daily"].DeterministicModules[1].Options.(map[string]any)
|
||||||
|
if !ok || !reflect.DeepEqual(got, rawOptions) {
|
||||||
|
t.Fatalf("Options after Validate = %#v, want original raw map", cfg.Reports["daily"].DeterministicModules[1].Options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateReportModuleAliasesDirectly(t *testing.T) {
|
||||||
|
retiredDailyKey := retiredDailyReportKeyForTest()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
reports map[string]ReportConfig
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "RetiredDailyReportID",
|
||||||
|
reports: map[string]ReportConfig{
|
||||||
|
"daily": {},
|
||||||
|
retiredDailyKey: {},
|
||||||
|
},
|
||||||
|
wantErr: "reports." + retiredDailyKey,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TodayAndDailyAreDistinct",
|
||||||
|
reports: map[string]ReportConfig{
|
||||||
|
"daily": {},
|
||||||
|
"today": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UnknownReport",
|
||||||
|
reports: map[string]ReportConfig{
|
||||||
|
"moon": {},
|
||||||
|
},
|
||||||
|
wantErr: "reports.moon",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cfg := Defaults()
|
||||||
|
cfg.Reports = tt.reports
|
||||||
|
err := Validate(cfg)
|
||||||
|
if tt.wantErr == "" {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Validate() error = %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Validate() error = nil, want report key error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
|
t.Fatalf("Validate() error = %q, want %q", err.Error(), tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReportModuleOverridesRejectsInvalidReportKeys(t *testing.T) {
|
||||||
|
cfg := Defaults()
|
||||||
|
cfg.Reports = map[string]ReportConfig{
|
||||||
|
"moon": {
|
||||||
|
DeterministicModules: []ModuleConfigItem{{ID: module.Metadata}},
|
||||||
|
deterministicModulesSet: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := cfg.ReportModuleOverrides()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ReportModuleOverrides() error = nil, want invalid report key")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "reports.moon") {
|
||||||
|
t.Fatalf("ReportModuleOverrides() error = %q, want report key context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReportModuleOverrideValidation(t *testing.T) {
|
func TestReportModuleOverrideValidation(t *testing.T) {
|
||||||
|
retiredDailyKey := retiredDailyReportKeyForTest()
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
yaml string
|
yaml string
|
||||||
@@ -198,7 +427,7 @@ reports:
|
|||||||
deterministic_modules:
|
deterministic_modules:
|
||||||
- tomorrow_planning
|
- tomorrow_planning
|
||||||
`,
|
`,
|
||||||
wantErr: `not compatible with report "daily_today"`,
|
wantErr: `not compatible with report "daily"`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "RemovedPlaceholderModule",
|
name: "RemovedPlaceholderModule",
|
||||||
@@ -210,6 +439,16 @@ reports:
|
|||||||
`,
|
`,
|
||||||
wantErr: `unknown module "forecast_delta"`,
|
wantErr: `unknown module "forecast_delta"`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "RetiredTomorrowReportID",
|
||||||
|
yaml: `
|
||||||
|
reports:
|
||||||
|
daily_tomorrow:
|
||||||
|
deterministic_modules:
|
||||||
|
- metadata
|
||||||
|
`,
|
||||||
|
wantErr: "reports.daily_tomorrow",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "InvalidOptions",
|
name: "InvalidOptions",
|
||||||
yaml: `
|
yaml: `
|
||||||
@@ -224,17 +463,27 @@ reports:
|
|||||||
wantErr: "options are invalid",
|
wantErr: "options are invalid",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "DuplicateReportAlias",
|
name: "RetiredDailyReportID",
|
||||||
yaml: `
|
yaml: `
|
||||||
reports:
|
reports:
|
||||||
daily:
|
daily:
|
||||||
deterministic_modules:
|
deterministic_modules:
|
||||||
- metadata
|
- metadata
|
||||||
daily_today:
|
` + retiredDailyKey + `:
|
||||||
deterministic_modules:
|
deterministic_modules:
|
||||||
- current_conditions
|
- current_conditions
|
||||||
`,
|
`,
|
||||||
wantErr: "duplicates report override",
|
wantErr: "reports." + retiredDailyKey,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HourlyIncompatibleDailyModule",
|
||||||
|
yaml: `
|
||||||
|
reports:
|
||||||
|
hourly:
|
||||||
|
deterministic_modules:
|
||||||
|
- derived_daily_summary
|
||||||
|
`,
|
||||||
|
wantErr: `not compatible with report "hourly"`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "UnknownReportField",
|
name: "UnknownReportField",
|
||||||
@@ -261,6 +510,32 @@ reports:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func retiredDailyReportKeyForTest() string {
|
||||||
|
return strings.Join([]string{"daily", "today"}, "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReportModuleOverrideRejectsRetiredHourlyKeys(t *testing.T) {
|
||||||
|
for _, key := range []string{
|
||||||
|
strings.Join([]string{"near", "term"}, "_"),
|
||||||
|
strings.Join([]string{"near", "term"}, "-"),
|
||||||
|
} {
|
||||||
|
t.Run(key, func(t *testing.T) {
|
||||||
|
_, err := LoadFile(writeConfig(t, `
|
||||||
|
reports:
|
||||||
|
`+key+`:
|
||||||
|
deterministic_modules:
|
||||||
|
- metadata
|
||||||
|
`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("LoadFile() error = nil, want unknown report")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "is not a known report") {
|
||||||
|
t.Fatalf("error = %q, want unknown report", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExplicitMissingConfigReturnsError(t *testing.T) {
|
func TestExplicitMissingConfigReturnsError(t *testing.T) {
|
||||||
_, err := LoadFile(filepath.Join(t.TempDir(), "missing.yml"))
|
_, err := LoadFile(filepath.Join(t.TempDir(), "missing.yml"))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -599,7 +874,7 @@ func TestLoadFileLoadsSecretsBeforeReturningNotifyConfig(t *testing.T) {
|
|||||||
"notify:\n" +
|
"notify:\n" +
|
||||||
" distributor:\n" +
|
" distributor:\n" +
|
||||||
" enabled: true\n" +
|
" enabled: true\n" +
|
||||||
" pipeline_id_template: weatherreporter.{artifact_group}\n"
|
" pipeline_id_template: weatherreporter.{report_id}\n"
|
||||||
if err := os.WriteFile(path, []byte(configYAML), 0o600); err != nil {
|
if err := os.WriteFile(path, []byte(configYAML), 0o600); err != nil {
|
||||||
t.Fatalf("write config fixture: %v", err)
|
t.Fatalf("write config fixture: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
@@ -12,23 +11,24 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (cfg Config) ReportModuleOverrides() map[report.ID][]module.ConfigItem {
|
func (cfg Config) ReportModuleOverrides() (map[report.ID][]module.ConfigItem, error) {
|
||||||
overrides := map[report.ID][]module.ConfigItem{}
|
overrides := map[report.ID][]module.ConfigItem{}
|
||||||
|
seenReports := map[report.ID]string{}
|
||||||
for key, reportCfg := range cfg.Reports {
|
for key, reportCfg := range cfg.Reports {
|
||||||
|
id, err := report.IDForConfigKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reports.%s: %w", key, err)
|
||||||
|
}
|
||||||
|
if previous, ok := seenReports[id]; ok {
|
||||||
|
return nil, fmt.Errorf("reports.%s duplicates report override %q", key, previous)
|
||||||
|
}
|
||||||
|
seenReports[id] = key
|
||||||
if !reportCfg.deterministicModulesSet {
|
if !reportCfg.deterministicModulesSet {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
id, err := reportIDForConfigKey(key)
|
overrides[id] = moduleItemsFromConfig(reportCfg.DeterministicModules)
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
items := make([]module.ConfigItem, 0, len(reportCfg.DeterministicModules))
|
return overrides, nil
|
||||||
for _, item := range reportCfg.DeterministicModules {
|
|
||||||
items = append(items, module.ConfigItem{ID: item.ID, Options: item.Options})
|
|
||||||
}
|
|
||||||
overrides[id] = items
|
|
||||||
}
|
|
||||||
return overrides
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeReportModules(cfg *Config) error {
|
func normalizeReportModules(cfg *Config) error {
|
||||||
@@ -42,9 +42,50 @@ func normalizeReportModules(cfg *Config) error {
|
|||||||
reportRegistry := report.DefaultRegistry()
|
reportRegistry := report.DefaultRegistry()
|
||||||
seenReports := map[report.ID]string{}
|
seenReports := map[report.ID]string{}
|
||||||
for key, reportCfg := range cfg.Reports {
|
for key, reportCfg := range cfg.Reports {
|
||||||
reportID, err := reportIDForConfigKey(key)
|
reportID, err := report.IDForConfigKey(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return 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 {
|
||||||
|
if cfg.Reports == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
moduleRegistry, err := briefing.DefaultModuleRegistry()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("initialize module registry: %w", err)
|
||||||
|
}
|
||||||
|
reportRegistry := report.DefaultRegistry()
|
||||||
|
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 {
|
if previous, ok := seenReports[reportID]; ok {
|
||||||
return fmt.Errorf("reports.%s duplicates report override %q", key, previous)
|
return fmt.Errorf("reports.%s duplicates report override %q", key, previous)
|
||||||
@@ -58,21 +99,31 @@ func normalizeReportModules(cfg *Config) error {
|
|||||||
}
|
}
|
||||||
items := make([]module.ConfigItem, 0, len(reportCfg.DeterministicModules))
|
items := make([]module.ConfigItem, 0, len(reportCfg.DeterministicModules))
|
||||||
for i, rawItem := range reportCfg.DeterministicModules {
|
for i, rawItem := range reportCfg.DeterministicModules {
|
||||||
options, err := normalizeModuleOptions(moduleRegistry, rawItem.ID, rawItem.Options)
|
options := rawItem.Options
|
||||||
|
if options != nil {
|
||||||
|
normalized, err := normalizeModuleOptions(moduleRegistry, rawItem.ID, options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("reports.%s.deterministic_modules[%d]: %w", key, i, err)
|
return fmt.Errorf("reports.%s.deterministic_modules[%d]: %w", key, i, err)
|
||||||
}
|
}
|
||||||
reportCfg.DeterministicModules[i].Options = options
|
options = normalized
|
||||||
|
}
|
||||||
items = append(items, module.ConfigItem{ID: rawItem.ID, Options: options})
|
items = append(items, module.ConfigItem{ID: rawItem.ID, Options: options})
|
||||||
}
|
}
|
||||||
if err := moduleRegistry.ValidateComposition(reportID, items); err != nil {
|
if err := moduleRegistry.ValidateComposition(reportID, items); err != nil {
|
||||||
return fmt.Errorf("reports.%s.deterministic_modules: %w", key, err)
|
return fmt.Errorf("reports.%s.deterministic_modules: %w", key, err)
|
||||||
}
|
}
|
||||||
cfg.Reports[key] = reportCfg
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func moduleItemsFromConfig(items []ModuleConfigItem) []module.ConfigItem {
|
||||||
|
out := make([]module.ConfigItem, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
out = append(out, module.ConfigItem{ID: item.ID, Options: item.Options})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeModuleOptions(registry briefing.ModuleRegistry, id module.ID, raw any) (any, error) {
|
func normalizeModuleOptions(registry briefing.ModuleRegistry, id module.ID, raw any) (any, error) {
|
||||||
if raw == nil {
|
if raw == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -84,6 +135,9 @@ func normalizeModuleOptions(registry briefing.ModuleRegistry, id module.ID, raw
|
|||||||
if definition.DefaultOptions == nil {
|
if definition.DefaultOptions == nil {
|
||||||
return nil, fmt.Errorf("module %q does not accept options", id)
|
return nil, fmt.Errorf("module %q does not accept options", id)
|
||||||
}
|
}
|
||||||
|
if err := definition.ValidateOptions(raw); err == nil {
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
optionType := reflect.TypeOf(definition.DefaultOptions)
|
optionType := reflect.TypeOf(definition.DefaultOptions)
|
||||||
normalized, err := decodeKnownOptions(raw, optionType)
|
normalized, err := decodeKnownOptions(raw, optionType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -105,21 +159,3 @@ func decodeKnownOptions(raw any, optionType reflect.Type) (any, error) {
|
|||||||
}
|
}
|
||||||
return target.Elem().Interface(), nil
|
return target.Elem().Interface(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func reportIDForConfigKey(key string) (report.ID, error) {
|
|
||||||
normalized := strings.ReplaceAll(strings.TrimSpace(strings.ToLower(key)), "-", "_")
|
|
||||||
switch normalized {
|
|
||||||
case "daily", "daily_today":
|
|
||||||
return report.DailyToday, nil
|
|
||||||
case "tomorrow", "daily_tomorrow":
|
|
||||||
return report.DailyTomorrow, nil
|
|
||||||
case "three_day", "three_day_outlook":
|
|
||||||
return report.ThreeDay, nil
|
|
||||||
case "weekend", "weekend_outlook":
|
|
||||||
return report.Weekend, nil
|
|
||||||
case "storm", "storm_report":
|
|
||||||
return report.Storm, nil
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("reports.%s is not a known report", key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func Validate(cfg Config) error {
|
func Validate(cfg Config) error {
|
||||||
if err := normalizeReportModules(&cfg); err != nil {
|
if err := validateReportModules(cfg); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if cfg.WeatherAPI.BaseURL != "" {
|
if cfg.WeatherAPI.BaseURL != "" {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package facts
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
@@ -21,6 +22,7 @@ type CollectedFacts struct {
|
|||||||
Discussion *weatherdata.Discussion
|
Discussion *weatherdata.Discussion
|
||||||
Daily *weatherdata.ForecastRun
|
Daily *weatherdata.ForecastRun
|
||||||
WeatherStory *weatherdata.WeatherStory
|
WeatherStory *weatherdata.WeatherStory
|
||||||
|
SPCConvectiveOutlooks *weatherdata.ConvectiveOutlookRun
|
||||||
|
|
||||||
SourceProvenance []weatherdata.Source
|
SourceProvenance []weatherdata.Source
|
||||||
SourceWarnings []weatherdata.SourceWarning
|
SourceWarnings []weatherdata.SourceWarning
|
||||||
@@ -40,6 +42,7 @@ func BuildCollected(bundle *weatherdata.Bundle) CollectedFacts {
|
|||||||
Discussion: bundle.Discussion,
|
Discussion: bundle.Discussion,
|
||||||
Daily: bundle.Daily,
|
Daily: bundle.Daily,
|
||||||
WeatherStory: bundle.WeatherStory,
|
WeatherStory: bundle.WeatherStory,
|
||||||
|
SPCConvectiveOutlooks: bundle.SPCConvectiveOutlooks,
|
||||||
SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...),
|
SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...),
|
||||||
SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...),
|
SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...),
|
||||||
}
|
}
|
||||||
@@ -56,6 +59,7 @@ func (f CollectedFacts) Bundle() *weatherdata.Bundle {
|
|||||||
Discussion: f.Discussion,
|
Discussion: f.Discussion,
|
||||||
Daily: f.Daily,
|
Daily: f.Daily,
|
||||||
WeatherStory: f.WeatherStory,
|
WeatherStory: f.WeatherStory,
|
||||||
|
SPCConvectiveOutlooks: f.SPCConvectiveOutlooks,
|
||||||
Sources: append([]weatherdata.Source(nil), f.SourceProvenance...),
|
Sources: append([]weatherdata.Source(nil), f.SourceProvenance...),
|
||||||
Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...),
|
Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...),
|
||||||
}
|
}
|
||||||
@@ -73,6 +77,8 @@ type DerivedFacts struct {
|
|||||||
ValidPeriodNarrativePeriods []weatherdata.ForecastPeriod
|
ValidPeriodNarrativePeriods []weatherdata.ForecastPeriod
|
||||||
ValidPeriodDailyPeriods []weatherdata.ForecastPeriod
|
ValidPeriodDailyPeriods []weatherdata.ForecastPeriod
|
||||||
AlertOverlaps []forecast.AlertOverlap
|
AlertOverlaps []forecast.AlertOverlap
|
||||||
|
SPCConvectiveOutlooks []weatherdata.ConvectiveOutlook
|
||||||
|
SPCConvectiveDiscussions []weatherdata.ConvectiveOutlookDiscussion
|
||||||
DailySummaries []forecast.DailySummary
|
DailySummaries []forecast.DailySummary
|
||||||
DaypartSummaries []forecast.DaypartSummary
|
DaypartSummaries []forecast.DaypartSummary
|
||||||
PrecipTiming forecast.PrecipTiming
|
PrecipTiming forecast.PrecipTiming
|
||||||
@@ -96,16 +102,20 @@ func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) {
|
|||||||
}
|
}
|
||||||
bundle := req.Collected.Bundle()
|
bundle := req.Collected.Bundle()
|
||||||
period := req.Resolved.ValidPeriod
|
period := req.Resolved.ValidPeriod
|
||||||
|
spcOutlooks, spcDiscussions := selectSPCConvectiveOutlooks(req.Collected.SPCConvectiveOutlooks, period)
|
||||||
derived := DerivedFacts{
|
derived := DerivedFacts{
|
||||||
ValidPeriodHourlyPeriods: forecast.SelectHourlyPeriods(req.Collected.Hourly, period),
|
ValidPeriodHourlyPeriods: forecast.SelectHourlyPeriods(req.Collected.Hourly, period),
|
||||||
ValidPeriodNarrativePeriods: forecast.SelectHourlyPeriods(req.Collected.Narrative, period),
|
ValidPeriodNarrativePeriods: forecast.SelectHourlyPeriods(req.Collected.Narrative, period),
|
||||||
ValidPeriodDailyPeriods: forecast.SelectHourlyPeriods(req.Collected.Daily, period),
|
ValidPeriodDailyPeriods: forecast.SelectHourlyPeriods(req.Collected.Daily, period),
|
||||||
AlertOverlaps: forecast.AlertOverlaps(req.Collected.Alerts, period),
|
AlertOverlaps: forecast.AlertOverlaps(req.Collected.Alerts, period),
|
||||||
|
SPCConvectiveOutlooks: spcOutlooks,
|
||||||
|
SPCConvectiveDiscussions: spcDiscussions,
|
||||||
}
|
}
|
||||||
derived.PrecipTiming = forecast.BuildPrecipTiming(derived.ValidPeriodHourlyPeriods)
|
derived.PrecipTiming = forecast.BuildPrecipTiming(derived.ValidPeriodHourlyPeriods)
|
||||||
|
|
||||||
switch req.Resolved.Definition.ID {
|
switch req.Resolved.Definition.ID {
|
||||||
case report.DailyToday, report.DailyTomorrow:
|
case report.Hourly:
|
||||||
|
case report.Daily, report.Today, report.Tomorrow:
|
||||||
summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts)
|
summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return DerivedFacts{}, err
|
return DerivedFacts{}, err
|
||||||
@@ -139,3 +149,81 @@ func collectDaypartSummaries(derived DerivedFacts) []forecast.DaypartSummary {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func selectSPCConvectiveOutlooks(run *weatherdata.ConvectiveOutlookRun, period timeutil.Period) ([]weatherdata.ConvectiveOutlook, []weatherdata.ConvectiveOutlookDiscussion) {
|
||||||
|
if run == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
outlooks := make([]weatherdata.ConvectiveOutlook, 0, len(run.Outlooks))
|
||||||
|
days := map[int]struct{}{}
|
||||||
|
for _, outlook := range run.Outlooks {
|
||||||
|
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
|
||||||
|
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(period) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
outlooks = append(outlooks, outlook)
|
||||||
|
days[outlook.Day] = struct{}{}
|
||||||
|
}
|
||||||
|
sort.SliceStable(outlooks, func(i, j int) bool {
|
||||||
|
left := outlooks[i]
|
||||||
|
right := outlooks[j]
|
||||||
|
if left.Day != right.Day {
|
||||||
|
return left.Day < right.Day
|
||||||
|
}
|
||||||
|
if left.OutlookType != right.OutlookType {
|
||||||
|
return left.OutlookType < right.OutlookType
|
||||||
|
}
|
||||||
|
leftRank, leftRankOK := severityRank(left)
|
||||||
|
rightRank, rightRankOK := severityRank(right)
|
||||||
|
if leftRankOK != rightRankOK {
|
||||||
|
return leftRankOK
|
||||||
|
}
|
||||||
|
if leftRankOK && leftRank != rightRank {
|
||||||
|
return leftRank > rightRank
|
||||||
|
}
|
||||||
|
if !left.ValidFrom.Equal(right.ValidFrom) {
|
||||||
|
return left.ValidFrom.Before(right.ValidFrom)
|
||||||
|
}
|
||||||
|
if left.Label != right.Label {
|
||||||
|
return left.Label < right.Label
|
||||||
|
}
|
||||||
|
return left.ID < right.ID
|
||||||
|
})
|
||||||
|
|
||||||
|
discussions := make([]weatherdata.ConvectiveOutlookDiscussion, 0, len(run.Discussions))
|
||||||
|
for _, discussion := range run.Discussions {
|
||||||
|
if _, ok := days[discussion.Day]; ok {
|
||||||
|
discussions = append(discussions, discussion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(discussions, func(i, j int) bool {
|
||||||
|
left := discussions[i]
|
||||||
|
right := discussions[j]
|
||||||
|
if left.Day != right.Day {
|
||||||
|
return left.Day < right.Day
|
||||||
|
}
|
||||||
|
if left.UpdatedAt != nil && right.UpdatedAt != nil && !left.UpdatedAt.Equal(*right.UpdatedAt) {
|
||||||
|
return left.UpdatedAt.Before(*right.UpdatedAt)
|
||||||
|
}
|
||||||
|
if (left.UpdatedAt != nil) != (right.UpdatedAt != nil) {
|
||||||
|
return left.UpdatedAt != nil
|
||||||
|
}
|
||||||
|
if left.Headline != right.Headline {
|
||||||
|
return left.Headline < right.Headline
|
||||||
|
}
|
||||||
|
if left.Summary != right.Summary {
|
||||||
|
return left.Summary < right.Summary
|
||||||
|
}
|
||||||
|
return left.Discussion < right.Discussion
|
||||||
|
})
|
||||||
|
|
||||||
|
return outlooks, discussions
|
||||||
|
}
|
||||||
|
|
||||||
|
func severityRank(outlook weatherdata.ConvectiveOutlook) (int, bool) {
|
||||||
|
if outlook.SeverityRank == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return *outlook.SeverityRank, true
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package facts
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,6 +17,13 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
|
|||||||
FetchedAt: fetchedAt,
|
FetchedAt: fetchedAt,
|
||||||
Current: &weatherdata.Current{ConditionText: "Clear"},
|
Current: &weatherdata.Current{ConditionText: "Clear"},
|
||||||
Hourly: &weatherdata.ForecastRun{Product: "hourly"},
|
Hourly: &weatherdata.ForecastRun{Product: "hourly"},
|
||||||
|
SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{
|
||||||
|
Product: "convective_outlook",
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{{
|
||||||
|
ID: "day1-categorical-slight",
|
||||||
|
Label: "SLGT",
|
||||||
|
}},
|
||||||
|
},
|
||||||
Sources: []weatherdata.Source{{Name: "hourly"}},
|
Sources: []weatherdata.Source{{Name: "hourly"}},
|
||||||
Warnings: []weatherdata.SourceWarning{{Source: "discussion", Code: "missing_source"}},
|
Warnings: []weatherdata.SourceWarning{{Source: "discussion", Code: "missing_source"}},
|
||||||
}
|
}
|
||||||
@@ -24,6 +32,9 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
|
|||||||
if collected.FetchedAt != fetchedAt || collected.Current.ConditionText != "Clear" || collected.Hourly.Product != "hourly" {
|
if collected.FetchedAt != fetchedAt || collected.Current.ConditionText != "Clear" || collected.Hourly.Product != "hourly" {
|
||||||
t.Fatalf("CollectedFacts = %#v, want source facts copied from bundle", collected)
|
t.Fatalf("CollectedFacts = %#v, want source facts copied from bundle", collected)
|
||||||
}
|
}
|
||||||
|
if collected.SPCConvectiveOutlooks == nil || collected.SPCConvectiveOutlooks.Outlooks[0].Label != "SLGT" {
|
||||||
|
t.Fatalf("SPCConvectiveOutlooks = %#v, want source copied from bundle", collected.SPCConvectiveOutlooks)
|
||||||
|
}
|
||||||
if len(collected.SourceProvenance) != 1 || collected.SourceProvenance[0].Name != "hourly" {
|
if len(collected.SourceProvenance) != 1 || collected.SourceProvenance[0].Name != "hourly" {
|
||||||
t.Fatalf("SourceProvenance = %#v, want hourly source", collected.SourceProvenance)
|
t.Fatalf("SourceProvenance = %#v, want hourly source", collected.SourceProvenance)
|
||||||
}
|
}
|
||||||
@@ -36,11 +47,16 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
|
|||||||
if collected.SourceProvenance[0].Name != "hourly" || collected.SourceWarnings[0].Source != "discussion" {
|
if collected.SourceProvenance[0].Name != "hourly" || collected.SourceWarnings[0].Source != "discussion" {
|
||||||
t.Fatalf("collected source slices changed after bundle mutation: %#v %#v", collected.SourceProvenance, collected.SourceWarnings)
|
t.Fatalf("collected source slices changed after bundle mutation: %#v %#v", collected.SourceProvenance, collected.SourceWarnings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
roundTrip := collected.Bundle()
|
||||||
|
if roundTrip.SPCConvectiveOutlooks == nil || roundTrip.SPCConvectiveOutlooks.Outlooks[0].ID != "day1-categorical-slight" {
|
||||||
|
t.Fatalf("Bundle().SPCConvectiveOutlooks = %#v, want collected source restored", roundTrip.SPCConvectiveOutlooks)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) {
|
func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) {
|
||||||
location := testLocation()
|
location := testLocation()
|
||||||
resolved := resolveForTest(t, report.DailyToday, mustParse("2026-05-29T08:00:00-05:00"), location)
|
resolved := resolveForTest(t, report.Daily, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||||
derived, err := BuildDerived(BuildDerivedRequest{
|
derived, err := BuildDerived(BuildDerivedRequest{
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
Timezone: location.String(),
|
Timezone: location.String(),
|
||||||
@@ -119,7 +135,7 @@ func TestBuildDerivedOutlookBuildsPartialDaySummariesWithMissingOptionalSources(
|
|||||||
|
|
||||||
func TestBuildDerivedWeekendAndTomorrow(t *testing.T) {
|
func TestBuildDerivedWeekendAndTomorrow(t *testing.T) {
|
||||||
location := testLocation()
|
location := testLocation()
|
||||||
for _, id := range []report.ID{report.DailyTomorrow, report.Weekend} {
|
for _, id := range []report.ID{report.Tomorrow, report.Weekend} {
|
||||||
resolved := resolveForTest(t, id, mustParse("2026-05-29T08:00:00-05:00"), location)
|
resolved := resolveForTest(t, id, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||||
derived, err := BuildDerived(BuildDerivedRequest{
|
derived, err := BuildDerived(BuildDerivedRequest{
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
@@ -136,6 +152,63 @@ func TestBuildDerivedWeekendAndTomorrow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildDerivedHourlyUsesRollingWindowFacts(t *testing.T) {
|
||||||
|
location := testLocation()
|
||||||
|
resolved := resolveForTest(t, report.Hourly, mustParse("2026-05-29T08:30:00-05:00"), location)
|
||||||
|
bundle := testBundle(location)
|
||||||
|
bundle.Alerts = &weatherdata.AlertRun{Alerts: []json.RawMessage{
|
||||||
|
json.RawMessage(`{"event":"Expired Advisory","headline":"Ends at start","severity":"Minor","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T08:30:00-05:00"}`),
|
||||||
|
json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T11:00:00-05:00","expires":"2026-05-29T15:00:00-05:00"}`),
|
||||||
|
json.RawMessage(`{"event":"Evening Advisory","headline":"Starts at end","severity":"Minor","effective":"2026-05-29T14:30:00-05:00","expires":"2026-05-29T18:00:00-05:00"}`),
|
||||||
|
}}
|
||||||
|
bundle.SPCConvectiveOutlooks = testConvectiveOutlookRun(location)
|
||||||
|
|
||||||
|
derived, err := BuildDerived(BuildDerivedRequest{
|
||||||
|
Resolved: resolved,
|
||||||
|
Timezone: location.String(),
|
||||||
|
Dayparts: testDayparts(),
|
||||||
|
Collected: BuildCollected(bundle),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildDerived() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(derived.ValidPeriodHourlyPeriods) != 4 {
|
||||||
|
t.Fatalf("ValidPeriodHourlyPeriods length = %d, want 4 rolling-window hours", len(derived.ValidPeriodHourlyPeriods))
|
||||||
|
}
|
||||||
|
if derived.ValidPeriodHourlyPeriods[0].StartTime.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" ||
|
||||||
|
derived.ValidPeriodHourlyPeriods[3].StartTime.Format(time.RFC3339) != "2026-05-29T14:00:00-05:00" {
|
||||||
|
t.Fatalf("ValidPeriodHourlyPeriods = %#v, want only hours overlapping 8:30 AM-2:30 PM", derived.ValidPeriodHourlyPeriods)
|
||||||
|
}
|
||||||
|
if len(derived.ValidPeriodNarrativePeriods) != 1 {
|
||||||
|
t.Fatalf("ValidPeriodNarrativePeriods length = %d, want overlapping narrative period", len(derived.ValidPeriodNarrativePeriods))
|
||||||
|
}
|
||||||
|
if len(derived.DailySummaries) != 0 || len(derived.DaypartSummaries) != 0 || derived.StormWindowSummary != nil {
|
||||||
|
t.Fatalf("hourly summaries daily=%#v daypart=%#v storm=%#v, want none", derived.DailySummaries, derived.DaypartSummaries, derived.StormWindowSummary)
|
||||||
|
}
|
||||||
|
if derived.PrecipTiming.FirstPrecipitation == nil || derived.PrecipTiming.FirstPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" {
|
||||||
|
t.Fatalf("PrecipTiming.FirstPrecipitation = %#v, want first selected rainy hour", derived.PrecipTiming.FirstPrecipitation)
|
||||||
|
}
|
||||||
|
if derived.PrecipTiming.LastPrecipitation == nil || derived.PrecipTiming.LastPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T14:00:00-05:00" {
|
||||||
|
t.Fatalf("PrecipTiming.LastPrecipitation = %#v, want final selected rainy window end", derived.PrecipTiming.LastPrecipitation)
|
||||||
|
}
|
||||||
|
if len(derived.PrecipTiming.PrecipitationWindows) != 2 {
|
||||||
|
t.Fatalf("PrecipTiming.PrecipitationWindows = %#v, want two hourly windows", derived.PrecipTiming.PrecipitationWindows)
|
||||||
|
}
|
||||||
|
if len(derived.AlertOverlaps) != 1 || derived.AlertOverlaps[0].Event != "Flood Watch" {
|
||||||
|
t.Fatalf("AlertOverlaps = %#v, want only alert overlapping hourly period", derived.AlertOverlaps)
|
||||||
|
}
|
||||||
|
if derived.AlertOverlaps[0].Overlap.End.Format(time.RFC3339) != "2026-05-29T14:30:00-05:00" {
|
||||||
|
t.Fatalf("Alert overlap end = %s, want clipped to hourly end", derived.AlertOverlaps[0].Overlap.End.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
if got, want := outlookIDs(derived.SPCConvectiveOutlooks), []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic"}; strings.Join(got, ",") != strings.Join(want, ",") {
|
||||||
|
t.Fatalf("SPCConvectiveOutlooks IDs = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
if got, want := discussionHeadlines(derived.SPCConvectiveDiscussions), []string{"day1 early", "day1 late"}; strings.Join(got, ",") != strings.Join(want, ",") {
|
||||||
|
t.Fatalf("SPCConvectiveDiscussions = %#v, want retained discussions for overlapping SPC day %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildDerivedStormBuildsWindowSummary(t *testing.T) {
|
func TestBuildDerivedStormBuildsWindowSummary(t *testing.T) {
|
||||||
location := testLocation()
|
location := testLocation()
|
||||||
resolved := resolveStormForTest(t, location)
|
resolved := resolveStormForTest(t, location)
|
||||||
@@ -166,6 +239,124 @@ func TestBuildDerivedStormBuildsWindowSummary(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildDerivedSelectsSPCConvectiveOutlooksByValidPeriod(t *testing.T) {
|
||||||
|
location := testLocation()
|
||||||
|
now := mustParse("2026-05-29T08:00:00-05:00")
|
||||||
|
bundle := testBundle(location)
|
||||||
|
bundle.SPCConvectiveOutlooks = testConvectiveOutlookRun(location)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
resolved report.Resolved
|
||||||
|
wantOutlookIDs []string
|
||||||
|
wantDiscussion []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "daily",
|
||||||
|
resolved: resolveForTest(t, report.Daily, now, location),
|
||||||
|
wantOutlookIDs: []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic"},
|
||||||
|
wantDiscussion: []string{"day1 early", "day1 late"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "daily tomorrow",
|
||||||
|
resolved: resolveForTest(t, report.Tomorrow, now, location),
|
||||||
|
wantOutlookIDs: []string{"sat-enhanced"},
|
||||||
|
wantDiscussion: []string{"day2"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "three day",
|
||||||
|
resolved: resolveForTest(t, report.ThreeDay, now, location),
|
||||||
|
wantOutlookIDs: []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic", "sat-enhanced", "sun-slight"},
|
||||||
|
wantDiscussion: []string{"day1 early", "day1 late", "day2", "day3"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "weekend",
|
||||||
|
resolved: resolveForTest(t, report.Weekend, now, location),
|
||||||
|
wantOutlookIDs: []string{"sat-enhanced", "sun-slight"},
|
||||||
|
wantDiscussion: []string{"day2", "day3"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "storm",
|
||||||
|
resolved: resolveStormForTest(t, location),
|
||||||
|
wantOutlookIDs: []string{"fri-storm", "fri-low"},
|
||||||
|
wantDiscussion: []string{"day1 early", "day1 late"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
derived, err := BuildDerived(BuildDerivedRequest{
|
||||||
|
Resolved: tt.resolved,
|
||||||
|
Timezone: location.String(),
|
||||||
|
Dayparts: testDayparts(),
|
||||||
|
Collected: BuildCollected(bundle),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildDerived() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := outlookIDs(derived.SPCConvectiveOutlooks); strings.Join(got, ",") != strings.Join(tt.wantOutlookIDs, ",") {
|
||||||
|
t.Fatalf("SPCConvectiveOutlooks IDs = %#v, want %#v", got, tt.wantOutlookIDs)
|
||||||
|
}
|
||||||
|
if got := discussionHeadlines(derived.SPCConvectiveDiscussions); strings.Join(got, ",") != strings.Join(tt.wantDiscussion, ",") {
|
||||||
|
t.Fatalf("SPCConvectiveDiscussions = %#v, want %#v", got, tt.wantDiscussion)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDerivedUnsupportedReportReturnsActionableError(t *testing.T) {
|
||||||
|
location := testLocation()
|
||||||
|
resolved := resolveForTest(t, report.Hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||||
|
resolved.Definition.ID = report.ID("future_report")
|
||||||
|
|
||||||
|
_, err := BuildDerived(BuildDerivedRequest{
|
||||||
|
Resolved: resolved,
|
||||||
|
Timezone: location.String(),
|
||||||
|
Dayparts: testDayparts(),
|
||||||
|
Collected: BuildCollected(testBundle(location)),
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `derived facts are not implemented for report "future_report"`) {
|
||||||
|
t.Fatalf("BuildDerived() error = %v, want actionable unsupported report error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDerivedSPCConvectiveOutlooksDistinguishesMissingAndCheckedEmpty(t *testing.T) {
|
||||||
|
location := testLocation()
|
||||||
|
resolved := resolveForTest(t, report.Daily, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||||
|
bundle := testBundle(location)
|
||||||
|
bundle.SPCConvectiveOutlooks = nil
|
||||||
|
|
||||||
|
derived, err := BuildDerived(BuildDerivedRequest{
|
||||||
|
Resolved: resolved,
|
||||||
|
Timezone: location.String(),
|
||||||
|
Dayparts: testDayparts(),
|
||||||
|
Collected: BuildCollected(bundle),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildDerived(missing source) error = %v", err)
|
||||||
|
}
|
||||||
|
if derived.SPCConvectiveOutlooks != nil || derived.SPCConvectiveDiscussions != nil {
|
||||||
|
t.Fatalf("missing source derived outlooks=%#v discussions=%#v, want nil slices", derived.SPCConvectiveOutlooks, derived.SPCConvectiveDiscussions)
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{Outlooks: []weatherdata.ConvectiveOutlook{}, Discussions: []weatherdata.ConvectiveOutlookDiscussion{}}
|
||||||
|
derived, err = BuildDerived(BuildDerivedRequest{
|
||||||
|
Resolved: resolved,
|
||||||
|
Timezone: location.String(),
|
||||||
|
Dayparts: testDayparts(),
|
||||||
|
Collected: BuildCollected(bundle),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildDerived(checked empty source) error = %v", err)
|
||||||
|
}
|
||||||
|
if derived.SPCConvectiveOutlooks == nil || len(derived.SPCConvectiveOutlooks) != 0 {
|
||||||
|
t.Fatalf("checked empty outlooks = %#v, want non-nil empty slice", derived.SPCConvectiveOutlooks)
|
||||||
|
}
|
||||||
|
if derived.SPCConvectiveDiscussions == nil || len(derived.SPCConvectiveDiscussions) != 0 {
|
||||||
|
t.Fatalf("checked empty discussions = %#v, want non-nil empty slice", derived.SPCConvectiveDiscussions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testBundle(location *time.Location) *weatherdata.Bundle {
|
func testBundle(location *time.Location) *weatherdata.Bundle {
|
||||||
return &weatherdata.Bundle{
|
return &weatherdata.Bundle{
|
||||||
FetchedAt: mustParse("2026-05-29T10:00:00Z"),
|
FetchedAt: mustParse("2026-05-29T10:00:00Z"),
|
||||||
@@ -193,6 +384,65 @@ func testBundle(location *time.Location) *weatherdata.Bundle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testConvectiveOutlookRun(location *time.Location) *weatherdata.ConvectiveOutlookRun {
|
||||||
|
rank1 := 1
|
||||||
|
rank2 := 2
|
||||||
|
rank3 := 3
|
||||||
|
rank4 := 4
|
||||||
|
rank5 := 5
|
||||||
|
return &weatherdata.ConvectiveOutlookRun{
|
||||||
|
Outlooks: []weatherdata.ConvectiveOutlook{
|
||||||
|
convectiveOutlook(location, "mon-outside", 4, "categorical", "MDT", &rank5, "2026-06-01T00:00:00-05:00", "2026-06-02T00:00:00-05:00"),
|
||||||
|
convectiveOutlook(location, "sun-slight", 3, "categorical", "SLGT", &rank3, "2026-05-31T06:00:00-05:00", "2026-06-01T00:00:00-05:00"),
|
||||||
|
convectiveOutlook(location, "fri-low", 1, "categorical", "MRGL", &rank1, "2026-05-29T06:00:00-05:00", "2026-05-29T12:00:00-05:00"),
|
||||||
|
convectiveOutlook(location, "fri-missing-rank", 1, "categorical", "GEN", nil, "2026-05-29T08:00:00-05:00", "2026-05-29T10:00:00-05:00"),
|
||||||
|
convectiveOutlook(location, "sat-enhanced", 2, "categorical", "ENH", &rank4, "2026-05-30T01:00:00-05:00", "2026-05-30T12:00:00-05:00"),
|
||||||
|
convectiveOutlook(location, "fri-high", 1, "categorical", "SLGT", &rank3, "2026-05-29T07:00:00-05:00", "2026-05-29T11:00:00-05:00"),
|
||||||
|
convectiveOutlook(location, "fri-probabilistic", 1, "probabilistic", "5%", &rank2, "2026-05-29T05:00:00-05:00", "2026-05-29T10:00:00-05:00"),
|
||||||
|
convectiveOutlook(location, "fri-storm", 1, "categorical", "SLGT", &rank2, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00"),
|
||||||
|
},
|
||||||
|
Discussions: []weatherdata.ConvectiveOutlookDiscussion{
|
||||||
|
{Day: 4, Headline: "day4", UpdatedAt: ptrTime(mustParse("2026-05-31T10:00:00-05:00"))},
|
||||||
|
{Day: 1, Headline: "day1 late", UpdatedAt: ptrTime(mustParse("2026-05-29T09:00:00-05:00"))},
|
||||||
|
{Day: 3, Headline: "day3", UpdatedAt: ptrTime(mustParse("2026-05-31T08:00:00-05:00"))},
|
||||||
|
{Day: 1, Headline: "day1 early", UpdatedAt: ptrTime(mustParse("2026-05-29T08:00:00-05:00"))},
|
||||||
|
{Day: 2, Headline: "day2", UpdatedAt: ptrTime(mustParse("2026-05-30T08:00:00-05:00"))},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func convectiveOutlook(location *time.Location, id string, day int, outlookType string, label string, rank *int, validFrom string, validTo string) weatherdata.ConvectiveOutlook {
|
||||||
|
return weatherdata.ConvectiveOutlook{
|
||||||
|
ID: id,
|
||||||
|
Day: day,
|
||||||
|
OutlookType: outlookType,
|
||||||
|
Label: label,
|
||||||
|
SeverityRank: rank,
|
||||||
|
ValidFrom: mustParse(validFrom).In(location),
|
||||||
|
ValidTo: mustParse(validTo).In(location),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func outlookIDs(outlooks []weatherdata.ConvectiveOutlook) []string {
|
||||||
|
out := make([]string, 0, len(outlooks))
|
||||||
|
for _, outlook := range outlooks {
|
||||||
|
out = append(out, outlook.ID)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func discussionHeadlines(discussions []weatherdata.ConvectiveOutlookDiscussion) []string {
|
||||||
|
out := make([]string, 0, len(discussions))
|
||||||
|
for _, discussion := range discussions {
|
||||||
|
out = append(out, discussion.Headline)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptrTime(value time.Time) *time.Time {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
func hour(location *time.Location, start string, end string, text string, precip float64, gust float64) weatherdata.ForecastPeriod {
|
func hour(location *time.Location, start string, end string, text string, precip float64, gust float64) weatherdata.ForecastPeriod {
|
||||||
temperature := 70.0
|
temperature := 70.0
|
||||||
return weatherdata.ForecastPeriod{
|
return weatherdata.ForecastPeriod{
|
||||||
@@ -215,7 +465,11 @@ func testDayparts() []forecast.DaypartDefinition {
|
|||||||
|
|
||||||
func resolveForTest(t *testing.T, id report.ID, now time.Time, location *time.Location) report.Resolved {
|
func resolveForTest(t *testing.T, id report.ID, now time.Time, location *time.Location) report.Resolved {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
resolved, err := report.Resolve(id, report.ResolveRequest{Now: now, Location: location})
|
req := report.ResolveRequest{Now: now, Location: location}
|
||||||
|
if id == report.Daily {
|
||||||
|
req.Date = now
|
||||||
|
}
|
||||||
|
resolved, err := report.Resolve(id, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("resolve %s: %v", id, err)
|
t.Fatalf("resolve %s: %v", id, err)
|
||||||
}
|
}
|
||||||
|
|||||||
195
internal/generatedtext/catalog.go
Normal file
195
internal/generatedtext/catalog.go
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
package generatedtext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/reporttemplate"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
schemaIDHourly = "hourly"
|
||||||
|
schemaIDDaily = "daily"
|
||||||
|
schemaIDToday = "today"
|
||||||
|
schemaIDTomorrow = "tomorrow"
|
||||||
|
|
||||||
|
templateIDHourly = "hourly"
|
||||||
|
templateIDDaily = "daily"
|
||||||
|
templateIDToday = "today"
|
||||||
|
templateIDTomorrow = "tomorrow"
|
||||||
|
)
|
||||||
|
|
||||||
|
type validator func([]byte) (any, []byte, error)
|
||||||
|
|
||||||
|
type renderContextBuilder func(report.ID, string, briefing.Metadata, module.Snapshot, facts.CollectedFacts, facts.DerivedFacts, any) (any, error)
|
||||||
|
|
||||||
|
type catalogEntry struct {
|
||||||
|
schemaID string
|
||||||
|
templateID string
|
||||||
|
validate validator
|
||||||
|
renderContextBuilder renderContextBuilder
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
reportID report.ID
|
||||||
|
schemaID string
|
||||||
|
templateID string
|
||||||
|
validate validator
|
||||||
|
renderContextBuilder renderContextBuilder
|
||||||
|
}
|
||||||
|
|
||||||
|
var catalog = []catalogEntry{
|
||||||
|
{
|
||||||
|
schemaID: schemaIDHourly,
|
||||||
|
templateID: templateIDHourly,
|
||||||
|
validate: validateHourly,
|
||||||
|
renderContextBuilder: buildHourlyContext,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schemaID: schemaIDDaily,
|
||||||
|
templateID: templateIDDaily,
|
||||||
|
validate: validateDaily,
|
||||||
|
renderContextBuilder: buildDailyContext,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schemaID: schemaIDToday,
|
||||||
|
templateID: templateIDToday,
|
||||||
|
validate: validateToday,
|
||||||
|
renderContextBuilder: buildTodayContext,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schemaID: schemaIDTomorrow,
|
||||||
|
templateID: templateIDTomorrow,
|
||||||
|
validate: validateTomorrow,
|
||||||
|
renderContextBuilder: buildTomorrowContext,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func LookupDefinition(definition report.Definition) (Handler, error) {
|
||||||
|
if definition.GenerationMode != report.GenerationModeGeneratedTextTemplate {
|
||||||
|
return Handler{}, fmt.Errorf("report %q uses generation mode %q, not %q", definition.ID, definition.GenerationMode, report.GenerationModeGeneratedTextTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
var schemaKnown, templateKnown bool
|
||||||
|
for _, entry := range catalog {
|
||||||
|
if entry.schemaID == definition.GeneratedTextSchemaID {
|
||||||
|
schemaKnown = true
|
||||||
|
}
|
||||||
|
if entry.templateID == definition.TemplateID {
|
||||||
|
templateKnown = true
|
||||||
|
}
|
||||||
|
if entry.schemaID == definition.GeneratedTextSchemaID && entry.templateID == definition.TemplateID {
|
||||||
|
return Handler{
|
||||||
|
reportID: definition.ID,
|
||||||
|
schemaID: entry.schemaID,
|
||||||
|
templateID: entry.templateID,
|
||||||
|
validate: entry.validate,
|
||||||
|
renderContextBuilder: entry.renderContextBuilder,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !schemaKnown {
|
||||||
|
return Handler{}, fmt.Errorf("generated text schema %q is not supported for report %q", definition.GeneratedTextSchemaID, definition.ID)
|
||||||
|
}
|
||||||
|
if !templateKnown {
|
||||||
|
return Handler{}, fmt.Errorf("report template %q is not supported for report %q", definition.TemplateID, definition.ID)
|
||||||
|
}
|
||||||
|
return Handler{}, fmt.Errorf("generated text schema %q and report template %q are not supported together for report %q", definition.GeneratedTextSchemaID, definition.TemplateID, definition.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) SchemaID() string {
|
||||||
|
return h.schemaID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) TemplateID() string {
|
||||||
|
return h.templateID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) Schema() ([]byte, error) {
|
||||||
|
data, err := reporttemplate.Schema(h.schemaID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load generated text schema %q for report %q: %w", h.schemaID, h.reportID, err)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) Template() (string, error) {
|
||||||
|
source, err := reporttemplate.Template(h.templateID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("load report template %q for report %q: %w", h.templateID, h.reportID, err)
|
||||||
|
}
|
||||||
|
return source, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) Validate(data []byte) (any, []byte, error) {
|
||||||
|
if h.validate == nil {
|
||||||
|
return nil, nil, fmt.Errorf("generated text validator is not registered for schema %q on report %q", h.schemaID, h.reportID)
|
||||||
|
}
|
||||||
|
return h.validate(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) BuildRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
|
||||||
|
if h.renderContextBuilder == nil {
|
||||||
|
return nil, fmt.Errorf("render-context builder is not registered for template %q on report %q", h.templateID, h.reportID)
|
||||||
|
}
|
||||||
|
return h.renderContextBuilder(h.reportID, h.templateID, metadata, snapshot, collected, derived, generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) Render(data any) ([]byte, error) {
|
||||||
|
rendered, err := reporttemplate.Render(h.templateID, data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("render report template %q for report %q: %w", h.templateID, h.reportID, err)
|
||||||
|
}
|
||||||
|
return rendered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateHourly(data []byte) (any, []byte, error) {
|
||||||
|
return ValidateHourly(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDaily(data []byte) (any, []byte, error) {
|
||||||
|
return ValidateDaily(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateToday(data []byte) (any, []byte, error) {
|
||||||
|
return ValidateToday(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateTomorrow(data []byte) (any, []byte, error) {
|
||||||
|
return ValidateTomorrow(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildHourlyContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
|
||||||
|
hourly, ok := generated.(Hourly)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("report template %q requires hourly generated text for report %q", templateID, reportID)
|
||||||
|
}
|
||||||
|
return BuildHourlyRenderContext(metadata, snapshot, hourly, collected, derived)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDailyContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
|
||||||
|
daily, ok := generated.(Daily)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("report template %q requires daily generated text for report %q", templateID, reportID)
|
||||||
|
}
|
||||||
|
return BuildDailyRenderContext(metadata, snapshot, daily, collected, derived)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTodayContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
|
||||||
|
today, ok := generated.(Today)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("report template %q requires today generated text for report %q", templateID, reportID)
|
||||||
|
}
|
||||||
|
return BuildTodayRenderContext(metadata, snapshot, today, collected, derived)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTomorrowContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
|
||||||
|
tomorrow, ok := generated.(Tomorrow)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("report template %q requires tomorrow generated text for report %q", templateID, reportID)
|
||||||
|
}
|
||||||
|
return BuildTomorrowRenderContext(metadata, snapshot, tomorrow, collected, derived)
|
||||||
|
}
|
||||||
324
internal/generatedtext/catalog_test.go
Normal file
324
internal/generatedtext/catalog_test.go
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
package generatedtext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCatalogCompleteForGeneratedTextTemplateReports(t *testing.T) {
|
||||||
|
for _, definition := range report.DefaultRegistry().All() {
|
||||||
|
if definition.GenerationMode != report.GenerationModeGeneratedTextTemplate {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.Run(string(definition.ID), func(t *testing.T) {
|
||||||
|
handler, err := LookupDefinition(definition)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupDefinition() error = %v", err)
|
||||||
|
}
|
||||||
|
if handler.SchemaID() != definition.GeneratedTextSchemaID {
|
||||||
|
t.Fatalf("SchemaID() = %q, want %q", handler.SchemaID(), definition.GeneratedTextSchemaID)
|
||||||
|
}
|
||||||
|
if handler.TemplateID() != definition.TemplateID {
|
||||||
|
t.Fatalf("TemplateID() = %q, want %q", handler.TemplateID(), definition.TemplateID)
|
||||||
|
}
|
||||||
|
if handler.validate == nil {
|
||||||
|
t.Fatal("validator is nil")
|
||||||
|
}
|
||||||
|
if handler.renderContextBuilder == nil {
|
||||||
|
t.Fatal("render-context builder is nil")
|
||||||
|
}
|
||||||
|
if schema, err := handler.Schema(); err != nil {
|
||||||
|
t.Fatalf("Schema() error = %v", err)
|
||||||
|
} else if len(schema) == 0 {
|
||||||
|
t.Fatal("Schema() returned empty asset")
|
||||||
|
}
|
||||||
|
if template, err := handler.Template(); err != nil {
|
||||||
|
t.Fatalf("Template() error = %v", err)
|
||||||
|
} else if template == "" {
|
||||||
|
t.Fatal("Template() returned empty asset")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogLookupRejectsUnsupportedSchemaAndTemplate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
definition report.Definition
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "schema",
|
||||||
|
definition: func() report.Definition {
|
||||||
|
definition := report.DefaultRegistry().MustLookup(report.Hourly)
|
||||||
|
definition.GeneratedTextSchemaID = "missing-schema"
|
||||||
|
return definition
|
||||||
|
}(),
|
||||||
|
want: []string{`generated text schema "missing-schema"`, `report "hourly"`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "template",
|
||||||
|
definition: func() report.Definition {
|
||||||
|
definition := report.DefaultRegistry().MustLookup(report.Hourly)
|
||||||
|
definition.TemplateID = "missing-template"
|
||||||
|
return definition
|
||||||
|
}(),
|
||||||
|
want: []string{`report template "missing-template"`, `report "hourly"`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "combination",
|
||||||
|
definition: func() report.Definition {
|
||||||
|
definition := report.DefaultRegistry().MustLookup(report.Hourly)
|
||||||
|
definition.TemplateID = "tomorrow"
|
||||||
|
return definition
|
||||||
|
}(),
|
||||||
|
want: []string{`generated text schema "hourly"`, `report template "tomorrow"`, `report "hourly"`},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, err := LookupDefinition(test.definition)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("LookupDefinition() error = nil, want error")
|
||||||
|
}
|
||||||
|
for _, want := range test.want {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Fatalf("LookupDefinition() error = %v, want %q", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogLookupSupportsTodayDefinition(t *testing.T) {
|
||||||
|
definition := report.Definition{
|
||||||
|
ID: report.Today,
|
||||||
|
GenerationMode: report.GenerationModeGeneratedTextTemplate,
|
||||||
|
GeneratedTextSchemaID: "today",
|
||||||
|
TemplateID: "today",
|
||||||
|
}
|
||||||
|
handler, err := LookupDefinition(definition)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupDefinition(today) error = %v", err)
|
||||||
|
}
|
||||||
|
if handler.SchemaID() != "today" || handler.TemplateID() != "today" {
|
||||||
|
t.Fatalf("handler IDs = %q/%q, want today/today", handler.SchemaID(), handler.TemplateID())
|
||||||
|
}
|
||||||
|
if schema, err := handler.Schema(); err != nil {
|
||||||
|
t.Fatalf("Schema() error = %v", err)
|
||||||
|
} else if len(schema) == 0 {
|
||||||
|
t.Fatal("Schema() returned empty asset")
|
||||||
|
}
|
||||||
|
if template, err := handler.Template(); err != nil {
|
||||||
|
t.Fatalf("Template() error = %v", err)
|
||||||
|
} else if !strings.Contains(template, "# {{ .Report.Title }}") {
|
||||||
|
t.Fatalf("Template() = %q, want Today template source", template)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogLookupSupportsDailyDefinitionAssets(t *testing.T) {
|
||||||
|
definition := report.Definition{
|
||||||
|
ID: report.Daily,
|
||||||
|
GenerationMode: report.GenerationModeGeneratedTextTemplate,
|
||||||
|
GeneratedTextSchemaID: "daily",
|
||||||
|
TemplateID: "daily",
|
||||||
|
}
|
||||||
|
handler, err := LookupDefinition(definition)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupDefinition(daily) error = %v", err)
|
||||||
|
}
|
||||||
|
if handler.SchemaID() != "daily" || handler.TemplateID() != "daily" {
|
||||||
|
t.Fatalf("handler IDs = %q/%q, want daily/daily", handler.SchemaID(), handler.TemplateID())
|
||||||
|
}
|
||||||
|
if schema, err := handler.Schema(); err != nil {
|
||||||
|
t.Fatalf("Schema() error = %v", err)
|
||||||
|
} else if len(schema) == 0 {
|
||||||
|
t.Fatal("Schema() returned empty asset")
|
||||||
|
}
|
||||||
|
if template, err := handler.Template(); err != nil {
|
||||||
|
t.Fatalf("Template() error = %v", err)
|
||||||
|
} else if !strings.Contains(template, "# {{ .Report.Title }}") {
|
||||||
|
t.Fatalf("Template() = %q, want Daily template source", template)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) {
|
||||||
|
hourlyHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Hourly))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupDefinition(hourly) error = %v", err)
|
||||||
|
}
|
||||||
|
hourly, normalized, err := hourlyHandler.Validate([]byte(`{
|
||||||
|
"summary": " Storm chances increase. ",
|
||||||
|
"forecast_discussion": " A front will keep the region unsettled. "
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Validate(hourly) error = %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := hourly.(Hourly); !ok {
|
||||||
|
t.Fatalf("hourly generated text type = %T, want generatedtext.Hourly", hourly)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(normalized), `"summary":"Storm chances increase."`) {
|
||||||
|
t.Fatalf("hourly normalized text = %s, want trimmed summary", normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
tomorrowHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Tomorrow))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupDefinition(tomorrow) error = %v", err)
|
||||||
|
}
|
||||||
|
tomorrow, normalized, err := tomorrowHandler.Validate([]byte(`{
|
||||||
|
"summary": " Storms become more likely tomorrow. ",
|
||||||
|
"forecast_discussion": [" A front will keep showers in the forecast. ", ""]
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Validate(tomorrow) error = %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := tomorrow.(Tomorrow); !ok {
|
||||||
|
t.Fatalf("tomorrow generated text type = %T, want generatedtext.Tomorrow", tomorrow)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(normalized), `"forecast_discussion":["A front will keep showers in the forecast."]`) {
|
||||||
|
t.Fatalf("tomorrow normalized text = %s, want trimmed discussion paragraph", normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
todayHandler, err := LookupDefinition(report.Definition{
|
||||||
|
ID: report.Today,
|
||||||
|
GenerationMode: report.GenerationModeGeneratedTextTemplate,
|
||||||
|
GeneratedTextSchemaID: "today",
|
||||||
|
TemplateID: "today",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupDefinition(today) error = %v", err)
|
||||||
|
}
|
||||||
|
today, normalized, err := todayHandler.Validate([]byte(`{
|
||||||
|
"summary": " Showers are likely today. ",
|
||||||
|
"forecast_discussion": [" A front will keep rain chances elevated. ", ""]
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Validate(today) error = %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := today.(Today); !ok {
|
||||||
|
t.Fatalf("today generated text type = %T, want generatedtext.Today", today)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(normalized), `"forecast_discussion":["A front will keep rain chances elevated."]`) {
|
||||||
|
t.Fatalf("today normalized text = %s, want trimmed discussion paragraph", normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
dailyHandler, err := LookupDefinition(report.Definition{
|
||||||
|
ID: report.Daily,
|
||||||
|
GenerationMode: report.GenerationModeGeneratedTextTemplate,
|
||||||
|
GeneratedTextSchemaID: "daily",
|
||||||
|
TemplateID: "daily",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupDefinition(daily) error = %v", err)
|
||||||
|
}
|
||||||
|
daily, normalized, err := dailyHandler.Validate([]byte(`{
|
||||||
|
"summary": " Showers are possible during the selected day. ",
|
||||||
|
"forecast_discussion": [" A front will keep rain chances in the forecast. ", ""]
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Validate(daily) error = %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := daily.(Daily); !ok {
|
||||||
|
t.Fatalf("daily generated text type = %T, want generatedtext.Daily", daily)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(normalized), `"forecast_discussion":["A front will keep rain chances in the forecast."]`) {
|
||||||
|
t.Fatalf("daily normalized text = %s, want trimmed discussion paragraph", normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
|
||||||
|
hourlyHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Hourly))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupDefinition(hourly) error = %v", err)
|
||||||
|
}
|
||||||
|
_, err = hourlyHandler.BuildRenderContext(testMetadata(), testSnapshot(t), testCollected(), testDerived(), Tomorrow{
|
||||||
|
Summary: "Storms become more likely tomorrow.",
|
||||||
|
ForecastDiscussion: []string{"A front will keep showers in the forecast."},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("BuildRenderContext(hourly) error = nil, want type mismatch")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), `requires hourly generated text`) {
|
||||||
|
t.Fatalf("BuildRenderContext(hourly) error = %v, want hourly generated text requirement", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tomorrowHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Tomorrow))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupDefinition(tomorrow) error = %v", err)
|
||||||
|
}
|
||||||
|
_, err = tomorrowHandler.BuildRenderContext(testTomorrowMetadata(), testTomorrowSnapshot(t), testCollected(), testTomorrowDerived(), Hourly{
|
||||||
|
Summary: "Storm chances increase.",
|
||||||
|
ForecastDiscussion: "A front will keep showers in the forecast.",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("BuildRenderContext(tomorrow) error = nil, want type mismatch")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), `requires tomorrow generated text`) {
|
||||||
|
t.Fatalf("BuildRenderContext(tomorrow) error = %v, want tomorrow generated text requirement", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
todayHandler, err := LookupDefinition(report.Definition{
|
||||||
|
ID: report.Today,
|
||||||
|
GenerationMode: report.GenerationModeGeneratedTextTemplate,
|
||||||
|
GeneratedTextSchemaID: "today",
|
||||||
|
TemplateID: "today",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupDefinition(today) error = %v", err)
|
||||||
|
}
|
||||||
|
_, err = todayHandler.BuildRenderContext(testTodayMetadata(), testTodaySnapshot(t), testCollected(), testTodayDerived(), Tomorrow{
|
||||||
|
Summary: "Storms become more likely tomorrow.",
|
||||||
|
ForecastDiscussion: []string{"A front will keep showers in the forecast."},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("BuildRenderContext(today) error = nil, want type mismatch")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), `requires today generated text`) {
|
||||||
|
t.Fatalf("BuildRenderContext(today) error = %v, want today generated text requirement", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dailyHandler, err := LookupDefinition(report.Definition{
|
||||||
|
ID: report.Daily,
|
||||||
|
GenerationMode: report.GenerationModeGeneratedTextTemplate,
|
||||||
|
GeneratedTextSchemaID: "daily",
|
||||||
|
TemplateID: "daily",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupDefinition(daily) error = %v", err)
|
||||||
|
}
|
||||||
|
_, err = dailyHandler.BuildRenderContext(testDailyMetadata(), testDailySnapshot(t), testCollected(), testDailyDerived(), Tomorrow{
|
||||||
|
Summary: "Storms become more likely tomorrow.",
|
||||||
|
ForecastDiscussion: []string{"A front will keep showers in the forecast."},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("BuildRenderContext(daily) error = nil, want type mismatch")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), `requires daily generated text`) {
|
||||||
|
t.Fatalf("BuildRenderContext(daily) error = %v, want daily generated text requirement", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogBuildRenderContextSupportsDaily(t *testing.T) {
|
||||||
|
handler, err := LookupDefinition(report.Definition{
|
||||||
|
ID: report.Daily,
|
||||||
|
GenerationMode: report.GenerationModeGeneratedTextTemplate,
|
||||||
|
GeneratedTextSchemaID: "daily",
|
||||||
|
TemplateID: "daily",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LookupDefinition(daily) error = %v", err)
|
||||||
|
}
|
||||||
|
ctx, err := handler.BuildRenderContext(testDailyMetadata(), testDailySnapshot(t), testCollected(), testDailyDerived(), Daily{
|
||||||
|
Summary: "Showers are possible during the selected day.",
|
||||||
|
ForecastDiscussion: []string{"A front will keep rain chances in the forecast."},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildRenderContext(daily) error = %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := ctx.(DailyRenderContext); !ok {
|
||||||
|
t.Fatalf("BuildRenderContext(daily) = %T, want DailyRenderContext", ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
37
internal/generatedtext/daily.go
Normal file
37
internal/generatedtext/daily.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package generatedtext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Daily struct {
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
ForecastDiscussion []string `json:"forecast_discussion"`
|
||||||
|
PrecipitationTiming string `json:"precipitation_timing,omitempty"`
|
||||||
|
Confidence string `json:"confidence,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateDaily(data []byte) (Daily, []byte, error) {
|
||||||
|
value, err := decodeGeneratedText[Daily](data, "daily")
|
||||||
|
if err != nil {
|
||||||
|
return Daily{}, 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 Daily{}, nil, fmt.Errorf("daily generated text summary is required")
|
||||||
|
}
|
||||||
|
if len(value.ForecastDiscussion) == 0 {
|
||||||
|
return Daily{}, nil, fmt.Errorf("daily generated text forecast discussion is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized, err := normalizeGeneratedText(value, "daily")
|
||||||
|
if err != nil {
|
||||||
|
return Daily{}, nil, err
|
||||||
|
}
|
||||||
|
return value, normalized, nil
|
||||||
|
}
|
||||||
111
internal/generatedtext/daily_test.go
Normal file
111
internal/generatedtext/daily_test.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package generatedtext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateDailyNormalizesJSON(t *testing.T) {
|
||||||
|
value, normalized, err := ValidateDaily([]byte(`{
|
||||||
|
"summary": " Showers are possible during the selected day. ",
|
||||||
|
"forecast_discussion": [
|
||||||
|
" A front will keep rain chances in the forecast. ",
|
||||||
|
"",
|
||||||
|
" Temperatures stay seasonable by afternoon. "
|
||||||
|
],
|
||||||
|
"precipitation_timing": " Rain is most likely during the afternoon. ",
|
||||||
|
"confidence": " Medium "
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateDaily() error = %v", err)
|
||||||
|
}
|
||||||
|
if value.Summary != "Showers are possible during the selected day." {
|
||||||
|
t.Fatalf("Summary = %q, want trimmed summary", value.Summary)
|
||||||
|
}
|
||||||
|
if strings.Join(value.ForecastDiscussion, "|") != "A front will keep rain chances in the forecast.|Temperatures stay seasonable by afternoon." {
|
||||||
|
t.Fatalf("ForecastDiscussion = %#v, want trimmed non-empty paragraphs", value.ForecastDiscussion)
|
||||||
|
}
|
||||||
|
if value.PrecipitationTiming != "Rain is most likely during the afternoon." {
|
||||||
|
t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming)
|
||||||
|
}
|
||||||
|
want := `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast.","Temperatures stay seasonable by afternoon."],"precipitation_timing":"Rain is most likely during the afternoon.","confidence":"Medium"}`
|
||||||
|
if string(normalized) != want {
|
||||||
|
t.Fatalf("normalized = %s, want %s", normalized, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDailyOmitsEmptyOptionalFields(t *testing.T) {
|
||||||
|
_, normalized, err := ValidateDaily([]byte(`{
|
||||||
|
"summary": "Showers are possible during the selected day.",
|
||||||
|
"forecast_discussion": ["A front will keep rain chances in the forecast."],
|
||||||
|
"precipitation_timing": " ",
|
||||||
|
"confidence": " "
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateDaily() error = %v", err)
|
||||||
|
}
|
||||||
|
want := `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."]}`
|
||||||
|
if string(normalized) != want {
|
||||||
|
t.Fatalf("normalized = %s, want %s", normalized, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDailyRejectsInvalidInput(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "malformed",
|
||||||
|
in: `{`,
|
||||||
|
want: "decode daily generated text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown field",
|
||||||
|
in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."],"extra":"value"}`,
|
||||||
|
want: `unknown field "extra"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing summary",
|
||||||
|
in: `{"forecast_discussion":["A front will keep rain chances in the forecast."]}`,
|
||||||
|
want: "summary is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blank summary",
|
||||||
|
in: `{"summary":" ","forecast_discussion":["A front will keep rain chances in the forecast."]}`,
|
||||||
|
want: "summary is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing forecast discussion",
|
||||||
|
in: `{"summary":"Showers are possible during the selected day."}`,
|
||||||
|
want: "forecast discussion is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blank forecast discussion",
|
||||||
|
in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":[" ",""]}`,
|
||||||
|
want: "forecast discussion is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "forecast discussion wrong type",
|
||||||
|
in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":"A front will keep rain chances in the forecast."}`,
|
||||||
|
want: "cannot unmarshal string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple values",
|
||||||
|
in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."]} {}`,
|
||||||
|
want: "multiple JSON values",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, _, err := ValidateDaily([]byte(test.in))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ValidateDaily() error = nil, want error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("ValidateDaily() error = %v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
38
internal/generatedtext/hourly.go
Normal file
38
internal/generatedtext/hourly.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// Package generatedtext validates structured LLM text and render contexts.
|
||||||
|
package generatedtext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Hourly struct {
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
ForecastDiscussion string `json:"forecast_discussion"`
|
||||||
|
PrecipitationTiming string `json:"precipitation_timing,omitempty"`
|
||||||
|
Confidence string `json:"confidence,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateHourly(data []byte) (Hourly, []byte, error) {
|
||||||
|
value, err := decodeGeneratedText[Hourly](data, "hourly")
|
||||||
|
if err != nil {
|
||||||
|
return Hourly{}, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
value.Summary = strings.TrimSpace(value.Summary)
|
||||||
|
value.ForecastDiscussion = strings.TrimSpace(value.ForecastDiscussion)
|
||||||
|
value.PrecipitationTiming = strings.TrimSpace(value.PrecipitationTiming)
|
||||||
|
value.Confidence = strings.TrimSpace(value.Confidence)
|
||||||
|
if value.Summary == "" {
|
||||||
|
return Hourly{}, nil, fmt.Errorf("hourly generated text summary is required")
|
||||||
|
}
|
||||||
|
if value.ForecastDiscussion == "" {
|
||||||
|
return Hourly{}, nil, fmt.Errorf("hourly generated text forecast discussion is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized, err := normalizeGeneratedText(value, "hourly")
|
||||||
|
if err != nil {
|
||||||
|
return Hourly{}, nil, err
|
||||||
|
}
|
||||||
|
return value, normalized, nil
|
||||||
|
}
|
||||||
102
internal/generatedtext/hourly_test.go
Normal file
102
internal/generatedtext/hourly_test.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package generatedtext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateHourlyNormalizesJSON(t *testing.T) {
|
||||||
|
value, normalized, err := ValidateHourly([]byte(`{
|
||||||
|
"summary": " Storm chances increase. ",
|
||||||
|
"forecast_discussion": " A front will keep the region unsettled. ",
|
||||||
|
"precipitation_timing": " Showers are most likely early this afternoon. ",
|
||||||
|
"confidence": " Medium "
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateHourly() error = %v", err)
|
||||||
|
}
|
||||||
|
if value.Summary != "Storm chances increase." {
|
||||||
|
t.Fatalf("Summary = %q, want trimmed summary", value.Summary)
|
||||||
|
}
|
||||||
|
if value.ForecastDiscussion != "A front will keep the region unsettled." {
|
||||||
|
t.Fatalf("ForecastDiscussion = %q, want trimmed discussion", value.ForecastDiscussion)
|
||||||
|
}
|
||||||
|
if value.PrecipitationTiming != "Showers are most likely early this afternoon." {
|
||||||
|
t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming)
|
||||||
|
}
|
||||||
|
want := `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"Showers are most likely early this afternoon.","confidence":"Medium"}`
|
||||||
|
if string(normalized) != want {
|
||||||
|
t.Fatalf("normalized = %s, want %s", normalized, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateHourlyOmitsEmptyConfidence(t *testing.T) {
|
||||||
|
_, normalized, err := ValidateHourly([]byte(`{
|
||||||
|
"summary": "Storm chances increase.",
|
||||||
|
"forecast_discussion": "A front will keep the region unsettled.",
|
||||||
|
"precipitation_timing": " ",
|
||||||
|
"confidence": " "
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateHourly() error = %v", err)
|
||||||
|
}
|
||||||
|
want := `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled."}`
|
||||||
|
if string(normalized) != want {
|
||||||
|
t.Fatalf("normalized = %s, want %s", normalized, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateHourlyRejectsInvalidInput(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "malformed",
|
||||||
|
in: `{`,
|
||||||
|
want: "decode hourly generated text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown field",
|
||||||
|
in: `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled.","extra":"value"}`,
|
||||||
|
want: `unknown field "extra"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing summary",
|
||||||
|
in: `{"forecast_discussion":"A front will keep the region unsettled."}`,
|
||||||
|
want: "summary is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blank forecast discussion",
|
||||||
|
in: `{"summary":"Storm chances increase.","forecast_discussion":" "}`,
|
||||||
|
want: "forecast discussion is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "old timing field rejected",
|
||||||
|
in: `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled.","timing":"Late morning."}`,
|
||||||
|
want: `unknown field "timing"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "old impacts field rejected",
|
||||||
|
in: `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled.","impacts":"Brief downpours."}`,
|
||||||
|
want: `unknown field "impacts"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple values",
|
||||||
|
in: `{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled."} {}`,
|
||||||
|
want: "multiple JSON values",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, _, err := ValidateHourly([]byte(test.in))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ValidateHourly() error = nil, want error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("ValidateHourly() error = %v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
33
internal/generatedtext/json.go
Normal file
33
internal/generatedtext/json.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package generatedtext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
func decodeGeneratedText[T any](data []byte, name string) (T, error) {
|
||||||
|
var value T
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&value); err != nil {
|
||||||
|
return value, fmt.Errorf("decode %s generated text: %w", name, err)
|
||||||
|
}
|
||||||
|
var extra any
|
||||||
|
if err := decoder.Decode(&extra); err != nil {
|
||||||
|
if err != io.EOF {
|
||||||
|
return value, fmt.Errorf("decode %s generated text: %w", name, err)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
return value, fmt.Errorf("decode %s generated text: multiple JSON values", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeGeneratedText[T any](value T, name string) ([]byte, error) {
|
||||||
|
normalized, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("normalize %s generated text: %w", name, err)
|
||||||
|
}
|
||||||
|
return normalized, nil
|
||||||
|
}
|
||||||
773
internal/generatedtext/render_context.go
Normal file
773
internal/generatedtext/render_context.go
Normal file
@@ -0,0 +1,773 @@
|
|||||||
|
package generatedtext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HourlyRenderContext struct {
|
||||||
|
Report HourlyReportContext
|
||||||
|
GeneratedText Hourly
|
||||||
|
Modules HourlyTemplateModules
|
||||||
|
Collected facts.CollectedFacts
|
||||||
|
Derived facts.DerivedFacts
|
||||||
|
}
|
||||||
|
|
||||||
|
type HourlyReportContext struct {
|
||||||
|
Title string
|
||||||
|
LocationName string
|
||||||
|
GeneratedAt time.Time
|
||||||
|
GeneratedAtLabel string
|
||||||
|
ValidPeriod timeutil.Period
|
||||||
|
ValidPeriodLabel string
|
||||||
|
Timezone string
|
||||||
|
}
|
||||||
|
|
||||||
|
type HourlyTemplateModules struct {
|
||||||
|
Metadata *briefing.MetadataModule
|
||||||
|
CurrentConditions *briefing.CurrentConditionsModule
|
||||||
|
HourlyForecast *briefing.HourlyForecastModule
|
||||||
|
PrecipTiming *briefing.PrecipTimingModule
|
||||||
|
AlertDigest *briefing.AlertDigestModule
|
||||||
|
SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule
|
||||||
|
AreaForecastDiscussion *briefing.AreaForecastDiscussionModule
|
||||||
|
SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule
|
||||||
|
WeatherStory *briefing.WeatherStoryModule
|
||||||
|
}
|
||||||
|
|
||||||
|
type TomorrowRenderContext struct {
|
||||||
|
Report TomorrowReportContext
|
||||||
|
GeneratedText Tomorrow
|
||||||
|
Modules TomorrowTemplateModules
|
||||||
|
Collected facts.CollectedFacts
|
||||||
|
Derived facts.DerivedFacts
|
||||||
|
}
|
||||||
|
|
||||||
|
type DailyRenderContext struct {
|
||||||
|
Report DailyReportContext
|
||||||
|
GeneratedText Daily
|
||||||
|
Modules DailyTemplateModules
|
||||||
|
Collected facts.CollectedFacts
|
||||||
|
Derived facts.DerivedFacts
|
||||||
|
}
|
||||||
|
|
||||||
|
type TodayRenderContext struct {
|
||||||
|
Report TodayReportContext
|
||||||
|
GeneratedText Today
|
||||||
|
Modules TodayTemplateModules
|
||||||
|
Collected facts.CollectedFacts
|
||||||
|
Derived facts.DerivedFacts
|
||||||
|
}
|
||||||
|
|
||||||
|
type TodayReportContext struct {
|
||||||
|
Title string
|
||||||
|
ForecastDate time.Time
|
||||||
|
ForecastDateLabel string
|
||||||
|
ForecastDayName string
|
||||||
|
GeneratedAt time.Time
|
||||||
|
GeneratedAtLabel string
|
||||||
|
ValidPeriod timeutil.Period
|
||||||
|
Timezone string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TodayTemplateModules struct {
|
||||||
|
Metadata *briefing.MetadataModule
|
||||||
|
CurrentConditions *briefing.CurrentConditionsModule
|
||||||
|
HourlyForecast *briefing.HourlyForecastModule
|
||||||
|
DerivedDailySummary *briefing.DerivedDailySummaryModule
|
||||||
|
DerivedDaypartSummaries *map[string]briefing.DerivedDaypartSummaryModule
|
||||||
|
Dayparts []TodayDaypartContext
|
||||||
|
PrecipTiming *briefing.PrecipTimingModule
|
||||||
|
AlertDigest *briefing.AlertDigestModule
|
||||||
|
SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule
|
||||||
|
AreaForecastDiscussion *briefing.AreaForecastDiscussionModule
|
||||||
|
SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule
|
||||||
|
WeatherStory *briefing.WeatherStoryModule
|
||||||
|
TodayPlanning *briefing.TodayPlanningModule
|
||||||
|
}
|
||||||
|
|
||||||
|
type TodayDaypartContext struct {
|
||||||
|
Key string
|
||||||
|
Summary briefing.DerivedDaypartSummaryModule
|
||||||
|
}
|
||||||
|
|
||||||
|
type TomorrowReportContext struct {
|
||||||
|
Title string
|
||||||
|
ForecastDate time.Time
|
||||||
|
ForecastDateLabel string
|
||||||
|
ForecastDayName string
|
||||||
|
GeneratedAt time.Time
|
||||||
|
GeneratedAtLabel string
|
||||||
|
ValidPeriod timeutil.Period
|
||||||
|
Timezone string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TomorrowTemplateModules struct {
|
||||||
|
Metadata *briefing.MetadataModule
|
||||||
|
CurrentConditions *briefing.CurrentConditionsModule
|
||||||
|
HourlyForecast *briefing.HourlyForecastModule
|
||||||
|
DerivedDailySummary *briefing.DerivedDailySummaryModule
|
||||||
|
DerivedDaypartSummaries *map[string]briefing.DerivedDaypartSummaryModule
|
||||||
|
Dayparts []TomorrowDaypartContext
|
||||||
|
PrecipTiming *briefing.PrecipTimingModule
|
||||||
|
AlertDigest *briefing.AlertDigestModule
|
||||||
|
SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule
|
||||||
|
AreaForecastDiscussion *briefing.AreaForecastDiscussionModule
|
||||||
|
SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule
|
||||||
|
WeatherStory *briefing.WeatherStoryModule
|
||||||
|
TomorrowPlanning *briefing.TomorrowPlanningModule
|
||||||
|
}
|
||||||
|
|
||||||
|
type TomorrowDaypartContext struct {
|
||||||
|
Key string
|
||||||
|
Summary briefing.DerivedDaypartSummaryModule
|
||||||
|
}
|
||||||
|
|
||||||
|
type DailyReportContext struct {
|
||||||
|
Title string
|
||||||
|
ForecastDate time.Time
|
||||||
|
ForecastDateLabel string
|
||||||
|
ForecastDayName string
|
||||||
|
GeneratedAt time.Time
|
||||||
|
GeneratedAtLabel string
|
||||||
|
ValidPeriod timeutil.Period
|
||||||
|
Timezone string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DailyTemplateModules struct {
|
||||||
|
Metadata *briefing.MetadataModule
|
||||||
|
CurrentConditions *briefing.CurrentConditionsModule
|
||||||
|
HourlyForecast *briefing.HourlyForecastModule
|
||||||
|
DerivedDailySummary *briefing.DerivedDailySummaryModule
|
||||||
|
DerivedDaypartSummaries *map[string]briefing.DerivedDaypartSummaryModule
|
||||||
|
Dayparts []DailyDaypartContext
|
||||||
|
PrecipTiming *briefing.PrecipTimingModule
|
||||||
|
AlertDigest *briefing.AlertDigestModule
|
||||||
|
SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule
|
||||||
|
AreaForecastDiscussion *briefing.AreaForecastDiscussionModule
|
||||||
|
SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule
|
||||||
|
WeatherStory *briefing.WeatherStoryModule
|
||||||
|
OutdoorWindows *briefing.OutdoorWindowsModule
|
||||||
|
DailyPlanning *briefing.DailyPlanningModule
|
||||||
|
}
|
||||||
|
|
||||||
|
type DailyDaypartContext struct {
|
||||||
|
Key string
|
||||||
|
Summary briefing.DerivedDaypartSummaryModule
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Hourly, collected facts.CollectedFacts, derived facts.DerivedFacts) (HourlyRenderContext, error) {
|
||||||
|
location, err := timeutil.LoadLocation(metadata.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return HourlyRenderContext{}, fmt.Errorf("build hourly render context: %w", err)
|
||||||
|
}
|
||||||
|
if metadata.GeneratedAt.IsZero() {
|
||||||
|
return HourlyRenderContext{}, fmt.Errorf("build hourly render context: generatedAt is required")
|
||||||
|
}
|
||||||
|
if !metadata.ValidPeriod.IsValid() {
|
||||||
|
return HourlyRenderContext{}, fmt.Errorf("build hourly render context: valid period is required")
|
||||||
|
}
|
||||||
|
modules, err := hourlyTemplateModules(snapshot)
|
||||||
|
if err != nil {
|
||||||
|
return HourlyRenderContext{}, err
|
||||||
|
}
|
||||||
|
return HourlyRenderContext{
|
||||||
|
Report: HourlyReportContext{
|
||||||
|
Title: "Hourly Report",
|
||||||
|
LocationName: locationName(metadata),
|
||||||
|
GeneratedAt: metadata.GeneratedAt,
|
||||||
|
GeneratedAtLabel: generatedAtLabel(metadata.GeneratedAt, location),
|
||||||
|
ValidPeriod: metadata.ValidPeriod,
|
||||||
|
ValidPeriodLabel: periodLabel(metadata.ValidPeriod, location),
|
||||||
|
Timezone: metadata.Timezone,
|
||||||
|
},
|
||||||
|
GeneratedText: generated,
|
||||||
|
Modules: modules,
|
||||||
|
Collected: collected,
|
||||||
|
Derived: derived,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildDailyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Daily, collected facts.CollectedFacts, derived facts.DerivedFacts) (DailyRenderContext, error) {
|
||||||
|
location, err := timeutil.LoadLocation(metadata.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return DailyRenderContext{}, fmt.Errorf("build daily render context: %w", 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)
|
||||||
|
if err != nil {
|
||||||
|
return DailyRenderContext{}, err
|
||||||
|
}
|
||||||
|
forecastDate := metadata.ValidPeriod.Start.In(location)
|
||||||
|
forecastDayName := forecastDate.Format("Monday")
|
||||||
|
return DailyRenderContext{
|
||||||
|
Report: 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,
|
||||||
|
Modules: modules,
|
||||||
|
Collected: collected,
|
||||||
|
Derived: derived,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildTodayRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Today, collected facts.CollectedFacts, derived facts.DerivedFacts) (TodayRenderContext, error) {
|
||||||
|
location, err := timeutil.LoadLocation(metadata.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return TodayRenderContext{}, fmt.Errorf("build today render context: %w", 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)
|
||||||
|
if err != nil {
|
||||||
|
return TodayRenderContext{}, err
|
||||||
|
}
|
||||||
|
forecastDate := metadata.ValidPeriod.Start.In(location)
|
||||||
|
return TodayRenderContext{
|
||||||
|
Report: 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,
|
||||||
|
Modules: modules,
|
||||||
|
Collected: collected,
|
||||||
|
Derived: derived,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Tomorrow, collected facts.CollectedFacts, derived facts.DerivedFacts) (TomorrowRenderContext, error) {
|
||||||
|
location, err := timeutil.LoadLocation(metadata.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowRenderContext{}, fmt.Errorf("build tomorrow render context: %w", 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)
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowRenderContext{}, err
|
||||||
|
}
|
||||||
|
forecastDate := metadata.ValidPeriod.Start.In(location)
|
||||||
|
forecastDayName := forecastDate.Format("Monday")
|
||||||
|
return TomorrowRenderContext{
|
||||||
|
Report: 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,
|
||||||
|
Modules: modules,
|
||||||
|
Collected: collected,
|
||||||
|
Derived: derived,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dailyTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (DailyTemplateModules, error) {
|
||||||
|
lookup := newModuleSnapshotLookup(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 {
|
||||||
|
return DailyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
outdoor, err := lookup.outdoorWindows()
|
||||||
|
if err != nil {
|
||||||
|
return DailyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
planning, err := lookup.dailyPlanning()
|
||||||
|
if err != nil {
|
||||||
|
return DailyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
return DailyTemplateModules{
|
||||||
|
Metadata: metadata,
|
||||||
|
CurrentConditions: current,
|
||||||
|
HourlyForecast: hourly,
|
||||||
|
DerivedDailySummary: daily,
|
||||||
|
DerivedDaypartSummaries: dayparts,
|
||||||
|
Dayparts: orderedDailyDayparts(dayparts, derived.DaypartSummaries),
|
||||||
|
PrecipTiming: precip,
|
||||||
|
AlertDigest: alerts,
|
||||||
|
SPCConvectiveOutlooks: outlooks,
|
||||||
|
AreaForecastDiscussion: discussion,
|
||||||
|
SPCConvectiveDiscussion: spcDiscussion,
|
||||||
|
WeatherStory: story,
|
||||||
|
OutdoorWindows: outdoor,
|
||||||
|
DailyPlanning: planning,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func todayTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TodayTemplateModules, error) {
|
||||||
|
lookup := newModuleSnapshotLookup(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 {
|
||||||
|
return TodayTemplateModules{}, err
|
||||||
|
}
|
||||||
|
planning, err := lookup.todayPlanning()
|
||||||
|
if err != nil {
|
||||||
|
return TodayTemplateModules{}, err
|
||||||
|
}
|
||||||
|
return TodayTemplateModules{
|
||||||
|
Metadata: metadata,
|
||||||
|
CurrentConditions: current,
|
||||||
|
HourlyForecast: hourly,
|
||||||
|
DerivedDailySummary: daily,
|
||||||
|
DerivedDaypartSummaries: dayparts,
|
||||||
|
Dayparts: orderedTodayDayparts(dayparts, derived.DaypartSummaries),
|
||||||
|
PrecipTiming: precip,
|
||||||
|
AlertDigest: alerts,
|
||||||
|
SPCConvectiveOutlooks: outlooks,
|
||||||
|
AreaForecastDiscussion: discussion,
|
||||||
|
SPCConvectiveDiscussion: spcDiscussion,
|
||||||
|
WeatherStory: story,
|
||||||
|
TodayPlanning: planning,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, error) {
|
||||||
|
lookup := newModuleSnapshotLookup(snapshot)
|
||||||
|
metadata, err := lookup.metadata()
|
||||||
|
if err != nil {
|
||||||
|
return HourlyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
current, err := lookup.currentConditions()
|
||||||
|
if err != nil {
|
||||||
|
return HourlyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
hourly, err := lookup.hourlyForecast()
|
||||||
|
if err != nil {
|
||||||
|
return HourlyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
precip, err := lookup.precipTiming()
|
||||||
|
if err != nil {
|
||||||
|
return HourlyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
alerts, err := lookup.alertDigest()
|
||||||
|
if err != nil {
|
||||||
|
return HourlyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
outlooks, err := lookup.spcConvectiveOutlooks()
|
||||||
|
if err != nil {
|
||||||
|
return HourlyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
discussion, err := lookup.areaForecastDiscussion()
|
||||||
|
if err != nil {
|
||||||
|
return HourlyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
spcDiscussion, err := lookup.spcConvectiveDiscussion()
|
||||||
|
if err != nil {
|
||||||
|
return HourlyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
story, err := lookup.weatherStory()
|
||||||
|
if err != nil {
|
||||||
|
return HourlyTemplateModules{}, err
|
||||||
|
}
|
||||||
|
return HourlyTemplateModules{
|
||||||
|
Metadata: metadata,
|
||||||
|
CurrentConditions: current,
|
||||||
|
HourlyForecast: hourly,
|
||||||
|
PrecipTiming: precip,
|
||||||
|
AlertDigest: alerts,
|
||||||
|
SPCConvectiveOutlooks: outlooks,
|
||||||
|
AreaForecastDiscussion: discussion,
|
||||||
|
SPCConvectiveDiscussion: spcDiscussion,
|
||||||
|
WeatherStory: story,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tomorrowTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TomorrowTemplateModules, error) {
|
||||||
|
lookup := newModuleSnapshotLookup(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 {
|
||||||
|
return TomorrowTemplateModules{}, err
|
||||||
|
}
|
||||||
|
planning, err := lookup.tomorrowPlanning()
|
||||||
|
if err != nil {
|
||||||
|
return TomorrowTemplateModules{}, err
|
||||||
|
}
|
||||||
|
return TomorrowTemplateModules{
|
||||||
|
Metadata: metadata,
|
||||||
|
CurrentConditions: current,
|
||||||
|
HourlyForecast: hourly,
|
||||||
|
DerivedDailySummary: daily,
|
||||||
|
DerivedDaypartSummaries: dayparts,
|
||||||
|
Dayparts: orderedTomorrowDayparts(dayparts, derived.DaypartSummaries),
|
||||||
|
PrecipTiming: precip,
|
||||||
|
AlertDigest: alerts,
|
||||||
|
SPCConvectiveOutlooks: outlooks,
|
||||||
|
AreaForecastDiscussion: discussion,
|
||||||
|
SPCConvectiveDiscussion: spcDiscussion,
|
||||||
|
WeatherStory: story,
|
||||||
|
TomorrowPlanning: planning,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type moduleSnapshotLookup struct {
|
||||||
|
snapshot module.Snapshot
|
||||||
|
stanzas map[string]module.Output
|
||||||
|
}
|
||||||
|
|
||||||
|
func newModuleSnapshotLookup(snapshot module.Snapshot) moduleSnapshotLookup {
|
||||||
|
stanzas := make(map[string]module.Output, len(snapshot.Outputs))
|
||||||
|
for _, output := range snapshot.Outputs {
|
||||||
|
stanzas[output.StanzaName] = output
|
||||||
|
}
|
||||||
|
return moduleSnapshotLookup{
|
||||||
|
snapshot: snapshot,
|
||||||
|
stanzas: stanzas,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) metadata() (*briefing.MetadataModule, error) {
|
||||||
|
return optionalStanza[briefing.MetadataModule](lookup, module.Metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) currentConditions() (*briefing.CurrentConditionsModule, error) {
|
||||||
|
return optionalStanza[briefing.CurrentConditionsModule](lookup, module.CurrentConditions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) hourlyForecast() (*briefing.HourlyForecastModule, error) {
|
||||||
|
return optionalStanza[briefing.HourlyForecastModule](lookup, module.HourlyForecast)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) derivedDailySummary() (*briefing.DerivedDailySummaryModule, error) {
|
||||||
|
return optionalStanza[briefing.DerivedDailySummaryModule](lookup, module.DerivedDailySummary)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) derivedDaypartSummaries() (*map[string]briefing.DerivedDaypartSummaryModule, error) {
|
||||||
|
return optionalStanza[map[string]briefing.DerivedDaypartSummaryModule](lookup, module.DerivedDaypartSummaries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) precipTiming() (*briefing.PrecipTimingModule, error) {
|
||||||
|
return optionalStanza[briefing.PrecipTimingModule](lookup, module.PrecipTiming)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) alertDigest() (*briefing.AlertDigestModule, error) {
|
||||||
|
return optionalStanza[briefing.AlertDigestModule](lookup, module.AlertDigest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) spcConvectiveOutlooks() (*briefing.SPCConvectiveOutlooksModule, error) {
|
||||||
|
return optionalStanza[briefing.SPCConvectiveOutlooksModule](lookup, module.SPCConvectiveOutlooks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) areaForecastDiscussion() (*briefing.AreaForecastDiscussionModule, error) {
|
||||||
|
return optionalStanza[briefing.AreaForecastDiscussionModule](lookup, module.AreaForecastDiscussion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) spcConvectiveDiscussion() (*briefing.SPCConvectiveDiscussionModule, error) {
|
||||||
|
return optionalStanza[briefing.SPCConvectiveDiscussionModule](lookup, module.SPCConvectiveDiscussion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) weatherStory() (*briefing.WeatherStoryModule, error) {
|
||||||
|
return optionalStanza[briefing.WeatherStoryModule](lookup, module.WeatherStory)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) outdoorWindows() (*briefing.OutdoorWindowsModule, error) {
|
||||||
|
return optionalStanza[briefing.OutdoorWindowsModule](lookup, module.OutdoorWindows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) dailyPlanning() (*briefing.DailyPlanningModule, error) {
|
||||||
|
return optionalStanza[briefing.DailyPlanningModule](lookup, module.DailyPlanning)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) todayPlanning() (*briefing.TodayPlanningModule, error) {
|
||||||
|
return optionalStanza[briefing.TodayPlanningModule](lookup, module.TodayPlanning)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lookup moduleSnapshotLookup) tomorrowPlanning() (*briefing.TomorrowPlanningModule, error) {
|
||||||
|
return optionalStanza[briefing.TomorrowPlanningModule](lookup, module.TomorrowPlanning)
|
||||||
|
}
|
||||||
|
|
||||||
|
func optionalStanza[T any](lookup moduleSnapshotLookup, id module.ID) (*T, error) {
|
||||||
|
name := string(id)
|
||||||
|
output, ok := lookup.stanzas[name]
|
||||||
|
if !ok || output.Value == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
value, _, err := module.StanzaValue[T](lookup.snapshot, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build render context module %q: %w", id, err)
|
||||||
|
}
|
||||||
|
return &value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderedTodayDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []TodayDaypartContext {
|
||||||
|
orderedRows := orderedDaypartRows(dayparts, ordered)
|
||||||
|
out := make([]TodayDaypartContext, 0, len(orderedRows))
|
||||||
|
for _, row := range orderedRows {
|
||||||
|
out = append(out, TodayDaypartContext{Key: row.Key, Summary: row.Summary})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderedDailyDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []DailyDaypartContext {
|
||||||
|
orderedRows := orderedDaypartRows(dayparts, ordered)
|
||||||
|
out := make([]DailyDaypartContext, 0, len(orderedRows))
|
||||||
|
for _, row := range orderedRows {
|
||||||
|
out = append(out, DailyDaypartContext{Key: row.Key, Summary: row.Summary})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderedTomorrowDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []TomorrowDaypartContext {
|
||||||
|
orderedRows := orderedDaypartRows(dayparts, ordered)
|
||||||
|
out := make([]TomorrowDaypartContext, 0, len(orderedRows))
|
||||||
|
for _, row := range orderedRows {
|
||||||
|
out = append(out, TomorrowDaypartContext{Key: row.Key, Summary: row.Summary})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type orderedDaypartRow struct {
|
||||||
|
Key string
|
||||||
|
Summary briefing.DerivedDaypartSummaryModule
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderedDaypartRows(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []orderedDaypartRow {
|
||||||
|
if dayparts == nil || len(*dayparts) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]orderedDaypartRow, 0, len(*dayparts))
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, daypart := range ordered {
|
||||||
|
for _, key := range daypartModuleKeyCandidates(daypart) {
|
||||||
|
value, ok := (*dayparts)[key]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
out = append(out, orderedDaypartRow{Key: key, Summary: value})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var remaining []string
|
||||||
|
for key := range *dayparts {
|
||||||
|
if _, ok := seen[key]; !ok {
|
||||||
|
remaining = append(remaining, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(remaining)
|
||||||
|
for _, key := range remaining {
|
||||||
|
out = append(out, orderedDaypartRow{Key: key, Summary: (*dayparts)[key]})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func daypartModuleKeyCandidates(daypart forecast.DaypartSummary) []string {
|
||||||
|
key := normalizedDaypartKey(daypart.Name)
|
||||||
|
if key == "" {
|
||||||
|
key = "unnamed"
|
||||||
|
}
|
||||||
|
if daypart.Period.Start.IsZero() {
|
||||||
|
return []string{key}
|
||||||
|
}
|
||||||
|
return []string{key, daypart.Period.Start.Format(timeutil.DateLayout) + "_" + key}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedDaypartKey(value string) string {
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(value))
|
||||||
|
var out strings.Builder
|
||||||
|
lastUnderscore := false
|
||||||
|
for _, r := range lower {
|
||||||
|
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||||
|
out.WriteRune(r)
|
||||||
|
lastUnderscore = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !lastUnderscore {
|
||||||
|
out.WriteByte('_')
|
||||||
|
lastUnderscore = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Trim(out.String(), "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationName(metadata briefing.Metadata) string {
|
||||||
|
if metadata.Location != nil {
|
||||||
|
if metadata.Location.Name != "" && metadata.Location.Region != "" {
|
||||||
|
return metadata.Location.Name + ", " + metadata.Location.Region
|
||||||
|
}
|
||||||
|
if metadata.Location.Name != "" {
|
||||||
|
return metadata.Location.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if metadata.SourceLocation != "" {
|
||||||
|
return metadata.SourceLocation
|
||||||
|
}
|
||||||
|
if metadata.SourceLocationID != "" {
|
||||||
|
return metadata.SourceLocationID
|
||||||
|
}
|
||||||
|
return "Unknown location"
|
||||||
|
}
|
||||||
|
|
||||||
|
func periodLabel(period timeutil.Period, location *time.Location) string {
|
||||||
|
return fmt.Sprintf("%s to %s", timeLabel(period.Start, location), timeLabel(period.End, location))
|
||||||
|
}
|
||||||
|
|
||||||
|
func timeLabel(value time.Time, location *time.Location) string {
|
||||||
|
return value.In(location).Format("2006-01-02 at 3:04 PM")
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatedAtLabel(value time.Time, location *time.Location) string {
|
||||||
|
return value.In(location).Format("Monday, January 2, 2006 at 3:04 PM")
|
||||||
|
}
|
||||||
1209
internal/generatedtext/render_context_test.go
Normal file
1209
internal/generatedtext/render_context_test.go
Normal file
File diff suppressed because it is too large
Load Diff
37
internal/generatedtext/today.go
Normal file
37
internal/generatedtext/today.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package generatedtext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Today struct {
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
ForecastDiscussion []string `json:"forecast_discussion"`
|
||||||
|
PrecipitationTiming string `json:"precipitation_timing,omitempty"`
|
||||||
|
Confidence string `json:"confidence,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateToday(data []byte) (Today, []byte, error) {
|
||||||
|
value, err := decodeGeneratedText[Today](data, "today")
|
||||||
|
if err != nil {
|
||||||
|
return Today{}, 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 Today{}, nil, fmt.Errorf("today generated text summary is required")
|
||||||
|
}
|
||||||
|
if len(value.ForecastDiscussion) == 0 {
|
||||||
|
return Today{}, nil, fmt.Errorf("today generated text forecast discussion is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized, err := normalizeGeneratedText(value, "today")
|
||||||
|
if err != nil {
|
||||||
|
return Today{}, nil, err
|
||||||
|
}
|
||||||
|
return value, normalized, nil
|
||||||
|
}
|
||||||
111
internal/generatedtext/today_test.go
Normal file
111
internal/generatedtext/today_test.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package generatedtext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateTodayNormalizesJSON(t *testing.T) {
|
||||||
|
value, normalized, err := ValidateToday([]byte(`{
|
||||||
|
"summary": " Showers are likely today. ",
|
||||||
|
"forecast_discussion": [
|
||||||
|
" A front will keep rain chances elevated. ",
|
||||||
|
"",
|
||||||
|
" Temperatures stay mild through the afternoon. "
|
||||||
|
],
|
||||||
|
"precipitation_timing": " Rain is most likely during the afternoon. ",
|
||||||
|
"confidence": " Medium "
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateToday() error = %v", err)
|
||||||
|
}
|
||||||
|
if value.Summary != "Showers are likely today." {
|
||||||
|
t.Fatalf("Summary = %q, want trimmed summary", value.Summary)
|
||||||
|
}
|
||||||
|
if strings.Join(value.ForecastDiscussion, "|") != "A front will keep rain chances elevated.|Temperatures stay mild through the afternoon." {
|
||||||
|
t.Fatalf("ForecastDiscussion = %#v, want trimmed non-empty paragraphs", value.ForecastDiscussion)
|
||||||
|
}
|
||||||
|
if value.PrecipitationTiming != "Rain is most likely during the afternoon." {
|
||||||
|
t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming)
|
||||||
|
}
|
||||||
|
want := `{"summary":"Showers are likely today.","forecast_discussion":["A front will keep rain chances elevated.","Temperatures stay mild through the afternoon."],"precipitation_timing":"Rain is most likely during the afternoon.","confidence":"Medium"}`
|
||||||
|
if string(normalized) != want {
|
||||||
|
t.Fatalf("normalized = %s, want %s", normalized, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateTodayOmitsEmptyOptionalFields(t *testing.T) {
|
||||||
|
_, normalized, err := ValidateToday([]byte(`{
|
||||||
|
"summary": "Showers are likely today.",
|
||||||
|
"forecast_discussion": ["A front will keep rain chances elevated."],
|
||||||
|
"precipitation_timing": " ",
|
||||||
|
"confidence": " "
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateToday() error = %v", err)
|
||||||
|
}
|
||||||
|
want := `{"summary":"Showers are likely today.","forecast_discussion":["A front will keep rain chances elevated."]}`
|
||||||
|
if string(normalized) != want {
|
||||||
|
t.Fatalf("normalized = %s, want %s", normalized, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateTodayRejectsInvalidInput(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "malformed",
|
||||||
|
in: `{`,
|
||||||
|
want: "decode today generated text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown field",
|
||||||
|
in: `{"summary":"Showers are likely today.","forecast_discussion":["A front will keep rain chances elevated."],"extra":"value"}`,
|
||||||
|
want: `unknown field "extra"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing summary",
|
||||||
|
in: `{"forecast_discussion":["A front will keep rain chances elevated."]}`,
|
||||||
|
want: "summary is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blank summary",
|
||||||
|
in: `{"summary":" ","forecast_discussion":["A front will keep rain chances elevated."]}`,
|
||||||
|
want: "summary is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing forecast discussion",
|
||||||
|
in: `{"summary":"Showers are likely today."}`,
|
||||||
|
want: "forecast discussion is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blank forecast discussion",
|
||||||
|
in: `{"summary":"Showers are likely today.","forecast_discussion":[" ",""]}`,
|
||||||
|
want: "forecast discussion is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "forecast discussion wrong type",
|
||||||
|
in: `{"summary":"Showers are likely today.","forecast_discussion":"A front will keep rain chances elevated."}`,
|
||||||
|
want: "cannot unmarshal string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple values",
|
||||||
|
in: `{"summary":"Showers are likely today.","forecast_discussion":["A front will keep rain chances elevated."]} {}`,
|
||||||
|
want: "multiple JSON values",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, _, err := ValidateToday([]byte(test.in))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ValidateToday() error = nil, want error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("ValidateToday() error = %v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
48
internal/generatedtext/tomorrow.go
Normal file
48
internal/generatedtext/tomorrow.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package generatedtext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Tomorrow struct {
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
ForecastDiscussion []string `json:"forecast_discussion"`
|
||||||
|
PrecipitationTiming string `json:"precipitation_timing,omitempty"`
|
||||||
|
Confidence string `json:"confidence,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateTomorrow(data []byte) (Tomorrow, []byte, error) {
|
||||||
|
value, err := decodeGeneratedText[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 {
|
||||||
|
out := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed != "" {
|
||||||
|
out = append(out, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
111
internal/generatedtext/tomorrow_test.go
Normal file
111
internal/generatedtext/tomorrow_test.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package generatedtext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateTomorrowNormalizesJSON(t *testing.T) {
|
||||||
|
value, normalized, err := ValidateTomorrow([]byte(`{
|
||||||
|
"summary": " Storms become more likely tomorrow. ",
|
||||||
|
"forecast_discussion": [
|
||||||
|
" A front will keep showers in the forecast. ",
|
||||||
|
"",
|
||||||
|
" Temperatures stay seasonable by afternoon. "
|
||||||
|
],
|
||||||
|
"precipitation_timing": " Rain is most likely before sunrise. ",
|
||||||
|
"confidence": " Medium "
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateTomorrow() error = %v", err)
|
||||||
|
}
|
||||||
|
if value.Summary != "Storms become more likely tomorrow." {
|
||||||
|
t.Fatalf("Summary = %q, want trimmed summary", value.Summary)
|
||||||
|
}
|
||||||
|
if strings.Join(value.ForecastDiscussion, "|") != "A front will keep showers in the forecast.|Temperatures stay seasonable by afternoon." {
|
||||||
|
t.Fatalf("ForecastDiscussion = %#v, want trimmed non-empty paragraphs", value.ForecastDiscussion)
|
||||||
|
}
|
||||||
|
if value.PrecipitationTiming != "Rain is most likely before sunrise." {
|
||||||
|
t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming)
|
||||||
|
}
|
||||||
|
want := `{"summary":"Storms become more likely tomorrow.","forecast_discussion":["A front will keep showers in the forecast.","Temperatures stay seasonable by afternoon."],"precipitation_timing":"Rain is most likely before sunrise.","confidence":"Medium"}`
|
||||||
|
if string(normalized) != want {
|
||||||
|
t.Fatalf("normalized = %s, want %s", normalized, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateTomorrowOmitsEmptyOptionalFields(t *testing.T) {
|
||||||
|
_, normalized, err := ValidateTomorrow([]byte(`{
|
||||||
|
"summary": "Storms become more likely tomorrow.",
|
||||||
|
"forecast_discussion": ["A front will keep showers in the forecast."],
|
||||||
|
"precipitation_timing": " ",
|
||||||
|
"confidence": " "
|
||||||
|
}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateTomorrow() error = %v", err)
|
||||||
|
}
|
||||||
|
want := `{"summary":"Storms become more likely tomorrow.","forecast_discussion":["A front will keep showers in the forecast."]}`
|
||||||
|
if string(normalized) != want {
|
||||||
|
t.Fatalf("normalized = %s, want %s", normalized, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateTomorrowRejectsInvalidInput(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "malformed",
|
||||||
|
in: `{`,
|
||||||
|
want: "decode tomorrow generated text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown field",
|
||||||
|
in: `{"summary":"Storms become more likely tomorrow.","forecast_discussion":["A front will keep showers in the forecast."],"extra":"value"}`,
|
||||||
|
want: `unknown field "extra"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing summary",
|
||||||
|
in: `{"forecast_discussion":["A front will keep showers in the forecast."]}`,
|
||||||
|
want: "summary is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blank summary",
|
||||||
|
in: `{"summary":" ","forecast_discussion":["A front will keep showers in the forecast."]}`,
|
||||||
|
want: "summary is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing forecast discussion",
|
||||||
|
in: `{"summary":"Storms become more likely tomorrow."}`,
|
||||||
|
want: "forecast discussion is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blank forecast discussion",
|
||||||
|
in: `{"summary":"Storms become more likely tomorrow.","forecast_discussion":[" ",""]}`,
|
||||||
|
want: "forecast discussion is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "forecast discussion wrong type",
|
||||||
|
in: `{"summary":"Storms become more likely tomorrow.","forecast_discussion":"A front will keep showers in the forecast."}`,
|
||||||
|
want: "cannot unmarshal string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple values",
|
||||||
|
in: `{"summary":"Storms become more likely tomorrow.","forecast_discussion":["A front will keep showers in the forecast."]} {}`,
|
||||||
|
want: "multiple JSON values",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, _, err := ValidateTomorrow([]byte(test.in))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ValidateTomorrow() error = nil, want error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("ValidateTomorrow() error = %v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,8 +21,12 @@ const (
|
|||||||
AlertDigest ID = "alert_digest"
|
AlertDigest ID = "alert_digest"
|
||||||
AreaForecastDiscussion ID = "area_forecast_discussion"
|
AreaForecastDiscussion ID = "area_forecast_discussion"
|
||||||
WeatherStory ID = "weather_story"
|
WeatherStory ID = "weather_story"
|
||||||
|
SPCConvectiveOutlooks ID = "spc_convective_outlooks"
|
||||||
|
SPCConvectiveDiscussion ID = "spc_convective_discussion"
|
||||||
OutdoorWindows ID = "outdoor_windows"
|
OutdoorWindows ID = "outdoor_windows"
|
||||||
|
TodayPlanning ID = "today_planning"
|
||||||
TomorrowPlanning ID = "tomorrow_planning"
|
TomorrowPlanning ID = "tomorrow_planning"
|
||||||
|
DailyPlanning ID = "daily_planning"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ConfigItem struct {
|
type ConfigItem struct {
|
||||||
@@ -34,6 +38,7 @@ type Output struct {
|
|||||||
ID ID `json:"id"`
|
ID ID `json:"id"`
|
||||||
StanzaName string `json:"stanzaName"`
|
StanzaName string `json:"stanzaName"`
|
||||||
Value any `json:"value"`
|
Value any `json:"value"`
|
||||||
|
PromptValue any `json:"-" yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Snapshot struct {
|
type Snapshot struct {
|
||||||
@@ -41,6 +46,13 @@ type Snapshot struct {
|
|||||||
Outputs []Output `json:"outputs"`
|
Outputs []Output `json:"outputs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (o Output) DataPackageValue() any {
|
||||||
|
if o.PromptValue != nil {
|
||||||
|
return o.PromptValue
|
||||||
|
}
|
||||||
|
return o.Value
|
||||||
|
}
|
||||||
|
|
||||||
func NewSnapshot(outputs []Output) (Snapshot, error) {
|
func NewSnapshot(outputs []Output) (Snapshot, error) {
|
||||||
snapshot := Snapshot{
|
snapshot := Snapshot{
|
||||||
SchemaVersion: SnapshotSchemaVersion,
|
SchemaVersion: SnapshotSchemaVersion,
|
||||||
@@ -111,6 +123,7 @@ const (
|
|||||||
CollectedAlerts FactRequirement = "collected.alerts"
|
CollectedAlerts FactRequirement = "collected.alerts"
|
||||||
CollectedDiscussion FactRequirement = "collected.discussion"
|
CollectedDiscussion FactRequirement = "collected.discussion"
|
||||||
CollectedWeatherStory FactRequirement = "collected.weather_story"
|
CollectedWeatherStory FactRequirement = "collected.weather_story"
|
||||||
|
CollectedSPCConvectiveOutlooks FactRequirement = "collected.spc_convective_outlooks"
|
||||||
CollectedSourceMetadata FactRequirement = "collected.source_metadata"
|
CollectedSourceMetadata FactRequirement = "collected.source_metadata"
|
||||||
RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods"
|
RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods"
|
||||||
RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods"
|
RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods"
|
||||||
@@ -118,6 +131,7 @@ const (
|
|||||||
RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries"
|
RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries"
|
||||||
RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries"
|
RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries"
|
||||||
RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing"
|
RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing"
|
||||||
|
RequiresDerivedSPCConvectiveOutlooks FactRequirement = "derived.spc_convective_outlooks"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MissingDataBehavior string
|
type MissingDataBehavior string
|
||||||
@@ -141,5 +155,9 @@ type AreaForecastDiscussionOptions struct {
|
|||||||
Sections []string `json:"sections,omitempty" yaml:"sections,omitempty"`
|
Sections []string `json:"sections,omitempty" yaml:"sections,omitempty"`
|
||||||
}
|
}
|
||||||
type WeatherStoryOptions struct{}
|
type WeatherStoryOptions struct{}
|
||||||
|
type SPCConvectiveOutlooksOptions struct{}
|
||||||
|
type SPCConvectiveDiscussionOptions struct{}
|
||||||
type OutdoorWindowsOptions struct{}
|
type OutdoorWindowsOptions struct{}
|
||||||
|
type TodayPlanningOptions struct{}
|
||||||
type TomorrowPlanningOptions struct{}
|
type TomorrowPlanningOptions struct{}
|
||||||
|
type DailyPlanningOptions struct{}
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ type testStanza struct {
|
|||||||
|
|
||||||
func TestSnapshotPreservesOutputOrderAndJSON(t *testing.T) {
|
func TestSnapshotPreservesOutputOrderAndJSON(t *testing.T) {
|
||||||
snapshot, err := NewSnapshot([]Output{
|
snapshot, err := NewSnapshot([]Output{
|
||||||
{ID: Metadata, StanzaName: "metadata", Value: testStanza{Message: "first", Count: 1}},
|
{
|
||||||
|
ID: Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Value: testStanza{Message: "first", Count: 1},
|
||||||
|
PromptValue: testStanza{Message: "prompt-only", Count: 10},
|
||||||
|
},
|
||||||
{ID: AlertDigest, StanzaName: "alert_digest", Value: testStanza{Message: "second", Count: 2}},
|
{ID: AlertDigest, StanzaName: "alert_digest", Value: testStanza{Message: "second", Count: 2}},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -35,6 +40,26 @@ func TestSnapshotPreservesOutputOrderAndJSON(t *testing.T) {
|
|||||||
if got != want {
|
if got != want {
|
||||||
t.Fatalf("json = %s, want %s", got, want)
|
t.Fatalf("json = %s, want %s", got, want)
|
||||||
}
|
}
|
||||||
|
if strings.Contains(got, "PromptValue") || strings.Contains(got, "promptValue") || strings.Contains(got, "prompt-only") {
|
||||||
|
t.Fatalf("json includes runtime-only prompt value: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutputDataPackageValue(t *testing.T) {
|
||||||
|
output := Output{
|
||||||
|
ID: Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Value: testStanza{Message: "rich", Count: 1},
|
||||||
|
PromptValue: testStanza{Message: "prompt", Count: 2},
|
||||||
|
}
|
||||||
|
if got := output.DataPackageValue(); got != output.PromptValue {
|
||||||
|
t.Fatalf("DataPackageValue() = %#v, want prompt value", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
output.PromptValue = nil
|
||||||
|
if got := output.DataPackageValue(); got != output.Value {
|
||||||
|
t.Fatalf("DataPackageValue() = %#v, want rich value fallback", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSnapshotRejectsDuplicateOutputs(t *testing.T) {
|
func TestSnapshotRejectsDuplicateOutputs(t *testing.T) {
|
||||||
@@ -57,7 +82,12 @@ func TestSnapshotRejectsDuplicateOutputs(t *testing.T) {
|
|||||||
|
|
||||||
func TestStanzaValueDecodesTypedOutput(t *testing.T) {
|
func TestStanzaValueDecodesTypedOutput(t *testing.T) {
|
||||||
snapshot, err := NewSnapshot([]Output{
|
snapshot, err := NewSnapshot([]Output{
|
||||||
{ID: Metadata, StanzaName: "metadata", Value: testStanza{Message: "available", Count: 3}},
|
{
|
||||||
|
ID: Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Value: testStanza{Message: "available", Count: 3},
|
||||||
|
PromptValue: testStanza{Message: "prompt-only", Count: 99},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewSnapshot() error = %v", err)
|
t.Fatalf("NewSnapshot() error = %v", err)
|
||||||
@@ -82,3 +112,29 @@ func TestStanzaValueDecodesTypedOutput(t *testing.T) {
|
|||||||
t.Fatal("StanzaValue(missing) found = true, want false")
|
t.Fatal("StanzaValue(missing) found = true, want false")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSPCConvectiveModuleContractsAreStable(t *testing.T) {
|
||||||
|
if SPCConvectiveOutlooks != ID("spc_convective_outlooks") {
|
||||||
|
t.Fatalf("SPCConvectiveOutlooks = %q, want stable source/module ID", SPCConvectiveOutlooks)
|
||||||
|
}
|
||||||
|
if SPCConvectiveDiscussion != ID("spc_convective_discussion") {
|
||||||
|
t.Fatalf("SPCConvectiveDiscussion = %q, want stable discussion module ID", SPCConvectiveDiscussion)
|
||||||
|
}
|
||||||
|
if CollectedSPCConvectiveOutlooks != FactRequirement("collected.spc_convective_outlooks") {
|
||||||
|
t.Fatalf("CollectedSPCConvectiveOutlooks = %q, want collected requirement", CollectedSPCConvectiveOutlooks)
|
||||||
|
}
|
||||||
|
if RequiresDerivedSPCConvectiveOutlooks != FactRequirement("derived.spc_convective_outlooks") {
|
||||||
|
t.Fatalf("RequiresDerivedSPCConvectiveOutlooks = %q, want derived requirement", RequiresDerivedSPCConvectiveOutlooks)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = SPCConvectiveOutlooksOptions{}
|
||||||
|
_ = SPCConvectiveDiscussionOptions{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDailyPlanningModuleContractIsStable(t *testing.T) {
|
||||||
|
if DailyPlanning != ID("daily_planning") {
|
||||||
|
t.Fatalf("DailyPlanning = %q, want stable module ID", DailyPlanning)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = DailyPlanningOptions{}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const SchemaVersion = "weatherreporter.data_package.v2"
|
const SchemaVersion = "weatherreporter.data_package.v3"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
metadataStanza = "metadata"
|
metadataStanza = "metadata"
|
||||||
@@ -35,13 +35,17 @@ var briefingCategoryOrder = []string{
|
|||||||
|
|
||||||
var briefingStanzaCategories = map[string]string{
|
var briefingStanzaCategories = map[string]string{
|
||||||
string(module.AlertDigest): categoryApplicableRiskProducts,
|
string(module.AlertDigest): categoryApplicableRiskProducts,
|
||||||
|
string(module.SPCConvectiveOutlooks): categoryApplicableRiskProducts,
|
||||||
string(module.DerivedDailySummary): categoryDerivedSummaries,
|
string(module.DerivedDailySummary): categoryDerivedSummaries,
|
||||||
string(module.DerivedDaypartSummaries): categoryDerivedSummaries,
|
string(module.DerivedDaypartSummaries): categoryDerivedSummaries,
|
||||||
string(module.PrecipTiming): categoryDerivedSummaries,
|
string(module.PrecipTiming): categoryDerivedSummaries,
|
||||||
string(module.OutdoorWindows): categoryDerivedSummaries,
|
string(module.OutdoorWindows): categoryDerivedSummaries,
|
||||||
|
string(module.DailyPlanning): categoryDerivedSummaries,
|
||||||
|
string(module.TodayPlanning): categoryDerivedSummaries,
|
||||||
string(module.TomorrowPlanning): categoryDerivedSummaries,
|
string(module.TomorrowPlanning): categoryDerivedSummaries,
|
||||||
string(module.NarrativeForecast): categoryNarrativeProducts,
|
string(module.NarrativeForecast): categoryNarrativeProducts,
|
||||||
string(module.AreaForecastDiscussion): categoryNarrativeProducts,
|
string(module.AreaForecastDiscussion): categoryNarrativeProducts,
|
||||||
|
string(module.SPCConvectiveDiscussion): categoryNarrativeProducts,
|
||||||
string(module.WeatherStory): categoryNarrativeProducts,
|
string(module.WeatherStory): categoryNarrativeProducts,
|
||||||
string(module.CurrentConditions): categoryRawData,
|
string(module.CurrentConditions): categoryRawData,
|
||||||
string(module.HourlyForecast): categoryRawData,
|
string(module.HourlyForecast): categoryRawData,
|
||||||
@@ -129,7 +133,7 @@ func stanzasFromSnapshot(snapshot module.Snapshot) BriefingStanzas {
|
|||||||
order := make([]string, 0, len(snapshot.Outputs))
|
order := make([]string, 0, len(snapshot.Outputs))
|
||||||
for _, output := range snapshot.Outputs {
|
for _, output := range snapshot.Outputs {
|
||||||
order = append(order, output.StanzaName)
|
order = append(order, output.StanzaName)
|
||||||
values[output.StanzaName] = output.Value
|
values[output.StanzaName] = output.DataPackageValue()
|
||||||
}
|
}
|
||||||
return BriefingStanzas{Order: order, Values: values}
|
return BriefingStanzas{Order: order, Values: values}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ func TestBuildDailyDataPackage(t *testing.T) {
|
|||||||
if pkg.SchemaVersion != SchemaVersion {
|
if pkg.SchemaVersion != SchemaVersion {
|
||||||
t.Fatalf("SchemaVersion = %q, want %q", pkg.SchemaVersion, SchemaVersion)
|
t.Fatalf("SchemaVersion = %q, want %q", pkg.SchemaVersion, SchemaVersion)
|
||||||
}
|
}
|
||||||
if pkg.RunID != "20260529T100000Z_daily_today" {
|
if pkg.RunID != "20260529T100000Z_daily" {
|
||||||
t.Fatalf("RunID = %q, want metadata run id", pkg.RunID)
|
t.Fatalf("RunID = %q, want metadata run id", pkg.RunID)
|
||||||
}
|
}
|
||||||
if pkg.Report.PromptID != "weather.daily_report" {
|
if pkg.Report.PromptID != "weather.daily_generated_text" {
|
||||||
t.Fatalf("PromptID = %q, want weather.daily_report", pkg.Report.PromptID)
|
t.Fatalf("PromptID = %q, want weather.daily_generated_text", pkg.Report.PromptID)
|
||||||
}
|
}
|
||||||
if pkg.Report.CurrentLocalDate != "2026-05-29" {
|
if pkg.Report.CurrentLocalDate != "2026-05-29" {
|
||||||
t.Fatalf("CurrentLocalDate = %q, want 2026-05-29", pkg.Report.CurrentLocalDate)
|
t.Fatalf("CurrentLocalDate = %q, want 2026-05-29", pkg.Report.CurrentLocalDate)
|
||||||
@@ -107,6 +107,7 @@ func TestBuildUsesNamedSnapshotStanzas(t *testing.T) {
|
|||||||
req.Modules = snapshotWithOutputs(t,
|
req.Modules = snapshotWithOutputs(t,
|
||||||
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": req.Metadata.RunID}},
|
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": req.Metadata.RunID}},
|
||||||
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{"days": []string{"2026-05-29"}}},
|
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{"days": []string{"2026-05-29"}}},
|
||||||
|
module.Output{ID: module.TodayPlanning, StanzaName: "today_planning", Value: map[string]any{"morning_readiness": []string{"routine"}}},
|
||||||
)
|
)
|
||||||
|
|
||||||
pkg, err := Build(req)
|
pkg, err := Build(req)
|
||||||
@@ -120,6 +121,47 @@ func TestBuildUsesNamedSnapshotStanzas(t *testing.T) {
|
|||||||
if _, ok := pkg.Briefing.Values["derived_daypart_summaries"]; !ok {
|
if _, ok := pkg.Briefing.Values["derived_daypart_summaries"]; !ok {
|
||||||
t.Fatal("Briefing.Values[derived_daypart_summaries] missing")
|
t.Fatal("Briefing.Values[derived_daypart_summaries] missing")
|
||||||
}
|
}
|
||||||
|
if _, ok := pkg.Briefing.Values["today_planning"]; !ok {
|
||||||
|
t.Fatal("Briefing.Values[today_planning] missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildUsesPromptValuesFromSnapshot(t *testing.T) {
|
||||||
|
req := validBuildRequest(t)
|
||||||
|
req.Modules = snapshotWithOutputs(t,
|
||||||
|
module.Output{
|
||||||
|
ID: module.Metadata,
|
||||||
|
StanzaName: "metadata",
|
||||||
|
Value: map[string]string{"run_id": "rich"},
|
||||||
|
PromptValue: map[string]string{"run_id": "prompt"},
|
||||||
|
},
|
||||||
|
module.Output{
|
||||||
|
ID: module.CurrentConditions,
|
||||||
|
StanzaName: "current_conditions",
|
||||||
|
Value: map[string]string{"condition_text": "Rich conditions"},
|
||||||
|
PromptValue: map[string]string{"condition_text": "Prompt conditions"},
|
||||||
|
},
|
||||||
|
module.Output{
|
||||||
|
ID: module.AlertDigest,
|
||||||
|
StanzaName: "alert_digest",
|
||||||
|
Value: map[string]bool{"checked": true},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
pkg, err := Build(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Build() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := pkg.Briefing.Values["metadata"].(map[string]string)["run_id"]; got != "prompt" {
|
||||||
|
t.Fatalf("metadata.run_id = %q, want prompt value", got)
|
||||||
|
}
|
||||||
|
if got := pkg.Briefing.Values["current_conditions"].(map[string]string)["condition_text"]; got != "Prompt conditions" {
|
||||||
|
t.Fatalf("current_conditions.condition_text = %q, want prompt value", got)
|
||||||
|
}
|
||||||
|
if got := pkg.Briefing.Values["alert_digest"].(map[string]bool)["checked"]; !got {
|
||||||
|
t.Fatalf("alert_digest.checked = %v, want rich value fallback", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) {
|
func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) {
|
||||||
@@ -139,7 +181,7 @@ func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) {
|
|||||||
if string(first) != string(second) {
|
if string(first) != string(second) {
|
||||||
t.Fatalf("YAML output changed between marshals:\n%s\n---\n%s", string(first), string(second))
|
t.Fatalf("YAML output changed between marshals:\n%s\n---\n%s", string(first), string(second))
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(first), "schema_version: weatherreporter.data_package.v2") ||
|
if !strings.Contains(string(first), "schema_version: weatherreporter.data_package.v3") ||
|
||||||
!strings.Contains(string(first), "briefing:\n") ||
|
!strings.Contains(string(first), "briefing:\n") ||
|
||||||
!strings.Contains(string(first), " applicable_risk_products:\n") ||
|
!strings.Contains(string(first), " applicable_risk_products:\n") ||
|
||||||
!strings.Contains(string(first), " derived_summaries:\n") ||
|
!strings.Contains(string(first), " derived_summaries:\n") ||
|
||||||
@@ -164,6 +206,50 @@ func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMarshalYAMLPlacesSPCConvectiveStanzasInPromptCategories(t *testing.T) {
|
||||||
|
req := validBuildRequest(t)
|
||||||
|
req.Modules = snapshotWithOutputs(t,
|
||||||
|
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": req.Metadata.RunID}},
|
||||||
|
module.Output{ID: module.SPCConvectiveDiscussion, StanzaName: string(module.SPCConvectiveDiscussion), Value: map[string]any{"discussions": []string{"day1"}}},
|
||||||
|
module.Output{ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), Value: map[string]any{"outlook_count": 1}},
|
||||||
|
)
|
||||||
|
|
||||||
|
pkg, err := Build(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Build() error = %v", err)
|
||||||
|
}
|
||||||
|
data, err := MarshalYAML(pkg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarshalYAML() error = %v", err)
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
|
||||||
|
riskIndex := strings.Index(text, " applicable_risk_products:\n")
|
||||||
|
outlookIndex := strings.Index(text, " spc_convective_outlooks:\n")
|
||||||
|
narrativeIndex := strings.Index(text, " narrative_products:\n")
|
||||||
|
discussionIndex := strings.Index(text, " spc_convective_discussion:\n")
|
||||||
|
if riskIndex < 0 || outlookIndex < 0 || narrativeIndex < 0 || discussionIndex < 0 {
|
||||||
|
t.Fatalf("YAML output missing SPC convective category placement:\n%s", text)
|
||||||
|
}
|
||||||
|
if !(riskIndex < outlookIndex && outlookIndex < narrativeIndex && narrativeIndex < discussionIndex) {
|
||||||
|
t.Fatalf("YAML output placed SPC convective stanzas in wrong order:\n%s", text)
|
||||||
|
}
|
||||||
|
if strings.Contains(text, " raw_data:\n spc_convective") || strings.Contains(text, " derived_summaries:\n spc_convective") {
|
||||||
|
t.Fatalf("YAML output placed SPC convective stanzas in wrong category:\n%s", text)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := LoadYAML(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadYAML() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := loaded.Briefing.Values[string(module.SPCConvectiveOutlooks)]; !ok {
|
||||||
|
t.Fatal("loaded package missing spc_convective_outlooks stanza")
|
||||||
|
}
|
||||||
|
if _, ok := loaded.Briefing.Values[string(module.SPCConvectiveDiscussion)]; !ok {
|
||||||
|
t.Fatal("loaded package missing spc_convective_discussion stanza")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadYAMLRoundTrip(t *testing.T) {
|
func TestLoadYAMLRoundTrip(t *testing.T) {
|
||||||
pkg, err := Build(validBuildRequest(t))
|
pkg, err := Build(validBuildRequest(t))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -203,11 +289,11 @@ func TestMarshalYAMLRejectsUncategorizedStanza(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoadYAMLRejectsMisplacedStanza(t *testing.T) {
|
func TestLoadYAMLRejectsMisplacedStanza(t *testing.T) {
|
||||||
data := []byte(`
|
data := []byte(`
|
||||||
schema_version: weatherreporter.data_package.v2
|
schema_version: weatherreporter.data_package.v3
|
||||||
run_id: 20260529T100000Z_daily_today
|
run_id: 20260529T100000Z_daily
|
||||||
report:
|
report:
|
||||||
id: daily_today
|
id: daily
|
||||||
prompt_id: weather.daily_report
|
prompt_id: weather.daily_generated_text
|
||||||
generated_at: 2026-05-29T10:00:00Z
|
generated_at: 2026-05-29T10:00:00Z
|
||||||
timezone: America/Chicago
|
timezone: America/Chicago
|
||||||
current_local_date: "2026-05-29"
|
current_local_date: "2026-05-29"
|
||||||
@@ -216,7 +302,7 @@ report:
|
|||||||
end: 2026-05-30T05:00:00Z
|
end: 2026-05-30T05:00:00Z
|
||||||
briefing:
|
briefing:
|
||||||
metadata:
|
metadata:
|
||||||
run_id: 20260529T100000Z_daily_today
|
run_id: 20260529T100000Z_daily
|
||||||
raw_data:
|
raw_data:
|
||||||
alert_digest:
|
alert_digest:
|
||||||
checked: true
|
checked: true
|
||||||
@@ -230,15 +316,32 @@ recent_changes:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadYAMLRejectsOldSchemaVersion(t *testing.T) {
|
||||||
|
pkg, err := Build(validBuildRequest(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Build() error = %v", err)
|
||||||
|
}
|
||||||
|
data, err := MarshalYAML(pkg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarshalYAML() error = %v", err)
|
||||||
|
}
|
||||||
|
data = []byte(strings.Replace(string(data), "weatherreporter.data_package.v3", "weatherreporter.data_package.v2", 1))
|
||||||
|
|
||||||
|
_, err = LoadYAML(data)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "schemaVersion must be weatherreporter.data_package.v3") {
|
||||||
|
t.Fatalf("LoadYAML() error = %v, want current schema version error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func validBuildRequest(t *testing.T) BuildRequest {
|
func validBuildRequest(t *testing.T) BuildRequest {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
generatedAt := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC)
|
generatedAt := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC)
|
||||||
return BuildRequest{
|
return BuildRequest{
|
||||||
Metadata: Metadata{
|
Metadata: Metadata{
|
||||||
RunID: "20260529T100000Z_daily_today",
|
RunID: "20260529T100000Z_daily",
|
||||||
ReportID: report.DailyToday,
|
ReportID: report.Daily,
|
||||||
Variant: "today",
|
Variant: "today",
|
||||||
PromptID: "weather.daily_report",
|
PromptID: "weather.daily_generated_text",
|
||||||
GeneratedAt: generatedAt,
|
GeneratedAt: generatedAt,
|
||||||
Timezone: "America/Chicago",
|
Timezone: "America/Chicago",
|
||||||
ValidPeriod: timeutil.Period{
|
ValidPeriod: timeutil.Period{
|
||||||
@@ -247,7 +350,7 @@ func validBuildRequest(t *testing.T) BuildRequest {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
Modules: snapshotWithOutputs(t,
|
Modules: snapshotWithOutputs(t,
|
||||||
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": "20260529T100000Z_daily_today"}},
|
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": "20260529T100000Z_daily"}},
|
||||||
module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: map[string]string{"condition_text": "Partly cloudy"}},
|
module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: map[string]string{"condition_text": "Partly cloudy"}},
|
||||||
module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]string{"date": "2026-05-29"}},
|
module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]string{"date": "2026-05-29"}},
|
||||||
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: map[string]bool{"checked": true}},
|
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: map[string]bool{"checked": true}},
|
||||||
|
|||||||
@@ -1,43 +1,31 @@
|
|||||||
package report
|
package report
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
func dailyTodayDefinition() Definition {
|
func dailyDefinition() Definition {
|
||||||
return Definition{
|
return Definition{
|
||||||
ID: DailyToday,
|
ID: Daily,
|
||||||
Name: "Daily Report",
|
Name: "Daily Report",
|
||||||
PromptID: "weather.daily_report",
|
PromptID: "weather.daily_generated_text",
|
||||||
|
GenerationMode: GenerationModeGeneratedTextTemplate,
|
||||||
|
TemplateID: "daily",
|
||||||
|
GeneratedTextSchemaID: "daily",
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
ArtifactGroup: "daily",
|
ArtifactGroup: "daily",
|
||||||
BatchOutputName: "daily.md",
|
BatchOutputName: "daily.md",
|
||||||
Generated: true,
|
Generated: true,
|
||||||
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
CompatiblePriorIDs: []ID{Daily},
|
||||||
Modules: dailyTodayModules(),
|
Modules: dailyModules(),
|
||||||
Morning: true,
|
resolve: resolveDaily,
|
||||||
resolve: resolveDailyToday,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func dailyTomorrowDefinition() Definition {
|
func dailyModules() []module.ConfigItem {
|
||||||
return Definition{
|
|
||||||
ID: DailyTomorrow,
|
|
||||||
Name: "Tomorrow Planning Brief",
|
|
||||||
PromptID: "weather.daily_report",
|
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
|
||||||
ArtifactGroup: "daily",
|
|
||||||
BatchOutputName: "tomorrow.md",
|
|
||||||
Generated: true,
|
|
||||||
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
|
||||||
Modules: dailyTomorrowModules(),
|
|
||||||
Evening: true,
|
|
||||||
resolve: resolveDailyTomorrow,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func dailyTodayModules() []module.ConfigItem {
|
|
||||||
return moduleItems(
|
return moduleItems(
|
||||||
module.Metadata,
|
module.Metadata,
|
||||||
module.CurrentConditions,
|
module.CurrentConditions,
|
||||||
@@ -46,37 +34,19 @@ func dailyTodayModules() []module.ConfigItem {
|
|||||||
module.DerivedDaypartSummaries,
|
module.DerivedDaypartSummaries,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
|
module.DailyPlanning,
|
||||||
module.HourlyForecast,
|
module.HourlyForecast,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func dailyTomorrowModules() []module.ConfigItem {
|
func resolveDaily(req ResolveRequest) (timeutil.Period, error) {
|
||||||
return moduleItems(
|
if req.Date.IsZero() {
|
||||||
module.Metadata,
|
return timeutil.Period{}, fmt.Errorf("daily report requires an explicit date")
|
||||||
module.CurrentConditions,
|
|
||||||
module.NarrativeForecast,
|
|
||||||
module.DerivedDailySummary,
|
|
||||||
module.DerivedDaypartSummaries,
|
|
||||||
module.PrecipTiming,
|
|
||||||
module.AlertDigest,
|
|
||||||
module.AreaForecastDiscussion,
|
|
||||||
module.WeatherStory,
|
|
||||||
module.OutdoorWindows,
|
|
||||||
module.TomorrowPlanning,
|
|
||||||
module.HourlyForecast,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveDailyToday(req ResolveRequest) (timeutil.Period, error) {
|
|
||||||
if !req.Date.IsZero() {
|
|
||||||
return timeutil.CivilDay(req.Date, req.Location), nil
|
return timeutil.CivilDay(req.Date, req.Location), nil
|
||||||
}
|
}
|
||||||
return timeutil.CivilDay(req.Now, req.Location), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveDailyTomorrow(req ResolveRequest) (timeutil.Period, error) {
|
|
||||||
return timeutil.CivilDay(req.Now.In(req.Location).AddDate(0, 0, 1), req.Location), nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import (
|
|||||||
type ID string
|
type ID string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DailyToday ID = "daily_today"
|
Daily ID = "daily"
|
||||||
DailyTomorrow ID = "daily_tomorrow"
|
Today ID = "today"
|
||||||
|
Tomorrow ID = "tomorrow"
|
||||||
|
Hourly ID = "hourly"
|
||||||
ThreeDay ID = "three_day"
|
ThreeDay ID = "three_day"
|
||||||
Weekend ID = "weekend"
|
Weekend ID = "weekend"
|
||||||
Storm ID = "storm"
|
Storm ID = "storm"
|
||||||
@@ -25,6 +27,14 @@ const (
|
|||||||
CompareSameValidDate ComparisonStrategy = "same_valid_date"
|
CompareSameValidDate ComparisonStrategy = "same_valid_date"
|
||||||
CompareWeekendWindow ComparisonStrategy = "same_weekend_window"
|
CompareWeekendWindow ComparisonStrategy = "same_weekend_window"
|
||||||
CompareExplicitWindow ComparisonStrategy = "explicit_event_window"
|
CompareExplicitWindow ComparisonStrategy = "explicit_event_window"
|
||||||
|
CompareRollingWindow ComparisonStrategy = "rolling_window"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GenerationMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
GenerationModeScriptoriumMarkdown GenerationMode = "scriptorium_markdown"
|
||||||
|
GenerationModeGeneratedTextTemplate GenerationMode = "generated_text_template"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Batch string
|
type Batch string
|
||||||
@@ -38,6 +48,9 @@ type Definition struct {
|
|||||||
ID ID
|
ID ID
|
||||||
Name string
|
Name string
|
||||||
PromptID string
|
PromptID string
|
||||||
|
GenerationMode GenerationMode
|
||||||
|
TemplateID string
|
||||||
|
GeneratedTextSchemaID string
|
||||||
ComparisonStrategy ComparisonStrategy
|
ComparisonStrategy ComparisonStrategy
|
||||||
ArtifactGroup string
|
ArtifactGroup string
|
||||||
BatchOutputName string
|
BatchOutputName string
|
||||||
|
|||||||
55
internal/report/hourly_report.go
Normal file
55
internal/report/hourly_report.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package report
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
const hourlyReportHours = 6
|
||||||
|
|
||||||
|
func hourlyDefinition() Definition {
|
||||||
|
return Definition{
|
||||||
|
ID: Hourly,
|
||||||
|
Name: "Hourly Report",
|
||||||
|
PromptID: "weather.hourly_generated_text",
|
||||||
|
GenerationMode: GenerationModeGeneratedTextTemplate,
|
||||||
|
TemplateID: "hourly",
|
||||||
|
GeneratedTextSchemaID: "hourly",
|
||||||
|
ComparisonStrategy: CompareRollingWindow,
|
||||||
|
ArtifactGroup: "hourly",
|
||||||
|
BatchOutputName: "hourly.md",
|
||||||
|
Generated: true,
|
||||||
|
CompatiblePriorIDs: []ID{Hourly},
|
||||||
|
Modules: hourlyModules(),
|
||||||
|
resolve: resolveHourly,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hourlyModules() []module.ConfigItem {
|
||||||
|
return []module.ConfigItem{
|
||||||
|
{ID: module.Metadata},
|
||||||
|
{ID: module.CurrentConditions},
|
||||||
|
{ID: module.HourlyForecast},
|
||||||
|
{ID: module.PrecipTiming},
|
||||||
|
{ID: module.AlertDigest},
|
||||||
|
{ID: module.SPCConvectiveOutlooks},
|
||||||
|
{
|
||||||
|
ID: module.AreaForecastDiscussion,
|
||||||
|
Options: module.AreaForecastDiscussionOptions{
|
||||||
|
Sections: []string{"key_messages", "short_term"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ID: module.SPCConvectiveDiscussion},
|
||||||
|
{ID: module.WeatherStory},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveHourly(req ResolveRequest) (timeutil.Period, error) {
|
||||||
|
localNow := req.Now.In(req.Location)
|
||||||
|
return timeutil.Period{
|
||||||
|
Start: localNow,
|
||||||
|
End: localNow.Add(hourlyReportHours * time.Hour),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
92
internal/report/names.go
Normal file
92
internal/report/names.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package report
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
CommandNameDaily = "daily"
|
||||||
|
CommandNameToday = "today"
|
||||||
|
CommandNameTomorrow = "tomorrow"
|
||||||
|
CommandNameHourly = "hourly"
|
||||||
|
CommandNameThreeDay = "three-day"
|
||||||
|
CommandNameWeekend = "weekend"
|
||||||
|
CommandNameStorm = "storm"
|
||||||
|
|
||||||
|
BatchNameMorning = "morning"
|
||||||
|
BatchNameEvening = "evening"
|
||||||
|
)
|
||||||
|
|
||||||
|
func IDForCommandName(name string) (ID, error) {
|
||||||
|
switch name {
|
||||||
|
case CommandNameDaily:
|
||||||
|
return Daily, nil
|
||||||
|
case CommandNameToday:
|
||||||
|
return Today, nil
|
||||||
|
case CommandNameTomorrow:
|
||||||
|
return Tomorrow, nil
|
||||||
|
case CommandNameHourly:
|
||||||
|
return Hourly, nil
|
||||||
|
case CommandNameThreeDay:
|
||||||
|
return ThreeDay, nil
|
||||||
|
case CommandNameWeekend:
|
||||||
|
return Weekend, nil
|
||||||
|
case CommandNameStorm:
|
||||||
|
return Storm, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unknown report command %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CommandNames() []string {
|
||||||
|
return []string{
|
||||||
|
CommandNameDaily,
|
||||||
|
CommandNameToday,
|
||||||
|
CommandNameTomorrow,
|
||||||
|
CommandNameHourly,
|
||||||
|
CommandNameThreeDay,
|
||||||
|
CommandNameWeekend,
|
||||||
|
CommandNameStorm,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func IDForConfigKey(key string) (ID, error) {
|
||||||
|
normalized := strings.ReplaceAll(strings.TrimSpace(strings.ToLower(key)), "-", "_")
|
||||||
|
switch normalized {
|
||||||
|
case "daily":
|
||||||
|
return Daily, nil
|
||||||
|
case "today":
|
||||||
|
return Today, nil
|
||||||
|
case "tomorrow":
|
||||||
|
return Tomorrow, nil
|
||||||
|
case "hourly":
|
||||||
|
return Hourly, nil
|
||||||
|
case "three_day", "three_day_outlook":
|
||||||
|
return ThreeDay, nil
|
||||||
|
case "weekend", "weekend_outlook":
|
||||||
|
return Weekend, nil
|
||||||
|
case "storm", "storm_report":
|
||||||
|
return Storm, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("report config key %q is not a known report", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BatchForCommandName(name string) (Batch, error) {
|
||||||
|
switch name {
|
||||||
|
case BatchNameMorning:
|
||||||
|
return Morning, nil
|
||||||
|
case BatchNameEvening:
|
||||||
|
return Evening, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unknown batch command %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BatchCommandNames() []string {
|
||||||
|
return []string{
|
||||||
|
BatchNameMorning,
|
||||||
|
BatchNameEvening,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,13 +26,13 @@ func (r Registry) BatchReports(batch Batch, req ResolveRequest) ([]Resolved, err
|
|||||||
}
|
}
|
||||||
switch batch {
|
switch batch {
|
||||||
case Morning:
|
case Morning:
|
||||||
ids := []ID{DailyToday, ThreeDay}
|
ids := []ID{Today, ThreeDay}
|
||||||
if req.Now.In(req.Location).Weekday() != time.Sunday {
|
if req.Now.In(req.Location).Weekday() != time.Sunday {
|
||||||
ids = append(ids, Weekend)
|
ids = append(ids, Weekend)
|
||||||
}
|
}
|
||||||
return r.resolveIDs(ids, req)
|
return r.resolveIDs(ids, req)
|
||||||
case Evening:
|
case Evening:
|
||||||
return r.resolveIDs([]ID{DailyTomorrow}, req)
|
return r.resolveIDs([]ID{Tomorrow}, req)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown batch %q", batch)
|
return nil, fmt.Errorf("unknown batch %q", batch)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,11 +14,14 @@ func TestDailyValidPeriod(t *testing.T) {
|
|||||||
location := mustLoadLocation(t)
|
location := mustLoadLocation(t)
|
||||||
now := mustParse("2026-05-29T17:45:00-05:00")
|
now := mustParse("2026-05-29T17:45:00-05:00")
|
||||||
|
|
||||||
resolved, err := Resolve(DailyToday, ResolveRequest{Now: now, Location: location})
|
resolved, err := Resolve(Daily, ResolveRequest{Now: now, Location: location})
|
||||||
if err != nil {
|
if err == nil {
|
||||||
t.Fatalf("Resolve() error = %v", err)
|
t.Fatal("Resolve() error = nil, want explicit date requirement")
|
||||||
}
|
}
|
||||||
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T00:00:00-05:00", "2026-05-30T00:00:00-05:00")
|
if !strings.Contains(err.Error(), "requires an explicit date") {
|
||||||
|
t.Fatalf("Resolve() error = %v, want explicit date requirement", err)
|
||||||
|
}
|
||||||
|
_ = resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDailyValidPeriodCanUseExplicitDate(t *testing.T) {
|
func TestDailyValidPeriodCanUseExplicitDate(t *testing.T) {
|
||||||
@@ -26,7 +29,54 @@ func TestDailyValidPeriodCanUseExplicitDate(t *testing.T) {
|
|||||||
now := mustParse("2026-05-29T17:45:00-05:00")
|
now := mustParse("2026-05-29T17:45:00-05:00")
|
||||||
date := mustParse("2026-05-31T12:00:00-05:00")
|
date := mustParse("2026-05-31T12:00:00-05:00")
|
||||||
|
|
||||||
resolved, err := Resolve(DailyToday, ResolveRequest{Now: now, Location: location, Date: date})
|
resolved, err := Resolve(Daily, ResolveRequest{Now: now, Location: location, Date: date})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
assertPeriod(t, resolved.ValidPeriod, "2026-05-31T00:00:00-05:00", "2026-06-01T00:00:00-05:00")
|
||||||
|
if resolved.Definition.PromptID != "weather.daily_generated_text" {
|
||||||
|
t.Fatalf("PromptID = %q, want weather.daily_generated_text", resolved.Definition.PromptID)
|
||||||
|
}
|
||||||
|
if resolved.Definition.GenerationMode != GenerationModeGeneratedTextTemplate {
|
||||||
|
t.Fatalf("GenerationMode = %q, want generated_text_template", resolved.Definition.GenerationMode)
|
||||||
|
}
|
||||||
|
if resolved.Definition.TemplateID != "daily" {
|
||||||
|
t.Fatalf("TemplateID = %q, want daily", resolved.Definition.TemplateID)
|
||||||
|
}
|
||||||
|
if resolved.Definition.GeneratedTextSchemaID != "daily" {
|
||||||
|
t.Fatalf("GeneratedTextSchemaID = %q, want daily", resolved.Definition.GeneratedTextSchemaID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTodayValidPeriod(t *testing.T) {
|
||||||
|
location := mustLoadLocation(t)
|
||||||
|
now := mustParse("2026-05-29T17:45:00-05:00")
|
||||||
|
|
||||||
|
resolved, err := Resolve(Today, ResolveRequest{Now: now, Location: location})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T00:00:00-05:00", "2026-05-30T00:00:00-05:00")
|
||||||
|
if resolved.Definition.PromptID != "weather.today_generated_text" {
|
||||||
|
t.Fatalf("PromptID = %q, want weather.today_generated_text", resolved.Definition.PromptID)
|
||||||
|
}
|
||||||
|
if resolved.Definition.GenerationMode != GenerationModeGeneratedTextTemplate {
|
||||||
|
t.Fatalf("GenerationMode = %q, want generated_text_template", resolved.Definition.GenerationMode)
|
||||||
|
}
|
||||||
|
if resolved.Definition.TemplateID != "today" {
|
||||||
|
t.Fatalf("TemplateID = %q, want today", resolved.Definition.TemplateID)
|
||||||
|
}
|
||||||
|
if resolved.Definition.GeneratedTextSchemaID != "today" {
|
||||||
|
t.Fatalf("GeneratedTextSchemaID = %q, want today", resolved.Definition.GeneratedTextSchemaID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTodayValidPeriodCanUseExplicitDate(t *testing.T) {
|
||||||
|
location := mustLoadLocation(t)
|
||||||
|
now := mustParse("2026-05-29T17:45:00-05:00")
|
||||||
|
date := mustParse("2026-05-31T12:00:00-05:00")
|
||||||
|
|
||||||
|
resolved, err := Resolve(Today, ResolveRequest{Now: now, Location: location, Date: date})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Resolve() error = %v", err)
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -37,13 +87,22 @@ func TestTomorrowValidPeriodFromEveningGeneration(t *testing.T) {
|
|||||||
location := mustLoadLocation(t)
|
location := mustLoadLocation(t)
|
||||||
now := mustParse("2026-05-29T20:00:00-05:00")
|
now := mustParse("2026-05-29T20:00:00-05:00")
|
||||||
|
|
||||||
resolved, err := Resolve(DailyTomorrow, ResolveRequest{Now: now, Location: location})
|
resolved, err := Resolve(Tomorrow, ResolveRequest{Now: now, Location: location})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Resolve() error = %v", err)
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
}
|
}
|
||||||
assertPeriod(t, resolved.ValidPeriod, "2026-05-30T00:00:00-05:00", "2026-05-31T00:00:00-05:00")
|
assertPeriod(t, resolved.ValidPeriod, "2026-05-30T00:00:00-05:00", "2026-05-31T00:00:00-05:00")
|
||||||
if resolved.Definition.PromptID != "weather.daily_report" {
|
if resolved.Definition.PromptID != "weather.tomorrow_generated_text" {
|
||||||
t.Fatalf("PromptID = %q, want weather.daily_report", resolved.Definition.PromptID)
|
t.Fatalf("PromptID = %q, want weather.tomorrow_generated_text", resolved.Definition.PromptID)
|
||||||
|
}
|
||||||
|
if resolved.Definition.GenerationMode != GenerationModeGeneratedTextTemplate {
|
||||||
|
t.Fatalf("GenerationMode = %q, want generated_text_template", resolved.Definition.GenerationMode)
|
||||||
|
}
|
||||||
|
if resolved.Definition.TemplateID != "tomorrow" {
|
||||||
|
t.Fatalf("TemplateID = %q, want tomorrow", resolved.Definition.TemplateID)
|
||||||
|
}
|
||||||
|
if resolved.Definition.GeneratedTextSchemaID != "tomorrow" {
|
||||||
|
t.Fatalf("GeneratedTextSchemaID = %q, want tomorrow", resolved.Definition.GeneratedTextSchemaID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +117,60 @@ func TestThreeDayPeriodCalculation(t *testing.T) {
|
|||||||
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:00:00-05:00", "2026-06-01T00:00:00-05:00")
|
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:00:00-05:00", "2026-06-01T00:00:00-05:00")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHourlyLookupAndPeriodCalculation(t *testing.T) {
|
||||||
|
location := mustLoadLocation(t)
|
||||||
|
now := mustParse("2026-05-29T05:15:00-05:00")
|
||||||
|
|
||||||
|
resolved, err := Resolve(Hourly, ResolveRequest{Now: now, Location: location})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
if resolved.Definition.ID != Hourly {
|
||||||
|
t.Fatalf("ID = %q, want hourly", resolved.Definition.ID)
|
||||||
|
}
|
||||||
|
if resolved.Definition.PromptID != "weather.hourly_generated_text" {
|
||||||
|
t.Fatalf("PromptID = %q, want weather.hourly_generated_text", resolved.Definition.PromptID)
|
||||||
|
}
|
||||||
|
if resolved.Definition.GenerationMode != GenerationModeGeneratedTextTemplate {
|
||||||
|
t.Fatalf("GenerationMode = %q, want generated_text_template", resolved.Definition.GenerationMode)
|
||||||
|
}
|
||||||
|
if resolved.Definition.TemplateID != "hourly" {
|
||||||
|
t.Fatalf("TemplateID = %q, want hourly", resolved.Definition.TemplateID)
|
||||||
|
}
|
||||||
|
if resolved.Definition.GeneratedTextSchemaID != "hourly" {
|
||||||
|
t.Fatalf("GeneratedTextSchemaID = %q, want hourly", resolved.Definition.GeneratedTextSchemaID)
|
||||||
|
}
|
||||||
|
if resolved.Definition.ComparisonStrategy != CompareRollingWindow {
|
||||||
|
t.Fatalf("ComparisonStrategy = %q, want rolling_window", resolved.Definition.ComparisonStrategy)
|
||||||
|
}
|
||||||
|
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:15:00-05:00", "2026-05-29T11:15:00-05:00")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHourlyPeriodUsesEffectiveTimezone(t *testing.T) {
|
||||||
|
location, err := time.LoadLocation("America/New_York")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load location: %v", err)
|
||||||
|
}
|
||||||
|
now := mustParse("2026-05-29T10:15:00Z")
|
||||||
|
|
||||||
|
resolved, err := Resolve(Hourly, ResolveRequest{Now: now, Location: location})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T06:15:00-04:00", "2026-05-29T12:15:00-04:00")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHourlyPeriodIsNotCivilDayTruncated(t *testing.T) {
|
||||||
|
location := mustLoadLocation(t)
|
||||||
|
now := mustParse("2026-05-29T22:30:00-05:00")
|
||||||
|
|
||||||
|
resolved, err := Resolve(Hourly, ResolveRequest{Now: now, Location: location})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T22:30:00-05:00", "2026-05-30T04:30:00-05:00")
|
||||||
|
}
|
||||||
|
|
||||||
func TestWeekendPeriodCalculation(t *testing.T) {
|
func TestWeekendPeriodCalculation(t *testing.T) {
|
||||||
location := mustLoadLocation(t)
|
location := mustLoadLocation(t)
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -138,8 +251,8 @@ func TestMorningBatchSkipsWeekendOnSunday(t *testing.T) {
|
|||||||
t.Fatalf("BatchReports() error = %v", err)
|
t.Fatalf("BatchReports() error = %v", err)
|
||||||
}
|
}
|
||||||
ids := resolvedIDs(resolved)
|
ids := resolvedIDs(resolved)
|
||||||
if strings.Join(ids, ",") != "daily_today,three_day" {
|
if strings.Join(ids, ",") != "today,three_day" {
|
||||||
t.Fatalf("ids = %v, want daily_today and three_day", ids)
|
t.Fatalf("ids = %v, want today and three_day", ids)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,8 +266,161 @@ func TestEveningBatchIncludesTomorrow(t *testing.T) {
|
|||||||
t.Fatalf("BatchReports() error = %v", err)
|
t.Fatalf("BatchReports() error = %v", err)
|
||||||
}
|
}
|
||||||
ids := resolvedIDs(resolved)
|
ids := resolvedIDs(resolved)
|
||||||
if strings.Join(ids, ",") != "daily_tomorrow" {
|
if strings.Join(ids, ",") != "tomorrow" {
|
||||||
t.Fatalf("ids = %v, want daily_tomorrow", ids)
|
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) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
want ID
|
||||||
|
}{
|
||||||
|
{name: "daily", want: Daily},
|
||||||
|
{name: "today", want: Today},
|
||||||
|
{name: "tomorrow", want: Tomorrow},
|
||||||
|
{name: "hourly", want: Hourly},
|
||||||
|
{name: "three-day", want: ThreeDay},
|
||||||
|
{name: "weekend", want: Weekend},
|
||||||
|
{name: "storm", want: Storm},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := IDForCommandName(tt.name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IDForCommandName() error = %v", err)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("IDForCommandName() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if names := strings.Join(CommandNames(), ","); names != "daily,today,tomorrow,hourly,three-day,weekend,storm" {
|
||||||
|
t.Fatalf("CommandNames() = %s, want stable command names", names)
|
||||||
|
}
|
||||||
|
dailyID, err := IDForCommandName("daily")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IDForCommandName(daily) error = %v", err)
|
||||||
|
}
|
||||||
|
todayID, err := IDForCommandName("today")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IDForCommandName(today) error = %v", err)
|
||||||
|
}
|
||||||
|
if dailyID == todayID {
|
||||||
|
t.Fatalf("daily and today both resolve to %q, want distinct report IDs", dailyID)
|
||||||
|
}
|
||||||
|
if _, err := IDForCommandName("near-term"); err == nil || !strings.Contains(err.Error(), `unknown report command "near-term"`) {
|
||||||
|
t.Fatalf("IDForCommandName(near-term) error = %v, want unknown command", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIDForConfigKey(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
key string
|
||||||
|
want ID
|
||||||
|
}{
|
||||||
|
{key: "daily", want: Daily},
|
||||||
|
{key: "today", want: Today},
|
||||||
|
{key: "tomorrow", want: Tomorrow},
|
||||||
|
{key: "hourly", want: Hourly},
|
||||||
|
{key: "three_day", want: ThreeDay},
|
||||||
|
{key: "three-day", want: ThreeDay},
|
||||||
|
{key: "three_day_outlook", want: ThreeDay},
|
||||||
|
{key: "three-day-outlook", want: ThreeDay},
|
||||||
|
{key: "weekend", want: Weekend},
|
||||||
|
{key: "weekend_outlook", want: Weekend},
|
||||||
|
{key: "storm", want: Storm},
|
||||||
|
{key: "storm_report", want: Storm},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.key, func(t *testing.T) {
|
||||||
|
got, err := IDForConfigKey(tt.key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IDForConfigKey() error = %v", err)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("IDForConfigKey() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if _, err := IDForConfigKey("daily_tomorrow"); err == nil || !strings.Contains(err.Error(), `report config key "daily_tomorrow" is not a known report`) {
|
||||||
|
t.Fatalf("IDForConfigKey(daily_tomorrow) error = %v, want unknown key", err)
|
||||||
|
}
|
||||||
|
retiredDailyKey := strings.Join([]string{"daily", "today"}, "_")
|
||||||
|
if _, err := IDForConfigKey(retiredDailyKey); err == nil || !strings.Contains(err.Error(), `report config key "`+retiredDailyKey+`" is not a known report`) {
|
||||||
|
t.Fatalf("IDForConfigKey(%s) error = %v, want unknown key", retiredDailyKey, err)
|
||||||
|
}
|
||||||
|
dailyID, err := IDForConfigKey("daily")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IDForConfigKey(daily) error = %v", err)
|
||||||
|
}
|
||||||
|
todayID, err := IDForConfigKey("today")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IDForConfigKey(today) error = %v", err)
|
||||||
|
}
|
||||||
|
if dailyID == todayID {
|
||||||
|
t.Fatalf("daily and today config keys both resolve to %q, want distinct report IDs", dailyID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchForCommandName(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
want Batch
|
||||||
|
}{
|
||||||
|
{name: "morning", want: Morning},
|
||||||
|
{name: "evening", want: Evening},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := BatchForCommandName(tt.name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BatchForCommandName() error = %v", err)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("BatchForCommandName() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if names := strings.Join(BatchCommandNames(), ","); names != "morning,evening" {
|
||||||
|
t.Fatalf("BatchCommandNames() = %s, want stable batch command names", names)
|
||||||
|
}
|
||||||
|
if _, err := BatchForCommandName("hourly"); err == nil || !strings.Contains(err.Error(), `unknown batch command "hourly"`) {
|
||||||
|
t.Fatalf("BatchForCommandName(hourly) error = %v, want unknown batch", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,17 +434,58 @@ func TestRegistryLookupErrorIsActionable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRegistryAllIncludesHourlyInStableOrder(t *testing.T) {
|
||||||
|
ids := resolvedDefinitionIDs(DefaultRegistry().All())
|
||||||
|
want := []string{"daily", "today", "tomorrow", "hourly", "three_day", "weekend", "storm"}
|
||||||
|
if strings.Join(ids, ",") != strings.Join(want, ",") {
|
||||||
|
t.Fatalf("All() ids = %#v, want %#v", ids, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRegistryDefinitionsHavePromptIDsAndComparisonStrategies(t *testing.T) {
|
func TestRegistryDefinitionsHavePromptIDsAndComparisonStrategies(t *testing.T) {
|
||||||
for _, definition := range DefaultRegistry().All() {
|
for _, definition := range DefaultRegistry().All() {
|
||||||
if definition.PromptID == "" {
|
if definition.PromptID == "" {
|
||||||
t.Fatalf("%s PromptID is empty", definition.ID)
|
t.Fatalf("%s PromptID is empty", definition.ID)
|
||||||
}
|
}
|
||||||
|
if definition.Generated && definition.GenerationMode == "" {
|
||||||
|
t.Fatalf("%s GenerationMode is empty", definition.ID)
|
||||||
|
}
|
||||||
if definition.ComparisonStrategy == "" {
|
if definition.ComparisonStrategy == "" {
|
||||||
t.Fatalf("%s ComparisonStrategy is empty", definition.ID)
|
t.Fatalf("%s ComparisonStrategy is empty", definition.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRegistryDefinitionsDeclareGenerationMetadata(t *testing.T) {
|
||||||
|
for _, definition := range DefaultRegistry().All() {
|
||||||
|
if !definition.Generated {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if definition.ID == Hourly || definition.ID == Daily || definition.ID == Today || definition.ID == Tomorrow {
|
||||||
|
wantTemplate := string(definition.ID)
|
||||||
|
if definition.GenerationMode != GenerationModeGeneratedTextTemplate {
|
||||||
|
t.Fatalf("%s GenerationMode = %q, want %q", definition.ID, definition.GenerationMode, GenerationModeGeneratedTextTemplate)
|
||||||
|
}
|
||||||
|
if definition.TemplateID != wantTemplate {
|
||||||
|
t.Fatalf("%s TemplateID = %q, want %s", definition.ID, definition.TemplateID, wantTemplate)
|
||||||
|
}
|
||||||
|
if definition.GeneratedTextSchemaID != wantTemplate {
|
||||||
|
t.Fatalf("%s GeneratedTextSchemaID = %q, want %s", definition.ID, definition.GeneratedTextSchemaID, wantTemplate)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if definition.GenerationMode != GenerationModeScriptoriumMarkdown {
|
||||||
|
t.Fatalf("%s GenerationMode = %q, want %q", definition.ID, definition.GenerationMode, GenerationModeScriptoriumMarkdown)
|
||||||
|
}
|
||||||
|
if definition.TemplateID != "" {
|
||||||
|
t.Fatalf("%s TemplateID = %q, want empty for Markdown report", definition.ID, definition.TemplateID)
|
||||||
|
}
|
||||||
|
if definition.GeneratedTextSchemaID != "" {
|
||||||
|
t.Fatalf("%s GeneratedTextSchemaID = %q, want empty for Markdown report", definition.ID, definition.GeneratedTextSchemaID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
id ID
|
id ID
|
||||||
@@ -186,20 +493,39 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
|||||||
batchOutputName string
|
batchOutputName string
|
||||||
generated bool
|
generated bool
|
||||||
compatiblePriorIDs []ID
|
compatiblePriorIDs []ID
|
||||||
|
comparisonStrategy ComparisonStrategy
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
id: DailyToday,
|
id: Daily,
|
||||||
artifactGroup: "daily",
|
artifactGroup: "daily",
|
||||||
batchOutputName: "daily.md",
|
batchOutputName: "daily.md",
|
||||||
generated: true,
|
generated: true,
|
||||||
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
compatiblePriorIDs: []ID{Daily},
|
||||||
|
comparisonStrategy: CompareSameValidDate,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: DailyTomorrow,
|
id: Today,
|
||||||
artifactGroup: "daily",
|
artifactGroup: "today",
|
||||||
|
batchOutputName: "today.md",
|
||||||
|
generated: true,
|
||||||
|
compatiblePriorIDs: []ID{Today},
|
||||||
|
comparisonStrategy: CompareSameValidDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: Tomorrow,
|
||||||
|
artifactGroup: "tomorrow",
|
||||||
batchOutputName: "tomorrow.md",
|
batchOutputName: "tomorrow.md",
|
||||||
generated: true,
|
generated: true,
|
||||||
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
compatiblePriorIDs: []ID{Tomorrow},
|
||||||
|
comparisonStrategy: CompareSameValidDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: Hourly,
|
||||||
|
artifactGroup: "hourly",
|
||||||
|
batchOutputName: "hourly.md",
|
||||||
|
generated: true,
|
||||||
|
compatiblePriorIDs: []ID{Hourly},
|
||||||
|
comparisonStrategy: CompareRollingWindow,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: ThreeDay,
|
id: ThreeDay,
|
||||||
@@ -207,6 +533,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
|||||||
batchOutputName: "three-day.md",
|
batchOutputName: "three-day.md",
|
||||||
generated: true,
|
generated: true,
|
||||||
compatiblePriorIDs: []ID{ThreeDay},
|
compatiblePriorIDs: []ID{ThreeDay},
|
||||||
|
comparisonStrategy: CompareSameValidDate,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: Weekend,
|
id: Weekend,
|
||||||
@@ -214,6 +541,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
|||||||
batchOutputName: "weekend.md",
|
batchOutputName: "weekend.md",
|
||||||
generated: true,
|
generated: true,
|
||||||
compatiblePriorIDs: []ID{Weekend},
|
compatiblePriorIDs: []ID{Weekend},
|
||||||
|
comparisonStrategy: CompareWeekendWindow,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: Storm,
|
id: Storm,
|
||||||
@@ -221,6 +549,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
|||||||
batchOutputName: "storm.md",
|
batchOutputName: "storm.md",
|
||||||
generated: true,
|
generated: true,
|
||||||
compatiblePriorIDs: []ID{Storm},
|
compatiblePriorIDs: []ID{Storm},
|
||||||
|
comparisonStrategy: CompareExplicitWindow,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,6 +569,9 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
|||||||
if definition.Generated != tt.generated {
|
if definition.Generated != tt.generated {
|
||||||
t.Fatalf("Generated = %t, want %t", definition.Generated, tt.generated)
|
t.Fatalf("Generated = %t, want %t", definition.Generated, tt.generated)
|
||||||
}
|
}
|
||||||
|
if definition.ComparisonStrategy != tt.comparisonStrategy {
|
||||||
|
t.Fatalf("ComparisonStrategy = %q, want %q", definition.ComparisonStrategy, tt.comparisonStrategy)
|
||||||
|
}
|
||||||
if !reflect.DeepEqual(definition.CompatiblePriorIDs, tt.compatiblePriorIDs) {
|
if !reflect.DeepEqual(definition.CompatiblePriorIDs, tt.compatiblePriorIDs) {
|
||||||
t.Fatalf("CompatiblePriorIDs = %#v, want %#v", definition.CompatiblePriorIDs, tt.compatiblePriorIDs)
|
t.Fatalf("CompatiblePriorIDs = %#v, want %#v", definition.CompatiblePriorIDs, tt.compatiblePriorIDs)
|
||||||
}
|
}
|
||||||
@@ -258,7 +590,7 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
|||||||
want []module.ID
|
want []module.ID
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
id: DailyToday,
|
id: Daily,
|
||||||
want: []module.ID{
|
want: []module.ID{
|
||||||
module.Metadata,
|
module.Metadata,
|
||||||
module.CurrentConditions,
|
module.CurrentConditions,
|
||||||
@@ -267,14 +599,17 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
|||||||
module.DerivedDaypartSummaries,
|
module.DerivedDaypartSummaries,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
|
module.DailyPlanning,
|
||||||
module.HourlyForecast,
|
module.HourlyForecast,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: DailyTomorrow,
|
id: Today,
|
||||||
want: []module.ID{
|
want: []module.ID{
|
||||||
module.Metadata,
|
module.Metadata,
|
||||||
module.CurrentConditions,
|
module.CurrentConditions,
|
||||||
@@ -283,13 +618,48 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
|||||||
module.DerivedDaypartSummaries,
|
module.DerivedDaypartSummaries,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
|
module.WeatherStory,
|
||||||
|
module.OutdoorWindows,
|
||||||
|
module.HourlyForecast,
|
||||||
|
module.TodayPlanning,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: Tomorrow,
|
||||||
|
want: []module.ID{
|
||||||
|
module.Metadata,
|
||||||
|
module.CurrentConditions,
|
||||||
|
module.NarrativeForecast,
|
||||||
|
module.DerivedDailySummary,
|
||||||
|
module.DerivedDaypartSummaries,
|
||||||
|
module.PrecipTiming,
|
||||||
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
module.TomorrowPlanning,
|
module.TomorrowPlanning,
|
||||||
module.HourlyForecast,
|
module.HourlyForecast,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: Hourly,
|
||||||
|
want: []module.ID{
|
||||||
|
module.Metadata,
|
||||||
|
module.CurrentConditions,
|
||||||
|
module.HourlyForecast,
|
||||||
|
module.PrecipTiming,
|
||||||
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
|
module.WeatherStory,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: ThreeDay,
|
id: ThreeDay,
|
||||||
want: []module.ID{
|
want: []module.ID{
|
||||||
@@ -298,7 +668,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
|||||||
module.DerivedDaypartSummaries,
|
module.DerivedDaypartSummaries,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
},
|
},
|
||||||
@@ -311,7 +683,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
|||||||
module.DerivedDaypartSummaries,
|
module.DerivedDaypartSummaries,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
},
|
},
|
||||||
@@ -323,7 +697,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
|||||||
module.CurrentConditions,
|
module.CurrentConditions,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -346,7 +722,7 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
|||||||
func TestRegistryAppliesModuleOverridesWithoutChangingDefaults(t *testing.T) {
|
func TestRegistryAppliesModuleOverridesWithoutChangingDefaults(t *testing.T) {
|
||||||
base := DefaultRegistry()
|
base := DefaultRegistry()
|
||||||
overridden, err := base.WithModuleOverrides(map[ID][]module.ConfigItem{
|
overridden, err := base.WithModuleOverrides(map[ID][]module.ConfigItem{
|
||||||
DailyToday: {
|
Daily: {
|
||||||
{ID: module.Metadata},
|
{ID: module.Metadata},
|
||||||
{ID: module.AlertDigest},
|
{ID: module.AlertDigest},
|
||||||
},
|
},
|
||||||
@@ -355,7 +731,7 @@ func TestRegistryAppliesModuleOverridesWithoutChangingDefaults(t *testing.T) {
|
|||||||
t.Fatalf("WithModuleOverrides() error = %v", err)
|
t.Fatalf("WithModuleOverrides() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
definition, err := overridden.Lookup(DailyToday)
|
definition, err := overridden.Lookup(Daily)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Lookup(overridden) error = %v", err)
|
t.Fatalf("Lookup(overridden) error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -363,7 +739,7 @@ func TestRegistryAppliesModuleOverridesWithoutChangingDefaults(t *testing.T) {
|
|||||||
t.Fatalf("overridden ModuleIDs() = %#v, want metadata and alert digest", definition.ModuleIDs())
|
t.Fatalf("overridden ModuleIDs() = %#v, want metadata and alert digest", definition.ModuleIDs())
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultDefinition, err := base.Lookup(DailyToday)
|
defaultDefinition, err := base.Lookup(Daily)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Lookup(default) error = %v", err)
|
t.Fatalf("Lookup(default) error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -386,18 +762,40 @@ func TestRegistryRejectsModuleOverrideForUnknownReport(t *testing.T) {
|
|||||||
|
|
||||||
func TestResolvedMetadata(t *testing.T) {
|
func TestResolvedMetadata(t *testing.T) {
|
||||||
location := mustLoadLocation(t)
|
location := mustLoadLocation(t)
|
||||||
resolved, err := Resolve(DailyToday, ResolveRequest{Now: mustParse("2026-05-29T05:00:00-05:00"), Location: location})
|
resolved, err := Resolve(Daily, ResolveRequest{
|
||||||
|
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
||||||
|
Location: location,
|
||||||
|
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Resolve() error = %v", err)
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
}
|
}
|
||||||
metadata := resolved.Metadata()
|
metadata := resolved.Metadata()
|
||||||
if metadata.ReportID != DailyToday {
|
if metadata.ReportID != Daily {
|
||||||
t.Fatalf("ReportID = %q, want daily_today", metadata.ReportID)
|
t.Fatalf("ReportID = %q, want daily", metadata.ReportID)
|
||||||
}
|
}
|
||||||
if metadata.PromptID != "weather.daily_report" {
|
if metadata.PromptID != "weather.daily_generated_text" {
|
||||||
t.Fatalf("PromptID = %q, want weather.daily_report", metadata.PromptID)
|
t.Fatalf("PromptID = %q, want weather.daily_generated_text", metadata.PromptID)
|
||||||
}
|
}
|
||||||
if !strings.Contains(metadata.RunID, "daily_today") {
|
if !strings.Contains(metadata.RunID, "daily") {
|
||||||
|
t.Fatalf("RunID = %q, want report id", metadata.RunID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHourlyMetadataRunIDIncludesReportID(t *testing.T) {
|
||||||
|
location := mustLoadLocation(t)
|
||||||
|
resolved, err := Resolve(Hourly, ResolveRequest{Now: mustParse("2026-05-29T05:15:00-05:00"), Location: location})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
metadata := resolved.Metadata()
|
||||||
|
if metadata.ReportID != Hourly {
|
||||||
|
t.Fatalf("ReportID = %q, want hourly", metadata.ReportID)
|
||||||
|
}
|
||||||
|
if metadata.PromptID != "weather.hourly_generated_text" {
|
||||||
|
t.Fatalf("PromptID = %q, want weather.hourly_generated_text", metadata.PromptID)
|
||||||
|
}
|
||||||
|
if !strings.Contains(metadata.RunID, "hourly") {
|
||||||
t.Fatalf("RunID = %q, want report id", metadata.RunID)
|
t.Fatalf("RunID = %q, want report id", metadata.RunID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -423,6 +821,14 @@ func resolvedIDs(resolved []Resolved) []string {
|
|||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolvedDefinitionIDs(definitions []Definition) []string {
|
||||||
|
ids := make([]string, 0, len(definitions))
|
||||||
|
for _, definition := range definitions {
|
||||||
|
ids = append(ids, string(definition.ID))
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
func mustLoadLocation(t *testing.T) *time.Location {
|
func mustLoadLocation(t *testing.T) *time.Location {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
location, err := time.LoadLocation("America/Chicago")
|
location, err := time.LoadLocation("America/Chicago")
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ type Registry struct {
|
|||||||
|
|
||||||
func DefaultRegistry() Registry {
|
func DefaultRegistry() Registry {
|
||||||
definitions := []Definition{
|
definitions := []Definition{
|
||||||
dailyTodayDefinition(),
|
dailyDefinition(),
|
||||||
dailyTomorrowDefinition(),
|
todayDefinition(),
|
||||||
|
tomorrowDefinition(),
|
||||||
|
hourlyDefinition(),
|
||||||
threeDayDefinition(),
|
threeDayDefinition(),
|
||||||
weekendDefinition(),
|
weekendDefinition(),
|
||||||
stormDefinition(),
|
stormDefinition(),
|
||||||
@@ -73,7 +75,7 @@ func (r Registry) MustLookup(id ID) Definition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r Registry) All() []Definition {
|
func (r Registry) All() []Definition {
|
||||||
ids := []ID{DailyToday, DailyTomorrow, ThreeDay, Weekend, Storm}
|
ids := []ID{Daily, Today, Tomorrow, Hourly, ThreeDay, Weekend, Storm}
|
||||||
out := make([]Definition, 0, len(ids))
|
out := make([]Definition, 0, len(ids))
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if definition, ok := r.definitions[id]; ok {
|
if definition, ok := r.definitions[id]; ok {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ func stormDefinition() Definition {
|
|||||||
ID: Storm,
|
ID: Storm,
|
||||||
Name: "Storm Report",
|
Name: "Storm Report",
|
||||||
PromptID: "weather.storm_report",
|
PromptID: "weather.storm_report",
|
||||||
|
GenerationMode: GenerationModeScriptoriumMarkdown,
|
||||||
ComparisonStrategy: CompareExplicitWindow,
|
ComparisonStrategy: CompareExplicitWindow,
|
||||||
ArtifactGroup: "storm",
|
ArtifactGroup: "storm",
|
||||||
BatchOutputName: "storm.md",
|
BatchOutputName: "storm.md",
|
||||||
@@ -29,7 +30,9 @@ func stormModules() []module.ConfigItem {
|
|||||||
module.CurrentConditions,
|
module.CurrentConditions,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ func threeDayDefinition() Definition {
|
|||||||
ID: ThreeDay,
|
ID: ThreeDay,
|
||||||
Name: "3-Day Outlook",
|
Name: "3-Day Outlook",
|
||||||
PromptID: "weather.three_day_outlook",
|
PromptID: "weather.three_day_outlook",
|
||||||
|
GenerationMode: GenerationModeScriptoriumMarkdown,
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
ArtifactGroup: "three-day",
|
ArtifactGroup: "three-day",
|
||||||
BatchOutputName: "three-day.md",
|
BatchOutputName: "three-day.md",
|
||||||
@@ -30,7 +31,9 @@ func threeDayModules() []module.ConfigItem {
|
|||||||
module.DerivedDaypartSummaries,
|
module.DerivedDaypartSummaries,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
)
|
)
|
||||||
|
|||||||
51
internal/report/today_report.go
Normal file
51
internal/report/today_report.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package report
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func todayDefinition() Definition {
|
||||||
|
return Definition{
|
||||||
|
ID: Today,
|
||||||
|
Name: "Today Report",
|
||||||
|
PromptID: "weather.today_generated_text",
|
||||||
|
GenerationMode: GenerationModeGeneratedTextTemplate,
|
||||||
|
TemplateID: "today",
|
||||||
|
GeneratedTextSchemaID: "today",
|
||||||
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
|
ArtifactGroup: "today",
|
||||||
|
BatchOutputName: "today.md",
|
||||||
|
Generated: true,
|
||||||
|
CompatiblePriorIDs: []ID{Today},
|
||||||
|
Modules: todayModules(),
|
||||||
|
Morning: true,
|
||||||
|
resolve: resolveToday,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func todayModules() []module.ConfigItem {
|
||||||
|
return moduleItems(
|
||||||
|
module.Metadata,
|
||||||
|
module.CurrentConditions,
|
||||||
|
module.NarrativeForecast,
|
||||||
|
module.DerivedDailySummary,
|
||||||
|
module.DerivedDaypartSummaries,
|
||||||
|
module.PrecipTiming,
|
||||||
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
|
module.WeatherStory,
|
||||||
|
module.OutdoorWindows,
|
||||||
|
module.HourlyForecast,
|
||||||
|
module.TodayPlanning,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveToday(req ResolveRequest) (timeutil.Period, error) {
|
||||||
|
if !req.Date.IsZero() {
|
||||||
|
return timeutil.CivilDay(req.Date, req.Location), nil
|
||||||
|
}
|
||||||
|
return timeutil.CivilDay(req.Now, req.Location), nil
|
||||||
|
}
|
||||||
48
internal/report/tomorrow_report.go
Normal file
48
internal/report/tomorrow_report.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package report
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func tomorrowDefinition() Definition {
|
||||||
|
return Definition{
|
||||||
|
ID: Tomorrow,
|
||||||
|
Name: "Tomorrow Report",
|
||||||
|
PromptID: "weather.tomorrow_generated_text",
|
||||||
|
GenerationMode: GenerationModeGeneratedTextTemplate,
|
||||||
|
TemplateID: "tomorrow",
|
||||||
|
GeneratedTextSchemaID: "tomorrow",
|
||||||
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
|
ArtifactGroup: "tomorrow",
|
||||||
|
BatchOutputName: "tomorrow.md",
|
||||||
|
Generated: true,
|
||||||
|
CompatiblePriorIDs: []ID{Tomorrow},
|
||||||
|
Modules: tomorrowModules(),
|
||||||
|
Evening: true,
|
||||||
|
resolve: resolveTomorrow,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tomorrowModules() []module.ConfigItem {
|
||||||
|
return moduleItems(
|
||||||
|
module.Metadata,
|
||||||
|
module.CurrentConditions,
|
||||||
|
module.NarrativeForecast,
|
||||||
|
module.DerivedDailySummary,
|
||||||
|
module.DerivedDaypartSummaries,
|
||||||
|
module.PrecipTiming,
|
||||||
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
|
module.WeatherStory,
|
||||||
|
module.OutdoorWindows,
|
||||||
|
module.TomorrowPlanning,
|
||||||
|
module.HourlyForecast,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveTomorrow(req ResolveRequest) (timeutil.Period, error) {
|
||||||
|
return timeutil.CivilDay(req.Now.In(req.Location).AddDate(0, 0, 1), req.Location), nil
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ func weekendDefinition() Definition {
|
|||||||
ID: Weekend,
|
ID: Weekend,
|
||||||
Name: "Weekend Outlook",
|
Name: "Weekend Outlook",
|
||||||
PromptID: "weather.weekend_outlook",
|
PromptID: "weather.weekend_outlook",
|
||||||
|
GenerationMode: GenerationModeScriptoriumMarkdown,
|
||||||
ComparisonStrategy: CompareWeekendWindow,
|
ComparisonStrategy: CompareWeekendWindow,
|
||||||
ArtifactGroup: "weekend",
|
ArtifactGroup: "weekend",
|
||||||
BatchOutputName: "weekend.md",
|
BatchOutputName: "weekend.md",
|
||||||
@@ -31,7 +32,9 @@ func weekendModules() []module.ConfigItem {
|
|||||||
module.DerivedDaypartSummaries,
|
module.DerivedDaypartSummaries,
|
||||||
module.PrecipTiming,
|
module.PrecipTiming,
|
||||||
module.AlertDigest,
|
module.AlertDigest,
|
||||||
|
module.SPCConvectiveOutlooks,
|
||||||
module.AreaForecastDiscussion,
|
module.AreaForecastDiscussion,
|
||||||
|
module.SPCConvectiveDiscussion,
|
||||||
module.WeatherStory,
|
module.WeatherStory,
|
||||||
module.OutdoorWindows,
|
module.OutdoorWindows,
|
||||||
)
|
)
|
||||||
|
|||||||
38
internal/reporttemplate/prompts/daily.generated_text.md
Normal file
38
internal/reporttemplate/prompts/daily.generated_text.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
TASK: You are writing structured prose slots for a dated daily weather report.
|
||||||
|
|
||||||
|
The calling application will render the final Markdown report. Your job is not to write the full report. Return only a JSON object matching the configured schema.
|
||||||
|
|
||||||
|
Use only the supplied `data_package`. Do not invent weather details, times, hazards, probabilities, or impacts that are not supported by the data.
|
||||||
|
|
||||||
|
The report focuses on the selected local civil day in `report.valid_period` for the configured location.
|
||||||
|
|
||||||
|
Return these fields:
|
||||||
|
|
||||||
|
- `summary`: required. 1-2 sentences summarizing the main weather story for the selected day.
|
||||||
|
- `forecast_discussion`: required. 1 or more short paragraphs explaining the setup, timing, trend, or uncertainty most relevant to the selected day.
|
||||||
|
- `precipitation_timing`: optional. Include only when the deterministic `precip_timing` module contains precipitation windows.
|
||||||
|
- `confidence`: optional. Include only if uncertainty, timing spread, or conflicting signals materially affect how the reader should interpret the forecast.
|
||||||
|
|
||||||
|
Return JSON only.
|
||||||
|
|
||||||
|
# summary
|
||||||
|
|
||||||
|
Lead with the most practical local outcome for the selected day. If an active warning is relevant during the report period, lead with the hazard.
|
||||||
|
|
||||||
|
Mention the expected temperature character, precipitation risk, wind, visibility, heat, cold, or other hazards only when supported by the data package.
|
||||||
|
|
||||||
|
# forecast_discussion
|
||||||
|
|
||||||
|
Use deterministic module facts and narrative products to explain the most useful details for the selected day.
|
||||||
|
|
||||||
|
Useful context may include:
|
||||||
|
|
||||||
|
- timing of condition changes by daypart or hour
|
||||||
|
- boundaries, forcing, moisture, instability, or storm mode when supported
|
||||||
|
- active alerts or SPC outlooks that apply to the location
|
||||||
|
- planning concerns surfaced by `daily_planning`
|
||||||
|
- confidence or uncertainty
|
||||||
|
|
||||||
|
# precipitation_timing
|
||||||
|
|
||||||
|
Use 1-2 sentences to add practical precipitation context only when the data package contains deterministic precipitation windows. Include expected timing, type, intensity, duration, and uncertainty only when those details are supported.
|
||||||
56
internal/reporttemplate/prompts/hourly.generated_text.md
Normal file
56
internal/reporttemplate/prompts/hourly.generated_text.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
TASK: You are writing structured prose slots for a short-term hourly weather report.
|
||||||
|
|
||||||
|
The calling application will render the final Markdown report. Your job is not to write the full report. Return only a JSON object matching the configured schema.
|
||||||
|
|
||||||
|
Use only the supplied `data_package`. Do not invent weather details, times, hazards, probabilities, or impacts that are not supported by the data.
|
||||||
|
|
||||||
|
The report focuses on the valid period in `report.valid_period`, typically the next several hours for the configured location.
|
||||||
|
|
||||||
|
Return these fields:
|
||||||
|
|
||||||
|
- `summary`: required. 1-2 sentences summarizing the main weather story for the valid period.
|
||||||
|
- `forecast_discussion`: required. 2-3 sentences explaining the broader setup, trend, or forecast reasoning most relevant to the valid period.
|
||||||
|
- `precipitation_timing`: optional. Include only when the deterministic `precip_timing` module contains precipitation windows.
|
||||||
|
- `confidence`: optional. Include only if uncertainty, timing spread, or conflicting signals materially affect how the reader should interpret the forecast.
|
||||||
|
|
||||||
|
Return JSON only.
|
||||||
|
|
||||||
|
# summary
|
||||||
|
|
||||||
|
The summary should typically consist of two sentences.
|
||||||
|
|
||||||
|
If an active warning is relevant during the report period, lead with the hazard. Otherwise, the first sentence should state the most likely local weather outcome for the valid period, including the overall character of the weather and expected temperature/temperature range.
|
||||||
|
|
||||||
|
If the forecast indicates a significant shift in conditions over time (e.g., from sunny to overcast), then identify the hour when the shift is most likely to occur. If the conditions are generally similar or stable across the forecast period, then pick a single descriptor (e.g., mostly clear) that best captures the character of the weather.
|
||||||
|
|
||||||
|
The second sentence should state the most important active hazard, caveat, uncertainty, or alternate outcome, if one exists. If there is no meaningful caveat, the second sentence may be omitted, or may briefly say that no major complications are apparent.
|
||||||
|
|
||||||
|
In the lead, distinguish the main weather outcome from the caveat. If showers and thunderstorms have different timing, state that difference rather than combining them as a single risk throughout the valid period. If the main caveat is a regional severe-weather or precipitation risk displaced from the report location, state that limitation clearly.
|
||||||
|
|
||||||
|
Example style:
|
||||||
|
|
||||||
|
- “The rest of the afternoon is expected to be warm and dry, with mostly clear skies. There is a slight chance of isolated showers and thunderstorms developing from late afternoon into early evening.”
|
||||||
|
|
||||||
|
# forecast_discussion
|
||||||
|
|
||||||
|
Use narrative products to explain the “why” behind the local forecast when useful.
|
||||||
|
|
||||||
|
Useful context may include:
|
||||||
|
|
||||||
|
- synoptic pattern
|
||||||
|
- fronts or boundaries
|
||||||
|
- shortwaves, troughs, or ridges
|
||||||
|
- instability, moisture, shear, forcing, or capping
|
||||||
|
- regional placement of precipitation or severe-weather chances
|
||||||
|
- hazard types and timing windows
|
||||||
|
- confidence or uncertainty
|
||||||
|
- conditional outcomes
|
||||||
|
- relevant notes about the following day or days
|
||||||
|
|
||||||
|
# precipitation_timing
|
||||||
|
|
||||||
|
Use 1-2 sentences to add practical context, including:
|
||||||
|
|
||||||
|
- Whether the precipitation is associated with a moving frontal boundary, convective initiation, or wide stratiform rain (if this can be determined from the data package);
|
||||||
|
- The expected type, intensity, and duration of the precipitation; and
|
||||||
|
- Any caveats or uncertainty with respect to the onset, duration, or occurrance of the precipitation.
|
||||||
38
internal/reporttemplate/prompts/today.generated_text.md
Normal file
38
internal/reporttemplate/prompts/today.generated_text.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
TASK: You are writing structured prose slots for a current-day weather report.
|
||||||
|
|
||||||
|
The calling application will render the final Markdown report. Your job is not to write the full report. Return only a JSON object matching the configured schema.
|
||||||
|
|
||||||
|
Use only the supplied `data_package`. Do not invent weather details, times, hazards, probabilities, or impacts that are not supported by the data.
|
||||||
|
|
||||||
|
The report focuses on today's valid period in `report.valid_period` for the configured location.
|
||||||
|
|
||||||
|
Return these fields:
|
||||||
|
|
||||||
|
- `summary`: required. 1-2 sentences summarizing the main weather story for today.
|
||||||
|
- `forecast_discussion`: required. 1 or more short paragraphs explaining the setup, timing, trend, or uncertainty most relevant to today.
|
||||||
|
- `precipitation_timing`: optional. Include only when the deterministic `precip_timing` module contains precipitation windows.
|
||||||
|
- `confidence`: optional. Include only if uncertainty, timing spread, or conflicting signals materially affect how the reader should interpret the forecast.
|
||||||
|
|
||||||
|
Return JSON only.
|
||||||
|
|
||||||
|
# summary
|
||||||
|
|
||||||
|
Lead with the most practical local outcome for today. If an active warning is relevant during the report period, lead with the hazard.
|
||||||
|
|
||||||
|
Mention the expected temperature character, precipitation risk, wind, visibility, heat, cold, or other hazards only when supported by the data package.
|
||||||
|
|
||||||
|
# forecast_discussion
|
||||||
|
|
||||||
|
Use deterministic module facts and narrative products to explain the most useful details for the current day.
|
||||||
|
|
||||||
|
Useful context may include:
|
||||||
|
|
||||||
|
- timing of condition changes by daypart or hour
|
||||||
|
- boundaries, forcing, moisture, instability, or storm mode when supported
|
||||||
|
- active alerts or SPC outlooks that apply to the location
|
||||||
|
- planning concerns surfaced by `today_planning`
|
||||||
|
- confidence or uncertainty
|
||||||
|
|
||||||
|
# precipitation_timing
|
||||||
|
|
||||||
|
Use 1-2 sentences to add practical precipitation context only when the data package contains deterministic precipitation windows. Include expected timing, type, intensity, duration, and uncertainty only when those details are supported.
|
||||||
54
internal/reporttemplate/prompts/tomorrow.generated_text.md
Normal file
54
internal/reporttemplate/prompts/tomorrow.generated_text.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
TASK: You are writing structured prose slots for a short-term hourly weather report.
|
||||||
|
|
||||||
|
The calling application will render the final Markdown report. Your job is not to write the full report. Return only a JSON object matching the configured schema.
|
||||||
|
|
||||||
|
Use only the supplied `data_package`. Do not invent weather details, times, hazards, probabilities, or impacts that are not supported by the data.
|
||||||
|
|
||||||
|
The report focuses on the valid period in `report.valid_period`, typically the next several hours for the configured location.
|
||||||
|
|
||||||
|
Return these fields:
|
||||||
|
|
||||||
|
- `summary`: required. 1-2 sentences summarizing the main weather story for the valid period.
|
||||||
|
- `forecast_discussion`: required. 2-3 sentences explaining the broader setup, trend, or forecast reasoning most relevant to the valid period.
|
||||||
|
- `precipitation_timing`: optional. Include only when the deterministic `precip_timing` module contains precipitation windows.
|
||||||
|
- `confidence`: optional. Include only if uncertainty, timing spread, or conflicting signals materially affect how the reader should interpret the forecast.
|
||||||
|
|
||||||
|
Return JSON only.
|
||||||
|
|
||||||
|
# summary
|
||||||
|
|
||||||
|
The summary should typically consist of two sentences.
|
||||||
|
|
||||||
|
If an active warning is relevant during the report period, lead with the hazard. Otherwise, the first sentence should state the most likely local weather outcome for the valid period, including the overall character of the weather and expected temperature/temperature range.
|
||||||
|
|
||||||
|
The second sentence should state the most important active hazard, caveat, uncertainty, or alternate outcome, if one exists. If there is no meaningful caveat, the second sentence may be omitted, or may briefly say that no major complications are apparent.
|
||||||
|
|
||||||
|
In the lead, distinguish the main weather outcome from the caveat. If showers and thunderstorms have different timing, state that difference rather than combining them as a single risk throughout the valid period. If the main caveat is a regional severe-weather or precipitation risk displaced from the report location, state that limitation clearly.
|
||||||
|
|
||||||
|
Example style:
|
||||||
|
|
||||||
|
- “Sunday is expected to be warm and dry, with mostly clear skies. There is a slight chance of isolated showers and thunderstorms developing from late afternoon into early evening.”
|
||||||
|
|
||||||
|
# forecast_discussion
|
||||||
|
|
||||||
|
Use narrative products to explain the “why” behind the local forecast when useful.
|
||||||
|
|
||||||
|
Useful context may include:
|
||||||
|
|
||||||
|
- synoptic pattern
|
||||||
|
- fronts or boundaries
|
||||||
|
- shortwaves, troughs, or ridges
|
||||||
|
- instability, moisture, shear, forcing, or capping
|
||||||
|
- regional placement of precipitation or severe-weather chances
|
||||||
|
- hazard types and timing windows
|
||||||
|
- confidence or uncertainty
|
||||||
|
- conditional outcomes
|
||||||
|
- relevant notes about the following day or days
|
||||||
|
|
||||||
|
# precipitation_timing
|
||||||
|
|
||||||
|
Use 1-2 sentences to add practical context, including:
|
||||||
|
|
||||||
|
- Whether the precipitation is associated with a moving frontal boundary, convective initiation, or wide stratiform rain (if this can be determined from the data package);
|
||||||
|
- The expected type, intensity, and duration of the precipitation; and
|
||||||
|
- Any caveats or uncertainty with respect to the onset, duration, or occurrance of the precipitation.
|
||||||
66
internal/reporttemplate/reporttemplate.go
Normal file
66
internal/reporttemplate/reporttemplate.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// Package reporttemplate provides embedded Markdown templates and schemas.
|
||||||
|
package reporttemplate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"text/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed templates/*.md.tmpl schemas/*.schema.json
|
||||||
|
var assets embed.FS
|
||||||
|
|
||||||
|
var templates = map[string]string{
|
||||||
|
"daily": "templates/daily.md.tmpl",
|
||||||
|
"hourly": "templates/hourly.md.tmpl",
|
||||||
|
"today": "templates/today.md.tmpl",
|
||||||
|
"tomorrow": "templates/tomorrow.md.tmpl",
|
||||||
|
}
|
||||||
|
|
||||||
|
var schemas = map[string]string{
|
||||||
|
"daily": "schemas/daily.generated_text.schema.json",
|
||||||
|
"hourly": "schemas/hourly.generated_text.schema.json",
|
||||||
|
"today": "schemas/today.generated_text.schema.json",
|
||||||
|
"tomorrow": "schemas/tomorrow.generated_text.schema.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
func Template(id string) (string, error) {
|
||||||
|
path, ok := templates[id]
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("unknown report template %q", id)
|
||||||
|
}
|
||||||
|
data, err := assets.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read report template %q: %w", id, err)
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Schema(id string) ([]byte, error) {
|
||||||
|
path, ok := schemas[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unknown generated text schema %q", id)
|
||||||
|
}
|
||||||
|
data, err := assets.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read generated text schema %q: %w", id, err)
|
||||||
|
}
|
||||||
|
return append([]byte(nil), data...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Render(id string, data any) ([]byte, error) {
|
||||||
|
source, err := Template(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tmpl, err := template.New(id).Option("missingkey=error").Parse(source)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse report template %q: %w", id, err)
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&out, data); err != nil {
|
||||||
|
return nil, fmt.Errorf("render report template %q: %w", id, err)
|
||||||
|
}
|
||||||
|
return out.Bytes(), nil
|
||||||
|
}
|
||||||
1013
internal/reporttemplate/reporttemplate_test.go
Normal file
1013
internal/reporttemplate/reporttemplate_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "weatherreporter.daily.generated_text.schema.json",
|
||||||
|
"title": "Daily GeneratedText",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"summary",
|
||||||
|
"forecast_discussion"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"summary": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"forecast_discussion": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"minItems": 1
|
||||||
|
},
|
||||||
|
"precipitation_timing": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"confidence": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "weatherreporter.hourly.generated_text.schema.json",
|
||||||
|
"title": "Hourly GeneratedText",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"summary",
|
||||||
|
"forecast_discussion"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"summary": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"forecast_discussion": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"precipitation_timing": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"confidence": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "weatherreporter.today.generated_text.schema.json",
|
||||||
|
"title": "Today GeneratedText",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"summary",
|
||||||
|
"forecast_discussion"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"summary": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"forecast_discussion": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"minItems": 1
|
||||||
|
},
|
||||||
|
"precipitation_timing": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"confidence": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user