Compare commits
100 Commits
v0.7.0
...
0884eb0ce5
| Author | SHA1 | Date | |
|---|---|---|---|
| 0884eb0ce5 | |||
| a27e870522 | |||
| 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
|
||||
|
||||
```sh
|
||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
||||
weatherreporter generate today --out ./today.md
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
57
docs/cli.md
57
docs/cli.md
@@ -6,21 +6,24 @@ batches, and inspects stored artifacts.
|
||||
## Shortest Useful Command
|
||||
|
||||
```sh
|
||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
||||
weatherreporter generate today --out ./today.md
|
||||
```
|
||||
|
||||
This loads configuration, fetches weather data, writes managed workspace
|
||||
artifacts, runs `scriptorium render` as a preflight check, runs
|
||||
`scriptorium run`, and writes an extra Markdown copy to `./daily.md`. If
|
||||
distributor notification is enabled in configuration, the command also uploads
|
||||
the managed Markdown report after final metadata is saved.
|
||||
artifacts, runs `scriptorium render` as a preflight check, runs structured
|
||||
`scriptorium run`, validates generated text, renders the embedded Today
|
||||
template, and writes an extra Markdown copy to `./today.md`. If distributor
|
||||
notification is enabled in configuration, the command also uploads the managed
|
||||
Markdown report after final metadata is saved.
|
||||
|
||||
## Commands
|
||||
|
||||
```text
|
||||
weatherreporter --help
|
||||
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
||||
weatherreporter generate 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 hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
|
||||
@@ -34,14 +37,21 @@ weatherreporter inspect prior [--config PATH] RUN_ID
|
||||
weatherreporter inspect sources [--config PATH] RUN_ID
|
||||
```
|
||||
|
||||
`generate` commands write a JSON module snapshot, YAML data package, preflight
|
||||
artifact, managed Markdown report, and metadata under the configured workspace.
|
||||
`--out` writes an extra Markdown copy for the operator; distributor
|
||||
notification uses the managed report path, not the extra copy. `generate storm`
|
||||
requires explicit event-window bounds with `--start` and `--end`.
|
||||
Implemented `generate` commands write a JSON module snapshot, YAML data package,
|
||||
preflight artifact, managed Markdown report, and metadata under the configured
|
||||
workspace. `--out` writes an extra Markdown copy for the operator; distributor
|
||||
notification uses the managed report path, not the extra copy. `generate daily`,
|
||||
`generate today`, `generate tomorrow`, and `generate hourly` write managed
|
||||
generated-text artifacts, validate structured text from Scriptorium, and render
|
||||
the managed Markdown report from embedded templates. `generate daily` requires
|
||||
`--date YYYY-MM-DD` for the selected local civil day; omitting `--date` is a
|
||||
command error and stops before weather data is 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
|
||||
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch
|
||||
`run morning` generates Today Report and the 3-Day Outlook, plus Weekend Outlook
|
||||
except on Sunday. `run evening` generates the Tomorrow Report. Batch
|
||||
runs continue independent reports after a failure, print a JSON summary to
|
||||
stdout, write compact status lines to stderr, and return nonzero when any report
|
||||
failed. `--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
|
||||
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.
|
||||
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`.
|
||||
- `--units VALUE`: override configured Weather API units for `generate` and `run`.
|
||||
- `--tz NAME`: override configured Weather API timezone for `generate` and `run`.
|
||||
- `--out PATH`: write an extra Markdown report copy for `generate` commands.
|
||||
- `--out PATH`: write an extra Markdown report copy where supported by the `generate` command.
|
||||
- `--out-dir PATH`: write extra Markdown report copies for `run morning` and `run evening`.
|
||||
- `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone.
|
||||
- `--date YYYY-MM-DD`: required date for `generate daily`; optional date for `generate today`, defaulting to the current local date in the configured timezone.
|
||||
- `--start TIME`: required start time for `generate storm`.
|
||||
- `--end TIME`: required end time for `generate storm`.
|
||||
- `--limit N`: maximum records for `inspect reports`; defaults to `20`, and `0` means no limit.
|
||||
@@ -75,7 +88,10 @@ are no distributor-specific CLI flags.
|
||||
## Common Workflows
|
||||
|
||||
```sh
|
||||
weatherreporter generate today --out ./today.md
|
||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
||||
weatherreporter generate tomorrow --out ./tomorrow.md
|
||||
weatherreporter generate hourly
|
||||
weatherreporter generate three-day --out ./three-day.md
|
||||
weatherreporter generate weekend --out ./weekend.md
|
||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
|
||||
@@ -87,11 +103,12 @@ weatherreporter run evening --out-dir ./reports
|
||||
|
||||
```sh
|
||||
weatherreporter inspect reports --limit 10
|
||||
weatherreporter inspect metadata 20260529T100000.000000000Z_daily_today
|
||||
weatherreporter inspect modules 20260529T100000.000000000Z_daily_today
|
||||
weatherreporter inspect data-package 20260529T100000.000000000Z_daily_today
|
||||
weatherreporter inspect prior 20260529T100000.000000000Z_daily_today
|
||||
weatherreporter inspect sources 20260529T100000.000000000Z_daily_today
|
||||
weatherreporter inspect metadata 20260529T100000.000000000Z_today
|
||||
weatherreporter inspect modules 20260529T100000.000000000Z_today
|
||||
weatherreporter inspect data-package 20260529T100000.000000000Z_today
|
||||
weatherreporter inspect prior 20260529T100000.000000000Z_today
|
||||
weatherreporter inspect sources 20260529T100000.000000000Z_today
|
||||
weatherreporter inspect metadata 20260529T100000.000000000Z_daily
|
||||
```
|
||||
|
||||
`inspect reports` lists recent generated runs with artifact paths and source
|
||||
|
||||
@@ -81,7 +81,7 @@ missing directories, and unreadable files fail config loading.
|
||||
`notify.distributor` controls distributor notification after successful report
|
||||
generation. It is disabled by default and does not add CLI flags. When enabled,
|
||||
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:
|
||||
`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.
|
||||
|
||||
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`
|
||||
|
||||
@@ -179,9 +181,14 @@ snapshot exists and a threshold is crossed.
|
||||
`reports` optionally overrides the ordered deterministic modules declared by
|
||||
report definitions. Omit a report entry to use its default module order.
|
||||
|
||||
Supported report keys are `daily`, `tomorrow`, `three_day`, `weekend`, and
|
||||
`storm`. Canonical report IDs such as `daily_today` and `daily_tomorrow` are
|
||||
also accepted.
|
||||
Supported report keys are `daily`, `today`, `tomorrow`, `hourly`,
|
||||
`three_day`, `weekend`, and `storm`. Canonical report IDs and accepted aliases
|
||||
are also valid, including `three_day_outlook`, `weekend_outlook`, and
|
||||
`storm_report`. Hyphens and underscores are treated equivalently in report
|
||||
keys. Retired report keys are not supported.
|
||||
|
||||
`reports.today` applies only to the Today Report. `reports.daily` applies only
|
||||
to the dated Daily Report.
|
||||
|
||||
Each report entry supports:
|
||||
|
||||
@@ -197,11 +204,46 @@ reports:
|
||||
- metadata
|
||||
- current_conditions
|
||||
- narrative_forecast
|
||||
- alert_digest
|
||||
- spc_convective_outlooks
|
||||
- id: area_forecast_discussion
|
||||
options:
|
||||
sections:
|
||||
- short_term
|
||||
- spc_convective_discussion
|
||||
- daily_planning
|
||||
- hourly_forecast
|
||||
today:
|
||||
deterministic_modules:
|
||||
- metadata
|
||||
- current_conditions
|
||||
- narrative_forecast
|
||||
- derived_daily_summary
|
||||
- derived_daypart_summaries
|
||||
- precip_timing
|
||||
- alert_digest
|
||||
- spc_convective_outlooks
|
||||
- area_forecast_discussion
|
||||
- spc_convective_discussion
|
||||
- weather_story
|
||||
- outdoor_windows
|
||||
- hourly_forecast
|
||||
- today_planning
|
||||
hourly:
|
||||
deterministic_modules:
|
||||
- metadata
|
||||
- current_conditions
|
||||
- hourly_forecast
|
||||
- precip_timing
|
||||
- alert_digest
|
||||
- spc_convective_outlooks
|
||||
- id: area_forecast_discussion
|
||||
options:
|
||||
sections:
|
||||
- key_messages
|
||||
- short_term
|
||||
- spc_convective_discussion
|
||||
- weather_story
|
||||
```
|
||||
|
||||
Unknown reports, unknown modules, duplicate modules, incompatible report/module
|
||||
|
||||
@@ -6,7 +6,7 @@ This document describes the external Scriptorium CLI contract used by
|
||||
## Purpose
|
||||
|
||||
`weatherreporter` invokes Scriptorium as a subprocess to preflight prompt input
|
||||
and generate Markdown reports. This page documents the CLI surface the adapter
|
||||
and generate report artifacts. This page documents the CLI surface the adapter
|
||||
uses, not the full Scriptorium product.
|
||||
|
||||
## Commands Used
|
||||
@@ -29,11 +29,25 @@ scriptorium run \
|
||||
--out <artifact_path>
|
||||
```
|
||||
|
||||
Structured generated-text report generation uses the same command shape:
|
||||
|
||||
```bash
|
||||
scriptorium run \
|
||||
--prompt <prompt_id> \
|
||||
--input data_package=<path> \
|
||||
--out <generated_text_raw_path>
|
||||
```
|
||||
|
||||
`weatherreporter` always passes prompt input as
|
||||
`--input data_package=<path>`. The data package is structured YAML created by
|
||||
`internal/promptinput`; module snapshots remain separate JSON artifacts for
|
||||
inspection and Recent Changes.
|
||||
|
||||
For generated-text reports, Scriptorium selects the structured output schema
|
||||
from the prompt configuration associated with the prompt ID. `weatherreporter`
|
||||
does not pass `--format`, schema path, or JSON Schema flags for structured
|
||||
generation.
|
||||
|
||||
## Configured Arguments
|
||||
|
||||
The adapter can prepend configured flags before prompt-specific arguments:
|
||||
@@ -68,11 +82,17 @@ Render results include:
|
||||
- exit code
|
||||
- truncation flags when applicable
|
||||
|
||||
Run results include the same fields plus the requested output path.
|
||||
Run results include the same fields plus the requested output path. Structured
|
||||
generated-text run results use the same captured fields and output-path
|
||||
recording, with the output path pointing at the raw generated-text JSON
|
||||
artifact.
|
||||
|
||||
`weatherreporter` persists render preflight JSON when orchestration reaches the
|
||||
preflight save point. The final Markdown artifact is written by Scriptorium to
|
||||
the `--out` path.
|
||||
preflight save point. For direct Markdown reports, Scriptorium writes the
|
||||
managed Markdown artifact to the `--out` path. For generated-text-template
|
||||
reports, Scriptorium writes raw JSON to the `--out` path; later
|
||||
weatherreporter workflow steps validate those bytes and render Markdown from an
|
||||
embedded template.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
@@ -80,7 +100,7 @@ The adapter validates required request fields before starting Scriptorium:
|
||||
|
||||
- prompt ID
|
||||
- data package path
|
||||
- output path for `run`
|
||||
- output path for `run` and structured generated-text `run`
|
||||
|
||||
Nonzero exits return both the captured result and an error containing the exit
|
||||
code and stderr. A `run` exit code such as `2` is still treated as an error by
|
||||
|
||||
@@ -37,6 +37,10 @@ generation.
|
||||
means the endpoint was checked and there are no current active alerts. The
|
||||
adapter records a non-missing alerts source and an empty alert run.
|
||||
|
||||
For `/outlooks/convective`, `data: null` means no latest run is available and
|
||||
follows missing-source policy. A non-null run with empty `outlooks` and
|
||||
`discussions` arrays is checked empty data, not a missing source.
|
||||
|
||||
Malformed JSON envelopes, non-2xx statuses, and response read failures include
|
||||
endpoint context in returned errors. Decode errors include source context when
|
||||
they fail the fetch; optional malformed sources follow the missing-source policy.
|
||||
@@ -49,11 +53,66 @@ The adapter sends these query parameters:
|
||||
- `units`: from `weather_api.units`
|
||||
- `precision`: from `weather_api.precision` on observations, current
|
||||
conditions, hourly forecast, and narrative forecast requests
|
||||
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast, and
|
||||
discussion requests
|
||||
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast,
|
||||
discussion, and SPC convective outlook requests
|
||||
|
||||
Alerts do not receive `precision` or `tz`. 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
|
||||
|
||||
@@ -66,6 +125,7 @@ The adapter fetches these endpoints once per bundle:
|
||||
- `/alerts/active`
|
||||
- `/discussion`
|
||||
- `/weatherstories/latest`
|
||||
- `/outlooks/convective`
|
||||
|
||||
`weatherreporter` does not call day-slice forecast endpoints or discussion
|
||||
subsection endpoints. Report-period selection and daypart summarization happen
|
||||
@@ -88,6 +148,7 @@ source-specific `missing_source.sources` policy:
|
||||
- `alerts` for `/alerts/active`
|
||||
- `discussion` for `/discussion`
|
||||
- `weather_story` for `/weatherstories/latest`
|
||||
- `spc_convective_outlooks` for `/outlooks/convective`
|
||||
|
||||
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
|
||||
missing-source condition.
|
||||
|
||||
For `/outlooks/convective`, a non-null data object with empty outlook and
|
||||
discussion arrays is accepted as checked empty data.
|
||||
|
||||
## Source Identity
|
||||
|
||||
For source payloads accepted into the bundle, including the explicit `null`
|
||||
@@ -116,8 +180,8 @@ warnings list.
|
||||
|
||||
## Compatibility Assumptions
|
||||
|
||||
The adapter expects payload fields compatible with the internal forecast bundle
|
||||
types in `internal/forecast/bundle.go`, including:
|
||||
The adapter expects payload fields compatible with the internal weather data
|
||||
bundle types in `internal/weatherdata/bundle.go`, including:
|
||||
|
||||
- observation timestamps and observation values
|
||||
- current condition values
|
||||
@@ -126,6 +190,8 @@ types in `internal/forecast/bundle.go`, including:
|
||||
- discussion metadata, key messages, and short/long-term section text
|
||||
- latest weather story title, description, timing, priority, order, alt text,
|
||||
and download URL
|
||||
- SPC convective outlook run metadata, outlooks, discussions, and GeoJSON
|
||||
geometry
|
||||
|
||||
The adapter intentionally keeps upstream transport and envelope details inside
|
||||
`internal/adapters/weatherapi`; downstream packages consume the normalized
|
||||
|
||||
@@ -28,7 +28,8 @@ Outputs:
|
||||
|
||||
- generated report results with JSON module snapshot, YAML data package,
|
||||
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
|
||||
notification outcome when attempted
|
||||
- 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
|
||||
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
|
||||
|
||||
- `weather_api.*` for Weather API client construction and module metadata
|
||||
@@ -63,7 +77,7 @@ defaults.
|
||||
|
||||
## Generation Workflow
|
||||
|
||||
Single-report generation follows this order:
|
||||
Single-report generation shares this setup:
|
||||
|
||||
1. Resolve the command report to a `report.Resolved` value.
|
||||
2. Create or use a filesystem store.
|
||||
@@ -76,13 +90,31 @@ Single-report generation follows this order:
|
||||
9. Run Scriptorium render preflight.
|
||||
10. Save preflight JSON when a render result is available.
|
||||
11. Save metadata for inspection.
|
||||
12. Run Scriptorium report generation to the managed report path.
|
||||
13. Copy the managed report to the requested `--out` path when provided.
|
||||
14. Save metadata with the managed report path.
|
||||
15. If distributor notification is enabled, notify using the managed report
|
||||
path as the source file.
|
||||
16. Save a distributor notification debug artifact and update metadata with its
|
||||
path.
|
||||
|
||||
For `scriptorium_markdown` reports, generation then:
|
||||
|
||||
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.
|
||||
4. Saves a distributor notification debug artifact and updates metadata with
|
||||
its path.
|
||||
|
||||
If render preflight returns both a result and an error, preflight JSON and
|
||||
metadata are persisted before the error is returned. If Scriptorium report
|
||||
@@ -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,
|
||||
module snapshot, prompt input, render, Scriptorium run, or metadata-save
|
||||
failures.
|
||||
Generated-text report failures are returned with report ID, RunID, and the
|
||||
failed operation. When available, the app preserves the latest generated-text
|
||||
artifacts already reached by the workflow: preflight output, structured run
|
||||
result, raw generated text, validated generated text, and render context.
|
||||
When notification is attempted, the debug artifact records request identity,
|
||||
including rendered pipeline ID, bundle paths, accepted upload fields,
|
||||
distributor status fields, raw status report JSON when available, and redacted
|
||||
@@ -98,15 +134,17 @@ failure context.
|
||||
|
||||
## Batch Workflow
|
||||
|
||||
`run morning` resolves Daily Today, 3-Day Outlook, and Weekend Outlook except
|
||||
on Sunday. `run evening` resolves Daily Tomorrow. Batch output copy names come
|
||||
from report definitions. Batch generation continues independent reports after a
|
||||
failure, records each result, writes compact status lines to stderr, emits a
|
||||
JSON summary to stdout, and returns an aggregate error when any report failed.
|
||||
When notification is enabled, each successfully generated report is notified
|
||||
independently. Notification failure 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.
|
||||
`run morning` resolves Today Report, 3-Day Outlook, and Weekend Outlook except
|
||||
on Sunday. `run evening` resolves Tomorrow Report. Daily Report is generated
|
||||
only through `generate daily --date YYYY-MM-DD`; it is not part of scheduled
|
||||
batches. Batch output copy names come from report definitions. Batch generation
|
||||
continues independent reports after a failure, records each result, writes
|
||||
compact status lines to stderr, emits a JSON summary to stdout, and returns an
|
||||
aggregate error when any report failed. When notification is enabled, each
|
||||
successfully generated report is notified independently. Notification failure
|
||||
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
|
||||
|
||||
@@ -122,6 +160,8 @@ inspection view.
|
||||
runs.
|
||||
- Prompt input validation fails before render preflight.
|
||||
- Render and run errors preserve Scriptorium stderr and exit-code context.
|
||||
- Generated-text report errors preserve available intermediate artifacts and do
|
||||
not create extra output copies.
|
||||
- Notification errors are wrapped with report ID, RunID, and managed report path
|
||||
context and are recorded separately in batch results.
|
||||
- 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
|
||||
report ID.
|
||||
- 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.
|
||||
- Metadata links artifacts produced for a run.
|
||||
- Distributor notification maps the managed Markdown report path to configured
|
||||
|
||||
@@ -28,14 +28,46 @@ Outputs:
|
||||
supported reports, fact requirements, missing-data behavior, and builder
|
||||
- `module.Output` values for source-oriented stanzas:
|
||||
`metadata`, `current_conditions`, `narrative_forecast`, `hourly_forecast`,
|
||||
`alert_digest`, `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:
|
||||
`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
|
||||
module IDs fail validation instead of being skipped.
|
||||
|
||||
Daily Report supports the Daily-style civil-day modules plus `daily_planning`
|
||||
and `hourly_forecast`; those outputs feed the dated Daily GeneratedText prompt
|
||||
package and embedded Markdown template.
|
||||
|
||||
Tomorrow Report supports the Daily-style civil-day modules plus
|
||||
`tomorrow_planning` and `hourly_forecast`; those outputs feed the Tomorrow
|
||||
GeneratedText prompt package and embedded Markdown template.
|
||||
|
||||
Today Report supports the Daily-style civil-day modules plus `today_planning`
|
||||
and `hourly_forecast`; those outputs feed the Today GeneratedText prompt
|
||||
package and embedded Markdown template.
|
||||
|
||||
`today_planning` is a Today-specific deterministic planning stanza with
|
||||
morning readiness, commute/school/workday concerns, outdoor planning, and
|
||||
late-day change-watch fields. It is compatible with `report.Today` only.
|
||||
|
||||
`daily_planning` is a dated Daily deterministic planning stanza with morning
|
||||
readiness, commute/school/workday concerns, and overnight change-watch fields.
|
||||
It is compatible only with the `daily` report ID value. The default Daily
|
||||
Report composition includes it.
|
||||
|
||||
Hourly Report supports source and valid-period modules that operate over its
|
||||
rolling six-hour period: `metadata`, `current_conditions`, `hourly_forecast`,
|
||||
`precip_timing`, `alert_digest`, `spc_convective_outlooks`,
|
||||
`area_forecast_discussion`, `spc_convective_discussion`, and `weather_story`.
|
||||
It does not support daily/daypart-only modules such as
|
||||
`derived_daily_summary`, `derived_daypart_summaries`, `outdoor_windows`,
|
||||
`today_planning`, `tomorrow_planning`, or `daily_planning`.
|
||||
|
||||
Prompt-facing module values use local, human-readable date and time labels
|
||||
where the LLM is expected to reason about report content. Canonical timestamps
|
||||
remain in report metadata, source provenance, and integration artifacts.
|
||||
@@ -57,7 +89,24 @@ builders run. Configured `location` values are prompt context only; Weather API
|
||||
`sourceLocationId` and `sourceLocation` remain source provenance.
|
||||
|
||||
`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
|
||||
|
||||
@@ -86,6 +135,11 @@ return an error for invalid required inputs.
|
||||
forecast discussion, and weather story stanzas.
|
||||
- Alert digest output distinguishes checked empty alert data from missing alert
|
||||
source data.
|
||||
- SPC convective outlook output distinguishes checked empty outlook data from
|
||||
missing outlook source data and omits GeoJSON geometry from prompt-facing
|
||||
fields.
|
||||
- SPC convective discussion output is omitted unless a retained outlook has
|
||||
severity rank `3` or higher and matching discussion text is available.
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -20,14 +20,29 @@ Inputs:
|
||||
Outputs:
|
||||
|
||||
- `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,
|
||||
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
|
||||
|
||||
- This package owns fact assembly and reusable deterministic derivation for a
|
||||
report run.
|
||||
- SPC convective outlook derivation selects already-collected outlooks whose
|
||||
half-open valid intervals overlap the resolved report period and retains
|
||||
discussions for represented outlook days.
|
||||
- Derived SPC outlook records preserve the collected outlook fields, including
|
||||
geometry, for downstream components that need source-level facts. Prompt
|
||||
modules decide which fields are exposed to Scriptorium.
|
||||
- It does not fetch upstream data, build prompt wording, compare prior
|
||||
snapshots, write workflow state, invoke Scriptorium, or define modules.
|
||||
|
||||
@@ -53,8 +68,13 @@ and inspection.
|
||||
- Invalid timezone names return an error.
|
||||
- Missing required hourly forecast data returns the underlying forecast
|
||||
derivation error for reports that require daily summaries.
|
||||
- Hourly Report can derive its default module facts without daily or
|
||||
daypart summaries.
|
||||
- Missing optional narrative, alert, discussion, daily, or weather story data
|
||||
produces empty or nil derived fields.
|
||||
- Missing optional SPC convective outlook data produces a nil collected field.
|
||||
- A present SPC convective outlook source with no report-period matches
|
||||
produces non-nil empty derived outlook and discussion slices.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -67,6 +87,8 @@ Inspect:
|
||||
|
||||
- Collected facts are built once from a fetched bundle.
|
||||
- Derived facts are scoped to one resolved report.
|
||||
- SPC convective outlook selection uses the resolved report period and the
|
||||
already-collected outlook run.
|
||||
- Source provenance and warnings stay separate from ordinary fact fields.
|
||||
- Prompt-specific wording and one-off presentation decisions stay outside this
|
||||
package.
|
||||
|
||||
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
|
||||
|
||||
`internal/module` defines the shared identifiers and data envelopes used for
|
||||
prompt-facing modules. Report definitions use module IDs for composition,
|
||||
module builders produce outputs with stanza names, prompt input packages consume
|
||||
snapshots, and Recent Changes compares snapshot stanzas.
|
||||
report modules. Report definitions use module IDs for composition, module
|
||||
builders produce rich outputs with stanza names, prompt input packages consume
|
||||
runtime prompt export values, and Recent Changes compares snapshot stanzas.
|
||||
|
||||
## Inputs And Outputs
|
||||
|
||||
@@ -23,8 +23,38 @@ Outputs:
|
||||
- typed option structs for registered modules
|
||||
- `module.Snapshot` with schema version `weatherreporter.modules.v1`
|
||||
- ordered snapshot outputs with module ID, stanza name, and typed value
|
||||
- runtime-only prompt export values on module outputs
|
||||
- `module.Output.DataPackageValue`, which selects the prompt export value and
|
||||
falls back to the rich value for hand-built or loaded snapshots
|
||||
- typed stanza lookup through `module.StanzaValue`
|
||||
|
||||
## Rich Values And Prompt Exports
|
||||
|
||||
Each `module.Output` has two value surfaces:
|
||||
|
||||
- `Value`: the rich module value used by templates, module snapshots,
|
||||
inspection, Recent Changes, and render contexts.
|
||||
- `PromptValue`: the runtime-only prompt export used when building Scriptorium
|
||||
data packages.
|
||||
|
||||
`PromptValue` is deliberately excluded from module snapshot JSON. Persisted
|
||||
module snapshots keep only the rich `value` field so inspection and
|
||||
render-context reconstruction keep the full deterministic template surface.
|
||||
|
||||
The `internal/briefing` module registry attaches prompt export values when it
|
||||
builds module outputs. Modules without a custom exporter use default
|
||||
pass-through behavior, so their prompt value is the same as their rich value.
|
||||
Modules 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
|
||||
|
||||
The registry recognizes these IDs:
|
||||
@@ -37,17 +67,140 @@ The registry recognizes these IDs:
|
||||
- `derived_daypart_summaries`
|
||||
- `precip_timing`
|
||||
- `alert_digest`
|
||||
- `spc_convective_outlooks`
|
||||
- `area_forecast_discussion`
|
||||
- `spc_convective_discussion`
|
||||
- `weather_story`
|
||||
- `outdoor_windows`
|
||||
- `today_planning`
|
||||
- `tomorrow_planning`
|
||||
- `daily_planning`
|
||||
|
||||
Every registered module has a builder. Report composition entries that refer to
|
||||
unknown or unimplemented module IDs fail validation instead of being skipped.
|
||||
|
||||
## Daily Composition
|
||||
|
||||
The default Daily Report module order is:
|
||||
|
||||
1. `metadata`
|
||||
2. `current_conditions`
|
||||
3. `narrative_forecast`
|
||||
4. `derived_daily_summary`
|
||||
5. `derived_daypart_summaries`
|
||||
6. `precip_timing`
|
||||
7. `alert_digest`
|
||||
8. `spc_convective_outlooks`
|
||||
9. `area_forecast_discussion`
|
||||
10. `spc_convective_discussion`
|
||||
11. `weather_story`
|
||||
12. `outdoor_windows`
|
||||
13. `daily_planning`
|
||||
14. `hourly_forecast`
|
||||
|
||||
The embedded Daily template uses selected deterministic fields from these
|
||||
module outputs after GeneratedText validation.
|
||||
|
||||
## Today Composition
|
||||
|
||||
The default Today Report module order is:
|
||||
|
||||
1. `metadata`
|
||||
2. `current_conditions`
|
||||
3. `narrative_forecast`
|
||||
4. `derived_daily_summary`
|
||||
5. `derived_daypart_summaries`
|
||||
6. `precip_timing`
|
||||
7. `alert_digest`
|
||||
8. `spc_convective_outlooks`
|
||||
9. `area_forecast_discussion`
|
||||
10. `spc_convective_discussion`
|
||||
11. `weather_story`
|
||||
12. `outdoor_windows`
|
||||
13. `hourly_forecast`
|
||||
14. `today_planning`
|
||||
|
||||
The embedded Today template uses selected deterministic fields from these
|
||||
module outputs after GeneratedText validation.
|
||||
|
||||
## Tomorrow Composition
|
||||
|
||||
The default Tomorrow Report module order is:
|
||||
|
||||
1. `metadata`
|
||||
2. `current_conditions`
|
||||
3. `narrative_forecast`
|
||||
4. `derived_daily_summary`
|
||||
5. `derived_daypart_summaries`
|
||||
6. `precip_timing`
|
||||
7. `alert_digest`
|
||||
8. `spc_convective_outlooks`
|
||||
9. `area_forecast_discussion`
|
||||
10. `spc_convective_discussion`
|
||||
11. `weather_story`
|
||||
12. `outdoor_windows`
|
||||
13. `tomorrow_planning`
|
||||
14. `hourly_forecast`
|
||||
|
||||
The embedded Tomorrow template uses selected deterministic fields from these
|
||||
module outputs after GeneratedText validation.
|
||||
|
||||
## Daily Planning
|
||||
|
||||
`daily_planning` emits dated daily planning facts for the `daily` report ID.
|
||||
Its output stanza is also named `daily_planning`. The module is supported only
|
||||
by that report ID and depends on daily summaries for the selected local civil
|
||||
day. The default Daily Report composition includes it.
|
||||
|
||||
The output uses this shape:
|
||||
|
||||
- `morning_readiness`
|
||||
- `commute_school_workday_concerns`
|
||||
- `overnight_change_watch`
|
||||
|
||||
The type is `briefing.DailyPlanningModule`; it is independent from
|
||||
`briefing.TomorrowPlanningModule`.
|
||||
|
||||
## Today Planning
|
||||
|
||||
`today_planning` emits current-day planning facts for Today Report. Its output
|
||||
stanza is also named `today_planning`. The module is supported only by Today
|
||||
Report and depends on daily and daypart summaries for the current local civil
|
||||
day.
|
||||
|
||||
The output uses this shape:
|
||||
|
||||
- `morning_readiness`
|
||||
- `commute_school_workday_concerns`
|
||||
- `outdoor_planning`
|
||||
- `late_day_change_watch`
|
||||
|
||||
The type is `briefing.TodayPlanningModule`; it is independent from
|
||||
`briefing.TomorrowPlanningModule`.
|
||||
|
||||
## Hourly Composition
|
||||
|
||||
The default Hourly Report module order is:
|
||||
|
||||
1. `metadata`
|
||||
2. `current_conditions`
|
||||
3. `hourly_forecast`
|
||||
4. `precip_timing`
|
||||
5. `alert_digest`
|
||||
6. `spc_convective_outlooks`
|
||||
7. `area_forecast_discussion`
|
||||
8. `spc_convective_discussion`
|
||||
9. `weather_story`
|
||||
|
||||
Hourly Report does not include daily or daypart summary modules by default.
|
||||
Its `area_forecast_discussion` item is configured to include only
|
||||
`key_messages` and `short_term`.
|
||||
|
||||
## Options
|
||||
|
||||
Most modules use an empty options struct. `area_forecast_discussion` accepts:
|
||||
Most modules use an empty options struct, including
|
||||
`spc_convective_outlooks` and `spc_convective_discussion`.
|
||||
`area_forecast_discussion` accepts:
|
||||
|
||||
```yaml
|
||||
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
|
||||
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
|
||||
|
||||
- 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
|
||||
validation rejects missing schema version, missing module IDs, missing stanza
|
||||
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
|
||||
|
||||
@@ -94,5 +273,8 @@ Inspect:
|
||||
|
||||
- `internal/module` does not import `internal/report`.
|
||||
- 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.
|
||||
|
||||
@@ -5,13 +5,15 @@ This document describes YAML prompt data package construction in
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/promptinput` converts report metadata, an ordered module snapshot,
|
||||
Recent Changes, and source warnings into the `data_package` file passed to
|
||||
`internal/promptinput` converts report metadata, ordered module outputs, Recent
|
||||
Changes, and source warnings into the `data_package` file passed to
|
||||
Scriptorium.
|
||||
|
||||
The persisted data package is YAML with schema version
|
||||
`weatherreporter.data_package.v2`. It is separate from the JSON module snapshot
|
||||
used for inspection and comparison.
|
||||
`weatherreporter.data_package.v3`. It is separate from the JSON module snapshot
|
||||
used for inspection and comparison. Data packages serialize each module
|
||||
output's prompt export value, not necessarily the full rich module value saved
|
||||
in the module snapshot.
|
||||
|
||||
## Inputs And Outputs
|
||||
|
||||
@@ -32,7 +34,7 @@ Outputs:
|
||||
The YAML shape includes:
|
||||
|
||||
```yaml
|
||||
schema_version: weatherreporter.data_package.v2
|
||||
schema_version: weatherreporter.data_package.v3
|
||||
run_id: <run_id>
|
||||
report:
|
||||
id: <report_id>
|
||||
@@ -41,6 +43,7 @@ briefing:
|
||||
metadata: {}
|
||||
applicable_risk_products:
|
||||
alert_digest: {}
|
||||
spc_convective_outlooks: {}
|
||||
derived_summaries:
|
||||
derived_daily_summary: {}
|
||||
derived_daypart_summaries: {}
|
||||
@@ -49,6 +52,7 @@ briefing:
|
||||
narrative_products:
|
||||
narrative_forecast: {}
|
||||
area_forecast_discussion: {}
|
||||
spc_convective_discussion: {}
|
||||
weather_story: {}
|
||||
raw_data:
|
||||
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
|
||||
`promptinput.Package` values expose flat stanza names in `Briefing.Values`.
|
||||
Within each category, stanza order follows the module snapshot output order.
|
||||
Prompt-facing module intervals use local `period_begins` and `period_ends`
|
||||
labels; canonical report metadata and source timestamps remain structured
|
||||
timestamps where applicable.
|
||||
|
||||
## Module Export Boundary
|
||||
|
||||
Data packages are curated prompt inputs. They are not full template render
|
||||
contexts and should not be treated as a dump of every field available to Go
|
||||
templates.
|
||||
|
||||
When module outputs are built by `internal/briefing`, the registry attaches a
|
||||
runtime prompt export value. `internal/promptinput` serializes
|
||||
`output.DataPackageValue()` for each stanza. That helper prefers the runtime
|
||||
prompt export and falls back to the rich `Value` when no prompt export is set,
|
||||
which keeps loaded snapshots and hand-built tests usable.
|
||||
|
||||
Modules without custom export policy use pass-through behavior. Modules with
|
||||
custom exports currently include:
|
||||
|
||||
- `current_conditions`: omits lower-case condition text and duplicate
|
||||
wind-direction text.
|
||||
- `hourly_forecast`: omits hour labels, lower-case description text, and the
|
||||
template precipitation-mention helper while keeping forecast facts.
|
||||
- `derived_daypart_summaries`: omits deterministic sentence-construction
|
||||
helpers while keeping daypart period, condition, temperature trend,
|
||||
precipitation, wind, notable-condition, hazard, and alert-relevance facts.
|
||||
|
||||
The rich module snapshot and generated-template render context still contain
|
||||
the helper fields used by deterministic Markdown templates.
|
||||
|
||||
Daily Report, Today Report, Tomorrow Report, and Hourly Report module snapshots
|
||||
use the same package schema and categories when converted into prompt input.
|
||||
The default hourly module list places
|
||||
`precip_timing` under `derived_summaries`, alert and SPC outlooks under
|
||||
`applicable_risk_products`, AFD/SPC discussion/weather story under
|
||||
`narrative_products`, and current/hourly data under `raw_data`. It does not
|
||||
include civil-day summary stanzas. Generated-text and render context artifacts
|
||||
are produced later in app orchestration and are not part of the YAML data
|
||||
package.
|
||||
|
||||
The default Daily, Today, and Tomorrow module lists include civil-day summary
|
||||
stanzas, planning stanzas, and `hourly_forecast` in the data package before
|
||||
structured GeneratedText is requested from Scriptorium. Daily uses
|
||||
`daily_planning`, Today uses `today_planning`, and Tomorrow uses
|
||||
`tomorrow_planning`.
|
||||
|
||||
Current categories are:
|
||||
|
||||
- `applicable_risk_products`: location-applicable alerts, warnings, outlooks,
|
||||
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.
|
||||
- `narrative_products`: official narrative text products and forecast stories.
|
||||
Current stanzas include `narrative_forecast`,
|
||||
`area_forecast_discussion`, `spc_convective_discussion`, and
|
||||
`weather_story`.
|
||||
- `raw_data`: minimally transformed underlying weather data.
|
||||
|
||||
## Boundaries
|
||||
@@ -76,8 +129,8 @@ Current categories are:
|
||||
- This package owns prompt package schema, YAML marshaling, YAML loading, and
|
||||
validation.
|
||||
- It does not fetch weather data, derive forecast summaries, execute modules,
|
||||
find prior snapshots, compare changes, choose artifact paths, or invoke
|
||||
Scriptorium.
|
||||
choose module prompt export shapes, find prior snapshots, compare changes,
|
||||
choose artifact paths, or invoke Scriptorium.
|
||||
|
||||
## Config Fields Used
|
||||
|
||||
@@ -117,5 +170,8 @@ Inspect:
|
||||
- Scriptorium receives structured YAML through `--input data_package=<path>`.
|
||||
- Module stanza order is deterministic within each prompt-facing category.
|
||||
- Every non-metadata module stanza has exactly one prompt-input category.
|
||||
- Data-package stanzas use curated module prompt exports when present and rich
|
||||
values only as pass-through or fallback values.
|
||||
- Data packages are narrower than generated-template render contexts.
|
||||
- Recent Changes are provided by `internal/changes`; this package does not
|
||||
infer changes from rendered report text.
|
||||
|
||||
@@ -6,9 +6,10 @@ membership, output naming, artifact grouping, and comparison declarations in
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/report` is the canonical source for report definitions. App, state,
|
||||
module building, and CLI wiring consume resolved definitions instead of owning
|
||||
report identity policy themselves.
|
||||
`internal/report` is the canonical source for report definitions, public
|
||||
command names, config-key aliases, and batch command names. App, config, state,
|
||||
module building, and CLI wiring consume report-owned helpers and resolved
|
||||
definitions instead of owning report identity policy themselves.
|
||||
|
||||
## Definition Fields
|
||||
|
||||
@@ -16,6 +17,7 @@ Each report definition declares:
|
||||
|
||||
- report ID and display name
|
||||
- Scriptorium prompt ID
|
||||
- generation mode
|
||||
- valid-period resolver
|
||||
- comparison strategy
|
||||
- managed artifact group
|
||||
@@ -25,23 +27,46 @@ Each report definition declares:
|
||||
- morning or evening batch membership
|
||||
- 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
|
||||
|
||||
| Report | ID | Prompt | Artifact group | Batch copy | Prior compatibility |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Daily Today | `daily_today` | `weather.daily_report` | `daily` | `daily.md` | Daily Today, Daily Tomorrow |
|
||||
| Daily Tomorrow | `daily_tomorrow` | `weather.daily_report` | `daily` | `tomorrow.md` | Daily Today, Daily Tomorrow |
|
||||
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `three-day` | `three-day.md` | 3-Day Outlook |
|
||||
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `weekend` | `weekend.md` | Weekend Outlook |
|
||||
| Storm Report | `storm` | `weather.storm_report` | `storm` | `storm.md` | Storm Report |
|
||||
| Report | ID | Prompt | Generation mode | Artifact group | Batch copy | Prior compatibility |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| Daily Report | `daily` | `weather.daily_generated_text` | `generated_text_template` | `daily` | `daily.md` | Daily Report |
|
||||
| Today Report | `today` | `weather.today_generated_text` | `generated_text_template` | `today` | `today.md` | Today Report |
|
||||
| Tomorrow Report | `tomorrow` | `weather.tomorrow_generated_text` | `generated_text_template` | `tomorrow` | `tomorrow.md` | Tomorrow Report |
|
||||
| Hourly Report | `hourly` | `weather.hourly_generated_text` | `generated_text_template` | `hourly` | `hourly.md` | Hourly Report |
|
||||
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `scriptorium_markdown` | `three-day` | `three-day.md` | 3-Day Outlook |
|
||||
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `scriptorium_markdown` | `weekend` | `weekend.md` | Weekend Outlook |
|
||||
| Storm Report | `storm` | `weather.storm_report` | `scriptorium_markdown` | `storm` | `storm.md` | Storm Report |
|
||||
|
||||
All report definitions are eligible for generation.
|
||||
|
||||
## Valid Periods
|
||||
|
||||
- Daily Today covers the selected local civil day, or the current local civil
|
||||
- Daily Report covers the selected local civil day and requires an explicit
|
||||
date.
|
||||
- Today Report covers the selected local civil day, or the current local civil
|
||||
day when no date override is supplied.
|
||||
- Daily Tomorrow covers the next local civil day from generation time.
|
||||
- Tomorrow Report covers the next local civil day from generation time.
|
||||
- Hourly Report covers the half-open six-hour period from generation time in
|
||||
the effective report timezone. The duration is an internal report constant,
|
||||
not a configuration field.
|
||||
- 3-Day Outlook covers the interval from generation time through local midnight
|
||||
three days later.
|
||||
- Weekend Outlook covers the upcoming weekend window and is not scheduled for
|
||||
@@ -54,17 +79,26 @@ must be after start time.
|
||||
|
||||
## Boundaries
|
||||
|
||||
`internal/report` defines report metadata and time coverage. It does not fetch
|
||||
weather data, build module values, compare snapshot contents, write state,
|
||||
parse CLI flags, or invoke Scriptorium.
|
||||
`internal/report` defines report metadata, public report names, batch command
|
||||
names, and time coverage. It does not fetch weather data, build module values,
|
||||
compare snapshot contents, write state, parse CLI flags, or invoke Scriptorium.
|
||||
|
||||
The CLI owns public command names. The app maps those command names to report
|
||||
IDs, then uses the registry for report policy.
|
||||
The CLI parses flags and command structure, then uses report-owned helpers for
|
||||
report and batch command names. Config loading uses report-owned helpers for
|
||||
report override keys.
|
||||
|
||||
## Config Fields Used
|
||||
|
||||
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
|
||||
output path copying uses batch output names from report definitions.
|
||||
output path copying uses batch output names from report definitions. Report
|
||||
module overrides can use short keys such as `daily`, `today`, `tomorrow`, and
|
||||
`hourly`, or descriptive names such as `three_day_outlook`.
|
||||
|
||||
## Batch 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
|
||||
|
||||
@@ -92,7 +126,11 @@ Inspect:
|
||||
## Invariants
|
||||
|
||||
- Report selection goes through the registry.
|
||||
- Daily Today and Daily Tomorrow both use `weather.daily_report`.
|
||||
- Public command names, config-key aliases, and batch command names are owned
|
||||
by `internal/report`.
|
||||
- Direct Markdown reports have empty template and generated-text schema IDs.
|
||||
- Generated-text-template reports declare prompt, template, and schema IDs in
|
||||
their report definition.
|
||||
- Valid periods are half-open intervals independent of rendered report text.
|
||||
- Artifact grouping, batch output filenames, generated-report eligibility,
|
||||
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
|
||||
|
||||
The adapter runs `scriptorium render` for prompt preflight and `scriptorium run`
|
||||
for Markdown report generation. It isolates subprocess execution, argv
|
||||
construction, timeout handling, output capture, and exit-code interpretation
|
||||
from app and domain packages.
|
||||
for Markdown report generation or structured generated-text output. It isolates
|
||||
subprocess execution, argv construction, timeout handling, output capture, and
|
||||
exit-code interpretation from app and domain packages.
|
||||
|
||||
## Inputs And Outputs
|
||||
|
||||
@@ -17,6 +17,7 @@ Inputs:
|
||||
- prompt ID
|
||||
- YAML prompt input data package path
|
||||
- report output path for `run`
|
||||
- raw generated-text output path for structured `run`
|
||||
- configured binary, config path, profile, timeout, and extra arguments
|
||||
- context for cancellation
|
||||
|
||||
@@ -27,6 +28,7 @@ Outputs:
|
||||
- truncation flags for captured output
|
||||
- exit code
|
||||
- report output path for `run`
|
||||
- raw generated-text output path for structured `run`
|
||||
|
||||
## Boundaries
|
||||
|
||||
@@ -34,9 +36,9 @@ Outputs:
|
||||
subprocess execution. It does not choose report types, build prompt input,
|
||||
fetch weather data, decide workflow order, or persist workflow metadata.
|
||||
|
||||
The adapter exposes request and result structs for render and run operations.
|
||||
State persistence uses a state-owned preflight artifact shape; app
|
||||
orchestration converts render results before saving.
|
||||
The adapter exposes request and result structs for render, Markdown run, and
|
||||
structured generated-text run operations. State persistence uses state-owned
|
||||
artifact shapes; app orchestration converts adapter results before saving.
|
||||
|
||||
## Config Fields Used
|
||||
|
||||
@@ -60,6 +62,12 @@ Report generation argv starts with:
|
||||
scriptorium run --prompt <prompt_id> --input data_package=<path> --out <path>
|
||||
```
|
||||
|
||||
Structured generated-text argv uses the same `scriptorium run` form, with the
|
||||
`--out` value set to the raw generated-text JSON artifact path. The adapter
|
||||
does not add `--format`, schema path, or JSON Schema flags for structured
|
||||
generation; Scriptorium selects the structured output schema from prompt
|
||||
configuration.
|
||||
|
||||
Configured `--config` and `--profile` flags are inserted after the subcommand
|
||||
and before prompt-specific arguments. Extra arguments are appended after the
|
||||
built-in arguments.
|
||||
@@ -67,8 +75,8 @@ built-in arguments.
|
||||
## Execution Behavior
|
||||
|
||||
The adapter runs commands without shell interpolation. The same private
|
||||
execution path is used by render and run after command-specific request
|
||||
validation and argv construction.
|
||||
execution path is used by render, Markdown run, and structured run after
|
||||
command-specific request validation and argv construction.
|
||||
|
||||
When `scriptorium.timeout` is greater than zero, each subprocess call uses a
|
||||
context with that timeout. Stdout and stderr are captured separately, capped at
|
||||
@@ -81,8 +89,8 @@ context with that timeout. Stdout and stderr are captured separately, capped at
|
||||
- Missing run output path returns an error before subprocess execution.
|
||||
- Subprocess start errors, context cancellation, and timeouts are wrapped with
|
||||
operation context by the caller-facing method.
|
||||
- Nonzero render and run exits return the captured result plus an error
|
||||
containing the exit code and stderr.
|
||||
- Nonzero render, Markdown run, and structured run exits return the captured
|
||||
result plus an error containing the exit code and stderr.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -97,5 +105,6 @@ Inspect:
|
||||
- No shell interpolation is used.
|
||||
- The Scriptorium input name is `data_package`.
|
||||
- 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.
|
||||
|
||||
@@ -16,6 +16,7 @@ Inputs:
|
||||
- module snapshot
|
||||
- prompt input data package
|
||||
- preflight artifact
|
||||
- generated-text raw, run-result, validated text, and render-context artifacts
|
||||
- rendered report path preparation request
|
||||
- RunID for inspection lookups
|
||||
|
||||
@@ -24,10 +25,15 @@ Outputs:
|
||||
- module snapshot JSON path
|
||||
- prompt input data package YAML path
|
||||
- render preflight JSON path
|
||||
- generated-text raw JSON path
|
||||
- generated-text run-result JSON path
|
||||
- validated generated-text JSON path
|
||||
- render context JSON path
|
||||
- managed Markdown report path
|
||||
- metadata JSON path
|
||||
- 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
|
||||
|
||||
## Boundaries
|
||||
@@ -61,6 +67,10 @@ valid-period start date for dated artifacts, and the RunID.
|
||||
<workspace.root>/
|
||||
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>.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
|
||||
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.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
|
||||
package, preflight, report paths, notification path when attempted, and
|
||||
configured prompt location. Report listing walks metadata files under the
|
||||
snapshots directory.
|
||||
configured prompt location. For generated-text-template reports, metadata also
|
||||
records the generated text schema ID and links the raw generated text,
|
||||
Scriptorium run result, validated generated text, and render context artifacts.
|
||||
Markdown-report metadata omits those generated-text fields. Report listing
|
||||
walks metadata files under the snapshots directory.
|
||||
|
||||
## 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
|
||||
current report definition.
|
||||
|
||||
- Daily Today and Daily Tomorrow are compatible with each other for the same
|
||||
- Daily Report compares with prior Daily Report snapshots for the same valid
|
||||
local date.
|
||||
- Today Report compares with prior Today Report snapshots for the same valid
|
||||
local date.
|
||||
- Tomorrow Report compares with prior Tomorrow Report snapshots for the same
|
||||
valid local date.
|
||||
- 3-Day Outlook compares with prior 3-Day snapshots for the same valid local
|
||||
date.
|
||||
- Weekend Outlook compares with prior Weekend snapshots for the same weekend
|
||||
window.
|
||||
- Hourly Report uses the rolling-window comparison strategy and currently
|
||||
returns no prior snapshot from filesystem lookup.
|
||||
- Storm Report has no prior lookup because explicit event-window comparison is
|
||||
not searched by the filesystem store.
|
||||
|
||||
## Writes And Inspection
|
||||
|
||||
Durable JSON writes use shared atomic file helpers. Managed Markdown reports are
|
||||
prepared by creating their parent directory; Scriptorium writes the report body
|
||||
to the prepared path. Extra Markdown copies are handled by app orchestration.
|
||||
Distributor notification debug artifacts are written atomically when
|
||||
notification is attempted and include rendered distributor pipeline ID, bundle
|
||||
ID, idempotency key, bundle paths, upload status, latest run status, and
|
||||
redacted errors.
|
||||
Durable JSON writes use shared atomic file helpers. Generated-text raw and
|
||||
validated JSON artifacts are written atomically as bytes; generated-text run
|
||||
result and render context artifacts are written atomically as JSON. Managed
|
||||
Markdown reports are prepared by creating their parent directory; Scriptorium
|
||||
writes the report body to the prepared path. Extra Markdown copies are handled
|
||||
by app orchestration. Distributor notification debug artifacts are written
|
||||
atomically when notification is attempted and include rendered distributor
|
||||
pipeline ID, bundle ID, idempotency key, bundle paths, upload status, latest
|
||||
run status, and redacted errors.
|
||||
|
||||
Inspection helpers read existing metadata, module snapshot, and data package
|
||||
files. Missing metadata directories return no inspection records or no prior
|
||||
snapshot rather than creating state.
|
||||
Inspection helpers read existing metadata, module snapshot, data package,
|
||||
generated text, generated-text run result, and render context files. Missing
|
||||
metadata directories return no inspection records or no prior snapshot rather
|
||||
than creating state.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
@@ -121,4 +143,6 @@ Inspect:
|
||||
- Managed paths stay under the configured workspace root.
|
||||
- Artifact grouping comes from report definitions.
|
||||
- Metadata links artifacts produced for a run.
|
||||
- Generated-text artifacts live under the snapshots tree beside module
|
||||
snapshots and metadata.
|
||||
- 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
|
||||
|
||||
`internal/adapters/weatherapi` fetches normalized weather data from one
|
||||
configured Weather API endpoint and assembles the bundle consumed by forecast
|
||||
derivation and module builders. Module builders expose normalized current
|
||||
conditions and weather story context when those sources are available.
|
||||
`internal/adapters/weatherapi` fetches normalized weather data from the
|
||||
configured Weather API and assembles the bundle consumed by forecast derivation
|
||||
and module builders. Module builders expose normalized current conditions and
|
||||
weather story context when those sources are available.
|
||||
|
||||
## Inputs And Outputs
|
||||
|
||||
@@ -21,7 +21,8 @@ Outputs:
|
||||
|
||||
- `weatherdata.Bundle` with observation, current conditions, hourly forecast,
|
||||
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
|
||||
|
||||
## Boundaries
|
||||
@@ -54,9 +55,19 @@ contract used by this project.
|
||||
The adapter records source name, endpoint, query, fetch time, source timestamps
|
||||
when available, SHA-256 hash over compact raw `data` JSON, missing status, and
|
||||
source warnings. Successful `data: null` responses from `/alerts/active`
|
||||
represent a checked empty active-alert list, not a missing source.
|
||||
represent a checked empty active-alert list, not a missing source. Successful
|
||||
non-null `/outlooks/convective` responses with empty outlook and discussion
|
||||
arrays represent checked empty outlook data.
|
||||
`app.FetchAndSaveBundle` can write bundle JSON atomically for inspection.
|
||||
|
||||
SPC convective outlook data is stored on
|
||||
`weatherdata.Bundle.SPCConvectiveOutlooks`. The collected run keeps upstream
|
||||
run metadata, location identifiers, ordered outlook records, discussion
|
||||
records, and each outlook's raw GeoJSON geometry. Source provenance for this
|
||||
payload uses the `spc_convective_outlooks` source name, endpoint
|
||||
`/outlooks/convective`, the query sent by the adapter, timestamps, and a hash
|
||||
of the raw `data` object.
|
||||
|
||||
## Skip And Resume Behavior
|
||||
|
||||
No resume behavior. Optional missing or malformed sources may be omitted,
|
||||
@@ -72,6 +83,8 @@ data is required and cannot be skipped.
|
||||
- Optional sources follow missing-source policy.
|
||||
- Explicit `data: null` from `/alerts/active` produces an empty, non-missing
|
||||
alert run.
|
||||
- Explicit `data: null` from `/outlooks/convective` follows optional
|
||||
missing-source policy.
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -10,20 +10,34 @@ Generation commands:
|
||||
|
||||
```text
|
||||
weatherreporter generate daily --date 2026-05-29
|
||||
weatherreporter generate today
|
||||
weatherreporter generate tomorrow
|
||||
weatherreporter generate hourly
|
||||
weatherreporter generate three-day
|
||||
weatherreporter generate weekend
|
||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
|
||||
```
|
||||
|
||||
Each command resolves a report period, fetches a Weather API bundle, builds a
|
||||
JSON module snapshot, builds a YAML prompt input data package, runs
|
||||
`scriptorium render`, runs `scriptorium run`, and writes managed artifacts under
|
||||
the configured workspace.
|
||||
Generation commands resolve a report period, fetch a Weather API bundle, build
|
||||
a rich JSON module snapshot, build a curated YAML prompt input data package, run
|
||||
`scriptorium render`, and write managed artifacts under the configured
|
||||
workspace. Markdown-path reports then run `scriptorium run` directly to the
|
||||
managed Markdown report path.
|
||||
|
||||
`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
|
||||
Markdown report after `scriptorium run` succeeds and final metadata is saved.
|
||||
`--out PATH` writes an extra Markdown copy for the current generated report; it
|
||||
is not used as the distributor upload source.
|
||||
Markdown report after report rendering succeeds and final metadata is saved.
|
||||
`--out PATH` writes an extra Markdown copy for generated reports; it is not used
|
||||
as the distributor upload source.
|
||||
|
||||
Batch commands:
|
||||
|
||||
@@ -32,14 +46,14 @@ weatherreporter run morning
|
||||
weatherreporter run evening
|
||||
```
|
||||
|
||||
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
||||
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch
|
||||
`run morning` generates Today Report and the 3-Day Outlook, plus Weekend Outlook
|
||||
except on Sunday. `run evening` generates the Tomorrow Report. Batch
|
||||
commands print a JSON summary to stdout, write compact per-report status lines
|
||||
to stderr, continue independent reports after one report fails, and return
|
||||
nonzero when any report failed. When notification is configured, the summary and
|
||||
status lines include notification status, accepted distributor run ID, or
|
||||
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
|
||||
distributor upload sources.
|
||||
|
||||
@@ -54,6 +68,18 @@ workspace/
|
||||
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
|
||||
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/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.modules.json
|
||||
@@ -62,6 +88,22 @@ workspace/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.modules.json
|
||||
<run_id>.metadata.json
|
||||
hourly/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.modules.json
|
||||
<run_id>.metadata.json
|
||||
<run_id>.generated_text.raw.json
|
||||
<run_id>.generated_text.run.json
|
||||
<run_id>.generated_text.json
|
||||
<run_id>.render_context.json
|
||||
tomorrow/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.modules.json
|
||||
<run_id>.metadata.json
|
||||
<run_id>.generated_text.raw.json
|
||||
<run_id>.generated_text.run.json
|
||||
<run_id>.generated_text.json
|
||||
<run_id>.render_context.json
|
||||
storm/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.modules.json
|
||||
@@ -70,12 +112,21 @@ workspace/
|
||||
daily/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.yaml
|
||||
today/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.yaml
|
||||
three-day/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.yaml
|
||||
weekend/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.yaml
|
||||
hourly/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.yaml
|
||||
tomorrow/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.yaml
|
||||
storm/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.yaml
|
||||
@@ -83,12 +134,21 @@ workspace/
|
||||
daily/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.render.json
|
||||
today/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.render.json
|
||||
three-day/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.render.json
|
||||
weekend/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.render.json
|
||||
hourly/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.render.json
|
||||
tomorrow/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.render.json
|
||||
storm/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.render.json
|
||||
@@ -96,22 +156,37 @@ workspace/
|
||||
daily/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.distributor.json
|
||||
today/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.distributor.json
|
||||
three-day/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.distributor.json
|
||||
weekend/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.distributor.json
|
||||
hourly/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.distributor.json
|
||||
tomorrow/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.distributor.json
|
||||
storm/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.distributor.json
|
||||
reports/
|
||||
daily/
|
||||
<run_id>.md
|
||||
today/
|
||||
<run_id>.md
|
||||
three-day/
|
||||
<run_id>.md
|
||||
weekend/
|
||||
<run_id>.md
|
||||
hourly/
|
||||
<run_id>.md
|
||||
tomorrow/
|
||||
<run_id>.md
|
||||
storm/
|
||||
<run_id>.md
|
||||
```
|
||||
@@ -124,7 +199,8 @@ period do not overwrite each other.
|
||||
RunIDs are based on generation time plus report ID:
|
||||
|
||||
```text
|
||||
20260529T100000.123456789Z_daily_today
|
||||
20260529T100000.123456789Z_daily
|
||||
20260529T100000.123456789Z_today
|
||||
```
|
||||
|
||||
Each generated report writes metadata that links:
|
||||
@@ -136,6 +212,8 @@ Each generated report writes metadata that links:
|
||||
- prompt input data package path
|
||||
- preflight output path
|
||||
- managed Markdown report path
|
||||
- generated text schema ID and generated-text artifact paths for
|
||||
generated-text-template reports
|
||||
- distributor notification debug artifact path, when notification is attempted
|
||||
|
||||
Batch summaries include report status, error text when applicable, 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
|
||||
the valid-period start date, artifact group, and RunID. Distributor owns
|
||||
destination merge, retention, and derived snapshot behavior such as `latest`.
|
||||
For Daily, the default report ID and artifact group values are both `daily`,
|
||||
and the default output filename value is `daily.md`.
|
||||
For Today, the default report ID and artifact group values are both `today`,
|
||||
and the batch output filename value is `today.md`.
|
||||
|
||||
Notification happens after final metadata save. Weather API, module snapshot,
|
||||
data-package, render preflight, Scriptorium run, and metadata-save failures do
|
||||
Notification happens after final metadata save for generated reports. Weather
|
||||
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.
|
||||
In a batch, other reports continue, the failed report includes notification
|
||||
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
|
||||
`inspect metadata` to see the artifact links recorded for a run. Use
|
||||
`inspect modules` to review the persisted ordered module snapshot, and
|
||||
`inspect data-package` to review the structured prompt package used for
|
||||
rendering. Use `inspect prior` to see the prior comparable snapshot selected for
|
||||
Recent Changes, or `null` when none exists. Use `inspect sources` to review
|
||||
source provenance and warnings without dumping full weather payloads.
|
||||
`inspect modules` to review the persisted ordered module snapshot with rich
|
||||
template-facing values, and `inspect data-package` to review the curated prompt
|
||||
package passed to Scriptorium. Use `inspect prior` to see the prior comparable
|
||||
snapshot selected for Recent Changes, or `null` when none exists. Use
|
||||
`inspect sources` to review source provenance and warnings without dumping full
|
||||
weather payloads.
|
||||
|
||||
## Recent Changes
|
||||
|
||||
Recent Changes are computed from structured module snapshots, not rendered
|
||||
Markdown or YAML text.
|
||||
|
||||
Daily Today and Daily Tomorrow can compare with each other when they cover the
|
||||
same valid local date. 3-Day Outlook compares with prior compatible 3-Day
|
||||
snapshots for the same valid local date. Weekend Outlook compares with prior
|
||||
compatible Weekend snapshots for the same weekend window. Storm Report leaves
|
||||
Recent Changes empty.
|
||||
Daily Report compares with prior Daily Report snapshots for the same valid
|
||||
local date. Today Report compares with prior Today Report snapshots for the
|
||||
same valid local date. Tomorrow Report compares with prior Tomorrow Report
|
||||
snapshots for the same valid local date. 3-Day Outlook compares with prior
|
||||
compatible 3-Day snapshots for the same valid local date. Weekend Outlook
|
||||
compares with prior compatible Weekend snapshots for the same weekend window.
|
||||
Hourly Report and Storm Report leave Recent Changes empty.
|
||||
|
||||
When no prior comparable snapshot exists, or no configured threshold is crossed,
|
||||
`recentChanges.items` is empty.
|
||||
@@ -232,6 +318,10 @@ A failed generation run may still leave useful artifacts:
|
||||
preflight JSON and metadata are written for inspection.
|
||||
- If `scriptorium run` exits nonzero after writing a report, the managed report
|
||||
and metadata remain available.
|
||||
- Generated-text failures for Daily, Today, Tomorrow, and Hourly reports preserve
|
||||
available intermediate artifacts, such as the structured run result, raw
|
||||
generated-text JSON, validated generated text, and render context. Metadata
|
||||
links those paths when it can be safely written.
|
||||
- If distributor notification fails, report artifacts and final metadata remain
|
||||
available, but the report or batch command returns nonzero.
|
||||
- For batch commands, inspect the stdout JSON summary first, then inspect the
|
||||
|
||||
659
docs/roadmap/audit.md
Normal file
659
docs/roadmap/audit.md
Normal file
@@ -0,0 +1,659 @@
|
||||
# Code Quality And Deduplication Audit
|
||||
|
||||
## Executive summary
|
||||
|
||||
Overall code quality is strong. The repository is modular, the important
|
||||
external boundaries are mostly contained behind adapters, state paths are
|
||||
centralized, durable writes use shared file helpers, and report/module identity
|
||||
policy is mostly registry-driven.
|
||||
|
||||
The top three cleanup targets before the next major release are:
|
||||
|
||||
1. Day-style generated-text report duplication across Daily, Today, and
|
||||
Tomorrow.
|
||||
2. Duplicated report-module config normalization and validation logic.
|
||||
3. Repeated Scriptorium run and structured-run result plumbing.
|
||||
|
||||
The codebase appears ready for a limited cleanup pass. I do not see a major
|
||||
architectural risk that would require a broad rewrite. The most useful cleanup
|
||||
work is narrow, behavior-preserving, and should keep public CLI syntax and
|
||||
managed artifact paths stable.
|
||||
|
||||
## Repository map reviewed
|
||||
|
||||
Reviewed documentation and examples:
|
||||
|
||||
- `README.md`
|
||||
- `docs/policy/architecture.md`
|
||||
- `docs/policy/development.md`
|
||||
- `docs/policy/documentation.md`
|
||||
- `docs/config.md`
|
||||
- `docs/cli.md`
|
||||
- `docs/operations.md`
|
||||
- `docs/troubleshooting.md`
|
||||
- `docs/internal/*.md`
|
||||
- `docs/integrations/{weatherapi,scriptorium,distributor}`
|
||||
- `docs/roadmap/{daily,data-package-exports,future,implementation}.md`
|
||||
- `examples/config.yml`
|
||||
- `examples/minimal-config.yml`
|
||||
|
||||
Reviewed code areas:
|
||||
|
||||
- `cmd/weatherreporter`
|
||||
- `internal/cli`
|
||||
- `internal/config`
|
||||
- `internal/app`
|
||||
- `internal/report`
|
||||
- `internal/module`
|
||||
- `internal/briefing`
|
||||
- `internal/facts`
|
||||
- `internal/forecast`
|
||||
- `internal/generatedtext`
|
||||
- `internal/reporttemplate`
|
||||
- `internal/promptinput`
|
||||
- `internal/changes`
|
||||
- `internal/state`
|
||||
- `internal/fileutil`
|
||||
- `internal/adapters/{weatherapi,scriptorium,distributor}`
|
||||
- `internal/weatherdata`
|
||||
- package-level tests throughout `internal`
|
||||
|
||||
Major execution paths reviewed:
|
||||
|
||||
- `weatherreporter generate ...` parsing and orchestration.
|
||||
- `weatherreporter run morning/evening` batch orchestration and output.
|
||||
- `weatherreporter inspect ...` artifact inspection.
|
||||
- Weather API bundle fan-out and missing-source handling.
|
||||
- module snapshot and prompt-input construction.
|
||||
- generated-text-template workflow for `daily`, `today`, `tomorrow`, and
|
||||
`hourly`.
|
||||
- Scriptorium render/run/structured-run calls.
|
||||
- distributor notification request construction, upload, status, and debug
|
||||
artifact persistence.
|
||||
|
||||
Areas not deeply inspected:
|
||||
|
||||
- Historical files under `workspace/`; these are generated artifacts rather
|
||||
than source of truth.
|
||||
- Full live integration behavior against Weather API, Scriptorium, or
|
||||
distributor; tests and adapter code were inspected instead.
|
||||
|
||||
## High-confidence deduplication opportunities
|
||||
|
||||
### 1. Consolidate common Daily/Today/Tomorrow generated-text report plumbing
|
||||
|
||||
Affected files/packages:
|
||||
|
||||
- `internal/generatedtext/daily.go`
|
||||
- `internal/generatedtext/today.go`
|
||||
- `internal/generatedtext/tomorrow.go`
|
||||
- `internal/generatedtext/render_context.go`
|
||||
- `internal/reporttemplate/templates/daily.md.tmpl`
|
||||
- `internal/reporttemplate/templates/today.md.tmpl`
|
||||
- `internal/reporttemplate/templates/tomorrow.md.tmpl`
|
||||
- `internal/reporttemplate/schemas/*.generated_text.schema.json`
|
||||
|
||||
Duplicated or near-duplicated behavior:
|
||||
|
||||
- `Daily`, `Today`, and `Tomorrow` have the same GeneratedText JSON shape:
|
||||
`summary`, array-valued `forecast_discussion`, optional
|
||||
`precipitation_timing`, and optional `confidence`.
|
||||
- `ValidateDaily`, `ValidateToday`, and `ValidateTomorrow` perform the same
|
||||
trim, required-field, normalize, and error-shaping steps with only the report
|
||||
name changing.
|
||||
- `DailyReportContext`, `TodayReportContext`, and `TomorrowReportContext` have
|
||||
the same fields.
|
||||
- `dailyTemplateModules`, `todayTemplateModules`, and
|
||||
`tomorrowTemplateModules` repeat the same module-snapshot lookups for common
|
||||
stanzas, then differ only in ordered daypart wrapper and planning module.
|
||||
- The Daily, Today, and Tomorrow Markdown templates repeat large daypart and
|
||||
precipitation-timing blocks. For example, the Daypart Forecast logic appears
|
||||
in all three templates, with small policy differences such as Today omitting
|
||||
missing daypart lines while Daily/Tomorrow render `Forecast details are
|
||||
limited`.
|
||||
|
||||
Why it matters:
|
||||
|
||||
- These reports are intentionally independent at the public/report-definition
|
||||
layer, but their current internal GeneratedText and daypart template surfaces
|
||||
are close enough that wording, validation, and render-context changes are
|
||||
likely to require edits in multiple places.
|
||||
- Small drift in deterministic report wording is easy to introduce accidentally
|
||||
because the duplicated template logic is dense and hard to compare.
|
||||
- New day-style reports would likely copy this pattern again.
|
||||
|
||||
Recommended refactor:
|
||||
|
||||
- Keep separate exported report-specific types and template files, because the
|
||||
reports are intended to diverge.
|
||||
- Introduce a small shared day-style GeneratedText validation helper that takes
|
||||
the report name and typed value, or introduce an unexported common
|
||||
`dayGeneratedText` shape embedded by report-specific types if that keeps
|
||||
JSON and schema behavior clear.
|
||||
- Add a shared `dayReportCommonModules` or equivalent helper that loads common
|
||||
module stanzas once and lets Daily/Today/Tomorrow add only report-specific
|
||||
planning fields and daypart wrapper types.
|
||||
- Consider named template partials or a small deterministic presentation struct
|
||||
for shared daypart/precipitation line rendering. Do not collapse the separate
|
||||
report template files; make the shared pieces explicit and opt-in.
|
||||
|
||||
Suggested tests:
|
||||
|
||||
- Keep existing `internal/generatedtext` validation tests for each public type.
|
||||
- Add a table test that Daily, Today, and Tomorrow validators share the same
|
||||
required-field behavior and unknown-field rejection.
|
||||
- Add render-context tests proving common module fields are populated for all
|
||||
three day-style reports and each report still exposes its own planning module.
|
||||
- Add template tests that pin the intended daypart behavior for all three
|
||||
reports after shared rendering cleanup.
|
||||
|
||||
Risk level: medium. The behavior is user-facing Markdown and prompt-schema
|
||||
related, but the cleanup can be done in small steps with existing tests.
|
||||
|
||||
### 2. Centralize report-module config normalization and validation
|
||||
|
||||
Affected files/packages:
|
||||
|
||||
- `internal/config/reports.go`
|
||||
- `internal/config/config_test.go`
|
||||
|
||||
Duplicated or near-duplicated behavior:
|
||||
|
||||
- `normalizeReportModules` and `validateReportModules` both traverse
|
||||
`cfg.Reports`, resolve config keys with `report.IDForConfigKey`, track
|
||||
duplicate report aliases, initialize the default report and module
|
||||
registries, normalize module options, and call
|
||||
`ModuleRegistry.ValidateComposition`.
|
||||
- `Config.ReportModuleOverrides` performs another report-key and duplicate
|
||||
alias traversal before returning normalized module items.
|
||||
|
||||
Why it matters:
|
||||
|
||||
- Report-module configuration is user-visible policy. Drift between load-time
|
||||
normalization, validation, and app-time override extraction could cause a
|
||||
config to pass one path and fail another, or produce different error wording.
|
||||
- Adding a new report key, module option type, or validation rule currently
|
||||
requires touching and reasoning about multiple similar loops.
|
||||
|
||||
Recommended refactor:
|
||||
|
||||
- Introduce one unexported helper that resolves report config entries into a
|
||||
canonical structure, for example `normalizedReportOverrides(cfg,
|
||||
normalizeOptions bool)`.
|
||||
- Have `Load` call the helper in mutating mode and have `Validate` or
|
||||
`ReportModuleOverrides` reuse the same canonical traversal. Preserve current
|
||||
error prefixes such as `reports.<key>.deterministic_modules[...]`.
|
||||
- Keep config loading and validation in `internal/config`; do not move user
|
||||
config parsing into `internal/app`.
|
||||
|
||||
Suggested tests:
|
||||
|
||||
- Existing config tests for unknown report keys, duplicate report aliases,
|
||||
unknown modules, incompatible modules, duplicate modules, and invalid module
|
||||
options should continue passing.
|
||||
- Add one regression test that a manually constructed `config.Config` with raw
|
||||
module options fails or normalizes consistently through the same helper path.
|
||||
- Add table coverage that duplicate aliases produce identical error context from
|
||||
`Load` and `ReportModuleOverrides`.
|
||||
|
||||
Risk level: low to medium. This is internal config plumbing, but it protects a
|
||||
public configuration surface.
|
||||
|
||||
### 3. Deduplicate Scriptorium run and structured-run execution plumbing
|
||||
|
||||
Affected files/packages:
|
||||
|
||||
- `internal/adapters/scriptorium/runner.go`
|
||||
- `internal/adapters/scriptorium/runner_test.go`
|
||||
- `internal/app/app.go`
|
||||
|
||||
Duplicated or near-duplicated behavior:
|
||||
|
||||
- `RunResult` and `StructuredRunResult` have the same fields.
|
||||
- `Runner.Run` and `Runner.StructuredRun` validate the same request fields,
|
||||
call `execute`, build the same result shape, attach `OutputPath`, and check a
|
||||
nonzero exit code. The only meaningful differences are request/result types
|
||||
and error wording.
|
||||
- `structuredRunArgs` delegates to `runArgs`, so the argv behavior is already
|
||||
shared.
|
||||
|
||||
Why it matters:
|
||||
|
||||
- `scriptorium run` is a critical external integration. Any future change to
|
||||
stdout/stderr truncation, exit-code handling, or output-path recording should
|
||||
not require repeated edits.
|
||||
- The duplication is small now, but it is exactly the kind of adapter behavior
|
||||
that can drift invisibly.
|
||||
|
||||
Recommended refactor:
|
||||
|
||||
- Keep the public `RunResult` and `StructuredRunResult` types if callers benefit
|
||||
from distinct names.
|
||||
- Add an unexported `executeRunLike` helper that validates prompt ID, data
|
||||
package path, and output path, executes args, and returns a shared internal
|
||||
result struct.
|
||||
- Convert the shared result into `RunResult` or `StructuredRunResult` at the
|
||||
public method boundary so JSON artifact shapes remain unchanged.
|
||||
|
||||
Suggested tests:
|
||||
|
||||
- Existing Scriptorium runner tests should remain behavior-preserving.
|
||||
- Add or keep table coverage that `Run` and `StructuredRun` produce the same
|
||||
argv, output-path field, truncation fields, and nonzero exit behavior.
|
||||
|
||||
Risk level: low. Adapter boundary is already narrow and well tested.
|
||||
|
||||
## Medium-confidence opportunities
|
||||
|
||||
### 1. Reduce repeated state save boilerplate without hiding artifact semantics
|
||||
|
||||
Affected files/packages:
|
||||
|
||||
- `internal/state/filesystem.go`
|
||||
- `internal/state/filesystem_test.go`
|
||||
|
||||
Duplicated or near-duplicated behavior:
|
||||
|
||||
- Most save methods call `Paths`, select one path, write JSON or bytes
|
||||
atomically, and return the selected path.
|
||||
- Examples include `SaveModuleSnapshot`, `SavePreflight`,
|
||||
`SaveDistributorNotification`, `SaveGeneratedTextRaw`,
|
||||
`SaveGeneratedTextResult`, `SaveGeneratedText`, and `SaveRenderContext`.
|
||||
|
||||
Semantic differences that may be intentional:
|
||||
|
||||
- Some methods validate payloads before writing.
|
||||
- `SaveDataPackage` delegates to `promptinput.Save`.
|
||||
- `SaveMetadata` intentionally writes to the explicit metadata path stored on
|
||||
`state.Metadata`.
|
||||
|
||||
Why it matters:
|
||||
|
||||
- Adding a new artifact type requires repeating the same path/write pattern.
|
||||
- Error context could drift if some methods wrap paths and others do not.
|
||||
|
||||
Recommended refactor:
|
||||
|
||||
- Consider small private helpers such as `saveJSONArtifact(resolved, pathFn,
|
||||
value)` and `saveBytesArtifact(resolved, pathFn, data)` only if a new artifact
|
||||
type is added or the save methods change again.
|
||||
- Do not create a generic manifest or artifact framework in this cleanup pass.
|
||||
|
||||
Suggested tests:
|
||||
|
||||
- Keep path tests and artifact save/load tests.
|
||||
- If helpers are introduced, add one focused test that a representative JSON
|
||||
artifact and byte artifact still write to the same managed paths.
|
||||
|
||||
Risk level: low.
|
||||
|
||||
### 2. Extract package-local CLI integration test fixture helpers
|
||||
|
||||
Affected files/packages:
|
||||
|
||||
- `internal/cli/root_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
|
||||
Duplicated or near-duplicated behavior:
|
||||
|
||||
- CLI integration tests carry large fake Scriptorium shell scripts with repeated
|
||||
prompt-specific JSON cases for today, tomorrow, daily, and hourly.
|
||||
- Config string construction and test Weather API setup are repeated across
|
||||
groups of tests.
|
||||
|
||||
Semantic differences that may be intentional:
|
||||
|
||||
- Some fake scripts exercise markdown-output mode, some structured-output mode,
|
||||
and some failure behavior.
|
||||
- These tests are integration-style and intentionally explicit.
|
||||
|
||||
Why it matters:
|
||||
|
||||
- As report types and prompt schemas grow, the shell fixture blocks become a
|
||||
brittle place to update prompt IDs, generated text schema shape, and expected
|
||||
report behavior.
|
||||
- The large test file makes failures harder to localize.
|
||||
|
||||
Recommended refactor:
|
||||
|
||||
- Add package-local helper builders for fake Scriptorium behavior, for example a
|
||||
map from prompt ID to JSON response plus a failure map for specific prompts.
|
||||
- Keep helpers local to `internal/cli` and `internal/app`; do not introduce a
|
||||
cross-package test framework.
|
||||
- Keep a few explicit end-to-end tests that prove real command wiring still
|
||||
works.
|
||||
|
||||
Suggested tests:
|
||||
|
||||
- No new behavior tests are required before helper extraction.
|
||||
- After extraction, run `go test ./internal/cli ./internal/app` and compare key
|
||||
assertions around generated-text artifacts, CLI output, and batch summaries.
|
||||
|
||||
Risk level: low.
|
||||
|
||||
### 3. Clarify config-to-briefing registry boundary
|
||||
|
||||
Affected files/packages:
|
||||
|
||||
- `internal/config/reports.go`
|
||||
- `internal/briefing/modules.go`
|
||||
- `internal/module/module.go`
|
||||
|
||||
Duplicated or near-duplicated behavior:
|
||||
|
||||
- Not primarily duplication. This is a boundary concern: `internal/config`
|
||||
imports `internal/briefing` to initialize the default module registry and
|
||||
validate report module composition.
|
||||
|
||||
Semantic differences that may be intentional:
|
||||
|
||||
- The briefing registry is currently the implemented source of module builders,
|
||||
supported reports, options, missing-data behavior, and prompt exporters.
|
||||
- Config validation needs option schemas and composition rules, so using the
|
||||
registry is pragmatic.
|
||||
|
||||
Why it matters:
|
||||
|
||||
- `internal/config` now depends on the module-builder package, not only on
|
||||
stable module option metadata. That is workable today, but it makes the
|
||||
config package pull in more of the report-building layer than it strictly
|
||||
needs.
|
||||
- If modules become more numerous or more expensive to initialize, config
|
||||
validation may become harder to keep side-effect free.
|
||||
|
||||
Recommended refactor:
|
||||
|
||||
- Do not split this immediately.
|
||||
- If module catalog complexity grows, consider separating build-free module
|
||||
definition metadata from module builders. The metadata/catalog can validate
|
||||
config options and composition; `internal/briefing` can attach builders and
|
||||
prompt exporters.
|
||||
|
||||
Suggested tests:
|
||||
|
||||
- Keep config tests that prove report/module overrides fail during config load.
|
||||
- If a build-free catalog is introduced later, add tests proving config
|
||||
validation and runtime module build registry accept the same module IDs,
|
||||
options, supported reports, and missing-data policies.
|
||||
|
||||
Risk level: medium if deferred too long, low today.
|
||||
|
||||
### 4. Keep Weather API source fan-out explicit, but watch source metadata drift
|
||||
|
||||
Affected files/packages:
|
||||
|
||||
- `internal/adapters/weatherapi/client.go`
|
||||
- `internal/weatherdata/bundle.go`
|
||||
|
||||
Duplicated or near-duplicated behavior:
|
||||
|
||||
- Each source fetch follows the same rough pattern: declare target, call
|
||||
`fetchDecodedSource`, attach source timestamps, assign bundle field, and add
|
||||
source provenance.
|
||||
|
||||
Semantic differences that may be intentional:
|
||||
|
||||
- Alerts intentionally special-case `data:null`.
|
||||
- Hourly is required and validates non-empty periods.
|
||||
- Weather story omits units.
|
||||
- SPC outlooks use endpoint constants and different issued/updated rules.
|
||||
|
||||
Why it matters:
|
||||
|
||||
- The current code is readable and not over-abstracted. The risk is future
|
||||
source additions repeating timestamp/hash/missing-policy decisions by hand.
|
||||
|
||||
Recommended refactor:
|
||||
|
||||
- Do not introduce a generic source ingestion framework now.
|
||||
- When the next source is added, consider adding small source-spec helpers for
|
||||
only the shared provenance fields that have identical semantics.
|
||||
|
||||
Suggested tests:
|
||||
|
||||
- Maintain focused adapter tests for each source's endpoint, query parameters,
|
||||
missing-source policy, and source metadata.
|
||||
|
||||
Risk level: low.
|
||||
|
||||
## Boundary and responsibility concerns
|
||||
|
||||
- `internal/config` depending on `internal/briefing` for module registry
|
||||
validation is the clearest boundary ambiguity. It is currently pragmatic, but
|
||||
a future build-free module catalog would fit the architecture better if
|
||||
module option/config complexity grows.
|
||||
- `internal/app` owns distributor notification request construction, including
|
||||
template values derived from report metadata. That is acceptable because
|
||||
orchestration owns the report result and managed report path, while
|
||||
distributor package types remain inside `internal/adapters/distributor`.
|
||||
- `internal/generatedtext` currently owns render-context assembly from module
|
||||
snapshots. That is a reasonable home, but the day-style report duplication
|
||||
should be reduced inside that package rather than moved into templates,
|
||||
`internal/app`, or `internal/reporttemplate`.
|
||||
- `internal/reporttemplate` correctly owns embedded template/schema lookup and
|
||||
rendering only. It should not absorb generated-text validation or report
|
||||
module policy.
|
||||
|
||||
## Path, key, and naming construction review
|
||||
|
||||
Local workspace paths are centralized in `internal/state.FilesystemStore.Paths`.
|
||||
This is a strong point: module snapshots, metadata, data packages, preflight
|
||||
artifacts, notification artifacts, generated-text artifacts, render contexts,
|
||||
and managed reports all derive from a single path function.
|
||||
|
||||
Distributor bundle path rendering is centralized in `internal/config` through
|
||||
`RenderDistributorReportPaths` and related template renderers. `internal/app`
|
||||
only assembles template values and maps the managed report path to rendered
|
||||
bundle paths. This is appropriate.
|
||||
|
||||
Report identity, artifact groups, batch output names, prompt IDs, template IDs,
|
||||
and schema IDs are declared in `internal/report` definitions. Current report
|
||||
definition files are now one report per file for active report types, which is
|
||||
easy to navigate.
|
||||
|
||||
Areas needing cleanup:
|
||||
|
||||
- The repeated state save methods can be lightly helperized later, but path
|
||||
construction itself is centralized enough.
|
||||
- Day-style template paths/schema IDs are declared in both `internal/report`
|
||||
definitions and `internal/generatedtext`/`internal/reporttemplate` catalogs.
|
||||
This is acceptable because those packages own different parts of the
|
||||
contract, but tests should continue asserting catalog compatibility.
|
||||
|
||||
## Resolution and catalog review
|
||||
|
||||
Report resolution is consistent:
|
||||
|
||||
- CLI command names resolve through `internal/report.IDForCommandName`.
|
||||
- Config report keys resolve through `internal/report.IDForConfigKey`.
|
||||
- Batches resolve through `internal/report.BatchReports`.
|
||||
- Report definitions own valid-period resolvers and module composition.
|
||||
|
||||
Module resolution is consistent:
|
||||
|
||||
- Report definitions and config overrides use `module.ConfigItem`.
|
||||
- Config module options are normalized and composition is validated before app
|
||||
use.
|
||||
- Runtime module execution uses `internal/briefing.ModuleRegistry`.
|
||||
- Prompt-input category mapping is centralized in `internal/promptinput`.
|
||||
|
||||
Generated-text resolution is mostly consistent:
|
||||
|
||||
- `internal/report` declares generation mode, template ID, and generated-text
|
||||
schema ID.
|
||||
- `internal/generatedtext.LookupDefinition` checks schema/template support and
|
||||
pairing.
|
||||
- `internal/reporttemplate` owns embedded asset lookup.
|
||||
|
||||
Recommended centralization:
|
||||
|
||||
- Consolidate day-style generated-text validation and common render-context
|
||||
extraction inside `internal/generatedtext`.
|
||||
- Keep separate report-specific public IDs, schemas, prompt assets, and
|
||||
template files.
|
||||
|
||||
## Config and command-loading review
|
||||
|
||||
Configuration loading is centralized in `internal/config.Load` with the
|
||||
documented precedence: CLI overrides, config file, built-in defaults.
|
||||
Environment secrets load through `secrets.directory` after config file parsing
|
||||
and CLI overrides, before validation completes. CLI commands consistently call
|
||||
`config.Load` rather than independently applying defaults.
|
||||
|
||||
Intentional differences:
|
||||
|
||||
- `generate` accepts `--out`; `run` accepts `--out-dir`; inspect commands do
|
||||
not accept weather overrides.
|
||||
- `generate daily` requires `--date`; `generate today` accepts optional
|
||||
`--date`; storm requires `--start`/`--end`.
|
||||
- Distributor has no CLI flags and is config-only.
|
||||
|
||||
Likely accidental or cleanup-worthy differences:
|
||||
|
||||
- Report-module config traversal is duplicated across normalization,
|
||||
validation, and override extraction. This is the main config cleanup target.
|
||||
|
||||
## State, manifest, or progress handling review
|
||||
|
||||
The application has durable workspace state but no manifest/resume engine.
|
||||
That is appropriate for the current scope.
|
||||
|
||||
State handling is consistent in the implemented flow:
|
||||
|
||||
- managed paths are computed by `state.FilesystemStore.Paths`;
|
||||
- module snapshots, data packages, preflight output, generated-text artifacts,
|
||||
render context, metadata, and notification debug artifacts are persisted
|
||||
under managed paths;
|
||||
- metadata links the relevant artifact paths;
|
||||
- prior snapshot lookup uses stored metadata and report compatibility policy;
|
||||
- inspection reads metadata and linked artifacts rather than refetching data.
|
||||
|
||||
Potential drift to watch:
|
||||
|
||||
- Metadata is saved several times during generated-text-template reports as
|
||||
artifacts become available. This is operationally useful for diagnosis, but
|
||||
future artifact additions should preserve the same pattern deliberately.
|
||||
- There is no retry/resume manifest. Do not add one unless operational
|
||||
requirements become concrete.
|
||||
|
||||
## Refactors to avoid
|
||||
|
||||
- Do not introduce a generic workflow engine for generation. The current
|
||||
explicit app orchestration is readable and well covered.
|
||||
- Do not migrate to Cobra or redesign the CLI. The standard-library CLI remains
|
||||
adequate.
|
||||
- Do not introduce a plugin architecture for reports, modules, or adapters.
|
||||
- Do not create per-module or per-report Go packages. Recent file-level
|
||||
separation is sufficient.
|
||||
- Do not build a broad Weather API source ingestion framework yet. Keep source
|
||||
fetches explicit until repeated source semantics become materially expensive.
|
||||
- Do not replace module snapshots with a manifest system in this cleanup pass.
|
||||
- Do not create a global test helper package. Use package-local helpers where
|
||||
test setup is noisy.
|
||||
- Do not consolidate Daily, Today, and Tomorrow into one public report type.
|
||||
Their public identities and templates are intentionally independent.
|
||||
|
||||
## Recommended implementation sequence
|
||||
|
||||
1. **GeneratedText day-report helper cleanup**
|
||||
- Goal: reduce Daily/Today/Tomorrow validation and render-context
|
||||
duplication while preserving separate public report types and template
|
||||
files.
|
||||
- Files: `internal/generatedtext/*.go`, generatedtext tests.
|
||||
- Validation: `go test ./internal/generatedtext`.
|
||||
|
||||
2. **Day-style template duplication cleanup**
|
||||
- Goal: reduce repeated Daypart Forecast and Precipitation Timing template
|
||||
logic without eliminating per-report templates.
|
||||
- Files: `internal/reporttemplate/templates/*.md.tmpl`,
|
||||
`internal/reporttemplate/reporttemplate.go` if named partial parsing is
|
||||
used, template tests, `docs/templates.md`.
|
||||
- Validation: `go test ./internal/reporttemplate ./internal/generatedtext`.
|
||||
|
||||
3. **Report-module config traversal cleanup**
|
||||
- Goal: use one canonical traversal for report override normalization,
|
||||
validation, and extraction.
|
||||
- Files: `internal/config/reports.go`, config tests.
|
||||
- Validation: `go test ./internal/config ./internal/app`.
|
||||
|
||||
4. **Scriptorium run-result helper cleanup**
|
||||
- Goal: share run/structured-run execution result construction while keeping
|
||||
exported result structs stable.
|
||||
- Files: `internal/adapters/scriptorium/runner.go`,
|
||||
`internal/adapters/scriptorium/runner_test.go`.
|
||||
- Validation: `go test ./internal/adapters/scriptorium`.
|
||||
|
||||
5. **Package-local CLI/app test fixture cleanup**
|
||||
- Goal: reduce repeated fake Scriptorium/config setup.
|
||||
- Files: `internal/cli/root_test.go`, optionally `internal/app/app_test.go`.
|
||||
- Validation: `go test ./internal/cli ./internal/app`.
|
||||
|
||||
6. **Optional state save helper cleanup**
|
||||
- Goal: reduce repeated `Paths` plus atomic write boilerplate only if the
|
||||
prior steps touch state tests or a new artifact type is being added.
|
||||
- Files: `internal/state/filesystem.go`, `internal/state/filesystem_test.go`.
|
||||
- Validation: `go test ./internal/state ./internal/app`.
|
||||
|
||||
7. **Final documentation and validation**
|
||||
- Goal: update implemented docs for any changed internal contracts, then run
|
||||
full validation.
|
||||
- Files: relevant `docs/internal/*`, `docs/templates.md`,
|
||||
`docs/policy/development.md` only if workflow changes.
|
||||
- Validation: `go test ./...`, `go run ./cmd/weatherreporter --help`,
|
||||
`git diff --check`.
|
||||
|
||||
## Test strategy
|
||||
|
||||
Tests to add before or during cleanup:
|
||||
|
||||
- `internal/generatedtext`: table tests proving Daily/Today/Tomorrow share the
|
||||
same generated-text required-field and unknown-field behavior.
|
||||
- `internal/generatedtext`: tests proving common day-style module extraction
|
||||
still returns report-specific planning modules.
|
||||
- `internal/reporttemplate`: tests for shared daypart and precipitation
|
||||
rendering behavior after template cleanup.
|
||||
- `internal/config`: tests proving `Load`, `Validate`, and
|
||||
`ReportModuleOverrides` share duplicate alias and invalid module behavior.
|
||||
- `internal/adapters/scriptorium`: tests proving `Run` and `StructuredRun`
|
||||
preserve argv, output path, captured output, truncation flags, and nonzero
|
||||
exit behavior.
|
||||
- `internal/cli` and `internal/app`: keep workflow tests for generated-text
|
||||
artifacts, data-package output, notification behavior, and batch summaries.
|
||||
|
||||
Validation commands for cleanup work:
|
||||
|
||||
```sh
|
||||
go test ./internal/generatedtext ./internal/reporttemplate
|
||||
go test ./internal/config ./internal/app
|
||||
go test ./internal/adapters/scriptorium
|
||||
go test ./internal/cli ./internal/state
|
||||
go test ./...
|
||||
go run ./cmd/weatherreporter --help
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Lightweight audit validation performed for this report:
|
||||
|
||||
```sh
|
||||
go list ./...
|
||||
```
|
||||
|
||||
## Appendix: findings not worth acting on
|
||||
|
||||
- **Weather API source fetch functions look similar but should remain explicit
|
||||
for now.** Each source has different required/optional behavior, endpoint
|
||||
query options, timestamp rules, and null-data semantics. A generic framework
|
||||
would make the current adapter harder to read.
|
||||
- **Report definition files intentionally repeat field names.** Each report
|
||||
definition should remain explicit about prompt ID, template ID, artifact
|
||||
group, batch output name, compatibility, and modules.
|
||||
- **CLI flag parsing uses repeated `flag.FlagSet` setup.** The current parser is
|
||||
small and clear. Additional abstraction would not reduce much risk beyond the
|
||||
existing `addCommonFlags` helper.
|
||||
- **`internal/app.GenerateReport` is long but linear.** It is the main
|
||||
orchestration function and currently reads in the same order as the workflow.
|
||||
Splitting it aggressively would risk hiding stage ordering. Prefer extracting
|
||||
only small repeated mechanics.
|
||||
- **Generated Markdown templates are necessarily editable assets.** Do not
|
||||
replace template wording with Go string builders. Cleanup should preserve the
|
||||
user's ability to edit report layout and prose structure in template files.
|
||||
481
docs/roadmap/cleanup.md
Normal file
481
docs/roadmap/cleanup.md
Normal file
@@ -0,0 +1,481 @@
|
||||
# Cleanup Roadmap
|
||||
|
||||
## Purpose
|
||||
|
||||
This roadmap defines the staged cleanup work recommended by
|
||||
`docs/roadmap/audit.md`. It is written for an LLM coding agent that will
|
||||
implement each stage in order.
|
||||
|
||||
The cleanup sequence is intentionally narrow. It should reduce duplication and
|
||||
clarify package responsibilities before the next major release without changing
|
||||
public CLI syntax, user configuration, report identities, managed artifact
|
||||
paths, or generated report behavior.
|
||||
|
||||
This file may describe planned work because it lives under `docs/roadmap/`.
|
||||
|
||||
## Cleanup Principles
|
||||
|
||||
- Preserve public behavior unless a stage explicitly says otherwise.
|
||||
- Prefer small behavior-preserving refactors over broad rewrites.
|
||||
- Keep domain policy in the package that owns the relevant contract.
|
||||
- Keep external system details behind adapter boundaries.
|
||||
- Keep report, module, template, schema, path, and artifact identity explicit.
|
||||
- Add tests before or during cleanup where they protect public behavior or
|
||||
important internal invariants.
|
||||
- Update implemented documentation only after code behavior exists.
|
||||
- Do not use cleanup as an opportunity to introduce new features.
|
||||
|
||||
## Locked Decisions
|
||||
|
||||
- Daily, Today, and Tomorrow remain separate report IDs, prompt IDs, schemas,
|
||||
templates, and public generated-text types.
|
||||
- Cleanup may reduce shared internal day-style plumbing, but must not merge the
|
||||
public report types.
|
||||
- Daily, Today, and Tomorrow keep separate top-level template files.
|
||||
- Named template partials may be introduced for repeated Daypart Forecast and
|
||||
Precipitation Timing blocks.
|
||||
- Configuration ownership remains in `internal/config`; config parsing and
|
||||
validation should not move into `internal/app` or `internal/briefing`.
|
||||
- Scriptorium and distributor dependency details must remain behind their
|
||||
adapter packages.
|
||||
- Existing public CLI syntax, config fields, report IDs, prompt IDs, template
|
||||
IDs, schema IDs, workspace paths, distributor paths, and generated report
|
||||
paths remain stable.
|
||||
- Do not introduce Cobra, a workflow engine, a plugin system,
|
||||
manifest/resume/progress infrastructure, per-module packages, per-report
|
||||
packages, or a global test helper package.
|
||||
|
||||
## Stage 1: Day-Style GeneratedText Validation Helpers
|
||||
|
||||
### Goal
|
||||
|
||||
Remove duplicated Daily/Today/Tomorrow generated-text validation while
|
||||
preserving public types and JSON schema behavior.
|
||||
|
||||
### Implementation Guidance
|
||||
|
||||
- Add an unexported shared helper in `internal/generatedtext` for the common
|
||||
day-style shape: `summary`, `forecast_discussion`, optional
|
||||
`precipitation_timing`, and optional `confidence`.
|
||||
- Keep exported `Daily`, `Today`, and `Tomorrow` structs.
|
||||
- Keep exported `ValidateDaily`, `ValidateToday`, and `ValidateTomorrow`
|
||||
functions.
|
||||
- Preserve normalized JSON output shape and unknown-field rejection.
|
||||
- Preserve current error messages except for the expected report-name
|
||||
substitution.
|
||||
- Do not change embedded generated-text schemas in this stage except as needed
|
||||
to keep tests aligned with existing behavior.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Daily, Today, and Tomorrow validation use one shared internal validation path
|
||||
for common trim, required-field, optional-field, and normalization behavior.
|
||||
- Public generated-text structs and function names remain unchanged.
|
||||
- Existing callers do not need to change.
|
||||
- Existing schema behavior remains unchanged.
|
||||
|
||||
### Tests
|
||||
|
||||
- Add table coverage proving Daily, Today, and Tomorrow share:
|
||||
- required `summary` behavior;
|
||||
- required non-empty `forecast_discussion` behavior;
|
||||
- trim behavior;
|
||||
- optional-field omission behavior;
|
||||
- normalized JSON output behavior;
|
||||
- unknown-field rejection.
|
||||
- Run:
|
||||
|
||||
```sh
|
||||
go test ./internal/generatedtext
|
||||
```
|
||||
|
||||
### Prompt Size
|
||||
|
||||
Small enough for one implementation prompt.
|
||||
|
||||
## Stage 2: Day-Style Render Context And Template Shared Blocks
|
||||
|
||||
### Goal
|
||||
|
||||
Reduce repeated Daily/Today/Tomorrow render-context and Markdown template logic
|
||||
without merging the reports.
|
||||
|
||||
### Implementation Guidance
|
||||
|
||||
- Add shared unexported helpers for common day-style report context fields such
|
||||
as forecast date, forecast date label, generated timestamp label, valid
|
||||
period, timezone, collected facts, and derived facts.
|
||||
- Add a common module snapshot extraction helper for shared day-style modules.
|
||||
- Keep report-specific planning modules separate:
|
||||
- Daily uses `DailyPlanning`;
|
||||
- Today uses `TodayPlanning`;
|
||||
- Tomorrow uses `TomorrowPlanning`.
|
||||
- Keep report-specific daypart wrapper types if tests or templates benefit from
|
||||
explicit names.
|
||||
- Add named template partial support in `internal/reporttemplate` for repeated
|
||||
Daypart Forecast and Precipitation Timing blocks.
|
||||
- Keep `daily.md.tmpl`, `today.md.tmpl`, and `tomorrow.md.tmpl` as separate
|
||||
top-level templates that opt into shared partials.
|
||||
- Preserve current rendered Markdown behavior, including Today's omission of
|
||||
elapsed or missing daypart lines.
|
||||
- Do not replace editable Markdown templates with Go string builders.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Common day-style render-context setup is shared internally.
|
||||
- Daily, Today, and Tomorrow still expose their own render context types.
|
||||
- Shared template partials reduce repeated daypart and precipitation template
|
||||
logic.
|
||||
- Report templates remain separately editable.
|
||||
- Current rendered output remains stable except for whitespace changes that are
|
||||
covered by updated tests and intentionally accepted.
|
||||
|
||||
### Tests
|
||||
|
||||
- Update render-context tests to prove:
|
||||
- common module fields are populated for Daily, Today, and Tomorrow;
|
||||
- each report still exposes its correct planning module;
|
||||
- Today still omits missing/elapsed dayparts where current behavior expects
|
||||
omission.
|
||||
- Update reporttemplate tests to prove Daily, Today, and Tomorrow output remains
|
||||
behaviorally stable.
|
||||
- Run:
|
||||
|
||||
```sh
|
||||
go test ./internal/generatedtext ./internal/reporttemplate
|
||||
```
|
||||
|
||||
### Prompt Size
|
||||
|
||||
Likely one implementation prompt. If template partial parsing changes and
|
||||
render-context helper extraction become difficult to review together, split
|
||||
this into:
|
||||
|
||||
1. render-context helper cleanup;
|
||||
2. template partial cleanup.
|
||||
|
||||
## Stage 3: Report Module Config Traversal Cleanup
|
||||
|
||||
### Goal
|
||||
|
||||
Make report-module config normalization, validation, and override extraction use
|
||||
one canonical traversal.
|
||||
|
||||
### Implementation Guidance
|
||||
|
||||
- Refactor `internal/config/reports.go` around one unexported helper that:
|
||||
- resolves report config keys through `internal/report`;
|
||||
- detects duplicate aliases;
|
||||
- validates report IDs against the report registry;
|
||||
- normalizes module options when requested;
|
||||
- builds `module.ConfigItem` values;
|
||||
- validates module composition through the module registry.
|
||||
- Reuse that helper from:
|
||||
- `normalizeReportModules`;
|
||||
- `validateReportModules`;
|
||||
- `ReportModuleOverrides`.
|
||||
- Preserve current configuration precedence and YAML shape.
|
||||
- Preserve current option normalization behavior.
|
||||
- Preserve error context such as
|
||||
`reports.<key>.deterministic_modules[...]`.
|
||||
- Keep `internal/config` as the owner of config loading, normalization, and
|
||||
validation.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Report-module config traversal exists in one implementation path.
|
||||
- Load-time normalization, validation, and override extraction cannot drift on
|
||||
report-key or module composition policy.
|
||||
- Existing config files continue to load unchanged.
|
||||
- Existing config error messages remain materially equivalent and actionable.
|
||||
|
||||
### Tests
|
||||
|
||||
- Keep or update tests for:
|
||||
- unknown report keys;
|
||||
- duplicate report aliases;
|
||||
- unknown modules;
|
||||
- duplicate modules;
|
||||
- incompatible modules;
|
||||
- invalid module options;
|
||||
- valid module overrides.
|
||||
- Add coverage proving loaded config and manually constructed config fail
|
||||
consistently for the same invalid report/module cases.
|
||||
- Run:
|
||||
|
||||
```sh
|
||||
go test ./internal/config ./internal/app
|
||||
```
|
||||
|
||||
### Prompt Size
|
||||
|
||||
Small enough for one implementation prompt.
|
||||
|
||||
## Stage 4: Scriptorium Run Plumbing Cleanup
|
||||
|
||||
### Goal
|
||||
|
||||
Share `Run` and `StructuredRun` execution mechanics while keeping the
|
||||
Scriptorium adapter API stable.
|
||||
|
||||
### Implementation Guidance
|
||||
|
||||
- Add an unexported run-like helper in `internal/adapters/scriptorium` that:
|
||||
- validates prompt ID;
|
||||
- validates data package path;
|
||||
- validates output path;
|
||||
- executes the resolved argv;
|
||||
- captures stdout and stderr;
|
||||
- records truncation flags;
|
||||
- records output path;
|
||||
- handles nonzero exit results.
|
||||
- Keep exported `RunRequest`, `StructuredRunRequest`, `RunResult`, and
|
||||
`StructuredRunResult`.
|
||||
- Keep `StructuredRun` using the same argv shape as `Run`; do not add schema or
|
||||
format flags.
|
||||
- Preserve argv order.
|
||||
- Preserve stdout/stderr capture and truncation fields.
|
||||
- Preserve output-path fields.
|
||||
- Preserve existing nonzero-exit error wording as closely as possible.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- `Run` and `StructuredRun` share validation and result construction mechanics.
|
||||
- Public adapter request/result types remain stable.
|
||||
- Existing app-layer Scriptorium calls do not need behavior changes.
|
||||
- Existing Scriptorium tests still pass with minimal expected-output updates.
|
||||
|
||||
### Tests
|
||||
|
||||
- Add or update parity tests proving `Run` and `StructuredRun` preserve:
|
||||
- argv construction;
|
||||
- output path;
|
||||
- captured stdout/stderr;
|
||||
- truncation flags;
|
||||
- nonzero exit result and error behavior.
|
||||
- Run:
|
||||
|
||||
```sh
|
||||
go test ./internal/adapters/scriptorium
|
||||
```
|
||||
|
||||
### Prompt Size
|
||||
|
||||
Small enough for one implementation prompt.
|
||||
|
||||
## Stage 5: CLI Test Fixture Cleanup
|
||||
|
||||
### Goal
|
||||
|
||||
Reduce noisy repeated CLI integration test setup without creating a
|
||||
cross-package test framework.
|
||||
|
||||
### Implementation Guidance
|
||||
|
||||
- Add package-local helpers in `internal/cli` tests for repeated:
|
||||
- fake Scriptorium script setup;
|
||||
- generated-text JSON responses;
|
||||
- config file writing;
|
||||
- Weather API test server setup;
|
||||
- artifact path or glob assertions.
|
||||
- Keep a few explicit CLI workflow tests readable end-to-end.
|
||||
- Do not add `internal/testutil` or another global test helper package.
|
||||
- Do not weaken assertions while deduplicating setup.
|
||||
- Do not change production CLI behavior in this stage.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Test setup repetition is reduced in the largest CLI test file.
|
||||
- Test behavior and coverage remain equivalent.
|
||||
- Helpers are local to `internal/cli`.
|
||||
- No production code changes are required for this stage.
|
||||
|
||||
### Tests
|
||||
|
||||
- Run:
|
||||
|
||||
```sh
|
||||
go test ./internal/cli
|
||||
```
|
||||
|
||||
### Prompt Size
|
||||
|
||||
Small enough for one implementation prompt.
|
||||
|
||||
## Stage 6: App Test Fixture Cleanup
|
||||
|
||||
### Goal
|
||||
|
||||
Reduce noisy repeated app orchestration test setup without creating a
|
||||
cross-package test framework.
|
||||
|
||||
### Implementation Guidance
|
||||
|
||||
- Add package-local helpers in `internal/app` tests where repetition is high and
|
||||
the helper improves readability.
|
||||
- Good candidates include repeated fake renderer setup, generated-text JSON
|
||||
responses, config file writing, Weather API test server setup, distributor
|
||||
notifier setup, recording store setup, and artifact assertions.
|
||||
- Keep key workflow tests readable end-to-end so generation ordering remains
|
||||
clear.
|
||||
- Do not add `internal/testutil` or another global test helper package.
|
||||
- Do not weaken assertions while deduplicating setup.
|
||||
- Do not change production app behavior in this stage.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Test setup repetition is reduced in the largest app test file.
|
||||
- Test behavior and coverage remain equivalent.
|
||||
- Helpers are local to `internal/app`.
|
||||
- No production code changes are required for this stage.
|
||||
|
||||
### Tests
|
||||
|
||||
- Run:
|
||||
|
||||
```sh
|
||||
go test ./internal/app
|
||||
```
|
||||
|
||||
### Prompt Size
|
||||
|
||||
Small enough for one implementation prompt.
|
||||
|
||||
## Stage 7: State Artifact Save Helper Cleanup
|
||||
|
||||
### Goal
|
||||
|
||||
Reduce repeated filesystem artifact write boilerplate while keeping artifact
|
||||
semantics visible.
|
||||
|
||||
### Implementation Guidance
|
||||
|
||||
- Add small private helpers in `internal/state/filesystem.go` for resolved JSON
|
||||
and byte artifact writes.
|
||||
- Keep artifact-specific validation in public save methods before calling any
|
||||
helper.
|
||||
- Keep each public save method explicit about which artifact path it writes.
|
||||
- Leave `SaveDataPackage` with its current special behavior unless a helper
|
||||
cleanly preserves `promptinput.Save`.
|
||||
- Leave `SaveMetadata` with its current explicit metadata-path validation and
|
||||
write behavior unless a helper cleanly preserves it.
|
||||
- Do not introduce a manifest, artifact registry, resume system, or broad
|
||||
artifact framework.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Repeated `Paths` plus atomic write mechanics are reduced where the semantics
|
||||
are identical.
|
||||
- Artifact-specific validation and path choice remain easy to see.
|
||||
- Managed artifact paths do not change.
|
||||
- Metadata JSON shape does not change.
|
||||
|
||||
### Tests
|
||||
|
||||
- Keep or update state save tests and metadata round-trip tests.
|
||||
- Run:
|
||||
|
||||
```sh
|
||||
go test ./internal/state ./internal/app
|
||||
```
|
||||
|
||||
### Prompt Size
|
||||
|
||||
Small enough for one implementation prompt.
|
||||
|
||||
## Stage 8: Documentation And Final Validation
|
||||
|
||||
### Goal
|
||||
|
||||
Align implemented documentation only where cleanup changes internal contracts or
|
||||
template editing guidance.
|
||||
|
||||
### Implementation Guidance
|
||||
|
||||
- Update `docs/templates.md` if named template partial support changes how
|
||||
report templates should be edited.
|
||||
- Update relevant `docs/internal/*` files only for implemented internal
|
||||
contract changes.
|
||||
- Update `docs/policy/development.md` only if contributor workflow guidance
|
||||
changes.
|
||||
- Keep unimplemented or deferred cleanup ideas only under `docs/roadmap/`.
|
||||
- Do not document future refactors as implemented behavior.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Non-roadmap docs describe only implemented behavior.
|
||||
- Template-editing guidance matches the final template partial structure, if
|
||||
partials were added.
|
||||
- Internal docs remain accurate for generated text, templates, config, state,
|
||||
and adapters touched by cleanup.
|
||||
|
||||
### Validation
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
go test ./internal/generatedtext ./internal/reporttemplate
|
||||
go test ./internal/config ./internal/app
|
||||
go test ./internal/adapters/scriptorium
|
||||
go test ./internal/cli ./internal/state
|
||||
go test ./...
|
||||
go run ./cmd/weatherreporter --help
|
||||
git diff --check
|
||||
```
|
||||
|
||||
### Prompt Size
|
||||
|
||||
Small enough for one implementation prompt.
|
||||
|
||||
## Deferred Refactors
|
||||
|
||||
The following refactors are out of scope for this cleanup sequence:
|
||||
|
||||
- Generic workflow engine.
|
||||
- Cobra migration or CLI redesign.
|
||||
- Plugin architecture.
|
||||
- Per-module or per-report packages.
|
||||
- Broad Weather API source-ingestion framework.
|
||||
- Manifest, resume, or progress system.
|
||||
- Global test helper package.
|
||||
- Consolidating Daily, Today, and Tomorrow into one public report type.
|
||||
- Replacing editable Markdown templates with Go string builders.
|
||||
- Build-free module catalog split.
|
||||
|
||||
The build-free module catalog split may be revisited later if
|
||||
config/module-boundary complexity grows enough to justify separating module
|
||||
metadata from module builders.
|
||||
|
||||
## Global Validation Checklist
|
||||
|
||||
Run these checks after completing the full cleanup sequence:
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
go run ./cmd/weatherreporter --help
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Also run focused checks after the relevant stages:
|
||||
|
||||
```sh
|
||||
go test ./internal/generatedtext
|
||||
go test ./internal/generatedtext ./internal/reporttemplate
|
||||
go test ./internal/config ./internal/app
|
||||
go test ./internal/adapters/scriptorium
|
||||
go test ./internal/cli
|
||||
go test ./internal/app
|
||||
go test ./internal/state ./internal/app
|
||||
```
|
||||
|
||||
Manual review checklist:
|
||||
|
||||
- Public CLI syntax is unchanged.
|
||||
- Public config fields and defaults are unchanged.
|
||||
- Report IDs, prompt IDs, template IDs, and schema IDs are unchanged.
|
||||
- Managed workspace artifact paths are unchanged.
|
||||
- Distributor bundle paths and notification behavior are unchanged.
|
||||
- Generated report Markdown behavior is unchanged except for intentional,
|
||||
test-covered whitespace differences.
|
||||
- Scriptorium argv construction is unchanged.
|
||||
- Non-roadmap docs do not describe unimplemented cleanup work.
|
||||
@@ -5,18 +5,22 @@ documented outside `docs/roadmap/`.
|
||||
|
||||
## Automatic Storm Monitoring
|
||||
|
||||
Manual Storm Report generation is implemented through
|
||||
`weatherreporter generate storm --start TIME --end TIME`. Automatic storm-event
|
||||
evaluation remains deferred.
|
||||
Manual Storm Report generation is available through:
|
||||
|
||||
Proposed direction:
|
||||
```sh
|
||||
weatherreporter generate storm --start TIME --end TIME
|
||||
```
|
||||
|
||||
1. detect candidate events deterministically from alerts, forecast discussion,
|
||||
weather story context, hourly thresholds, and material forecast changes;
|
||||
2. evaluate candidates through Scriptorium or another narrow evaluator adapter;
|
||||
3. persist storm lifecycle state;
|
||||
4. generate or update Storm Reports only when a meaningful event is present;
|
||||
5. suppress ordinary low-impact thunder or rain chances.
|
||||
Automatic storm-event evaluation is not implemented.
|
||||
|
||||
Possible direction:
|
||||
|
||||
1. Detect candidate storm events from alerts, forecast discussion, weather
|
||||
story context, hourly thresholds, and material forecast changes.
|
||||
2. Evaluate candidates through Scriptorium or another narrow evaluator adapter.
|
||||
3. Persist storm lifecycle state.
|
||||
4. Generate or update Storm Reports only when a meaningful event is present.
|
||||
5. Suppress ordinary low-impact thunder or rain chances.
|
||||
|
||||
Possible lifecycle states:
|
||||
|
||||
@@ -27,40 +31,41 @@ Possible lifecycle states:
|
||||
- `deescalating`
|
||||
- `resolved`
|
||||
|
||||
Acceptance criteria before implementation:
|
||||
Before implementation, the design must preserve scheduled report behavior,
|
||||
manual Storm Report generation, inspectable evaluator failures, and fixture
|
||||
coverage for deterministic candidate detection.
|
||||
|
||||
- scheduled reports and manual Storm Reports remain stable;
|
||||
- candidate detection has fixture coverage;
|
||||
- evaluator failures are inspectable and do not create noisy report output;
|
||||
- manual Storm Report generation remains available.
|
||||
|
||||
## Future Report Types 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.
|
||||
## Future Report Types
|
||||
|
||||
Possible future report types:
|
||||
|
||||
- `next_6_hours` or another short-fuse planning report;
|
||||
- event-specific reports with stable event IDs;
|
||||
- storm review or yesterday-style reports using historical observations;
|
||||
- a short-fuse planning report distinct from the implemented Hourly Report, if
|
||||
a separate product is needed
|
||||
- event-specific reports with stable event IDs
|
||||
- storm review or yesterday-style reports using historical observations
|
||||
- archive-focused report variants if generated report history becomes a
|
||||
first-class product.
|
||||
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:
|
||||
|
||||
- `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
|
||||
Changes;
|
||||
Changes
|
||||
- `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
|
||||
prompt-facing storm-window module;
|
||||
prompt-facing storm-window module
|
||||
- separate AFD section aliases, such as `afd_key_messages`,
|
||||
`afd_short_term_text`, and `afd_long_term_text`, if separate stanzas prove
|
||||
more useful than `area_forecast_discussion.options.sections`;
|
||||
more useful than `area_forecast_discussion.options.sections`
|
||||
- SPC, radar, QPF, snow/rain total, or historical-observation modules once
|
||||
upstream sources and report requirements exist.
|
||||
upstream sources and report requirements exist
|
||||
|
||||
QPF fields such as `measurable_qpf_total_in` and `max_hourly_qpf_in` should
|
||||
remain omitted until a real upstream quantitative precipitation source is
|
||||
@@ -68,80 +73,61 @@ represented in `CollectedFacts`.
|
||||
|
||||
Future module work should preserve these boundaries:
|
||||
|
||||
- collect upstream facts once per report run;
|
||||
- keep upstream fetching out of modules;
|
||||
- keep broad reusable calculations in `DerivedFacts`;
|
||||
- keep prompt-facing field shape inside module builders;
|
||||
- use typed options for configurable module behavior;
|
||||
- keep module snapshots structured and deterministic for Recent Changes.
|
||||
- collect upstream facts once per report run
|
||||
- keep upstream fetching out of modules
|
||||
- keep broad reusable calculations in `DerivedFacts`
|
||||
- keep prompt-facing field shape inside module builders
|
||||
- use typed options for configurable module behavior
|
||||
- keep module snapshots structured and deterministic for Recent Changes
|
||||
|
||||
## Distributor Notification Enhancements
|
||||
|
||||
Distributor notification currently uploads one managed Markdown report per
|
||||
successful generated report through the configured HTTP upload pipeline.
|
||||
Distributor notification uploads one managed Markdown report per successful
|
||||
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`;
|
||||
- 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:
|
||||
Any distributor enhancement should preserve the adapter boundary:
|
||||
weatherreporter selects explicit generated files and submits source bundles,
|
||||
while distributor owns destination routing and publication behavior.
|
||||
|
||||
## Alternate Runtime Integrations
|
||||
|
||||
These ideas are not current behavior:
|
||||
These ideas are not implemented:
|
||||
|
||||
- native LLM client inside `weatherreporter`;
|
||||
- database-backed state;
|
||||
- public HTTP API;
|
||||
- multi-location selection;
|
||||
- daemon mode;
|
||||
- multi-user authorization;
|
||||
- plugin system;
|
||||
- dynamic module loading;
|
||||
- user-defined module code;
|
||||
- YAML-defined module schemas;
|
||||
- module-owned Weather API fetching.
|
||||
- native LLM client inside `weatherreporter`
|
||||
- database-backed state
|
||||
- public HTTP API
|
||||
- multi-location selection
|
||||
- daemon mode
|
||||
- multi-user authorization
|
||||
- plugin system
|
||||
- dynamic module loading
|
||||
- user-defined module code
|
||||
- YAML-defined module schemas
|
||||
- module-owned Weather API fetching
|
||||
|
||||
Each item needs its own design note before implementation. Non-roadmap docs
|
||||
must not describe these as available behavior.
|
||||
|
||||
## Cleanup Refactors
|
||||
## Deferred Refactors
|
||||
|
||||
The initial cleanup pass intentionally left these refactors out because the
|
||||
current implementation does not yet make them worth the added abstraction.
|
||||
These refactors should remain deferred until new requirements or recurring
|
||||
maintenance costs make the added abstraction worthwhile:
|
||||
|
||||
Revisit these only when new source types, report types, operational
|
||||
requirements, or recurring maintenance costs make the duplication materially
|
||||
more expensive:
|
||||
|
||||
- Weather API optional-source specification/helper refactor: consider when
|
||||
additional Weather API sources make per-source fan-out, policy handling, and
|
||||
provenance wiring repetitive enough to obscure adapter behavior.
|
||||
- Broad briefing weather-signal consolidation: consider when multiple 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.
|
||||
- broad briefing weather-signal consolidation
|
||||
- generic workflow engine
|
||||
- Cobra migration
|
||||
- manifest, resume, or progress system
|
||||
- global test helper package
|
||||
- logging subsystem
|
||||
|
||||
Any future implementation should preserve the existing public CLI, artifact
|
||||
paths, report identities, module boundaries, and adapter boundaries unless a
|
||||
|
||||
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),
|
||||
[Scriptorium integration](integrations/scriptorium.md).
|
||||
|
||||
## Generated Text Validation Fails
|
||||
|
||||
Symptom: Daily, Today, Tomorrow, or Hourly generation fails with generated-text
|
||||
decode, unknown-field, required-field, or multiple-JSON-values context.
|
||||
|
||||
Likely cause: Scriptorium wrote structured JSON that does not match the
|
||||
GeneratedText contract for the selected report.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
weatherreporter inspect metadata RUN_ID
|
||||
```
|
||||
|
||||
Then inspect the generated-text raw path recorded in metadata, if present.
|
||||
|
||||
Safe fix: update the Scriptorium prompt or schema configuration so the prompt
|
||||
writes the expected structured JSON for the report.
|
||||
|
||||
Relevant docs: [Operations guide](operations.md),
|
||||
[Generated Text internals](internal/generatedtext.md),
|
||||
[Scriptorium integration](integrations/scriptorium.md).
|
||||
|
||||
## Template Rendering Fails
|
||||
|
||||
Symptom: Daily, Today, Tomorrow, or Hourly generation fails with report template
|
||||
parsing or execution context after generated text validation succeeds.
|
||||
|
||||
Likely cause: an embedded template references a missing context field or
|
||||
receives a value shape that does not match its typed render context.
|
||||
|
||||
Diagnostic:
|
||||
|
||||
```sh
|
||||
weatherreporter inspect metadata RUN_ID
|
||||
```
|
||||
|
||||
If metadata records generated-text and render-context paths, inspect those
|
||||
artifacts along with the template named by the report definition.
|
||||
|
||||
Safe fix: update the embedded template or render-context builder so the
|
||||
template uses the implemented typed context.
|
||||
|
||||
Relevant docs: [Report Templates](templates.md),
|
||||
[Report Template internals](internal/reporttemplate.md).
|
||||
|
||||
## Batch Command Returns Nonzero
|
||||
|
||||
Symptom: `run morning` or `run evening` returns nonzero.
|
||||
|
||||
@@ -21,7 +21,7 @@ notify:
|
||||
token_env: DISTRIBUTOR_UPLOAD_TOKEN
|
||||
timeout: 30s
|
||||
failure_policy: error
|
||||
pipeline_id_template: "weatherreporter.{artifact_group}"
|
||||
pipeline_id_template: "weatherreporter.{report_id}"
|
||||
bundle_id_template: "weatherreporter.{location_id}.{report_id}"
|
||||
idempotency_key_template: "{bundle_id}.{run_id}"
|
||||
report_path_templates:
|
||||
@@ -77,6 +77,7 @@ reports:
|
||||
- derived_daypart_summaries
|
||||
- precip_timing
|
||||
- alert_digest
|
||||
- spc_convective_outlooks
|
||||
- id: area_forecast_discussion
|
||||
options:
|
||||
sections:
|
||||
@@ -84,6 +85,45 @@ reports:
|
||||
- key_messages
|
||||
- short_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
|
||||
- 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
|
||||
|
||||
@@ -79,6 +79,12 @@ type RunRequest struct {
|
||||
OutputPath string
|
||||
}
|
||||
|
||||
type StructuredRunRequest struct {
|
||||
PromptID string
|
||||
DataPackagePath string
|
||||
OutputPath string
|
||||
}
|
||||
|
||||
type RenderResult struct {
|
||||
Command []string `json:"command"`
|
||||
Stdout string `json:"stdout"`
|
||||
@@ -98,6 +104,16 @@ type RunResult struct {
|
||||
OutputPath string `json:"outputPath"`
|
||||
}
|
||||
|
||||
type StructuredRunResult struct {
|
||||
Command []string `json:"command"`
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
StdoutTruncated bool `json:"stdoutTruncated,omitempty"`
|
||||
StderrTruncated bool `json:"stderrTruncated,omitempty"`
|
||||
ExitCode int `json:"exitCode"`
|
||||
OutputPath string `json:"outputPath"`
|
||||
}
|
||||
|
||||
func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, error) {
|
||||
if req.PromptID == "" {
|
||||
return nil, fmt.Errorf("prompt id is required")
|
||||
@@ -152,6 +168,35 @@ func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
||||
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 {
|
||||
binary string
|
||||
args []string
|
||||
@@ -212,6 +257,14 @@ func (r Runner) runArgs(req RunRequest) []string {
|
||||
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 {
|
||||
data []byte
|
||||
limit int
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestRenderConstructsCommand(t *testing.T) {
|
||||
}
|
||||
|
||||
result, err := runner.Render(context.Background(), RenderRequest{
|
||||
PromptID: "weather.daily_report",
|
||||
PromptID: "weather.markdown_report",
|
||||
DataPackagePath: "/tmp/data_package.yaml",
|
||||
})
|
||||
if err != nil {
|
||||
@@ -30,7 +30,7 @@ func TestRenderConstructsCommand(t *testing.T) {
|
||||
"render",
|
||||
"--config", "/etc/scriptorium.yml",
|
||||
"--profile", "weather",
|
||||
"--prompt", "weather.daily_report",
|
||||
"--prompt", "weather.markdown_report",
|
||||
"--input", "data_package=/tmp/data_package.yaml",
|
||||
"--format", "json",
|
||||
}
|
||||
@@ -56,7 +56,7 @@ func TestRenderReturnsResultForNonzeroExit(t *testing.T) {
|
||||
}
|
||||
|
||||
result, err := runner.Render(context.Background(), RenderRequest{
|
||||
PromptID: "weather.daily_report",
|
||||
PromptID: "weather.markdown_report",
|
||||
DataPackagePath: "/tmp/data_package.yaml",
|
||||
})
|
||||
if err == nil {
|
||||
@@ -84,7 +84,7 @@ func TestRunConstructsCommand(t *testing.T) {
|
||||
}
|
||||
|
||||
result, err := runner.Run(context.Background(), RunRequest{
|
||||
PromptID: "weather.daily_report",
|
||||
PromptID: "weather.markdown_report",
|
||||
DataPackagePath: "/tmp/data_package.yaml",
|
||||
OutputPath: "/tmp/daily.md",
|
||||
})
|
||||
@@ -96,7 +96,7 @@ func TestRunConstructsCommand(t *testing.T) {
|
||||
"run",
|
||||
"--config", "/etc/scriptorium.yml",
|
||||
"--profile", "weather",
|
||||
"--prompt", "weather.daily_report",
|
||||
"--prompt", "weather.markdown_report",
|
||||
"--input", "data_package=/tmp/data_package.yaml",
|
||||
"--out", "/tmp/daily.md",
|
||||
}
|
||||
@@ -129,7 +129,7 @@ func TestRunReturnsResultForValidationExit(t *testing.T) {
|
||||
}
|
||||
|
||||
result, err := runner.Run(context.Background(), RunRequest{
|
||||
PromptID: "weather.daily_report",
|
||||
PromptID: "weather.markdown_report",
|
||||
DataPackagePath: "/tmp/data_package.yaml",
|
||||
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 {
|
||||
name string
|
||||
args []string
|
||||
timeout time.Duration
|
||||
result CommandResult
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeCommands) Run(_ context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) {
|
||||
f.calls++
|
||||
f.name = name
|
||||
f.args = append([]string{}, args...)
|
||||
f.timeout = timeout
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
func containsArg(args []string, want string) bool {
|
||||
for _, arg := range args {
|
||||
if arg == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ import (
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
const (
|
||||
convectiveOutlooksEndpoint = "/outlooks/convective"
|
||||
sourceSPCConvectiveOutlooks = "spc_convective_outlooks"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL *url.URL
|
||||
httpClient *http.Client
|
||||
@@ -112,6 +117,9 @@ func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
|
||||
if err := builder.fetchWeatherStory(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := builder.fetchSPCConvectiveOutlooks(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return builder.bundle, nil
|
||||
}
|
||||
@@ -122,18 +130,32 @@ type bundleBuilder struct {
|
||||
fetchedAt time.Time
|
||||
}
|
||||
|
||||
type sourceRequest struct {
|
||||
name string
|
||||
endpoint string
|
||||
query queryOptions
|
||||
missingMessage string
|
||||
required bool
|
||||
decodeLabel string
|
||||
}
|
||||
|
||||
type fetchedSource struct {
|
||||
raw json.RawMessage
|
||||
source weatherdata.Source
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, "observations", "/observations", queryOptions{precision: true})
|
||||
if err != nil {
|
||||
var observation weatherdata.Observation
|
||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||
name: "observations",
|
||||
endpoint: "/observations",
|
||||
query: queryOptions{precision: true},
|
||||
missingMessage: "observation data is missing",
|
||||
}, &observation)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "observation data is missing", false)
|
||||
}
|
||||
var observation weatherdata.Observation
|
||||
if err := decodeSource(raw, &observation); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
}
|
||||
source := fetched.source
|
||||
source.IssuedAt = &observation.Timestamp
|
||||
b.bundle.Observation = &observation
|
||||
b.addSource(source)
|
||||
@@ -141,34 +163,36 @@ func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, "current", "/conditions/current", queryOptions{precision: true})
|
||||
if err != nil {
|
||||
var current weatherdata.Current
|
||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||
name: "current",
|
||||
endpoint: "/conditions/current",
|
||||
query: queryOptions{precision: true},
|
||||
missingMessage: "current conditions data is missing",
|
||||
}, ¤t)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
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)
|
||||
}
|
||||
source := fetched.source
|
||||
b.bundle.Current = ¤t
|
||||
b.addSource(source)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, "hourly", "/forecast/hourly", queryOptions{precision: true, timezone: true})
|
||||
if err != nil {
|
||||
var hourly weatherdata.ForecastRun
|
||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||
name: "hourly",
|
||||
endpoint: "/forecast/hourly",
|
||||
query: queryOptions{precision: true, timezone: true},
|
||||
missingMessage: "hourly forecast data is missing",
|
||||
required: true,
|
||||
decodeLabel: "hourly forecast",
|
||||
}, &hourly)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "hourly forecast data is missing", true)
|
||||
}
|
||||
var hourly weatherdata.ForecastRun
|
||||
if err := decodeSource(raw, &hourly); err != nil {
|
||||
return fmt.Errorf("decode hourly forecast from %s: %w", source.Endpoint, err)
|
||||
}
|
||||
source := fetched.source
|
||||
if len(hourly.Periods) == 0 {
|
||||
return fmt.Errorf("hourly forecast from %s contains no periods", source.Endpoint)
|
||||
}
|
||||
@@ -180,17 +204,17 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, "narrative", "/forecast/narrative", queryOptions{precision: true, timezone: true})
|
||||
if err != nil {
|
||||
var narrative weatherdata.ForecastRun
|
||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||
name: "narrative",
|
||||
endpoint: "/forecast/narrative",
|
||||
query: queryOptions{precision: true, timezone: true},
|
||||
missingMessage: "narrative forecast data is missing",
|
||||
}, &narrative)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "narrative forecast data is missing", false)
|
||||
}
|
||||
var narrative weatherdata.ForecastRun
|
||||
if err := decodeSource(raw, &narrative); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
}
|
||||
source := fetched.source
|
||||
source.IssuedAt = &narrative.IssuedAt
|
||||
source.UpdatedAt = narrative.UpdatedAt
|
||||
b.bundle.Narrative = &narrative
|
||||
@@ -213,7 +237,7 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
||||
}
|
||||
var alerts weatherdata.AlertRun
|
||||
if err := decodeSource(raw, &alerts); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
return b.handleMalformed(&source, err, sourceRequest{name: "alerts"})
|
||||
}
|
||||
alerts.Raw = append(json.RawMessage(nil), raw...)
|
||||
if alerts.AsOf != nil {
|
||||
@@ -225,17 +249,17 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, "discussion", "/discussion", queryOptions{timezone: true})
|
||||
if err != nil {
|
||||
var discussion weatherdata.Discussion
|
||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||
name: "discussion",
|
||||
endpoint: "/discussion",
|
||||
query: queryOptions{timezone: true},
|
||||
missingMessage: "forecast discussion data is missing",
|
||||
}, &discussion)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "forecast discussion data is missing", false)
|
||||
}
|
||||
var discussion weatherdata.Discussion
|
||||
if err := decodeSource(raw, &discussion); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
}
|
||||
source := fetched.source
|
||||
source.IssuedAt = &discussion.IssuedAt
|
||||
source.UpdatedAt = discussion.UpdatedAt
|
||||
b.bundle.Discussion = &discussion
|
||||
@@ -244,17 +268,17 @@ func (b *bundleBuilder) fetchDiscussion(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})
|
||||
if err != nil {
|
||||
var story weatherdata.WeatherStory
|
||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
||||
name: "weather_story",
|
||||
endpoint: "/weatherstories/latest",
|
||||
query: queryOptions{omitUnits: true},
|
||||
missingMessage: "NWS weather story data is missing",
|
||||
}, &story)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
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)
|
||||
}
|
||||
source := fetched.source
|
||||
if !story.StartTime.IsZero() {
|
||||
source.IssuedAt = &story.StartTime
|
||||
}
|
||||
@@ -264,6 +288,51 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
||||
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 {
|
||||
source.Missing = true
|
||||
if required {
|
||||
@@ -272,9 +341,13 @@ func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string
|
||||
return b.applyMissingPolicy(source, "missing_source", message)
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) handleMalformed(source *weatherdata.Source, err error, required bool) error {
|
||||
if required {
|
||||
return fmt.Errorf("decode %s from %s: %w", source.Name, source.Endpoint, err)
|
||||
func (b *bundleBuilder) handleMalformed(source *weatherdata.Source, err error, request sourceRequest) error {
|
||||
if request.required {
|
||||
label := request.name
|
||||
if request.decodeLabel != "" {
|
||||
label = request.decodeLabel
|
||||
}
|
||||
return fmt.Errorf("decode %s from %s: %w", label, source.Endpoint, err)
|
||||
}
|
||||
source.Missing = true
|
||||
return b.applyMissingPolicy(source, "malformed_source", fmt.Sprintf("malformed %s data: %v", source.Name, err))
|
||||
|
||||
@@ -55,12 +55,39 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
||||
if bundle.WeatherStory.UpdatedAt == nil {
|
||||
t.Fatalf("WeatherStory.UpdatedAt = nil, want update timestamp")
|
||||
}
|
||||
if len(bundle.Sources) != 7 {
|
||||
t.Fatalf("Sources length = %d, want 7", len(bundle.Sources))
|
||||
if bundle.SPCConvectiveOutlooks == nil || len(bundle.SPCConvectiveOutlooks.Outlooks) != 1 {
|
||||
t.Fatalf("SPCConvectiveOutlooks = %#v, want one outlook", bundle.SPCConvectiveOutlooks)
|
||||
}
|
||||
if len(bundle.SPCConvectiveOutlooks.Outlooks[0].Geometry) == 0 {
|
||||
t.Fatalf("SPCConvectiveOutlooks.Outlooks[0].Geometry is empty, want GeoJSON")
|
||||
}
|
||||
if len(bundle.SPCConvectiveOutlooks.Discussions) != 1 || bundle.SPCConvectiveOutlooks.Discussions[0].Headline != "Severe storms possible" {
|
||||
t.Fatalf("SPCConvectiveOutlooks.Discussions = %#v, want one discussion", bundle.SPCConvectiveOutlooks.Discussions)
|
||||
}
|
||||
if len(bundle.Sources) != 8 {
|
||||
t.Fatalf("Sources length = %d, want 8", len(bundle.Sources))
|
||||
}
|
||||
if len(bundle.Warnings) != 0 {
|
||||
t.Fatalf("Warnings length = %d, want no warnings", len(bundle.Warnings))
|
||||
}
|
||||
wantPaths := []string{
|
||||
"/observations",
|
||||
"/conditions/current",
|
||||
"/forecast/hourly",
|
||||
"/forecast/narrative",
|
||||
"/alerts/active",
|
||||
"/discussion",
|
||||
"/weatherstories/latest",
|
||||
convectiveOutlooksEndpoint,
|
||||
}
|
||||
if len(requested) != len(wantPaths) {
|
||||
t.Fatalf("requested paths = %v, want %d source endpoints", requested, len(wantPaths))
|
||||
}
|
||||
for _, want := range wantPaths {
|
||||
if !containsPath(requested, want) {
|
||||
t.Fatalf("requested paths = %v, want %s", requested, want)
|
||||
}
|
||||
}
|
||||
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
|
||||
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
|
||||
}
|
||||
@@ -70,6 +97,9 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
||||
if !containsPath(requested, "/weatherstories/latest") {
|
||||
t.Fatalf("requested paths = %v, want weather story endpoint", requested)
|
||||
}
|
||||
if !containsPath(requested, convectiveOutlooksEndpoint) {
|
||||
t.Fatalf("requested paths = %v, want convective outlook endpoint", requested)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
||||
@@ -92,6 +122,12 @@ func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(rawURL, convectiveOutlooksEndpoint) {
|
||||
if strings.Contains(rawURL, "units=") || strings.Contains(rawURL, "precision=") || !strings.Contains(rawURL, "tz=America%2FChicago") {
|
||||
t.Fatalf("convective outlook request %q should use format and tz only", rawURL)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(rawURL, "units=us") {
|
||||
t.Fatalf("request %q missing units=us", rawURL)
|
||||
}
|
||||
@@ -127,6 +163,25 @@ func TestFetchBundleRecordsSourceHash(t *testing.T) {
|
||||
if story.IssuedAt == nil || story.UpdatedAt == nil {
|
||||
t.Fatalf("weather story source timestamps = issued %#v updated %#v, want both", story.IssuedAt, story.UpdatedAt)
|
||||
}
|
||||
outlooks := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
|
||||
if outlooks.Endpoint != convectiveOutlooksEndpoint {
|
||||
t.Fatalf("convective outlook endpoint = %q, want %s", outlooks.Endpoint, convectiveOutlooksEndpoint)
|
||||
}
|
||||
if outlooks.Query["format"] != "json" || outlooks.Query["tz"] != "America/Chicago" || outlooks.Query["units"] != "" || outlooks.Query["precision"] != "" {
|
||||
t.Fatalf("convective outlook query = %#v, want format and tz only", outlooks.Query)
|
||||
}
|
||||
if outlooks.DataSHA256 != hashFixtureData(t, "convective_outlooks.json") {
|
||||
t.Fatalf("convective outlook DataSHA256 = %q, want fixture hash", outlooks.DataSHA256)
|
||||
}
|
||||
if outlooks.Missing {
|
||||
t.Fatal("convective outlook source Missing = true, want false")
|
||||
}
|
||||
if outlooks.IssuedAt == nil || outlooks.IssuedAt.Format(time.RFC3339) != "2026-05-29T15:45:00Z" {
|
||||
t.Fatalf("convective outlook IssuedAt = %#v, want run issuedAt", outlooks.IssuedAt)
|
||||
}
|
||||
if outlooks.UpdatedAt == nil || outlooks.UpdatedAt.Format(time.RFC3339) != "2026-05-29T16:05:00Z" {
|
||||
t.Fatalf("convective outlook UpdatedAt = %#v, want run updatedAt", outlooks.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPErrorIsActionable(t *testing.T) {
|
||||
@@ -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) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -363,13 +471,14 @@ type handlerOverride struct {
|
||||
func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server {
|
||||
t.Helper()
|
||||
fixtures := map[string]string{
|
||||
"/observations": "observations.json",
|
||||
"/conditions/current": "current.json",
|
||||
"/forecast/hourly": "hourly.json",
|
||||
"/forecast/narrative": "narrative.json",
|
||||
"/alerts/active": "alerts.json",
|
||||
"/discussion": "discussion.json",
|
||||
"/weatherstories/latest": "weather_story.json",
|
||||
"/observations": "observations.json",
|
||||
"/conditions/current": "current.json",
|
||||
"/forecast/hourly": "hourly.json",
|
||||
"/forecast/narrative": "narrative.json",
|
||||
"/alerts/active": "alerts.json",
|
||||
"/discussion": "discussion.json",
|
||||
"/weatherstories/latest": "weather_story.json",
|
||||
convectiveOutlooksEndpoint: "convective_outlooks.json",
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if requested != nil {
|
||||
|
||||
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/fileutil"
|
||||
"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/promptinput"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
@@ -28,18 +29,20 @@ import (
|
||||
type ReportKind string
|
||||
|
||||
const (
|
||||
ReportDaily ReportKind = "daily"
|
||||
ReportTomorrow ReportKind = "tomorrow"
|
||||
ReportThreeDay ReportKind = "three-day"
|
||||
ReportWeekend ReportKind = "weekend"
|
||||
ReportStorm ReportKind = "storm"
|
||||
ReportDaily ReportKind = ReportKind(report.CommandNameDaily)
|
||||
ReportToday ReportKind = ReportKind(report.CommandNameToday)
|
||||
ReportTomorrow ReportKind = ReportKind(report.CommandNameTomorrow)
|
||||
ReportHourly ReportKind = ReportKind(report.CommandNameHourly)
|
||||
ReportThreeDay ReportKind = ReportKind(report.CommandNameThreeDay)
|
||||
ReportWeekend ReportKind = ReportKind(report.CommandNameWeekend)
|
||||
ReportStorm ReportKind = ReportKind(report.CommandNameStorm)
|
||||
)
|
||||
|
||||
type BatchKind string
|
||||
|
||||
const (
|
||||
BatchMorning BatchKind = "morning"
|
||||
BatchEvening BatchKind = "evening"
|
||||
BatchMorning BatchKind = BatchKind(report.BatchNameMorning)
|
||||
BatchEvening BatchKind = BatchKind(report.BatchNameEvening)
|
||||
)
|
||||
|
||||
type GenerateRequest struct {
|
||||
@@ -88,21 +91,26 @@ type ReportRequest struct {
|
||||
}
|
||||
|
||||
type ReportResult struct {
|
||||
ModuleSnapshot module.Snapshot
|
||||
ModuleSnapshotPath string
|
||||
DataPackage promptinput.Package
|
||||
DataPackagePath string
|
||||
PreflightPath string
|
||||
ReportPath string
|
||||
OutputPath string
|
||||
NotificationPath string
|
||||
Metadata state.Metadata
|
||||
MetadataPath string
|
||||
PriorSnapshot *state.PriorSnapshot
|
||||
RecentChanges []changes.Change
|
||||
RenderResult *scriptorium.RenderResult
|
||||
RunResult *scriptorium.RunResult
|
||||
Notification *NotificationResult
|
||||
ModuleSnapshot module.Snapshot
|
||||
ModuleSnapshotPath string
|
||||
DataPackage promptinput.Package
|
||||
DataPackagePath string
|
||||
PreflightPath string
|
||||
ReportPath string
|
||||
OutputPath string
|
||||
NotificationPath string
|
||||
Metadata state.Metadata
|
||||
MetadataPath string
|
||||
PriorSnapshot *state.PriorSnapshot
|
||||
RecentChanges []changes.Change
|
||||
RenderResult *scriptorium.RenderResult
|
||||
RunResult *scriptorium.RunResult
|
||||
StructuredRunResult *scriptorium.StructuredRunResult
|
||||
GeneratedTextRawPath string
|
||||
GeneratedTextResultPath string
|
||||
GeneratedTextPath string
|
||||
RenderContextPath string
|
||||
Notification *NotificationResult
|
||||
}
|
||||
|
||||
type BatchResult struct {
|
||||
@@ -150,6 +158,7 @@ func (e BatchError) Error() string {
|
||||
type Renderer interface {
|
||||
Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error)
|
||||
Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error)
|
||||
StructuredRun(context.Context, scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error)
|
||||
}
|
||||
|
||||
type Notifier interface {
|
||||
@@ -336,7 +345,7 @@ func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error
|
||||
if err != nil {
|
||||
return report.Resolved{}, err
|
||||
}
|
||||
id, err := reportIDForCommand(req.Report)
|
||||
id, err := report.IDForCommandName(string(req.Report))
|
||||
if err != nil {
|
||||
return report.Resolved{}, err
|
||||
}
|
||||
@@ -358,7 +367,7 @@ func ResolveBatch(req BatchRequest, now time.Time) ([]report.Resolved, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
batch, err := reportBatchForCommand(req.Batch)
|
||||
batch, err := report.BatchForCommandName(string(req.Batch))
|
||||
if err != nil {
|
||||
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) {
|
||||
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 {
|
||||
return report.Registry{}, err
|
||||
}
|
||||
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) {
|
||||
client, err := weatherapi.New(req.Config)
|
||||
if err != nil {
|
||||
@@ -482,11 +467,15 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
||||
}
|
||||
briefingMetadata := briefing.BuildMetadata(briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected))
|
||||
metadata := state.BuildMetadataFromBriefingMetadata(req.Resolved, briefingMetadata, state.ArtifactPaths{
|
||||
ModuleSnapshot: moduleSnapshotPath,
|
||||
Metadata: paths.Metadata,
|
||||
DataPackage: paths.DataPackage,
|
||||
Preflight: paths.Preflight,
|
||||
RenderedReport: paths.RenderedReport,
|
||||
ModuleSnapshot: moduleSnapshotPath,
|
||||
Metadata: paths.Metadata,
|
||||
DataPackage: paths.DataPackage,
|
||||
Preflight: paths.Preflight,
|
||||
RenderedReport: paths.RenderedReport,
|
||||
GeneratedTextRaw: paths.GeneratedTextRaw,
|
||||
GeneratedTextResult: paths.GeneratedTextResult,
|
||||
GeneratedText: paths.GeneratedText,
|
||||
RenderContext: paths.RenderContext,
|
||||
})
|
||||
dataPackage, err := promptinput.Build(promptinput.BuildRequest{
|
||||
Metadata: promptMetadata(metadata),
|
||||
@@ -531,9 +520,36 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
||||
return nil, metadataErr
|
||||
}
|
||||
if renderErr != nil {
|
||||
if req.Resolved.Definition.GenerationMode == report.GenerationModeGeneratedTextTemplate {
|
||||
return nil, generatedReportError(req.Resolved, metadata.RunID, "render preflight", 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -543,32 +559,16 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
||||
DataPackagePath: dataPackagePath,
|
||||
OutputPath: reportPath,
|
||||
})
|
||||
if runErr == nil && req.OutputPath != "" && req.OutputPath != reportPath {
|
||||
if err := fileutil.CopyFileAtomic(reportPath, req.OutputPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
outputPath := reportPath
|
||||
if req.OutputPath != "" {
|
||||
outputPath = req.OutputPath
|
||||
}
|
||||
metadata.RenderedReportPath = reportPath
|
||||
metadataPath, metadataErr = store.SaveMetadata(ctx, metadata)
|
||||
if metadataErr != nil {
|
||||
return nil, metadataErr
|
||||
}
|
||||
if runErr != nil {
|
||||
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
|
||||
}
|
||||
}
|
||||
finalized, err := finalizeRenderedReport(ctx, finalizeRenderedReportRequest{
|
||||
Config: req.Config,
|
||||
Store: store,
|
||||
Resolved: req.Resolved,
|
||||
Metadata: metadata,
|
||||
ManagedReportPath: reportPath,
|
||||
OutputPath: req.OutputPath,
|
||||
Notifier: req.Notifier,
|
||||
GenerationErr: runErr,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -580,18 +580,214 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
||||
DataPackagePath: dataPackagePath,
|
||||
PreflightPath: preflightPath,
|
||||
ReportPath: reportPath,
|
||||
OutputPath: outputPath,
|
||||
NotificationPath: notificationPath,
|
||||
Metadata: metadata,
|
||||
MetadataPath: metadataPath,
|
||||
OutputPath: finalized.OutputPath,
|
||||
NotificationPath: finalized.NotificationPath,
|
||||
Metadata: finalized.Metadata,
|
||||
MetadataPath: finalized.MetadataPath,
|
||||
PriorSnapshot: priorSnapshot,
|
||||
RecentChanges: recentChanges,
|
||||
RenderResult: renderResult,
|
||||
RunResult: runResult,
|
||||
Notification: notification,
|
||||
Notification: finalized.Notification,
|
||||
}, 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) {
|
||||
notifier, enabled := reportNotifier(cfg, notifier)
|
||||
if !enabled {
|
||||
@@ -911,7 +1107,7 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.
|
||||
PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes,
|
||||
}
|
||||
switch reportID {
|
||||
case report.DailyToday, report.DailyTomorrow:
|
||||
case report.Daily, report.Today, report.Tomorrow:
|
||||
return changes.CompareDaily(previous, current, thresholds)
|
||||
case report.ThreeDay:
|
||||
return changes.CompareThreeDay(previous, current, thresholds)
|
||||
@@ -935,3 +1131,10 @@ func preflightArtifact(result *scriptorium.RenderResult) state.PreflightArtifact
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if period.StartTime != "2026-05-29 at 8:00 AM" || period.EndTime != "2026-05-29 at 9:00 AM" {
|
||||
t.Fatalf("HourlyForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime)
|
||||
if period.PeriodBegins != "2026-05-29 at 8:00 AM" || period.PeriodEnds != "2026-05-29 at 9:00 AM" {
|
||||
t.Fatalf("HourlyForecast period times = %q/%q, want friendly local time labels", period.PeriodBegins, period.PeriodEnds)
|
||||
}
|
||||
if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
|
||||
t.Fatalf("HourlyForecast period = %#v, want compass wind and precip chance", period)
|
||||
}
|
||||
if !period.MentionPrecipitation {
|
||||
t.Fatalf("MentionPrecipitation = false, want true for default threshold")
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal hourly forecast: %v", err)
|
||||
}
|
||||
jsonText := string(data)
|
||||
for _, field := range []string{"source_location_id", "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) {
|
||||
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") {
|
||||
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`) {
|
||||
t.Fatalf("hourly json = %s, want friendly local start/end times", jsonText)
|
||||
if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) {
|
||||
t.Fatalf("hourly json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHourlyForecastPromptExportOmitsTemplateHelpers(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
richText := mustMarshalModuleJSON(t, output.Value)
|
||||
for _, field := range []string{"hour_label", "text_description_lower", "mention_precipitation"} {
|
||||
if !strings.Contains(richText, field) {
|
||||
t.Fatalf("rich hourly json = %s, want helper field %s", richText, field)
|
||||
}
|
||||
}
|
||||
|
||||
prompt := moduleDataPackageValue[HourlyForecastPromptExport](t, output)
|
||||
if prompt.Product != "hourly" || prompt.SourceLocationID != "test-grid" || len(prompt.Periods) != 1 {
|
||||
t.Fatalf("hourly prompt export = %#v, want hourly metadata and one period", prompt)
|
||||
}
|
||||
period := prompt.Periods[0]
|
||||
if period.PeriodBegins != "2026-05-29 at 8:00 AM" || period.PeriodEnds != "2026-05-29 at 9:00 AM" || period.TextDescription != "Showers likely." {
|
||||
t.Fatalf("hourly prompt period = %#v, want factual period fields", period)
|
||||
}
|
||||
if period.TemperatureF == nil || *period.TemperatureF != 76 || period.WindSpeedMph == nil || *period.WindSpeedMph != 14 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
|
||||
t.Fatalf("hourly prompt period = %#v, want temperature, wind, and precip fields", period)
|
||||
}
|
||||
if period.WindDirection != "S" || period.RelativeHumidityPercent == nil || *period.RelativeHumidityPercent != 66 {
|
||||
t.Fatalf("hourly prompt period = %#v, want wind direction and humidity", period)
|
||||
}
|
||||
promptText := mustMarshalModuleJSON(t, output.DataPackageValue())
|
||||
for _, field := range []string{"period_begins", "period_ends", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} {
|
||||
if !strings.Contains(promptText, field) {
|
||||
t.Fatalf("hourly prompt json = %s, want field %s", promptText, field)
|
||||
}
|
||||
}
|
||||
for _, field := range []string{"hour_label", "text_description_lower", "mention_precipitation"} {
|
||||
if strings.Contains(promptText, field) {
|
||||
t.Fatalf("hourly prompt json = %s, want omitted helper field %s", promptText, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHourlyForecastPrecipMentionThreshold(t *testing.T) {
|
||||
periods := []weatherdata.ForecastPeriod{
|
||||
{StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"), ProbabilityOfPrecipitationPercent: floatPtr(19)},
|
||||
{StartTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"), ProbabilityOfPrecipitationPercent: floatPtr(20)},
|
||||
{StartTime: mustParseModuleTime("2026-05-29T10:00:00-05:00")},
|
||||
}
|
||||
value := hourlyForecastPeriodsWithPrecipMentionThreshold(periods, "America/Chicago", DefaultHourlyForecastPrecipMentionProbabilityThreshold)
|
||||
if len(value) != 3 {
|
||||
t.Fatalf("periods length = %d, want 3", len(value))
|
||||
}
|
||||
if value[0].MentionPrecipitation {
|
||||
t.Fatalf("19%% MentionPrecipitation = true, want false")
|
||||
}
|
||||
if !value[1].MentionPrecipitation {
|
||||
t.Fatalf("20%% MentionPrecipitation = false, want true")
|
||||
}
|
||||
if value[2].MentionPrecipitation {
|
||||
t.Fatalf("nil MentionPrecipitation = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
@@ -113,8 +194,8 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
||||
if period.Name != "Today" || period.TextDescription != "Morning storms, then partly sunny." {
|
||||
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" {
|
||||
t.Fatalf("NarrativeForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime)
|
||||
if period.PeriodBegins != "2026-05-29 at 6:00 AM" || period.PeriodEnds != "2026-05-29 at 6:00 PM" {
|
||||
t.Fatalf("NarrativeForecast period times = %q/%q, want friendly local time labels", period.PeriodBegins, period.PeriodEnds)
|
||||
}
|
||||
if period.IsDay == nil || !*period.IsDay || period.TemperatureF == nil || *period.TemperatureF != 81 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 60 {
|
||||
t.Fatalf("NarrativeForecast period = %#v, want day, temperature, and precip values", period)
|
||||
@@ -127,7 +208,7 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
||||
t.Fatalf("Marshal narrative forecast: %v", err)
|
||||
}
|
||||
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) {
|
||||
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") {
|
||||
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`) {
|
||||
t.Fatalf("narrative json = %s, want friendly local start/end times", jsonText)
|
||||
if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) {
|
||||
t.Fatalf("narrative json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText)
|
||||
}
|
||||
if strings.Contains(jsonText, "Tomorrow night") {
|
||||
t.Fatalf("narrative json = %s, want only valid-period narrative periods", jsonText)
|
||||
@@ -163,7 +244,7 @@ func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if value.Location == nil || value.Location.Name != "Brentwood" {
|
||||
@@ -194,18 +275,24 @@ func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[CurrentConditionsModule](t, output)
|
||||
if value.ConditionText != "Partly cloudy" || value.TemperatureF == nil || *value.TemperatureF != 74 {
|
||||
t.Fatalf("CurrentConditions = %#v, want current condition facts", value)
|
||||
if value.ConditionText != "Partly cloudy" || value.ConditionTextLower != "partly cloudy" || value.TemperatureF == nil || *value.TemperatureF != 74 {
|
||||
t.Fatalf("CurrentConditions = %#v, want rounded current condition facts", value)
|
||||
}
|
||||
if value.ApparentTemperatureF == nil || *value.ApparentTemperatureF != 76 || value.RelativeHumidityPercent == nil || *value.RelativeHumidityPercent != 71 || value.WindSpeedMph == nil || *value.WindSpeedMph != 8 {
|
||||
t.Fatalf("CurrentConditions rounded values = %#v, want apparent 76, humidity 71, wind 8", value)
|
||||
}
|
||||
if value.WindDirection != "S" {
|
||||
t.Fatalf("WindDirection = %q, want S", value.WindDirection)
|
||||
}
|
||||
if value.WindDirectionText != "south" {
|
||||
t.Fatalf("WindDirectionText = %q, want south", value.WindDirectionText)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal current conditions: %v", err)
|
||||
}
|
||||
jsonText := string(data)
|
||||
for _, field := range []string{"condition_text", "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) {
|
||||
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) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
@@ -285,13 +410,19 @@ func TestAreaForecastDiscussionAndWeatherStoryModules(t *testing.T) {
|
||||
if !story.Available || story.Title != "Rain Chances" || story.Description != "Scattered showers are possible." {
|
||||
t.Fatalf("WeatherStory = %#v, want structured story fields", story)
|
||||
}
|
||||
if story.PeriodBegins != "2026-05-29 at 6:00 AM" || story.PeriodEnds != "2026-05-29 at 6:00 PM" {
|
||||
t.Fatalf("WeatherStory period = %q/%q, want friendly local period labels", story.PeriodBegins, story.PeriodEnds)
|
||||
}
|
||||
data, err := json.Marshal(storyOutput.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal weather story: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "download_url") {
|
||||
if !strings.Contains(string(data), "download_url") || !strings.Contains(string(data), "period_begins") || !strings.Contains(string(data), "period_ends") {
|
||||
t.Fatalf("weather story json = %s, want snake_case download_url", string(data))
|
||||
}
|
||||
if strings.Contains(string(data), "start_time") || strings.Contains(string(data), "end_time") {
|
||||
t.Fatalf("weather story json = %s, want period_begins/period_ends instead of start_time/end_time", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
|
||||
@@ -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 {
|
||||
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
|
||||
definition := report.DefaultRegistry().MustLookup(report.DailyToday)
|
||||
definition := report.DefaultRegistry().MustLookup(report.Daily)
|
||||
resolved := report.Resolved{
|
||||
Definition: definition,
|
||||
GeneratedAt: generatedAt,
|
||||
@@ -327,10 +486,10 @@ func testModuleContext() ModuleContext {
|
||||
},
|
||||
}
|
||||
isDay := true
|
||||
tempF := 74.0
|
||||
apparentF := 76.0
|
||||
humidity := 71.0
|
||||
windMph := 8.0
|
||||
tempF := 74.4
|
||||
apparentF := 75.6
|
||||
humidity := 70.6
|
||||
windMph := 8.4
|
||||
windDirection := 190.0
|
||||
narrativeTempF := 81.0
|
||||
narrativePop := 60.0
|
||||
@@ -484,6 +643,28 @@ func moduleValue[T any](t *testing.T, output *module.Output) T {
|
||||
return value
|
||||
}
|
||||
|
||||
func moduleDataPackageValue[T any](t *testing.T, output *module.Output) T {
|
||||
t.Helper()
|
||||
var value T
|
||||
data, err := json.Marshal(output.DataPackageValue())
|
||||
if err != nil {
|
||||
t.Fatalf("marshal module data package value: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
t.Fatalf("decode module data package value: %v", err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func mustMarshalModuleJSON(t *testing.T, value any) string {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal module value: %v", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func mustParseModuleTime(value string) time.Time {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,20 +1,41 @@
|
||||
package briefing
|
||||
|
||||
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
)
|
||||
|
||||
type CurrentConditionsModule struct {
|
||||
ConditionText string `json:"condition_text,omitempty"`
|
||||
IsDay *bool `json:"is_day,omitempty"`
|
||||
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||
TemperatureF *float64 `json:"temperature_f,omitempty"`
|
||||
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"`
|
||||
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"`
|
||||
DewpointC *float64 `json:"dewpoint_c,omitempty"`
|
||||
DewpointF *float64 `json:"dewpoint_f,omitempty"`
|
||||
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
|
||||
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
|
||||
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
|
||||
WindDirection string `json:"wind_direction,omitempty"`
|
||||
ConditionText string `json:"condition_text,omitempty"`
|
||||
ConditionTextLower string `json:"condition_text_lower,omitempty"`
|
||||
IsDay *bool `json:"is_day,omitempty"`
|
||||
TemperatureC *int `json:"temperature_c,omitempty"`
|
||||
TemperatureF *int `json:"temperature_f,omitempty"`
|
||||
ApparentTemperatureC *int `json:"apparent_temperature_c,omitempty"`
|
||||
ApparentTemperatureF *int `json:"apparent_temperature_f,omitempty"`
|
||||
DewpointC *int `json:"dewpoint_c,omitempty"`
|
||||
DewpointF *int `json:"dewpoint_f,omitempty"`
|
||||
RelativeHumidityPercent *int `json:"relative_humidity_percent,omitempty"`
|
||||
WindSpeedKmh *int `json:"wind_speed_kmh,omitempty"`
|
||||
WindSpeedMph *int `json:"wind_speed_mph,omitempty"`
|
||||
WindDirection string `json:"wind_direction,omitempty"`
|
||||
WindDirectionText string `json:"wind_direction_text,omitempty"`
|
||||
}
|
||||
|
||||
type CurrentConditionsPromptExport struct {
|
||||
ConditionText string `json:"condition_text,omitempty"`
|
||||
IsDay *bool `json:"is_day,omitempty"`
|
||||
TemperatureC *int `json:"temperature_c,omitempty"`
|
||||
TemperatureF *int `json:"temperature_f,omitempty"`
|
||||
ApparentTemperatureC *int `json:"apparent_temperature_c,omitempty"`
|
||||
ApparentTemperatureF *int `json:"apparent_temperature_f,omitempty"`
|
||||
DewpointC *int `json:"dewpoint_c,omitempty"`
|
||||
DewpointF *int `json:"dewpoint_f,omitempty"`
|
||||
RelativeHumidityPercent *int `json:"relative_humidity_percent,omitempty"`
|
||||
WindSpeedKmh *int `json:"wind_speed_kmh,omitempty"`
|
||||
WindSpeedMph *int `json:"wind_speed_mph,omitempty"`
|
||||
WindDirection string `json:"wind_direction,omitempty"`
|
||||
}
|
||||
|
||||
func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
@@ -24,17 +45,19 @@ func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, err
|
||||
}
|
||||
value := CurrentConditionsModule{
|
||||
ConditionText: current.ConditionText,
|
||||
ConditionTextLower: strings.ToLower(current.ConditionText),
|
||||
IsDay: copyBool(current.IsDay),
|
||||
TemperatureC: copyFloat(current.TemperatureC),
|
||||
TemperatureF: copyFloat(current.TemperatureF),
|
||||
ApparentTemperatureC: copyFloat(current.ApparentTemperatureC),
|
||||
ApparentTemperatureF: copyFloat(current.ApparentTemperatureF),
|
||||
DewpointC: copyFloat(current.DewpointC),
|
||||
DewpointF: copyFloat(current.DewpointF),
|
||||
RelativeHumidityPercent: copyFloat(current.RelativeHumidityPercent),
|
||||
WindSpeedKmh: copyFloat(current.WindSpeedKmh),
|
||||
WindSpeedMph: copyFloat(current.WindSpeedMph),
|
||||
TemperatureC: roundedInt(current.TemperatureC),
|
||||
TemperatureF: roundedInt(current.TemperatureF),
|
||||
ApparentTemperatureC: roundedInt(current.ApparentTemperatureC),
|
||||
ApparentTemperatureF: roundedInt(current.ApparentTemperatureF),
|
||||
DewpointC: roundedInt(current.DewpointC),
|
||||
DewpointF: roundedInt(current.DewpointF),
|
||||
RelativeHumidityPercent: roundedInt(current.RelativeHumidityPercent),
|
||||
WindSpeedKmh: roundedInt(current.WindSpeedKmh),
|
||||
WindSpeedMph: roundedInt(current.WindSpeedMph),
|
||||
WindDirection: windDirectionLabel(current.WindDirectionDegrees),
|
||||
WindDirectionText: windDirectionTextLabel(current.WindDirectionDegrees),
|
||||
}
|
||||
if value.isEmpty() {
|
||||
return nil, nil
|
||||
@@ -42,8 +65,30 @@ func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, err
|
||||
return &module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: value}, nil
|
||||
}
|
||||
|
||||
func exportCurrentConditionsPromptValue(value any) (any, error) {
|
||||
rich, ok := value.(CurrentConditionsModule)
|
||||
if !ok {
|
||||
return nil, unexpectedPromptExportValue(value, CurrentConditionsModule{})
|
||||
}
|
||||
return CurrentConditionsPromptExport{
|
||||
ConditionText: rich.ConditionText,
|
||||
IsDay: copyBool(rich.IsDay),
|
||||
TemperatureC: copyInt(rich.TemperatureC),
|
||||
TemperatureF: copyInt(rich.TemperatureF),
|
||||
ApparentTemperatureC: copyInt(rich.ApparentTemperatureC),
|
||||
ApparentTemperatureF: copyInt(rich.ApparentTemperatureF),
|
||||
DewpointC: copyInt(rich.DewpointC),
|
||||
DewpointF: copyInt(rich.DewpointF),
|
||||
RelativeHumidityPercent: copyInt(rich.RelativeHumidityPercent),
|
||||
WindSpeedKmh: copyInt(rich.WindSpeedKmh),
|
||||
WindSpeedMph: copyInt(rich.WindSpeedMph),
|
||||
WindDirection: rich.WindDirection,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (v CurrentConditionsModule) isEmpty() bool {
|
||||
return v.ConditionText == "" &&
|
||||
v.ConditionTextLower == "" &&
|
||||
v.IsDay == nil &&
|
||||
v.TemperatureC == nil &&
|
||||
v.TemperatureF == nil &&
|
||||
@@ -54,5 +99,6 @@ func (v CurrentConditionsModule) isEmpty() bool {
|
||||
v.RelativeHumidityPercent == nil &&
|
||||
v.WindSpeedKmh == 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,32 +2,74 @@ package briefing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type DerivedDaypartSummaryModule struct {
|
||||
Date string `json:"date,omitempty"`
|
||||
Period string `json:"period,omitempty"`
|
||||
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
|
||||
DominantCondition string `json:"dominant_condition,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"`
|
||||
Date string `json:"date,omitempty"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||
TemperaturePhraseF string `json:"temperature_phrase_f,omitempty"`
|
||||
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||
MaxPopTimeLabel string `json:"max_pop_time_label,omitempty"`
|
||||
MentionPrecipitation bool `json:"mention_precipitation,omitempty"`
|
||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
|
||||
DominantCondition string `json:"dominant_condition,omitempty"`
|
||||
DominantConditionLower string `json:"dominant_condition_lower,omitempty"`
|
||||
DominantConditionDisplay string `json:"dominant_condition_display,omitempty"`
|
||||
TemperatureTrend string `json:"temperature_trend,omitempty"`
|
||||
TemperatureStartPhraseF string `json:"temperature_start_phrase_f,omitempty"`
|
||||
TemperatureEndPhraseF string `json:"temperature_end_phrase_f,omitempty"`
|
||||
TemperaturePeakPhraseF string `json:"temperature_peak_phrase_f,omitempty"`
|
||||
TemperatureSteadyPhraseF string `json:"temperature_steady_phrase_f,omitempty"`
|
||||
NotableConditions []string `json:"notable_conditions,omitempty"`
|
||||
Snow bool `json:"snow,omitempty"`
|
||||
Ice bool `json:"ice,omitempty"`
|
||||
Fog bool `json:"fog,omitempty"`
|
||||
Heat bool `json:"heat,omitempty"`
|
||||
Cold bool `json:"cold,omitempty"`
|
||||
Wind bool `json:"wind,omitempty"`
|
||||
RelevantAlertCount int `json:"relevant_alert_count,omitempty"`
|
||||
}
|
||||
|
||||
type DerivedDaypartSummaryPromptExport struct {
|
||||
Date string `json:"date,omitempty"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||
MentionPrecipitation bool `json:"mention_precipitation,omitempty"`
|
||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
|
||||
DominantCondition string `json:"dominant_condition,omitempty"`
|
||||
TemperatureTrend string `json:"temperature_trend,omitempty"`
|
||||
TemperatureStartPhraseF string `json:"temperature_start_phrase_f,omitempty"`
|
||||
TemperatureEndPhraseF string `json:"temperature_end_phrase_f,omitempty"`
|
||||
TemperaturePeakPhraseF string `json:"temperature_peak_phrase_f,omitempty"`
|
||||
TemperatureSteadyPhraseF string `json:"temperature_steady_phrase_f,omitempty"`
|
||||
NotableConditions []string `json:"notable_conditions,omitempty"`
|
||||
Snow bool `json:"snow,omitempty"`
|
||||
Ice bool `json:"ice,omitempty"`
|
||||
Fog bool `json:"fog,omitempty"`
|
||||
Heat bool `json:"heat,omitempty"`
|
||||
Cold bool `json:"cold,omitempty"`
|
||||
Wind bool `json:"wind,omitempty"`
|
||||
RelevantAlertCount int `json:"relevant_alert_count,omitempty"`
|
||||
}
|
||||
|
||||
func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
@@ -43,25 +85,84 @@ func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Outpu
|
||||
return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil
|
||||
}
|
||||
|
||||
func exportDerivedDaypartSummariesPromptValue(value any) (any, error) {
|
||||
rich, ok := value.(map[string]DerivedDaypartSummaryModule)
|
||||
if !ok {
|
||||
return nil, unexpectedPromptExportValue(value, map[string]DerivedDaypartSummaryModule{})
|
||||
}
|
||||
out := make(map[string]DerivedDaypartSummaryPromptExport, len(rich))
|
||||
for key, daypart := range rich {
|
||||
out[key] = derivedDaypartSummaryPromptValue(daypart)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func derivedDaypartSummaryPromptValue(rich DerivedDaypartSummaryModule) DerivedDaypartSummaryPromptExport {
|
||||
maxPopTime := rich.MaxPopTime
|
||||
if rich.MaxPopTimeLabel != "" {
|
||||
maxPopTime = rich.MaxPopTimeLabel
|
||||
}
|
||||
return DerivedDaypartSummaryPromptExport{
|
||||
Date: rich.Date,
|
||||
DisplayName: rich.DisplayName,
|
||||
PeriodBegins: rich.PeriodBegins,
|
||||
PeriodEnds: rich.PeriodEnds,
|
||||
TempRangeF: rich.TempRangeF,
|
||||
ApparentTempRangeF: rich.ApparentTempRangeF,
|
||||
MaxPopPercent: copyInt(rich.MaxPopPercent),
|
||||
MaxPopTime: maxPopTime,
|
||||
MentionPrecipitation: rich.MentionPrecipitation,
|
||||
MaxWindGustMph: copyInt(rich.MaxWindGustMph),
|
||||
MaxWindGustTime: rich.MaxWindGustTime,
|
||||
DominantCondition: rich.DominantCondition,
|
||||
TemperatureTrend: rich.TemperatureTrend,
|
||||
TemperatureStartPhraseF: rich.TemperatureStartPhraseF,
|
||||
TemperatureEndPhraseF: rich.TemperatureEndPhraseF,
|
||||
TemperaturePeakPhraseF: rich.TemperaturePeakPhraseF,
|
||||
TemperatureSteadyPhraseF: rich.TemperatureSteadyPhraseF,
|
||||
NotableConditions: append([]string(nil), rich.NotableConditions...),
|
||||
Snow: rich.Snow,
|
||||
Ice: rich.Ice,
|
||||
Fog: rich.Fog,
|
||||
Heat: rich.Heat,
|
||||
Cold: rich.Cold,
|
||||
Wind: rich.Wind,
|
||||
RelevantAlertCount: rich.RelevantAlertCount,
|
||||
}
|
||||
}
|
||||
|
||||
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
|
||||
temperature := daypartTemperatureDisplay(daypart)
|
||||
value := DerivedDaypartSummaryModule{
|
||||
Date: localDateLabel(daypart.Period.Start, timezone),
|
||||
Period: friendlyPeriodLabel(daypart.Period, timezone),
|
||||
TempRangeF: rangeLabel(daypart.Temperature),
|
||||
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
|
||||
DominantCondition: daypart.DominantCondition,
|
||||
NotableConditions: append([]string(nil), daypart.NotableConditions...),
|
||||
Snow: daypart.Indicators.Snow,
|
||||
Ice: daypart.Indicators.Ice,
|
||||
Fog: daypart.Indicators.Fog,
|
||||
Heat: daypart.Indicators.Heat,
|
||||
Cold: daypart.Indicators.Cold,
|
||||
Wind: daypart.Indicators.Wind,
|
||||
RelevantAlertCount: len(daypart.AlertOverlaps),
|
||||
Date: localDateLabel(daypart.Period.Start, timezone),
|
||||
DisplayName: titleWord(strings.TrimSpace(daypart.Name)),
|
||||
PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone),
|
||||
PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone),
|
||||
TempRangeF: rangeLabel(daypart.Temperature),
|
||||
TemperaturePhraseF: temperaturePhraseF(daypart.Temperature),
|
||||
TemperatureTrend: temperature.Trend,
|
||||
TemperatureStartPhraseF: temperature.StartPhrase,
|
||||
TemperatureEndPhraseF: temperature.EndPhrase,
|
||||
TemperaturePeakPhraseF: temperature.PeakPhrase,
|
||||
TemperatureSteadyPhraseF: temperature.SteadyPhrase,
|
||||
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
|
||||
DominantCondition: daypart.DominantCondition,
|
||||
DominantConditionLower: strings.ToLower(daypart.DominantCondition),
|
||||
DominantConditionDisplay: sentenceCase(daypart.DominantCondition),
|
||||
NotableConditions: append([]string(nil), daypart.NotableConditions...),
|
||||
Snow: daypart.Indicators.Snow,
|
||||
Ice: daypart.Indicators.Ice,
|
||||
Fog: daypart.Indicators.Fog,
|
||||
Heat: daypart.Indicators.Heat,
|
||||
Cold: daypart.Indicators.Cold,
|
||||
Wind: daypart.Indicators.Wind,
|
||||
RelevantAlertCount: len(daypart.AlertOverlaps),
|
||||
}
|
||||
if daypart.MaxPrecipitationProbability != nil {
|
||||
value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value)
|
||||
value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone)
|
||||
value.MaxPopTimeLabel = hourMinuteLabel(daypart.MaxPrecipitationProbability.Time, timezone)
|
||||
value.MentionPrecipitation = mentionHourlyForecastPrecipitation(&daypart.MaxPrecipitationProbability.Value, DefaultHourlyForecastPrecipMentionProbabilityThreshold)
|
||||
}
|
||||
if daypart.PeakWindGust != nil {
|
||||
value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value)
|
||||
@@ -70,6 +171,208 @@ func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string
|
||||
return value
|
||||
}
|
||||
|
||||
type daypartTemperaturePresentation struct {
|
||||
Trend string
|
||||
StartPhrase string
|
||||
EndPhrase string
|
||||
PeakPhrase string
|
||||
SteadyPhrase string
|
||||
}
|
||||
|
||||
type temperaturePoint struct {
|
||||
value int
|
||||
bandIndex int
|
||||
phrase string
|
||||
}
|
||||
|
||||
const (
|
||||
temperatureTrendRising = "rising"
|
||||
temperatureTrendFalling = "falling"
|
||||
temperatureTrendPeaking = "peaking"
|
||||
temperatureTrendSteady = "steady"
|
||||
)
|
||||
|
||||
func daypartTemperatureDisplay(daypart forecast.DaypartSummary) daypartTemperaturePresentation {
|
||||
points := daypartTemperaturePoints(daypart.HourlyPeriods)
|
||||
if len(points) == 0 {
|
||||
return daypartSteadyTemperatureDisplay(temperaturePhraseF(daypart.Temperature))
|
||||
}
|
||||
if len(points) == 1 {
|
||||
return daypartSteadyTemperatureDisplay(points[0].phrase)
|
||||
}
|
||||
|
||||
first := points[0]
|
||||
last := points[len(points)-1]
|
||||
peak, peakIndex := peakTemperaturePoint(points)
|
||||
if peakIndex > 0 && peakIndex < len(points)-1 && peak.bandIndex > first.bandIndex && peak.bandIndex > last.bandIndex {
|
||||
return daypartTemperaturePresentation{
|
||||
Trend: temperatureTrendPeaking,
|
||||
PeakPhrase: peak.phrase,
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case first.bandIndex < last.bandIndex:
|
||||
return daypartTemperaturePresentation{
|
||||
Trend: temperatureTrendRising,
|
||||
StartPhrase: first.phrase,
|
||||
EndPhrase: last.phrase,
|
||||
}
|
||||
case first.bandIndex > last.bandIndex:
|
||||
return daypartTemperaturePresentation{
|
||||
Trend: temperatureTrendFalling,
|
||||
StartPhrase: first.phrase,
|
||||
EndPhrase: last.phrase,
|
||||
}
|
||||
default:
|
||||
return daypartSteadyTemperatureDisplay(temperaturePhraseF(daypart.Temperature))
|
||||
}
|
||||
}
|
||||
|
||||
func daypartSteadyTemperatureDisplay(phrase string) daypartTemperaturePresentation {
|
||||
if phrase == "" {
|
||||
return daypartTemperaturePresentation{}
|
||||
}
|
||||
return daypartTemperaturePresentation{
|
||||
Trend: temperatureTrendSteady,
|
||||
SteadyPhrase: phrase,
|
||||
}
|
||||
}
|
||||
|
||||
func daypartTemperaturePoints(periods []weatherdata.ForecastPeriod) []temperaturePoint {
|
||||
sorted := append([]weatherdata.ForecastPeriod(nil), periods...)
|
||||
sort.SliceStable(sorted, func(i int, j int) bool {
|
||||
return sorted[i].StartTime.Before(sorted[j].StartTime)
|
||||
})
|
||||
points := make([]temperaturePoint, 0, len(sorted))
|
||||
for _, period := range sorted {
|
||||
temperature := forecastPeriodTemperatureF(period)
|
||||
if temperature == nil {
|
||||
continue
|
||||
}
|
||||
rounded := roundedInt(temperature)
|
||||
if rounded == nil {
|
||||
continue
|
||||
}
|
||||
points = append(points, temperaturePoint{
|
||||
value: *rounded,
|
||||
bandIndex: temperatureBandIndex(*rounded),
|
||||
phrase: temperatureBandPhrase(*rounded),
|
||||
})
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
func peakTemperaturePoint(points []temperaturePoint) (temperaturePoint, int) {
|
||||
peak := points[0]
|
||||
peakIndex := 0
|
||||
for index, point := range points[1:] {
|
||||
if point.value > peak.value {
|
||||
peak = point
|
||||
peakIndex = index + 1
|
||||
}
|
||||
}
|
||||
return peak, peakIndex
|
||||
}
|
||||
|
||||
func forecastPeriodTemperatureF(period weatherdata.ForecastPeriod) *float64 {
|
||||
switch {
|
||||
case period.TemperatureF != nil:
|
||||
return period.TemperatureF
|
||||
case period.TemperatureFMax != nil:
|
||||
return period.TemperatureFMax
|
||||
case period.TemperatureFMin != nil:
|
||||
return period.TemperatureFMin
|
||||
case period.TemperatureC != nil:
|
||||
value := celsiusToFahrenheit(*period.TemperatureC)
|
||||
return &value
|
||||
case period.TemperatureCMax != nil:
|
||||
value := celsiusToFahrenheit(*period.TemperatureCMax)
|
||||
return &value
|
||||
case period.TemperatureCMin != nil:
|
||||
value := celsiusToFahrenheit(*period.TemperatureCMin)
|
||||
return &value
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func celsiusToFahrenheit(value float64) float64 {
|
||||
return value*9/5 + 32
|
||||
}
|
||||
|
||||
func temperatureBandIndex(value int) int {
|
||||
decade := (value / 10) * 10
|
||||
remainder := value - decade
|
||||
if remainder < 0 {
|
||||
remainder = -remainder
|
||||
}
|
||||
band := 1
|
||||
switch {
|
||||
case remainder <= 3:
|
||||
band = 0
|
||||
case remainder >= 7:
|
||||
band = 2
|
||||
}
|
||||
return decade*3 + band
|
||||
}
|
||||
|
||||
func temperaturePhraseF(value forecast.Range) string {
|
||||
if value.Min == nil && value.Max == nil {
|
||||
return ""
|
||||
}
|
||||
if value.Min != nil && value.Max != nil {
|
||||
low := roundedInt(value.Min)
|
||||
high := roundedInt(value.Max)
|
||||
if low == nil || high == nil {
|
||||
return ""
|
||||
}
|
||||
lowPhrase := temperatureBandPhrase(*low)
|
||||
highPhrase := temperatureBandPhrase(*high)
|
||||
if lowPhrase == highPhrase {
|
||||
return lowPhrase
|
||||
}
|
||||
return lowPhrase + " to " + highPhrase
|
||||
}
|
||||
if value.Min != nil {
|
||||
low := roundedInt(value.Min)
|
||||
if low == nil {
|
||||
return ""
|
||||
}
|
||||
return temperatureBandPhrase(*low)
|
||||
}
|
||||
high := roundedInt(value.Max)
|
||||
if high == nil {
|
||||
return ""
|
||||
}
|
||||
return temperatureBandPhrase(*high)
|
||||
}
|
||||
|
||||
func temperatureBandPhrase(value int) string {
|
||||
decade := (value / 10) * 10
|
||||
remainder := value - decade
|
||||
if remainder < 0 {
|
||||
remainder = -remainder
|
||||
}
|
||||
qualifier := "mid"
|
||||
switch {
|
||||
case remainder <= 3:
|
||||
qualifier = "low"
|
||||
case remainder >= 7:
|
||||
qualifier = "upper"
|
||||
}
|
||||
return fmt.Sprintf("%s %ds", qualifier, decade)
|
||||
}
|
||||
|
||||
func sentenceCase(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(trimmed)
|
||||
runes[0] = unicode.ToUpper(runes[0])
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
func multipleSummaryDates(summaries []forecast.DailySummary) bool {
|
||||
seen := map[string]struct{}{}
|
||||
for _, summary := range summaries {
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
|
||||
func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.DailyToday)
|
||||
ctx := derivedModuleContext(report.Daily)
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
|
||||
if err != nil {
|
||||
@@ -67,7 +67,7 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
|
||||
|
||||
func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.DailyToday)
|
||||
ctx := derivedModuleContext(report.Daily)
|
||||
ctx.Derived.DailySummaries[0].NarrativePeriods = nil
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
|
||||
@@ -89,7 +89,7 @@ func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
|
||||
|
||||
func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.DailyToday)
|
||||
ctx := derivedModuleContext(report.Daily)
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
|
||||
if err != nil {
|
||||
@@ -102,19 +102,22 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
||||
if len(rainy.PrecipitationWindows) != 2 {
|
||||
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])
|
||||
}
|
||||
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])
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal precip timing: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "precipitation_windows") || !strings.Contains(string(data), "probability_threshold") {
|
||||
if !strings.Contains(string(data), "precipitation_windows") || !strings.Contains(string(data), "probability_threshold") || !strings.Contains(string(data), "period_begins_hour_label") || !strings.Contains(string(data), "max_pop_hour_label") {
|
||||
t.Fatalf("precip timing json = %s, want threshold and windows", string(data))
|
||||
}
|
||||
if strings.Contains(string(data), `"start"`) || strings.Contains(string(data), `"end"`) {
|
||||
t.Fatalf("precip timing json = %s, want period_begins/period_ends instead of start/end", string(data))
|
||||
}
|
||||
if strings.Contains(string(data), "first_precip_hour") || strings.Contains(string(data), "last_precip_hour") {
|
||||
t.Fatalf("precip timing json = %s, want no ambiguous first/last fields", string(data))
|
||||
}
|
||||
@@ -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) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.DailyToday)
|
||||
ctx := derivedModuleContext(report.Daily)
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
|
||||
if err != nil {
|
||||
@@ -150,14 +169,20 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
||||
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 {
|
||||
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" {
|
||||
t.Fatalf("morning period = %q/%q, want friendly local date and period labels", morning.Date, morning.Period)
|
||||
if morning.DisplayName != "Morning" || morning.DominantConditionLower != "showers" || morning.DominantConditionDisplay != "Showers" || morning.TemperaturePhraseF != "upper 50s" || morning.TemperatureTrend != "steady" || morning.TemperatureSteadyPhraseF != "upper 50s" || !morning.MentionPrecipitation || morning.MaxPopTimeLabel != "6:00 AM" {
|
||||
t.Fatalf("morning presentation fields = %#v, want display facts for template composition", morning)
|
||||
}
|
||||
if morning.Date != "2026-05-29" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" {
|
||||
t.Fatalf("morning period = %q/%q/%q, want friendly local date and period labels", morning.Date, morning.PeriodBegins, morning.PeriodEnds)
|
||||
}
|
||||
overnight := value["overnight"]
|
||||
if overnight.MentionPrecipitation {
|
||||
t.Fatalf("overnight MentionPrecipitation = true, want false below threshold")
|
||||
}
|
||||
afternoon := value["afternoon"]
|
||||
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 {
|
||||
t.Fatalf("afternoon = %#v, want heat and wind hazard values", afternoon)
|
||||
}
|
||||
overnight := value["overnight"]
|
||||
if !overnight.Cold {
|
||||
t.Fatalf("overnight = %#v, want cold hazard", overnight)
|
||||
}
|
||||
@@ -166,19 +191,236 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
||||
t.Fatalf("marshal daypart summaries: %v", err)
|
||||
}
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedDaypartSummariesPromptExportOmitsTemplateHelpers(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.Daily)
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
richText := mustMarshalModuleJSON(t, output.Value)
|
||||
for _, field := range []string{"temperature_phrase_f", "dominant_condition_lower", "dominant_condition_display", "max_pop_time_label"} {
|
||||
if !strings.Contains(richText, field) {
|
||||
t.Fatalf("rich daypart json = %s, want helper field %s", richText, field)
|
||||
}
|
||||
}
|
||||
|
||||
prompt := moduleDataPackageValue[map[string]DerivedDaypartSummaryPromptExport](t, output)
|
||||
morning, ok := prompt["morning"]
|
||||
if !ok {
|
||||
t.Fatalf("daypart prompt keys = %#v, want morning", prompt)
|
||||
}
|
||||
if morning.Date != "2026-05-29" || morning.DisplayName != "Morning" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" {
|
||||
t.Fatalf("morning prompt period = %#v, want date/display/period labels", morning)
|
||||
}
|
||||
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 || morning.MaxPopTime != "6:00 AM" || !morning.MentionPrecipitation {
|
||||
t.Fatalf("morning prompt precip/temp = %#v, want factual prompt fields with friendly max pop time", morning)
|
||||
}
|
||||
if morning.DominantCondition != "Showers" || morning.TemperatureTrend != "steady" || morning.TemperatureSteadyPhraseF != "upper 50s" {
|
||||
t.Fatalf("morning prompt condition/trend = %#v, want condition and trend fields", morning)
|
||||
}
|
||||
if len(morning.NotableConditions) == 0 || morning.NotableConditions[0] != "Showers" {
|
||||
t.Fatalf("morning prompt notable conditions = %#v, want copied conditions", morning.NotableConditions)
|
||||
}
|
||||
afternoon := prompt["afternoon"]
|
||||
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 || afternoon.RelevantAlertCount != 1 {
|
||||
t.Fatalf("afternoon prompt = %#v, want hazard, wind, and alert fields", afternoon)
|
||||
}
|
||||
|
||||
promptText := mustMarshalModuleJSON(t, output.DataPackageValue())
|
||||
for _, field := range []string{"date", "display_name", "period_begins", "period_ends", "temp_range_f", "max_pop_percent", "max_pop_time", "mention_precipitation", "max_wind_gust_mph", "dominant_condition", "temperature_trend", "temperature_steady_phrase_f", "notable_conditions", "relevant_alert_count"} {
|
||||
if !strings.Contains(promptText, field) {
|
||||
t.Fatalf("daypart prompt json = %s, want field %s", promptText, field)
|
||||
}
|
||||
}
|
||||
for _, field := range []string{"temperature_phrase_f", "dominant_condition_lower", "dominant_condition_display", "max_pop_time_label"} {
|
||||
if strings.Contains(promptText, field) {
|
||||
t.Fatalf("daypart prompt json = %s, want omitted helper field %s", promptText, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedDaypartPromptExportTemperatureTrends(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
temps []float64
|
||||
wantTrend string
|
||||
wantStart string
|
||||
wantEnd string
|
||||
wantPeak string
|
||||
wantSteady string
|
||||
}{
|
||||
{
|
||||
name: "rising",
|
||||
temps: []float64{58, 68},
|
||||
wantTrend: "rising",
|
||||
wantStart: "upper 50s",
|
||||
wantEnd: "upper 60s",
|
||||
},
|
||||
{
|
||||
name: "falling",
|
||||
temps: []float64{65, 58},
|
||||
wantTrend: "falling",
|
||||
wantStart: "mid 60s",
|
||||
wantEnd: "upper 50s",
|
||||
},
|
||||
{
|
||||
name: "peaking",
|
||||
temps: []float64{62, 78, 65},
|
||||
wantTrend: "peaking",
|
||||
wantPeak: "upper 70s",
|
||||
},
|
||||
{
|
||||
name: "steady",
|
||||
temps: []float64{77, 78},
|
||||
wantTrend: "steady",
|
||||
wantSteady: "upper 70s",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
rich := derivedDaypartSummaryValue(derivedDaypartWithTemperatures(test.name, "2026-05-29T12:00:00-05:00", "sunny", test.temps...), "America/Chicago")
|
||||
prompt := derivedDaypartSummaryPromptValue(rich)
|
||||
if prompt.TemperatureTrend != test.wantTrend ||
|
||||
prompt.TemperatureStartPhraseF != test.wantStart ||
|
||||
prompt.TemperatureEndPhraseF != test.wantEnd ||
|
||||
prompt.TemperaturePeakPhraseF != test.wantPeak ||
|
||||
prompt.TemperatureSteadyPhraseF != test.wantSteady {
|
||||
t.Fatalf("prompt temperature presentation = %#v", prompt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedDaypartPromptExportMaxPopTimeFallback(t *testing.T) {
|
||||
withLabel := derivedDaypartSummaryPromptValue(DerivedDaypartSummaryModule{
|
||||
MaxPopTime: "6 AM",
|
||||
MaxPopTimeLabel: "6:00 AM",
|
||||
})
|
||||
if withLabel.MaxPopTime != "6:00 AM" {
|
||||
t.Fatalf("MaxPopTime with label = %q, want friendly label", withLabel.MaxPopTime)
|
||||
}
|
||||
|
||||
withoutLabel := derivedDaypartSummaryPromptValue(DerivedDaypartSummaryModule{
|
||||
MaxPopTime: "6 AM",
|
||||
})
|
||||
if withoutLabel.MaxPopTime != "6 AM" {
|
||||
t.Fatalf("MaxPopTime without label = %q, want fallback time", withoutLabel.MaxPopTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedDaypartTemperaturePresentationFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
temps []float64
|
||||
wantTrend string
|
||||
wantStart string
|
||||
wantEnd string
|
||||
wantPeak string
|
||||
wantSteady string
|
||||
}{
|
||||
{
|
||||
name: "rising",
|
||||
temps: []float64{58, 68},
|
||||
wantTrend: "rising",
|
||||
wantStart: "upper 50s",
|
||||
wantEnd: "upper 60s",
|
||||
},
|
||||
{
|
||||
name: "falling",
|
||||
temps: []float64{65, 58},
|
||||
wantTrend: "falling",
|
||||
wantStart: "mid 60s",
|
||||
wantEnd: "upper 50s",
|
||||
},
|
||||
{
|
||||
name: "peaking",
|
||||
temps: []float64{62, 78, 65},
|
||||
wantTrend: "peaking",
|
||||
wantPeak: "upper 70s",
|
||||
},
|
||||
{
|
||||
name: "steady same band",
|
||||
temps: []float64{77, 78},
|
||||
wantTrend: "steady",
|
||||
wantSteady: "upper 70s",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
summary := derivedDaypartWithTemperatures("afternoon", "2026-05-29T12:00:00-05:00", "sunny", test.temps...)
|
||||
value := derivedDaypartSummaryValue(summary, "America/Chicago")
|
||||
if value.DominantConditionDisplay != "Sunny" {
|
||||
t.Fatalf("DominantConditionDisplay = %q, want Sunny", value.DominantConditionDisplay)
|
||||
}
|
||||
if value.TemperatureTrend != test.wantTrend ||
|
||||
value.TemperatureStartPhraseF != test.wantStart ||
|
||||
value.TemperatureEndPhraseF != test.wantEnd ||
|
||||
value.TemperaturePeakPhraseF != test.wantPeak ||
|
||||
value.TemperatureSteadyPhraseF != test.wantSteady {
|
||||
t.Fatalf("temperature presentation = %#v", value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemperaturePhraseF(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value forecast.Range
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "single low band",
|
||||
value: forecast.Range{Min: floatPtr(71), Max: floatPtr(73)},
|
||||
want: "low 70s",
|
||||
},
|
||||
{
|
||||
name: "single upper value",
|
||||
value: forecast.Range{Min: floatPtr(68), Max: floatPtr(68)},
|
||||
want: "upper 60s",
|
||||
},
|
||||
{
|
||||
name: "range across bands",
|
||||
value: forecast.Range{Min: floatPtr(68), Max: floatPtr(75)},
|
||||
want: "upper 60s to mid 70s",
|
||||
},
|
||||
{
|
||||
name: "max only",
|
||||
value: forecast.Range{Max: floatPtr(84)},
|
||||
want: "mid 80s",
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
value: forecast.Range{},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := temperaturePhraseF(test.value); got != test.want {
|
||||
t.Fatalf("temperaturePhraseF() = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.DailyTomorrow)
|
||||
ctx := derivedModuleContext(report.Tomorrow)
|
||||
|
||||
outdoorOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows})
|
||||
if err != nil {
|
||||
@@ -191,6 +433,12 @@ func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing
|
||||
if outdoor.Best.Daypart != "overnight" || outdoor.Worst.Daypart != "afternoon" {
|
||||
t.Fatalf("outdoor windows = %#v, want quiet overnight and stormy afternoon", outdoor)
|
||||
}
|
||||
if outdoor.Best.PeriodBegins != "2026-05-29 at 12:00 AM" || outdoor.Best.PeriodEnds != "2026-05-29 at 6:00 AM" {
|
||||
t.Fatalf("best outdoor period = %#v, want overnight period labels", outdoor.Best)
|
||||
}
|
||||
if outdoor.Worst.PeriodBegins != "2026-05-29 at 12:00 PM" || outdoor.Worst.PeriodEnds != "2026-05-29 at 6:00 PM" {
|
||||
t.Fatalf("worst outdoor period = %#v, want afternoon period labels", outdoor.Worst)
|
||||
}
|
||||
|
||||
planningOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TomorrowPlanning})
|
||||
if err != nil {
|
||||
@@ -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) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.DailyToday)
|
||||
ctx := derivedModuleContext(report.Daily)
|
||||
ctx.Derived.DailySummaries = 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 {
|
||||
hour := derivedHour(start, text, precip, temperature, apparent, gust)
|
||||
return forecast.SummarizeDaypart(name, timeutil.Period{
|
||||
@@ -310,6 +723,24 @@ func derivedDaypart(name string, start string, end string, text string, temperat
|
||||
}, []weatherdata.ForecastPeriod{hour})
|
||||
}
|
||||
|
||||
func derivedDaypartWithTemperatures(name string, start string, text string, temperatures ...float64) forecast.DaypartSummary {
|
||||
startTime := mustParseModuleTime(start)
|
||||
periods := make([]weatherdata.ForecastPeriod, 0, len(temperatures))
|
||||
for index, temperature := range temperatures {
|
||||
periodStart := startTime.Add(time.Duration(index) * time.Hour)
|
||||
periods = append(periods, weatherdata.ForecastPeriod{
|
||||
StartTime: periodStart,
|
||||
EndTime: periodStart.Add(time.Hour),
|
||||
TextDescription: text,
|
||||
TemperatureF: floatPtr(temperature),
|
||||
})
|
||||
}
|
||||
return forecast.SummarizeDaypart(name, timeutil.Period{
|
||||
Start: startTime,
|
||||
End: startTime.Add(time.Duration(len(temperatures)) * time.Hour),
|
||||
}, periods)
|
||||
}
|
||||
|
||||
func derivedHour(start string, text string, precip float64, temperature float64, apparent *float64, gust float64) weatherdata.ForecastPeriod {
|
||||
startTime := mustParseModuleTime(start)
|
||||
endTime := startTime.Add(time.Hour)
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
const DefaultHourlyForecastPrecipMentionProbabilityThreshold = 20
|
||||
|
||||
type HourlyForecastModule struct {
|
||||
Product string `json:"product,omitempty"`
|
||||
IssuedAt time.Time `json:"issued_at,omitempty"`
|
||||
@@ -16,9 +20,57 @@ type HourlyForecastModule struct {
|
||||
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 {
|
||||
StartTime string `json:"start_time,omitempty"`
|
||||
EndTime string `json:"end_time,omitempty"`
|
||||
HourLabel string `json:"hour_label,omitempty"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
IsDay *bool `json:"is_day,omitempty"`
|
||||
ConditionCode *int `json:"condition_code,omitempty"`
|
||||
TextDescription string `json:"text_description,omitempty"`
|
||||
TextDescriptionLower string `json:"text_description_lower,omitempty"`
|
||||
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||
TemperatureF *float64 `json:"temperature_f,omitempty"`
|
||||
TemperatureCMin *float64 `json:"temperature_c_min,omitempty"`
|
||||
TemperatureFMin *float64 `json:"temperature_f_min,omitempty"`
|
||||
TemperatureCMax *float64 `json:"temperature_c_max,omitempty"`
|
||||
TemperatureFMax *float64 `json:"temperature_f_max,omitempty"`
|
||||
DewpointC *float64 `json:"dewpoint_c,omitempty"`
|
||||
DewpointF *float64 `json:"dewpoint_f,omitempty"`
|
||||
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
|
||||
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
|
||||
WindGustKmh *float64 `json:"wind_gust_kmh,omitempty"`
|
||||
WindGustMph *float64 `json:"wind_gust_mph,omitempty"`
|
||||
WindDirection string `json:"wind_direction,omitempty"`
|
||||
BarometricPressurePa *float64 `json:"barometric_pressure_pa,omitempty"`
|
||||
BarometricPressureInHg *float64 `json:"barometric_pressure_in_hg,omitempty"`
|
||||
VisibilityMeters *float64 `json:"visibility_meters,omitempty"`
|
||||
VisibilityMiles *float64 `json:"visibility_miles,omitempty"`
|
||||
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"`
|
||||
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"`
|
||||
CloudCoverPercent *float64 `json:"cloud_cover_percent,omitempty"`
|
||||
ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"`
|
||||
MentionPrecipitation bool `json:"mention_precipitation,omitempty"`
|
||||
PrecipitationAmountMm *float64 `json:"precipitation_amount_mm,omitempty"`
|
||||
PrecipitationAmountIn *float64 `json:"precipitation_amount_in,omitempty"`
|
||||
SnowfallDepthMM *float64 `json:"snowfall_depth_mm,omitempty"`
|
||||
SnowfallDepthIn *float64 `json:"snowfall_depth_in,omitempty"`
|
||||
UVIndex *float64 `json:"uv_index,omitempty"`
|
||||
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
|
||||
}
|
||||
|
||||
type HourlyForecastPromptPeriod struct {
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
IsDay *bool `json:"is_day,omitempty"`
|
||||
ConditionCode *int `json:"condition_code,omitempty"`
|
||||
@@ -71,12 +123,30 @@ func buildHourlyForecastModule(ctx ModuleContext, _ any) (*module.Output, error)
|
||||
return &module.Output{ID: module.HourlyForecast, StanzaName: "hourly_forecast", Value: value}, nil
|
||||
}
|
||||
|
||||
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod {
|
||||
out := make([]HourlyForecastPeriod, 0, len(periods))
|
||||
func exportHourlyForecastPromptValue(value any) (any, error) {
|
||||
rich, ok := value.(HourlyForecastModule)
|
||||
if !ok {
|
||||
return nil, unexpectedPromptExportValue(value, HourlyForecastModule{})
|
||||
}
|
||||
return HourlyForecastPromptExport{
|
||||
Product: rich.Product,
|
||||
IssuedAt: rich.IssuedAt,
|
||||
UpdatedAt: copyTime(rich.UpdatedAt),
|
||||
SourceLocation: rich.SourceLocation,
|
||||
SourceLocationID: rich.SourceLocationID,
|
||||
Periods: hourlyForecastPromptPeriods(rich.Periods),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func hourlyForecastPromptPeriods(periods []HourlyForecastPeriod) []HourlyForecastPromptPeriod {
|
||||
if len(periods) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]HourlyForecastPromptPeriod, 0, len(periods))
|
||||
for _, period := range periods {
|
||||
out = append(out, HourlyForecastPeriod{
|
||||
StartTime: friendlyDateTimeLabel(period.StartTime, timezone),
|
||||
EndTime: friendlyDateTimeLabel(period.EndTime, timezone),
|
||||
out = append(out, HourlyForecastPromptPeriod{
|
||||
PeriodBegins: period.PeriodBegins,
|
||||
PeriodEnds: period.PeriodEnds,
|
||||
Name: period.Name,
|
||||
IsDay: copyBool(period.IsDay),
|
||||
ConditionCode: copyInt(period.ConditionCode),
|
||||
@@ -93,6 +163,55 @@ func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string
|
||||
WindSpeedMph: copyFloat(period.WindSpeedMph),
|
||||
WindGustKmh: copyFloat(period.WindGustKmh),
|
||||
WindGustMph: copyFloat(period.WindGustMph),
|
||||
WindDirection: period.WindDirection,
|
||||
BarometricPressurePa: copyFloat(period.BarometricPressurePa),
|
||||
BarometricPressureInHg: copyFloat(period.BarometricPressureInHg),
|
||||
VisibilityMeters: copyFloat(period.VisibilityMeters),
|
||||
VisibilityMiles: copyFloat(period.VisibilityMiles),
|
||||
ApparentTemperatureC: copyFloat(period.ApparentTemperatureC),
|
||||
ApparentTemperatureF: copyFloat(period.ApparentTemperatureF),
|
||||
CloudCoverPercent: copyFloat(period.CloudCoverPercent),
|
||||
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
|
||||
PrecipitationAmountMm: copyFloat(period.PrecipitationAmountMm),
|
||||
PrecipitationAmountIn: copyFloat(period.PrecipitationAmountIn),
|
||||
SnowfallDepthMM: copyFloat(period.SnowfallDepthMM),
|
||||
SnowfallDepthIn: copyFloat(period.SnowfallDepthIn),
|
||||
UVIndex: copyFloat(period.UVIndex),
|
||||
RelativeHumidityPercent: copyFloat(period.RelativeHumidityPercent),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod {
|
||||
return hourlyForecastPeriodsWithPrecipMentionThreshold(periods, timezone, DefaultHourlyForecastPrecipMentionProbabilityThreshold)
|
||||
}
|
||||
|
||||
func hourlyForecastPeriodsWithPrecipMentionThreshold(periods []weatherdata.ForecastPeriod, timezone string, threshold float64) []HourlyForecastPeriod {
|
||||
out := make([]HourlyForecastPeriod, 0, len(periods))
|
||||
for _, period := range periods {
|
||||
validPeriod := timeutil.Period{Start: period.StartTime, End: period.EndTime}
|
||||
out = append(out, HourlyForecastPeriod{
|
||||
HourLabel: hourMinuteLabel(period.StartTime, timezone),
|
||||
PeriodBegins: friendlyPeriodBeginsLabel(validPeriod, timezone),
|
||||
PeriodEnds: friendlyPeriodEndsLabel(validPeriod, timezone),
|
||||
Name: period.Name,
|
||||
IsDay: copyBool(period.IsDay),
|
||||
ConditionCode: copyInt(period.ConditionCode),
|
||||
TextDescription: period.TextDescription,
|
||||
TextDescriptionLower: strings.ToLower(period.TextDescription),
|
||||
TemperatureC: copyFloat(period.TemperatureC),
|
||||
TemperatureF: copyFloat(period.TemperatureF),
|
||||
TemperatureCMin: copyFloat(period.TemperatureCMin),
|
||||
TemperatureFMin: copyFloat(period.TemperatureFMin),
|
||||
TemperatureCMax: copyFloat(period.TemperatureCMax),
|
||||
TemperatureFMax: copyFloat(period.TemperatureFMax),
|
||||
DewpointC: copyFloat(period.DewpointC),
|
||||
DewpointF: copyFloat(period.DewpointF),
|
||||
WindSpeedKmh: copyFloat(period.WindSpeedKmh),
|
||||
WindSpeedMph: copyFloat(period.WindSpeedMph),
|
||||
WindGustKmh: copyFloat(period.WindGustKmh),
|
||||
WindGustMph: copyFloat(period.WindGustMph),
|
||||
WindDirection: windDirectionLabel(period.WindDirectionDegrees),
|
||||
BarometricPressurePa: copyFloat(period.BarometricPressurePa),
|
||||
BarometricPressureInHg: copyFloat(period.BarometricPressureInHg),
|
||||
@@ -102,6 +221,7 @@ func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string
|
||||
ApparentTemperatureF: copyFloat(period.ApparentTemperatureF),
|
||||
CloudCoverPercent: copyFloat(period.CloudCoverPercent),
|
||||
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
|
||||
MentionPrecipitation: mentionHourlyForecastPrecipitation(period.ProbabilityOfPrecipitationPercent, threshold),
|
||||
PrecipitationAmountMm: copyFloat(period.PrecipitationAmountMm),
|
||||
PrecipitationAmountIn: copyFloat(period.PrecipitationAmountIn),
|
||||
SnowfallDepthMM: copyFloat(period.SnowfallDepthMM),
|
||||
@@ -113,6 +233,10 @@ func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string
|
||||
return out
|
||||
}
|
||||
|
||||
func mentionHourlyForecastPrecipitation(probability *float64, threshold float64) bool {
|
||||
return probability != nil && *probability >= threshold
|
||||
}
|
||||
|
||||
func (v HourlyForecastModule) isEmpty() bool {
|
||||
return v.Product == "" &&
|
||||
v.IssuedAt.IsZero() &&
|
||||
|
||||
@@ -60,6 +60,36 @@ func windDirectionLabel(degrees *float64) string {
|
||||
return labels[sector]
|
||||
}
|
||||
|
||||
func windDirectionTextLabel(degrees *float64) string {
|
||||
if degrees == nil {
|
||||
return ""
|
||||
}
|
||||
labels := []string{
|
||||
"north",
|
||||
"north-northeast",
|
||||
"northeast",
|
||||
"east-northeast",
|
||||
"east",
|
||||
"east-southeast",
|
||||
"southeast",
|
||||
"south-southeast",
|
||||
"south",
|
||||
"south-southwest",
|
||||
"southwest",
|
||||
"west-southwest",
|
||||
"west",
|
||||
"west-northwest",
|
||||
"northwest",
|
||||
"north-northwest",
|
||||
}
|
||||
normalized := math.Mod(*degrees, 360)
|
||||
if normalized < 0 {
|
||||
normalized += 360
|
||||
}
|
||||
sector := int(math.Floor((normalized+11.25)/22.5)) % len(labels)
|
||||
return labels[sector]
|
||||
}
|
||||
|
||||
func timedClockLabel(value *forecast.TimedValue, timezone string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
@@ -67,18 +97,18 @@ func timedClockLabel(value *forecast.TimedValue, timezone string) string {
|
||||
return clockLabel(value.Time, timezone)
|
||||
}
|
||||
|
||||
func periodClockLabel(period timeutil.Period, timezone string) string {
|
||||
func friendlyPeriodBeginsLabel(period timeutil.Period, timezone string) string {
|
||||
if !period.IsValid() {
|
||||
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() {
|
||||
return ""
|
||||
}
|
||||
return friendlyDateTimeLabel(period.Start, timezone) + " to " + friendlyDateTimeLabel(period.End, timezone)
|
||||
return friendlyDateTimeLabel(period.End, timezone)
|
||||
}
|
||||
|
||||
func friendlyDateTimeLabel(value time.Time, timezone string) string {
|
||||
@@ -126,3 +156,11 @@ func clockLabel(value time.Time, timezone string) string {
|
||||
}
|
||||
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 ModulePromptExporter func(value any) (any, error)
|
||||
|
||||
type ModuleDefinition struct {
|
||||
ID module.ID
|
||||
StanzaName string
|
||||
@@ -31,12 +33,17 @@ type ModuleDefinition struct {
|
||||
MissingData module.MissingDataBehavior
|
||||
AllowDuplicate bool
|
||||
Builder ModuleBuilder
|
||||
PromptExporter ModulePromptExporter
|
||||
}
|
||||
|
||||
type ModuleRegistry struct {
|
||||
definitions map[module.ID]ModuleDefinition
|
||||
}
|
||||
|
||||
func unexpectedPromptExportValue(got any, want any) error {
|
||||
return fmt.Errorf("value has type %T, want %T", got, want)
|
||||
}
|
||||
|
||||
func DefaultModuleRegistry() (ModuleRegistry, error) {
|
||||
return NewModuleRegistry(defaultModuleDefinitions())
|
||||
}
|
||||
@@ -130,6 +137,15 @@ func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (
|
||||
if output.StanzaName != definition.StanzaName {
|
||||
return nil, fmt.Errorf("module %q produced stanza %q, want %q", definition.ID, output.StanzaName, definition.StanzaName)
|
||||
}
|
||||
if definition.PromptExporter == nil {
|
||||
output.PromptValue = output.Value
|
||||
return output, nil
|
||||
}
|
||||
promptValue, err := definition.PromptExporter(output.Value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("module %q stanza %q prompt export: %w", definition.ID, definition.StanzaName, err)
|
||||
}
|
||||
output.PromptValue = promptValue
|
||||
return output, nil
|
||||
}
|
||||
|
||||
@@ -162,6 +178,8 @@ func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContex
|
||||
return ctx.Collected.Discussion != nil
|
||||
case module.CollectedWeatherStory:
|
||||
return ctx.Collected.WeatherStory != nil
|
||||
case module.CollectedSPCConvectiveOutlooks:
|
||||
return ctx.Collected.SPCConvectiveOutlooks != nil
|
||||
case module.CollectedSourceMetadata:
|
||||
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
|
||||
default:
|
||||
@@ -183,6 +201,8 @@ func derivedFactAvailable(requirement module.FactRequirement, ctx ModuleContext)
|
||||
return len(ctx.Derived.DaypartSummaries) > 0
|
||||
case module.RequiresDerivedPrecipTiming:
|
||||
return true
|
||||
case module.RequiresDerivedSPCConvectiveOutlooks:
|
||||
return ctx.Derived.SPCConvectiveOutlooks != nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -245,8 +265,8 @@ func (d ModuleDefinition) ValidateOptions(options any) error {
|
||||
}
|
||||
|
||||
func defaultModuleDefinitions() []ModuleDefinition {
|
||||
allReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend, report.Storm}
|
||||
daypartReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend}
|
||||
allReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm}
|
||||
daypartReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.ThreeDay, report.Weekend}
|
||||
return []ModuleDefinition{
|
||||
{
|
||||
ID: module.Metadata,
|
||||
@@ -265,6 +285,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildCurrentConditionsModule,
|
||||
PromptExporter: exportCurrentConditionsPromptValue,
|
||||
},
|
||||
{
|
||||
ID: module.NarrativeForecast,
|
||||
@@ -272,7 +293,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
DefaultOptions: module.NarrativeForecastOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods},
|
||||
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow},
|
||||
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildNarrativeForecastModule,
|
||||
},
|
||||
@@ -282,16 +303,17 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
DefaultOptions: module.HourlyForecastOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
|
||||
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,
|
||||
Builder: buildHourlyForecastModule,
|
||||
PromptExporter: exportHourlyForecastPromptValue,
|
||||
},
|
||||
{
|
||||
ID: module.DerivedDailySummary,
|
||||
StanzaName: "derived_daily_summary",
|
||||
DefaultOptions: module.DerivedDailySummaryOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming},
|
||||
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow},
|
||||
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
|
||||
MissingData: module.MissingDataError,
|
||||
Builder: buildDerivedDailySummaryModule,
|
||||
},
|
||||
@@ -303,12 +325,13 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
SupportedReports: daypartReports,
|
||||
MissingData: module.MissingDataError,
|
||||
Builder: buildDerivedDaypartSummariesModule,
|
||||
PromptExporter: exportDerivedDaypartSummariesPromptValue,
|
||||
},
|
||||
{
|
||||
ID: module.PrecipTiming,
|
||||
StanzaName: "precip_timing",
|
||||
DefaultOptions: module.PrecipTimingOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedPrecipTiming},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildPrecipTimingModule,
|
||||
@@ -323,6 +346,16 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildAlertDigestModule,
|
||||
},
|
||||
{
|
||||
ID: module.SPCConvectiveOutlooks,
|
||||
StanzaName: string(module.SPCConvectiveOutlooks),
|
||||
DefaultOptions: module.SPCConvectiveOutlooksOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildSPCConvectiveOutlooksModule,
|
||||
},
|
||||
{
|
||||
ID: module.AreaForecastDiscussion,
|
||||
StanzaName: "area_forecast_discussion",
|
||||
@@ -332,6 +365,16 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildAreaForecastDiscussionModule,
|
||||
},
|
||||
{
|
||||
ID: module.SPCConvectiveDiscussion,
|
||||
StanzaName: string(module.SPCConvectiveDiscussion),
|
||||
DefaultOptions: module.SPCConvectiveDiscussionOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildSPCConvectiveDiscussionModule,
|
||||
},
|
||||
{
|
||||
ID: module.WeatherStory,
|
||||
StanzaName: "weather_story",
|
||||
@@ -350,14 +393,32 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildOutdoorWindowsModule,
|
||||
},
|
||||
{
|
||||
ID: module.TodayPlanning,
|
||||
StanzaName: "today_planning",
|
||||
DefaultOptions: module.TodayPlanningOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
||||
SupportedReports: []report.ID{report.Today},
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildTodayPlanningModule,
|
||||
},
|
||||
{
|
||||
ID: module.TomorrowPlanning,
|
||||
StanzaName: "tomorrow_planning",
|
||||
DefaultOptions: module.TomorrowPlanningOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
||||
SupportedReports: []report.ID{report.DailyTomorrow},
|
||||
SupportedReports: []report.ID{report.Tomorrow},
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildTomorrowPlanningModule,
|
||||
},
|
||||
{
|
||||
ID: module.DailyPlanning,
|
||||
StanzaName: "daily_planning",
|
||||
DefaultOptions: module.DailyPlanningOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
||||
SupportedReports: []report.ID{report.Daily},
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildDailyPlanningModule,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) {
|
||||
@@ -38,6 +42,9 @@ func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
|
||||
t.Fatalf("BuildModule(%s) error = %v", item.ID, err)
|
||||
}
|
||||
if output != nil {
|
||||
if output.DataPackageValue() == nil {
|
||||
t.Fatalf("BuildModule(%s) data package value = nil", item.ID)
|
||||
}
|
||||
outputs = append(outputs, *output)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
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"`) {
|
||||
t.Fatalf("error = %v, want unknown module", err)
|
||||
}
|
||||
@@ -62,7 +294,7 @@ func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
|
||||
|
||||
func TestModuleRegistryRejectsDuplicateModuleIDs(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{
|
||||
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
|
||||
{ID: module.Metadata},
|
||||
{ID: module.Metadata},
|
||||
})
|
||||
@@ -83,12 +315,84 @@ func TestModuleRegistryRejectsDuplicateStanzaNames(t *testing.T) {
|
||||
|
||||
func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.TomorrowPlanning}})
|
||||
if err == nil || !strings.Contains(err.Error(), `module "tomorrow_planning" is not compatible with report "daily_today"`) {
|
||||
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.TomorrowPlanning}})
|
||||
if err == nil || !strings.Contains(err.Error(), `module "tomorrow_planning" is not compatible with report "daily"`) {
|
||||
t.Fatalf("error = %v, want incompatible report", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryValidatesTodayPlanningSupport(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
if err := registry.ValidateComposition(report.Today, []module.ConfigItem{{ID: module.TodayPlanning}}); err != nil {
|
||||
t.Fatalf("ValidateComposition(today) error = %v", err)
|
||||
}
|
||||
for _, id := range []report.ID{report.Tomorrow, report.Daily} {
|
||||
t.Run(string(id), func(t *testing.T) {
|
||||
err := registry.ValidateComposition(id, []module.ConfigItem{{ID: module.TodayPlanning}})
|
||||
if err == nil || !strings.Contains(err.Error(), `module "today_planning" is not compatible with report`) {
|
||||
t.Fatalf("ValidateComposition(%s) error = %v, want incompatible report", id, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryValidatesDailyPlanningSupport(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
if err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.DailyPlanning}}); err != nil {
|
||||
t.Fatalf("ValidateComposition(daily) error = %v", err)
|
||||
}
|
||||
for _, id := range []report.ID{report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm} {
|
||||
t.Run(string(id), func(t *testing.T) {
|
||||
err := registry.ValidateComposition(id, []module.ConfigItem{{ID: module.DailyPlanning}})
|
||||
if err == nil || !strings.Contains(err.Error(), `module "daily_planning" is not compatible with report`) {
|
||||
t.Fatalf("ValidateComposition(%s) error = %v, want incompatible report", id, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistrySupportsTodayEligibleModules(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
err := registry.ValidateComposition(report.Today, []module.ConfigItem{
|
||||
{ID: module.Metadata},
|
||||
{ID: module.CurrentConditions},
|
||||
{ID: module.NarrativeForecast},
|
||||
{ID: module.HourlyForecast},
|
||||
{ID: module.DerivedDailySummary},
|
||||
{ID: module.DerivedDaypartSummaries},
|
||||
{ID: module.PrecipTiming},
|
||||
{ID: module.AlertDigest},
|
||||
{ID: module.SPCConvectiveOutlooks},
|
||||
{ID: module.AreaForecastDiscussion},
|
||||
{ID: module.SPCConvectiveDiscussion},
|
||||
{ID: module.WeatherStory},
|
||||
{ID: module.OutdoorWindows},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateComposition(today eligible modules) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryRejectsHourlyIncompatibleModules(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
for _, id := range []module.ID{
|
||||
module.NarrativeForecast,
|
||||
module.DerivedDailySummary,
|
||||
module.DerivedDaypartSummaries,
|
||||
module.OutdoorWindows,
|
||||
module.TodayPlanning,
|
||||
module.TomorrowPlanning,
|
||||
module.DailyPlanning,
|
||||
} {
|
||||
t.Run(string(id), func(t *testing.T) {
|
||||
err := registry.ValidateComposition(report.Hourly, []module.ConfigItem{{ID: id}})
|
||||
if err == nil || !strings.Contains(err.Error(), `not compatible with report "hourly"`) {
|
||||
t.Fatalf("ValidateComposition() error = %v, want incompatible hourly module", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryRejectsDefinitionsWithoutBuilders(t *testing.T) {
|
||||
_, err := NewModuleRegistry([]ModuleDefinition{
|
||||
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}},
|
||||
@@ -109,7 +413,7 @@ func TestModuleRegistryRejectsUnsupportedMissingDataWarn(t *testing.T) {
|
||||
|
||||
func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{
|
||||
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
|
||||
{ID: module.Metadata, Options: module.CurrentConditionsOptions{}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `module "metadata" options have type module.CurrentConditionsOptions, want module.MetadataOptions`) {
|
||||
@@ -119,7 +423,7 @@ func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) {
|
||||
|
||||
func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{
|
||||
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
|
||||
{ID: module.Metadata, Options: module.MetadataOptions{}},
|
||||
{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) {
|
||||
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"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
@@ -18,8 +19,8 @@ type NarrativeForecastModule struct {
|
||||
|
||||
type NarrativeForecastPeriod struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
StartTime string `json:"start_time,omitempty"`
|
||||
EndTime string `json:"end_time,omitempty"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
IsDay *bool `json:"is_day,omitempty"`
|
||||
TextDescription string `json:"text_description,omitempty"`
|
||||
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||
@@ -58,10 +59,11 @@ func buildNarrativeForecastModule(ctx ModuleContext, _ any) (*module.Output, err
|
||||
func narrativeForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []NarrativeForecastPeriod {
|
||||
out := make([]NarrativeForecastPeriod, 0, len(periods))
|
||||
for _, period := range periods {
|
||||
validPeriod := timeutil.Period{Start: period.StartTime, End: period.EndTime}
|
||||
out = append(out, NarrativeForecastPeriod{
|
||||
Name: period.Name,
|
||||
StartTime: friendlyDateTimeLabel(period.StartTime, timezone),
|
||||
EndTime: friendlyDateTimeLabel(period.EndTime, timezone),
|
||||
PeriodBegins: friendlyPeriodBeginsLabel(validPeriod, timezone),
|
||||
PeriodEnds: friendlyPeriodEndsLabel(validPeriod, timezone),
|
||||
IsDay: copyBool(period.IsDay),
|
||||
TextDescription: period.TextDescription,
|
||||
TemperatureC: copyFloat(period.TemperatureC),
|
||||
|
||||
@@ -8,31 +8,31 @@ type OutdoorWindowsModule struct {
|
||||
}
|
||||
|
||||
type OutdoorWindowModule struct {
|
||||
Daypart string `json:"daypart"`
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
Reasons []string `json:"reasons,omitempty"`
|
||||
Score float64 `json:"score"`
|
||||
Daypart string `json:"daypart"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
Reasons []string `json:"reasons,omitempty"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries)
|
||||
value := OutdoorWindowsModule{
|
||||
Best: outdoorWindowValue(windows.Best),
|
||||
Worst: outdoorWindowValue(windows.Worst),
|
||||
Best: outdoorWindowValue(windows.Best, ctx.Timezone),
|
||||
Worst: outdoorWindowValue(windows.Worst, ctx.Timezone),
|
||||
}
|
||||
return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil
|
||||
}
|
||||
|
||||
func outdoorWindowValue(window *OutdoorWindow) *OutdoorWindowModule {
|
||||
func outdoorWindowValue(window *OutdoorWindow, timezone string) *OutdoorWindowModule {
|
||||
if window == nil {
|
||||
return nil
|
||||
}
|
||||
return &OutdoorWindowModule{
|
||||
Daypart: window.Daypart,
|
||||
Start: window.Start,
|
||||
End: window.End,
|
||||
Reasons: append([]string(nil), window.Reasons...),
|
||||
Score: window.Score,
|
||||
Daypart: window.Daypart,
|
||||
PeriodBegins: friendlyPeriodBeginsLabel(window.Period, timezone),
|
||||
PeriodEnds: friendlyPeriodEndsLabel(window.Period, timezone),
|
||||
Reasons: append([]string(nil), window.Reasons...),
|
||||
Score: window.Score,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,9 +185,9 @@ func alertStatus(bundle *weatherdata.Bundle) *AlertStatus {
|
||||
|
||||
func variantForReport(id report.ID) string {
|
||||
switch id {
|
||||
case report.DailyToday:
|
||||
case report.Daily, report.Today:
|
||||
return "today"
|
||||
case report.DailyTomorrow:
|
||||
case report.Tomorrow:
|
||||
return "tomorrow"
|
||||
default:
|
||||
return ""
|
||||
|
||||
@@ -14,10 +14,13 @@ type PrecipTimingModule struct {
|
||||
}
|
||||
|
||||
type PrecipitationWindowModule struct {
|
||||
Start string `json:"start"`
|
||||
End string `json:"end,omitempty"`
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||
PeriodBegins string `json:"period_begins"`
|
||||
PeriodBeginsHourLabel string `json:"period_begins_hour_label,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
PeriodEndsHourLabel string `json:"period_ends_hour_label,omitempty"`
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||
MaxPopHourLabel string `json:"max_pop_hour_label,omitempty"`
|
||||
}
|
||||
|
||||
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 {
|
||||
item := PrecipitationWindowModule{
|
||||
Start: clockLabel(window.Start, timezone),
|
||||
PeriodBegins: friendlyDateTimeLabel(window.Start, timezone),
|
||||
PeriodBeginsHourLabel: hourMinuteLabel(window.Start, timezone),
|
||||
}
|
||||
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.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone)
|
||||
item.MaxPopHourLabel = hourMinuteLabel(window.MaxPrecipitationProbability.Time, timezone)
|
||||
value.PrecipitationWindows = append(value.PrecipitationWindows, item)
|
||||
}
|
||||
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"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
type OutdoorWindows struct {
|
||||
@@ -16,8 +17,7 @@ type OutdoorWindows struct {
|
||||
|
||||
type OutdoorWindow struct {
|
||||
Daypart string
|
||||
Start string
|
||||
End string
|
||||
Period timeutil.Period
|
||||
Reasons []string
|
||||
Score float64
|
||||
}
|
||||
@@ -28,6 +28,19 @@ type TomorrowPlanning struct {
|
||||
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 {
|
||||
var best *OutdoorWindow
|
||||
var worst *OutdoorWindow
|
||||
@@ -48,8 +61,60 @@ func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
|
||||
return OutdoorWindows{Best: best, Worst: worst}
|
||||
}
|
||||
|
||||
func buildTodayPlanning(summary *forecast.DailySummary) *TodayPlanning {
|
||||
planning := &TodayPlanning{}
|
||||
morning := daypartNamed(summary.Dayparts, "morning")
|
||||
if morning != nil {
|
||||
planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...)
|
||||
}
|
||||
if len(planning.MorningReadiness) == 0 {
|
||||
planning.MorningReadiness = append(planning.MorningReadiness, "Morning weather looks routine based on the available hourly forecast.")
|
||||
}
|
||||
|
||||
for _, daypart := range summary.Dayparts {
|
||||
if daypart.Name == "overnight" || daypart.Name == "evening" {
|
||||
continue
|
||||
}
|
||||
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, concernNotes(daypart)...)
|
||||
}
|
||||
for _, alert := range summary.AlertOverlaps {
|
||||
if alert.Event != "" {
|
||||
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, "Active alert to plan around: "+alert.Event+".")
|
||||
}
|
||||
}
|
||||
if len(planning.CommuteSchoolWorkdayConcerns) == 0 {
|
||||
planning.CommuteSchoolWorkdayConcerns = append(planning.CommuteSchoolWorkdayConcerns, "No major commute, school, or workday weather concerns stand out in the available forecast.")
|
||||
}
|
||||
|
||||
planning.OutdoorPlanning = append(planning.OutdoorPlanning, outdoorPlanningNotes(summary.Dayparts)...)
|
||||
if len(planning.OutdoorPlanning) == 0 {
|
||||
planning.OutdoorPlanning = append(planning.OutdoorPlanning, "No standout outdoor weather constraints are evident in the available forecast.")
|
||||
}
|
||||
|
||||
for _, name := range []string{"afternoon", "evening"} {
|
||||
daypart := daypartNamed(summary.Dayparts, name)
|
||||
if daypart != nil {
|
||||
planning.LateDayChangeWatch = appendUnique(planning.LateDayChangeWatch, lateDayWatchNotes(*daypart)...)
|
||||
}
|
||||
}
|
||||
if len(planning.LateDayChangeWatch) == 0 {
|
||||
planning.LateDayChangeWatch = append(planning.LateDayChangeWatch, "Watch for forecast timing or intensity adjustments later today.")
|
||||
}
|
||||
|
||||
return planning
|
||||
}
|
||||
|
||||
func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning {
|
||||
planning := &TomorrowPlanning{}
|
||||
base := buildMorningCommuteOvernightPlanning(summary)
|
||||
return &TomorrowPlanning{
|
||||
MorningReadiness: append([]string(nil), base.MorningReadiness...),
|
||||
CommuteSchoolWorkdayConcerns: append([]string(nil), base.CommuteSchoolWorkdayConcerns...),
|
||||
OvernightChangeWatch: append([]string(nil), base.OvernightChangeWatch...),
|
||||
}
|
||||
}
|
||||
|
||||
func buildMorningCommuteOvernightPlanning(summary *forecast.DailySummary) *morningCommuteOvernightPlanning {
|
||||
planning := &morningCommuteOvernightPlanning{}
|
||||
morning := daypartNamed(summary.Dayparts, "morning")
|
||||
if morning != nil {
|
||||
planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...)
|
||||
@@ -84,6 +149,18 @@ func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning {
|
||||
return planning
|
||||
}
|
||||
|
||||
func outdoorPlanningNotes(dayparts []forecast.DaypartSummary) []string {
|
||||
windows := buildOutdoorWindows(dayparts)
|
||||
var notes []string
|
||||
if windows.Best != nil {
|
||||
notes = append(notes, fmt.Sprintf("Best outdoor window: %s (%s).", titleWord(windows.Best.Daypart), strings.Join(windows.Best.Reasons, ", ")))
|
||||
}
|
||||
if windows.Worst != nil && (windows.Best == nil || windows.Worst.Daypart != windows.Best.Daypart) {
|
||||
notes = append(notes, fmt.Sprintf("Toughest outdoor window: %s (%s).", titleWord(windows.Worst.Daypart), strings.Join(windows.Worst.Reasons, ", ")))
|
||||
}
|
||||
return appendUnique(nil, notes...)
|
||||
}
|
||||
|
||||
func readinessNotes(daypart forecast.DaypartSummary) []string {
|
||||
notes := []string{}
|
||||
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 50 {
|
||||
@@ -104,6 +181,27 @@ func readinessNotes(daypart forecast.DaypartSummary) []string {
|
||||
return appendUnique(nil, notes...)
|
||||
}
|
||||
|
||||
func lateDayWatchNotes(daypart forecast.DaypartSummary) []string {
|
||||
notes := []string{}
|
||||
prefix := titleWord(daypart.Name)
|
||||
if prefix == "" {
|
||||
prefix = "Late-day"
|
||||
}
|
||||
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 {
|
||||
notes = append(notes, fmt.Sprintf("%s precipitation timing may shift; current peak is near %.0f%%.", prefix, daypart.MaxPrecipitationProbability.Value))
|
||||
}
|
||||
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
||||
notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", prefix, daypart.PeakWindGust.Value))
|
||||
}
|
||||
if daypart.Indicators.Snow || daypart.Indicators.Ice {
|
||||
notes = append(notes, prefix+" wintry weather could affect late-day travel.")
|
||||
}
|
||||
if len(daypart.AlertOverlaps) > 0 {
|
||||
notes = append(notes, prefix+" alert timing could affect late-day plans.")
|
||||
}
|
||||
return appendUnique(nil, notes...)
|
||||
}
|
||||
|
||||
func concernNotes(daypart forecast.DaypartSummary) []string {
|
||||
notes := []string{}
|
||||
prefix := titleWord(daypart.Name)
|
||||
@@ -190,8 +288,7 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
|
||||
}
|
||||
return OutdoorWindow{
|
||||
Daypart: daypart.Name,
|
||||
Start: daypart.Period.Start.Format("15:04"),
|
||||
End: daypart.Period.End.Format("15:04"),
|
||||
Period: daypart.Period,
|
||||
Reasons: dedupe(reasons),
|
||||
Score: math.Round(score*10) / 10,
|
||||
}
|
||||
|
||||
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,20 +4,21 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
type WeatherStoryModule struct {
|
||||
Available bool `json:"available"`
|
||||
OfficeID string `json:"office_id,omitempty"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
EndTime time.Time `json:"end_time"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
AltText string `json:"alt_text,omitempty"`
|
||||
Priority bool `json:"priority"`
|
||||
Order int `json:"order"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
Available bool `json:"available"`
|
||||
OfficeID string `json:"office_id,omitempty"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
AltText string `json:"alt_text,omitempty"`
|
||||
Priority bool `json:"priority"`
|
||||
Order int `json:"order"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
}
|
||||
|
||||
func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
@@ -25,18 +26,19 @@ func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
if story == nil {
|
||||
return nil, nil
|
||||
}
|
||||
period := timeutil.Period{Start: story.StartTime, End: story.EndTime}
|
||||
value := WeatherStoryModule{
|
||||
Available: true,
|
||||
OfficeID: story.OfficeID,
|
||||
StartTime: story.StartTime,
|
||||
EndTime: story.EndTime,
|
||||
UpdatedAt: copyTime(story.UpdatedAt),
|
||||
Title: story.Title,
|
||||
Description: story.Description,
|
||||
AltText: story.AltText,
|
||||
Priority: story.Priority,
|
||||
Order: story.Order,
|
||||
DownloadURL: story.DownloadURL,
|
||||
Available: true,
|
||||
OfficeID: story.OfficeID,
|
||||
PeriodBegins: friendlyPeriodBeginsLabel(period, ctx.Timezone),
|
||||
PeriodEnds: friendlyPeriodEndsLabel(period, ctx.Timezone),
|
||||
UpdatedAt: copyTime(story.UpdatedAt),
|
||||
Title: story.Title,
|
||||
Description: story.Description,
|
||||
AltText: story.AltText,
|
||||
Priority: story.Priority,
|
||||
Order: story.Order,
|
||||
DownloadURL: story.DownloadURL,
|
||||
}
|
||||
return &module.Output{ID: module.WeatherStory, StanzaName: "weather_story", Value: value}, nil
|
||||
}
|
||||
|
||||
@@ -84,7 +84,8 @@ type dailySummaryStanza struct {
|
||||
|
||||
type daypartSummaryStanza struct {
|
||||
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"`
|
||||
MaxPopPercent *int `json:"max_pop_percent,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,
|
||||
}},
|
||||
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.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{
|
||||
date + "_morning": {
|
||||
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,
|
||||
MaxPopPercent: &precip,
|
||||
MaxPopTime: precipTime,
|
||||
|
||||
@@ -17,8 +17,10 @@ const helpText = `weatherreporter prepares weather reports from normalized forec
|
||||
|
||||
Usage:
|
||||
weatherreporter --help
|
||||
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
||||
weatherreporter generate 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 hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
|
||||
@@ -36,7 +38,7 @@ Options:
|
||||
--config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml.
|
||||
--units VALUE Override weather API units.
|
||||
--tz NAME Override weather API timezone.
|
||||
--out PATH Write an extra Markdown report copy for generate commands.
|
||||
--out PATH Write an extra Markdown report copy where supported by the generate command.
|
||||
--out-dir PATH Write extra Markdown report copies for run commands.
|
||||
`
|
||||
|
||||
@@ -185,10 +187,10 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
||||
if len(args) == 0 {
|
||||
return app.GenerateRequest{}, fmt.Errorf("generate requires a report name")
|
||||
}
|
||||
reportKind, ok := reportKind(args[0])
|
||||
if !ok {
|
||||
if _, err := report.IDForCommandName(args[0]); err != nil {
|
||||
return app.GenerateRequest{}, fmt.Errorf("unknown generate report %q", args[0])
|
||||
}
|
||||
reportKind := app.ReportKind(args[0])
|
||||
|
||||
opts, err := parseGenerateFlags(reportKind, args[1:])
|
||||
if err != nil {
|
||||
@@ -216,6 +218,14 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
||||
|
||||
switch reportKind {
|
||||
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 == "" {
|
||||
req.Date = timeutil.LocalDate(r.Clock.Now(), location)
|
||||
} else {
|
||||
@@ -249,10 +259,10 @@ func (r Runner) resolveRun(args []string) (app.BatchRequest, error) {
|
||||
if len(args) == 0 {
|
||||
return app.BatchRequest{}, fmt.Errorf("run requires a batch name")
|
||||
}
|
||||
batch, ok := batchKind(args[0])
|
||||
if !ok {
|
||||
if _, err := report.BatchForCommandName(args[0]); err != nil {
|
||||
return app.BatchRequest{}, fmt.Errorf("unknown run batch %q", args[0])
|
||||
}
|
||||
batch := app.BatchKind(args[0])
|
||||
opts, err := parseRunFlags(args[1:])
|
||||
if err != nil {
|
||||
return app.BatchRequest{}, err
|
||||
@@ -277,7 +287,7 @@ func parseGenerateFlags(report app.ReportKind, args []string) (generateOptions,
|
||||
fs.SetOutput(io.Discard)
|
||||
opts := generateOptions{}
|
||||
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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"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" {
|
||||
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)
|
||||
}
|
||||
if len(cfg.Notify.Distributor.ReportPathTemplates) != 1 {
|
||||
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) {
|
||||
@@ -126,34 +146,243 @@ reports:
|
||||
deterministic_modules:
|
||||
- metadata
|
||||
- current_conditions
|
||||
- alert_digest
|
||||
- spc_convective_outlooks
|
||||
- id: area_forecast_discussion
|
||||
options:
|
||||
sections:
|
||||
- short_term
|
||||
- spc_convective_discussion
|
||||
`)
|
||||
|
||||
cfg, err := LoadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFile() error = %v", err)
|
||||
}
|
||||
overrides := cfg.ReportModuleOverrides()
|
||||
items := overrides[report.DailyToday]
|
||||
if len(items) != 3 {
|
||||
t.Fatalf("daily override length = %d, want 3", len(items))
|
||||
overrides, err := cfg.ReportModuleOverrides()
|
||||
if err != nil {
|
||||
t.Fatalf("ReportModuleOverrides() error = %v", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
options, ok := items[2].Options.(module.AreaForecastDiscussionOptions)
|
||||
options, ok := items[4].Options.(module.AreaForecastDiscussionOptions)
|
||||
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" {
|
||||
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) {
|
||||
retiredDailyKey := retiredDailyReportKeyForTest()
|
||||
tests := []struct {
|
||||
name string
|
||||
yaml string
|
||||
@@ -198,7 +427,7 @@ reports:
|
||||
deterministic_modules:
|
||||
- tomorrow_planning
|
||||
`,
|
||||
wantErr: `not compatible with report "daily_today"`,
|
||||
wantErr: `not compatible with report "daily"`,
|
||||
},
|
||||
{
|
||||
name: "RemovedPlaceholderModule",
|
||||
@@ -210,6 +439,16 @@ reports:
|
||||
`,
|
||||
wantErr: `unknown module "forecast_delta"`,
|
||||
},
|
||||
{
|
||||
name: "RetiredTomorrowReportID",
|
||||
yaml: `
|
||||
reports:
|
||||
daily_tomorrow:
|
||||
deterministic_modules:
|
||||
- metadata
|
||||
`,
|
||||
wantErr: "reports.daily_tomorrow",
|
||||
},
|
||||
{
|
||||
name: "InvalidOptions",
|
||||
yaml: `
|
||||
@@ -224,17 +463,27 @@ reports:
|
||||
wantErr: "options are invalid",
|
||||
},
|
||||
{
|
||||
name: "DuplicateReportAlias",
|
||||
name: "RetiredDailyReportID",
|
||||
yaml: `
|
||||
reports:
|
||||
daily:
|
||||
deterministic_modules:
|
||||
- metadata
|
||||
daily_today:
|
||||
` + retiredDailyKey + `:
|
||||
deterministic_modules:
|
||||
- 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",
|
||||
@@ -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) {
|
||||
_, err := LoadFile(filepath.Join(t.TempDir(), "missing.yml"))
|
||||
if err == nil {
|
||||
@@ -599,7 +874,7 @@ func TestLoadFileLoadsSecretsBeforeReturningNotifyConfig(t *testing.T) {
|
||||
"notify:\n" +
|
||||
" distributor:\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 {
|
||||
t.Fatalf("write config fixture: %v", err)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
@@ -12,23 +11,24 @@ import (
|
||||
"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{}
|
||||
seenReports := map[report.ID]string{}
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
id, err := reportIDForConfigKey(key)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
items := make([]module.ConfigItem, 0, len(reportCfg.DeterministicModules))
|
||||
for _, item := range reportCfg.DeterministicModules {
|
||||
items = append(items, module.ConfigItem{ID: item.ID, Options: item.Options})
|
||||
}
|
||||
overrides[id] = items
|
||||
overrides[id] = moduleItemsFromConfig(reportCfg.DeterministicModules)
|
||||
}
|
||||
return overrides
|
||||
return overrides, nil
|
||||
}
|
||||
|
||||
func normalizeReportModules(cfg *Config) error {
|
||||
@@ -42,9 +42,50 @@ func normalizeReportModules(cfg *Config) error {
|
||||
reportRegistry := report.DefaultRegistry()
|
||||
seenReports := map[report.ID]string{}
|
||||
for key, reportCfg := range cfg.Reports {
|
||||
reportID, err := reportIDForConfigKey(key)
|
||||
reportID, err := report.IDForConfigKey(key)
|
||||
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 {
|
||||
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))
|
||||
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)
|
||||
options := rawItem.Options
|
||||
if options != nil {
|
||||
normalized, err := normalizeModuleOptions(moduleRegistry, rawItem.ID, options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reports.%s.deterministic_modules[%d]: %w", key, i, err)
|
||||
}
|
||||
options = normalized
|
||||
}
|
||||
reportCfg.DeterministicModules[i].Options = options
|
||||
items = append(items, module.ConfigItem{ID: rawItem.ID, Options: options})
|
||||
}
|
||||
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 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) {
|
||||
if raw == nil {
|
||||
return nil, nil
|
||||
@@ -84,6 +135,9 @@ func normalizeModuleOptions(registry briefing.ModuleRegistry, id module.ID, raw
|
||||
if definition.DefaultOptions == nil {
|
||||
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)
|
||||
normalized, err := decodeKnownOptions(raw, optionType)
|
||||
if err != nil {
|
||||
@@ -105,21 +159,3 @@ func decodeKnownOptions(raw any, optionType reflect.Type) (any, error) {
|
||||
}
|
||||
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 {
|
||||
if err := normalizeReportModules(&cfg); err != nil {
|
||||
if err := validateReportModules(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.WeatherAPI.BaseURL != "" {
|
||||
|
||||
@@ -3,6 +3,7 @@ package facts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
@@ -12,15 +13,16 @@ import (
|
||||
)
|
||||
|
||||
type CollectedFacts struct {
|
||||
FetchedAt time.Time
|
||||
Observation *weatherdata.Observation
|
||||
Current *weatherdata.Current
|
||||
Hourly *weatherdata.ForecastRun
|
||||
Narrative *weatherdata.ForecastRun
|
||||
Alerts *weatherdata.AlertRun
|
||||
Discussion *weatherdata.Discussion
|
||||
Daily *weatherdata.ForecastRun
|
||||
WeatherStory *weatherdata.WeatherStory
|
||||
FetchedAt time.Time
|
||||
Observation *weatherdata.Observation
|
||||
Current *weatherdata.Current
|
||||
Hourly *weatherdata.ForecastRun
|
||||
Narrative *weatherdata.ForecastRun
|
||||
Alerts *weatherdata.AlertRun
|
||||
Discussion *weatherdata.Discussion
|
||||
Daily *weatherdata.ForecastRun
|
||||
WeatherStory *weatherdata.WeatherStory
|
||||
SPCConvectiveOutlooks *weatherdata.ConvectiveOutlookRun
|
||||
|
||||
SourceProvenance []weatherdata.Source
|
||||
SourceWarnings []weatherdata.SourceWarning
|
||||
@@ -31,33 +33,35 @@ func BuildCollected(bundle *weatherdata.Bundle) CollectedFacts {
|
||||
return CollectedFacts{}
|
||||
}
|
||||
return CollectedFacts{
|
||||
FetchedAt: bundle.FetchedAt,
|
||||
Observation: bundle.Observation,
|
||||
Current: bundle.Current,
|
||||
Hourly: bundle.Hourly,
|
||||
Narrative: bundle.Narrative,
|
||||
Alerts: bundle.Alerts,
|
||||
Discussion: bundle.Discussion,
|
||||
Daily: bundle.Daily,
|
||||
WeatherStory: bundle.WeatherStory,
|
||||
SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...),
|
||||
SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...),
|
||||
FetchedAt: bundle.FetchedAt,
|
||||
Observation: bundle.Observation,
|
||||
Current: bundle.Current,
|
||||
Hourly: bundle.Hourly,
|
||||
Narrative: bundle.Narrative,
|
||||
Alerts: bundle.Alerts,
|
||||
Discussion: bundle.Discussion,
|
||||
Daily: bundle.Daily,
|
||||
WeatherStory: bundle.WeatherStory,
|
||||
SPCConvectiveOutlooks: bundle.SPCConvectiveOutlooks,
|
||||
SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...),
|
||||
SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...),
|
||||
}
|
||||
}
|
||||
|
||||
func (f CollectedFacts) Bundle() *weatherdata.Bundle {
|
||||
return &weatherdata.Bundle{
|
||||
FetchedAt: f.FetchedAt,
|
||||
Observation: f.Observation,
|
||||
Current: f.Current,
|
||||
Hourly: f.Hourly,
|
||||
Narrative: f.Narrative,
|
||||
Alerts: f.Alerts,
|
||||
Discussion: f.Discussion,
|
||||
Daily: f.Daily,
|
||||
WeatherStory: f.WeatherStory,
|
||||
Sources: append([]weatherdata.Source(nil), f.SourceProvenance...),
|
||||
Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...),
|
||||
FetchedAt: f.FetchedAt,
|
||||
Observation: f.Observation,
|
||||
Current: f.Current,
|
||||
Hourly: f.Hourly,
|
||||
Narrative: f.Narrative,
|
||||
Alerts: f.Alerts,
|
||||
Discussion: f.Discussion,
|
||||
Daily: f.Daily,
|
||||
WeatherStory: f.WeatherStory,
|
||||
SPCConvectiveOutlooks: f.SPCConvectiveOutlooks,
|
||||
Sources: append([]weatherdata.Source(nil), f.SourceProvenance...),
|
||||
Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +77,8 @@ type DerivedFacts struct {
|
||||
ValidPeriodNarrativePeriods []weatherdata.ForecastPeriod
|
||||
ValidPeriodDailyPeriods []weatherdata.ForecastPeriod
|
||||
AlertOverlaps []forecast.AlertOverlap
|
||||
SPCConvectiveOutlooks []weatherdata.ConvectiveOutlook
|
||||
SPCConvectiveDiscussions []weatherdata.ConvectiveOutlookDiscussion
|
||||
DailySummaries []forecast.DailySummary
|
||||
DaypartSummaries []forecast.DaypartSummary
|
||||
PrecipTiming forecast.PrecipTiming
|
||||
@@ -96,16 +102,20 @@ func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) {
|
||||
}
|
||||
bundle := req.Collected.Bundle()
|
||||
period := req.Resolved.ValidPeriod
|
||||
spcOutlooks, spcDiscussions := selectSPCConvectiveOutlooks(req.Collected.SPCConvectiveOutlooks, period)
|
||||
derived := DerivedFacts{
|
||||
ValidPeriodHourlyPeriods: forecast.SelectHourlyPeriods(req.Collected.Hourly, period),
|
||||
ValidPeriodNarrativePeriods: forecast.SelectHourlyPeriods(req.Collected.Narrative, period),
|
||||
ValidPeriodDailyPeriods: forecast.SelectHourlyPeriods(req.Collected.Daily, period),
|
||||
AlertOverlaps: forecast.AlertOverlaps(req.Collected.Alerts, period),
|
||||
SPCConvectiveOutlooks: spcOutlooks,
|
||||
SPCConvectiveDiscussions: spcDiscussions,
|
||||
}
|
||||
derived.PrecipTiming = forecast.BuildPrecipTiming(derived.ValidPeriodHourlyPeriods)
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return DerivedFacts{}, err
|
||||
@@ -139,3 +149,81 @@ func collectDaypartSummaries(derived DerivedFacts) []forecast.DaypartSummary {
|
||||
}
|
||||
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 (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -16,14 +17,24 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
|
||||
FetchedAt: fetchedAt,
|
||||
Current: &weatherdata.Current{ConditionText: "Clear"},
|
||||
Hourly: &weatherdata.ForecastRun{Product: "hourly"},
|
||||
Sources: []weatherdata.Source{{Name: "hourly"}},
|
||||
Warnings: []weatherdata.SourceWarning{{Source: "discussion", Code: "missing_source"}},
|
||||
SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{
|
||||
Product: "convective_outlook",
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{{
|
||||
ID: "day1-categorical-slight",
|
||||
Label: "SLGT",
|
||||
}},
|
||||
},
|
||||
Sources: []weatherdata.Source{{Name: "hourly"}},
|
||||
Warnings: []weatherdata.SourceWarning{{Source: "discussion", Code: "missing_source"}},
|
||||
}
|
||||
|
||||
collected := BuildCollected(bundle)
|
||||
if collected.FetchedAt != fetchedAt || collected.Current.ConditionText != "Clear" || collected.Hourly.Product != "hourly" {
|
||||
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" {
|
||||
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" {
|
||||
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) {
|
||||
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{
|
||||
Resolved: resolved,
|
||||
Timezone: location.String(),
|
||||
@@ -119,7 +135,7 @@ func TestBuildDerivedOutlookBuildsPartialDaySummariesWithMissingOptionalSources(
|
||||
|
||||
func TestBuildDerivedWeekendAndTomorrow(t *testing.T) {
|
||||
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)
|
||||
derived, err := BuildDerived(BuildDerivedRequest{
|
||||
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) {
|
||||
location := testLocation()
|
||||
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 {
|
||||
return &weatherdata.Bundle{
|
||||
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 {
|
||||
temperature := 70.0
|
||||
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 {
|
||||
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 {
|
||||
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"
|
||||
AreaForecastDiscussion ID = "area_forecast_discussion"
|
||||
WeatherStory ID = "weather_story"
|
||||
SPCConvectiveOutlooks ID = "spc_convective_outlooks"
|
||||
SPCConvectiveDiscussion ID = "spc_convective_discussion"
|
||||
OutdoorWindows ID = "outdoor_windows"
|
||||
TodayPlanning ID = "today_planning"
|
||||
TomorrowPlanning ID = "tomorrow_planning"
|
||||
DailyPlanning ID = "daily_planning"
|
||||
)
|
||||
|
||||
type ConfigItem struct {
|
||||
@@ -31,9 +35,10 @@ type ConfigItem struct {
|
||||
}
|
||||
|
||||
type Output struct {
|
||||
ID ID `json:"id"`
|
||||
StanzaName string `json:"stanzaName"`
|
||||
Value any `json:"value"`
|
||||
ID ID `json:"id"`
|
||||
StanzaName string `json:"stanzaName"`
|
||||
Value any `json:"value"`
|
||||
PromptValue any `json:"-" yaml:"-"`
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
@@ -41,6 +46,13 @@ type Snapshot struct {
|
||||
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) {
|
||||
snapshot := Snapshot{
|
||||
SchemaVersion: SnapshotSchemaVersion,
|
||||
@@ -105,19 +117,21 @@ func StanzaValue[T any](s Snapshot, name string) (T, bool, error) {
|
||||
type FactRequirement string
|
||||
|
||||
const (
|
||||
CollectedCurrentConditions FactRequirement = "collected.current_conditions"
|
||||
CollectedNarrativeForecast FactRequirement = "collected.narrative_forecast"
|
||||
CollectedHourlyForecast FactRequirement = "collected.hourly_forecast"
|
||||
CollectedAlerts FactRequirement = "collected.alerts"
|
||||
CollectedDiscussion FactRequirement = "collected.discussion"
|
||||
CollectedWeatherStory FactRequirement = "collected.weather_story"
|
||||
CollectedSourceMetadata FactRequirement = "collected.source_metadata"
|
||||
RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods"
|
||||
RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods"
|
||||
RequiresDerivedAlertOverlaps FactRequirement = "derived.alert_overlaps"
|
||||
RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries"
|
||||
RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries"
|
||||
RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing"
|
||||
CollectedCurrentConditions FactRequirement = "collected.current_conditions"
|
||||
CollectedNarrativeForecast FactRequirement = "collected.narrative_forecast"
|
||||
CollectedHourlyForecast FactRequirement = "collected.hourly_forecast"
|
||||
CollectedAlerts FactRequirement = "collected.alerts"
|
||||
CollectedDiscussion FactRequirement = "collected.discussion"
|
||||
CollectedWeatherStory FactRequirement = "collected.weather_story"
|
||||
CollectedSPCConvectiveOutlooks FactRequirement = "collected.spc_convective_outlooks"
|
||||
CollectedSourceMetadata FactRequirement = "collected.source_metadata"
|
||||
RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods"
|
||||
RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods"
|
||||
RequiresDerivedAlertOverlaps FactRequirement = "derived.alert_overlaps"
|
||||
RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries"
|
||||
RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries"
|
||||
RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing"
|
||||
RequiresDerivedSPCConvectiveOutlooks FactRequirement = "derived.spc_convective_outlooks"
|
||||
)
|
||||
|
||||
type MissingDataBehavior string
|
||||
@@ -141,5 +155,9 @@ type AreaForecastDiscussionOptions struct {
|
||||
Sections []string `json:"sections,omitempty" yaml:"sections,omitempty"`
|
||||
}
|
||||
type WeatherStoryOptions struct{}
|
||||
type SPCConvectiveOutlooksOptions struct{}
|
||||
type SPCConvectiveDiscussionOptions struct{}
|
||||
type OutdoorWindowsOptions struct{}
|
||||
type TodayPlanningOptions struct{}
|
||||
type TomorrowPlanningOptions struct{}
|
||||
type DailyPlanningOptions struct{}
|
||||
|
||||
@@ -13,7 +13,12 @@ type testStanza struct {
|
||||
|
||||
func TestSnapshotPreservesOutputOrderAndJSON(t *testing.T) {
|
||||
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}},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -35,6 +40,26 @@ func TestSnapshotPreservesOutputOrderAndJSON(t *testing.T) {
|
||||
if 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) {
|
||||
@@ -57,7 +82,12 @@ func TestSnapshotRejectsDuplicateOutputs(t *testing.T) {
|
||||
|
||||
func TestStanzaValueDecodesTypedOutput(t *testing.T) {
|
||||
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 {
|
||||
t.Fatalf("NewSnapshot() error = %v", err)
|
||||
@@ -82,3 +112,29 @@ func TestStanzaValueDecodesTypedOutput(t *testing.T) {
|
||||
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"
|
||||
)
|
||||
|
||||
const SchemaVersion = "weatherreporter.data_package.v2"
|
||||
const SchemaVersion = "weatherreporter.data_package.v3"
|
||||
|
||||
const (
|
||||
metadataStanza = "metadata"
|
||||
@@ -35,13 +35,17 @@ var briefingCategoryOrder = []string{
|
||||
|
||||
var briefingStanzaCategories = map[string]string{
|
||||
string(module.AlertDigest): categoryApplicableRiskProducts,
|
||||
string(module.SPCConvectiveOutlooks): categoryApplicableRiskProducts,
|
||||
string(module.DerivedDailySummary): categoryDerivedSummaries,
|
||||
string(module.DerivedDaypartSummaries): categoryDerivedSummaries,
|
||||
string(module.PrecipTiming): categoryDerivedSummaries,
|
||||
string(module.OutdoorWindows): categoryDerivedSummaries,
|
||||
string(module.DailyPlanning): categoryDerivedSummaries,
|
||||
string(module.TodayPlanning): categoryDerivedSummaries,
|
||||
string(module.TomorrowPlanning): categoryDerivedSummaries,
|
||||
string(module.NarrativeForecast): categoryNarrativeProducts,
|
||||
string(module.AreaForecastDiscussion): categoryNarrativeProducts,
|
||||
string(module.SPCConvectiveDiscussion): categoryNarrativeProducts,
|
||||
string(module.WeatherStory): categoryNarrativeProducts,
|
||||
string(module.CurrentConditions): categoryRawData,
|
||||
string(module.HourlyForecast): categoryRawData,
|
||||
@@ -129,7 +133,7 @@ func stanzasFromSnapshot(snapshot module.Snapshot) BriefingStanzas {
|
||||
order := make([]string, 0, len(snapshot.Outputs))
|
||||
for _, output := range snapshot.Outputs {
|
||||
order = append(order, output.StanzaName)
|
||||
values[output.StanzaName] = output.Value
|
||||
values[output.StanzaName] = output.DataPackageValue()
|
||||
}
|
||||
return BriefingStanzas{Order: order, Values: values}
|
||||
}
|
||||
|
||||
@@ -19,11 +19,11 @@ func TestBuildDailyDataPackage(t *testing.T) {
|
||||
if 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)
|
||||
}
|
||||
if pkg.Report.PromptID != "weather.daily_report" {
|
||||
t.Fatalf("PromptID = %q, want weather.daily_report", pkg.Report.PromptID)
|
||||
if pkg.Report.PromptID != "weather.daily_generated_text" {
|
||||
t.Fatalf("PromptID = %q, want weather.daily_generated_text", pkg.Report.PromptID)
|
||||
}
|
||||
if pkg.Report.CurrentLocalDate != "2026-05-29" {
|
||||
t.Fatalf("CurrentLocalDate = %q, want 2026-05-29", pkg.Report.CurrentLocalDate)
|
||||
@@ -107,6 +107,7 @@ func TestBuildUsesNamedSnapshotStanzas(t *testing.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.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)
|
||||
@@ -120,6 +121,47 @@ func TestBuildUsesNamedSnapshotStanzas(t *testing.T) {
|
||||
if _, ok := pkg.Briefing.Values["derived_daypart_summaries"]; !ok {
|
||||
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) {
|
||||
@@ -139,7 +181,7 @@ func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) {
|
||||
if 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), " applicable_risk_products:\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) {
|
||||
pkg, err := Build(validBuildRequest(t))
|
||||
if err != nil {
|
||||
@@ -203,11 +289,11 @@ func TestMarshalYAMLRejectsUncategorizedStanza(t *testing.T) {
|
||||
|
||||
func TestLoadYAMLRejectsMisplacedStanza(t *testing.T) {
|
||||
data := []byte(`
|
||||
schema_version: weatherreporter.data_package.v2
|
||||
run_id: 20260529T100000Z_daily_today
|
||||
schema_version: weatherreporter.data_package.v3
|
||||
run_id: 20260529T100000Z_daily
|
||||
report:
|
||||
id: daily_today
|
||||
prompt_id: weather.daily_report
|
||||
id: daily
|
||||
prompt_id: weather.daily_generated_text
|
||||
generated_at: 2026-05-29T10:00:00Z
|
||||
timezone: America/Chicago
|
||||
current_local_date: "2026-05-29"
|
||||
@@ -216,7 +302,7 @@ report:
|
||||
end: 2026-05-30T05:00:00Z
|
||||
briefing:
|
||||
metadata:
|
||||
run_id: 20260529T100000Z_daily_today
|
||||
run_id: 20260529T100000Z_daily
|
||||
raw_data:
|
||||
alert_digest:
|
||||
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 {
|
||||
t.Helper()
|
||||
generatedAt := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC)
|
||||
return BuildRequest{
|
||||
Metadata: Metadata{
|
||||
RunID: "20260529T100000Z_daily_today",
|
||||
ReportID: report.DailyToday,
|
||||
RunID: "20260529T100000Z_daily",
|
||||
ReportID: report.Daily,
|
||||
Variant: "today",
|
||||
PromptID: "weather.daily_report",
|
||||
PromptID: "weather.daily_generated_text",
|
||||
GeneratedAt: generatedAt,
|
||||
Timezone: "America/Chicago",
|
||||
ValidPeriod: timeutil.Period{
|
||||
@@ -247,7 +350,7 @@ func validBuildRequest(t *testing.T) BuildRequest {
|
||||
},
|
||||
},
|
||||
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.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}},
|
||||
|
||||
@@ -1,43 +1,31 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
func dailyTodayDefinition() Definition {
|
||||
func dailyDefinition() Definition {
|
||||
return Definition{
|
||||
ID: DailyToday,
|
||||
Name: "Daily Report",
|
||||
PromptID: "weather.daily_report",
|
||||
ComparisonStrategy: CompareSameValidDate,
|
||||
ArtifactGroup: "daily",
|
||||
BatchOutputName: "daily.md",
|
||||
Generated: true,
|
||||
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
||||
Modules: dailyTodayModules(),
|
||||
Morning: true,
|
||||
resolve: resolveDailyToday,
|
||||
ID: Daily,
|
||||
Name: "Daily Report",
|
||||
PromptID: "weather.daily_generated_text",
|
||||
GenerationMode: GenerationModeGeneratedTextTemplate,
|
||||
TemplateID: "daily",
|
||||
GeneratedTextSchemaID: "daily",
|
||||
ComparisonStrategy: CompareSameValidDate,
|
||||
ArtifactGroup: "daily",
|
||||
BatchOutputName: "daily.md",
|
||||
Generated: true,
|
||||
CompatiblePriorIDs: []ID{Daily},
|
||||
Modules: dailyModules(),
|
||||
resolve: resolveDaily,
|
||||
}
|
||||
}
|
||||
|
||||
func dailyTomorrowDefinition() Definition {
|
||||
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 {
|
||||
func dailyModules() []module.ConfigItem {
|
||||
return moduleItems(
|
||||
module.Metadata,
|
||||
module.CurrentConditions,
|
||||
@@ -46,37 +34,19 @@ func dailyTodayModules() []module.ConfigItem {
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.SPCConvectiveOutlooks,
|
||||
module.AreaForecastDiscussion,
|
||||
module.SPCConvectiveDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
module.DailyPlanning,
|
||||
module.HourlyForecast,
|
||||
)
|
||||
}
|
||||
|
||||
func dailyTomorrowModules() []module.ConfigItem {
|
||||
return moduleItems(
|
||||
module.Metadata,
|
||||
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
|
||||
func resolveDaily(req ResolveRequest) (timeutil.Period, error) {
|
||||
if req.Date.IsZero() {
|
||||
return timeutil.Period{}, fmt.Errorf("daily report requires an explicit date")
|
||||
}
|
||||
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
|
||||
return timeutil.CivilDay(req.Date, req.Location), nil
|
||||
}
|
||||
|
||||
@@ -12,11 +12,13 @@ import (
|
||||
type ID string
|
||||
|
||||
const (
|
||||
DailyToday ID = "daily_today"
|
||||
DailyTomorrow ID = "daily_tomorrow"
|
||||
ThreeDay ID = "three_day"
|
||||
Weekend ID = "weekend"
|
||||
Storm ID = "storm"
|
||||
Daily ID = "daily"
|
||||
Today ID = "today"
|
||||
Tomorrow ID = "tomorrow"
|
||||
Hourly ID = "hourly"
|
||||
ThreeDay ID = "three_day"
|
||||
Weekend ID = "weekend"
|
||||
Storm ID = "storm"
|
||||
)
|
||||
|
||||
type ComparisonStrategy string
|
||||
@@ -25,6 +27,14 @@ const (
|
||||
CompareSameValidDate ComparisonStrategy = "same_valid_date"
|
||||
CompareWeekendWindow ComparisonStrategy = "same_weekend_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
|
||||
@@ -35,18 +45,21 @@ const (
|
||||
)
|
||||
|
||||
type Definition struct {
|
||||
ID ID
|
||||
Name string
|
||||
PromptID string
|
||||
ComparisonStrategy ComparisonStrategy
|
||||
ArtifactGroup string
|
||||
BatchOutputName string
|
||||
Generated bool
|
||||
CompatiblePriorIDs []ID
|
||||
Modules []module.ConfigItem
|
||||
Morning bool
|
||||
Evening bool
|
||||
resolve func(ResolveRequest) (timeutil.Period, error)
|
||||
ID ID
|
||||
Name string
|
||||
PromptID string
|
||||
GenerationMode GenerationMode
|
||||
TemplateID string
|
||||
GeneratedTextSchemaID string
|
||||
ComparisonStrategy ComparisonStrategy
|
||||
ArtifactGroup string
|
||||
BatchOutputName string
|
||||
Generated bool
|
||||
CompatiblePriorIDs []ID
|
||||
Modules []module.ConfigItem
|
||||
Morning bool
|
||||
Evening bool
|
||||
resolve func(ResolveRequest) (timeutil.Period, error)
|
||||
}
|
||||
|
||||
func (d Definition) ResolvePeriod(req ResolveRequest) (timeutil.Period, error) {
|
||||
|
||||
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 {
|
||||
case Morning:
|
||||
ids := []ID{DailyToday, ThreeDay}
|
||||
ids := []ID{Today, ThreeDay}
|
||||
if req.Now.In(req.Location).Weekday() != time.Sunday {
|
||||
ids = append(ids, Weekend)
|
||||
}
|
||||
return r.resolveIDs(ids, req)
|
||||
case Evening:
|
||||
return r.resolveIDs([]ID{DailyTomorrow}, req)
|
||||
return r.resolveIDs([]ID{Tomorrow}, req)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown batch %q", batch)
|
||||
}
|
||||
|
||||
@@ -14,11 +14,14 @@ func TestDailyValidPeriod(t *testing.T) {
|
||||
location := mustLoadLocation(t)
|
||||
now := mustParse("2026-05-29T17:45:00-05:00")
|
||||
|
||||
resolved, err := Resolve(DailyToday, ResolveRequest{Now: now, Location: location})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
resolved, err := Resolve(Daily, ResolveRequest{Now: now, Location: location})
|
||||
if err == nil {
|
||||
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) {
|
||||
@@ -26,7 +29,54 @@ func TestDailyValidPeriodCanUseExplicitDate(t *testing.T) {
|
||||
now := mustParse("2026-05-29T17:45: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 {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
@@ -37,13 +87,22 @@ func TestTomorrowValidPeriodFromEveningGeneration(t *testing.T) {
|
||||
location := mustLoadLocation(t)
|
||||
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 {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
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" {
|
||||
t.Fatalf("PromptID = %q, want weather.daily_report", resolved.Definition.PromptID)
|
||||
if resolved.Definition.PromptID != "weather.tomorrow_generated_text" {
|
||||
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")
|
||||
}
|
||||
|
||||
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) {
|
||||
location := mustLoadLocation(t)
|
||||
tests := []struct {
|
||||
@@ -138,8 +251,8 @@ func TestMorningBatchSkipsWeekendOnSunday(t *testing.T) {
|
||||
t.Fatalf("BatchReports() error = %v", err)
|
||||
}
|
||||
ids := resolvedIDs(resolved)
|
||||
if strings.Join(ids, ",") != "daily_today,three_day" {
|
||||
t.Fatalf("ids = %v, want daily_today and three_day", ids)
|
||||
if strings.Join(ids, ",") != "today,three_day" {
|
||||
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)
|
||||
}
|
||||
ids := resolvedIDs(resolved)
|
||||
if strings.Join(ids, ",") != "daily_tomorrow" {
|
||||
t.Fatalf("ids = %v, want daily_tomorrow", ids)
|
||||
if strings.Join(ids, ",") != "tomorrow" {
|
||||
t.Fatalf("ids = %v, want tomorrow", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchesDoNotIncludeManualReports(t *testing.T) {
|
||||
location := mustLoadLocation(t)
|
||||
req := ResolveRequest{Now: mustParse("2026-05-29T06:00:00-05:00"), Location: location}
|
||||
|
||||
morning, err := DefaultRegistry().BatchReports(Morning, req)
|
||||
if err != nil {
|
||||
t.Fatalf("BatchReports(morning) error = %v", err)
|
||||
}
|
||||
evening, err := DefaultRegistry().BatchReports(Evening, req)
|
||||
if err != nil {
|
||||
t.Fatalf("BatchReports(evening) error = %v", err)
|
||||
}
|
||||
for _, resolved := range append(morning, evening...) {
|
||||
if resolved.Definition.ID == Hourly || resolved.Definition.ID == Daily {
|
||||
t.Fatalf("batch included %q, want manual reports excluded", resolved.Definition.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIDForCommandName(t *testing.T) {
|
||||
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) {
|
||||
for _, definition := range DefaultRegistry().All() {
|
||||
if definition.PromptID == "" {
|
||||
t.Fatalf("%s PromptID is empty", definition.ID)
|
||||
}
|
||||
if definition.Generated && definition.GenerationMode == "" {
|
||||
t.Fatalf("%s GenerationMode is empty", definition.ID)
|
||||
}
|
||||
if definition.ComparisonStrategy == "" {
|
||||
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) {
|
||||
tests := []struct {
|
||||
id ID
|
||||
@@ -186,20 +493,39 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
||||
batchOutputName string
|
||||
generated bool
|
||||
compatiblePriorIDs []ID
|
||||
comparisonStrategy ComparisonStrategy
|
||||
}{
|
||||
{
|
||||
id: DailyToday,
|
||||
id: Daily,
|
||||
artifactGroup: "daily",
|
||||
batchOutputName: "daily.md",
|
||||
generated: true,
|
||||
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
||||
compatiblePriorIDs: []ID{Daily},
|
||||
comparisonStrategy: CompareSameValidDate,
|
||||
},
|
||||
{
|
||||
id: DailyTomorrow,
|
||||
artifactGroup: "daily",
|
||||
id: Today,
|
||||
artifactGroup: "today",
|
||||
batchOutputName: "today.md",
|
||||
generated: true,
|
||||
compatiblePriorIDs: []ID{Today},
|
||||
comparisonStrategy: CompareSameValidDate,
|
||||
},
|
||||
{
|
||||
id: Tomorrow,
|
||||
artifactGroup: "tomorrow",
|
||||
batchOutputName: "tomorrow.md",
|
||||
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,
|
||||
@@ -207,6 +533,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
||||
batchOutputName: "three-day.md",
|
||||
generated: true,
|
||||
compatiblePriorIDs: []ID{ThreeDay},
|
||||
comparisonStrategy: CompareSameValidDate,
|
||||
},
|
||||
{
|
||||
id: Weekend,
|
||||
@@ -214,6 +541,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
||||
batchOutputName: "weekend.md",
|
||||
generated: true,
|
||||
compatiblePriorIDs: []ID{Weekend},
|
||||
comparisonStrategy: CompareWeekendWindow,
|
||||
},
|
||||
{
|
||||
id: Storm,
|
||||
@@ -221,6 +549,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
||||
batchOutputName: "storm.md",
|
||||
generated: true,
|
||||
compatiblePriorIDs: []ID{Storm},
|
||||
comparisonStrategy: CompareExplicitWindow,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -240,6 +569,9 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
||||
if 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) {
|
||||
t.Fatalf("CompatiblePriorIDs = %#v, want %#v", definition.CompatiblePriorIDs, tt.compatiblePriorIDs)
|
||||
}
|
||||
@@ -258,7 +590,7 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
||||
want []module.ID
|
||||
}{
|
||||
{
|
||||
id: DailyToday,
|
||||
id: Daily,
|
||||
want: []module.ID{
|
||||
module.Metadata,
|
||||
module.CurrentConditions,
|
||||
@@ -267,14 +599,17 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.SPCConvectiveOutlooks,
|
||||
module.AreaForecastDiscussion,
|
||||
module.SPCConvectiveDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
module.DailyPlanning,
|
||||
module.HourlyForecast,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: DailyTomorrow,
|
||||
id: Today,
|
||||
want: []module.ID{
|
||||
module.Metadata,
|
||||
module.CurrentConditions,
|
||||
@@ -283,13 +618,48 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.SPCConvectiveOutlooks,
|
||||
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.OutdoorWindows,
|
||||
module.TomorrowPlanning,
|
||||
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,
|
||||
want: []module.ID{
|
||||
@@ -298,7 +668,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.SPCConvectiveOutlooks,
|
||||
module.AreaForecastDiscussion,
|
||||
module.SPCConvectiveDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
},
|
||||
@@ -311,7 +683,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.SPCConvectiveOutlooks,
|
||||
module.AreaForecastDiscussion,
|
||||
module.SPCConvectiveDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
},
|
||||
@@ -323,7 +697,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
||||
module.CurrentConditions,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.SPCConvectiveOutlooks,
|
||||
module.AreaForecastDiscussion,
|
||||
module.SPCConvectiveDiscussion,
|
||||
module.WeatherStory,
|
||||
},
|
||||
},
|
||||
@@ -346,7 +722,7 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
||||
func TestRegistryAppliesModuleOverridesWithoutChangingDefaults(t *testing.T) {
|
||||
base := DefaultRegistry()
|
||||
overridden, err := base.WithModuleOverrides(map[ID][]module.ConfigItem{
|
||||
DailyToday: {
|
||||
Daily: {
|
||||
{ID: module.Metadata},
|
||||
{ID: module.AlertDigest},
|
||||
},
|
||||
@@ -355,7 +731,7 @@ func TestRegistryAppliesModuleOverridesWithoutChangingDefaults(t *testing.T) {
|
||||
t.Fatalf("WithModuleOverrides() error = %v", err)
|
||||
}
|
||||
|
||||
definition, err := overridden.Lookup(DailyToday)
|
||||
definition, err := overridden.Lookup(Daily)
|
||||
if err != nil {
|
||||
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())
|
||||
}
|
||||
|
||||
defaultDefinition, err := base.Lookup(DailyToday)
|
||||
defaultDefinition, err := base.Lookup(Daily)
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup(default) error = %v", err)
|
||||
}
|
||||
@@ -386,18 +762,40 @@ func TestRegistryRejectsModuleOverrideForUnknownReport(t *testing.T) {
|
||||
|
||||
func TestResolvedMetadata(t *testing.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 {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
metadata := resolved.Metadata()
|
||||
if metadata.ReportID != DailyToday {
|
||||
t.Fatalf("ReportID = %q, want daily_today", metadata.ReportID)
|
||||
if metadata.ReportID != Daily {
|
||||
t.Fatalf("ReportID = %q, want daily", metadata.ReportID)
|
||||
}
|
||||
if metadata.PromptID != "weather.daily_report" {
|
||||
t.Fatalf("PromptID = %q, want weather.daily_report", metadata.PromptID)
|
||||
if metadata.PromptID != "weather.daily_generated_text" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -423,6 +821,14 @@ func resolvedIDs(resolved []Resolved) []string {
|
||||
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 {
|
||||
t.Helper()
|
||||
location, err := time.LoadLocation("America/Chicago")
|
||||
|
||||
@@ -12,8 +12,10 @@ type Registry struct {
|
||||
|
||||
func DefaultRegistry() Registry {
|
||||
definitions := []Definition{
|
||||
dailyTodayDefinition(),
|
||||
dailyTomorrowDefinition(),
|
||||
dailyDefinition(),
|
||||
todayDefinition(),
|
||||
tomorrowDefinition(),
|
||||
hourlyDefinition(),
|
||||
threeDayDefinition(),
|
||||
weekendDefinition(),
|
||||
stormDefinition(),
|
||||
@@ -73,7 +75,7 @@ func (r Registry) MustLookup(id ID) 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))
|
||||
for _, id := range ids {
|
||||
if definition, ok := r.definitions[id]; ok {
|
||||
|
||||
@@ -13,6 +13,7 @@ func stormDefinition() Definition {
|
||||
ID: Storm,
|
||||
Name: "Storm Report",
|
||||
PromptID: "weather.storm_report",
|
||||
GenerationMode: GenerationModeScriptoriumMarkdown,
|
||||
ComparisonStrategy: CompareExplicitWindow,
|
||||
ArtifactGroup: "storm",
|
||||
BatchOutputName: "storm.md",
|
||||
@@ -29,7 +30,9 @@ func stormModules() []module.ConfigItem {
|
||||
module.CurrentConditions,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.SPCConvectiveOutlooks,
|
||||
module.AreaForecastDiscussion,
|
||||
module.SPCConvectiveDiscussion,
|
||||
module.WeatherStory,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ func threeDayDefinition() Definition {
|
||||
ID: ThreeDay,
|
||||
Name: "3-Day Outlook",
|
||||
PromptID: "weather.three_day_outlook",
|
||||
GenerationMode: GenerationModeScriptoriumMarkdown,
|
||||
ComparisonStrategy: CompareSameValidDate,
|
||||
ArtifactGroup: "three-day",
|
||||
BatchOutputName: "three-day.md",
|
||||
@@ -30,7 +31,9 @@ func threeDayModules() []module.ConfigItem {
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.SPCConvectiveOutlooks,
|
||||
module.AreaForecastDiscussion,
|
||||
module.SPCConvectiveDiscussion,
|
||||
module.WeatherStory,
|
||||
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,
|
||||
Name: "Weekend Outlook",
|
||||
PromptID: "weather.weekend_outlook",
|
||||
GenerationMode: GenerationModeScriptoriumMarkdown,
|
||||
ComparisonStrategy: CompareWeekendWindow,
|
||||
ArtifactGroup: "weekend",
|
||||
BatchOutputName: "weekend.md",
|
||||
@@ -31,7 +32,9 @@ func weekendModules() []module.ConfigItem {
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.SPCConvectiveOutlooks,
|
||||
module.AreaForecastDiscussion,
|
||||
module.SPCConvectiveDiscussion,
|
||||
module.WeatherStory,
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "weatherreporter.tomorrow.generated_text.schema.json",
|
||||
"title": "Tomorrow 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