Compare commits
52 Commits
v0.7.0
...
473f252aee
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
24
docs/cli.md
24
docs/cli.md
@@ -21,6 +21,7 @@ the managed Markdown report after final metadata is saved.
|
||||
weatherreporter --help
|
||||
weatherreporter generate daily [--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 +35,19 @@ 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
|
||||
tomorrow` and `generate hourly` write managed generated-text artifacts,
|
||||
validate structured text from Scriptorium, and render the managed Markdown
|
||||
report from embedded templates. `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
|
||||
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 +56,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,7 +68,7 @@ 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.
|
||||
- `--start TIME`: required start time for `generate storm`.
|
||||
@@ -76,6 +85,7 @@ are no distributor-specific CLI flags.
|
||||
|
||||
```sh
|
||||
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
|
||||
|
||||
@@ -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,9 @@ 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`, `tomorrow`, `hourly`, `three_day`,
|
||||
`weekend`, and `storm`. Canonical report IDs such as `daily_today` are also
|
||||
accepted.
|
||||
|
||||
Each report entry supports:
|
||||
|
||||
@@ -197,11 +199,29 @@ reports:
|
||||
- metadata
|
||||
- current_conditions
|
||||
- narrative_forecast
|
||||
- alert_digest
|
||||
- spc_convective_outlooks
|
||||
- id: area_forecast_discussion
|
||||
options:
|
||||
sections:
|
||||
- short_term
|
||||
- spc_convective_discussion
|
||||
- hourly_forecast
|
||||
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
|
||||
|
||||
For `scriptorium_markdown` reports, generation then:
|
||||
|
||||
12. Runs Scriptorium report generation to the managed report path.
|
||||
13. Copies the managed report to the requested `--out` path when provided.
|
||||
14. Saves metadata with the managed report path.
|
||||
15. If distributor notification is enabled, notifies using the managed report
|
||||
path as the source file.
|
||||
16. Save a distributor notification debug artifact and update metadata with its
|
||||
path.
|
||||
16. Saves a distributor notification debug artifact and updates metadata with
|
||||
its path.
|
||||
|
||||
For `generated_text_template` reports, generation then:
|
||||
|
||||
12. Runs structured Scriptorium generation to the raw generated-text JSON path.
|
||||
13. Saves the structured Scriptorium run result.
|
||||
14. Validates and saves normalized generated text.
|
||||
15. Builds and saves a typed render context.
|
||||
16. Renders Markdown from the embedded template to the managed report path.
|
||||
17. Saves final metadata with generated-text paths, render context path, schema
|
||||
ID, and managed report path.
|
||||
18. Copies the managed report to the requested `--out` path when provided.
|
||||
19. If distributor notification is enabled, notifies using the managed report
|
||||
path as the source file.
|
||||
20. 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
|
||||
@@ -99,7 +135,7 @@ 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
|
||||
on Sunday. `run evening` resolves Tomorrow Report. 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.
|
||||
@@ -122,6 +158,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 +180,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,7 +28,9 @@ 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`
|
||||
@@ -36,6 +38,18 @@ Outputs:
|
||||
Every registered composition entry has a builder. Unknown or unimplemented
|
||||
module IDs fail validation instead of being skipped.
|
||||
|
||||
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.
|
||||
|
||||
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`, or
|
||||
`tomorrow_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 +71,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 +117,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.
|
||||
|
||||
94
docs/internal/generatedtext.md
Normal file
94
docs/internal/generatedtext.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# 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. The implemented contracts are Tomorrow Report and Hourly Report.
|
||||
|
||||
## Inputs And Outputs
|
||||
|
||||
Inputs:
|
||||
|
||||
- raw GeneratedText JSON for Tomorrow Report or Hourly Report
|
||||
- report metadata from `internal/briefing`
|
||||
- a module snapshot from `internal/module`
|
||||
- validated generated text
|
||||
|
||||
Outputs:
|
||||
|
||||
- typed `Tomorrow` generated text
|
||||
- typed `Hourly` generated text
|
||||
- normalized stable JSON for validated generated text
|
||||
- typed `TomorrowRenderContext` values for `internal/reporttemplate`
|
||||
- typed `HourlyRenderContext` values for `internal/reporttemplate`
|
||||
|
||||
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 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 uses typed module snapshot decoding through `module.StanzaValue`.
|
||||
- It does not invoke Scriptorium, write state artifacts, choose report
|
||||
definitions, compare snapshots, or render templates directly in production
|
||||
workflows.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/generatedtext/hourly_test.go`
|
||||
- `internal/generatedtext/tomorrow_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.
|
||||
- Missing optional weather narrative stanzas produce empty or fallback render
|
||||
context fields rather than forcing raw module data into templates.
|
||||
@@ -37,7 +37,9 @@ 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`
|
||||
- `tomorrow_planning`
|
||||
@@ -45,9 +47,51 @@ The registry recognizes these IDs:
|
||||
Every registered module has a builder. Report composition entries that refer to
|
||||
unknown or unimplemented module IDs fail validation instead of being skipped.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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 +105,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 prompt-facing 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
|
||||
|
||||
@@ -41,6 +41,7 @@ briefing:
|
||||
metadata: {}
|
||||
applicable_risk_products:
|
||||
alert_digest: {}
|
||||
spc_convective_outlooks: {}
|
||||
derived_summaries:
|
||||
derived_daily_summary: {}
|
||||
derived_daypart_summaries: {}
|
||||
@@ -49,6 +50,7 @@ briefing:
|
||||
narrative_products:
|
||||
narrative_forecast: {}
|
||||
area_forecast_discussion: {}
|
||||
spc_convective_discussion: {}
|
||||
weather_story: {}
|
||||
raw_data:
|
||||
current_conditions: {}
|
||||
@@ -62,13 +64,34 @@ 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.
|
||||
|
||||
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 Tomorrow module list includes civil-day summary stanzas,
|
||||
`tomorrow_planning`, and `hourly_forecast` in the data package before
|
||||
structured GeneratedText is requested from Scriptorium.
|
||||
|
||||
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
|
||||
|
||||
@@ -16,6 +16,7 @@ Each report definition declares:
|
||||
|
||||
- report ID and display name
|
||||
- Scriptorium prompt ID
|
||||
- generation mode
|
||||
- valid-period resolver
|
||||
- comparison strategy
|
||||
- managed artifact group
|
||||
@@ -25,15 +26,22 @@ Each report definition declares:
|
||||
- morning or evening batch membership
|
||||
- default ordered module composition
|
||||
|
||||
Markdown report definitions use the `scriptorium_markdown` generation mode.
|
||||
Their template and structured-text schema identifiers are empty. 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 Today | `daily_today` | `weather.daily_report` | `scriptorium_markdown` | `daily` | `daily.md` | Daily Today |
|
||||
| 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.
|
||||
|
||||
@@ -41,7 +49,10 @@ All report definitions are eligible for generation.
|
||||
|
||||
- Daily Today 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
|
||||
@@ -64,7 +75,15 @@ IDs, then uses the registry for report policy.
|
||||
## 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 `tomorrow` and `hourly`, or
|
||||
canonical report IDs such as `daily_today`.
|
||||
|
||||
## Batch Membership
|
||||
|
||||
Morning batches include Daily Today, 3-Day Outlook, and Weekend Outlook except
|
||||
on Sunday. Evening batches include Tomorrow Report. Hourly Report is not part
|
||||
of a scheduled batch.
|
||||
|
||||
## State And App Usage
|
||||
|
||||
@@ -92,7 +111,9 @@ Inspect:
|
||||
## Invariants
|
||||
|
||||
- Report selection goes through the registry.
|
||||
- Daily Today and Daily Tomorrow both use `weather.daily_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
|
||||
|
||||
100
docs/internal/reporttemplate.md
Normal file
100
docs/internal/reporttemplate.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# 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 contracts are Tomorrow
|
||||
Report and Hourly Report.
|
||||
|
||||
The package embeds assets from:
|
||||
|
||||
- `internal/reporttemplate/templates/*.md.tmpl`
|
||||
- `internal/reporttemplate/schemas/*.schema.json`
|
||||
|
||||
## 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 `tomorrow` and `hourly`. The implemented
|
||||
schema IDs are also `tomorrow` and `hourly`, backed by
|
||||
`tomorrow.generated_text.schema.json` and `hourly.generated_text.schema.json`.
|
||||
|
||||
## 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 decides which template and schema IDs apply to a report through
|
||||
`internal/report` definitions.
|
||||
|
||||
## Template Contracts
|
||||
|
||||
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
|
||||
|
||||
Tomorrow additionally exposes forecast-date labels, ordered daypart forecast
|
||||
rows, daily/daypart summaries, tomorrow 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.
|
||||
|
||||
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`
|
||||
|
||||
Tomorrow requires `summary` and a nonempty `forecast_discussion` array. Both
|
||||
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,35 @@ 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 Today compares with prior Daily Today 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 +141,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
|
||||
|
||||
|
||||
@@ -11,19 +11,29 @@ Generation commands:
|
||||
```text
|
||||
weatherreporter generate daily --date 2026-05-29
|
||||
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 JSON module snapshot, build a 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 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 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:
|
||||
|
||||
@@ -33,7 +43,7 @@ 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
|
||||
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
|
||||
@@ -62,6 +72,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
|
||||
@@ -76,6 +102,12 @@ workspace/
|
||||
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
|
||||
@@ -89,6 +121,12 @@ workspace/
|
||||
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
|
||||
@@ -102,6 +140,12 @@ workspace/
|
||||
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
|
||||
@@ -112,6 +156,10 @@ workspace/
|
||||
<run_id>.md
|
||||
weekend/
|
||||
<run_id>.md
|
||||
hourly/
|
||||
<run_id>.md
|
||||
tomorrow/
|
||||
<run_id>.md
|
||||
storm/
|
||||
<run_id>.md
|
||||
```
|
||||
@@ -136,6 +184,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
|
||||
@@ -165,8 +215,9 @@ 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`.
|
||||
|
||||
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.
|
||||
@@ -215,11 +266,12 @@ source provenance and warnings without dumping full weather payloads.
|
||||
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
|
||||
Daily Today compares with prior Daily Today 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. Storm Report leaves
|
||||
Recent Changes empty.
|
||||
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 +284,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.
|
||||
- Hourly generated-text failures 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
|
||||
|
||||
@@ -41,7 +41,8 @@ add only modules backed by implemented upstream facts and clear report needs.
|
||||
|
||||
Possible future report types:
|
||||
|
||||
- `next_6_hours` or another short-fuse planning report;
|
||||
- another 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
|
||||
@@ -84,7 +85,6 @@ These enhancements are not current behavior:
|
||||
|
||||
- `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;
|
||||
|
||||
441
docs/roadmap/implementation.md
Normal file
441
docs/roadmap/implementation.md
Normal file
@@ -0,0 +1,441 @@
|
||||
# Tomorrow Report Implementation Roadmap
|
||||
|
||||
## Purpose
|
||||
|
||||
This roadmap defines the staged implementation plan for
|
||||
`docs/roadmap/tomorrow.md`. It is written for an LLM coding agent that will
|
||||
implement each stage in order. The conceptual target state, user intent, and
|
||||
locked product decisions live in `docs/roadmap/tomorrow.md`; this file defines
|
||||
the concrete implementation sequence.
|
||||
|
||||
This is future-work planning. Do not treat the behavior described here as
|
||||
implemented until the corresponding code, tests, examples, and non-roadmap docs
|
||||
are updated.
|
||||
|
||||
## Implementation Guardrails
|
||||
|
||||
- Preserve public CLI syntax: `weatherreporter generate tomorrow`.
|
||||
- Make a clean pre-release break from report ID `daily_tomorrow`; do not add
|
||||
compatibility aliases.
|
||||
- Keep report identity, prompt IDs, template IDs, artifact groups, output names,
|
||||
and comparison policy centralized in `internal/report`.
|
||||
- Reuse the existing `generated_text_template` workflow implemented for Hourly.
|
||||
- Keep Scriptorium details behind the existing adapter boundary.
|
||||
- Keep Go responsible for deterministic facts, valid periods, module snapshots,
|
||||
structured generated-text validation, and final Markdown template rendering.
|
||||
- Keep templates responsible for wording and layout.
|
||||
- Keep generated JSON schemas and Markdown templates as embedded asset files,
|
||||
not inline Go strings.
|
||||
- Preserve current managed artifact behavior except where report identity
|
||||
intentionally changes from `daily_tomorrow` to `tomorrow`.
|
||||
|
||||
## Stage 1: Report Identity Split
|
||||
|
||||
Goal: make Tomorrow an independent report ID and artifact identity while
|
||||
preserving the public `generate tomorrow` command.
|
||||
|
||||
Implementation:
|
||||
|
||||
- Replace `report.DailyTomorrow` with `report.Tomorrow` whose value is
|
||||
`"tomorrow"`.
|
||||
- Rename report-definition helpers and resolvers around the new identity:
|
||||
`dailyTomorrowDefinition` to `tomorrowDefinition`,
|
||||
`dailyTomorrowModules` to `tomorrowModules`, and
|
||||
`resolveDailyTomorrow` to `resolveTomorrow`.
|
||||
- Update `report.DefaultRegistry`, `Registry.All`, batch resolution, and tests
|
||||
so the built-in report order contains `tomorrow` instead of
|
||||
`daily_tomorrow`.
|
||||
- Update `internal/app` so `app.ReportTomorrow` resolves to `report.Tomorrow`.
|
||||
- Keep the valid period as the next local civil day.
|
||||
- Keep evening batch behavior: `run evening` should still generate the Tomorrow
|
||||
report.
|
||||
- Change Tomorrow definition identity fields to:
|
||||
- `ID: report.Tomorrow`
|
||||
- `Name: "Tomorrow Report"`
|
||||
- `ArtifactGroup: "tomorrow"`
|
||||
- `BatchOutputName: "tomorrow.md"`
|
||||
- `Generated: true`
|
||||
- `CompatiblePriorIDs: []report.ID{report.Tomorrow}`
|
||||
- `ComparisonStrategy: report.CompareSameValidDate`
|
||||
- Keep the current Tomorrow module composition initially, renamed to
|
||||
`tomorrowModules`, so the prompt data package continues to include the
|
||||
daypart, precipitation, alert, SPC, AFD, weather story, tomorrow planning,
|
||||
and hourly facts already available.
|
||||
- Update all code and tests that assert `daily_tomorrow` paths, metadata,
|
||||
RunIDs, prior compatibility, or registry IDs.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- No production-code references to `report.DailyTomorrow` or report ID
|
||||
`daily_tomorrow` remain.
|
||||
- `weatherreporter generate tomorrow` still parses and resolves successfully.
|
||||
- Evening batch contains `tomorrow`.
|
||||
- Managed workspace artifact paths and distributor identity values now use
|
||||
`tomorrow`.
|
||||
|
||||
Suggested validation:
|
||||
|
||||
```bash
|
||||
go test ./internal/report ./internal/app ./internal/cli ./internal/state
|
||||
```
|
||||
|
||||
This stage is suitable for one implementation prompt.
|
||||
|
||||
## Stage 2: Tomorrow GeneratedText Contract
|
||||
|
||||
Goal: add structured Tomorrow LLM output validation and schema assets.
|
||||
|
||||
Implementation:
|
||||
|
||||
- Add `internal/generatedtext/tomorrow.go` with:
|
||||
|
||||
```go
|
||||
type Tomorrow struct {
|
||||
Summary string `json:"summary"`
|
||||
ForecastDiscussion []string `json:"forecast_discussion"`
|
||||
PrecipitationTiming string `json:"precipitation_timing,omitempty"`
|
||||
Confidence string `json:"confidence,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
- Add `generatedtext.ValidateTomorrow`.
|
||||
- Use `json.Decoder.DisallowUnknownFields`.
|
||||
- Reject multiple JSON values.
|
||||
- Trim `Summary`, `PrecipitationTiming`, `Confidence`, and each
|
||||
`ForecastDiscussion` paragraph.
|
||||
- Drop blank discussion paragraphs after trimming, then require at least one
|
||||
remaining paragraph.
|
||||
- Reject blank `Summary`.
|
||||
- Return normalized JSON with the same public field names.
|
||||
- Add `internal/reporttemplate/schemas/tomorrow.generated_text.schema.json`.
|
||||
The schema should:
|
||||
- require `summary`;
|
||||
- require `forecast_discussion`;
|
||||
- define `forecast_discussion` as an array of strings with at least one item;
|
||||
- allow optional `precipitation_timing` and `confidence`;
|
||||
- reject additional properties.
|
||||
- Add `internal/reporttemplate/prompts/tomorrow.generated_text.md` as the
|
||||
maintained Scriptorium prompt source asset. This file is a source contract for
|
||||
out-of-band Scriptorium prompt registration; weatherreporter does not need to
|
||||
load prompt Markdown at runtime.
|
||||
- Add Tomorrow to `internal/reporttemplate` schema lookup.
|
||||
- Update app generated-text validation dispatch so it can return either
|
||||
`generatedtext.Hourly` or `generatedtext.Tomorrow`. Prefer a small generic
|
||||
dispatch shape such as:
|
||||
|
||||
```go
|
||||
func validateGeneratedText(def report.Definition, data []byte) (any, []byte, error)
|
||||
```
|
||||
|
||||
Then type-check the returned value in render-context dispatch.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Tomorrow generated text rejects unknown fields, missing required fields,
|
||||
blank summary, and no usable forecast discussion paragraphs.
|
||||
- Optional `precipitation_timing` and `confidence` are trimmed and omitted from
|
||||
normalized JSON when empty.
|
||||
- Hourly generated text behavior is unchanged.
|
||||
|
||||
Suggested validation:
|
||||
|
||||
```bash
|
||||
go test ./internal/generatedtext ./internal/reporttemplate ./internal/app
|
||||
```
|
||||
|
||||
This stage is suitable for one implementation prompt.
|
||||
|
||||
## Stage 3: Daypart Presentation Fields
|
||||
|
||||
Goal: add only the daypart fields needed to keep the Tomorrow template
|
||||
composable without moving prose construction into Go.
|
||||
|
||||
Implementation:
|
||||
|
||||
- Extend the `derived_daypart_summaries` module output with presentation
|
||||
helpers that are facts, not complete sentences:
|
||||
- `display_name`, for example `Morning`;
|
||||
- `dominant_condition_lower`, for inline template text;
|
||||
- `temperature_phrase_f`, such as `low 70s`, `upper 60s`, or
|
||||
`upper 60s to mid-70s`;
|
||||
- `mention_precipitation`, true when max PoP is at or above the existing
|
||||
hourly forecast precipitation mention threshold, currently 20%;
|
||||
- `max_pop_time_label`, using friendly local hour format such as `8:00 AM`
|
||||
when max PoP time exists.
|
||||
- Reuse the existing hourly forecast precipitation mention threshold constant
|
||||
rather than adding a user config field in this change.
|
||||
- Keep existing structured numeric fields in the module output.
|
||||
- Do not add a prewritten `daypart_line` string.
|
||||
- Do not add wind prose in this stage unless tests show the initial template
|
||||
needs a specific structured wind fact. If wind wording is needed, add a
|
||||
small structured wind field, not a full sentence.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Daypart module output has enough structured fields for a readable Tomorrow
|
||||
template.
|
||||
- The output remains useful for YAML prompt packages.
|
||||
- No generated prose sentence is hard-coded into the module.
|
||||
|
||||
Suggested validation:
|
||||
|
||||
```bash
|
||||
go test ./internal/briefing ./internal/promptinput
|
||||
```
|
||||
|
||||
This stage is suitable for one implementation prompt.
|
||||
|
||||
## Stage 4: Tomorrow Render Context And Template
|
||||
|
||||
Goal: render Tomorrow Markdown from structured generated text, module outputs,
|
||||
and report metadata.
|
||||
|
||||
Implementation:
|
||||
|
||||
- Add a dedicated Tomorrow render context under `internal/generatedtext`, for
|
||||
example:
|
||||
|
||||
```go
|
||||
type TomorrowRenderContext struct {
|
||||
Report TomorrowReportContext
|
||||
GeneratedText Tomorrow
|
||||
Modules TomorrowTemplateModules
|
||||
Collected facts.CollectedFacts
|
||||
Derived facts.DerivedFacts
|
||||
}
|
||||
```
|
||||
|
||||
- Add `TomorrowReportContext` with:
|
||||
- `Title`, for example `Sunday's Weather`;
|
||||
- `ForecastDate`;
|
||||
- `ForecastDateLabel`, for example `Sunday, June 15, 2026`;
|
||||
- `ForecastDayName`, for example `Sunday`;
|
||||
- `GeneratedAt`;
|
||||
- `GeneratedAtLabel`, for example
|
||||
`Saturday, June 14, 2026 at 9:14 AM`;
|
||||
- `ValidPeriod`;
|
||||
- `Timezone`.
|
||||
- Construct `Title` in Go, not in the template.
|
||||
- Add `TomorrowTemplateModules` with pointer fields for the module outputs used
|
||||
by the template:
|
||||
- `Metadata`;
|
||||
- `DerivedDailySummary`;
|
||||
- `DerivedDaypartSummaries`;
|
||||
- `PrecipTiming`;
|
||||
- `AlertDigest`;
|
||||
- `SPCConvectiveOutlooks`;
|
||||
- `AreaForecastDiscussion`;
|
||||
- `SPCConvectiveDiscussion`;
|
||||
- `WeatherStory`;
|
||||
- `TomorrowPlanning`.
|
||||
- Add an ordered daypart slice for the template, derived from the configured
|
||||
daypart order rather than ranging directly over a map. This can live in
|
||||
`TomorrowTemplateModules`, for example `Dayparts []TomorrowDaypartContext`.
|
||||
- Add `BuildTomorrowRenderContext`.
|
||||
- Add `internal/reporttemplate/templates/tomorrow.md.tmpl`.
|
||||
- Add Tomorrow to `internal/reporttemplate` template lookup.
|
||||
- Template shape:
|
||||
- title;
|
||||
- forecast date;
|
||||
- generated timestamp;
|
||||
- `GeneratedText.Summary`;
|
||||
- deterministic `Daypart Forecast` bullets in configured order;
|
||||
- conditional `Precipitation Timing` only when precipitation windows exist;
|
||||
- deterministic precipitation window bullets before optional
|
||||
`GeneratedText.PrecipitationTiming`;
|
||||
- `Forecast Discussion` with one paragraph per
|
||||
`GeneratedText.ForecastDiscussion` item.
|
||||
- Keep current conditions and hourly forecast available through the data
|
||||
package and render context, but do not render them in the initial Tomorrow
|
||||
template unless the template explicitly uses them.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- The template renders without map-order nondeterminism.
|
||||
- Precipitation Timing is omitted when no precipitation windows exist.
|
||||
- Forecast Discussion supports multiple paragraphs.
|
||||
- Missing optional modules produce clean omission or fallback behavior, not
|
||||
template execution errors.
|
||||
|
||||
Suggested validation:
|
||||
|
||||
```bash
|
||||
go test ./internal/generatedtext ./internal/reporttemplate ./internal/briefing
|
||||
```
|
||||
|
||||
This stage is suitable for one implementation prompt.
|
||||
|
||||
## Stage 5: App Workflow Integration
|
||||
|
||||
Goal: route Tomorrow through the generated-text-template workflow end to end.
|
||||
|
||||
Implementation:
|
||||
|
||||
- Change Tomorrow report definition to:
|
||||
- `PromptID: "weather.tomorrow_generated_text"`
|
||||
- `GenerationMode: report.GenerationModeGeneratedTextTemplate`
|
||||
- `TemplateID: "tomorrow"`
|
||||
- `GeneratedTextSchemaID: "tomorrow"`
|
||||
- Update `internal/app.buildRenderContext` dispatch:
|
||||
- hourly template requires `generatedtext.Hourly`;
|
||||
- tomorrow template requires `generatedtext.Tomorrow`;
|
||||
- unsupported type/template combinations return actionable errors.
|
||||
- Ensure Scriptorium `run` writes raw generated text to a `.json` path for
|
||||
Tomorrow, matching the existing generated-text-template workflow.
|
||||
- Ensure normalized generated text, render context JSON, generated Markdown,
|
||||
metadata, and final report artifacts are persisted through existing state
|
||||
helpers.
|
||||
- Ensure app errors include report ID `tomorrow` and RunID context.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- `weatherreporter generate tomorrow` no longer invokes Scriptorium for full
|
||||
Markdown.
|
||||
- The workflow validates Scriptorium JSON output, builds a Tomorrow render
|
||||
context, and renders Markdown locally.
|
||||
- Hourly generated-text-template behavior remains unchanged.
|
||||
|
||||
Suggested validation:
|
||||
|
||||
```bash
|
||||
go test ./internal/app ./internal/state ./internal/adapters/scriptorium
|
||||
```
|
||||
|
||||
This stage is suitable for one implementation prompt.
|
||||
|
||||
## Stage 6: CLI, State, Distributor, And Batch Behavior
|
||||
|
||||
Goal: update cross-package behavior affected by the report ID clean break.
|
||||
|
||||
Implementation:
|
||||
|
||||
- Update CLI tests and command-output expectations for `generate tomorrow`.
|
||||
- Update state/path tests so managed artifacts, snapshots, data packages,
|
||||
generated-text artifacts, render contexts, and report files use artifact group
|
||||
`tomorrow`.
|
||||
- Update batch tests:
|
||||
- evening batch emits report ID `tomorrow`;
|
||||
- batch output copy name remains `tomorrow.md`;
|
||||
- partial-failure behavior is unchanged.
|
||||
- Update Recent Changes tests:
|
||||
- Tomorrow compares only against prior `tomorrow` snapshots;
|
||||
- Daily Today no longer treats Tomorrow as a compatible prior unless the
|
||||
implementation explicitly keeps that relationship for Daily Today only.
|
||||
- Update distributor notification tests so rendered template variables use:
|
||||
- `report_id=tomorrow`;
|
||||
- `artifact_group=tomorrow`;
|
||||
- `batch_output_name=tomorrow.md`.
|
||||
- Update config tests so report module overrides use `reports.tomorrow`.
|
||||
Do not accept `reports.daily_tomorrow` unless a future explicit
|
||||
compatibility decision reverses the clean break.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Public CLI syntax is stable.
|
||||
- Persisted artifacts and distributor request context use the new report ID.
|
||||
- Batch behavior is unchanged except for the new ID.
|
||||
- No tests rely on `daily_tomorrow`.
|
||||
|
||||
Suggested validation:
|
||||
|
||||
```bash
|
||||
go test ./internal/cli ./internal/app ./internal/state ./internal/config ./internal/report
|
||||
```
|
||||
|
||||
This stage is suitable for one implementation prompt.
|
||||
|
||||
## Stage 7: Documentation And Examples
|
||||
|
||||
Goal: align implemented docs and maintained examples after the code change.
|
||||
|
||||
Implementation:
|
||||
|
||||
- Update non-roadmap docs only after the behavior is implemented.
|
||||
- Inspect and update:
|
||||
- `docs/cli.md`;
|
||||
- `docs/config.md`;
|
||||
- `docs/operations.md`;
|
||||
- `docs/troubleshooting.md`, if new failure modes are introduced;
|
||||
- `docs/internal/report-registry.md`;
|
||||
- `docs/internal/generatedtext.md`;
|
||||
- `docs/internal/reporttemplate.md`;
|
||||
- `docs/internal/state.md`;
|
||||
- `docs/templates.md`;
|
||||
- relevant Scriptorium and distributor integration docs only if their
|
||||
weatherreporter-facing contract changed.
|
||||
- Update `examples/config.yml` if it references Tomorrow modules or
|
||||
`reports.daily_tomorrow`.
|
||||
- Keep future Today/Daily split language only under `docs/roadmap/`.
|
||||
- Do not document unimplemented Today or generic Daily products as current
|
||||
behavior.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Non-roadmap docs describe implemented behavior only.
|
||||
- Docs use report ID `tomorrow`.
|
||||
- Examples load under current config validation.
|
||||
- No stale user-facing references to `daily_tomorrow` remain outside historical
|
||||
roadmap context.
|
||||
|
||||
Suggested validation:
|
||||
|
||||
```bash
|
||||
rg -n "daily_tomorrow|Daily Tomorrow|weather.daily_report" README.md docs examples internal
|
||||
git diff --check
|
||||
```
|
||||
|
||||
This stage is suitable for one implementation prompt.
|
||||
|
||||
## Stage 8: Final Validation
|
||||
|
||||
Goal: verify the completed cutover as a coherent behavior change.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/report ./internal/generatedtext ./internal/reporttemplate ./internal/briefing ./internal/app ./internal/cli ./internal/state
|
||||
go test ./...
|
||||
go run ./cmd/weatherreporter --help
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Also run targeted stale-symbol checks:
|
||||
|
||||
```bash
|
||||
rg -n "DailyTomorrow|dailyTomorrow|daily_tomorrow" internal docs examples
|
||||
rg -n "weather.tomorrow_generated_text|TemplateID:.*tomorrow|GeneratedTextSchemaID:.*tomorrow" internal docs
|
||||
```
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Full test suite passes.
|
||||
- Help output still shows `generate tomorrow`.
|
||||
- No production-code stale `daily_tomorrow` symbols remain.
|
||||
- Generated-text-template artifacts for Tomorrow are persisted in the same
|
||||
categories as Hourly.
|
||||
- Existing Hourly behavior still passes tests.
|
||||
|
||||
This stage is suitable for one implementation prompt.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None block implementation. The required decisions are locked by
|
||||
`docs/roadmap/tomorrow.md` and this implementation plan:
|
||||
|
||||
- Tomorrow uses report ID `tomorrow`.
|
||||
- Tomorrow uses prompt ID `weather.tomorrow_generated_text`.
|
||||
- Tomorrow uses template ID and generated-text schema ID `tomorrow`.
|
||||
- Tomorrow forecast discussion is an array of paragraph strings.
|
||||
- `daily_tomorrow` compatibility aliases are intentionally not preserved.
|
||||
|
||||
## Global Validation Checklist
|
||||
|
||||
- `go test ./...`
|
||||
- `go run ./cmd/weatherreporter --help`
|
||||
- `git diff --check`
|
||||
- `rg -n "DailyTomorrow|dailyTomorrow|daily_tomorrow" internal docs examples`
|
||||
- Confirm `weatherreporter generate tomorrow` uses structured JSON from
|
||||
Scriptorium and renders final Markdown locally.
|
||||
- Confirm distributor notification context uses `report_id=tomorrow` and
|
||||
`artifact_group=tomorrow`.
|
||||
- Confirm examples contain no unimplemented fields and no secrets.
|
||||
276
docs/roadmap/tomorrow.md
Normal file
276
docs/roadmap/tomorrow.md
Normal file
@@ -0,0 +1,276 @@
|
||||
# Tomorrow Report Roadmap
|
||||
|
||||
## Purpose
|
||||
|
||||
This roadmap defines the target state for making Tomorrow an independent
|
||||
generated-text-template report. The feature is not implemented yet, so this
|
||||
document lives under `docs/roadmap/`.
|
||||
|
||||
## Intent
|
||||
|
||||
Tomorrow should become its own report product, not a variant of the Daily
|
||||
Report. The current CLI command `weatherreporter generate tomorrow` should
|
||||
remain, but the internal report ID, prompt ID, template, schema, workspace
|
||||
paths, and distributor identity should use `tomorrow`.
|
||||
|
||||
The report should combine deterministic daypart and precipitation facts with
|
||||
LLM prose for the high-level summary, optional precipitation context, and
|
||||
forecast discussion. The resulting Markdown should be predictable and
|
||||
template-driven, similar to the implemented Hourly Report.
|
||||
|
||||
Longer term, Today, Tomorrow, and Daily may all become separate report products
|
||||
with different prompts, templates, and deterministic sections. This roadmap
|
||||
starts that split with Tomorrow.
|
||||
|
||||
## Target Report Shape
|
||||
|
||||
Example structure:
|
||||
|
||||
```markdown
|
||||
# Sunday's Weather
|
||||
|
||||
**Forecast Date:** Sunday, June 15, 2026
|
||||
**Generated:** Saturday, June 14, 2026 at 9:14 AM
|
||||
|
||||
<GeneratedText summary>
|
||||
|
||||
## Daypart Forecast
|
||||
|
||||
- **Overnight:** <deterministic daypart line>
|
||||
- **Morning:** <deterministic daypart line>
|
||||
- **Midday:** <deterministic daypart line>
|
||||
- **Afternoon:** <deterministic daypart line>
|
||||
- **Evening:** <deterministic daypart line>
|
||||
|
||||
## Precipitation Timing
|
||||
|
||||
- **1:00 AM** to **5:00 AM**: Precipitation is expected during this period. The peak precipitation chance is 59% at 2:00 AM.
|
||||
- <optional GeneratedText precipitation_timing>
|
||||
|
||||
## Forecast Discussion
|
||||
|
||||
<GeneratedText forecast_discussion paragraphs>
|
||||
```
|
||||
|
||||
`Precipitation Timing` should render only when at least one precipitation
|
||||
window exists for the valid period. The threshold for precipitation windows
|
||||
remains the existing precipitation-window threshold, currently 40%.
|
||||
|
||||
## Locked Decisions
|
||||
|
||||
- Replace report ID `daily_tomorrow` with `tomorrow`.
|
||||
- Do not preserve compatibility aliases for `daily_tomorrow`; this is a
|
||||
pre-release clean break.
|
||||
- Keep public CLI syntax: `weatherreporter generate tomorrow`.
|
||||
- Use generated-text-template generation for Tomorrow, not full Markdown
|
||||
generation by Scriptorium.
|
||||
- Use a dedicated Scriptorium prompt ID, template ID, and schema ID:
|
||||
- prompt ID: `weather.tomorrow_generated_text`
|
||||
- template ID: `tomorrow`
|
||||
- generated-text schema ID: `tomorrow`
|
||||
- Use `ArtifactGroup: "tomorrow"` and `BatchOutputName: "tomorrow.md"`.
|
||||
- Use `CompatiblePriorIDs: []report.ID{report.Tomorrow}`.
|
||||
- Keep valid-period behavior: Tomorrow covers the next local civil day.
|
||||
- Keep Morning/Evening batch behavior unless explicitly changed later; evening
|
||||
batch should still include Tomorrow.
|
||||
- Future Today/Daily split is out of scope for this roadmap.
|
||||
|
||||
## GeneratedText Contract
|
||||
|
||||
Add a Tomorrow GeneratedText schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": "string",
|
||||
"forecast_discussion": ["string"],
|
||||
"precipitation_timing": "string",
|
||||
"confidence": "string"
|
||||
}
|
||||
```
|
||||
|
||||
Required:
|
||||
|
||||
- `summary`
|
||||
- `forecast_discussion`
|
||||
|
||||
Optional:
|
||||
|
||||
- `precipitation_timing`
|
||||
- `confidence`
|
||||
|
||||
`forecast_discussion` should be an array of paragraph strings so Scriptorium
|
||||
can return multi-paragraph discussion without embedding paragraph delimiters in
|
||||
one string. Empty or whitespace-only discussion paragraphs should be rejected or
|
||||
trimmed out during validation; after trimming, at least one paragraph is
|
||||
required.
|
||||
|
||||
`confidence` may be validated and persisted but does not need to render in the
|
||||
initial template.
|
||||
|
||||
## Template Context
|
||||
|
||||
Add a dedicated Tomorrow render context rather than reusing Hourly context
|
||||
types.
|
||||
|
||||
Recommended top-level shape:
|
||||
|
||||
```go
|
||||
type TomorrowRenderContext struct {
|
||||
Report TomorrowReportContext
|
||||
GeneratedText Tomorrow
|
||||
Modules TomorrowTemplateModules
|
||||
Collected facts.CollectedFacts
|
||||
Derived facts.DerivedFacts
|
||||
}
|
||||
```
|
||||
|
||||
`TomorrowReportContext` should include:
|
||||
|
||||
- `Title`: for example `Sunday's Weather`
|
||||
- `ForecastDate`: canonical local forecast date if useful
|
||||
- `ForecastDateLabel`: for example `Sunday, June 15, 2026`
|
||||
- `ForecastDayName`: for example `Sunday`
|
||||
- `GeneratedAt`
|
||||
- `GeneratedAtLabel`: for example `Saturday, June 14, 2026 at 9:14 AM`
|
||||
- `ValidPeriod`
|
||||
- `Timezone`
|
||||
|
||||
Do not derive the possessive title in the template. Go should provide `Title`
|
||||
so wording is consistent and easy to test.
|
||||
|
||||
`TomorrowTemplateModules` should expose the module outputs needed by the
|
||||
template:
|
||||
|
||||
- `Metadata`
|
||||
- `DerivedDailySummary`
|
||||
- `DerivedDaypartSummaries`
|
||||
- `PrecipTiming`
|
||||
- `AlertDigest`
|
||||
- `SPCConvectiveOutlooks`
|
||||
- `AreaForecastDiscussion`
|
||||
- `SPCConvectiveDiscussion`
|
||||
- `WeatherStory`
|
||||
- `TomorrowPlanning`, if still useful
|
||||
|
||||
Current conditions and hourly forecast can remain in the module snapshot and
|
||||
data package if useful for Scriptorium, but they do not need to render in the
|
||||
initial Tomorrow template unless a later design calls for them.
|
||||
|
||||
## Daypart Forecast
|
||||
|
||||
The Daypart Forecast should be deterministic but composable. Avoid adding a
|
||||
single prewritten Go `DaypartLine` string that makes template wording rigid.
|
||||
|
||||
Add presentation-friendly fields to `derived_daypart_summaries` only where they
|
||||
avoid awkward template logic. Likely useful fields:
|
||||
|
||||
- display name, such as `Overnight` or `Morning`;
|
||||
- lower-case dominant condition text for inline sentences;
|
||||
- rounded temperature range phrase if available;
|
||||
- precipitation mention flag using the existing hourly line mention threshold
|
||||
concept, currently 20%;
|
||||
- max precipitation probability and friendly max time;
|
||||
- optional wind phrase or wind range only if deterministic wind wording is
|
||||
clearly needed.
|
||||
|
||||
The initial implementation may keep daypart bullet wording simple. It should be
|
||||
easy to revise the template text without editing Go unless new facts are
|
||||
needed.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
1. Report identity split
|
||||
- Rename `report.DailyTomorrow` to `report.Tomorrow` with ID `tomorrow`.
|
||||
- Update registry order, resolver references, CLI mapping, batch selection,
|
||||
state metadata expectations, docs, and tests.
|
||||
- Preserve `weatherreporter generate tomorrow`.
|
||||
- Accept that workspace paths, RunIDs, distributor bundle IDs, and report
|
||||
URLs change from `daily_tomorrow` to `tomorrow`.
|
||||
|
||||
2. GeneratedText contract
|
||||
- Add `generatedtext.Tomorrow`, validation, normalized JSON output, and
|
||||
tests.
|
||||
- Add `tomorrow.generated_text.schema.json`.
|
||||
- Add `internal/reporttemplate/prompts/tomorrow.generated_text.md` as the
|
||||
maintained prompt source asset.
|
||||
- Update app validation dispatch to use the Tomorrow schema.
|
||||
|
||||
3. Template and render context
|
||||
- Add `internal/reporttemplate/templates/tomorrow.md.tmpl`.
|
||||
- Add Tomorrow template/schema lookup entries.
|
||||
- Add `BuildTomorrowRenderContext`.
|
||||
- Update app render-context dispatch for template ID `tomorrow`.
|
||||
- Persist render context in the existing generated-text-template workflow.
|
||||
|
||||
4. Module presentation fields
|
||||
- Add only the daypart presentation fields needed by the template.
|
||||
- Reuse the existing precipitation window hour-label fields.
|
||||
- Keep deterministic weather derivation in Go and wording/layout in the
|
||||
template.
|
||||
|
||||
5. Report definition conversion
|
||||
- Change Tomorrow report definition to:
|
||||
- `PromptID: "weather.tomorrow_generated_text"`
|
||||
- `GenerationMode: generated_text_template`
|
||||
- `TemplateID: "tomorrow"`
|
||||
- `GeneratedTextSchemaID: "tomorrow"`
|
||||
- `ArtifactGroup: "tomorrow"`
|
||||
- `BatchOutputName: "tomorrow.md"`
|
||||
- compatible prior IDs containing only `tomorrow`
|
||||
- Review module composition and keep only modules used by the prompt,
|
||||
template, or future inspection value.
|
||||
|
||||
6. Documentation and examples
|
||||
- After implementation, update non-roadmap docs for implemented behavior:
|
||||
`docs/cli.md`, `docs/operations.md`, `docs/internal/report-registry.md`,
|
||||
`docs/internal/generatedtext.md`, `docs/internal/reporttemplate.md`, and
|
||||
`docs/templates.md`.
|
||||
- Update examples that refer to `reports.tomorrow` or report module
|
||||
overrides if the config key changes.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Report tests:
|
||||
- registry contains `tomorrow`, not `daily_tomorrow`;
|
||||
- `generate tomorrow` resolves report ID `tomorrow`;
|
||||
- valid period remains next local civil day;
|
||||
- evening batch still includes Tomorrow;
|
||||
- RunID and artifact paths use `tomorrow`.
|
||||
- GeneratedText tests:
|
||||
- `summary` and non-empty `forecast_discussion` are required;
|
||||
- `forecast_discussion` trims paragraph strings and rejects/omits blanks;
|
||||
- optional `precipitation_timing` and `confidence` normalize correctly;
|
||||
- unknown fields are rejected.
|
||||
- Template tests:
|
||||
- title renders as `<weekday>'s Weather`;
|
||||
- forecast date and generated labels render;
|
||||
- daypart bullets render in configured daypart order;
|
||||
- precipitation section is omitted when no precipitation windows exist;
|
||||
- precipitation section includes deterministic windows and optional LLM text
|
||||
when windows exist;
|
||||
- forecast discussion renders multiple paragraphs.
|
||||
- App/CLI workflow tests:
|
||||
- `weatherreporter generate tomorrow` uses structured Scriptorium output and
|
||||
the template renderer;
|
||||
- raw generated text, validated generated text, render context, report, and
|
||||
metadata artifacts are persisted;
|
||||
- optional `--out` behavior remains unchanged;
|
||||
- distributor notification uses report ID/artifact group `tomorrow`.
|
||||
|
||||
Validation commands:
|
||||
|
||||
```bash
|
||||
go test ./internal/report ./internal/generatedtext ./internal/reporttemplate ./internal/briefing ./internal/app ./internal/cli ./internal/state
|
||||
go test ./...
|
||||
go run ./cmd/weatherreporter --help
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Open Questions
|
||||
|
||||
None block implementation. Recommended defaults are:
|
||||
|
||||
- make `forecast_discussion` an array of strings for Tomorrow;
|
||||
- keep Hourly GeneratedText unchanged for now;
|
||||
- do not add Daily Today or generic Daily report splits in this change;
|
||||
- do not preserve `daily_tomorrow` compatibility aliases.
|
||||
301
docs/templates.md
Normal file
301
docs/templates.md
Normal file
@@ -0,0 +1,301 @@
|
||||
# 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/tomorrow.md.tmpl`
|
||||
- `internal/reporttemplate/templates/hourly.md.tmpl`
|
||||
|
||||
Templates are rendered from structured contexts such as `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.
|
||||
|
||||
## 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.
|
||||
|
||||
| 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. |
|
||||
|
||||
### 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 currently exercise the hourly template 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: 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: 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,22 @@ reports:
|
||||
- key_messages
|
||||
- short_term
|
||||
- long_term
|
||||
- spc_convective_discussion
|
||||
- weather_story
|
||||
- outdoor_windows
|
||||
- hourly_forecast
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -264,6 +272,29 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchSPCConvectiveOutlooks(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, sourceSPCConvectiveOutlooks, convectiveOutlooksEndpoint, queryOptions{timezone: true, omitUnits: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "SPC convective outlook data is missing", false)
|
||||
}
|
||||
var run weatherdata.ConvectiveOutlookRun
|
||||
if err := decodeSource(raw, &run); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
}
|
||||
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) handleMissing(source *weatherdata.Source, message string, required bool) error {
|
||||
source.Missing = true
|
||||
if required {
|
||||
|
||||
@@ -55,8 +55,17 @@ 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))
|
||||
@@ -70,6 +79,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 +104,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 +145,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 +226,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 +453,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,9 +17,11 @@ 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"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/reporttemplate"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
@@ -30,6 +32,7 @@ type ReportKind string
|
||||
const (
|
||||
ReportDaily ReportKind = "daily"
|
||||
ReportTomorrow ReportKind = "tomorrow"
|
||||
ReportHourly ReportKind = "hourly"
|
||||
ReportThreeDay ReportKind = "three-day"
|
||||
ReportWeekend ReportKind = "weekend"
|
||||
ReportStorm ReportKind = "storm"
|
||||
@@ -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 {
|
||||
@@ -385,7 +394,9 @@ func reportIDForCommand(kind ReportKind) (report.ID, error) {
|
||||
case ReportDaily:
|
||||
return report.DailyToday, nil
|
||||
case ReportTomorrow:
|
||||
return report.DailyTomorrow, nil
|
||||
return report.Tomorrow, nil
|
||||
case ReportHourly:
|
||||
return report.Hourly, nil
|
||||
case ReportThreeDay:
|
||||
return report.ThreeDay, nil
|
||||
case ReportWeekend:
|
||||
@@ -482,11 +493,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 +546,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
|
||||
@@ -592,6 +634,141 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
||||
}, 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) {
|
||||
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 := validateGeneratedText(req.Resolved.Definition, 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 := buildRenderContext(req.Resolved.Definition, req.briefingMetadata, req.moduleSnapshot, req.reportFacts, 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 := reporttemplate.Render(req.Resolved.Definition.TemplateID, 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
|
||||
}
|
||||
req.metadata.RenderedReportPath = reportPath
|
||||
metadataPath, err := req.store.SaveMetadata(ctx, req.metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outputPath := reportPath
|
||||
if req.OutputPath != "" {
|
||||
outputPath = req.OutputPath
|
||||
if req.OutputPath != reportPath {
|
||||
if err := fileutil.CopyFileAtomic(reportPath, req.OutputPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
notification, notificationPath, notificationErr := notifyReport(ctx, req.Config, req.Resolved, reportPath, req.metadata, req.Notifier, req.store)
|
||||
if notificationPath != "" {
|
||||
req.metadata.NotificationPath = notificationPath
|
||||
metadataPath, err = req.store.SaveMetadata(ctx, req.metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if notificationErr != nil {
|
||||
return nil, notificationErr
|
||||
}
|
||||
|
||||
return &ReportResult{
|
||||
ModuleSnapshot: req.moduleSnapshot,
|
||||
ModuleSnapshotPath: req.moduleSnapshotPath,
|
||||
DataPackage: req.dataPackage,
|
||||
DataPackagePath: req.dataPackagePath,
|
||||
PreflightPath: req.preflightPath,
|
||||
ReportPath: reportPath,
|
||||
OutputPath: outputPath,
|
||||
NotificationPath: notificationPath,
|
||||
Metadata: req.metadata,
|
||||
MetadataPath: metadataPath,
|
||||
PriorSnapshot: req.priorSnapshot,
|
||||
RecentChanges: req.recentChanges,
|
||||
RenderResult: req.renderResult,
|
||||
StructuredRunResult: structuredResult,
|
||||
GeneratedTextRawPath: req.paths.GeneratedTextRaw,
|
||||
GeneratedTextResultPath: generatedTextResultPath,
|
||||
GeneratedTextPath: generatedTextPath,
|
||||
RenderContextPath: renderContextPath,
|
||||
Notification: notification,
|
||||
}, 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 +1088,7 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.
|
||||
PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes,
|
||||
}
|
||||
switch reportID {
|
||||
case report.DailyToday, report.DailyTomorrow:
|
||||
case report.DailyToday, report.Tomorrow:
|
||||
return changes.CompareDaily(previous, current, thresholds)
|
||||
case report.ThreeDay:
|
||||
return changes.CompareThreeDay(previous, current, thresholds)
|
||||
@@ -935,3 +1112,40 @@ func preflightArtifact(result *scriptorium.RenderResult) state.PreflightArtifact
|
||||
ExitCode: result.ExitCode,
|
||||
}
|
||||
}
|
||||
|
||||
func validateGeneratedText(definition report.Definition, data []byte) (any, []byte, error) {
|
||||
switch definition.GeneratedTextSchemaID {
|
||||
case "hourly":
|
||||
return generatedtext.ValidateHourly(data)
|
||||
case "tomorrow":
|
||||
return generatedtext.ValidateTomorrow(data)
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("generated text schema %q is not supported for report %q", definition.GeneratedTextSchemaID, definition.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func buildRenderContext(definition report.Definition, metadata briefing.Metadata, snapshot module.Snapshot, reportFacts ReportFacts, generated any) (any, error) {
|
||||
switch definition.TemplateID {
|
||||
case "hourly":
|
||||
hourly, ok := generated.(generatedtext.Hourly)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("report template %q requires hourly generated text for report %q", definition.TemplateID, definition.ID)
|
||||
}
|
||||
return generatedtext.BuildHourlyRenderContext(metadata, snapshot, hourly, reportFacts.Collected, reportFacts.Derived)
|
||||
case "tomorrow":
|
||||
tomorrow, ok := generated.(generatedtext.Tomorrow)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("report template %q requires tomorrow generated text for report %q", definition.TemplateID, definition.ID)
|
||||
}
|
||||
return generatedtext.BuildTomorrowRenderContext(metadata, snapshot, tomorrow, reportFacts.Collected, reportFacts.Derived)
|
||||
default:
|
||||
return nil, fmt.Errorf("report template %q is not supported for report %q", definition.TemplateID, definition.ID)
|
||||
}
|
||||
}
|
||||
|
||||
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,29 @@ 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 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 +121,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 +152,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 +166,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 +174,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)
|
||||
@@ -194,18 +233,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)
|
||||
}
|
||||
@@ -285,13 +330,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,6 +365,34 @@ 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)
|
||||
@@ -327,10 +406,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
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
@@ -24,17 +30,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
|
||||
@@ -44,6 +52,7 @@ func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, err
|
||||
|
||||
func (v CurrentConditionsModule) isEmpty() bool {
|
||||
return v.ConditionText == "" &&
|
||||
v.ConditionTextLower == "" &&
|
||||
v.IsDay == nil &&
|
||||
v.TemperatureC == nil &&
|
||||
v.TemperatureF == nil &&
|
||||
@@ -54,5 +63,6 @@ func (v CurrentConditionsModule) isEmpty() bool {
|
||||
v.RelativeHumidityPercent == nil &&
|
||||
v.WindSpeedKmh == nil &&
|
||||
v.WindSpeedMph == nil &&
|
||||
v.WindDirection == ""
|
||||
v.WindDirection == "" &&
|
||||
v.WindDirectionText == ""
|
||||
}
|
||||
|
||||
@@ -2,32 +2,46 @@ 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"`
|
||||
}
|
||||
|
||||
func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
@@ -44,24 +58,37 @@ func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Outpu
|
||||
}
|
||||
|
||||
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 +97,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 {
|
||||
|
||||
@@ -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,6 +136,22 @@ 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)
|
||||
@@ -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,116 @@ 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 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 +313,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 {
|
||||
@@ -310,6 +438,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"`
|
||||
@@ -17,12 +21,14 @@ type HourlyForecastModule struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
@@ -44,6 +50,7 @@ type HourlyForecastPeriod struct {
|
||||
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"`
|
||||
@@ -72,15 +79,22 @@ func buildHourlyForecastModule(ctx ModuleContext, _ any) (*module.Output, error)
|
||||
}
|
||||
|
||||
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{
|
||||
StartTime: friendlyDateTimeLabel(period.StartTime, timezone),
|
||||
EndTime: friendlyDateTimeLabel(period.EndTime, timezone),
|
||||
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),
|
||||
@@ -102,6 +116,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 +128,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +162,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 +185,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 +249,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.DailyToday, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm}
|
||||
daypartReports := []report.ID{report.DailyToday, report.Tomorrow, report.ThreeDay, report.Weekend}
|
||||
return []ModuleDefinition{
|
||||
{
|
||||
ID: module.Metadata,
|
||||
@@ -272,7 +276,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.DailyToday, report.Tomorrow},
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildNarrativeForecastModule,
|
||||
},
|
||||
@@ -282,7 +286,7 @@ 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.DailyToday, report.Tomorrow, report.Hourly},
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildHourlyForecastModule,
|
||||
},
|
||||
@@ -291,7 +295,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
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.DailyToday, report.Tomorrow},
|
||||
MissingData: module.MissingDataError,
|
||||
Builder: buildDerivedDailySummaryModule,
|
||||
},
|
||||
@@ -308,7 +312,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
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 +327,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 +346,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",
|
||||
@@ -355,7 +379,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
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,
|
||||
},
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"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) {
|
||||
@@ -52,6 +54,27 @@ func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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")}})
|
||||
@@ -89,6 +112,24 @@ func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryRejectsHourlyIncompatibleModules(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
for _, id := range []module.ID{
|
||||
module.NarrativeForecast,
|
||||
module.DerivedDailySummary,
|
||||
module.DerivedDaypartSummaries,
|
||||
module.OutdoorWindows,
|
||||
module.TomorrowPlanning,
|
||||
} {
|
||||
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{}},
|
||||
@@ -128,6 +169,30 @@ 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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ func variantForReport(id report.ID) string {
|
||||
switch id {
|
||||
case report.DailyToday:
|
||||
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
|
||||
}
|
||||
@@ -190,8 +190,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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,6 +19,7 @@ Usage:
|
||||
weatherreporter --help
|
||||
weatherreporter generate daily [--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 +37,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.
|
||||
`
|
||||
|
||||
@@ -386,6 +387,8 @@ func reportKind(value string) (app.ReportKind, bool) {
|
||||
return app.ReportDaily, true
|
||||
case string(app.ReportTomorrow):
|
||||
return app.ReportTomorrow, true
|
||||
case string(app.ReportHourly):
|
||||
return app.ReportHourly, true
|
||||
case string(app.ReportThreeDay):
|
||||
return app.ReportThreeDay, true
|
||||
case string(app.ReportWeekend):
|
||||
|
||||
@@ -28,6 +28,13 @@ func TestRunHelpLongFlag(t *testing.T) {
|
||||
if !strings.Contains(stdout.String(), "generate daily") {
|
||||
t.Fatalf("help output missing generate command:\n%s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "weatherreporter generate hourly") {
|
||||
t.Fatalf("help output missing hourly generate command:\n%s", stdout.String())
|
||||
}
|
||||
removedGenerateCommand := "generate " + strings.Join([]string{"near", "term"}, "-")
|
||||
if strings.Contains(stdout.String(), removedGenerateCommand) {
|
||||
t.Fatalf("help output includes retired generate command:\n%s", stdout.String())
|
||||
}
|
||||
removedInspectCommand := "inspect " + "briefing"
|
||||
if !strings.Contains(stdout.String(), "inspect modules") || strings.Contains(stdout.String(), removedInspectCommand) {
|
||||
t.Fatalf("help output has wrong inspect commands:\n%s", stdout.String())
|
||||
@@ -123,22 +130,22 @@ func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("read report: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(report), "# Daily Report") {
|
||||
if !strings.Contains(string(report), "# Saturday's Weather") {
|
||||
t.Fatalf("report output missing markdown:\n%s", string(report))
|
||||
}
|
||||
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.yaml")
|
||||
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml")
|
||||
data, err := os.ReadFile(dataPackagePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read managed data package: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "id: daily_tomorrow") || !strings.Contains(string(data), "tomorrow_planning:") {
|
||||
if !strings.Contains(string(data), "id: tomorrow") || !strings.Contains(string(data), "tomorrow_planning:") {
|
||||
t.Fatalf("data package output missing tomorrow content:\n%s", string(data))
|
||||
}
|
||||
reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "daily", "*.md"))
|
||||
reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "tomorrow", "*.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob managed report: %v", err)
|
||||
}
|
||||
if len(reportMatches) != 1 || !strings.Contains(filepath.Base(reportMatches[0]), "daily_tomorrow") {
|
||||
if len(reportMatches) != 1 || !strings.Contains(filepath.Base(reportMatches[0]), "tomorrow") {
|
||||
t.Fatalf("managed reports = %#v, want tomorrow report", reportMatches)
|
||||
}
|
||||
}
|
||||
@@ -164,18 +171,18 @@ func TestRunEveningGeneratesTomorrowReport(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.yaml"))
|
||||
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob data package: %v", err)
|
||||
}
|
||||
if len(dataPackageMatches) != 1 {
|
||||
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
|
||||
}
|
||||
reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "daily", "*.md"))
|
||||
reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "tomorrow", "*.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob managed report: %v", err)
|
||||
}
|
||||
if len(reportMatches) != 1 || !strings.Contains(filepath.Base(reportMatches[0]), "daily_tomorrow") {
|
||||
if len(reportMatches) != 1 || !strings.Contains(filepath.Base(reportMatches[0]), "tomorrow") {
|
||||
t.Fatalf("managed reports = %#v, want only tomorrow report", reportMatches)
|
||||
}
|
||||
}
|
||||
@@ -475,10 +482,10 @@ func TestRunEveningReportsNotificationSuccess(t *testing.T) {
|
||||
distributorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/runs/distributor-run-1" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"run_id":"distributor-run-1","pipeline_id":"weatherreporter.daily","status":"succeeded","report":{"actions":[{"action":"replace_older"}]}}`))
|
||||
_, _ = w.Write([]byte(`{"run_id":"distributor-run-1","pipeline_id":"weatherreporter.tomorrow","status":"succeeded","report":{"actions":[{"action":"replace_older"}]}}`))
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/v1/pipelines/weatherreporter.daily/upload" {
|
||||
if r.URL.Path != "/v1/pipelines/weatherreporter.tomorrow/upload" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
@@ -514,7 +521,7 @@ func TestRunEveningReportsNotificationSuccess(t *testing.T) {
|
||||
if len(summary.Reports) != 1 {
|
||||
t.Fatalf("reports = %#v, want one report", summary.Reports)
|
||||
}
|
||||
if summary.Reports[0].NotificationStatus != "succeeded" || summary.Reports[0].NotificationRunID != "distributor-run-1" || summary.Reports[0].NotificationPipelineID != "weatherreporter.daily" {
|
||||
if summary.Reports[0].NotificationStatus != "succeeded" || summary.Reports[0].NotificationRunID != "distributor-run-1" || summary.Reports[0].NotificationPipelineID != "weatherreporter.tomorrow" {
|
||||
t.Fatalf("notification fields = %#v", summary.Reports[0])
|
||||
}
|
||||
if !strings.Contains(stderr.String(), `notificationStatus="succeeded"`) || !strings.Contains(stderr.String(), `notificationRunId="distributor-run-1"`) {
|
||||
@@ -682,6 +689,63 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
|
||||
server := dailyServer(t)
|
||||
tempDir := t.TempDir()
|
||||
scriptoriumPath := writeStructuredOutputScriptorium(t, tempDir)
|
||||
workspaceRoot := filepath.Join(tempDir, "workspace")
|
||||
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
|
||||
outPath := filepath.Join(tempDir, "hourly.md")
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
runner := Runner{Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 11, 0, 0, 0, time.UTC)}}
|
||||
|
||||
err := runner.Run(context.Background(), []string{
|
||||
"generate", "hourly",
|
||||
"--config", configPath,
|
||||
"--out", outPath,
|
||||
}, &stdout, &stderr)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
report, err := os.ReadFile(outPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read report: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"# Hourly Report",
|
||||
"Storm chances increase through late morning.",
|
||||
"A cold front is moving into the region.",
|
||||
"A front will keep the region unsettled.",
|
||||
} {
|
||||
if !strings.Contains(string(report), want) {
|
||||
t.Fatalf("report output missing %q:\n%s", want, string(report))
|
||||
}
|
||||
}
|
||||
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "hourly", "2026-05-29", "*.data_package.yaml")
|
||||
dataPackage, err := os.ReadFile(dataPackagePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read managed data package: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(dataPackage), "id: hourly") ||
|
||||
!strings.Contains(string(dataPackage), "prompt_id: weather.hourly_generated_text") ||
|
||||
!strings.Contains(string(dataPackage), "hourly_forecast:") {
|
||||
t.Fatalf("data package output missing hourly content:\n%s", string(dataPackage))
|
||||
}
|
||||
rawGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.generated_text.raw.json")
|
||||
validatedGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.generated_text.json")
|
||||
renderContextPath := oneArtifact(t, workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.render_context.json")
|
||||
managedReportPath := oneArtifact(t, workspaceRoot, "reports", "hourly", "*.md")
|
||||
assertFileContains(t, rawGeneratedTextPath, `"summary": " Storm chances increase through late morning. "`)
|
||||
assertFileContains(t, validatedGeneratedTextPath, `"summary":"Storm chances increase through late morning."`)
|
||||
assertFileContains(t, renderContextPath, `"Report": {`)
|
||||
assertFileContains(t, renderContextPath, `"Title": "Hourly Report"`)
|
||||
assertFileContains(t, renderContextPath, `"Modules": {`)
|
||||
assertFileContains(t, renderContextPath, `"Collected": {`)
|
||||
assertFileContains(t, renderContextPath, `"Derived": {`)
|
||||
assertFileContains(t, managedReportPath, "# Hourly Report")
|
||||
}
|
||||
|
||||
func TestRunInspectGeneratedArtifacts(t *testing.T) {
|
||||
server := dailyServer(t)
|
||||
tempDir := t.TempDir()
|
||||
@@ -809,6 +873,7 @@ func TestResolveGenerateCommands(t *testing.T) {
|
||||
}{
|
||||
{name: "daily", args: []string{"daily", "--date", "2026-05-29"}, want: app.ReportDaily},
|
||||
{name: "tomorrow", args: []string{"tomorrow"}, want: app.ReportTomorrow},
|
||||
{name: "hourly", args: []string{"hourly"}, want: app.ReportHourly},
|
||||
{name: "three-day", args: []string{"three-day"}, want: app.ReportThreeDay},
|
||||
{name: "weekend", args: []string{"weekend"}, want: app.ReportWeekend},
|
||||
{name: "storm", args: []string{"storm", "--start", "2026-05-29T18:00", "--end", "2026-05-30T06:00"}, want: app.ReportStorm},
|
||||
@@ -827,6 +892,55 @@ func TestResolveGenerateCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGenerateHourlyAppliesSharedFlags(t *testing.T) {
|
||||
runner := Runner{Clock: fixedClock()}
|
||||
configPath := filepath.Join(t.TempDir(), "config.yml")
|
||||
if err := os.WriteFile(configPath, []byte("weather_api:\n units: metric\n timezone: UTC\n"), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
req, err := runner.resolveGenerate([]string{"hourly", "--config", configPath, "--units", "us", "--tz", "America/Chicago", "--out", "./hourly.md"})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveGenerate() error = %v", err)
|
||||
}
|
||||
|
||||
if req.Report != app.ReportHourly {
|
||||
t.Fatalf("Report = %q, want hourly", req.Report)
|
||||
}
|
||||
if req.Config.WeatherAPI.Units != "us" {
|
||||
t.Fatalf("Units = %q, want us", req.Config.WeatherAPI.Units)
|
||||
}
|
||||
if req.Config.WeatherAPI.Timezone != "America/Chicago" {
|
||||
t.Fatalf("Timezone = %q, want America/Chicago", req.Config.WeatherAPI.Timezone)
|
||||
}
|
||||
if req.OutputPath != "./hourly.md" {
|
||||
t.Fatalf("OutputPath = %q, want ./hourly.md", req.OutputPath)
|
||||
}
|
||||
if !req.Date.IsZero() || !req.StormStart.IsZero() || !req.StormEnd.IsZero() {
|
||||
t.Fatalf("date/storm bounds = %s/%s/%s, want unset for hourly", req.Date, req.StormStart, req.StormEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGenerateHourlyRejectsDateAndStormBounds(t *testing.T) {
|
||||
runner := Runner{Clock: fixedClock()}
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"hourly", "--date", "2026-05-29"},
|
||||
{"hourly", "--start", "2026-05-29T18:00"},
|
||||
{"hourly", "--end", "2026-05-29T20:00"},
|
||||
{"hourly", "--hours", "6"},
|
||||
{"hourly", "--duration", "6h"},
|
||||
} {
|
||||
_, err := runner.resolveGenerate(args)
|
||||
if err == nil {
|
||||
t.Fatalf("resolveGenerate(%v) error = nil, want flag error", args)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "flag provided but not defined") {
|
||||
t.Fatalf("resolveGenerate(%v) error = %q, want undefined flag error", args, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGenerateDailyDefaultsDateInConfiguredTimezone(t *testing.T) {
|
||||
runner := Runner{Clock: fixedClock()}
|
||||
|
||||
@@ -859,6 +973,19 @@ func TestResolveGenerateAppliesSharedFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGenerateRejectsRetiredHourlyCommand(t *testing.T) {
|
||||
runner := Runner{Clock: fixedClock()}
|
||||
retired := strings.Join([]string{"near", "term"}, "-")
|
||||
|
||||
_, err := runner.resolveGenerate([]string{retired})
|
||||
if err == nil {
|
||||
t.Fatal("resolveGenerate() error = nil, want unknown report")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown generate report") {
|
||||
t.Fatalf("error = %q, want unknown generate report", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) {
|
||||
runner := Runner{Clock: fixedClock()}
|
||||
|
||||
@@ -996,6 +1123,8 @@ func dailyServer(t *testing.T) *httptest.Server {
|
||||
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}}`))
|
||||
case "/weatherstories/latest":
|
||||
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
|
||||
case "/outlooks/convective":
|
||||
_, _ = w.Write([]byte(`{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
@@ -1026,6 +1155,17 @@ func oneArtifact(t *testing.T, root string, parts ...string) string {
|
||||
return matches[0]
|
||||
}
|
||||
|
||||
func assertFileContains(t *testing.T, path string, want string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
if !strings.Contains(string(data), want) {
|
||||
t.Fatalf("%s missing %q:\n%s", path, want, string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func writeFakeScriptorium(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, "scriptorium")
|
||||
@@ -1036,13 +1176,91 @@ if [ "$1" = "render" ]; then
|
||||
fi
|
||||
if [ "$1" = "run" ]; then
|
||||
out=""
|
||||
prompt=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
if [ "$1" = "--out" ]; then
|
||||
shift
|
||||
out="$1"
|
||||
elif [ "$1" = "--prompt" ]; then
|
||||
shift
|
||||
prompt="$1"
|
||||
fi
|
||||
shift
|
||||
done
|
||||
if [ "$prompt" = "weather.tomorrow_generated_text" ]; then
|
||||
cat > "$out" <<'JSON'
|
||||
{
|
||||
"summary": "Tomorrow starts with showers before improving.",
|
||||
"forecast_discussion": [
|
||||
"Morning showers should taper as drier air arrives.",
|
||||
"Afternoon conditions trend quieter."
|
||||
],
|
||||
"precipitation_timing": "The best rain chance is during the morning."
|
||||
}
|
||||
JSON
|
||||
printf 'wrote generated text\n' >&2
|
||||
exit 0
|
||||
fi
|
||||
printf '# Daily Report\n\nGenerated by fake scriptorium.\n' > "$out"
|
||||
printf 'wrote report\n' >&2
|
||||
exit 0
|
||||
fi
|
||||
printf 'unexpected command\n' >&2
|
||||
exit 1
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(body), 0o700); err != nil {
|
||||
t.Fatalf("write fake scriptorium: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func writeStructuredOutputScriptorium(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, "scriptorium")
|
||||
body := `#!/bin/sh
|
||||
if [ "$1" = "render" ]; then
|
||||
printf '{"ok":true,"argv":"%s"}' "$*"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "run" ]; then
|
||||
out=""
|
||||
prompt=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
if [ "$1" = "--out" ]; then
|
||||
shift
|
||||
out="$1"
|
||||
elif [ "$1" = "--prompt" ]; then
|
||||
shift
|
||||
prompt="$1"
|
||||
fi
|
||||
shift
|
||||
done
|
||||
if [ "$prompt" = "weather.hourly_generated_text" ]; then
|
||||
cat > "$out" <<'JSON'
|
||||
{
|
||||
"summary": " Storm chances increase through late morning. ",
|
||||
"forecast_discussion": "A front will keep the region unsettled.",
|
||||
"precipitation_timing": "A cold front is moving into the region.",
|
||||
"confidence": "Medium"
|
||||
}
|
||||
JSON
|
||||
printf 'wrote generated text\n' >&2
|
||||
exit 0
|
||||
fi
|
||||
if [ "$prompt" = "weather.tomorrow_generated_text" ]; then
|
||||
cat > "$out" <<'JSON'
|
||||
{
|
||||
"summary": "Tomorrow starts with showers before improving.",
|
||||
"forecast_discussion": [
|
||||
"Morning showers should taper as drier air arrives.",
|
||||
"Afternoon conditions trend quieter."
|
||||
],
|
||||
"precipitation_timing": "The best rain chance is during the morning."
|
||||
}
|
||||
JSON
|
||||
printf 'wrote generated text\n' >&2
|
||||
exit 0
|
||||
fi
|
||||
printf '# Daily Report\n\nGenerated by fake scriptorium.\n' > "$out"
|
||||
printf 'wrote report\n' >&2
|
||||
exit 0
|
||||
|
||||
@@ -85,12 +85,28 @@ 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 := cfg.ReportModuleOverrides()
|
||||
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,10 +142,13 @@ 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)
|
||||
@@ -138,21 +157,54 @@ reports:
|
||||
}
|
||||
overrides := cfg.ReportModuleOverrides()
|
||||
items := overrides[report.DailyToday]
|
||||
if len(items) != 3 {
|
||||
t.Fatalf("daily override length = %d, want 3", len(items))
|
||||
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.AreaForecastDiscussion {
|
||||
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 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 := cfg.ReportModuleOverrides()
|
||||
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 TestReportModuleOverrideValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -210,6 +262,16 @@ reports:
|
||||
`,
|
||||
wantErr: `unknown module "forecast_delta"`,
|
||||
},
|
||||
{
|
||||
name: "RetiredTomorrowReportID",
|
||||
yaml: `
|
||||
reports:
|
||||
daily_tomorrow:
|
||||
deterministic_modules:
|
||||
- metadata
|
||||
`,
|
||||
wantErr: "reports.daily_tomorrow is not a known report",
|
||||
},
|
||||
{
|
||||
name: "InvalidOptions",
|
||||
yaml: `
|
||||
@@ -236,6 +298,16 @@ reports:
|
||||
`,
|
||||
wantErr: "duplicates report override",
|
||||
},
|
||||
{
|
||||
name: "HourlyIncompatibleDailyModule",
|
||||
yaml: `
|
||||
reports:
|
||||
hourly:
|
||||
deterministic_modules:
|
||||
- derived_daily_summary
|
||||
`,
|
||||
wantErr: `not compatible with report "hourly"`,
|
||||
},
|
||||
{
|
||||
name: "UnknownReportField",
|
||||
yaml: `
|
||||
@@ -261,6 +333,28 @@ reports:
|
||||
}
|
||||
}
|
||||
|
||||
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 +693,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)
|
||||
}
|
||||
|
||||
@@ -111,8 +111,10 @@ func reportIDForConfigKey(key string) (report.ID, error) {
|
||||
switch normalized {
|
||||
case "daily", "daily_today":
|
||||
return report.DailyToday, nil
|
||||
case "tomorrow", "daily_tomorrow":
|
||||
return report.DailyTomorrow, nil
|
||||
case "tomorrow":
|
||||
return report.Tomorrow, nil
|
||||
case "hourly":
|
||||
return report.Hourly, nil
|
||||
case "three_day", "three_day_outlook":
|
||||
return report.ThreeDay, nil
|
||||
case "weekend", "weekend_outlook":
|
||||
|
||||
@@ -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.DailyToday, 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,6 +47,11 @@ 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) {
|
||||
@@ -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 today",
|
||||
resolved: resolveForTest(t, report.DailyToday, 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.DailyToday, 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{
|
||||
|
||||
52
internal/generatedtext/hourly.go
Normal file
52
internal/generatedtext/hourly.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Package generatedtext validates structured LLM text and render contexts.
|
||||
package generatedtext
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"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) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
var value Hourly
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return Hourly{}, nil, fmt.Errorf("decode hourly generated text: %w", err)
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != nil {
|
||||
if err != io.EOF {
|
||||
return Hourly{}, nil, fmt.Errorf("decode hourly generated text: %w", err)
|
||||
}
|
||||
} else {
|
||||
return Hourly{}, nil, fmt.Errorf("decode hourly generated text: multiple JSON values")
|
||||
}
|
||||
|
||||
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 := json.Marshal(value)
|
||||
if err != nil {
|
||||
return Hourly{}, nil, fmt.Errorf("normalize hourly generated text: %w", 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
373
internal/generatedtext/render_context.go
Normal file
373
internal/generatedtext/render_context.go
Normal file
@@ -0,0 +1,373 @@
|
||||
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 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
|
||||
}
|
||||
|
||||
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 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 hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, error) {
|
||||
metadata, err := optionalStanza[briefing.MetadataModule](snapshot, string(module.Metadata))
|
||||
if err != nil {
|
||||
return HourlyTemplateModules{}, err
|
||||
}
|
||||
current, err := optionalStanza[briefing.CurrentConditionsModule](snapshot, string(module.CurrentConditions))
|
||||
if err != nil {
|
||||
return HourlyTemplateModules{}, err
|
||||
}
|
||||
hourly, err := optionalStanza[briefing.HourlyForecastModule](snapshot, string(module.HourlyForecast))
|
||||
if err != nil {
|
||||
return HourlyTemplateModules{}, err
|
||||
}
|
||||
precip, err := optionalStanza[briefing.PrecipTimingModule](snapshot, string(module.PrecipTiming))
|
||||
if err != nil {
|
||||
return HourlyTemplateModules{}, err
|
||||
}
|
||||
alerts, err := optionalStanza[briefing.AlertDigestModule](snapshot, string(module.AlertDigest))
|
||||
if err != nil {
|
||||
return HourlyTemplateModules{}, err
|
||||
}
|
||||
outlooks, err := optionalStanza[briefing.SPCConvectiveOutlooksModule](snapshot, string(module.SPCConvectiveOutlooks))
|
||||
if err != nil {
|
||||
return HourlyTemplateModules{}, err
|
||||
}
|
||||
discussion, err := optionalStanza[briefing.AreaForecastDiscussionModule](snapshot, string(module.AreaForecastDiscussion))
|
||||
if err != nil {
|
||||
return HourlyTemplateModules{}, err
|
||||
}
|
||||
spcDiscussion, err := optionalStanza[briefing.SPCConvectiveDiscussionModule](snapshot, string(module.SPCConvectiveDiscussion))
|
||||
if err != nil {
|
||||
return HourlyTemplateModules{}, err
|
||||
}
|
||||
story, err := optionalStanza[briefing.WeatherStoryModule](snapshot, string(module.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) {
|
||||
metadata, err := optionalStanza[briefing.MetadataModule](snapshot, string(module.Metadata))
|
||||
if err != nil {
|
||||
return TomorrowTemplateModules{}, err
|
||||
}
|
||||
current, err := optionalStanza[briefing.CurrentConditionsModule](snapshot, string(module.CurrentConditions))
|
||||
if err != nil {
|
||||
return TomorrowTemplateModules{}, err
|
||||
}
|
||||
hourly, err := optionalStanza[briefing.HourlyForecastModule](snapshot, string(module.HourlyForecast))
|
||||
if err != nil {
|
||||
return TomorrowTemplateModules{}, err
|
||||
}
|
||||
daily, err := optionalStanza[briefing.DerivedDailySummaryModule](snapshot, string(module.DerivedDailySummary))
|
||||
if err != nil {
|
||||
return TomorrowTemplateModules{}, err
|
||||
}
|
||||
dayparts, err := optionalStanza[map[string]briefing.DerivedDaypartSummaryModule](snapshot, string(module.DerivedDaypartSummaries))
|
||||
if err != nil {
|
||||
return TomorrowTemplateModules{}, err
|
||||
}
|
||||
precip, err := optionalStanza[briefing.PrecipTimingModule](snapshot, string(module.PrecipTiming))
|
||||
if err != nil {
|
||||
return TomorrowTemplateModules{}, err
|
||||
}
|
||||
alerts, err := optionalStanza[briefing.AlertDigestModule](snapshot, string(module.AlertDigest))
|
||||
if err != nil {
|
||||
return TomorrowTemplateModules{}, err
|
||||
}
|
||||
outlooks, err := optionalStanza[briefing.SPCConvectiveOutlooksModule](snapshot, string(module.SPCConvectiveOutlooks))
|
||||
if err != nil {
|
||||
return TomorrowTemplateModules{}, err
|
||||
}
|
||||
discussion, err := optionalStanza[briefing.AreaForecastDiscussionModule](snapshot, string(module.AreaForecastDiscussion))
|
||||
if err != nil {
|
||||
return TomorrowTemplateModules{}, err
|
||||
}
|
||||
spcDiscussion, err := optionalStanza[briefing.SPCConvectiveDiscussionModule](snapshot, string(module.SPCConvectiveDiscussion))
|
||||
if err != nil {
|
||||
return TomorrowTemplateModules{}, err
|
||||
}
|
||||
story, err := optionalStanza[briefing.WeatherStoryModule](snapshot, string(module.WeatherStory))
|
||||
if err != nil {
|
||||
return TomorrowTemplateModules{}, err
|
||||
}
|
||||
planning, err := optionalStanza[briefing.TomorrowPlanningModule](snapshot, string(module.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
|
||||
}
|
||||
|
||||
func optionalStanza[T any](snapshot module.Snapshot, name string) (*T, error) {
|
||||
output, ok := snapshot.LookupStanza(name)
|
||||
if !ok || output.Value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
value, _, err := module.StanzaValue[T](snapshot, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build render context: %w", err)
|
||||
}
|
||||
return &value, nil
|
||||
}
|
||||
|
||||
func orderedTomorrowDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []TomorrowDaypartContext {
|
||||
if dayparts == nil || len(*dayparts) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]TomorrowDaypartContext, 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, TomorrowDaypartContext{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, TomorrowDaypartContext{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")
|
||||
}
|
||||
581
internal/generatedtext/render_context_test.go
Normal file
581
internal/generatedtext/render_context_test.go
Normal file
@@ -0,0 +1,581 @@
|
||||
package generatedtext
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/reporttemplate"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
func TestBuildHourlyRenderContext(t *testing.T) {
|
||||
metadata := testMetadata()
|
||||
snapshot := testSnapshot(t)
|
||||
generated := Hourly{
|
||||
Summary: "Storm chances increase through late morning.",
|
||||
ForecastDiscussion: "A front will keep the region unsettled.",
|
||||
PrecipitationTiming: "A cold front is moving into the region.",
|
||||
Confidence: "Medium confidence in timing.",
|
||||
}
|
||||
collected := testCollected()
|
||||
derived := testDerived()
|
||||
ctx, err := BuildHourlyRenderContext(metadata, snapshot, generated, collected, derived)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildHourlyRenderContext() error = %v", err)
|
||||
}
|
||||
if ctx.Report.Title != "Hourly Report" {
|
||||
t.Fatalf("Report.Title = %q, want Hourly Report", ctx.Report.Title)
|
||||
}
|
||||
if ctx.Report.LocationName != "Brentwood, MO" {
|
||||
t.Fatalf("Report.LocationName = %q, want Brentwood, MO", ctx.Report.LocationName)
|
||||
}
|
||||
if ctx.Report.ValidPeriodLabel != "2026-05-29 at 8:30 AM to 2026-05-29 at 2:30 PM" {
|
||||
t.Fatalf("Report.ValidPeriodLabel = %q, want friendly period", ctx.Report.ValidPeriodLabel)
|
||||
}
|
||||
if ctx.Report.GeneratedAtLabel != "Friday, May 29, 2026 at 8:30 AM" {
|
||||
t.Fatalf("Report.GeneratedAtLabel = %q, want friendly generated-at label", ctx.Report.GeneratedAtLabel)
|
||||
}
|
||||
if ctx.Modules.CurrentConditions == nil || ctx.Modules.CurrentConditions.ConditionText != "Partly cloudy" || ctx.Modules.CurrentConditions.TemperatureF == nil || *ctx.Modules.CurrentConditions.TemperatureF != 74 {
|
||||
t.Fatalf("Modules.CurrentConditions = %#v, want structured current conditions", ctx.Modules.CurrentConditions)
|
||||
}
|
||||
if ctx.Modules.HourlyForecast == nil || len(ctx.Modules.HourlyForecast.Periods) != 2 {
|
||||
t.Fatalf("Modules.HourlyForecast = %#v, want 2 periods", ctx.Modules.HourlyForecast)
|
||||
}
|
||||
if period := ctx.Modules.HourlyForecast.Periods[1]; period.PeriodBegins != "2026-05-29 at 10:00 AM" || period.TextDescription != "Showers" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
|
||||
t.Fatalf("Modules.HourlyForecast.Periods[1] = %#v, want 10 AM showers row", period)
|
||||
}
|
||||
if ctx.Modules.PrecipTiming == nil || ctx.Modules.PrecipTiming.MaxPopPercent == nil || *ctx.Modules.PrecipTiming.MaxPopPercent != 70 {
|
||||
t.Fatalf("Modules.PrecipTiming = %#v, want max pop", ctx.Modules.PrecipTiming)
|
||||
}
|
||||
if ctx.Modules.AlertDigest == nil || len(ctx.Modules.AlertDigest.Relevant) != 1 || ctx.Modules.AlertDigest.Relevant[0].Event != "Flood Watch" {
|
||||
t.Fatalf("Modules.AlertDigest = %#v, want alert", ctx.Modules.AlertDigest)
|
||||
}
|
||||
if ctx.Modules.SPCConvectiveOutlooks == nil || len(ctx.Modules.SPCConvectiveOutlooks.Outlooks) != 1 || ctx.Modules.SPCConvectiveOutlooks.Outlooks[0].LabelText != "Slight Risk" {
|
||||
t.Fatalf("Modules.SPCConvectiveOutlooks = %#v, want outlook", ctx.Modules.SPCConvectiveOutlooks)
|
||||
}
|
||||
if ctx.Modules.AreaForecastDiscussion == nil || ctx.Modules.AreaForecastDiscussion.ShortTerm != "Short-term discussion favors increasing rain coverage." {
|
||||
t.Fatalf("Modules.AreaForecastDiscussion = %#v, want discussion text", ctx.Modules.AreaForecastDiscussion)
|
||||
}
|
||||
if ctx.Modules.SPCConvectiveDiscussion == nil || len(ctx.Modules.SPCConvectiveDiscussion.Discussions) != 1 || ctx.Modules.SPCConvectiveDiscussion.Discussions[0].Headline != "Mesoscale discussion" {
|
||||
t.Fatalf("Modules.SPCConvectiveDiscussion = %#v, want discussion", ctx.Modules.SPCConvectiveDiscussion)
|
||||
}
|
||||
if ctx.Modules.WeatherStory == nil || ctx.Modules.WeatherStory.Title != "Morning storms" {
|
||||
t.Fatalf("Modules.WeatherStory = %#v, want story", ctx.Modules.WeatherStory)
|
||||
}
|
||||
if !ctx.Collected.FetchedAt.Equal(collected.FetchedAt) {
|
||||
t.Fatalf("Collected.FetchedAt = %s, want %s", ctx.Collected.FetchedAt, collected.FetchedAt)
|
||||
}
|
||||
if !ctx.Derived.PrecipTiming.ThunderMentioned {
|
||||
t.Fatalf("Derived.PrecipTiming.ThunderMentioned = false, want true")
|
||||
}
|
||||
|
||||
rendered, err := reporttemplate.Render("hourly", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Render() error = %v", err)
|
||||
}
|
||||
text := string(rendered)
|
||||
for _, want := range []string{
|
||||
"# Hourly Report",
|
||||
"**Updated:** Friday, May 29, 2026 at 8:30 AM",
|
||||
"Storm chances increase through late morning.",
|
||||
"- **10:00 AM:** 75°F and showers. Probability of precipitation is 70%.",
|
||||
"- **Flood Watch**: Flood Watch until early afternoon",
|
||||
"A cold front is moving into the region.",
|
||||
"A front will keep the region unsettled.",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("rendered template missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHourlyRenderContextAllowsOmittedOptionalModules(t *testing.T) {
|
||||
snapshot, err := module.NewSnapshot([]module.Output{
|
||||
{ID: module.CurrentConditions, StanzaName: string(module.CurrentConditions), Value: briefing.CurrentConditionsModule{}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSnapshot() error = %v", err)
|
||||
}
|
||||
ctx, err := BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{
|
||||
Summary: "Storm chances increase.",
|
||||
ForecastDiscussion: "A front will keep the region unsettled.",
|
||||
}, testCollected(), facts.DerivedFacts{})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildHourlyRenderContext() error = %v", err)
|
||||
}
|
||||
if ctx.Modules.HourlyForecast != nil {
|
||||
t.Fatalf("Modules.HourlyForecast = %#v, want nil for omitted module", ctx.Modules.HourlyForecast)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTomorrowRenderContext(t *testing.T) {
|
||||
metadata := testTomorrowMetadata()
|
||||
snapshot := testTomorrowSnapshot(t)
|
||||
generated := Tomorrow{
|
||||
Summary: "Tomorrow starts quiet, then showers become more likely later in the day.",
|
||||
ForecastDiscussion: []string{
|
||||
"Morning conditions should stay mostly dry.",
|
||||
"Rain chances increase during the afternoon as deeper moisture arrives.",
|
||||
},
|
||||
PrecipitationTiming: "The most likely rain window is from midafternoon into early evening.",
|
||||
}
|
||||
collected := testCollected()
|
||||
derived := testTomorrowDerived()
|
||||
|
||||
ctx, err := BuildTomorrowRenderContext(metadata, snapshot, generated, collected, derived)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildTomorrowRenderContext() error = %v", err)
|
||||
}
|
||||
if ctx.Report.Title != "Monday's Weather" {
|
||||
t.Fatalf("Report.Title = %q, want Monday's Weather", ctx.Report.Title)
|
||||
}
|
||||
if ctx.Report.ForecastDateLabel != "Monday, June 15, 2026" || ctx.Report.ForecastDayName != "Monday" {
|
||||
t.Fatalf("forecast date labels = %q/%q, want Monday labels", ctx.Report.ForecastDateLabel, ctx.Report.ForecastDayName)
|
||||
}
|
||||
if ctx.Report.GeneratedAtLabel != "Sunday, June 14, 2026 at 9:14 AM" {
|
||||
t.Fatalf("Report.GeneratedAtLabel = %q, want friendly generated-at label", ctx.Report.GeneratedAtLabel)
|
||||
}
|
||||
if ctx.Modules.Metadata == nil || ctx.Modules.Metadata.ReportID != report.Tomorrow {
|
||||
t.Fatalf("Modules.Metadata = %#v, want tomorrow metadata", ctx.Modules.Metadata)
|
||||
}
|
||||
if ctx.Modules.CurrentConditions == nil || ctx.Modules.HourlyForecast == nil {
|
||||
t.Fatalf("current/hourly modules = %#v/%#v, want available in render context", ctx.Modules.CurrentConditions, ctx.Modules.HourlyForecast)
|
||||
}
|
||||
if ctx.Modules.DerivedDailySummary == nil || ctx.Modules.DerivedDailySummary.HighTempF == nil || *ctx.Modules.DerivedDailySummary.HighTempF != 74 {
|
||||
t.Fatalf("Modules.DerivedDailySummary = %#v, want daily summary", ctx.Modules.DerivedDailySummary)
|
||||
}
|
||||
if ctx.Modules.DerivedDaypartSummaries == nil || len(*ctx.Modules.DerivedDaypartSummaries) != 2 {
|
||||
t.Fatalf("Modules.DerivedDaypartSummaries = %#v, want daypart map", ctx.Modules.DerivedDaypartSummaries)
|
||||
}
|
||||
if len(ctx.Modules.Dayparts) != 2 || ctx.Modules.Dayparts[0].Key != "morning" || ctx.Modules.Dayparts[1].Key != "afternoon" {
|
||||
t.Fatalf("Modules.Dayparts = %#v, want configured order", ctx.Modules.Dayparts)
|
||||
}
|
||||
if ctx.Modules.PrecipTiming == nil || len(ctx.Modules.PrecipTiming.PrecipitationWindows) != 1 {
|
||||
t.Fatalf("Modules.PrecipTiming = %#v, want precipitation window", ctx.Modules.PrecipTiming)
|
||||
}
|
||||
if ctx.Modules.AlertDigest == nil || ctx.Modules.SPCConvectiveOutlooks == nil || ctx.Modules.AreaForecastDiscussion == nil || ctx.Modules.SPCConvectiveDiscussion == nil || ctx.Modules.WeatherStory == nil || ctx.Modules.TomorrowPlanning == nil {
|
||||
t.Fatalf("optional modules missing from render context: %#v", ctx.Modules)
|
||||
}
|
||||
if !ctx.Collected.FetchedAt.Equal(collected.FetchedAt) {
|
||||
t.Fatalf("Collected.FetchedAt = %s, want %s", ctx.Collected.FetchedAt, collected.FetchedAt)
|
||||
}
|
||||
if len(ctx.Derived.DaypartSummaries) != 2 {
|
||||
t.Fatalf("Derived.DaypartSummaries = %#v, want passthrough facts", ctx.Derived.DaypartSummaries)
|
||||
}
|
||||
|
||||
rendered, err := reporttemplate.Render("tomorrow", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Render() error = %v", err)
|
||||
}
|
||||
text := string(rendered)
|
||||
for _, want := range []string{
|
||||
"# Monday's Weather",
|
||||
"**Forecast date:** Monday, June 15, 2026",
|
||||
"Tomorrow starts quiet, then showers become more likely later in the day.",
|
||||
"- **Morning:** Partly cloudy, with temperatures in the low 60s.",
|
||||
"- **Afternoon:** Showers, with temperatures in the mid 70s. Chance of precipitation is 70%.",
|
||||
"## Precipitation Timing",
|
||||
"- **3:00 PM** to **6:00 PM**: Precipitation is expected during this period. The peak precipitation chance is 70% at 3:00 PM.",
|
||||
"The most likely rain window is from midafternoon into early evening.",
|
||||
"Morning conditions should stay mostly dry.",
|
||||
"Rain chances increase during the afternoon as deeper moisture arrives.",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("rendered template missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
assertOrderedText(t, text, []string{
|
||||
"# Monday's Weather",
|
||||
"## Daypart Forecast",
|
||||
"- **Morning:**",
|
||||
"- **Afternoon:**",
|
||||
"## Precipitation Timing",
|
||||
"## Forecast Discussion",
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildTomorrowRenderContextAllowsOmittedOptionalModules(t *testing.T) {
|
||||
snapshot, err := module.NewSnapshot(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSnapshot() error = %v", err)
|
||||
}
|
||||
ctx, err := BuildTomorrowRenderContext(testTomorrowMetadata(), snapshot, Tomorrow{
|
||||
Summary: "Dry weather is expected tomorrow.",
|
||||
ForecastDiscussion: []string{"High pressure keeps conditions quiet."},
|
||||
}, testCollected(), facts.DerivedFacts{})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildTomorrowRenderContext() error = %v", err)
|
||||
}
|
||||
if ctx.Modules.PrecipTiming != nil || len(ctx.Modules.Dayparts) != 0 {
|
||||
t.Fatalf("Modules = %#v, want omitted optional modules", ctx.Modules)
|
||||
}
|
||||
rendered, err := reporttemplate.Render("tomorrow", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Render() error = %v", err)
|
||||
}
|
||||
text := string(rendered)
|
||||
if strings.Contains(text, "## Precipitation Timing") {
|
||||
t.Fatalf("rendered template included precipitation section without windows:\n%s", text)
|
||||
}
|
||||
if !strings.Contains(text, "- No daypart forecast details are available.") {
|
||||
t.Fatalf("rendered template missing daypart fallback:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func testMetadata() briefing.Metadata {
|
||||
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
|
||||
return briefing.Metadata{
|
||||
RunID: "run-1",
|
||||
ReportID: report.Hourly,
|
||||
PromptID: "weather.hourly_generated_text",
|
||||
GeneratedAt: generatedAt,
|
||||
Units: "imperial",
|
||||
Timezone: "America/Chicago",
|
||||
ValidPeriod: timeutil.Period{
|
||||
Start: generatedAt,
|
||||
End: generatedAt.Add(6 * time.Hour),
|
||||
},
|
||||
Location: &briefing.LocationContext{
|
||||
Name: "Brentwood",
|
||||
Region: "MO",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testTomorrowMetadata() briefing.Metadata {
|
||||
generatedAt := time.Date(2026, 6, 14, 14, 14, 0, 0, time.UTC)
|
||||
return briefing.Metadata{
|
||||
RunID: "run-tomorrow",
|
||||
ReportID: report.Tomorrow,
|
||||
PromptID: "weather.tomorrow_generated_text",
|
||||
GeneratedAt: generatedAt,
|
||||
Units: "imperial",
|
||||
Timezone: "America/Chicago",
|
||||
ValidPeriod: timeutil.Period{
|
||||
Start: time.Date(2026, 6, 15, 5, 0, 0, 0, time.UTC),
|
||||
End: time.Date(2026, 6, 16, 5, 0, 0, 0, time.UTC),
|
||||
},
|
||||
Location: &briefing.LocationContext{
|
||||
Name: "Brentwood",
|
||||
Region: "MO",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testCollected() facts.CollectedFacts {
|
||||
return facts.CollectedFacts{FetchedAt: time.Date(2026, 5, 29, 13, 31, 0, 0, time.UTC)}
|
||||
}
|
||||
|
||||
func testDerived() facts.DerivedFacts {
|
||||
return facts.DerivedFacts{PrecipTiming: forecast.PrecipTiming{ThunderMentioned: true}}
|
||||
}
|
||||
|
||||
func testTomorrowDerived() facts.DerivedFacts {
|
||||
morningStart := time.Date(2026, 6, 15, 11, 0, 0, 0, time.UTC)
|
||||
afternoonStart := time.Date(2026, 6, 15, 17, 0, 0, 0, time.UTC)
|
||||
return facts.DerivedFacts{
|
||||
DaypartSummaries: []forecast.DaypartSummary{
|
||||
{
|
||||
Name: "morning",
|
||||
Period: timeutil.Period{
|
||||
Start: morningStart,
|
||||
End: afternoonStart,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "afternoon",
|
||||
Period: timeutil.Period{
|
||||
Start: afternoonStart,
|
||||
End: time.Date(2026, 6, 15, 23, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testSnapshot(t *testing.T) module.Snapshot {
|
||||
t.Helper()
|
||||
snapshot, err := module.NewSnapshot([]module.Output{
|
||||
{
|
||||
ID: module.CurrentConditions,
|
||||
StanzaName: string(module.CurrentConditions),
|
||||
Value: briefing.CurrentConditionsModule{
|
||||
ConditionText: "Partly cloudy",
|
||||
ConditionTextLower: "partly cloudy",
|
||||
TemperatureF: intPtr(74),
|
||||
ApparentTemperatureF: intPtr(76),
|
||||
RelativeHumidityPercent: intPtr(71),
|
||||
WindSpeedMph: intPtr(8),
|
||||
WindDirection: "S",
|
||||
WindDirectionText: "south",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.HourlyForecast,
|
||||
StanzaName: string(module.HourlyForecast),
|
||||
Value: briefing.HourlyForecastModule{
|
||||
Periods: []briefing.HourlyForecastPeriod{
|
||||
{
|
||||
HourLabel: "9:00 AM",
|
||||
PeriodBegins: "2026-05-29 at 9:00 AM",
|
||||
TextDescription: "Cloudy",
|
||||
TextDescriptionLower: "cloudy",
|
||||
TemperatureF: floatPtr(74),
|
||||
ProbabilityOfPrecipitationPercent: floatPtr(30),
|
||||
MentionPrecipitation: true,
|
||||
WindSpeedMph: floatPtr(8),
|
||||
WindDirection: "S",
|
||||
},
|
||||
{
|
||||
HourLabel: "10:00 AM",
|
||||
PeriodBegins: "2026-05-29 at 10:00 AM",
|
||||
TextDescription: "Showers",
|
||||
TextDescriptionLower: "showers",
|
||||
TemperatureF: floatPtr(75),
|
||||
ProbabilityOfPrecipitationPercent: floatPtr(70),
|
||||
MentionPrecipitation: true,
|
||||
WindSpeedMph: floatPtr(10),
|
||||
WindGustMph: floatPtr(18),
|
||||
WindDirection: "S",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.PrecipTiming,
|
||||
StanzaName: string(module.PrecipTiming),
|
||||
Value: briefing.PrecipTimingModule{
|
||||
MaxPopPercent: intPtr(70),
|
||||
MaxPopTime: "10 AM",
|
||||
ProbabilityThreshold: 50,
|
||||
PrecipitationWindows: []briefing.PrecipitationWindowModule{
|
||||
{
|
||||
PeriodBegins: "2026-05-29 at 10:00 AM",
|
||||
PeriodBeginsHourLabel: "10:00 AM",
|
||||
PeriodEnds: "2026-05-29 at 12:00 PM",
|
||||
PeriodEndsHourLabel: "12:00 PM",
|
||||
MaxPopPercent: intPtr(70),
|
||||
MaxPopTime: "10 AM",
|
||||
MaxPopHourLabel: "10:00 AM",
|
||||
},
|
||||
},
|
||||
ThunderMentioned: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.AlertDigest,
|
||||
StanzaName: string(module.AlertDigest),
|
||||
Value: briefing.AlertDigestModule{
|
||||
Checked: true,
|
||||
ActiveCount: 1,
|
||||
RelevantCount: 1,
|
||||
Relevant: []briefing.AlertSummary{
|
||||
{Event: "Flood Watch", Headline: "Flood Watch until early afternoon", Severity: "Moderate"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.SPCConvectiveOutlooks,
|
||||
StanzaName: string(module.SPCConvectiveOutlooks),
|
||||
Value: briefing.SPCConvectiveOutlooksModule{
|
||||
Checked: true,
|
||||
OutlookCount: 1,
|
||||
Outlooks: []briefing.SPCConvectiveOutlookRecord{
|
||||
{
|
||||
Label: "SLGT",
|
||||
LabelText: "Slight Risk",
|
||||
PeriodBegins: "2026-05-29 at 7:00 AM",
|
||||
PeriodEnds: "2026-05-29 at 3:00 PM",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.AreaForecastDiscussion,
|
||||
StanzaName: string(module.AreaForecastDiscussion),
|
||||
Value: briefing.AreaForecastDiscussionModule{
|
||||
KeyMessages: []string{"Storms are most likely late morning."},
|
||||
ShortTerm: "Short-term discussion favors increasing rain coverage.",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.SPCConvectiveDiscussion,
|
||||
StanzaName: string(module.SPCConvectiveDiscussion),
|
||||
Value: briefing.SPCConvectiveDiscussionModule{
|
||||
IncludedBecause: "categorical severity_rank >= 3",
|
||||
Discussions: []briefing.SPCConvectiveDiscussionRecord{
|
||||
{Headline: "Mesoscale discussion", Summary: "Strong storms may develop late morning."},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.WeatherStory,
|
||||
StanzaName: string(module.WeatherStory),
|
||||
Value: briefing.WeatherStoryModule{
|
||||
Available: true,
|
||||
Title: "Morning storms",
|
||||
Description: "Morning storms remain the main story.",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSnapshot() error = %v", err)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func testTomorrowSnapshot(t *testing.T) module.Snapshot {
|
||||
t.Helper()
|
||||
snapshot, err := module.NewSnapshot([]module.Output{
|
||||
{
|
||||
ID: module.Metadata,
|
||||
StanzaName: string(module.Metadata),
|
||||
Value: briefing.MetadataModule{
|
||||
RunID: "run-tomorrow",
|
||||
ReportID: report.Tomorrow,
|
||||
PromptID: "weather.tomorrow_generated_text",
|
||||
GeneratedAt: testTomorrowMetadata().GeneratedAt,
|
||||
Units: "imperial",
|
||||
Timezone: "America/Chicago",
|
||||
ValidPeriod: testTomorrowMetadata().ValidPeriod,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.CurrentConditions,
|
||||
StanzaName: string(module.CurrentConditions),
|
||||
Value: briefing.CurrentConditionsModule{
|
||||
ConditionTextLower: "clear",
|
||||
TemperatureF: intPtr(58),
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.HourlyForecast,
|
||||
StanzaName: string(module.HourlyForecast),
|
||||
Value: briefing.HourlyForecastModule{
|
||||
Periods: []briefing.HourlyForecastPeriod{
|
||||
{HourLabel: "7:00 AM", TextDescriptionLower: "partly cloudy", TemperatureF: floatPtr(63)},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.DerivedDailySummary,
|
||||
StanzaName: string(module.DerivedDailySummary),
|
||||
Value: briefing.DerivedDailySummaryModule{
|
||||
Date: "Monday, June 15, 2026",
|
||||
HighTempF: intPtr(74),
|
||||
LowTempF: intPtr(61),
|
||||
DailyPrecipitationProbability: intPtr(70),
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.DerivedDaypartSummaries,
|
||||
StanzaName: string(module.DerivedDaypartSummaries),
|
||||
Value: map[string]briefing.DerivedDaypartSummaryModule{
|
||||
"afternoon": {
|
||||
DisplayName: "Afternoon",
|
||||
TemperatureTrend: "steady",
|
||||
TemperatureSteadyPhraseF: "mid 70s",
|
||||
DominantConditionDisplay: "Showers",
|
||||
DominantConditionLower: "showers",
|
||||
MaxPopPercent: intPtr(70),
|
||||
MaxPopTimeLabel: "3:00 PM",
|
||||
MentionPrecipitation: true,
|
||||
},
|
||||
"morning": {
|
||||
DisplayName: "Morning",
|
||||
TemperatureTrend: "steady",
|
||||
TemperatureSteadyPhraseF: "low 60s",
|
||||
DominantConditionDisplay: "Partly cloudy",
|
||||
DominantConditionLower: "partly cloudy",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.PrecipTiming,
|
||||
StanzaName: string(module.PrecipTiming),
|
||||
Value: briefing.PrecipTimingModule{
|
||||
MaxPopPercent: intPtr(70),
|
||||
MaxPopTime: "3 PM",
|
||||
PrecipitationWindows: []briefing.PrecipitationWindowModule{
|
||||
{PeriodBeginsHourLabel: "3:00 PM", PeriodEndsHourLabel: "6:00 PM", MaxPopPercent: intPtr(70), MaxPopHourLabel: "3:00 PM"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.AlertDigest,
|
||||
StanzaName: string(module.AlertDigest),
|
||||
Value: briefing.AlertDigestModule{
|
||||
Checked: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.SPCConvectiveOutlooks,
|
||||
StanzaName: string(module.SPCConvectiveOutlooks),
|
||||
Value: briefing.SPCConvectiveOutlooksModule{
|
||||
Checked: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.AreaForecastDiscussion,
|
||||
StanzaName: string(module.AreaForecastDiscussion),
|
||||
Value: briefing.AreaForecastDiscussionModule{
|
||||
KeyMessages: []string{"Rain chances increase late."},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.SPCConvectiveDiscussion,
|
||||
StanzaName: string(module.SPCConvectiveDiscussion),
|
||||
Value: briefing.SPCConvectiveDiscussionModule{
|
||||
IncludedBecause: "convective outlook",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.WeatherStory,
|
||||
StanzaName: string(module.WeatherStory),
|
||||
Value: briefing.WeatherStoryModule{
|
||||
Available: true,
|
||||
Title: "Rain returns",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.TomorrowPlanning,
|
||||
StanzaName: string(module.TomorrowPlanning),
|
||||
Value: briefing.TomorrowPlanningModule{
|
||||
MorningReadiness: []string{"Take sunglasses early."},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSnapshot() error = %v", err)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func floatPtr(value float64) *float64 {
|
||||
return &value
|
||||
}
|
||||
|
||||
func intPtr(value int) *int {
|
||||
return &value
|
||||
}
|
||||
|
||||
func assertOrderedText(t *testing.T, text string, ordered []string) {
|
||||
t.Helper()
|
||||
previousIndex := -1
|
||||
for _, want := range ordered {
|
||||
index := strings.Index(text, want)
|
||||
if index < 0 {
|
||||
t.Fatalf("text missing %q:\n%s", want, text)
|
||||
}
|
||||
if index <= previousIndex {
|
||||
t.Fatalf("%q appears out of order in:\n%s", want, text)
|
||||
}
|
||||
previousIndex = index
|
||||
}
|
||||
}
|
||||
62
internal/generatedtext/tomorrow.go
Normal file
62
internal/generatedtext/tomorrow.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package generatedtext
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"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) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
var value Tomorrow
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return Tomorrow{}, nil, fmt.Errorf("decode tomorrow generated text: %w", err)
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != nil {
|
||||
if err != io.EOF {
|
||||
return Tomorrow{}, nil, fmt.Errorf("decode tomorrow generated text: %w", err)
|
||||
}
|
||||
} else {
|
||||
return Tomorrow{}, nil, fmt.Errorf("decode tomorrow generated text: multiple JSON values")
|
||||
}
|
||||
|
||||
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 := json.Marshal(value)
|
||||
if err != nil {
|
||||
return Tomorrow{}, nil, fmt.Errorf("normalize tomorrow generated text: %w", 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,6 +21,8 @@ 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"
|
||||
TomorrowPlanning ID = "tomorrow_planning"
|
||||
)
|
||||
@@ -105,19 +107,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 +145,7 @@ type AreaForecastDiscussionOptions struct {
|
||||
Sections []string `json:"sections,omitempty" yaml:"sections,omitempty"`
|
||||
}
|
||||
type WeatherStoryOptions struct{}
|
||||
type SPCConvectiveOutlooksOptions struct{}
|
||||
type SPCConvectiveDiscussionOptions struct{}
|
||||
type OutdoorWindowsOptions struct{}
|
||||
type TomorrowPlanningOptions struct{}
|
||||
|
||||
@@ -82,3 +82,21 @@ 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{}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ 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,
|
||||
@@ -42,6 +43,7 @@ var briefingStanzaCategories = map[string]string{
|
||||
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,
|
||||
|
||||
@@ -164,6 +164,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 {
|
||||
|
||||
@@ -10,30 +10,34 @@ func dailyTodayDefinition() Definition {
|
||||
ID: DailyToday,
|
||||
Name: "Daily Report",
|
||||
PromptID: "weather.daily_report",
|
||||
GenerationMode: GenerationModeScriptoriumMarkdown,
|
||||
ComparisonStrategy: CompareSameValidDate,
|
||||
ArtifactGroup: "daily",
|
||||
BatchOutputName: "daily.md",
|
||||
Generated: true,
|
||||
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
||||
CompatiblePriorIDs: []ID{DailyToday},
|
||||
Modules: dailyTodayModules(),
|
||||
Morning: true,
|
||||
resolve: resolveDailyToday,
|
||||
}
|
||||
}
|
||||
|
||||
func dailyTomorrowDefinition() Definition {
|
||||
func tomorrowDefinition() 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,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,14 +50,16 @@ func dailyTodayModules() []module.ConfigItem {
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.SPCConvectiveOutlooks,
|
||||
module.AreaForecastDiscussion,
|
||||
module.SPCConvectiveDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
module.HourlyForecast,
|
||||
)
|
||||
}
|
||||
|
||||
func dailyTomorrowModules() []module.ConfigItem {
|
||||
func tomorrowModules() []module.ConfigItem {
|
||||
return moduleItems(
|
||||
module.Metadata,
|
||||
module.CurrentConditions,
|
||||
@@ -62,7 +68,9 @@ func dailyTomorrowModules() []module.ConfigItem {
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.SPCConvectiveOutlooks,
|
||||
module.AreaForecastDiscussion,
|
||||
module.SPCConvectiveDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
module.TomorrowPlanning,
|
||||
@@ -77,6 +85,6 @@ func resolveDailyToday(req ResolveRequest) (timeutil.Period, error) {
|
||||
return timeutil.CivilDay(req.Now, req.Location), nil
|
||||
}
|
||||
|
||||
func resolveDailyTomorrow(req ResolveRequest) (timeutil.Period, error) {
|
||||
func resolveTomorrow(req ResolveRequest) (timeutil.Period, error) {
|
||||
return timeutil.CivilDay(req.Now.In(req.Location).AddDate(0, 0, 1), req.Location), nil
|
||||
}
|
||||
|
||||
@@ -12,11 +12,12 @@ import (
|
||||
type ID string
|
||||
|
||||
const (
|
||||
DailyToday ID = "daily_today"
|
||||
DailyTomorrow ID = "daily_tomorrow"
|
||||
ThreeDay ID = "three_day"
|
||||
Weekend ID = "weekend"
|
||||
Storm ID = "storm"
|
||||
DailyToday ID = "daily_today"
|
||||
Tomorrow ID = "tomorrow"
|
||||
Hourly ID = "hourly"
|
||||
ThreeDay ID = "three_day"
|
||||
Weekend ID = "weekend"
|
||||
Storm ID = "storm"
|
||||
)
|
||||
|
||||
type ComparisonStrategy string
|
||||
@@ -25,6 +26,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 +44,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
|
||||
}
|
||||
@@ -32,7 +32,7 @@ func (r Registry) BatchReports(batch Batch, req ResolveRequest) ([]Resolved, err
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -37,13 +37,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 +67,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 {
|
||||
@@ -153,8 +216,27 @@ 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 TestBatchesDoNotIncludeHourly(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 {
|
||||
t.Fatalf("batch included %q, want hourly excluded", resolved.Definition.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,17 +250,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 == 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 +309,31 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
||||
batchOutputName string
|
||||
generated bool
|
||||
compatiblePriorIDs []ID
|
||||
comparisonStrategy ComparisonStrategy
|
||||
}{
|
||||
{
|
||||
id: DailyToday,
|
||||
artifactGroup: "daily",
|
||||
batchOutputName: "daily.md",
|
||||
generated: true,
|
||||
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
||||
compatiblePriorIDs: []ID{DailyToday},
|
||||
comparisonStrategy: CompareSameValidDate,
|
||||
},
|
||||
{
|
||||
id: DailyTomorrow,
|
||||
artifactGroup: "daily",
|
||||
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 +341,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
||||
batchOutputName: "three-day.md",
|
||||
generated: true,
|
||||
compatiblePriorIDs: []ID{ThreeDay},
|
||||
comparisonStrategy: CompareSameValidDate,
|
||||
},
|
||||
{
|
||||
id: Weekend,
|
||||
@@ -214,6 +349,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
||||
batchOutputName: "weekend.md",
|
||||
generated: true,
|
||||
compatiblePriorIDs: []ID{Weekend},
|
||||
comparisonStrategy: CompareWeekendWindow,
|
||||
},
|
||||
{
|
||||
id: Storm,
|
||||
@@ -221,6 +357,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
||||
batchOutputName: "storm.md",
|
||||
generated: true,
|
||||
compatiblePriorIDs: []ID{Storm},
|
||||
comparisonStrategy: CompareExplicitWindow,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -240,6 +377,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)
|
||||
}
|
||||
@@ -267,14 +407,16 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.SPCConvectiveOutlooks,
|
||||
module.AreaForecastDiscussion,
|
||||
module.SPCConvectiveDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
module.HourlyForecast,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: DailyTomorrow,
|
||||
id: Tomorrow,
|
||||
want: []module.ID{
|
||||
module.Metadata,
|
||||
module.CurrentConditions,
|
||||
@@ -283,13 +425,29 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
||||
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 +456,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.SPCConvectiveOutlooks,
|
||||
module.AreaForecastDiscussion,
|
||||
module.SPCConvectiveDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
},
|
||||
@@ -311,7 +471,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.SPCConvectiveOutlooks,
|
||||
module.AreaForecastDiscussion,
|
||||
module.SPCConvectiveDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
},
|
||||
@@ -323,7 +485,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
||||
module.CurrentConditions,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.SPCConvectiveOutlooks,
|
||||
module.AreaForecastDiscussion,
|
||||
module.SPCConvectiveDiscussion,
|
||||
module.WeatherStory,
|
||||
},
|
||||
},
|
||||
@@ -402,6 +566,24 @@ func TestResolvedMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func assertPeriod(t *testing.T, period timeutil.Period, wantStart string, wantEnd string) {
|
||||
t.Helper()
|
||||
if !period.IsValid() {
|
||||
@@ -423,6 +605,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")
|
||||
|
||||
@@ -13,7 +13,8 @@ type Registry struct {
|
||||
func DefaultRegistry() Registry {
|
||||
definitions := []Definition{
|
||||
dailyTodayDefinition(),
|
||||
dailyTomorrowDefinition(),
|
||||
tomorrowDefinition(),
|
||||
hourlyDefinition(),
|
||||
threeDayDefinition(),
|
||||
weekendDefinition(),
|
||||
stormDefinition(),
|
||||
@@ -73,7 +74,7 @@ func (r Registry) MustLookup(id ID) Definition {
|
||||
}
|
||||
|
||||
func (r Registry) All() []Definition {
|
||||
ids := []ID{DailyToday, DailyTomorrow, ThreeDay, Weekend, Storm}
|
||||
ids := []ID{DailyToday, 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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
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.
|
||||
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.
|
||||
62
internal/reporttemplate/reporttemplate.go
Normal file
62
internal/reporttemplate/reporttemplate.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// 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{
|
||||
"hourly": "templates/hourly.md.tmpl",
|
||||
"tomorrow": "templates/tomorrow.md.tmpl",
|
||||
}
|
||||
|
||||
var schemas = map[string]string{
|
||||
"hourly": "schemas/hourly.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
|
||||
}
|
||||
595
internal/reporttemplate/reporttemplate_test.go
Normal file
595
internal/reporttemplate/reporttemplate_test.go
Normal file
@@ -0,0 +1,595 @@
|
||||
package reporttemplate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTemplateLookup(t *testing.T) {
|
||||
source, err := Template("hourly")
|
||||
if err != nil {
|
||||
t.Fatalf("Template() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{"# {{ .Report.Title }}", "**Updated:**", "## Current Conditions", "## Hourly Forecast", "## Forecast Discussion"} {
|
||||
if !strings.Contains(source, want) {
|
||||
t.Fatalf("template missing %q:\n%s", want, source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTomorrowTemplateLookup(t *testing.T) {
|
||||
source, err := Template("tomorrow")
|
||||
if err != nil {
|
||||
t.Fatalf("Template() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{"# {{ .Report.Title }}", "**Forecast date:**", "## Daypart Forecast", "## Precipitation Timing", "## Forecast Discussion"} {
|
||||
if !strings.Contains(source, want) {
|
||||
t.Fatalf("template missing %q:\n%s", want, source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaLookup(t *testing.T) {
|
||||
data, err := Schema("hourly")
|
||||
if err != nil {
|
||||
t.Fatalf("Schema() error = %v", err)
|
||||
}
|
||||
assertStringSchema(t, data, "summary,forecast_discussion", []string{"summary", "forecast_discussion", "precipitation_timing", "confidence"})
|
||||
}
|
||||
|
||||
func TestTomorrowSchemaLookup(t *testing.T) {
|
||||
data, err := Schema("tomorrow")
|
||||
if err != nil {
|
||||
t.Fatalf("Schema() error = %v", err)
|
||||
}
|
||||
schema := assertSchema(t, data, "summary,forecast_discussion")
|
||||
property, ok := schema.Properties["forecast_discussion"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("schema property forecast_discussion missing or invalid")
|
||||
}
|
||||
if property["type"] != "array" {
|
||||
t.Fatalf("forecast_discussion type = %v, want array", property["type"])
|
||||
}
|
||||
if property["minItems"] != float64(1) {
|
||||
t.Fatalf("forecast_discussion minItems = %v, want 1", property["minItems"])
|
||||
}
|
||||
items, ok := property["items"].(map[string]any)
|
||||
if !ok || items["type"] != "string" {
|
||||
t.Fatalf("forecast_discussion items = %#v, want string items", property["items"])
|
||||
}
|
||||
for _, field := range []string{"summary", "precipitation_timing", "confidence"} {
|
||||
property, ok := schema.Properties[field].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema property %q missing or invalid", field)
|
||||
}
|
||||
if property["type"] != "string" {
|
||||
t.Fatalf("schema property %q type = %v, want string", field, property["type"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertStringSchema(t *testing.T, data []byte, required string, fields []string) {
|
||||
t.Helper()
|
||||
schema := assertSchema(t, data, required)
|
||||
for _, field := range fields {
|
||||
property, ok := schema.Properties[field].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema property %q missing or invalid", field)
|
||||
}
|
||||
if property["type"] != "string" {
|
||||
t.Fatalf("schema property %q type = %v, want string", field, property["type"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertSchema(t *testing.T, data []byte, required string) struct {
|
||||
Type string `json:"type"`
|
||||
AdditionalProperties bool `json:"additionalProperties"`
|
||||
Required []string `json:"required"`
|
||||
Properties map[string]any `json:"properties"`
|
||||
} {
|
||||
t.Helper()
|
||||
var schema struct {
|
||||
Type string `json:"type"`
|
||||
AdditionalProperties bool `json:"additionalProperties"`
|
||||
Required []string `json:"required"`
|
||||
Properties map[string]any `json:"properties"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &schema); err != nil {
|
||||
t.Fatalf("schema is invalid JSON: %v", err)
|
||||
}
|
||||
if schema.Type != "object" {
|
||||
t.Fatalf("schema type = %q, want object", schema.Type)
|
||||
}
|
||||
if schema.AdditionalProperties {
|
||||
t.Fatal("additionalProperties = true, want false")
|
||||
}
|
||||
if strings.Join(schema.Required, ",") != required {
|
||||
t.Fatalf("required = %#v, want %s", schema.Required, required)
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
func TestRenderHourly(t *testing.T) {
|
||||
rendered, err := Render("hourly", testRenderContext{
|
||||
Report: testReportContext{
|
||||
Title: "Hourly Report",
|
||||
LocationName: "Brentwood",
|
||||
ValidPeriodLabel: "May 29, 8:30 AM to 2:30 PM",
|
||||
GeneratedAtLabel: "Saturday, June 14, 2026 at 9:14 AM",
|
||||
},
|
||||
GeneratedText: testGeneratedText{
|
||||
Summary: "Storm chances increase through late morning.",
|
||||
ForecastDiscussion: "A front will keep the region unsettled.",
|
||||
PrecipitationTiming: "A cold front is moving into the region.",
|
||||
Confidence: "Medium confidence in timing.",
|
||||
},
|
||||
Modules: testModules{
|
||||
CurrentConditions: &testCurrentConditions{
|
||||
ConditionText: "Partly cloudy",
|
||||
ConditionTextLower: "partly cloudy",
|
||||
TemperatureF: intPtr(74),
|
||||
ApparentTemperatureF: intPtr(76),
|
||||
RelativeHumidityPercent: intPtr(71),
|
||||
WindDirection: "S",
|
||||
WindDirectionText: "south",
|
||||
WindSpeedMph: intPtr(8),
|
||||
},
|
||||
HourlyForecast: &testHourlyForecast{
|
||||
Periods: []testHourlyPeriod{
|
||||
{HourLabel: "9:00 AM", TextDescription: "Cloudy", TextDescriptionLower: "cloudy", TemperatureF: floatPtr(74), ProbabilityOfPrecipitationPercent: floatPtr(19), WindDirection: "S", WindSpeedMph: floatPtr(8)},
|
||||
{HourLabel: "10:00 AM", TextDescription: "Showers", TextDescriptionLower: "showers", TemperatureF: floatPtr(75), ProbabilityOfPrecipitationPercent: floatPtr(70), MentionPrecipitation: true, WindDirection: "S", WindSpeedMph: floatPtr(10)},
|
||||
},
|
||||
},
|
||||
PrecipTiming: &testPrecipTiming{
|
||||
MaxPopPercent: intPtr(70),
|
||||
MaxPopTime: "10 AM",
|
||||
PrecipitationWindows: []testPrecipWindow{
|
||||
{PeriodBegins: "10 AM", PeriodBeginsHourLabel: "10:00 AM", PeriodEnds: "12 PM", PeriodEndsHourLabel: "12:00 PM", MaxPopPercent: intPtr(70), MaxPopTime: "10 AM", MaxPopHourLabel: "10:00 AM"},
|
||||
},
|
||||
},
|
||||
AlertDigest: &testAlertDigest{
|
||||
Relevant: []testAlert{{Event: "Flood Watch", Headline: "Flood Watch until 2:30 PM", Severity: "Moderate"}},
|
||||
},
|
||||
SPCConvectiveOutlooks: &testSPCOutlooks{
|
||||
Outlooks: []testSPCOutlook{{LabelText: "Slight Risk", PeriodBegins: "8 AM", PeriodEnds: "2 PM"}},
|
||||
},
|
||||
SPCConvectiveDiscussion: &testSPCDiscussion{
|
||||
Discussions: []testSPCDiscussionRecord{{Summary: "Strong storms may develop late morning."}},
|
||||
},
|
||||
AreaForecastDiscussion: &testForecastDiscussion{
|
||||
KeyMessages: []string{"Storms are most likely late morning."},
|
||||
ShortTerm: "Short-term discussion favors increasing rain coverage.",
|
||||
},
|
||||
WeatherStory: &testWeatherStory{
|
||||
Available: true,
|
||||
Title: "Morning storms",
|
||||
Description: "Morning storms remain the main story.",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Render() error = %v", err)
|
||||
}
|
||||
text := string(rendered)
|
||||
for _, want := range []string{
|
||||
"# Hourly Report",
|
||||
"**Updated:** Saturday, June 14, 2026 at 9:14 AM",
|
||||
"Storm chances increase through late morning.",
|
||||
"Currently, it is 74°F and partly cloudy. It feels like 76°F, with a relative humidity of 71% and winds from the south at 8 mph.",
|
||||
"## Active Alerts",
|
||||
"- **Flood Watch**: Flood Watch until 2:30 PM",
|
||||
"- **9:00 AM:** 74°F and cloudy.",
|
||||
"- **10:00 AM:** 75°F and showers. Probability of precipitation is 70%.",
|
||||
"- **10:00 AM** to **12:00 PM**: Precipitation is expected during this period. The peak precipitation chance is 70% at 10:00 AM.",
|
||||
"A cold front is moving into the region.",
|
||||
"A front will keep the region unsettled.",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("rendered template missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, "19%") || strings.Contains(text, "wind S") || strings.Contains(text, "## Confidence") {
|
||||
t.Fatalf("rendered template included omitted details:\n%s", text)
|
||||
}
|
||||
assertOrderedText(t, text, []string{
|
||||
"# Hourly Report",
|
||||
"## Current Conditions",
|
||||
"## Active Alerts",
|
||||
"## Hourly Forecast",
|
||||
"## Precipitation Timing",
|
||||
"## Forecast Discussion",
|
||||
})
|
||||
}
|
||||
|
||||
func TestRenderTomorrow(t *testing.T) {
|
||||
rendered, err := Render("tomorrow", testTomorrowRenderContext{
|
||||
Report: testTomorrowReportContext{
|
||||
Title: "Monday's Weather",
|
||||
ForecastDateLabel: "Monday, June 15, 2026",
|
||||
GeneratedAtLabel: "Sunday, June 14, 2026 at 9:14 AM",
|
||||
},
|
||||
GeneratedText: testTomorrowGeneratedText{
|
||||
Summary: "Tomorrow starts dry before showers return later in the day.",
|
||||
ForecastDiscussion: []string{
|
||||
"Clouds increase after sunrise.",
|
||||
"Rain chances peak during the afternoon.",
|
||||
},
|
||||
PrecipitationTiming: "A few showers may linger into early evening.",
|
||||
},
|
||||
Modules: testTomorrowModules{
|
||||
Dayparts: []testTomorrowDaypart{
|
||||
{
|
||||
Key: "overnight",
|
||||
Summary: testDaypartSummary{
|
||||
DisplayName: "Overnight",
|
||||
TemperatureTrend: "falling",
|
||||
TemperatureStartPhraseF: "mid 60s",
|
||||
TemperatureEndPhraseF: "upper 50s",
|
||||
DominantConditionDisplay: "Partly cloudy",
|
||||
DominantConditionLower: "partly cloudy",
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "morning",
|
||||
Summary: testDaypartSummary{
|
||||
DisplayName: "Morning",
|
||||
TemperatureTrend: "rising",
|
||||
TemperatureStartPhraseF: "upper 50s",
|
||||
TemperatureEndPhraseF: "upper 60s",
|
||||
DominantConditionDisplay: "Sunny",
|
||||
DominantConditionLower: "sunny",
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "afternoon",
|
||||
Summary: testDaypartSummary{
|
||||
DisplayName: "Afternoon",
|
||||
TemperatureTrend: "steady",
|
||||
TemperatureSteadyPhraseF: "upper 70s",
|
||||
DominantConditionDisplay: "Sunny",
|
||||
DominantConditionLower: "sunny",
|
||||
MaxPopPercent: intPtr(70),
|
||||
MaxPopTimeLabel: "3:00 PM",
|
||||
MentionPrecipitation: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
PrecipTiming: &testPrecipTiming{
|
||||
PrecipitationWindows: []testPrecipWindow{
|
||||
{PeriodBeginsHourLabel: "3:00 PM", PeriodEndsHourLabel: "6:00 PM", MaxPopPercent: intPtr(70), MaxPopHourLabel: "3:00 PM"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Render() error = %v", err)
|
||||
}
|
||||
text := string(rendered)
|
||||
for _, want := range []string{
|
||||
"# Monday's Weather",
|
||||
"**Forecast date:** Monday, June 15, 2026",
|
||||
"**Updated:** Sunday, June 14, 2026 at 9:14 AM",
|
||||
"Tomorrow starts dry before showers return later in the day.",
|
||||
"- **Overnight:** Partly cloudy, with temperatures falling from the mid 60s to the upper 50s.",
|
||||
"- **Morning:** Sunny, with temperatures rising from the upper 50s to the upper 60s.",
|
||||
"- **Afternoon:** Sunny, with temperatures in the upper 70s. Chance of precipitation is 70%.",
|
||||
"- **3:00 PM** to **6:00 PM**: Precipitation is expected during this period. The peak precipitation chance is 70% at 3:00 PM.",
|
||||
"A few showers may linger into early evening.",
|
||||
"Clouds increase after sunrise.",
|
||||
"Rain chances peak during the afternoon.",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("rendered template missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{
|
||||
"upper 50s.\n\n- **Morning:**",
|
||||
"upper 60s.\n\n- **Afternoon:**",
|
||||
} {
|
||||
if strings.Contains(text, unwanted) {
|
||||
t.Fatalf("rendered template includes blank lines between daypart bullets:\n%s", text)
|
||||
}
|
||||
}
|
||||
assertOrderedText(t, text, []string{
|
||||
"# Monday's Weather",
|
||||
"## Daypart Forecast",
|
||||
"- **Overnight:**",
|
||||
"- **Morning:**",
|
||||
"- **Afternoon:**",
|
||||
"## Precipitation Timing",
|
||||
"## Forecast Discussion",
|
||||
"Clouds increase after sunrise.",
|
||||
"Rain chances peak during the afternoon.",
|
||||
})
|
||||
}
|
||||
|
||||
func TestRenderTomorrowOmitsPrecipitationTimingWithoutWindows(t *testing.T) {
|
||||
rendered, err := Render("tomorrow", testTomorrowRenderContext{
|
||||
Report: testTomorrowReportContext{
|
||||
Title: "Monday's Weather",
|
||||
ForecastDateLabel: "Monday, June 15, 2026",
|
||||
GeneratedAtLabel: "Sunday, June 14, 2026 at 9:14 AM",
|
||||
},
|
||||
GeneratedText: testTomorrowGeneratedText{
|
||||
Summary: "Dry weather is expected tomorrow.",
|
||||
ForecastDiscussion: []string{"High pressure keeps rain chances low."},
|
||||
PrecipitationTiming: "Any stray shower chance is too low to highlight.",
|
||||
},
|
||||
Modules: testTomorrowModules{
|
||||
Dayparts: []testTomorrowDaypart{
|
||||
{
|
||||
Key: "morning",
|
||||
Summary: testDaypartSummary{
|
||||
DisplayName: "Morning",
|
||||
TemperatureTrend: "steady",
|
||||
TemperatureSteadyPhraseF: "low 60s",
|
||||
DominantConditionDisplay: "Clear",
|
||||
DominantConditionLower: "clear",
|
||||
},
|
||||
},
|
||||
},
|
||||
PrecipTiming: &testPrecipTiming{},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Render() error = %v", err)
|
||||
}
|
||||
text := string(rendered)
|
||||
for _, unwanted := range []string{"## Precipitation Timing", "Any stray shower chance is too low to highlight."} {
|
||||
if strings.Contains(text, unwanted) {
|
||||
t.Fatalf("dry render includes %q:\n%s", unwanted, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHourlyOmitsConditionalSectionsForClearWeather(t *testing.T) {
|
||||
rendered, err := Render("hourly", testRenderContext{
|
||||
Report: testReportContext{
|
||||
Title: "Hourly Report",
|
||||
GeneratedAtLabel: "Saturday, June 14, 2026 at 9:14 AM",
|
||||
},
|
||||
GeneratedText: testGeneratedText{
|
||||
Summary: "Dry weather is expected through the next several hours.",
|
||||
ForecastDiscussion: "Quiet conditions should persist through midday.",
|
||||
},
|
||||
Modules: testModules{
|
||||
CurrentConditions: &testCurrentConditions{
|
||||
ConditionTextLower: "cloudy",
|
||||
TemperatureF: intPtr(72),
|
||||
},
|
||||
HourlyForecast: &testHourlyForecast{
|
||||
Periods: []testHourlyPeriod{
|
||||
{HourLabel: "9:00 AM", TextDescriptionLower: "mostly cloudy", TemperatureF: floatPtr(71), ProbabilityOfPrecipitationPercent: floatPtr(10)},
|
||||
},
|
||||
},
|
||||
AlertDigest: &testAlertDigest{},
|
||||
PrecipTiming: &testPrecipTiming{
|
||||
MaxPopPercent: intPtr(10),
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Render() error = %v", err)
|
||||
}
|
||||
text := string(rendered)
|
||||
for _, unwanted := range []string{"## Active Alerts", "## Precipitation Timing", "Probability of precipitation is 10%", "wind"} {
|
||||
if strings.Contains(text, unwanted) {
|
||||
t.Fatalf("clear render includes %q:\n%s", unwanted, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownAssetsReturnActionableErrors(t *testing.T) {
|
||||
if _, err := Template("daily"); err == nil || !strings.Contains(err.Error(), `unknown report template "daily"`) {
|
||||
t.Fatalf("Template() error = %v, want unknown template", err)
|
||||
}
|
||||
if _, err := Schema("daily"); err == nil || !strings.Contains(err.Error(), `unknown generated text schema "daily"`) {
|
||||
t.Fatalf("Schema() error = %v, want unknown schema", err)
|
||||
}
|
||||
if _, err := Render("daily", testRenderContext{}); err == nil || !strings.Contains(err.Error(), `unknown report template "daily"`) {
|
||||
t.Fatalf("Render() error = %v, want unknown template", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderFailsForMissingContextFields(t *testing.T) {
|
||||
_, err := Render("hourly", map[string]any{"Report": map[string]any{"Title": "Hourly Report"}})
|
||||
if err == nil {
|
||||
t.Fatal("Render() error = nil, want missing field error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `render report template "hourly"`) {
|
||||
t.Fatalf("Render() error = %v, want render context", err)
|
||||
}
|
||||
}
|
||||
|
||||
type testRenderContext struct {
|
||||
Report testReportContext
|
||||
GeneratedText testGeneratedText
|
||||
Modules testModules
|
||||
}
|
||||
|
||||
type testTomorrowRenderContext struct {
|
||||
Report testTomorrowReportContext
|
||||
GeneratedText testTomorrowGeneratedText
|
||||
Modules testTomorrowModules
|
||||
}
|
||||
|
||||
type testReportContext struct {
|
||||
Title string
|
||||
LocationName string
|
||||
ValidPeriodLabel string
|
||||
GeneratedAtLabel string
|
||||
}
|
||||
|
||||
type testGeneratedText struct {
|
||||
Summary string
|
||||
ForecastDiscussion string
|
||||
PrecipitationTiming string
|
||||
Confidence string
|
||||
}
|
||||
|
||||
type testTomorrowReportContext struct {
|
||||
Title string
|
||||
ForecastDateLabel string
|
||||
GeneratedAtLabel string
|
||||
}
|
||||
|
||||
type testTomorrowGeneratedText struct {
|
||||
Summary string
|
||||
ForecastDiscussion []string
|
||||
PrecipitationTiming string
|
||||
Confidence string
|
||||
}
|
||||
|
||||
type testModules struct {
|
||||
CurrentConditions *testCurrentConditions
|
||||
HourlyForecast *testHourlyForecast
|
||||
PrecipTiming *testPrecipTiming
|
||||
AlertDigest *testAlertDigest
|
||||
SPCConvectiveOutlooks *testSPCOutlooks
|
||||
AreaForecastDiscussion *testForecastDiscussion
|
||||
SPCConvectiveDiscussion *testSPCDiscussion
|
||||
WeatherStory *testWeatherStory
|
||||
}
|
||||
|
||||
type testTomorrowModules struct {
|
||||
Dayparts []testTomorrowDaypart
|
||||
PrecipTiming *testPrecipTiming
|
||||
}
|
||||
|
||||
type testTomorrowDaypart struct {
|
||||
Key string
|
||||
Summary testDaypartSummary
|
||||
}
|
||||
|
||||
type testDaypartSummary struct {
|
||||
DisplayName string
|
||||
TempRangeF string
|
||||
TemperaturePhraseF string
|
||||
TemperatureTrend string
|
||||
TemperatureStartPhraseF string
|
||||
TemperatureEndPhraseF string
|
||||
TemperaturePeakPhraseF string
|
||||
TemperatureSteadyPhraseF string
|
||||
MaxPopPercent *int
|
||||
MaxPopTime string
|
||||
MaxPopTimeLabel string
|
||||
MentionPrecipitation bool
|
||||
DominantCondition string
|
||||
DominantConditionLower string
|
||||
DominantConditionDisplay string
|
||||
}
|
||||
|
||||
type testCurrentConditions struct {
|
||||
ConditionText string
|
||||
ConditionTextLower string
|
||||
TemperatureF *int
|
||||
ApparentTemperatureF *int
|
||||
RelativeHumidityPercent *int
|
||||
WindSpeedMph *int
|
||||
WindDirection string
|
||||
WindDirectionText string
|
||||
}
|
||||
|
||||
type testHourlyForecast struct {
|
||||
Periods []testHourlyPeriod
|
||||
}
|
||||
|
||||
type testHourlyPeriod struct {
|
||||
HourLabel string
|
||||
PeriodBegins string
|
||||
Name string
|
||||
TextDescription string
|
||||
TextDescriptionLower string
|
||||
TemperatureF *float64
|
||||
WindSpeedMph *float64
|
||||
WindGustMph *float64
|
||||
WindDirection string
|
||||
ProbabilityOfPrecipitationPercent *float64
|
||||
MentionPrecipitation bool
|
||||
}
|
||||
|
||||
type testPrecipTiming struct {
|
||||
MaxPopPercent *int
|
||||
MaxPopTime string
|
||||
PrecipitationWindows []testPrecipWindow
|
||||
ThunderMentioned bool
|
||||
}
|
||||
|
||||
type testPrecipWindow struct {
|
||||
PeriodBegins string
|
||||
PeriodBeginsHourLabel string
|
||||
PeriodEnds string
|
||||
PeriodEndsHourLabel string
|
||||
MaxPopPercent *int
|
||||
MaxPopTime string
|
||||
MaxPopHourLabel string
|
||||
}
|
||||
|
||||
type testAlertDigest struct {
|
||||
Missing bool
|
||||
Relevant []testAlert
|
||||
}
|
||||
|
||||
type testAlert struct {
|
||||
Event string
|
||||
Headline string
|
||||
Severity string
|
||||
}
|
||||
|
||||
type testSPCOutlooks struct {
|
||||
Outlooks []testSPCOutlook
|
||||
}
|
||||
|
||||
type testSPCOutlook struct {
|
||||
Label string
|
||||
LabelText string
|
||||
OutlookType string
|
||||
PeriodBegins string
|
||||
PeriodEnds string
|
||||
}
|
||||
|
||||
type testForecastDiscussion struct {
|
||||
KeyMessages []string
|
||||
ShortTerm string
|
||||
}
|
||||
|
||||
type testSPCDiscussion struct {
|
||||
Discussions []testSPCDiscussionRecord
|
||||
}
|
||||
|
||||
type testSPCDiscussionRecord struct {
|
||||
Headline string
|
||||
Summary string
|
||||
Discussion string
|
||||
}
|
||||
|
||||
type testWeatherStory struct {
|
||||
Available bool
|
||||
Title string
|
||||
Description string
|
||||
}
|
||||
|
||||
func floatPtr(value float64) *float64 {
|
||||
return &value
|
||||
}
|
||||
|
||||
func intPtr(value int) *int {
|
||||
return &value
|
||||
}
|
||||
|
||||
func assertOrderedText(t *testing.T, text string, ordered []string) {
|
||||
t.Helper()
|
||||
previousIndex := -1
|
||||
for _, want := range ordered {
|
||||
index := strings.Index(text, want)
|
||||
if index < 0 {
|
||||
t.Fatalf("text missing %q:\n%s", want, text)
|
||||
}
|
||||
if index <= previousIndex {
|
||||
t.Fatalf("%q appears out of order in:\n%s", want, text)
|
||||
}
|
||||
previousIndex = index
|
||||
}
|
||||
}
|
||||
@@ -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.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"
|
||||
}
|
||||
}
|
||||
}
|
||||
39
internal/reporttemplate/templates/hourly.md.tmpl
Normal file
39
internal/reporttemplate/templates/hourly.md.tmpl
Normal file
@@ -0,0 +1,39 @@
|
||||
# {{ .Report.Title }}
|
||||
|
||||
**Updated:** {{ .Report.GeneratedAtLabel }}
|
||||
|
||||
{{ .GeneratedText.Summary }}
|
||||
|
||||
## Current Conditions
|
||||
{{ with .Modules.CurrentConditions }}
|
||||
{{ with .TemperatureF }}Currently, it is {{ . }}°F{{ with $.Modules.CurrentConditions.ConditionTextLower }} and {{ . }}{{ end }}.{{ else }}{{ with .ConditionText }}Currently, it is {{ . }}.{{ else }}Current conditions are unavailable.{{ end }}{{ end }}{{ with .ApparentTemperatureF }} It feels like {{ . }}°F{{ with $.Modules.CurrentConditions.RelativeHumidityPercent }}, with a relative humidity of {{ . }}%{{ end }}{{ with $.Modules.CurrentConditions.WindDirectionText }} and winds from the {{ . }}{{ with $.Modules.CurrentConditions.WindSpeedMph }} at {{ . }} mph{{ end }}{{ end }}.{{ else }}{{ with .RelativeHumidityPercent }} Relative humidity is {{ . }}%.{{ end }}{{ with .WindDirectionText }} Winds are from the {{ . }}{{ with $.Modules.CurrentConditions.WindSpeedMph }} at {{ . }} mph{{ end }}.{{ end }}{{ end }}
|
||||
{{ else }}
|
||||
Current conditions are unavailable.
|
||||
{{ end }}
|
||||
|
||||
{{ with .Modules.AlertDigest }}{{ with .Relevant }}
|
||||
## Active Alerts
|
||||
{{ range . }}
|
||||
- **{{ if .Event }}{{ .Event }}{{ else }}{{ .Headline }}{{ end }}**{{ with .Headline }}: {{ . }}{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}{{ end }}
|
||||
## Hourly Forecast
|
||||
{{ with .Modules.HourlyForecast }}{{ range .Periods }}
|
||||
- **{{ if .HourLabel }}{{ .HourLabel }}{{ else }}{{ .Name }}{{ end }}:**{{ with .TemperatureF }} {{ . }}°F{{ end }}{{ with .TextDescriptionLower }} and {{ . }}{{ else }}{{ with .TextDescription }} and {{ . }}{{ end }}{{ end }}.{{ if .MentionPrecipitation }}{{ with .ProbabilityOfPrecipitationPercent }} Probability of precipitation is {{ . }}%.{{ end }}{{ end }}
|
||||
{{ else }}
|
||||
- No hourly forecast rows are available.
|
||||
{{ end }}{{ else }}
|
||||
- No hourly forecast rows are available.
|
||||
{{ end }}
|
||||
|
||||
{{ with .Modules.PrecipTiming }}{{ with .PrecipitationWindows }}
|
||||
## Precipitation Timing
|
||||
{{ range . }}{{ $window := . }}
|
||||
- **{{ if .PeriodBeginsHourLabel }}{{ .PeriodBeginsHourLabel }}{{ else }}{{ .PeriodBegins }}{{ end }}**{{ with .PeriodEndsHourLabel }} to **{{ . }}**{{ else }}{{ with .PeriodEnds }} to **{{ . }}**{{ end }}{{ end }}: Precipitation is expected during this period.{{ with .MaxPopPercent }} The peak precipitation chance is {{ . }}%{{ with $window.MaxPopHourLabel }} at {{ . }}{{ else }}{{ with $window.MaxPopTime }} at {{ . }}{{ end }}{{ end }}.{{ end }}
|
||||
{{ end }}{{ with $.GeneratedText.PrecipitationTiming }}
|
||||
{{ . }}
|
||||
{{ end }}
|
||||
{{ end }}{{ end }}
|
||||
## Forecast Discussion
|
||||
|
||||
{{ .GeneratedText.ForecastDiscussion }}
|
||||
24
internal/reporttemplate/templates/tomorrow.md.tmpl
Normal file
24
internal/reporttemplate/templates/tomorrow.md.tmpl
Normal file
@@ -0,0 +1,24 @@
|
||||
# {{ .Report.Title }}
|
||||
|
||||
**Forecast date:** {{ .Report.ForecastDateLabel }}
|
||||
**Updated:** {{ .Report.GeneratedAtLabel }}
|
||||
|
||||
{{ .GeneratedText.Summary }}
|
||||
|
||||
## Daypart Forecast
|
||||
{{ with .Modules.Dayparts }}{{ range . }}{{ $daypart := . }}- **{{ if .Summary.DisplayName }}{{ .Summary.DisplayName }}{{ else }}{{ .Key }}{{ end }}:** {{ with .Summary.DominantConditionDisplay }}{{ . }}{{ else }}{{ with .Summary.DominantCondition }}{{ . }}{{ else }}Forecast details are limited{{ end }}{{ end }}{{ if eq .Summary.TemperatureTrend "rising" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures rising from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "falling" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures falling from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "peaking" }}{{ with .Summary.TemperaturePeakPhraseF }}, with temperatures peaking in the {{ . }}{{ end }}{{ else }}{{ with .Summary.TemperatureSteadyPhraseF }}, with temperatures in the {{ . }}{{ else }}{{ with .Summary.TemperaturePhraseF }}, with temperatures in the {{ . }}{{ end }}{{ end }}{{ end }}.{{ if .Summary.MentionPrecipitation }}{{ with .Summary.MaxPopPercent }} Chance of precipitation is {{ . }}%.{{ end }}{{ end }}
|
||||
{{ end }}{{ else }}- No daypart forecast details are available.
|
||||
{{ end }}
|
||||
|
||||
{{ with .Modules.PrecipTiming }}{{ with .PrecipitationWindows }}
|
||||
## Precipitation Timing
|
||||
{{ range . }}{{ $window := . }}
|
||||
- **{{ if .PeriodBeginsHourLabel }}{{ .PeriodBeginsHourLabel }}{{ else }}{{ .PeriodBegins }}{{ end }}**{{ with .PeriodEndsHourLabel }} to **{{ . }}**{{ else }}{{ with .PeriodEnds }} to **{{ . }}**{{ end }}{{ end }}: Precipitation is expected during this period.{{ with .MaxPopPercent }} The peak precipitation chance is {{ . }}%{{ with $window.MaxPopHourLabel }} at {{ . }}{{ else }}{{ with $window.MaxPopTime }} at {{ . }}{{ end }}{{ end }}.{{ end }}
|
||||
{{ end }}{{ with $.GeneratedText.PrecipitationTiming }}
|
||||
{{ . }}
|
||||
{{ end }}
|
||||
{{ end }}{{ end }}
|
||||
## Forecast Discussion
|
||||
{{ range .GeneratedText.ForecastDiscussion }}
|
||||
{{ . }}
|
||||
{{ end }}
|
||||
@@ -27,12 +27,16 @@ type FilesystemStore struct {
|
||||
}
|
||||
|
||||
type ArtifactPaths struct {
|
||||
ModuleSnapshot string `json:"moduleSnapshot"`
|
||||
Metadata string `json:"metadata"`
|
||||
DataPackage string `json:"dataPackage"`
|
||||
Preflight string `json:"preflight"`
|
||||
Notification string `json:"notification,omitempty"`
|
||||
RenderedReport string `json:"renderedReport,omitempty"`
|
||||
ModuleSnapshot string `json:"moduleSnapshot"`
|
||||
Metadata string `json:"metadata"`
|
||||
DataPackage string `json:"dataPackage"`
|
||||
Preflight string `json:"preflight"`
|
||||
Notification string `json:"notification,omitempty"`
|
||||
RenderedReport string `json:"renderedReport,omitempty"`
|
||||
GeneratedTextRaw string `json:"generatedTextRaw,omitempty"`
|
||||
GeneratedTextResult string `json:"generatedTextResult,omitempty"`
|
||||
GeneratedText string `json:"generatedText,omitempty"`
|
||||
RenderContext string `json:"renderContext,omitempty"`
|
||||
}
|
||||
|
||||
type ReportRecord struct {
|
||||
@@ -89,12 +93,16 @@ func (s *FilesystemStore) Paths(resolved report.Resolved) (ArtifactPaths, error)
|
||||
validDate := resolved.ValidPeriod.Start.Format("2006-01-02")
|
||||
filenameBase := metadata.RunID
|
||||
return ArtifactPaths{
|
||||
ModuleSnapshot: s.join(s.snapshotsDir, group, validDate, filenameBase+".modules.json"),
|
||||
Metadata: s.join(s.snapshotsDir, group, validDate, filenameBase+".metadata.json"),
|
||||
DataPackage: s.join(s.dataPackagesDir, group, validDate, filenameBase+".data_package.yaml"),
|
||||
Preflight: s.join(s.preflightDir, group, validDate, filenameBase+".render.json"),
|
||||
Notification: s.join(s.notificationsDir, group, validDate, filenameBase+".distributor.json"),
|
||||
RenderedReport: s.join(s.reportsDir, group, filenameBase+".md"),
|
||||
ModuleSnapshot: s.join(s.snapshotsDir, group, validDate, filenameBase+".modules.json"),
|
||||
Metadata: s.join(s.snapshotsDir, group, validDate, filenameBase+".metadata.json"),
|
||||
DataPackage: s.join(s.dataPackagesDir, group, validDate, filenameBase+".data_package.yaml"),
|
||||
Preflight: s.join(s.preflightDir, group, validDate, filenameBase+".render.json"),
|
||||
Notification: s.join(s.notificationsDir, group, validDate, filenameBase+".distributor.json"),
|
||||
RenderedReport: s.join(s.reportsDir, group, filenameBase+".md"),
|
||||
GeneratedTextRaw: s.join(s.snapshotsDir, group, validDate, filenameBase+".generated_text.raw.json"),
|
||||
GeneratedTextResult: s.join(s.snapshotsDir, group, validDate, filenameBase+".generated_text.run.json"),
|
||||
GeneratedText: s.join(s.snapshotsDir, group, validDate, filenameBase+".generated_text.json"),
|
||||
RenderContext: s.join(s.snapshotsDir, group, validDate, filenameBase+".render_context.json"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -148,6 +156,50 @@ func (s *FilesystemStore) SaveDistributorNotification(_ context.Context, resolve
|
||||
return paths.Notification, nil
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) SaveGeneratedTextRaw(_ context.Context, resolved report.Resolved, data []byte) (string, error) {
|
||||
paths, err := s.Paths(resolved)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := fileutil.WriteFileAtomic(paths.GeneratedTextRaw, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return paths.GeneratedTextRaw, nil
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) SaveGeneratedTextResult(_ context.Context, resolved report.Resolved, value any) (string, error) {
|
||||
paths, err := s.Paths(resolved)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := fileutil.WriteJSONAtomic(paths.GeneratedTextResult, value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return paths.GeneratedTextResult, nil
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) SaveGeneratedText(_ context.Context, resolved report.Resolved, data []byte) (string, error) {
|
||||
paths, err := s.Paths(resolved)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := fileutil.WriteFileAtomic(paths.GeneratedText, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return paths.GeneratedText, nil
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) SaveRenderContext(_ context.Context, resolved report.Resolved, value any) (string, error) {
|
||||
paths, err := s.Paths(resolved)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := fileutil.WriteJSONAtomic(paths.RenderContext, value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return paths.RenderContext, nil
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) PrepareRenderedReport(_ context.Context, resolved report.Resolved) (string, error) {
|
||||
paths, err := s.Paths(resolved)
|
||||
if err != nil {
|
||||
@@ -325,6 +377,37 @@ func (s *FilesystemStore) LoadModuleSnapshot(_ context.Context, path string) (mo
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) LoadGeneratedText(_ context.Context, path string) ([]byte, error) {
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("generated text path is required")
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %q: %w", path, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) LoadGeneratedTextResult(_ context.Context, path string, target any) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("generated text result path is required")
|
||||
}
|
||||
if target == nil {
|
||||
return fmt.Errorf("generated text result target is required")
|
||||
}
|
||||
return readJSON(path, target)
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) LoadRenderContext(_ context.Context, path string, target any) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("render context path is required")
|
||||
}
|
||||
if target == nil {
|
||||
return fmt.Errorf("render context target is required")
|
||||
}
|
||||
return readJSON(path, target)
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) reportRecord(path string) (ReportRecord, error) {
|
||||
var metadata Metadata
|
||||
if err := readJSON(path, &metadata); err != nil {
|
||||
|
||||
@@ -40,6 +40,62 @@ func TestPathsUseRunIDAndWorkspace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedTextArtifactPathsUseSnapshotTree(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
tests := []struct {
|
||||
name string
|
||||
resolved report.Resolved
|
||||
group string
|
||||
validDate string
|
||||
runID string
|
||||
}{
|
||||
{
|
||||
name: "hourly",
|
||||
resolved: resolveHourlyAt(t, "2026-05-29T05:00:00-05:00"),
|
||||
group: "hourly",
|
||||
validDate: "2026-05-29",
|
||||
runID: "20260529T100000.000000000Z_hourly",
|
||||
},
|
||||
{
|
||||
name: "tomorrow",
|
||||
resolved: resolveTomorrowAt(t, "2026-05-29T18:00:00-05:00"),
|
||||
group: "tomorrow",
|
||||
validDate: "2026-05-30",
|
||||
runID: "20260529T230000.000000000Z_tomorrow",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
paths, err := store.Paths(tt.resolved)
|
||||
if err != nil {
|
||||
t.Fatalf("Paths() error = %v", err)
|
||||
}
|
||||
wants := map[string]string{
|
||||
"DataPackage": filepath.Join("data-packages", tt.group, tt.validDate, tt.runID+".data_package.yaml"),
|
||||
"RenderedReport": filepath.Join("reports", tt.group, tt.runID+".md"),
|
||||
"GeneratedTextRaw": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".generated_text.raw.json"),
|
||||
"GeneratedTextResult": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".generated_text.run.json"),
|
||||
"GeneratedText": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".generated_text.json"),
|
||||
"RenderContext": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".render_context.json"),
|
||||
}
|
||||
got := map[string]string{
|
||||
"DataPackage": paths.DataPackage,
|
||||
"RenderedReport": paths.RenderedReport,
|
||||
"GeneratedTextRaw": paths.GeneratedTextRaw,
|
||||
"GeneratedTextResult": paths.GeneratedTextResult,
|
||||
"GeneratedText": paths.GeneratedText,
|
||||
"RenderContext": paths.RenderContext,
|
||||
}
|
||||
for name, want := range wants {
|
||||
if !strings.Contains(got[name], want) {
|
||||
t.Fatalf("%s path = %q, want component %q", name, got[name], want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
||||
@@ -180,6 +236,117 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
||||
if strings.Contains(string(data), "MetadataPath") || strings.Contains(string(data), "metadataPath") {
|
||||
t.Fatalf("metadata JSON includes runtime-only MetadataPath:\n%s", string(data))
|
||||
}
|
||||
for _, unexpected := range []string{"generatedTextSchemaId", "generatedTextRawPath", "generatedTextResultPath", "generatedTextPath", "renderContextPath"} {
|
||||
if strings.Contains(string(data), unexpected) {
|
||||
t.Fatalf("metadata JSON includes generated-text field %q for Markdown report:\n%s", unexpected, string(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveGeneratedTextArtifactsAndMetadataRoundTrip(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
resolved := resolveHourlyAt(t, "2026-05-29T05:00:00-05:00")
|
||||
briefingMetadata := stateBriefingMetadata(resolved)
|
||||
|
||||
rawPath, err := store.SaveGeneratedTextRaw(context.Background(), resolved, []byte(`{"summary":"raw"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveGeneratedTextRaw() error = %v", err)
|
||||
}
|
||||
resultPath, err := store.SaveGeneratedTextResult(context.Background(), resolved, map[string]any{
|
||||
"command": []string{"scriptorium", "run"},
|
||||
"status": "succeeded",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveGeneratedTextResult() error = %v", err)
|
||||
}
|
||||
generatedPath, err := store.SaveGeneratedText(context.Background(), resolved, []byte(`{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled."}`))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveGeneratedText() error = %v", err)
|
||||
}
|
||||
contextPath, err := store.SaveRenderContext(context.Background(), resolved, struct {
|
||||
ReportTitle string `json:"reportTitle"`
|
||||
Location string `json:"location"`
|
||||
}{
|
||||
ReportTitle: "Hourly Report",
|
||||
Location: "Brentwood",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveRenderContext() error = %v", err)
|
||||
}
|
||||
|
||||
for _, path := range []string{rawPath, resultPath, generatedPath, contextPath} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected generated-text artifact %q: %v", path, err)
|
||||
}
|
||||
}
|
||||
rawData, err := store.LoadGeneratedText(context.Background(), rawPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadGeneratedText() raw error = %v", err)
|
||||
}
|
||||
if string(rawData) != `{"summary":"raw"}` {
|
||||
t.Fatalf("raw data = %q, want saved bytes", rawData)
|
||||
}
|
||||
generatedData, err := store.LoadGeneratedText(context.Background(), generatedPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadGeneratedText() generated error = %v", err)
|
||||
}
|
||||
if !strings.Contains(string(generatedData), `"forecast_discussion":"A front will keep the region unsettled."`) {
|
||||
t.Fatalf("generated text = %q, want saved normalized JSON", generatedData)
|
||||
}
|
||||
var runResult struct {
|
||||
Command []string `json:"command"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := store.LoadGeneratedTextResult(context.Background(), resultPath, &runResult); err != nil {
|
||||
t.Fatalf("LoadGeneratedTextResult() error = %v", err)
|
||||
}
|
||||
if runResult.Status != "succeeded" || strings.Join(runResult.Command, " ") != "scriptorium run" {
|
||||
t.Fatalf("run result = %#v, want saved result", runResult)
|
||||
}
|
||||
var renderContext struct {
|
||||
ReportTitle string `json:"reportTitle"`
|
||||
Location string `json:"location"`
|
||||
}
|
||||
if err := store.LoadRenderContext(context.Background(), contextPath, &renderContext); err != nil {
|
||||
t.Fatalf("LoadRenderContext() error = %v", err)
|
||||
}
|
||||
if renderContext.ReportTitle != "Hourly Report" || renderContext.Location != "Brentwood" {
|
||||
t.Fatalf("render context = %#v, want saved context", renderContext)
|
||||
}
|
||||
|
||||
paths, err := store.Paths(resolved)
|
||||
if err != nil {
|
||||
t.Fatalf("Paths() error = %v", err)
|
||||
}
|
||||
metadata := BuildMetadataFromBriefingMetadata(resolved, briefingMetadata, ArtifactPaths{
|
||||
ModuleSnapshot: paths.ModuleSnapshot,
|
||||
Metadata: paths.Metadata,
|
||||
DataPackage: paths.DataPackage,
|
||||
Preflight: paths.Preflight,
|
||||
RenderedReport: paths.RenderedReport,
|
||||
GeneratedTextRaw: rawPath,
|
||||
GeneratedTextResult: resultPath,
|
||||
GeneratedText: generatedPath,
|
||||
RenderContext: contextPath,
|
||||
})
|
||||
metadataPath, err := store.SaveMetadata(context.Background(), metadata)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveMetadata() error = %v", err)
|
||||
}
|
||||
var decoded Metadata
|
||||
metadataData, err := os.ReadFile(metadataPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read metadata: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(metadataData, &decoded); err != nil {
|
||||
t.Fatalf("decode metadata: %v", err)
|
||||
}
|
||||
if decoded.GeneratedTextSchemaID != "hourly" {
|
||||
t.Fatalf("GeneratedTextSchemaID = %q, want hourly", decoded.GeneratedTextSchemaID)
|
||||
}
|
||||
if decoded.GeneratedTextRawPath != rawPath || decoded.GeneratedTextResultPath != resultPath || decoded.GeneratedTextPath != generatedPath || decoded.RenderContextPath != contextPath {
|
||||
t.Fatalf("metadata generated-text paths = %#v, want saved artifact paths", decoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveMetadataUsesExplicitMetadataPath(t *testing.T) {
|
||||
@@ -305,6 +472,21 @@ func TestFindPriorSnapshotSupportsNarrowedWeekendPeriod(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindPriorSnapshotIgnoresRollingWindowReports(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
first := resolveHourlyAt(t, "2026-05-29T05:00:00-05:00")
|
||||
second := resolveHourlyAt(t, "2026-05-29T06:00:00-05:00")
|
||||
savePriorMetadata(t, store, first, stateBriefingMetadata(first))
|
||||
|
||||
prior, err := store.FindPriorSnapshot(context.Background(), second)
|
||||
if err != nil {
|
||||
t.Fatalf("FindPriorSnapshot() error = %v", err)
|
||||
}
|
||||
if prior != nil {
|
||||
t.Fatalf("FindPriorSnapshot() = %#v, want nil for rolling-window comparison", prior)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesystemStoreRejectsUnsafeDirs(t *testing.T) {
|
||||
cfg := config.Defaults().Workspace
|
||||
cfg.Root = t.TempDir()
|
||||
@@ -390,6 +572,46 @@ func resolveWeekendAt(t *testing.T, value string) report.Resolved {
|
||||
return resolved
|
||||
}
|
||||
|
||||
func resolveHourlyAt(t *testing.T, value string) report.Resolved {
|
||||
t.Helper()
|
||||
location, err := timeutil.LoadLocation("America/Chicago")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLocation() error = %v", err)
|
||||
}
|
||||
now, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
t.Fatalf("parse time: %v", err)
|
||||
}
|
||||
resolved, err := report.DefaultRegistry().Resolve(report.Hourly, report.ResolveRequest{
|
||||
Now: now,
|
||||
Location: location,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func resolveTomorrowAt(t *testing.T, value string) report.Resolved {
|
||||
t.Helper()
|
||||
location, err := timeutil.LoadLocation("America/Chicago")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLocation() error = %v", err)
|
||||
}
|
||||
now, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
t.Fatalf("parse time: %v", err)
|
||||
}
|
||||
resolved, err := report.DefaultRegistry().Resolve(report.Tomorrow, report.ResolveRequest{
|
||||
Now: now,
|
||||
Location: location,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func stateBriefingMetadata(resolved report.Resolved) briefing.Metadata {
|
||||
return briefing.Metadata{
|
||||
RunID: resolved.Metadata().RunID,
|
||||
@@ -436,5 +658,9 @@ func pathsString(paths ArtifactPaths) string {
|
||||
paths.Preflight,
|
||||
paths.Notification,
|
||||
paths.RenderedReport,
|
||||
paths.GeneratedTextRaw,
|
||||
paths.GeneratedTextResult,
|
||||
paths.GeneratedText,
|
||||
paths.RenderContext,
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
@@ -12,30 +12,35 @@ import (
|
||||
const MetadataSchemaVersion = "weatherreporter.metadata.v1"
|
||||
|
||||
type Metadata struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
RunID string `json:"runId"`
|
||||
MetadataPath string `json:"-"`
|
||||
ReportID report.ID `json:"reportId"`
|
||||
Variant string `json:"variant,omitempty"`
|
||||
PromptID string `json:"promptId"`
|
||||
GeneratedAt time.Time `json:"generatedAt"`
|
||||
Timezone string `json:"timezone"`
|
||||
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||
Location *briefing.LocationContext `json:"location,omitempty"`
|
||||
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
||||
SourceLocation string `json:"sourceLocation,omitempty"`
|
||||
Sources []briefing.SourceMetadata `json:"sources,omitempty"`
|
||||
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
|
||||
ModuleSnapshotPath string `json:"moduleSnapshotPath"`
|
||||
DataPackagePath string `json:"dataPackagePath"`
|
||||
PreflightPath string `json:"preflightPath"`
|
||||
NotificationPath string `json:"notificationPath,omitempty"`
|
||||
RenderedReportPath string `json:"renderedReportPath,omitempty"`
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
RunID string `json:"runId"`
|
||||
MetadataPath string `json:"-"`
|
||||
ReportID report.ID `json:"reportId"`
|
||||
Variant string `json:"variant,omitempty"`
|
||||
PromptID string `json:"promptId"`
|
||||
GeneratedAt time.Time `json:"generatedAt"`
|
||||
Timezone string `json:"timezone"`
|
||||
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||
Location *briefing.LocationContext `json:"location,omitempty"`
|
||||
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
||||
SourceLocation string `json:"sourceLocation,omitempty"`
|
||||
Sources []briefing.SourceMetadata `json:"sources,omitempty"`
|
||||
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
|
||||
ModuleSnapshotPath string `json:"moduleSnapshotPath"`
|
||||
DataPackagePath string `json:"dataPackagePath"`
|
||||
PreflightPath string `json:"preflightPath"`
|
||||
NotificationPath string `json:"notificationPath,omitempty"`
|
||||
RenderedReportPath string `json:"renderedReportPath,omitempty"`
|
||||
GeneratedTextSchemaID string `json:"generatedTextSchemaId,omitempty"`
|
||||
GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"`
|
||||
GeneratedTextResultPath string `json:"generatedTextResultPath,omitempty"`
|
||||
GeneratedTextPath string `json:"generatedTextPath,omitempty"`
|
||||
RenderContextPath string `json:"renderContextPath,omitempty"`
|
||||
}
|
||||
|
||||
func BuildMetadataFromBriefingMetadata(resolved report.Resolved, briefingMetadata briefing.Metadata, paths ArtifactPaths) Metadata {
|
||||
metadata := resolved.Metadata()
|
||||
return Metadata{
|
||||
out := Metadata{
|
||||
SchemaVersion: MetadataSchemaVersion,
|
||||
RunID: metadata.RunID,
|
||||
MetadataPath: paths.Metadata,
|
||||
@@ -55,6 +60,14 @@ func BuildMetadataFromBriefingMetadata(resolved report.Resolved, briefingMetadat
|
||||
PreflightPath: paths.Preflight,
|
||||
RenderedReportPath: paths.RenderedReport,
|
||||
}
|
||||
if resolved.Definition.GenerationMode == report.GenerationModeGeneratedTextTemplate {
|
||||
out.GeneratedTextSchemaID = resolved.Definition.GeneratedTextSchemaID
|
||||
out.GeneratedTextRawPath = paths.GeneratedTextRaw
|
||||
out.GeneratedTextResultPath = paths.GeneratedTextResult
|
||||
out.GeneratedTextPath = paths.GeneratedText
|
||||
out.RenderContextPath = paths.RenderContext
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func copyLocation(location *briefing.LocationContext) *briefing.LocationContext {
|
||||
|
||||
@@ -17,10 +17,17 @@ type Store interface {
|
||||
SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error)
|
||||
SavePreflight(context.Context, report.Resolved, PreflightArtifact) (string, error)
|
||||
SaveDistributorNotification(context.Context, report.Resolved, DistributorNotificationArtifact) (string, error)
|
||||
SaveGeneratedTextRaw(context.Context, report.Resolved, []byte) (string, error)
|
||||
SaveGeneratedTextResult(context.Context, report.Resolved, any) (string, error)
|
||||
SaveGeneratedText(context.Context, report.Resolved, []byte) (string, error)
|
||||
SaveRenderContext(context.Context, report.Resolved, any) (string, error)
|
||||
PrepareRenderedReport(context.Context, report.Resolved) (string, error)
|
||||
SaveMetadata(context.Context, Metadata) (string, error)
|
||||
FindPriorSnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
|
||||
LoadModuleSnapshot(context.Context, string) (module.Snapshot, error)
|
||||
LoadGeneratedText(context.Context, string) ([]byte, error)
|
||||
LoadGeneratedTextResult(context.Context, string, any) error
|
||||
LoadRenderContext(context.Context, string, any) error
|
||||
}
|
||||
|
||||
type PriorSnapshot struct {
|
||||
|
||||
@@ -7,17 +7,18 @@ import (
|
||||
)
|
||||
|
||||
type Bundle struct {
|
||||
FetchedAt time.Time `json:"fetchedAt"`
|
||||
Observation *Observation `json:"observation,omitempty"`
|
||||
Current *Current `json:"current,omitempty"`
|
||||
Hourly *ForecastRun `json:"hourly,omitempty"`
|
||||
Narrative *ForecastRun `json:"narrative,omitempty"`
|
||||
Alerts *AlertRun `json:"alerts,omitempty"`
|
||||
Discussion *Discussion `json:"discussion,omitempty"`
|
||||
Daily *ForecastRun `json:"daily,omitempty"`
|
||||
WeatherStory *WeatherStory `json:"weatherStory,omitempty"`
|
||||
Sources []Source `json:"sources"`
|
||||
Warnings []SourceWarning `json:"warnings,omitempty"`
|
||||
FetchedAt time.Time `json:"fetchedAt"`
|
||||
Observation *Observation `json:"observation,omitempty"`
|
||||
Current *Current `json:"current,omitempty"`
|
||||
Hourly *ForecastRun `json:"hourly,omitempty"`
|
||||
Narrative *ForecastRun `json:"narrative,omitempty"`
|
||||
Alerts *AlertRun `json:"alerts,omitempty"`
|
||||
Discussion *Discussion `json:"discussion,omitempty"`
|
||||
Daily *ForecastRun `json:"daily,omitempty"`
|
||||
WeatherStory *WeatherStory `json:"weatherStory,omitempty"`
|
||||
SPCConvectiveOutlooks *ConvectiveOutlookRun `json:"spcConvectiveOutlooks,omitempty"`
|
||||
Sources []Source `json:"sources"`
|
||||
Warnings []SourceWarning `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
@@ -166,3 +167,42 @@ type WeatherStory struct {
|
||||
Order int `json:"order"`
|
||||
DownloadURL string `json:"downloadUrl,omitempty"`
|
||||
}
|
||||
|
||||
type ConvectiveOutlookRun struct {
|
||||
LocationID string `json:"locationId,omitempty"`
|
||||
LocationName string `json:"locationName,omitempty"`
|
||||
AsOf *time.Time `json:"asOf,omitempty"`
|
||||
IssuedAt *time.Time `json:"issuedAt,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
Product string `json:"product,omitempty"`
|
||||
Outlooks []ConvectiveOutlook `json:"outlooks,omitempty"`
|
||||
Discussions []ConvectiveOutlookDiscussion `json:"discussions,omitempty"`
|
||||
}
|
||||
|
||||
type ConvectiveOutlook struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Product string `json:"product,omitempty"`
|
||||
Day int `json:"day,omitempty"`
|
||||
OutlookType string `json:"outlookType,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
LabelText string `json:"labelText,omitempty"`
|
||||
Forecaster string `json:"forecaster,omitempty"`
|
||||
SeverityRank *int `json:"severityRank,omitempty"`
|
||||
ValidFrom time.Time `json:"validFrom"`
|
||||
ValidTo time.Time `json:"validTo"`
|
||||
IssuedAt *time.Time `json:"issuedAt,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
||||
SourceURL string `json:"sourceUrl,omitempty"`
|
||||
ImageURL string `json:"imageUrl,omitempty"`
|
||||
ContainsLocation bool `json:"containsLocation"`
|
||||
Geometry json.RawMessage `json:"geometry,omitempty"`
|
||||
}
|
||||
|
||||
type ConvectiveOutlookDiscussion struct {
|
||||
Day int `json:"day,omitempty"`
|
||||
Headline string `json:"headline,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Discussion string `json:"discussion,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
78
internal/weatherdata/bundle_test.go
Normal file
78
internal/weatherdata/bundle_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package weatherdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConvectiveOutlookRunJSONPreservesGeometry(t *testing.T) {
|
||||
severity := 3
|
||||
validFrom := mustParseBundleTime("2026-06-12T13:00:00Z")
|
||||
validTo := mustParseBundleTime("2026-06-13T12:00:00Z")
|
||||
issuedAt := mustParseBundleTime("2026-06-12T12:30:00Z")
|
||||
updatedAt := mustParseBundleTime("2026-06-12T12:45:00Z")
|
||||
run := ConvectiveOutlookRun{
|
||||
LocationID: "test-grid",
|
||||
LocationName: "Brentwood",
|
||||
AsOf: &updatedAt,
|
||||
IssuedAt: &issuedAt,
|
||||
Product: "convective_outlook",
|
||||
Outlooks: []ConvectiveOutlook{{
|
||||
ID: "day1-categorical-slight",
|
||||
Provider: "spc",
|
||||
Product: "convective_outlook",
|
||||
Day: 1,
|
||||
OutlookType: "categorical",
|
||||
Label: "SLGT",
|
||||
LabelText: "Slight Risk",
|
||||
Forecaster: "Smith",
|
||||
SeverityRank: &severity,
|
||||
ValidFrom: validFrom,
|
||||
ValidTo: validTo,
|
||||
IssuedAt: &issuedAt,
|
||||
ExpiresAt: &validTo,
|
||||
SourceURL: "https://example.test/source",
|
||||
ImageURL: "https://example.test/image.png",
|
||||
ContainsLocation: true,
|
||||
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[-90.1,38.1],[-90.0,38.2],[-90.1,38.1]]]}`),
|
||||
}},
|
||||
Discussions: []ConvectiveOutlookDiscussion{{
|
||||
Day: 1,
|
||||
Headline: "Severe storms possible",
|
||||
Summary: "Scattered severe storms are possible.",
|
||||
Discussion: "Discussion text.",
|
||||
UpdatedAt: &updatedAt,
|
||||
}},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(run)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
|
||||
var decoded ConvectiveOutlookRun
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(decoded.Outlooks) != 1 || string(decoded.Outlooks[0].Geometry) == "" {
|
||||
t.Fatalf("decoded outlooks = %#v, want preserved geometry", decoded.Outlooks)
|
||||
}
|
||||
if got := string(decoded.Outlooks[0].Geometry); got != `{"type":"Polygon","coordinates":[[[-90.1,38.1],[-90.0,38.2],[-90.1,38.1]]]}` {
|
||||
t.Fatalf("Geometry = %s, want preserved GeoJSON coordinates", got)
|
||||
}
|
||||
if decoded.Outlooks[0].SeverityRank == nil || *decoded.Outlooks[0].SeverityRank != severity {
|
||||
t.Fatalf("SeverityRank = %#v, want %d", decoded.Outlooks[0].SeverityRank, severity)
|
||||
}
|
||||
if len(decoded.Discussions) != 1 || decoded.Discussions[0].Headline != "Severe storms possible" {
|
||||
t.Fatalf("Discussions = %#v, want decoded discussion", decoded.Discussions)
|
||||
}
|
||||
}
|
||||
|
||||
func mustParseBundleTime(value string) time.Time {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
Reference in New Issue
Block a user