100 Commits

Author SHA1 Message Date
0884eb0ce5 Added a staged roadmap to implement the small changes and refactors identified by the audit 2026-06-16 09:51:22 -05:00
a27e870522 Audit code quality and deduplication opportunities 2026-06-16 08:25:58 -05:00
d90801cff5 Separate the daily report and tomorrow report definitions 2026-06-16 08:17:25 -05:00
0f63159482 Document curated data package exports 2026-06-15 20:46:11 +00:00
2792933833 Add data package export regression coverage 2026-06-15 20:42:48 +00:00
9261431329 Curate daypart prompt exports 2026-06-15 20:38:56 +00:00
1bfd865333 Curate current and hourly prompt exports 2026-06-15 20:32:12 +00:00
e5af7477af Use exported module values in data packages 2026-06-15 20:27:28 +00:00
92fcbfcc05 Attach prompt export values in module registry 2026-06-15 20:25:12 +00:00
ff6aade42c Add runtime prompt values to module outputs 2026-06-15 20:23:02 +00:00
4fac69c9f0 Confirm daily documentation updates 2026-06-15 20:19:31 +00:00
90ab6973e6 Confirm legacy daily report cleanup 2026-06-15 20:19:31 +00:00
90a502f50e Confirm daily CLI workflow integration 2026-06-15 20:19:31 +00:00
273f462e09 Confirm daily report registry cutover 2026-06-15 20:19:31 +00:00
5361d5647b Confirm daily render context 2026-06-15 20:19:31 +00:00
d2e90da148 Confirm daily generated text assets 2026-06-15 20:19:31 +00:00
047ce32ac6 Confirm daily planning module implementation 2026-06-15 20:19:31 +00:00
5896168a93 Add feature roadmap and implementation plan to clean up and rationalize the fields provided to the data package 2026-06-15 13:03:31 -05:00
fe9c40741a Validate daily report cutover 2026-06-15 16:56:11 +00:00
88004a1827 Document daily report operations and templates 2026-06-15 16:54:41 +00:00
a515b7e7d9 Remove legacy daily report references 2026-06-15 16:49:37 +00:00
696454cf34 Require explicit dates for daily generation 2026-06-15 16:46:10 +00:00
8c97788682 Replace legacy daily report with generated text daily report 2026-06-15 16:43:06 +00:00
5203440ba0 Add daily render context 2026-06-15 16:29:18 +00:00
3d452a120a Add daily generated text assets 2026-06-15 16:23:13 +00:00
4eece7cc8a Add daily planning module 2026-06-15 16:17:22 +00:00
d0d0b698f9 Revise the feature roadmap for consistency with the implementation plan 2026-06-15 11:11:36 -05:00
4c4b01f265 Add a feature roadmap and implementation plan for a new daily report 2026-06-15 11:07:23 -05:00
58fe794227 Update the today report template 2026-06-15 11:01:03 -05:00
63dfc0b55a Validate Today report implementation 2026-06-15 15:05:23 +00:00
1ddc33eb17 Document Today report workflow 2026-06-15 15:04:14 +00:00
4a0238909b Add Today generate command workflow 2026-06-15 14:58:36 +00:00
3d5f71e72d Add Today report to morning batch 2026-06-15 14:54:37 +00:00
8ff5c44324 Add Today generated text assets 2026-06-15 14:45:49 +00:00
4e704e4f51 Add Today planning module scaffold 2026-06-15 14:37:08 +00:00
7b4c73d1e6 Add staged implementation plan for the new today report type 2026-06-15 08:48:04 -05:00
7efd8b5855 Add today report roadmap 2026-06-15 13:07:40 +00:00
7cbc59d8a7 Clarify future roadmap documentation 2026-06-15 13:00:59 +00:00
67b30dbad6 Remove completed roadmap cleanup plans 2026-06-15 12:52:18 +00:00
cd8d77b37c Reduce CLI test setup duplication 2026-06-15 12:48:36 +00:00
bb79232e3e Simplify Weather API source fetching 2026-06-15 12:44:57 +00:00
b4e0aadbef Clean up generated text helpers 2026-06-15 12:41:45 +00:00
4fe0f40cef Make report module overrides explicit 2026-06-15 12:36:53 +00:00
40b42f4bf3 Centralize report name resolution 2026-06-15 12:33:55 +00:00
e8f1aa5caf Centralize final report finalization 2026-06-15 12:28:54 +00:00
a02af0bce0 Centralize generated text template catalog 2026-06-15 12:24:47 +00:00
ff2f8c16a3 Create a staged roadmap to address the issues identified in the code quality audit 2026-06-15 07:16:43 -05:00
ace5577402 Audit code quality and deduplication opportunities 2026-06-15 07:08:20 -05:00
473f252aee Revise the report template for the tomorrow report. 2026-06-14 23:26:45 -05:00
74bd69834c Validate Tomorrow cutover 2026-06-14 23:55:56 +00:00
98cab70b53 Document Tomorrow generated-text workflow 2026-06-14 23:53:31 +00:00
dc0172ff82 Cover Tomorrow artifact and notification behavior 2026-06-14 23:49:18 +00:00
0b41773017 Route Tomorrow through generated text rendering 2026-06-14 23:44:31 +00:00
ddda42453f Add Tomorrow render context and template 2026-06-14 23:36:47 +00:00
295f06915f Add daypart presentation facts 2026-06-14 23:30:57 +00:00
120bce3391 Add Tomorrow generated text contract 2026-06-14 23:25:55 +00:00
386263784c Split Tomorrow report identity 2026-06-14 23:22:31 +00:00
afe6803a63 Add an implementation plan to convert the tomorrow report into the new hybrid deterministic/LLM format 2026-06-14 18:17:49 -05:00
5344c7880a Updated the current_conditions module to round all numeric values to integers 2026-06-14 17:40:19 -05:00
74e32eb18e Simplify and rationalize the hourly report template 2026-06-14 12:54:32 -05:00
ceb00ad45e Bugfix in the hourly weather template 2026-06-14 09:10:28 -05:00
7cd07c429b Revise the hourly weather template 2026-06-14 09:07:10 -05:00
28b8391d53 Refactor the template variable framework 2026-06-14 08:57:53 -05:00
bb8de054dc Update documentation for templated reports 2026-06-14 08:22:09 -05:00
9d6502460e Update buildRenderContext to use Definition.TemplateID instead of GeneratedTextSchemaID 2026-06-14 12:23:50 +00:00
fcf1108641 Strengthen final hourly workflow validation 2026-06-14 05:24:25 +00:00
185605fbf0 Document hourly generated text behavior 2026-06-14 05:21:15 +00:00
662d906997 Cover hourly CLI generation workflow 2026-06-14 05:17:05 +00:00
f6e20d1412 Notify hourly generated reports after success 2026-06-14 05:13:40 +00:00
9e14a9b7c7 Preserve generated text failure artifacts 2026-06-14 05:08:26 +00:00
422430613c Wire hourly generated text rendering 2026-06-14 05:01:26 +00:00
2a4ce64d6f Add structured Scriptorium run support 2026-06-14 04:55:07 +00:00
316ab8f3fc Add generated text state artifacts 2026-06-14 04:51:52 +00:00
f6a68426e1 Add hourly generated text contract 2026-06-14 04:46:53 +00:00
bed2b84100 Add embedded hourly report template assets 2026-06-14 04:38:48 +00:00
b39ec1e4d3 Add hourly generated-text report shell 2026-06-14 04:35:52 +00:00
dd92e9b061 Add report generation mode metadata 2026-06-14 04:28:30 +00:00
8d737395dc Rename rolling report to hourly 2026-06-14 04:26:05 +00:00
b3f7c9c1f2 Add roadmap and implementation plan to move towards hybrid deterministic/llm generation of reports 2026-06-13 23:20:04 -05:00
d425132ae7 Validate near-term implementation 2026-06-12 17:48:02 +00:00
925d351341 Document near-term report behavior 2026-06-12 17:45:37 +00:00
c4107490df Add near-term report config overrides 2026-06-12 17:42:49 +00:00
b38230bc35 Add near-term generation artifact coverage 2026-06-12 17:40:54 +00:00
1f5f9964e0 Add near-term generate command 2026-06-12 17:36:58 +00:00
b0d8c6983d Support near-term derived facts 2026-06-12 17:34:40 +00:00
55f0180599 Add near-term module composition 2026-06-12 17:31:37 +00:00
5284033fb8 Add near-term report identity 2026-06-12 17:28:26 +00:00
0b1423c90d Add a roadmap and implementation plan for rolling near-term forecast reports 2026-06-12 12:24:07 -05:00
e6a4bb2d16 Clean up and normalize prompt output modules 2026-06-12 12:00:40 -05:00
c3a051d372 Validate SPC convective outlook feature 2026-06-12 15:18:38 +00:00
3482551360 Document SPC convective outlook behavior 2026-06-12 15:17:06 +00:00
d7a72f8581 Add SPC convective workflow coverage 2026-06-12 15:14:09 +00:00
c09b7410ce Add SPC convective modules to report defaults 2026-06-12 15:10:40 +00:00
96ce0edd46 Add SPC convective discussion briefing module 2026-06-12 15:05:42 +00:00
7cd68ff222 Add SPC convective outlook briefing module 2026-06-12 15:02:49 +00:00
16680e3f61 Route SPC convective stanzas in prompt packages 2026-06-12 14:58:14 +00:00
3389d4fa93 Derive report-period SPC convective outlooks 2026-06-12 14:56:17 +00:00
0041845935 Fetch SPC convective outlook data 2026-06-12 14:51:54 +00:00
3bcccb4a7b Add SPC convective outlook data contracts 2026-06-12 14:47:42 +00:00
2aba52f552 Add roadmap and staged plan for implementing SPC convective outlook support 2026-06-12 09:42:43 -05:00
110 changed files with 15404 additions and 1159 deletions

View File

@@ -9,7 +9,7 @@ Markdown reports to a configured `distributor` HTTP upload endpoint.
## Quickstart ## Quickstart
```sh ```sh
weatherreporter generate daily --date 2026-05-29 --out ./daily.md weatherreporter generate today --out ./today.md
``` ```
## Documentation ## Documentation

View File

@@ -6,21 +6,24 @@ batches, and inspects stored artifacts.
## Shortest Useful Command ## Shortest Useful Command
```sh ```sh
weatherreporter generate daily --date 2026-05-29 --out ./daily.md weatherreporter generate today --out ./today.md
``` ```
This loads configuration, fetches weather data, writes managed workspace This loads configuration, fetches weather data, writes managed workspace
artifacts, runs `scriptorium render` as a preflight check, runs artifacts, runs `scriptorium render` as a preflight check, runs structured
`scriptorium run`, and writes an extra Markdown copy to `./daily.md`. If `scriptorium run`, validates generated text, renders the embedded Today
distributor notification is enabled in configuration, the command also uploads template, and writes an extra Markdown copy to `./today.md`. If distributor
the managed Markdown report after final metadata is saved. notification is enabled in configuration, the command also uploads the managed
Markdown report after final metadata is saved.
## Commands ## Commands
```text ```text
weatherreporter --help weatherreporter --help
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
@@ -34,14 +37,21 @@ weatherreporter inspect prior [--config PATH] RUN_ID
weatherreporter inspect sources [--config PATH] RUN_ID weatherreporter inspect sources [--config PATH] RUN_ID
``` ```
`generate` commands write a JSON module snapshot, YAML data package, preflight Implemented `generate` commands write a JSON module snapshot, YAML data package,
artifact, managed Markdown report, and metadata under the configured workspace. preflight artifact, managed Markdown report, and metadata under the configured
`--out` writes an extra Markdown copy for the operator; distributor workspace. `--out` writes an extra Markdown copy for the operator; distributor
notification uses the managed report path, not the extra copy. `generate storm` notification uses the managed report path, not the extra copy. `generate daily`,
requires explicit event-window bounds with `--start` and `--end`. `generate today`, `generate tomorrow`, and `generate hourly` write managed
generated-text artifacts, validate structured text from Scriptorium, and render
the managed Markdown report from embedded templates. `generate daily` requires
`--date YYYY-MM-DD` for the selected local civil day; omitting `--date` is a
command error and stops before weather data is fetched. `generate hourly`
covers the next six hours in the effective report timezone and does not accept
date or event window flags. `generate storm` requires explicit event-window
bounds with `--start` and `--end`.
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook `run morning` generates Today Report and the 3-Day Outlook, plus Weekend Outlook
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch except on Sunday. `run evening` generates the Tomorrow Report. Batch
runs continue independent reports after a failure, print a JSON summary to runs continue independent reports after a failure, print a JSON summary to
stdout, write compact status lines to stderr, and return nonzero when any report stdout, write compact status lines to stderr, and return nonzero when any report
failed. `--out-dir` writes extra Markdown copies for the operator; distributor failed. `--out-dir` writes extra Markdown copies for the operator; distributor
@@ -50,6 +60,9 @@ notification is enabled, batch summaries and status lines include notification
status, accepted distributor run ID, or notification error fields for each status, accepted distributor run ID, or notification error fields for each
attempted report. attempted report.
Hourly Report is explicit only; it is not included in `run morning` or `run
evening`.
`inspect` commands read existing workspace artifacts and emit JSON to stdout. `inspect` commands read existing workspace artifacts and emit JSON to stdout.
They do not fetch weather data or invoke `scriptorium`. They do not fetch weather data or invoke `scriptorium`.
@@ -59,9 +72,9 @@ They do not fetch weather data or invoke `scriptorium`.
- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`. - `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`.
- `--units VALUE`: override configured Weather API units for `generate` and `run`. - `--units VALUE`: override configured Weather API units for `generate` and `run`.
- `--tz NAME`: override configured Weather API timezone for `generate` and `run`. - `--tz NAME`: override configured Weather API timezone for `generate` and `run`.
- `--out PATH`: write an extra Markdown report copy for `generate` commands. - `--out PATH`: write an extra Markdown report copy where supported by the `generate` command.
- `--out-dir PATH`: write extra Markdown report copies for `run morning` and `run evening`. - `--out-dir PATH`: write extra Markdown report copies for `run morning` and `run evening`.
- `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone. - `--date YYYY-MM-DD`: required date for `generate daily`; optional date for `generate today`, defaulting to the current local date in the configured timezone.
- `--start TIME`: required start time for `generate storm`. - `--start TIME`: required start time for `generate storm`.
- `--end TIME`: required end time for `generate storm`. - `--end TIME`: required end time for `generate storm`.
- `--limit N`: maximum records for `inspect reports`; defaults to `20`, and `0` means no limit. - `--limit N`: maximum records for `inspect reports`; defaults to `20`, and `0` means no limit.
@@ -75,7 +88,10 @@ are no distributor-specific CLI flags.
## Common Workflows ## Common Workflows
```sh ```sh
weatherreporter generate today --out ./today.md
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
weatherreporter generate tomorrow --out ./tomorrow.md weatherreporter generate tomorrow --out ./tomorrow.md
weatherreporter generate hourly
weatherreporter generate three-day --out ./three-day.md weatherreporter generate three-day --out ./three-day.md
weatherreporter generate weekend --out ./weekend.md weatherreporter generate weekend --out ./weekend.md
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
@@ -87,11 +103,12 @@ weatherreporter run evening --out-dir ./reports
```sh ```sh
weatherreporter inspect reports --limit 10 weatherreporter inspect reports --limit 10
weatherreporter inspect metadata 20260529T100000.000000000Z_daily_today weatherreporter inspect metadata 20260529T100000.000000000Z_today
weatherreporter inspect modules 20260529T100000.000000000Z_daily_today weatherreporter inspect modules 20260529T100000.000000000Z_today
weatherreporter inspect data-package 20260529T100000.000000000Z_daily_today weatherreporter inspect data-package 20260529T100000.000000000Z_today
weatherreporter inspect prior 20260529T100000.000000000Z_daily_today weatherreporter inspect prior 20260529T100000.000000000Z_today
weatherreporter inspect sources 20260529T100000.000000000Z_daily_today weatherreporter inspect sources 20260529T100000.000000000Z_today
weatherreporter inspect metadata 20260529T100000.000000000Z_daily
``` ```
`inspect reports` lists recent generated runs with artifact paths and source `inspect reports` lists recent generated runs with artifact paths and source

View File

@@ -81,7 +81,7 @@ missing directories, and unreadable files fail config loading.
`notify.distributor` controls distributor notification after successful report `notify.distributor` controls distributor notification after successful report
generation. It is disabled by default and does not add CLI flags. When enabled, generation. It is disabled by default and does not add CLI flags. When enabled,
weatherreporter uploads one distributor bundle per generated report after weatherreporter uploads one distributor bundle per generated report after
`scriptorium run` succeeds and final metadata is saved. report rendering succeeds and final metadata is saved.
- `enabled`: whether distributor notification config is active. Default: - `enabled`: whether distributor notification config is active. Default:
`false`. `false`.
@@ -130,7 +130,9 @@ name the variable only; they should not contain the token value.
- `sources`: optional map of source-specific overrides, using the same policy values. - `sources`: optional map of source-specific overrides, using the same policy values.
Hourly forecast data is required for generated reports. Optional sources use Hourly forecast data is required for generated reports. Optional sources use
the missing-source policy. the missing-source policy. Source override keys include `observations`,
`current`, `narrative`, `alerts`, `discussion`, `weather_story`, and
`spc_convective_outlooks`.
### `scriptorium` ### `scriptorium`
@@ -179,9 +181,14 @@ snapshot exists and a threshold is crossed.
`reports` optionally overrides the ordered deterministic modules declared by `reports` optionally overrides the ordered deterministic modules declared by
report definitions. Omit a report entry to use its default module order. report definitions. Omit a report entry to use its default module order.
Supported report keys are `daily`, `tomorrow`, `three_day`, `weekend`, and Supported report keys are `daily`, `today`, `tomorrow`, `hourly`,
`storm`. Canonical report IDs such as `daily_today` and `daily_tomorrow` are `three_day`, `weekend`, and `storm`. Canonical report IDs and accepted aliases
also accepted. are also valid, including `three_day_outlook`, `weekend_outlook`, and
`storm_report`. Hyphens and underscores are treated equivalently in report
keys. Retired report keys are not supported.
`reports.today` applies only to the Today Report. `reports.daily` applies only
to the dated Daily Report.
Each report entry supports: Each report entry supports:
@@ -197,11 +204,46 @@ reports:
- metadata - metadata
- current_conditions - current_conditions
- narrative_forecast - narrative_forecast
- alert_digest
- spc_convective_outlooks
- id: area_forecast_discussion - id: area_forecast_discussion
options: options:
sections: sections:
- short_term - short_term
- spc_convective_discussion
- daily_planning
- hourly_forecast - hourly_forecast
today:
deterministic_modules:
- metadata
- current_conditions
- narrative_forecast
- derived_daily_summary
- derived_daypart_summaries
- precip_timing
- alert_digest
- spc_convective_outlooks
- area_forecast_discussion
- spc_convective_discussion
- weather_story
- outdoor_windows
- hourly_forecast
- today_planning
hourly:
deterministic_modules:
- metadata
- current_conditions
- hourly_forecast
- precip_timing
- alert_digest
- spc_convective_outlooks
- id: area_forecast_discussion
options:
sections:
- key_messages
- short_term
- spc_convective_discussion
- weather_story
``` ```
Unknown reports, unknown modules, duplicate modules, incompatible report/module Unknown reports, unknown modules, duplicate modules, incompatible report/module

View File

@@ -6,7 +6,7 @@ This document describes the external Scriptorium CLI contract used by
## Purpose ## Purpose
`weatherreporter` invokes Scriptorium as a subprocess to preflight prompt input `weatherreporter` invokes Scriptorium as a subprocess to preflight prompt input
and generate Markdown reports. This page documents the CLI surface the adapter and generate report artifacts. This page documents the CLI surface the adapter
uses, not the full Scriptorium product. uses, not the full Scriptorium product.
## Commands Used ## Commands Used
@@ -29,11 +29,25 @@ scriptorium run \
--out <artifact_path> --out <artifact_path>
``` ```
Structured generated-text report generation uses the same command shape:
```bash
scriptorium run \
--prompt <prompt_id> \
--input data_package=<path> \
--out <generated_text_raw_path>
```
`weatherreporter` always passes prompt input as `weatherreporter` always passes prompt input as
`--input data_package=<path>`. The data package is structured YAML created by `--input data_package=<path>`. The data package is structured YAML created by
`internal/promptinput`; module snapshots remain separate JSON artifacts for `internal/promptinput`; module snapshots remain separate JSON artifacts for
inspection and Recent Changes. inspection and Recent Changes.
For generated-text reports, Scriptorium selects the structured output schema
from the prompt configuration associated with the prompt ID. `weatherreporter`
does not pass `--format`, schema path, or JSON Schema flags for structured
generation.
## Configured Arguments ## Configured Arguments
The adapter can prepend configured flags before prompt-specific arguments: The adapter can prepend configured flags before prompt-specific arguments:
@@ -68,11 +82,17 @@ Render results include:
- exit code - exit code
- truncation flags when applicable - truncation flags when applicable
Run results include the same fields plus the requested output path. Run results include the same fields plus the requested output path. Structured
generated-text run results use the same captured fields and output-path
recording, with the output path pointing at the raw generated-text JSON
artifact.
`weatherreporter` persists render preflight JSON when orchestration reaches the `weatherreporter` persists render preflight JSON when orchestration reaches the
preflight save point. The final Markdown artifact is written by Scriptorium to preflight save point. For direct Markdown reports, Scriptorium writes the
the `--out` path. managed Markdown artifact to the `--out` path. For generated-text-template
reports, Scriptorium writes raw JSON to the `--out` path; later
weatherreporter workflow steps validate those bytes and render Markdown from an
embedded template.
## Failure Behavior ## Failure Behavior
@@ -80,7 +100,7 @@ The adapter validates required request fields before starting Scriptorium:
- prompt ID - prompt ID
- data package path - data package path
- output path for `run` - output path for `run` and structured generated-text `run`
Nonzero exits return both the captured result and an error containing the exit Nonzero exits return both the captured result and an error containing the exit
code and stderr. A `run` exit code such as `2` is still treated as an error by code and stderr. A `run` exit code such as `2` is still treated as an error by

View File

@@ -37,6 +37,10 @@ generation.
means the endpoint was checked and there are no current active alerts. The means the endpoint was checked and there are no current active alerts. The
adapter records a non-missing alerts source and an empty alert run. adapter records a non-missing alerts source and an empty alert run.
For `/outlooks/convective`, `data: null` means no latest run is available and
follows missing-source policy. A non-null run with empty `outlooks` and
`discussions` arrays is checked empty data, not a missing source.
Malformed JSON envelopes, non-2xx statuses, and response read failures include Malformed JSON envelopes, non-2xx statuses, and response read failures include
endpoint context in returned errors. Decode errors include source context when endpoint context in returned errors. Decode errors include source context when
they fail the fetch; optional malformed sources follow the missing-source policy. they fail the fetch; optional malformed sources follow the missing-source policy.
@@ -49,11 +53,66 @@ The adapter sends these query parameters:
- `units`: from `weather_api.units` - `units`: from `weather_api.units`
- `precision`: from `weather_api.precision` on observations, current - `precision`: from `weather_api.precision` on observations, current
conditions, hourly forecast, and narrative forecast requests conditions, hourly forecast, and narrative forecast requests
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast, and - `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast,
discussion requests discussion, and SPC convective outlook requests
Alerts do not receive `precision` or `tz`. Weather story requests receive only Alerts do not receive `precision` or `tz`. Weather story requests receive only
`format=json`. `format=json`. SPC convective outlook requests receive only `format=json` and
`tz`; they do not receive `units` or `precision`.
## SPC Convective Outlooks
The adapter fetches SPC convective outlook data from:
```text
GET /outlooks/convective?format=json&tz=<weather_api.timezone>
```
The response uses the standard `data` envelope. `data: null` means no latest
run is available and follows missing-source policy. A non-null object with
empty `outlooks` and `discussions` arrays is accepted as checked empty data.
Run fields consumed by weatherreporter:
- `locationId`
- `locationName`
- `asOf`
- `issuedAt`
- `updatedAt`
- `product`
- `outlooks`
- `discussions`
Outlook fields consumed:
- `id`
- `provider`
- `product`
- `day`
- `outlookType`
- `label`
- `labelText`
- `forecaster`
- `severityRank`
- `validFrom`
- `validTo`
- `issuedAt`
- `expiresAt`
- `sourceUrl`
- `imageUrl`
- `containsLocation`
- `geometry`
Discussion fields consumed:
- `day`
- `headline`
- `summary`
- `discussion`
- `updatedAt`
GeoJSON `geometry` is decoded into collected weather facts and persisted in
bundle/debug artifacts, but prompt-facing SPC module output omits geometry.
## Endpoints Used ## Endpoints Used
@@ -66,6 +125,7 @@ The adapter fetches these endpoints once per bundle:
- `/alerts/active` - `/alerts/active`
- `/discussion` - `/discussion`
- `/weatherstories/latest` - `/weatherstories/latest`
- `/outlooks/convective`
`weatherreporter` does not call day-slice forecast endpoints or discussion `weatherreporter` does not call day-slice forecast endpoints or discussion
subsection endpoints. Report-period selection and daypart summarization happen subsection endpoints. Report-period selection and daypart summarization happen
@@ -88,6 +148,7 @@ source-specific `missing_source.sources` policy:
- `alerts` for `/alerts/active` - `alerts` for `/alerts/active`
- `discussion` for `/discussion` - `discussion` for `/discussion`
- `weather_story` for `/weatherstories/latest` - `weather_story` for `/weatherstories/latest`
- `spc_convective_outlooks` for `/outlooks/convective`
Policy behavior: Policy behavior:
@@ -99,6 +160,9 @@ For `/alerts/active`, an HTTP error or missing `data` field still fails or
follows the relevant error path, but explicit `data: null` is not a follows the relevant error path, but explicit `data: null` is not a
missing-source condition. missing-source condition.
For `/outlooks/convective`, a non-null data object with empty outlook and
discussion arrays is accepted as checked empty data.
## Source Identity ## Source Identity
For source payloads accepted into the bundle, including the explicit `null` For source payloads accepted into the bundle, including the explicit `null`
@@ -116,8 +180,8 @@ warnings list.
## Compatibility Assumptions ## Compatibility Assumptions
The adapter expects payload fields compatible with the internal forecast bundle The adapter expects payload fields compatible with the internal weather data
types in `internal/forecast/bundle.go`, including: bundle types in `internal/weatherdata/bundle.go`, including:
- observation timestamps and observation values - observation timestamps and observation values
- current condition values - current condition values
@@ -126,6 +190,8 @@ types in `internal/forecast/bundle.go`, including:
- discussion metadata, key messages, and short/long-term section text - discussion metadata, key messages, and short/long-term section text
- latest weather story title, description, timing, priority, order, alt text, - latest weather story title, description, timing, priority, order, alt text,
and download URL and download URL
- SPC convective outlook run metadata, outlooks, discussions, and GeoJSON
geometry
The adapter intentionally keeps upstream transport and envelope details inside The adapter intentionally keeps upstream transport and envelope details inside
`internal/adapters/weatherapi`; downstream packages consume the normalized `internal/adapters/weatherapi`; downstream packages consume the normalized

View File

@@ -28,7 +28,8 @@ Outputs:
- generated report results with JSON module snapshot, YAML data package, - generated report results with JSON module snapshot, YAML data package,
preflight, report, metadata, prior snapshot, Recent Changes, Scriptorium preflight, report, metadata, prior snapshot, Recent Changes, Scriptorium
result details, and notification result when attempted result details, generated-text artifact paths when applicable, and
notification result when attempted
- batch summaries with per-report status, artifact paths, error text, and - batch summaries with per-report status, artifact paths, error text, and
notification outcome when attempted notification outcome when attempted
- saved Weather API bundle JSON for fetch workflows - saved Weather API bundle JSON for fetch workflows
@@ -49,6 +50,19 @@ subprocess behavior stays in `internal/adapters/scriptorium`. Distributor
upload behavior stays in `internal/adapters/distributor`. Filesystem layout and upload behavior stays in `internal/adapters/distributor`. Filesystem layout and
persisted metadata stay in `internal/state`. persisted metadata stay in `internal/state`.
## Data Flow Terms
- `CollectedFacts` are normalized source facts fetched once from Weather API
and made available to derivation and module builders.
- `DerivedFacts` are deterministic calculations over collected facts, the
resolved valid period, daypart configuration, and report-specific windows.
- `module.Output` values are ordered deterministic stanzas built from collected
and derived facts for prompt input and inspection.
- `GeneratedText` is structured prose returned by Scriptorium for
generated-text-template reports and validated by `internal/generatedtext`.
- `RenderContext` is the typed template input built from report metadata,
module outputs, and validated generated text before Markdown rendering.
## Config Fields Used ## Config Fields Used
- `weather_api.*` for Weather API client construction and module metadata - `weather_api.*` for Weather API client construction and module metadata
@@ -63,7 +77,7 @@ defaults.
## Generation Workflow ## Generation Workflow
Single-report generation follows this order: Single-report generation shares this setup:
1. Resolve the command report to a `report.Resolved` value. 1. Resolve the command report to a `report.Resolved` value.
2. Create or use a filesystem store. 2. Create or use a filesystem store.
@@ -76,13 +90,31 @@ Single-report generation follows this order:
9. Run Scriptorium render preflight. 9. Run Scriptorium render preflight.
10. Save preflight JSON when a render result is available. 10. Save preflight JSON when a render result is available.
11. Save metadata for inspection. 11. Save metadata for inspection.
12. Run Scriptorium report generation to the managed report path.
13. Copy the managed report to the requested `--out` path when provided. For `scriptorium_markdown` reports, generation then:
14. Save metadata with the managed report path.
15. If distributor notification is enabled, notify using the managed report 12. Runs Scriptorium report generation to the managed report path.
path as the source file.
16. Save a distributor notification debug artifact and update metadata with its For `generated_text_template` reports, generation then:
path.
12. Looks up the generated-text catalog entry for the report schema/template
IDs.
13. Runs structured Scriptorium generation to the raw generated-text JSON path.
14. Saves the structured Scriptorium run result.
15. Validates and saves normalized generated text.
16. Builds and saves a typed render context.
17. Renders Markdown from the embedded template to the managed report path.
After either mode has produced a managed Markdown report, shared finalization:
1. Copies the managed report to the requested `--out` or `--out-dir` path when
provided.
2. Saves final metadata with the managed report path and any generated-text
artifact paths already produced.
3. If distributor notification is enabled, notifies using the managed report
path as the source file.
4. Saves a distributor notification debug artifact and updates metadata with
its path.
If render preflight returns both a result and an error, preflight JSON and If render preflight returns both a result and an error, preflight JSON and
metadata are persisted before the error is returned. If Scriptorium report metadata are persisted before the error is returned. If Scriptorium report
@@ -90,6 +122,10 @@ generation returns an error after writing output, the managed report and
metadata remain inspectable. Notification is not attempted after Weather API, metadata remain inspectable. Notification is not attempted after Weather API,
module snapshot, prompt input, render, Scriptorium run, or metadata-save module snapshot, prompt input, render, Scriptorium run, or metadata-save
failures. failures.
Generated-text report failures are returned with report ID, RunID, and the
failed operation. When available, the app preserves the latest generated-text
artifacts already reached by the workflow: preflight output, structured run
result, raw generated text, validated generated text, and render context.
When notification is attempted, the debug artifact records request identity, When notification is attempted, the debug artifact records request identity,
including rendered pipeline ID, bundle paths, accepted upload fields, including rendered pipeline ID, bundle paths, accepted upload fields,
distributor status fields, raw status report JSON when available, and redacted distributor status fields, raw status report JSON when available, and redacted
@@ -98,15 +134,17 @@ failure context.
## Batch Workflow ## Batch Workflow
`run morning` resolves Daily Today, 3-Day Outlook, and Weekend Outlook except `run morning` resolves Today Report, 3-Day Outlook, and Weekend Outlook except
on Sunday. `run evening` resolves Daily Tomorrow. Batch output copy names come on Sunday. `run evening` resolves Tomorrow Report. Daily Report is generated
from report definitions. Batch generation continues independent reports after a only through `generate daily --date YYYY-MM-DD`; it is not part of scheduled
failure, records each result, writes compact status lines to stderr, emits a batches. Batch output copy names come from report definitions. Batch generation
JSON summary to stdout, and returns an aggregate error when any report failed. continues independent reports after a failure, records each result, writes
When notification is enabled, each successfully generated report is notified compact status lines to stderr, emits a JSON summary to stdout, and returns an
independently. Notification failure marks that report failed, records aggregate error when any report failed. When notification is enabled, each
notification fields in the batch result, and does not stop later reports. successfully generated report is notified independently. Notification failure
`--out-dir` copies are never used as notification source files. marks that report failed, records notification fields in the batch result, and
does not stop later reports. `--out-dir` copies are never used as notification
source files.
## Inspection Workflow ## Inspection Workflow
@@ -122,6 +160,8 @@ inspection view.
runs. runs.
- Prompt input validation fails before render preflight. - Prompt input validation fails before render preflight.
- Render and run errors preserve Scriptorium stderr and exit-code context. - Render and run errors preserve Scriptorium stderr and exit-code context.
- Generated-text report errors preserve available intermediate artifacts and do
not create extra output copies.
- Notification errors are wrapped with report ID, RunID, and managed report path - Notification errors are wrapped with report ID, RunID, and managed report path
context and are recorded separately in batch results. context and are recorded separately in batch results.
- Metadata and artifact path errors include filesystem context. - Metadata and artifact path errors include filesystem context.
@@ -142,6 +182,8 @@ Inspect:
- Generated reports use the same app request and result types regardless of - Generated reports use the same app request and result types regardless of
report ID. report ID.
- Render preflight precedes Scriptorium report generation. - Render preflight precedes Scriptorium report generation.
- Generated-text reports render Markdown from a curated render context, not from
a raw data package.
- Recent Changes are computed from structured module snapshots. - Recent Changes are computed from structured module snapshots.
- Metadata links artifacts produced for a run. - Metadata links artifacts produced for a run.
- Distributor notification maps the managed Markdown report path to configured - Distributor notification maps the managed Markdown report path to configured

View File

@@ -28,14 +28,46 @@ Outputs:
supported reports, fact requirements, missing-data behavior, and builder supported reports, fact requirements, missing-data behavior, and builder
- `module.Output` values for source-oriented stanzas: - `module.Output` values for source-oriented stanzas:
`metadata`, `current_conditions`, `narrative_forecast`, `hourly_forecast`, `metadata`, `current_conditions`, `narrative_forecast`, `hourly_forecast`,
`alert_digest`, `area_forecast_discussion`, and `weather_story` `alert_digest`, `spc_convective_outlooks`,
`area_forecast_discussion`, `spc_convective_discussion`, and
`weather_story`
- `module.Output` values for derived stanzas: - `module.Output` values for derived stanzas:
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`, `derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
`outdoor_windows`, and `tomorrow_planning` `outdoor_windows`, `today_planning`, `tomorrow_planning`, and
`daily_planning`
Every registered composition entry has a builder. Unknown or unimplemented Every registered composition entry has a builder. Unknown or unimplemented
module IDs fail validation instead of being skipped. module IDs fail validation instead of being skipped.
Daily Report supports the Daily-style civil-day modules plus `daily_planning`
and `hourly_forecast`; those outputs feed the dated Daily GeneratedText prompt
package and embedded Markdown template.
Tomorrow Report supports the Daily-style civil-day modules plus
`tomorrow_planning` and `hourly_forecast`; those outputs feed the Tomorrow
GeneratedText prompt package and embedded Markdown template.
Today Report supports the Daily-style civil-day modules plus `today_planning`
and `hourly_forecast`; those outputs feed the Today GeneratedText prompt
package and embedded Markdown template.
`today_planning` is a Today-specific deterministic planning stanza with
morning readiness, commute/school/workday concerns, outdoor planning, and
late-day change-watch fields. It is compatible with `report.Today` only.
`daily_planning` is a dated Daily deterministic planning stanza with morning
readiness, commute/school/workday concerns, and overnight change-watch fields.
It is compatible only with the `daily` report ID value. The default Daily
Report composition includes it.
Hourly Report supports source and valid-period modules that operate over its
rolling six-hour period: `metadata`, `current_conditions`, `hourly_forecast`,
`precip_timing`, `alert_digest`, `spc_convective_outlooks`,
`area_forecast_discussion`, `spc_convective_discussion`, and `weather_story`.
It does not support daily/daypart-only modules such as
`derived_daily_summary`, `derived_daypart_summaries`, `outdoor_windows`,
`today_planning`, `tomorrow_planning`, or `daily_planning`.
Prompt-facing module values use local, human-readable date and time labels Prompt-facing module values use local, human-readable date and time labels
where the LLM is expected to reason about report content. Canonical timestamps where the LLM is expected to reason about report content. Canonical timestamps
remain in report metadata, source provenance, and integration artifacts. remain in report metadata, source provenance, and integration artifacts.
@@ -57,7 +89,24 @@ builders run. Configured `location` values are prompt context only; Weather API
`sourceLocationId` and `sourceLocation` remain source provenance. `sourceLocationId` and `sourceLocation` remain source provenance.
`area_forecast_discussion` uses optional `sections` configuration to include a `area_forecast_discussion` uses optional `sections` configuration to include a
subset of discussion fields. subset of discussion fields. Hourly Report defaults this module to
`key_messages` and `short_term`.
`spc_convective_outlooks` uses collected SPC run metadata and derived
report-period outlooks. It emits `checked: true` for a successfully fetched
empty run, reports `outlook_count`, and includes prompt-facing outlook fields
such as risk label, `period_begins`, `period_ends`, image URL, and whether the
outlook contains the configured location. It does not emit GeoJSON geometry,
source URL, expiration time, or severity rank.
Prompt-facing module intervals use friendly local `period_begins` and
`period_ends` labels. Canonical report metadata, source provenance,
`issued_at`, `updated_at`, and point-in-time fields remain separate.
`spc_convective_discussion` uses the same derived report-period outlooks and
discussion records. It is omitted unless at least one retained categorical
outlook for the same SPC day has severity rank `3` or higher and matching
discussion text exists.
## External Adapters Used ## External Adapters Used
@@ -86,6 +135,11 @@ return an error for invalid required inputs.
forecast discussion, and weather story stanzas. forecast discussion, and weather story stanzas.
- Alert digest output distinguishes checked empty alert data from missing alert - Alert digest output distinguishes checked empty alert data from missing alert
source data. source data.
- SPC convective outlook output distinguishes checked empty outlook data from
missing outlook source data and omits GeoJSON geometry from prompt-facing
fields.
- SPC convective discussion output is omitted unless a retained outlook has
severity rank `3` or higher and matching discussion text is available.
## Tests ## Tests

View File

@@ -20,14 +20,29 @@ Inputs:
Outputs: Outputs:
- `facts.CollectedFacts` with normalized source facts plus separate source - `facts.CollectedFacts` with normalized source facts plus separate source
provenance and warnings provenance and warnings. SPC convective outlook source data is carried
through when present in the bundle, including upstream geometry and source
provenance.
- `facts.DerivedFacts` with valid-period forecast slices, alert overlaps, - `facts.DerivedFacts` with valid-period forecast slices, alert overlaps,
daily summaries, daypart summaries, and Storm Report window summary report-period SPC convective outlooks and discussions, daily summaries,
daypart summaries, and Storm Report window summary
Hourly Report uses the generic valid-period hourly and narrative selection
for its rolling six-hour window. Its derived facts include precipitation timing
from the selected hourly periods, alert overlaps for the six-hour period, and
SPC outlooks/discussions overlapping that period. It does not build daily
summaries, daypart summaries, or a storm-window summary.
## Boundaries ## Boundaries
- This package owns fact assembly and reusable deterministic derivation for a - This package owns fact assembly and reusable deterministic derivation for a
report run. report run.
- SPC convective outlook derivation selects already-collected outlooks whose
half-open valid intervals overlap the resolved report period and retains
discussions for represented outlook days.
- Derived SPC outlook records preserve the collected outlook fields, including
geometry, for downstream components that need source-level facts. Prompt
modules decide which fields are exposed to Scriptorium.
- It does not fetch upstream data, build prompt wording, compare prior - It does not fetch upstream data, build prompt wording, compare prior
snapshots, write workflow state, invoke Scriptorium, or define modules. snapshots, write workflow state, invoke Scriptorium, or define modules.
@@ -53,8 +68,13 @@ and inspection.
- Invalid timezone names return an error. - Invalid timezone names return an error.
- Missing required hourly forecast data returns the underlying forecast - Missing required hourly forecast data returns the underlying forecast
derivation error for reports that require daily summaries. derivation error for reports that require daily summaries.
- Hourly Report can derive its default module facts without daily or
daypart summaries.
- Missing optional narrative, alert, discussion, daily, or weather story data - Missing optional narrative, alert, discussion, daily, or weather story data
produces empty or nil derived fields. produces empty or nil derived fields.
- Missing optional SPC convective outlook data produces a nil collected field.
- A present SPC convective outlook source with no report-period matches
produces non-nil empty derived outlook and discussion slices.
## Tests ## Tests
@@ -67,6 +87,8 @@ Inspect:
- Collected facts are built once from a fetched bundle. - Collected facts are built once from a fetched bundle.
- Derived facts are scoped to one resolved report. - Derived facts are scoped to one resolved report.
- SPC convective outlook selection uses the resolved report period and the
already-collected outlook run.
- Source provenance and warnings stay separate from ordinary fact fields. - Source provenance and warnings stay separate from ordinary fact fields.
- Prompt-specific wording and one-off presentation decisions stay outside this - Prompt-specific wording and one-off presentation decisions stay outside this
package. package.

View File

@@ -0,0 +1,140 @@
# Generated Text Internals
This document describes structured generated-text handling in
`internal/generatedtext`.
## Purpose
`internal/generatedtext` validates structured text returned for
generated-text-template reports and builds curated render contexts for
templates. It also owns the generated-text catalog that connects report
definitions to validators, render-context builders, schema assets, and template
assets. The implemented contracts are Daily render context, Today Report,
Tomorrow Report, and Hourly Report.
## Inputs And Outputs
Inputs:
- raw GeneratedText JSON for Daily, Today, Tomorrow Report, or Hourly Report
- report metadata from `internal/briefing`
- a module snapshot from `internal/module`
- validated generated text
Outputs:
- typed `Daily` generated text
- typed `Today` generated text
- typed `Tomorrow` generated text
- typed `Hourly` generated text
- normalized stable JSON for validated generated text
- typed `DailyRenderContext` values for `internal/reporttemplate`
- typed `TodayRenderContext` values for `internal/reporttemplate`
- typed `TomorrowRenderContext` values for `internal/reporttemplate`
- typed `HourlyRenderContext` values for `internal/reporttemplate`
- generated-text catalog handlers for report definitions that use
`generated_text_template`
The Daily generated text JSON accepts the same public fields and validation
rules as Tomorrow. Its catalog entry is selected through schema ID `daily` and
template ID `daily`, with prompt ID `weather.daily_generated_text`.
```json
{
"summary": "string",
"forecast_discussion": ["string"],
"precipitation_timing": "string",
"confidence": "string"
}
```
The hourly generated text JSON accepts:
```json
{
"summary": "string",
"forecast_discussion": "string",
"precipitation_timing": "string",
"confidence": "string"
}
```
`summary` and `forecast_discussion` are required after trimming whitespace.
`precipitation_timing` and `confidence` are optional and omitted from normalized
JSON when blank.
The Today generated text JSON accepts the same public fields and validation
rules as Tomorrow. It is selected by the active Today report definition through
schema ID `today`, template ID `today`, and prompt ID
`weather.today_generated_text`:
```json
{
"summary": "string",
"forecast_discussion": ["string"],
"precipitation_timing": "string",
"confidence": "string"
}
```
The Tomorrow generated text JSON accepts:
```json
{
"summary": "string",
"forecast_discussion": ["string"],
"precipitation_timing": "string",
"confidence": "string"
}
```
`summary` is required after trimming whitespace. `forecast_discussion` must
contain at least one nonblank paragraph after trimming blank items.
`precipitation_timing` and `confidence` are optional and omitted from normalized
JSON when blank.
## Boundaries
- This package owns typed generated-text validation and render-context shaping.
- It owns generated-text catalog lookup for schema/template combinations.
- It uses typed module snapshot decoding through `module.StanzaValue`.
- It does not invoke Scriptorium, write state artifacts, choose report
definitions, compare snapshots, or own embedded template/schema files.
- It renders through `internal/reporttemplate`; embedded asset lookup remains
in `internal/reporttemplate`.
- It does not use a Go JSON Schema dependency; schema enforcement in Go is
limited to typed JSON decoding, unknown-field rejection, and required-field
checks.
## Failure Behavior
- Malformed generated-text JSON fails with decode context.
- Unknown generated-text JSON fields fail during decoding.
- Empty required fields fail after trimming whitespace.
- Daily, Today, and Tomorrow forecast discussion fails when no nonblank
paragraphs remain.
- Missing optional render-context stanzas become nil module pointers.
- Invalid render metadata, including missing timezone, missing generated time,
or invalid valid period, fails before template rendering.
- Unsupported generated-text schema IDs, template IDs, or schema/template
combinations fail during catalog lookup with report ID context.
## Tests
Inspect:
- `internal/generatedtext/hourly_test.go`
- `internal/generatedtext/daily_test.go`
- `internal/generatedtext/today_test.go`
- `internal/generatedtext/tomorrow_test.go`
- `internal/generatedtext/catalog_test.go`
- `internal/generatedtext/render_context_test.go`
## Invariants
- Render contexts are curated structs, not raw prompt-input packages.
- Required generated text is normalized before downstream artifact storage.
- Generated-text-template reports must have one catalog entry matching their
report definition schema and template IDs.
- Missing optional weather narrative stanzas produce empty or fallback render
context fields rather than forcing raw module data into templates.

View File

@@ -5,9 +5,9 @@ This document describes the module contract in `internal/module`.
## Purpose ## Purpose
`internal/module` defines the shared identifiers and data envelopes used for `internal/module` defines the shared identifiers and data envelopes used for
prompt-facing modules. Report definitions use module IDs for composition, report modules. Report definitions use module IDs for composition, module
module builders produce outputs with stanza names, prompt input packages consume builders produce rich outputs with stanza names, prompt input packages consume
snapshots, and Recent Changes compares snapshot stanzas. runtime prompt export values, and Recent Changes compares snapshot stanzas.
## Inputs And Outputs ## Inputs And Outputs
@@ -23,8 +23,38 @@ Outputs:
- typed option structs for registered modules - typed option structs for registered modules
- `module.Snapshot` with schema version `weatherreporter.modules.v1` - `module.Snapshot` with schema version `weatherreporter.modules.v1`
- ordered snapshot outputs with module ID, stanza name, and typed value - ordered snapshot outputs with module ID, stanza name, and typed value
- runtime-only prompt export values on module outputs
- `module.Output.DataPackageValue`, which selects the prompt export value and
falls back to the rich value for hand-built or loaded snapshots
- typed stanza lookup through `module.StanzaValue` - typed stanza lookup through `module.StanzaValue`
## Rich Values And Prompt Exports
Each `module.Output` has two value surfaces:
- `Value`: the rich module value used by templates, module snapshots,
inspection, Recent Changes, and render contexts.
- `PromptValue`: the runtime-only prompt export used when building Scriptorium
data packages.
`PromptValue` is deliberately excluded from module snapshot JSON. Persisted
module snapshots keep only the rich `value` field so inspection and
render-context reconstruction keep the full deterministic template surface.
The `internal/briefing` module registry attaches prompt export values when it
builds module outputs. Modules without a custom exporter use default
pass-through behavior, so their prompt value is the same as their rich value.
Modules that need cleanup own typed prompt export structs near the module
builder. Current custom prompt exports are:
- `current_conditions`
- `hourly_forecast`
- `derived_daypart_summaries`
Custom exporters remove template-only helpers or confusing duplicates from the
data package without shrinking the rich module structs used by templates.
Exporter failures include module ID and stanza context.
## Registered Module IDs ## Registered Module IDs
The registry recognizes these IDs: The registry recognizes these IDs:
@@ -37,17 +67,140 @@ The registry recognizes these IDs:
- `derived_daypart_summaries` - `derived_daypart_summaries`
- `precip_timing` - `precip_timing`
- `alert_digest` - `alert_digest`
- `spc_convective_outlooks`
- `area_forecast_discussion` - `area_forecast_discussion`
- `spc_convective_discussion`
- `weather_story` - `weather_story`
- `outdoor_windows` - `outdoor_windows`
- `today_planning`
- `tomorrow_planning` - `tomorrow_planning`
- `daily_planning`
Every registered module has a builder. Report composition entries that refer to Every registered module has a builder. Report composition entries that refer to
unknown or unimplemented module IDs fail validation instead of being skipped. unknown or unimplemented module IDs fail validation instead of being skipped.
## Daily Composition
The default Daily Report module order is:
1. `metadata`
2. `current_conditions`
3. `narrative_forecast`
4. `derived_daily_summary`
5. `derived_daypart_summaries`
6. `precip_timing`
7. `alert_digest`
8. `spc_convective_outlooks`
9. `area_forecast_discussion`
10. `spc_convective_discussion`
11. `weather_story`
12. `outdoor_windows`
13. `daily_planning`
14. `hourly_forecast`
The embedded Daily template uses selected deterministic fields from these
module outputs after GeneratedText validation.
## Today Composition
The default Today Report module order is:
1. `metadata`
2. `current_conditions`
3. `narrative_forecast`
4. `derived_daily_summary`
5. `derived_daypart_summaries`
6. `precip_timing`
7. `alert_digest`
8. `spc_convective_outlooks`
9. `area_forecast_discussion`
10. `spc_convective_discussion`
11. `weather_story`
12. `outdoor_windows`
13. `hourly_forecast`
14. `today_planning`
The embedded Today template uses selected deterministic fields from these
module outputs after GeneratedText validation.
## Tomorrow Composition
The default Tomorrow Report module order is:
1. `metadata`
2. `current_conditions`
3. `narrative_forecast`
4. `derived_daily_summary`
5. `derived_daypart_summaries`
6. `precip_timing`
7. `alert_digest`
8. `spc_convective_outlooks`
9. `area_forecast_discussion`
10. `spc_convective_discussion`
11. `weather_story`
12. `outdoor_windows`
13. `tomorrow_planning`
14. `hourly_forecast`
The embedded Tomorrow template uses selected deterministic fields from these
module outputs after GeneratedText validation.
## Daily Planning
`daily_planning` emits dated daily planning facts for the `daily` report ID.
Its output stanza is also named `daily_planning`. The module is supported only
by that report ID and depends on daily summaries for the selected local civil
day. The default Daily Report composition includes it.
The output uses this shape:
- `morning_readiness`
- `commute_school_workday_concerns`
- `overnight_change_watch`
The type is `briefing.DailyPlanningModule`; it is independent from
`briefing.TomorrowPlanningModule`.
## Today Planning
`today_planning` emits current-day planning facts for Today Report. Its output
stanza is also named `today_planning`. The module is supported only by Today
Report and depends on daily and daypart summaries for the current local civil
day.
The output uses this shape:
- `morning_readiness`
- `commute_school_workday_concerns`
- `outdoor_planning`
- `late_day_change_watch`
The type is `briefing.TodayPlanningModule`; it is independent from
`briefing.TomorrowPlanningModule`.
## Hourly Composition
The default Hourly Report module order is:
1. `metadata`
2. `current_conditions`
3. `hourly_forecast`
4. `precip_timing`
5. `alert_digest`
6. `spc_convective_outlooks`
7. `area_forecast_discussion`
8. `spc_convective_discussion`
9. `weather_story`
Hourly Report does not include daily or daypart summary modules by default.
Its `area_forecast_discussion` item is configured to include only
`key_messages` and `short_term`.
## Options ## Options
Most modules use an empty options struct. `area_forecast_discussion` accepts: Most modules use an empty options struct, including
`spc_convective_outlooks` and `spc_convective_discussion`.
`area_forecast_discussion` accepts:
```yaml ```yaml
sections: sections:
@@ -61,6 +214,31 @@ An omitted or empty `sections` list includes all available discussion sections.
Invalid option shapes fail during config normalization or composition Invalid option shapes fail during config normalization or composition
validation. validation.
## SPC Convective Module Outputs
`spc_convective_outlooks` emits a risk-product stanza with:
- `checked`
- `as_of`
- `issued_at`
- `location_id`
- `location_name`
- `outlook_count`
- `outlooks`
Each outlook entry may include `day`, `outlook_type`, `label`, `label_text`,
`period_begins`, `period_ends`, `issued_at`, `contains_location`, and
`image_url`. It omits GeoJSON geometry, source URL, expiration time, and
severity rank.
`spc_convective_discussion` emits a narrative stanza only when a retained
report-period categorical outlook has severity rank `3` or higher and matching
discussion text is available. Its output includes `included_because` and
`discussions`; each discussion may include `day`, `period_begins`,
`period_ends`, `headline`, `summary`, `discussion`, and `updated_at`.
Discussions are included only for SPC days whose retained categorical outlooks
meet the severity threshold.
## Boundaries ## Boundaries
- This package owns module identifiers, config item envelopes, output - This package owns module identifiers, config item envelopes, output
@@ -73,7 +251,8 @@ validation.
`module.Snapshot` values are persisted by `internal/state` as JSON. Snapshot `module.Snapshot` values are persisted by `internal/state` as JSON. Snapshot
validation rejects missing schema version, missing module IDs, missing stanza validation rejects missing schema version, missing module IDs, missing stanza
names, duplicate module outputs, and duplicate stanza names while preserving names, duplicate module outputs, and duplicate stanza names while preserving
output order. output order. Snapshot JSON contains rich module values only; runtime prompt
export values are not persisted.
## Failure Behavior ## Failure Behavior
@@ -94,5 +273,8 @@ Inspect:
- `internal/module` does not import `internal/report`. - `internal/module` does not import `internal/report`.
- Module IDs are stable strings. - Module IDs are stable strings.
- Each emitted module output has exactly one stanza name and one typed value. - Each emitted module output has exactly one stanza name and one rich typed
value.
- Built module outputs have a data-package value, either from a custom prompt
exporter or from default pass-through behavior.
- Snapshot output order is caller-owned and preserved. - Snapshot output order is caller-owned and preserved.

View File

@@ -5,13 +5,15 @@ This document describes YAML prompt data package construction in
## Purpose ## Purpose
`internal/promptinput` converts report metadata, an ordered module snapshot, `internal/promptinput` converts report metadata, ordered module outputs, Recent
Recent Changes, and source warnings into the `data_package` file passed to Changes, and source warnings into the `data_package` file passed to
Scriptorium. Scriptorium.
The persisted data package is YAML with schema version The persisted data package is YAML with schema version
`weatherreporter.data_package.v2`. It is separate from the JSON module snapshot `weatherreporter.data_package.v3`. It is separate from the JSON module snapshot
used for inspection and comparison. used for inspection and comparison. Data packages serialize each module
output's prompt export value, not necessarily the full rich module value saved
in the module snapshot.
## Inputs And Outputs ## Inputs And Outputs
@@ -32,7 +34,7 @@ Outputs:
The YAML shape includes: The YAML shape includes:
```yaml ```yaml
schema_version: weatherreporter.data_package.v2 schema_version: weatherreporter.data_package.v3
run_id: <run_id> run_id: <run_id>
report: report:
id: <report_id> id: <report_id>
@@ -41,6 +43,7 @@ briefing:
metadata: {} metadata: {}
applicable_risk_products: applicable_risk_products:
alert_digest: {} alert_digest: {}
spc_convective_outlooks: {}
derived_summaries: derived_summaries:
derived_daily_summary: {} derived_daily_summary: {}
derived_daypart_summaries: {} derived_daypart_summaries: {}
@@ -49,6 +52,7 @@ briefing:
narrative_products: narrative_products:
narrative_forecast: {} narrative_forecast: {}
area_forecast_discussion: {} area_forecast_discussion: {}
spc_convective_discussion: {}
weather_story: {} weather_story: {}
raw_data: raw_data:
current_conditions: {} current_conditions: {}
@@ -62,13 +66,62 @@ weather module stanzas under prompt-facing categories. This grouping is a YAML
presentation concern only: module snapshots remain flat, and loaded presentation concern only: module snapshots remain flat, and loaded
`promptinput.Package` values expose flat stanza names in `Briefing.Values`. `promptinput.Package` values expose flat stanza names in `Briefing.Values`.
Within each category, stanza order follows the module snapshot output order. Within each category, stanza order follows the module snapshot output order.
Prompt-facing module intervals use local `period_begins` and `period_ends`
labels; canonical report metadata and source timestamps remain structured
timestamps where applicable.
## Module Export Boundary
Data packages are curated prompt inputs. They are not full template render
contexts and should not be treated as a dump of every field available to Go
templates.
When module outputs are built by `internal/briefing`, the registry attaches a
runtime prompt export value. `internal/promptinput` serializes
`output.DataPackageValue()` for each stanza. That helper prefers the runtime
prompt export and falls back to the rich `Value` when no prompt export is set,
which keeps loaded snapshots and hand-built tests usable.
Modules without custom export policy use pass-through behavior. Modules with
custom exports currently include:
- `current_conditions`: omits lower-case condition text and duplicate
wind-direction text.
- `hourly_forecast`: omits hour labels, lower-case description text, and the
template precipitation-mention helper while keeping forecast facts.
- `derived_daypart_summaries`: omits deterministic sentence-construction
helpers while keeping daypart period, condition, temperature trend,
precipitation, wind, notable-condition, hazard, and alert-relevance facts.
The rich module snapshot and generated-template render context still contain
the helper fields used by deterministic Markdown templates.
Daily Report, Today Report, Tomorrow Report, and Hourly Report module snapshots
use the same package schema and categories when converted into prompt input.
The default hourly module list places
`precip_timing` under `derived_summaries`, alert and SPC outlooks under
`applicable_risk_products`, AFD/SPC discussion/weather story under
`narrative_products`, and current/hourly data under `raw_data`. It does not
include civil-day summary stanzas. Generated-text and render context artifacts
are produced later in app orchestration and are not part of the YAML data
package.
The default Daily, Today, and Tomorrow module lists include civil-day summary
stanzas, planning stanzas, and `hourly_forecast` in the data package before
structured GeneratedText is requested from Scriptorium. Daily uses
`daily_planning`, Today uses `today_planning`, and Tomorrow uses
`tomorrow_planning`.
Current categories are: Current categories are:
- `applicable_risk_products`: location-applicable alerts, warnings, outlooks, - `applicable_risk_products`: location-applicable alerts, warnings, outlooks,
discussions, and similar risk products. and similar risk products. Current stanzas include `alert_digest` and
`spc_convective_outlooks`.
- `derived_summaries`: deterministic summaries and calculated report facts. - `derived_summaries`: deterministic summaries and calculated report facts.
- `narrative_products`: official narrative text products and forecast stories. - `narrative_products`: official narrative text products and forecast stories.
Current stanzas include `narrative_forecast`,
`area_forecast_discussion`, `spc_convective_discussion`, and
`weather_story`.
- `raw_data`: minimally transformed underlying weather data. - `raw_data`: minimally transformed underlying weather data.
## Boundaries ## Boundaries
@@ -76,8 +129,8 @@ Current categories are:
- This package owns prompt package schema, YAML marshaling, YAML loading, and - This package owns prompt package schema, YAML marshaling, YAML loading, and
validation. validation.
- It does not fetch weather data, derive forecast summaries, execute modules, - It does not fetch weather data, derive forecast summaries, execute modules,
find prior snapshots, compare changes, choose artifact paths, or invoke choose module prompt export shapes, find prior snapshots, compare changes,
Scriptorium. choose artifact paths, or invoke Scriptorium.
## Config Fields Used ## Config Fields Used
@@ -117,5 +170,8 @@ Inspect:
- Scriptorium receives structured YAML through `--input data_package=<path>`. - Scriptorium receives structured YAML through `--input data_package=<path>`.
- Module stanza order is deterministic within each prompt-facing category. - Module stanza order is deterministic within each prompt-facing category.
- Every non-metadata module stanza has exactly one prompt-input category. - Every non-metadata module stanza has exactly one prompt-input category.
- Data-package stanzas use curated module prompt exports when present and rich
values only as pass-through or fallback values.
- Data packages are narrower than generated-template render contexts.
- Recent Changes are provided by `internal/changes`; this package does not - Recent Changes are provided by `internal/changes`; this package does not
infer changes from rendered report text. infer changes from rendered report text.

View File

@@ -6,9 +6,10 @@ membership, output naming, artifact grouping, and comparison declarations in
## Purpose ## Purpose
`internal/report` is the canonical source for report definitions. App, state, `internal/report` is the canonical source for report definitions, public
module building, and CLI wiring consume resolved definitions instead of owning command names, config-key aliases, and batch command names. App, config, state,
report identity policy themselves. module building, and CLI wiring consume report-owned helpers and resolved
definitions instead of owning report identity policy themselves.
## Definition Fields ## Definition Fields
@@ -16,6 +17,7 @@ Each report definition declares:
- report ID and display name - report ID and display name
- Scriptorium prompt ID - Scriptorium prompt ID
- generation mode
- valid-period resolver - valid-period resolver
- comparison strategy - comparison strategy
- managed artifact group - managed artifact group
@@ -25,23 +27,46 @@ Each report definition declares:
- morning or evening batch membership - morning or evening batch membership
- default ordered module composition - default ordered module composition
Report-owned helpers map public command names and config keys to report IDs.
The generate command names are `daily`, `today`, `tomorrow`, `hourly`,
`three-day`, `weekend`, and `storm`. Config keys also accept selected
underscore and descriptive aliases such as `three_day_outlook`,
`weekend_outlook`, and `storm_report`.
`daily` resolves to the dated Daily Report ID `daily`. `today` resolves to the
independent Today report ID `today`. `reports.today` is not an alias for
`reports.daily`, and retired report keys are not supported.
Markdown report definitions use the `scriptorium_markdown` generation mode.
Their template and structured-text schema identifiers are empty. Daily Report,
Today Report, Tomorrow Report, and Hourly Report declare
`generated_text_template`; the app uses their template and schema identifiers
to validate generated text and render embedded Markdown templates.
## Reports ## Reports
| Report | ID | Prompt | Artifact group | Batch copy | Prior compatibility | | Report | ID | Prompt | Generation mode | Artifact group | Batch copy | Prior compatibility |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- |
| Daily Today | `daily_today` | `weather.daily_report` | `daily` | `daily.md` | Daily Today, Daily Tomorrow | | Daily Report | `daily` | `weather.daily_generated_text` | `generated_text_template` | `daily` | `daily.md` | Daily Report |
| Daily Tomorrow | `daily_tomorrow` | `weather.daily_report` | `daily` | `tomorrow.md` | Daily Today, Daily Tomorrow | | Today Report | `today` | `weather.today_generated_text` | `generated_text_template` | `today` | `today.md` | Today Report |
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `three-day` | `three-day.md` | 3-Day Outlook | | Tomorrow Report | `tomorrow` | `weather.tomorrow_generated_text` | `generated_text_template` | `tomorrow` | `tomorrow.md` | Tomorrow Report |
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `weekend` | `weekend.md` | Weekend Outlook | | Hourly Report | `hourly` | `weather.hourly_generated_text` | `generated_text_template` | `hourly` | `hourly.md` | Hourly Report |
| Storm Report | `storm` | `weather.storm_report` | `storm` | `storm.md` | Storm Report | | 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `scriptorium_markdown` | `three-day` | `three-day.md` | 3-Day Outlook |
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `scriptorium_markdown` | `weekend` | `weekend.md` | Weekend Outlook |
| Storm Report | `storm` | `weather.storm_report` | `scriptorium_markdown` | `storm` | `storm.md` | Storm Report |
All report definitions are eligible for generation. All report definitions are eligible for generation.
## Valid Periods ## Valid Periods
- Daily Today covers the selected local civil day, or the current local civil - Daily Report covers the selected local civil day and requires an explicit
date.
- Today Report covers the selected local civil day, or the current local civil
day when no date override is supplied. day when no date override is supplied.
- Daily Tomorrow covers the next local civil day from generation time. - Tomorrow Report covers the next local civil day from generation time.
- Hourly Report covers the half-open six-hour period from generation time in
the effective report timezone. The duration is an internal report constant,
not a configuration field.
- 3-Day Outlook covers the interval from generation time through local midnight - 3-Day Outlook covers the interval from generation time through local midnight
three days later. three days later.
- Weekend Outlook covers the upcoming weekend window and is not scheduled for - Weekend Outlook covers the upcoming weekend window and is not scheduled for
@@ -54,17 +79,26 @@ must be after start time.
## Boundaries ## Boundaries
`internal/report` defines report metadata and time coverage. It does not fetch `internal/report` defines report metadata, public report names, batch command
weather data, build module values, compare snapshot contents, write state, names, and time coverage. It does not fetch weather data, build module values,
parse CLI flags, or invoke Scriptorium. compare snapshot contents, write state, parse CLI flags, or invoke Scriptorium.
The CLI owns public command names. The app maps those command names to report The CLI parses flags and command structure, then uses report-owned helpers for
IDs, then uses the registry for report policy. report and batch command names. Config loading uses report-owned helpers for
report override keys.
## Config Fields Used ## Config Fields Used
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
output path copying uses batch output names from report definitions. output path copying uses batch output names from report definitions. Report
module overrides can use short keys such as `daily`, `today`, `tomorrow`, and
`hourly`, or descriptive names such as `three_day_outlook`.
## Batch Membership
Morning batches include Today Report, 3-Day Outlook, and Weekend Outlook
except on Sunday. Evening batches include Tomorrow Report. Daily Report and
Hourly Report are not part of a scheduled batch.
## State And App Usage ## State And App Usage
@@ -92,7 +126,11 @@ Inspect:
## Invariants ## Invariants
- Report selection goes through the registry. - Report selection goes through the registry.
- Daily Today and Daily Tomorrow both use `weather.daily_report`. - Public command names, config-key aliases, and batch command names are owned
by `internal/report`.
- Direct Markdown reports have empty template and generated-text schema IDs.
- Generated-text-template reports declare prompt, template, and schema IDs in
their report definition.
- Valid periods are half-open intervals independent of rendered report text. - Valid periods are half-open intervals independent of rendered report text.
- Artifact grouping, batch output filenames, generated-report eligibility, - Artifact grouping, batch output filenames, generated-report eligibility,
default module composition, comparison compatibility, and comparison strategy default module composition, comparison compatibility, and comparison strategy

View File

@@ -0,0 +1,113 @@
# Report Template Internals
This document describes embedded Markdown templates and GeneratedText schemas
in `internal/reporttemplate`.
## Purpose
`internal/reporttemplate` owns repository-native report templates and companion
GeneratedText JSON schemas. The implemented template assets are Daily, Today,
Tomorrow, and Hourly.
The package embeds assets from:
- `internal/reporttemplate/templates/*.md.tmpl`
- `internal/reporttemplate/schemas/*.schema.json`
Generated-text prompt source files live under
`internal/reporttemplate/prompts/`. They are repository assets for prompt
registration, not embedded lookup APIs.
## Inputs And Outputs
Inputs:
- template ID from a report definition
- typed render context built by `internal/generatedtext`
Outputs:
- template source for inspection and tests
- GeneratedText schema bytes for prompt/schema configuration
- rendered Markdown bytes for app orchestration to persist
The implemented template IDs are `daily`, `today`, `tomorrow`, and `hourly`.
The implemented schema IDs are also `daily`, `today`, `tomorrow`, and
`hourly`, backed by matching `*.generated_text.schema.json` files.
Generated-text prompt sources are maintained under
`internal/reporttemplate/prompts/`, including Daily's
`daily.generated_text.md` source for prompt ID `weather.daily_generated_text`.
## Boundaries
This package owns embedded asset lookup, Go template parsing, and Markdown
template execution. It does not fetch weather data, build module outputs,
validate GeneratedText, construct render contexts, choose report definitions,
write artifacts, invoke Scriptorium, or notify distributor.
GeneratedText validation is owned by `internal/generatedtext`. App
orchestration uses `internal/generatedtext` catalog lookup to connect
`internal/report` definition schema/template IDs to the matching validator,
render-context builder, and embedded assets.
## Template Contracts
Daily, Today, Tomorrow, and Hourly rendering use typed render contexts with:
- report metadata labels such as title, location, valid period, and generation
time
- validated GeneratedText prose slots
- deterministic labels derived from module outputs, including current
conditions, hourly forecast rows, precipitation timing, alerts, SPC outlooks,
forecast discussion, SPC discussion, and weather story
Daily, Today, and Tomorrow additionally expose forecast-date labels, ordered
daypart forecast rows, daily/daypart summaries, planning facts, and a
multi-paragraph forecast discussion generated-text slot. The ordered daypart
slice is built in Go so templates do not range over maps.
The Daily template asset uses the same Markdown structure as Tomorrow's
template and renders from `generatedtext.DailyRenderContext`.
Templates use `text/template` with `missingkey=error`, so missing context fields
fail rendering instead of producing incomplete Markdown.
## Schema Contract
The GeneratedText schemas describe the structured prose Scriptorium is expected
to write for each generated-text prompt. Hourly requires:
- `summary`
- `forecast_discussion`
Daily, Today, and Tomorrow require `summary` and a nonempty
`forecast_discussion` array. All generated-text schemas allow optional
`precipitation_timing` and `confidence`, and reject additional properties.
Weather truth remains in module outputs; GeneratedText is limited to prose
slots consumed by the template.
## Failure Behavior
- Unknown template IDs return actionable lookup errors.
- Unknown schema IDs return actionable lookup errors.
- Template parse errors include the template ID.
- Template execution errors include the template ID and usually identify the
missing context field.
## Tests
Inspect:
- `internal/reporttemplate/reporttemplate_test.go`
- `internal/generatedtext/render_context_test.go`
- `internal/app/app_test.go`
- `internal/cli/root_test.go`
## Invariants
- Embedded templates and schemas live as separate files, not inline Go strings.
- Report definitions select templates by ID.
- Templates render from curated render contexts, not raw data packages.
- GeneratedText schemas describe LLM prose slots, not deterministic weather
facts.

View File

@@ -6,9 +6,9 @@ This document describes the subprocess adapter in
## Purpose ## Purpose
The adapter runs `scriptorium render` for prompt preflight and `scriptorium run` The adapter runs `scriptorium render` for prompt preflight and `scriptorium run`
for Markdown report generation. It isolates subprocess execution, argv for Markdown report generation or structured generated-text output. It isolates
construction, timeout handling, output capture, and exit-code interpretation subprocess execution, argv construction, timeout handling, output capture, and
from app and domain packages. exit-code interpretation from app and domain packages.
## Inputs And Outputs ## Inputs And Outputs
@@ -17,6 +17,7 @@ Inputs:
- prompt ID - prompt ID
- YAML prompt input data package path - YAML prompt input data package path
- report output path for `run` - report output path for `run`
- raw generated-text output path for structured `run`
- configured binary, config path, profile, timeout, and extra arguments - configured binary, config path, profile, timeout, and extra arguments
- context for cancellation - context for cancellation
@@ -27,6 +28,7 @@ Outputs:
- truncation flags for captured output - truncation flags for captured output
- exit code - exit code
- report output path for `run` - report output path for `run`
- raw generated-text output path for structured `run`
## Boundaries ## Boundaries
@@ -34,9 +36,9 @@ Outputs:
subprocess execution. It does not choose report types, build prompt input, subprocess execution. It does not choose report types, build prompt input,
fetch weather data, decide workflow order, or persist workflow metadata. fetch weather data, decide workflow order, or persist workflow metadata.
The adapter exposes request and result structs for render and run operations. The adapter exposes request and result structs for render, Markdown run, and
State persistence uses a state-owned preflight artifact shape; app structured generated-text run operations. State persistence uses state-owned
orchestration converts render results before saving. artifact shapes; app orchestration converts adapter results before saving.
## Config Fields Used ## Config Fields Used
@@ -60,6 +62,12 @@ Report generation argv starts with:
scriptorium run --prompt <prompt_id> --input data_package=<path> --out <path> scriptorium run --prompt <prompt_id> --input data_package=<path> --out <path>
``` ```
Structured generated-text argv uses the same `scriptorium run` form, with the
`--out` value set to the raw generated-text JSON artifact path. The adapter
does not add `--format`, schema path, or JSON Schema flags for structured
generation; Scriptorium selects the structured output schema from prompt
configuration.
Configured `--config` and `--profile` flags are inserted after the subcommand Configured `--config` and `--profile` flags are inserted after the subcommand
and before prompt-specific arguments. Extra arguments are appended after the and before prompt-specific arguments. Extra arguments are appended after the
built-in arguments. built-in arguments.
@@ -67,8 +75,8 @@ built-in arguments.
## Execution Behavior ## Execution Behavior
The adapter runs commands without shell interpolation. The same private The adapter runs commands without shell interpolation. The same private
execution path is used by render and run after command-specific request execution path is used by render, Markdown run, and structured run after
validation and argv construction. command-specific request validation and argv construction.
When `scriptorium.timeout` is greater than zero, each subprocess call uses a When `scriptorium.timeout` is greater than zero, each subprocess call uses a
context with that timeout. Stdout and stderr are captured separately, capped at context with that timeout. Stdout and stderr are captured separately, capped at
@@ -81,8 +89,8 @@ context with that timeout. Stdout and stderr are captured separately, capped at
- Missing run output path returns an error before subprocess execution. - Missing run output path returns an error before subprocess execution.
- Subprocess start errors, context cancellation, and timeouts are wrapped with - Subprocess start errors, context cancellation, and timeouts are wrapped with
operation context by the caller-facing method. operation context by the caller-facing method.
- Nonzero render and run exits return the captured result plus an error - Nonzero render, Markdown run, and structured run exits return the captured
containing the exit code and stderr. result plus an error containing the exit code and stderr.
## Tests ## Tests
@@ -97,5 +105,6 @@ Inspect:
- No shell interpolation is used. - No shell interpolation is used.
- The Scriptorium input name is `data_package`. - The Scriptorium input name is `data_package`.
- The file at the data package path is YAML produced by `internal/promptinput`. - The file at the data package path is YAML produced by `internal/promptinput`.
- Render and run preserve command-specific result structs. - Render, Markdown run, and structured run preserve command-specific result
structs.
- Scriptorium-specific flags stay inside adapter and config boundaries. - Scriptorium-specific flags stay inside adapter and config boundaries.

View File

@@ -16,6 +16,7 @@ Inputs:
- module snapshot - module snapshot
- prompt input data package - prompt input data package
- preflight artifact - preflight artifact
- generated-text raw, run-result, validated text, and render-context artifacts
- rendered report path preparation request - rendered report path preparation request
- RunID for inspection lookups - RunID for inspection lookups
@@ -24,10 +25,15 @@ Outputs:
- module snapshot JSON path - module snapshot JSON path
- prompt input data package YAML path - prompt input data package YAML path
- render preflight JSON path - render preflight JSON path
- generated-text raw JSON path
- generated-text run-result JSON path
- validated generated-text JSON path
- render context JSON path
- managed Markdown report path - managed Markdown report path
- metadata JSON path - metadata JSON path
- prior comparable snapshot metadata - prior comparable snapshot metadata
- loaded module snapshot or data package - loaded module snapshot, data package, generated text, generated-text run
result, or render context
- recent report records for inspection - recent report records for inspection
## Boundaries ## Boundaries
@@ -61,6 +67,10 @@ valid-period start date for dated artifacts, and the RunID.
<workspace.root>/ <workspace.root>/
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.modules.json snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.modules.json
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.metadata.json snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.metadata.json
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.raw.json
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.run.json
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.json
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.render_context.json
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.yaml data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.yaml
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json
notifications/<artifact_group>/<YYYY-MM-DD>/<run_id>.distributor.json notifications/<artifact_group>/<YYYY-MM-DD>/<run_id>.distributor.json
@@ -69,8 +79,11 @@ valid-period start date for dated artifacts, and the RunID.
Metadata is stored beside module snapshots and links the module snapshot, data Metadata is stored beside module snapshots and links the module snapshot, data
package, preflight, report paths, notification path when attempted, and package, preflight, report paths, notification path when attempted, and
configured prompt location. Report listing walks metadata files under the configured prompt location. For generated-text-template reports, metadata also
snapshots directory. records the generated text schema ID and links the raw generated text,
Scriptorium run result, validated generated text, and render context artifacts.
Markdown-report metadata omits those generated-text fields. Report listing
walks metadata files under the snapshots directory.
## Prior Lookup ## Prior Lookup
@@ -78,28 +91,37 @@ Prior snapshot lookup reads stored metadata through the shared lookup path and
selects the latest earlier snapshot whose report ID is compatible with the selects the latest earlier snapshot whose report ID is compatible with the
current report definition. current report definition.
- Daily Today and Daily Tomorrow are compatible with each other for the same - Daily Report compares with prior Daily Report snapshots for the same valid
local date.
- Today Report compares with prior Today Report snapshots for the same valid
local date.
- Tomorrow Report compares with prior Tomorrow Report snapshots for the same
valid local date. valid local date.
- 3-Day Outlook compares with prior 3-Day snapshots for the same valid local - 3-Day Outlook compares with prior 3-Day snapshots for the same valid local
date. date.
- Weekend Outlook compares with prior Weekend snapshots for the same weekend - Weekend Outlook compares with prior Weekend snapshots for the same weekend
window. window.
- Hourly Report uses the rolling-window comparison strategy and currently
returns no prior snapshot from filesystem lookup.
- Storm Report has no prior lookup because explicit event-window comparison is - Storm Report has no prior lookup because explicit event-window comparison is
not searched by the filesystem store. not searched by the filesystem store.
## Writes And Inspection ## Writes And Inspection
Durable JSON writes use shared atomic file helpers. Managed Markdown reports are Durable JSON writes use shared atomic file helpers. Generated-text raw and
prepared by creating their parent directory; Scriptorium writes the report body validated JSON artifacts are written atomically as bytes; generated-text run
to the prepared path. Extra Markdown copies are handled by app orchestration. result and render context artifacts are written atomically as JSON. Managed
Distributor notification debug artifacts are written atomically when Markdown reports are prepared by creating their parent directory; Scriptorium
notification is attempted and include rendered distributor pipeline ID, bundle writes the report body to the prepared path. Extra Markdown copies are handled
ID, idempotency key, bundle paths, upload status, latest run status, and by app orchestration. Distributor notification debug artifacts are written
redacted errors. atomically when notification is attempted and include rendered distributor
pipeline ID, bundle ID, idempotency key, bundle paths, upload status, latest
run status, and redacted errors.
Inspection helpers read existing metadata, module snapshot, and data package Inspection helpers read existing metadata, module snapshot, data package,
files. Missing metadata directories return no inspection records or no prior generated text, generated-text run result, and render context files. Missing
snapshot rather than creating state. metadata directories return no inspection records or no prior snapshot rather
than creating state.
## Failure Behavior ## Failure Behavior
@@ -121,4 +143,6 @@ Inspect:
- Managed paths stay under the configured workspace root. - Managed paths stay under the configured workspace root.
- Artifact grouping comes from report definitions. - Artifact grouping comes from report definitions.
- Metadata links artifacts produced for a run. - Metadata links artifacts produced for a run.
- Generated-text artifacts live under the snapshots tree beside module
snapshots and metadata.
- Prior lookup is based on structured metadata, not rendered report text. - Prior lookup is based on structured metadata, not rendered report text.

View File

@@ -4,10 +4,10 @@ This document describes Weather API ingestion into `weatherdata.Bundle`.
## Purpose ## Purpose
`internal/adapters/weatherapi` fetches normalized weather data from one `internal/adapters/weatherapi` fetches normalized weather data from the
configured Weather API endpoint and assembles the bundle consumed by forecast configured Weather API and assembles the bundle consumed by forecast derivation
derivation and module builders. Module builders expose normalized current and module builders. Module builders expose normalized current conditions and
conditions and weather story context when those sources are available. weather story context when those sources are available.
## Inputs And Outputs ## Inputs And Outputs
@@ -21,7 +21,8 @@ Outputs:
- `weatherdata.Bundle` with observation, current conditions, hourly forecast, - `weatherdata.Bundle` with observation, current conditions, hourly forecast,
narrative forecast, active alerts, discussion, latest weather story, source narrative forecast, active alerts, discussion, latest weather story, source
records, and source warnings records, source warnings, and typed SPC convective outlook data when that
optional source is available
- optional saved bundle JSON through app fetch helpers - optional saved bundle JSON through app fetch helpers
## Boundaries ## Boundaries
@@ -54,9 +55,19 @@ contract used by this project.
The adapter records source name, endpoint, query, fetch time, source timestamps The adapter records source name, endpoint, query, fetch time, source timestamps
when available, SHA-256 hash over compact raw `data` JSON, missing status, and when available, SHA-256 hash over compact raw `data` JSON, missing status, and
source warnings. Successful `data: null` responses from `/alerts/active` source warnings. Successful `data: null` responses from `/alerts/active`
represent a checked empty active-alert list, not a missing source. represent a checked empty active-alert list, not a missing source. Successful
non-null `/outlooks/convective` responses with empty outlook and discussion
arrays represent checked empty outlook data.
`app.FetchAndSaveBundle` can write bundle JSON atomically for inspection. `app.FetchAndSaveBundle` can write bundle JSON atomically for inspection.
SPC convective outlook data is stored on
`weatherdata.Bundle.SPCConvectiveOutlooks`. The collected run keeps upstream
run metadata, location identifiers, ordered outlook records, discussion
records, and each outlook's raw GeoJSON geometry. Source provenance for this
payload uses the `spc_convective_outlooks` source name, endpoint
`/outlooks/convective`, the query sent by the adapter, timestamps, and a hash
of the raw `data` object.
## Skip And Resume Behavior ## Skip And Resume Behavior
No resume behavior. Optional missing or malformed sources may be omitted, No resume behavior. Optional missing or malformed sources may be omitted,
@@ -72,6 +83,8 @@ data is required and cannot be skipped.
- Optional sources follow missing-source policy. - Optional sources follow missing-source policy.
- Explicit `data: null` from `/alerts/active` produces an empty, non-missing - Explicit `data: null` from `/alerts/active` produces an empty, non-missing
alert run. alert run.
- Explicit `data: null` from `/outlooks/convective` follows optional
missing-source policy.
## Tests ## Tests

View File

@@ -10,20 +10,34 @@ Generation commands:
```text ```text
weatherreporter generate daily --date 2026-05-29 weatherreporter generate daily --date 2026-05-29
weatherreporter generate today
weatherreporter generate tomorrow weatherreporter generate tomorrow
weatherreporter generate hourly
weatherreporter generate three-day weatherreporter generate three-day
weatherreporter generate weekend weatherreporter generate weekend
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
``` ```
Each command resolves a report period, fetches a Weather API bundle, builds a Generation commands resolve a report period, fetch a Weather API bundle, build
JSON module snapshot, builds a YAML prompt input data package, runs a rich JSON module snapshot, build a curated YAML prompt input data package, run
`scriptorium render`, runs `scriptorium run`, and writes managed artifacts under `scriptorium render`, and write managed artifacts under the configured
the configured workspace. workspace. Markdown-path reports then run `scriptorium run` directly to the
managed Markdown report path.
`generate daily`, `generate today`, `generate tomorrow`, and `generate hourly`
use the generated-text-template workflow. They run structured `scriptorium run`
to raw GeneratedText JSON, validate the structured text, save a render context,
and render the managed Markdown report from embedded templates. `generate
daily` requires `--date YYYY-MM-DD` for the selected local civil day.
`generate today` covers the selected or current local civil day. `generate
hourly` covers the six-hour rolling period from generation time in the
effective report timezone and is not part of scheduled morning or evening
batches.
When distributor notification is enabled, weatherreporter uploads the managed When distributor notification is enabled, weatherreporter uploads the managed
Markdown report after `scriptorium run` succeeds and final metadata is saved. Markdown report after report rendering succeeds and final metadata is saved.
`--out PATH` writes an extra Markdown copy for the current generated report; it `--out PATH` writes an extra Markdown copy for generated reports; it is not used
is not used as the distributor upload source. as the distributor upload source.
Batch commands: Batch commands:
@@ -32,14 +46,14 @@ weatherreporter run morning
weatherreporter run evening weatherreporter run evening
``` ```
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook `run morning` generates Today Report and the 3-Day Outlook, plus Weekend Outlook
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch except on Sunday. `run evening` generates the Tomorrow Report. Batch
commands print a JSON summary to stdout, write compact per-report status lines commands print a JSON summary to stdout, write compact per-report status lines
to stderr, continue independent reports after one report fails, and return to stderr, continue independent reports after one report fails, and return
nonzero when any report failed. When notification is configured, the summary and nonzero when any report failed. When notification is configured, the summary and
status lines include notification status, accepted distributor run ID, or status lines include notification status, accepted distributor run ID, or
notification error fields for each attempted report. `--out-dir PATH` writes notification error fields for each attempted report. `--out-dir PATH` writes
extra Markdown copies using report default filenames such as `daily.md`, extra Markdown copies using report default filenames such as `today.md`,
`three-day.md`, `weekend.md`, and `tomorrow.md`; these copies are not used as `three-day.md`, `weekend.md`, and `tomorrow.md`; these copies are not used as
distributor upload sources. distributor upload sources.
@@ -54,6 +68,18 @@ workspace/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.modules.json <run_id>.modules.json
<run_id>.metadata.json <run_id>.metadata.json
<run_id>.generated_text.raw.json
<run_id>.generated_text.run.json
<run_id>.generated_text.json
<run_id>.render_context.json
today/
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
<run_id>.generated_text.raw.json
<run_id>.generated_text.run.json
<run_id>.generated_text.json
<run_id>.render_context.json
three-day/ three-day/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.modules.json <run_id>.modules.json
@@ -62,6 +88,22 @@ workspace/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.modules.json <run_id>.modules.json
<run_id>.metadata.json <run_id>.metadata.json
hourly/
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
<run_id>.generated_text.raw.json
<run_id>.generated_text.run.json
<run_id>.generated_text.json
<run_id>.render_context.json
tomorrow/
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
<run_id>.generated_text.raw.json
<run_id>.generated_text.run.json
<run_id>.generated_text.json
<run_id>.render_context.json
storm/ storm/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.modules.json <run_id>.modules.json
@@ -70,12 +112,21 @@ workspace/
daily/ daily/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.data_package.yaml <run_id>.data_package.yaml
today/
YYYY-MM-DD/
<run_id>.data_package.yaml
three-day/ three-day/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.data_package.yaml <run_id>.data_package.yaml
weekend/ weekend/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.data_package.yaml <run_id>.data_package.yaml
hourly/
YYYY-MM-DD/
<run_id>.data_package.yaml
tomorrow/
YYYY-MM-DD/
<run_id>.data_package.yaml
storm/ storm/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.data_package.yaml <run_id>.data_package.yaml
@@ -83,12 +134,21 @@ workspace/
daily/ daily/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.render.json <run_id>.render.json
today/
YYYY-MM-DD/
<run_id>.render.json
three-day/ three-day/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.render.json <run_id>.render.json
weekend/ weekend/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.render.json <run_id>.render.json
hourly/
YYYY-MM-DD/
<run_id>.render.json
tomorrow/
YYYY-MM-DD/
<run_id>.render.json
storm/ storm/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.render.json <run_id>.render.json
@@ -96,22 +156,37 @@ workspace/
daily/ daily/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.distributor.json <run_id>.distributor.json
today/
YYYY-MM-DD/
<run_id>.distributor.json
three-day/ three-day/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.distributor.json <run_id>.distributor.json
weekend/ weekend/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.distributor.json <run_id>.distributor.json
hourly/
YYYY-MM-DD/
<run_id>.distributor.json
tomorrow/
YYYY-MM-DD/
<run_id>.distributor.json
storm/ storm/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.distributor.json <run_id>.distributor.json
reports/ reports/
daily/ daily/
<run_id>.md <run_id>.md
today/
<run_id>.md
three-day/ three-day/
<run_id>.md <run_id>.md
weekend/ weekend/
<run_id>.md <run_id>.md
hourly/
<run_id>.md
tomorrow/
<run_id>.md
storm/ storm/
<run_id>.md <run_id>.md
``` ```
@@ -124,7 +199,8 @@ period do not overwrite each other.
RunIDs are based on generation time plus report ID: RunIDs are based on generation time plus report ID:
```text ```text
20260529T100000.123456789Z_daily_today 20260529T100000.123456789Z_daily
20260529T100000.123456789Z_today
``` ```
Each generated report writes metadata that links: Each generated report writes metadata that links:
@@ -136,6 +212,8 @@ Each generated report writes metadata that links:
- prompt input data package path - prompt input data package path
- preflight output path - preflight output path
- managed Markdown report path - managed Markdown report path
- generated text schema ID and generated-text artifact paths for
generated-text-template reports
- distributor notification debug artifact path, when notification is attempted - distributor notification debug artifact path, when notification is attempted
Batch summaries include report status, error text when applicable, notification Batch summaries include report status, error text when applicable, notification
@@ -164,9 +242,14 @@ The default idempotency key appends RunID to the rendered bundle ID so each
report generation has a distinct retry identity. The default bundle path uses report generation has a distinct retry identity. The default bundle path uses
the valid-period start date, artifact group, and RunID. Distributor owns the valid-period start date, artifact group, and RunID. Distributor owns
destination merge, retention, and derived snapshot behavior such as `latest`. destination merge, retention, and derived snapshot behavior such as `latest`.
For Daily, the default report ID and artifact group values are both `daily`,
and the default output filename value is `daily.md`.
For Today, the default report ID and artifact group values are both `today`,
and the batch output filename value is `today.md`.
Notification happens after final metadata save. Weather API, module snapshot, Notification happens after final metadata save for generated reports. Weather
data-package, render preflight, Scriptorium run, and metadata-save failures do API, module snapshot, data-package, render preflight, Scriptorium run,
generated-text validation, template rendering, and metadata-save failures do
not trigger notification. A notification failure fails that report. not trigger notification. A notification failure fails that report.
In a batch, other reports continue, the failed report includes notification In a batch, other reports continue, the failed report includes notification
fields in the JSON summary, and the batch returns nonzero. fields in the JSON summary, and the batch returns nonzero.
@@ -204,22 +287,25 @@ weatherreporter inspect sources RUN_ID
Use `inspect reports` to find RunIDs and artifact paths. Use Use `inspect reports` to find RunIDs and artifact paths. Use
`inspect metadata` to see the artifact links recorded for a run. Use `inspect metadata` to see the artifact links recorded for a run. Use
`inspect modules` to review the persisted ordered module snapshot, and `inspect modules` to review the persisted ordered module snapshot with rich
`inspect data-package` to review the structured prompt package used for template-facing values, and `inspect data-package` to review the curated prompt
rendering. Use `inspect prior` to see the prior comparable snapshot selected for package passed to Scriptorium. Use `inspect prior` to see the prior comparable
Recent Changes, or `null` when none exists. Use `inspect sources` to review snapshot selected for Recent Changes, or `null` when none exists. Use
source provenance and warnings without dumping full weather payloads. `inspect sources` to review source provenance and warnings without dumping full
weather payloads.
## Recent Changes ## Recent Changes
Recent Changes are computed from structured module snapshots, not rendered Recent Changes are computed from structured module snapshots, not rendered
Markdown or YAML text. Markdown or YAML text.
Daily Today and Daily Tomorrow can compare with each other when they cover the Daily Report compares with prior Daily Report snapshots for the same valid
same valid local date. 3-Day Outlook compares with prior compatible 3-Day local date. Today Report compares with prior Today Report snapshots for the
snapshots for the same valid local date. Weekend Outlook compares with prior same valid local date. Tomorrow Report compares with prior Tomorrow Report
compatible Weekend snapshots for the same weekend window. Storm Report leaves snapshots for the same valid local date. 3-Day Outlook compares with prior
Recent Changes empty. compatible 3-Day snapshots for the same valid local date. Weekend Outlook
compares with prior compatible Weekend snapshots for the same weekend window.
Hourly Report and Storm Report leave Recent Changes empty.
When no prior comparable snapshot exists, or no configured threshold is crossed, When no prior comparable snapshot exists, or no configured threshold is crossed,
`recentChanges.items` is empty. `recentChanges.items` is empty.
@@ -232,6 +318,10 @@ A failed generation run may still leave useful artifacts:
preflight JSON and metadata are written for inspection. preflight JSON and metadata are written for inspection.
- If `scriptorium run` exits nonzero after writing a report, the managed report - If `scriptorium run` exits nonzero after writing a report, the managed report
and metadata remain available. and metadata remain available.
- Generated-text failures for Daily, Today, Tomorrow, and Hourly reports preserve
available intermediate artifacts, such as the structured run result, raw
generated-text JSON, validated generated text, and render context. Metadata
links those paths when it can be safely written.
- If distributor notification fails, report artifacts and final metadata remain - If distributor notification fails, report artifacts and final metadata remain
available, but the report or batch command returns nonzero. available, but the report or batch command returns nonzero.
- For batch commands, inspect the stdout JSON summary first, then inspect the - For batch commands, inspect the stdout JSON summary first, then inspect the

659
docs/roadmap/audit.md Normal file
View File

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

481
docs/roadmap/cleanup.md Normal file
View File

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

View File

@@ -5,18 +5,22 @@ documented outside `docs/roadmap/`.
## Automatic Storm Monitoring ## Automatic Storm Monitoring
Manual Storm Report generation is implemented through Manual Storm Report generation is available through:
`weatherreporter generate storm --start TIME --end TIME`. Automatic storm-event
evaluation remains deferred.
Proposed direction: ```sh
weatherreporter generate storm --start TIME --end TIME
```
1. detect candidate events deterministically from alerts, forecast discussion, Automatic storm-event evaluation is not implemented.
weather story context, hourly thresholds, and material forecast changes;
2. evaluate candidates through Scriptorium or another narrow evaluator adapter; Possible direction:
3. persist storm lifecycle state;
4. generate or update Storm Reports only when a meaningful event is present; 1. Detect candidate storm events from alerts, forecast discussion, weather
5. suppress ordinary low-impact thunder or rain chances. story context, hourly thresholds, and material forecast changes.
2. Evaluate candidates through Scriptorium or another narrow evaluator adapter.
3. Persist storm lifecycle state.
4. Generate or update Storm Reports only when a meaningful event is present.
5. Suppress ordinary low-impact thunder or rain chances.
Possible lifecycle states: Possible lifecycle states:
@@ -27,40 +31,41 @@ Possible lifecycle states:
- `deescalating` - `deescalating`
- `resolved` - `resolved`
Acceptance criteria before implementation: Before implementation, the design must preserve scheduled report behavior,
manual Storm Report generation, inspectable evaluator failures, and fixture
coverage for deterministic candidate detection.
- scheduled reports and manual Storm Reports remain stable; ## Future Report Types
- candidate detection has fixture coverage;
- evaluator failures are inspectable and do not create noisy report output;
- manual Storm Report generation remains available.
## Future Report Types And Modules
The module-based prompt package architecture is implemented. Future work should
add only modules backed by implemented upstream facts and clear report needs.
Possible future report types: Possible future report types:
- `next_6_hours` or another short-fuse planning report; - a short-fuse planning report distinct from the implemented Hourly Report, if
- event-specific reports with stable event IDs; a separate product is needed
- storm review or yesterday-style reports using historical observations; - event-specific reports with stable event IDs
- storm review or yesterday-style reports using historical observations
- archive-focused report variants if generated report history becomes a - archive-focused report variants if generated report history becomes a
first-class product. first-class product
New reports should keep report identity, prompt IDs, templates, valid-period
resolution, artifact grouping, batch output names, and comparison policy inside
`internal/report`.
## Future Modules
Possible future modules: Possible future modules:
- `hourly_table` for compact valid-period hourly facts; - `hourly_table` for compact valid-period hourly facts
- `forecast_delta` if a separate stanza is useful beyond current Recent - `forecast_delta` if a separate stanza is useful beyond current Recent
Changes; Changes
- `weekend_planning` if weekend-specific planning guidance needs a dedicated - `weekend_planning` if weekend-specific planning guidance needs a dedicated
deterministic stanza; deterministic stanza
- `storm_window_summary` if manual or automatic Storm Reports need a dedicated - `storm_window_summary` if manual or automatic Storm Reports need a dedicated
prompt-facing storm-window module; prompt-facing storm-window module
- separate AFD section aliases, such as `afd_key_messages`, - separate AFD section aliases, such as `afd_key_messages`,
`afd_short_term_text`, and `afd_long_term_text`, if separate stanzas prove `afd_short_term_text`, and `afd_long_term_text`, if separate stanzas prove
more useful than `area_forecast_discussion.options.sections`; more useful than `area_forecast_discussion.options.sections`
- SPC, radar, QPF, snow/rain total, or historical-observation modules once - SPC, radar, QPF, snow/rain total, or historical-observation modules once
upstream sources and report requirements exist. upstream sources and report requirements exist
QPF fields such as `measurable_qpf_total_in` and `max_hourly_qpf_in` should QPF fields such as `measurable_qpf_total_in` and `max_hourly_qpf_in` should
remain omitted until a real upstream quantitative precipitation source is remain omitted until a real upstream quantitative precipitation source is
@@ -68,80 +73,61 @@ represented in `CollectedFacts`.
Future module work should preserve these boundaries: Future module work should preserve these boundaries:
- collect upstream facts once per report run; - collect upstream facts once per report run
- keep upstream fetching out of modules; - keep upstream fetching out of modules
- keep broad reusable calculations in `DerivedFacts`; - keep broad reusable calculations in `DerivedFacts`
- keep prompt-facing field shape inside module builders; - keep prompt-facing field shape inside module builders
- use typed options for configurable module behavior; - use typed options for configurable module behavior
- keep module snapshots structured and deterministic for Recent Changes. - keep module snapshots structured and deterministic for Recent Changes
## Distributor Notification Enhancements ## Distributor Notification Enhancements
Distributor notification currently uploads one managed Markdown report per Distributor notification uploads one managed Markdown report per successful
successful generated report through the configured HTTP upload pipeline. generated report through the configured HTTP upload pipeline. The following
enhancements are not implemented:
These enhancements are not current behavior: - `failure_policy: warn`
- uploading metadata, module snapshots, data packages, or preflight artifacts
- durable upload retry queues
- distributor-specific CLI flags
- distributor workspace scanning
- destination routing, Markdown-to-HTML transformation, public URLs, or nginx
layout inside weatherreporter
- `failure_policy: warn`; Any distributor enhancement should preserve the adapter boundary:
- uploading metadata, module snapshots, data packages, or preflight artifacts;
- polling distributor status after upload acceptance;
- durable upload retry queues;
- distributor-specific CLI flags;
- making distributor scan the weatherreporter workspace;
- handling destination routing, Markdown-to-HTML transformation, public URLs,
or nginx layout inside weatherreporter.
Any distributor enhancement should preserve the existing adapter boundary:
weatherreporter selects explicit generated files and submits source bundles, weatherreporter selects explicit generated files and submits source bundles,
while distributor owns destination routing and publication behavior. while distributor owns destination routing and publication behavior.
## Alternate Runtime Integrations ## Alternate Runtime Integrations
These ideas are not current behavior: These ideas are not implemented:
- native LLM client inside `weatherreporter`; - native LLM client inside `weatherreporter`
- database-backed state; - database-backed state
- public HTTP API; - public HTTP API
- multi-location selection; - multi-location selection
- daemon mode; - daemon mode
- multi-user authorization; - multi-user authorization
- plugin system; - plugin system
- dynamic module loading; - dynamic module loading
- user-defined module code; - user-defined module code
- YAML-defined module schemas; - YAML-defined module schemas
- module-owned Weather API fetching. - module-owned Weather API fetching
Each item needs its own design note before implementation. Non-roadmap docs Each item needs its own design note before implementation. Non-roadmap docs
must not describe these as available behavior. must not describe these as available behavior.
## Cleanup Refactors ## Deferred Refactors
The initial cleanup pass intentionally left these refactors out because the These refactors should remain deferred until new requirements or recurring
current implementation does not yet make them worth the added abstraction. maintenance costs make the added abstraction worthwhile:
Revisit these only when new source types, report types, operational - broad briefing weather-signal consolidation
requirements, or recurring maintenance costs make the duplication materially - generic workflow engine
more expensive: - Cobra migration
- manifest, resume, or progress system
- Weather API optional-source specification/helper refactor: consider when - global test helper package
additional Weather API sources make per-source fan-out, policy handling, and - logging subsystem
provenance wiring repetitive enough to obscure adapter behavior.
- Broad briefing weather-signal consolidation: consider when multiple module
builders repeatedly derive the same weather signals and tests begin to need
coordinated fixture updates.
- Generic workflow engine: defer unless generation, inspection, recovery, or
future background workflows gain enough shared step semantics to justify a
declared execution model.
- Cobra migration: defer while the standard-library CLI remains small,
explicit, and covered by parser tests.
- Manifest, resume, or progress system: defer until operators need resumable
runs, checkpoint recovery, or richer audit trails than the current durable
artifacts and metadata provide.
- Global test helper package: defer while package-local helpers keep tests
clear; revisit only if setup duplication starts to hide behavior.
- Logging subsystem: defer until there are recurring operator diagnostics that
cannot be handled with current errors, metadata, inspection commands, and
artifact output.
Any future implementation should preserve the existing public CLI, artifact Any future implementation should preserve the existing public CLI, artifact
paths, report identities, module boundaries, and adapter boundaries unless a paths, report identities, module boundaries, and adapter boundaries unless a

405
docs/templates.md Normal file
View File

@@ -0,0 +1,405 @@
# Report Templates
## Purpose
This guide describes the implemented Markdown report template surface for
`weatherreporter`. It is for maintainers editing embedded report templates,
especially generated-text-template reports.
Templates are Go `text/template` files. The current implemented templates are:
- `internal/reporttemplate/templates/daily.md.tmpl`
- `internal/reporttemplate/templates/today.md.tmpl`
- `internal/reporttemplate/templates/tomorrow.md.tmpl`
- `internal/reporttemplate/templates/hourly.md.tmpl`
Templates are rendered from structured contexts such as `DailyRenderContext`,
`TodayRenderContext`, `TomorrowRenderContext`, and `HourlyRenderContext`.
Weather data collection, derivation, module execution, generated text
validation, and artifact paths are handled before template rendering.
## Editing Rules
- Use Go `text/template` syntax.
- Keep templates focused on Markdown layout, headings, ordering, and simple
conditional display.
- Do not put weather derivation, source selection, or path construction logic in
templates.
- Missing template keys are errors. A misspelled variable will fail rendering.
- No custom template functions are currently registered.
- Optional module stanzas are pointers and should be guarded with
`{{ with .Modules.WeatherStory }}...{{ end }}`.
- Slices can be rendered with `{{ range .Items }}...{{ else }}...{{ end }}`.
## Hourly Context
The hourly template receives five top-level values:
| Variable | Type | Description |
| --- | --- | --- |
| `.Report` | HourlyReportContext | Display metadata and friendly labels for the rendered report. |
| `.GeneratedText` | Hourly | Structured text returned by Scriptorium. |
| `.Modules` | HourlyTemplateModules | Preferred deterministic template surface, keyed by module purpose. |
| `.Collected` | facts.CollectedFacts | Normalized upstream facts for advanced template use. |
| `.Derived` | facts.DerivedFacts | Shared derived facts for advanced template use. |
Prefer `.Modules` for normal template edits. `.Collected` and `.Derived` are
available when a template needs lower-level facts, but templates should still
avoid nontrivial derivation.
## Report
| Variable | Type | Description |
| --- | --- | --- |
| `.Report.Title` | string | Display title. Currently `Hourly Report`. |
| `.Report.LocationName` | string | Prompt/report location label, such as `Brentwood, MO`. |
| `.Report.GeneratedAt` | time.Time | Canonical generation timestamp. |
| `.Report.GeneratedAtLabel` | string | Friendly local generation time label. |
| `.Report.ValidPeriod` | timeutil.Period | Canonical valid period. |
| `.Report.ValidPeriodLabel` | string | Friendly local valid period label, such as `2026-05-29 at 8:30 AM to 2026-05-29 at 2:30 PM`. |
| `.Report.Timezone` | string | Effective report timezone. |
## GeneratedText
These fields are written by Scriptorium as structured JSON, validated by
weatherreporter, and then inserted into the render context.
| Variable | Type | Description |
| --- | --- | --- |
| `.GeneratedText.Summary` | string | Required short prose summary. |
| `.GeneratedText.ForecastDiscussion` | string | Required prose for the Forecast Discussion section. |
| `.GeneratedText.PrecipitationTiming` | string | Optional prose rendered after deterministic precipitation windows. |
| `.GeneratedText.Confidence` | string | Optional confidence or uncertainty note. Empty when omitted by the LLM; not rendered by the current hourly template. |
Example:
```gotemplate
{{ .GeneratedText.Summary }}
## Forecast Discussion
{{ .GeneratedText.ForecastDiscussion }}
```
## Tomorrow Context
The Tomorrow template receives five top-level values:
| Variable | Type | Description |
| --- | --- | --- |
| `.Report` | TomorrowReportContext | Display metadata and friendly labels for the rendered report. |
| `.GeneratedText` | Tomorrow | Structured text returned by Scriptorium. |
| `.Modules` | TomorrowTemplateModules | Preferred deterministic template surface, keyed by module purpose. |
| `.Collected` | facts.CollectedFacts | Normalized upstream facts for advanced template use. |
| `.Derived` | facts.DerivedFacts | Shared derived facts for advanced template use. |
Tomorrow report metadata includes `.Report.Title`, `.Report.ForecastDate`,
`.Report.ForecastDateLabel`, `.Report.ForecastDayName`,
`.Report.GeneratedAt`, `.Report.GeneratedAtLabel`, `.Report.ValidPeriod`, and
`.Report.Timezone`.
Tomorrow generated text uses the same `.GeneratedText.Summary`,
`.GeneratedText.PrecipitationTiming`, and `.GeneratedText.Confidence` fields as
Hourly. `.GeneratedText.ForecastDiscussion` is a slice of paragraphs and should
be rendered with `range`.
Tomorrow modules include the Hourly module fields plus:
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.DerivedDailySummary` | *briefing.DerivedDailySummaryModule | Daily summary facts for the forecast date. |
| `.Modules.DerivedDaypartSummaries` | *map[string]briefing.DerivedDaypartSummaryModule | Raw daypart summary map, when direct keyed access is needed. |
| `.Modules.Dayparts` | []generatedtext.TomorrowDaypartContext | Ordered daypart summaries for deterministic template rendering. |
| `.Modules.TomorrowPlanning` | *briefing.TomorrowPlanningModule | Planning facts for the next local civil day. |
Prefer `.Modules.Dayparts` over ranging through
`.Modules.DerivedDaypartSummaries`; it follows configured daypart order and
falls back to sorted keys for any unmatched entries.
## Daily Context
The Daily template receives the same five top-level values as Tomorrow, using
`DailyReportContext`, `Daily`, and `DailyTemplateModules`.
Daily report metadata includes `.Report.Title`, `.Report.ForecastDate`,
`.Report.ForecastDateLabel`, `.Report.ForecastDayName`,
`.Report.GeneratedAt`, `.Report.GeneratedAtLabel`, `.Report.ValidPeriod`, and
`.Report.Timezone`.
Daily generated text uses `.GeneratedText.Summary`,
`.GeneratedText.ForecastDiscussion`, `.GeneratedText.PrecipitationTiming`, and
`.GeneratedText.Confidence`. Forecast discussion is a slice of paragraphs and
should be rendered with `range`.
Daily uses template ID `daily`, generated-text schema ID `daily`, and prompt
source `internal/reporttemplate/prompts/daily.generated_text.md`.
Daily modules include the Hourly module fields plus:
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.DerivedDailySummary` | *briefing.DerivedDailySummaryModule | Daily summary facts for the forecast date. |
| `.Modules.DerivedDaypartSummaries` | *map[string]briefing.DerivedDaypartSummaryModule | Raw daypart summary map, when direct keyed access is needed. |
| `.Modules.Dayparts` | []generatedtext.DailyDaypartContext | Ordered daypart summaries for deterministic template rendering. |
| `.Modules.DailyPlanning` | *briefing.DailyPlanningModule | Planning facts for the selected local civil day. |
Prefer `.Modules.Dayparts` over ranging through
`.Modules.DerivedDaypartSummaries`; it follows configured daypart order and
falls back to sorted keys for any unmatched entries.
## Today Context
The Today template receives the same five top-level values as Tomorrow, using
`TodayReportContext`, `Today`, and `TodayTemplateModules`.
Today report metadata includes `.Report.Title`, `.Report.ForecastDate`,
`.Report.ForecastDateLabel`, `.Report.ForecastDayName`,
`.Report.GeneratedAt`, `.Report.GeneratedAtLabel`, `.Report.ValidPeriod`, and
`.Report.Timezone`.
Today generated text uses `.GeneratedText.Summary`,
`.GeneratedText.ForecastDiscussion`, `.GeneratedText.PrecipitationTiming`, and
`.GeneratedText.Confidence`. Forecast discussion is a slice of paragraphs and
should be rendered with `range`.
Today uses template ID `today`, generated-text schema ID `today`, and prompt
source `internal/reporttemplate/prompts/today.generated_text.md`.
Today modules include the Hourly module fields plus:
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.DerivedDailySummary` | *briefing.DerivedDailySummaryModule | Daily summary facts for the forecast date. |
| `.Modules.DerivedDaypartSummaries` | *map[string]briefing.DerivedDaypartSummaryModule | Raw daypart summary map, when direct keyed access is needed. |
| `.Modules.Dayparts` | []generatedtext.TodayDaypartContext | Ordered daypart summaries for deterministic template rendering. |
| `.Modules.TodayPlanning` | *briefing.TodayPlanningModule | Planning facts for the current local civil day. |
Prefer `.Modules.Dayparts` over ranging through
`.Modules.DerivedDaypartSummaries`; it follows configured daypart order and
falls back to sorted keys for any unmatched entries.
## Modules
`.Modules` exposes typed outputs from the same module pipeline used for the
prompt data package. Module fields are pointers because missing-data policy may
omit a stanza.
Templates render from rich module values, not from the curated YAML data
package. Some fields documented below are deterministic wording helpers for
Markdown templates and are intentionally omitted from data packages passed to
Scriptorium. The data package is a prompt input, while the render context is the
template surface.
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.Metadata` | *briefing.MetadataModule | Report metadata module output, when present. |
| `.Modules.CurrentConditions` | *briefing.CurrentConditionsModule | Current conditions from `/conditions/current`. |
| `.Modules.HourlyForecast` | *briefing.HourlyForecastModule | Hourly forecast periods overlapping the report valid period. |
| `.Modules.PrecipTiming` | *briefing.PrecipTimingModule | Derived precipitation timing facts and threshold windows. |
| `.Modules.AlertDigest` | *briefing.AlertDigestModule | Active alert status and relevant alert overlaps. |
| `.Modules.SPCConvectiveOutlooks` | *briefing.SPCConvectiveOutlooksModule | SPC outlooks that overlap the report valid period. |
| `.Modules.AreaForecastDiscussion` | *briefing.AreaForecastDiscussionModule | AFD key messages and configured discussion sections. |
| `.Modules.SPCConvectiveDiscussion` | *briefing.SPCConvectiveDiscussionModule | SPC discussions retained for qualifying overlapping categorical risk days. |
| `.Modules.WeatherStory` | *briefing.WeatherStoryModule | Latest NWS weather story, when available. |
### Current Conditions
Common fields:
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.CurrentConditions.ConditionText` | string | Current condition text. |
| `.Modules.CurrentConditions.ConditionTextLower` | string | Lower-case current condition text for inline sentences. |
| `.Modules.CurrentConditions.TemperatureF` | *int | Rounded current temperature. |
| `.Modules.CurrentConditions.ApparentTemperatureF` | *int | Rounded apparent temperature. |
| `.Modules.CurrentConditions.RelativeHumidityPercent` | *int | Rounded relative humidity. |
| `.Modules.CurrentConditions.WindDirection` | string | 16-point compass wind direction. |
| `.Modules.CurrentConditions.WindDirectionText` | string | Lower-case full wind direction text, such as `northwest`. |
| `.Modules.CurrentConditions.WindSpeedMph` | *int | Rounded wind speed. |
Example:
```gotemplate
{{ with .Modules.CurrentConditions }}
{{ .ConditionText }}{{ with .TemperatureF }}; {{ . }} F{{ end }}{{ with .WindDirection }}; wind {{ . }}{{ end }}{{ with .WindSpeedMph }} {{ . }} mph{{ end }}
{{ else }}
No current conditions available.
{{ end }}
```
### Hourly Forecast
Common period fields:
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.HourlyForecast.Periods` | []briefing.HourlyForecastPeriod | Ordered periods for the hourly report valid period. |
| `.Modules.HourlyForecast.Periods[].HourLabel` | string | Friendly hour label such as `4:00 PM`. |
| `.Modules.HourlyForecast.Periods[].PeriodBegins` | string | Friendly local period start label. |
| `.Modules.HourlyForecast.Periods[].PeriodEnds` | string | Friendly local period end label. |
| `.Modules.HourlyForecast.Periods[].Name` | string | Source period name. |
| `.Modules.HourlyForecast.Periods[].TextDescription` | string | Hourly forecast text. |
| `.Modules.HourlyForecast.Periods[].TextDescriptionLower` | string | Lower-case hourly forecast text for inline sentences. |
| `.Modules.HourlyForecast.Periods[].TemperatureF` | *float64 | Forecast temperature. |
| `.Modules.HourlyForecast.Periods[].ProbabilityOfPrecipitationPercent` | *float64 | Forecast precipitation probability. |
| `.Modules.HourlyForecast.Periods[].MentionPrecipitation` | bool | True when precipitation probability meets the hourly mention threshold. |
| `.Modules.HourlyForecast.Periods[].WindDirection` | string | 16-point compass wind direction. |
| `.Modules.HourlyForecast.Periods[].WindSpeedMph` | *float64 | Wind speed. |
| `.Modules.HourlyForecast.Periods[].WindGustMph` | *float64 | Wind gust. |
Example:
```gotemplate
{{ with .Modules.HourlyForecast }}{{ range .Periods }}
- **{{ .HourLabel }}:**{{ with .TemperatureF }} {{ . }}°F{{ end }} and {{ .TextDescriptionLower }}.{{ if .MentionPrecipitation }}{{ with .ProbabilityOfPrecipitationPercent }} Probability of precipitation is {{ . }}%.{{ end }}{{ end }}
{{ else }}
- No hourly forecast rows available.
{{ end }}{{ end }}
```
### Precipitation Timing
Common fields:
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.PrecipTiming.MaxPopPercent` | *int | Highest hourly precipitation probability in the valid period. |
| `.Modules.PrecipTiming.MaxPopTime` | string | Friendly local time for the highest hourly precipitation probability. |
| `.Modules.PrecipTiming.ProbabilityThreshold` | float64 | Threshold used to define precipitation windows. |
| `.Modules.PrecipTiming.PrecipitationWindows` | []briefing.PrecipitationWindowModule | One or more threshold precipitation windows. |
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodBegins` | string | Friendly local window start. |
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodBeginsHourLabel` | string | Friendly window start hour, such as `4:00 PM`. |
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodEnds` | string | Friendly local window end; omitted for open windows. |
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodEndsHourLabel` | string | Friendly window end hour; omitted for open windows. |
| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopPercent` | *int | Highest precipitation probability inside the window. |
| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopTime` | string | Friendly local time for the window maximum. |
| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopHourLabel` | string | Friendly hour label for the window maximum. |
| `.Modules.PrecipTiming.ThunderMentioned` | bool | Whether thunder is mentioned in the forecast text. |
### Daypart Summaries
Daily, Today, and Tomorrow templates should use `.Modules.Dayparts` for
ordered daypart rendering. Each item has `Key` and `Summary`; `Summary` is a
rich `briefing.DerivedDaypartSummaryModule`.
Common rich daypart fields:
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.Dayparts[].Summary.DisplayName` | string | Human-readable daypart label. |
| `.Modules.Dayparts[].Summary.PeriodBegins` | string | Friendly local daypart start. |
| `.Modules.Dayparts[].Summary.PeriodEnds` | string | Friendly local daypart end. |
| `.Modules.Dayparts[].Summary.TempRangeF` | string | Rounded temperature range or single temperature. |
| `.Modules.Dayparts[].Summary.TemperaturePhraseF` | string | Temperature phrase used for steady template wording. |
| `.Modules.Dayparts[].Summary.TemperatureTrend` | string | Trend category such as `rising`, `falling`, `peaking`, or `steady`. |
| `.Modules.Dayparts[].Summary.TemperatureStartPhraseF` | string | Starting temperature phrase for rising/falling wording. |
| `.Modules.Dayparts[].Summary.TemperatureEndPhraseF` | string | Ending temperature phrase for rising/falling wording. |
| `.Modules.Dayparts[].Summary.TemperaturePeakPhraseF` | string | Peak temperature phrase for peaking wording. |
| `.Modules.Dayparts[].Summary.TemperatureSteadyPhraseF` | string | Steady temperature phrase. |
| `.Modules.Dayparts[].Summary.MaxPopPercent` | *int | Highest precipitation probability in the daypart. |
| `.Modules.Dayparts[].Summary.MaxPopTime` | string | Friendly local time for the highest precipitation probability. |
| `.Modules.Dayparts[].Summary.MaxPopTimeLabel` | string | Clock-style label for deterministic precipitation timing text. |
| `.Modules.Dayparts[].Summary.MentionPrecipitation` | bool | True when precipitation probability should be mentioned by the template. |
| `.Modules.Dayparts[].Summary.DominantCondition` | string | Dominant condition text. |
| `.Modules.Dayparts[].Summary.DominantConditionLower` | string | Lower-case condition text for inline sentences. |
| `.Modules.Dayparts[].Summary.DominantConditionDisplay` | string | Display-case condition text for bullet starts. |
| `.Modules.Dayparts[].Summary.NotableConditions` | []string | Notable condition labels retained for the daypart. |
Template-only daypart helpers such as `TemperaturePhraseF`,
`DominantConditionLower`, `DominantConditionDisplay`, and `MaxPopTimeLabel`
remain available here even though they are not serialized into data-package
YAML.
### Alert Digest
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.AlertDigest.Checked` | bool | Whether alert data was checked successfully. |
| `.Modules.AlertDigest.ActiveCount` | int | Active alert count from the source. |
| `.Modules.AlertDigest.RelevantCount` | int | Alert count overlapping the report period. |
| `.Modules.AlertDigest.Missing` | bool | True when alert data is unavailable. |
| `.Modules.AlertDigest.Relevant` | []briefing.AlertSummary | Relevant alert summaries. |
| `.Modules.AlertDigest.Relevant[].Event` | string | Alert event name. |
| `.Modules.AlertDigest.Relevant[].Headline` | string | Alert headline. |
| `.Modules.AlertDigest.Relevant[].Severity` | string | Alert severity. |
### SPC Outlooks And Discussion
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.SPCConvectiveOutlooks.Checked` | bool | Whether SPC outlook data was checked successfully. |
| `.Modules.SPCConvectiveOutlooks.AsOf` | string | Friendly source as-of time. |
| `.Modules.SPCConvectiveOutlooks.IssuedAt` | string | Friendly source issue time. |
| `.Modules.SPCConvectiveOutlooks.Outlooks` | []briefing.SPCConvectiveOutlookRecord | Overlapping outlook records. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].Day` | int | SPC day number. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].OutlookType` | string | Outlook type, such as `categorical`. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].Label` | string | Short outlook label. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].LabelText` | string | Human-readable outlook label. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodBegins` | string | Friendly outlook period start. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodEnds` | string | Friendly outlook period end. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].ImageURL` | string | Source image URL. |
| `.Modules.SPCConvectiveDiscussion.IncludedBecause` | string | Criterion used to include discussions. |
| `.Modules.SPCConvectiveDiscussion.Discussions` | []briefing.SPCConvectiveDiscussionRecord | Retained discussion records. |
| `.Modules.SPCConvectiveDiscussion.Discussions[].Headline` | string | Discussion headline. |
| `.Modules.SPCConvectiveDiscussion.Discussions[].Summary` | string | Discussion summary. |
| `.Modules.SPCConvectiveDiscussion.Discussions[].Discussion` | string | Full discussion text. |
### Area Forecast Discussion
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.AreaForecastDiscussion.Product` | string | Source product identifier. |
| `.Modules.AreaForecastDiscussion.KeyMessages` | []string | AFD key messages. |
| `.Modules.AreaForecastDiscussion.ShortTerm` | string | AFD short-term section text. |
| `.Modules.AreaForecastDiscussion.LongTerm` | string | AFD long-term section text. |
### Weather Story
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.WeatherStory.Available` | bool | True when a story is available. |
| `.Modules.WeatherStory.OfficeID` | string | Source office ID. |
| `.Modules.WeatherStory.PeriodBegins` | string | Friendly story period start. |
| `.Modules.WeatherStory.PeriodEnds` | string | Friendly story period end. |
| `.Modules.WeatherStory.UpdatedAt` | *time.Time | Canonical update timestamp. |
| `.Modules.WeatherStory.Title` | string | Story title. |
| `.Modules.WeatherStory.Description` | string | Story description. |
| `.Modules.WeatherStory.AltText` | string | Story image alt text. |
| `.Modules.WeatherStory.Priority` | bool | Source priority flag. |
| `.Modules.WeatherStory.Order` | int | Source order. |
| `.Modules.WeatherStory.DownloadURL` | string | Source download URL. |
## Collected And Derived Facts
The template also receives the full `facts.CollectedFacts` and
`facts.DerivedFacts` structs:
- `.Collected` contains normalized source data and provenance from upstream
Weather API fetches.
- `.Derived` contains shared slices and calculations used across modules, such
as valid-period hourly periods, precipitation timing, alert overlaps, and SPC
filtering inputs.
These values are intentionally lower-level than `.Modules`. Use them when a
template needs a specific field that is not exposed by a module, but keep
calculation-heavy changes in Go.
## Validation
After editing a template, run:
```bash
go test ./internal/reporttemplate ./internal/generatedtext ./internal/app
```
For a full check, run:
```bash
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Template render tests exercise the Daily, Today, Tomorrow, and Hourly
templates through `internal/generatedtext/render_context_test.go` and
`internal/reporttemplate/reporttemplate_test.go`.

View File

@@ -185,6 +185,52 @@ prompt, profile, model configuration, or validation issue.
Relevant docs: [Operations guide](operations.md), Relevant docs: [Operations guide](operations.md),
[Scriptorium integration](integrations/scriptorium.md). [Scriptorium integration](integrations/scriptorium.md).
## Generated Text Validation Fails
Symptom: Daily, Today, Tomorrow, or Hourly generation fails with generated-text
decode, unknown-field, required-field, or multiple-JSON-values context.
Likely cause: Scriptorium wrote structured JSON that does not match the
GeneratedText contract for the selected report.
Diagnostic:
```sh
weatherreporter inspect metadata RUN_ID
```
Then inspect the generated-text raw path recorded in metadata, if present.
Safe fix: update the Scriptorium prompt or schema configuration so the prompt
writes the expected structured JSON for the report.
Relevant docs: [Operations guide](operations.md),
[Generated Text internals](internal/generatedtext.md),
[Scriptorium integration](integrations/scriptorium.md).
## Template Rendering Fails
Symptom: Daily, Today, Tomorrow, or Hourly generation fails with report template
parsing or execution context after generated text validation succeeds.
Likely cause: an embedded template references a missing context field or
receives a value shape that does not match its typed render context.
Diagnostic:
```sh
weatherreporter inspect metadata RUN_ID
```
If metadata records generated-text and render-context paths, inspect those
artifacts along with the template named by the report definition.
Safe fix: update the embedded template or render-context builder so the
template uses the implemented typed context.
Relevant docs: [Report Templates](templates.md),
[Report Template internals](internal/reporttemplate.md).
## Batch Command Returns Nonzero ## Batch Command Returns Nonzero
Symptom: `run morning` or `run evening` returns nonzero. Symptom: `run morning` or `run evening` returns nonzero.

View File

@@ -21,7 +21,7 @@ notify:
token_env: DISTRIBUTOR_UPLOAD_TOKEN token_env: DISTRIBUTOR_UPLOAD_TOKEN
timeout: 30s timeout: 30s
failure_policy: error failure_policy: error
pipeline_id_template: "weatherreporter.{artifact_group}" pipeline_id_template: "weatherreporter.{report_id}"
bundle_id_template: "weatherreporter.{location_id}.{report_id}" bundle_id_template: "weatherreporter.{location_id}.{report_id}"
idempotency_key_template: "{bundle_id}.{run_id}" idempotency_key_template: "{bundle_id}.{run_id}"
report_path_templates: report_path_templates:
@@ -77,6 +77,7 @@ reports:
- derived_daypart_summaries - derived_daypart_summaries
- precip_timing - precip_timing
- alert_digest - alert_digest
- spc_convective_outlooks
- id: area_forecast_discussion - id: area_forecast_discussion
options: options:
sections: sections:
@@ -84,6 +85,45 @@ reports:
- key_messages - key_messages
- short_term - short_term
- long_term - long_term
- spc_convective_discussion
- weather_story
- outdoor_windows
- daily_planning
- hourly_forecast
today:
deterministic_modules:
- metadata
- current_conditions
- narrative_forecast
- derived_daily_summary
- derived_daypart_summaries
- precip_timing
- alert_digest
- spc_convective_outlooks
- id: area_forecast_discussion
options:
sections:
- product
- key_messages
- short_term
- long_term
- spc_convective_discussion
- weather_story - weather_story
- outdoor_windows - outdoor_windows
- hourly_forecast - hourly_forecast
- today_planning
hourly:
deterministic_modules:
- metadata
- current_conditions
- hourly_forecast
- precip_timing
- alert_digest
- spc_convective_outlooks
- id: area_forecast_discussion
options:
sections:
- key_messages
- short_term
- spc_convective_discussion
- weather_story

View File

@@ -79,6 +79,12 @@ type RunRequest struct {
OutputPath string OutputPath string
} }
type StructuredRunRequest struct {
PromptID string
DataPackagePath string
OutputPath string
}
type RenderResult struct { type RenderResult struct {
Command []string `json:"command"` Command []string `json:"command"`
Stdout string `json:"stdout"` Stdout string `json:"stdout"`
@@ -98,6 +104,16 @@ type RunResult struct {
OutputPath string `json:"outputPath"` OutputPath string `json:"outputPath"`
} }
type StructuredRunResult struct {
Command []string `json:"command"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
StdoutTruncated bool `json:"stdoutTruncated,omitempty"`
StderrTruncated bool `json:"stderrTruncated,omitempty"`
ExitCode int `json:"exitCode"`
OutputPath string `json:"outputPath"`
}
func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, error) { func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, error) {
if req.PromptID == "" { if req.PromptID == "" {
return nil, fmt.Errorf("prompt id is required") return nil, fmt.Errorf("prompt id is required")
@@ -152,6 +168,35 @@ func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
return result, nil return result, nil
} }
func (r Runner) StructuredRun(ctx context.Context, req StructuredRunRequest) (*StructuredRunResult, error) {
if req.PromptID == "" {
return nil, fmt.Errorf("prompt id is required")
}
if req.DataPackagePath == "" {
return nil, fmt.Errorf("data package path is required")
}
if req.OutputPath == "" {
return nil, fmt.Errorf("output path is required")
}
execution, err := r.execute(ctx, r.structuredRunArgs(req))
if err != nil {
return nil, fmt.Errorf("run scriptorium structured output: %w", err)
}
result := &StructuredRunResult{
Command: execution.argv(),
Stdout: string(execution.result.Stdout),
Stderr: string(execution.result.Stderr),
StdoutTruncated: execution.result.StdoutTruncated,
StderrTruncated: execution.result.StderrTruncated,
ExitCode: execution.result.ExitCode,
OutputPath: req.OutputPath,
}
if execution.result.ExitCode != 0 {
return result, fmt.Errorf("scriptorium structured run exited with code %d: %s", execution.result.ExitCode, result.Stderr)
}
return result, nil
}
type execution struct { type execution struct {
binary string binary string
args []string args []string
@@ -212,6 +257,14 @@ func (r Runner) runArgs(req RunRequest) []string {
return args return args
} }
func (r Runner) structuredRunArgs(req StructuredRunRequest) []string {
return r.runArgs(RunRequest{
PromptID: req.PromptID,
DataPackagePath: req.DataPackagePath,
OutputPath: req.OutputPath,
})
}
type limitedBuffer struct { type limitedBuffer struct {
data []byte data []byte
limit int limit int

View File

@@ -19,7 +19,7 @@ func TestRenderConstructsCommand(t *testing.T) {
} }
result, err := runner.Render(context.Background(), RenderRequest{ result, err := runner.Render(context.Background(), RenderRequest{
PromptID: "weather.daily_report", PromptID: "weather.markdown_report",
DataPackagePath: "/tmp/data_package.yaml", DataPackagePath: "/tmp/data_package.yaml",
}) })
if err != nil { if err != nil {
@@ -30,7 +30,7 @@ func TestRenderConstructsCommand(t *testing.T) {
"render", "render",
"--config", "/etc/scriptorium.yml", "--config", "/etc/scriptorium.yml",
"--profile", "weather", "--profile", "weather",
"--prompt", "weather.daily_report", "--prompt", "weather.markdown_report",
"--input", "data_package=/tmp/data_package.yaml", "--input", "data_package=/tmp/data_package.yaml",
"--format", "json", "--format", "json",
} }
@@ -56,7 +56,7 @@ func TestRenderReturnsResultForNonzeroExit(t *testing.T) {
} }
result, err := runner.Render(context.Background(), RenderRequest{ result, err := runner.Render(context.Background(), RenderRequest{
PromptID: "weather.daily_report", PromptID: "weather.markdown_report",
DataPackagePath: "/tmp/data_package.yaml", DataPackagePath: "/tmp/data_package.yaml",
}) })
if err == nil { if err == nil {
@@ -84,7 +84,7 @@ func TestRunConstructsCommand(t *testing.T) {
} }
result, err := runner.Run(context.Background(), RunRequest{ result, err := runner.Run(context.Background(), RunRequest{
PromptID: "weather.daily_report", PromptID: "weather.markdown_report",
DataPackagePath: "/tmp/data_package.yaml", DataPackagePath: "/tmp/data_package.yaml",
OutputPath: "/tmp/daily.md", OutputPath: "/tmp/daily.md",
}) })
@@ -96,7 +96,7 @@ func TestRunConstructsCommand(t *testing.T) {
"run", "run",
"--config", "/etc/scriptorium.yml", "--config", "/etc/scriptorium.yml",
"--profile", "weather", "--profile", "weather",
"--prompt", "weather.daily_report", "--prompt", "weather.markdown_report",
"--input", "data_package=/tmp/data_package.yaml", "--input", "data_package=/tmp/data_package.yaml",
"--out", "/tmp/daily.md", "--out", "/tmp/daily.md",
} }
@@ -129,7 +129,7 @@ func TestRunReturnsResultForValidationExit(t *testing.T) {
} }
result, err := runner.Run(context.Background(), RunRequest{ result, err := runner.Run(context.Background(), RunRequest{
PromptID: "weather.daily_report", PromptID: "weather.markdown_report",
DataPackagePath: "/tmp/data_package.yaml", DataPackagePath: "/tmp/data_package.yaml",
OutputPath: "/tmp/daily.md", OutputPath: "/tmp/daily.md",
}) })
@@ -147,17 +147,169 @@ func TestRunReturnsResultForValidationExit(t *testing.T) {
} }
} }
func TestStructuredRunConstructsCommandWithoutSchemaFlags(t *testing.T) {
commands := &fakeCommands{result: CommandResult{
Stdout: []byte(`{"summary":"ok"}`),
Stderr: []byte("wrote generated text"),
StdoutTruncated: true,
}}
runner := Runner{
Binary: "/usr/local/bin/scriptorium",
ConfigPath: "/etc/scriptorium.yml",
Profile: "weather",
Timeout: 30 * time.Second,
Commands: commands,
}
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
PromptID: "weather.hourly_generated_text",
DataPackagePath: "/tmp/hourly.data_package.yaml",
OutputPath: "/tmp/hourly.generated_text.raw.json",
})
if err != nil {
t.Fatalf("StructuredRun() error = %v", err)
}
wantArgs := []string{
"run",
"--config", "/etc/scriptorium.yml",
"--profile", "weather",
"--prompt", "weather.hourly_generated_text",
"--input", "data_package=/tmp/hourly.data_package.yaml",
"--out", "/tmp/hourly.generated_text.raw.json",
}
if commands.name != "/usr/local/bin/scriptorium" {
t.Fatalf("command name = %q, want custom binary", commands.name)
}
if !reflect.DeepEqual(commands.args, wantArgs) {
t.Fatalf("args = %#v, want %#v", commands.args, wantArgs)
}
for _, disallowed := range []string{"--format", "--schema", "--schema-path", "--json-schema"} {
if containsArg(commands.args, disallowed) {
t.Fatalf("args = %#v, should not include %q", commands.args, disallowed)
}
}
if commands.timeout != 30*time.Second {
t.Fatalf("timeout = %s, want 30s", commands.timeout)
}
if !reflect.DeepEqual(result.Command, append([]string{"/usr/local/bin/scriptorium"}, wantArgs...)) {
t.Fatalf("result command = %#v, want full argv", result.Command)
}
if result.Stdout != `{"summary":"ok"}` || result.Stderr != "wrote generated text" || !result.StdoutTruncated {
t.Fatalf("result = %#v, want captured output and truncation flags", result)
}
if result.OutputPath != "/tmp/hourly.generated_text.raw.json" {
t.Fatalf("OutputPath = %q, want generated text raw path", result.OutputPath)
}
}
func TestStructuredRunReturnsResultForNonzeroExit(t *testing.T) {
runner := Runner{
Commands: &fakeCommands{
result: CommandResult{
Stdout: []byte(`{"summary":"partial"}`),
Stderr: []byte("structured output failed"),
ExitCode: 3,
},
},
}
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
PromptID: "weather.hourly_generated_text",
DataPackagePath: "/tmp/hourly.data_package.yaml",
OutputPath: "/tmp/hourly.generated_text.raw.json",
})
if err == nil {
t.Fatal("StructuredRun() error = nil, want nonzero exit error")
}
if result == nil {
t.Fatal("StructuredRun() result = nil, want captured result")
}
if result.ExitCode != 3 {
t.Fatalf("ExitCode = %d, want 3", result.ExitCode)
}
if result.Stdout != `{"summary":"partial"}` || result.OutputPath != "/tmp/hourly.generated_text.raw.json" {
t.Fatalf("result = %#v, want captured result fields", result)
}
if !strings.Contains(err.Error(), "structured output failed") {
t.Fatalf("error = %q, want stderr context", err.Error())
}
}
func TestStructuredRunValidatesRequiredFieldsBeforeExecution(t *testing.T) {
tests := []struct {
name string
req StructuredRunRequest
want string
}{
{
name: "prompt id",
req: StructuredRunRequest{
DataPackagePath: "/tmp/hourly.data_package.yaml",
OutputPath: "/tmp/hourly.generated_text.raw.json",
},
want: "prompt id is required",
},
{
name: "data package path",
req: StructuredRunRequest{
PromptID: "weather.hourly_generated_text",
OutputPath: "/tmp/hourly.generated_text.raw.json",
},
want: "data package path is required",
},
{
name: "output path",
req: StructuredRunRequest{
PromptID: "weather.hourly_generated_text",
DataPackagePath: "/tmp/hourly.data_package.yaml",
},
want: "output path is required",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
commands := &fakeCommands{}
runner := Runner{Commands: commands}
result, err := runner.StructuredRun(context.Background(), test.req)
if err == nil {
t.Fatal("StructuredRun() error = nil, want validation error")
}
if result != nil {
t.Fatalf("StructuredRun() result = %#v, want nil", result)
}
if !strings.Contains(err.Error(), test.want) {
t.Fatalf("StructuredRun() error = %v, want %q", err, test.want)
}
if commands.calls != 0 {
t.Fatalf("commands calls = %d, want no subprocess execution", commands.calls)
}
})
}
}
type fakeCommands struct { type fakeCommands struct {
name string name string
args []string args []string
timeout time.Duration timeout time.Duration
result CommandResult result CommandResult
err error err error
calls int
} }
func (f *fakeCommands) Run(_ context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) { func (f *fakeCommands) Run(_ context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) {
f.calls++
f.name = name f.name = name
f.args = append([]string{}, args...) f.args = append([]string{}, args...)
f.timeout = timeout f.timeout = timeout
return f.result, f.err return f.result, f.err
} }
func containsArg(args []string, want string) bool {
for _, arg := range args {
if arg == want {
return true
}
}
return false
}

View File

@@ -21,6 +21,11 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
const (
convectiveOutlooksEndpoint = "/outlooks/convective"
sourceSPCConvectiveOutlooks = "spc_convective_outlooks"
)
type Client struct { type Client struct {
baseURL *url.URL baseURL *url.URL
httpClient *http.Client httpClient *http.Client
@@ -112,6 +117,9 @@ func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
if err := builder.fetchWeatherStory(ctx); err != nil { if err := builder.fetchWeatherStory(ctx); err != nil {
return nil, err return nil, err
} }
if err := builder.fetchSPCConvectiveOutlooks(ctx); err != nil {
return nil, err
}
return builder.bundle, nil return builder.bundle, nil
} }
@@ -122,18 +130,32 @@ type bundleBuilder struct {
fetchedAt time.Time fetchedAt time.Time
} }
type sourceRequest struct {
name string
endpoint string
query queryOptions
missingMessage string
required bool
decodeLabel string
}
type fetchedSource struct {
raw json.RawMessage
source weatherdata.Source
}
func (b *bundleBuilder) fetchObservation(ctx context.Context) error { func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "observations", "/observations", queryOptions{precision: true}) var observation weatherdata.Observation
if err != nil { fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "observations",
endpoint: "/observations",
query: queryOptions{precision: true},
missingMessage: "observation data is missing",
}, &observation)
if err != nil || !ok {
return err return err
} }
if raw == nil { source := fetched.source
return b.handleMissing(&source, "observation data is missing", false)
}
var observation weatherdata.Observation
if err := decodeSource(raw, &observation); err != nil {
return b.handleMalformed(&source, err, false)
}
source.IssuedAt = &observation.Timestamp source.IssuedAt = &observation.Timestamp
b.bundle.Observation = &observation b.bundle.Observation = &observation
b.addSource(source) b.addSource(source)
@@ -141,34 +163,36 @@ func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
} }
func (b *bundleBuilder) fetchCurrent(ctx context.Context) error { func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "current", "/conditions/current", queryOptions{precision: true}) var current weatherdata.Current
if err != nil { fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "current",
endpoint: "/conditions/current",
query: queryOptions{precision: true},
missingMessage: "current conditions data is missing",
}, &current)
if err != nil || !ok {
return err return err
} }
if raw == nil { source := fetched.source
return b.handleMissing(&source, "current conditions data is missing", false)
}
var current weatherdata.Current
if err := decodeSource(raw, &current); err != nil {
return b.handleMalformed(&source, err, false)
}
b.bundle.Current = &current b.bundle.Current = &current
b.addSource(source) b.addSource(source)
return nil return nil
} }
func (b *bundleBuilder) fetchHourly(ctx context.Context) error { func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "hourly", "/forecast/hourly", queryOptions{precision: true, timezone: true}) var hourly weatherdata.ForecastRun
if err != nil { fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "hourly",
endpoint: "/forecast/hourly",
query: queryOptions{precision: true, timezone: true},
missingMessage: "hourly forecast data is missing",
required: true,
decodeLabel: "hourly forecast",
}, &hourly)
if err != nil || !ok {
return err return err
} }
if raw == nil { source := fetched.source
return b.handleMissing(&source, "hourly forecast data is missing", true)
}
var hourly weatherdata.ForecastRun
if err := decodeSource(raw, &hourly); err != nil {
return fmt.Errorf("decode hourly forecast from %s: %w", source.Endpoint, err)
}
if len(hourly.Periods) == 0 { if len(hourly.Periods) == 0 {
return fmt.Errorf("hourly forecast from %s contains no periods", source.Endpoint) return fmt.Errorf("hourly forecast from %s contains no periods", source.Endpoint)
} }
@@ -180,17 +204,17 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
} }
func (b *bundleBuilder) fetchNarrative(ctx context.Context) error { func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "narrative", "/forecast/narrative", queryOptions{precision: true, timezone: true}) var narrative weatherdata.ForecastRun
if err != nil { fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "narrative",
endpoint: "/forecast/narrative",
query: queryOptions{precision: true, timezone: true},
missingMessage: "narrative forecast data is missing",
}, &narrative)
if err != nil || !ok {
return err return err
} }
if raw == nil { source := fetched.source
return b.handleMissing(&source, "narrative forecast data is missing", false)
}
var narrative weatherdata.ForecastRun
if err := decodeSource(raw, &narrative); err != nil {
return b.handleMalformed(&source, err, false)
}
source.IssuedAt = &narrative.IssuedAt source.IssuedAt = &narrative.IssuedAt
source.UpdatedAt = narrative.UpdatedAt source.UpdatedAt = narrative.UpdatedAt
b.bundle.Narrative = &narrative b.bundle.Narrative = &narrative
@@ -213,7 +237,7 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
} }
var alerts weatherdata.AlertRun var alerts weatherdata.AlertRun
if err := decodeSource(raw, &alerts); err != nil { if err := decodeSource(raw, &alerts); err != nil {
return b.handleMalformed(&source, err, false) return b.handleMalformed(&source, err, sourceRequest{name: "alerts"})
} }
alerts.Raw = append(json.RawMessage(nil), raw...) alerts.Raw = append(json.RawMessage(nil), raw...)
if alerts.AsOf != nil { if alerts.AsOf != nil {
@@ -225,17 +249,17 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
} }
func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error { func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "discussion", "/discussion", queryOptions{timezone: true}) var discussion weatherdata.Discussion
if err != nil { fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "discussion",
endpoint: "/discussion",
query: queryOptions{timezone: true},
missingMessage: "forecast discussion data is missing",
}, &discussion)
if err != nil || !ok {
return err return err
} }
if raw == nil { source := fetched.source
return b.handleMissing(&source, "forecast discussion data is missing", false)
}
var discussion weatherdata.Discussion
if err := decodeSource(raw, &discussion); err != nil {
return b.handleMalformed(&source, err, false)
}
source.IssuedAt = &discussion.IssuedAt source.IssuedAt = &discussion.IssuedAt
source.UpdatedAt = discussion.UpdatedAt source.UpdatedAt = discussion.UpdatedAt
b.bundle.Discussion = &discussion b.bundle.Discussion = &discussion
@@ -244,17 +268,17 @@ func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
} }
func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error { func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "weather_story", "/weatherstories/latest", queryOptions{omitUnits: true}) var story weatherdata.WeatherStory
if err != nil { fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "weather_story",
endpoint: "/weatherstories/latest",
query: queryOptions{omitUnits: true},
missingMessage: "NWS weather story data is missing",
}, &story)
if err != nil || !ok {
return err return err
} }
if raw == nil { source := fetched.source
return b.handleMissing(&source, "NWS weather story data is missing", false)
}
var story weatherdata.WeatherStory
if err := decodeSource(raw, &story); err != nil {
return b.handleMalformed(&source, err, false)
}
if !story.StartTime.IsZero() { if !story.StartTime.IsZero() {
source.IssuedAt = &story.StartTime source.IssuedAt = &story.StartTime
} }
@@ -264,6 +288,51 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
return nil return nil
} }
func (b *bundleBuilder) fetchSPCConvectiveOutlooks(ctx context.Context) error {
var run weatherdata.ConvectiveOutlookRun
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: sourceSPCConvectiveOutlooks,
endpoint: convectiveOutlooksEndpoint,
query: queryOptions{timezone: true, omitUnits: true},
missingMessage: "SPC convective outlook data is missing",
}, &run)
if err != nil || !ok {
return err
}
source := fetched.source
if run.IssuedAt != nil {
source.IssuedAt = run.IssuedAt
} else {
source.IssuedAt = run.AsOf
}
source.UpdatedAt = run.UpdatedAt
b.bundle.SPCConvectiveOutlooks = &run
b.addSource(source)
return nil
}
func (b *bundleBuilder) fetchDecodedSource(ctx context.Context, request sourceRequest, target any) (fetchedSource, bool, error) {
fetched, ok, err := b.fetchSource(ctx, request)
if err != nil || !ok {
return fetchedSource{}, false, err
}
if err := decodeSource(fetched.raw, target); err != nil {
return fetchedSource{}, false, b.handleMalformed(&fetched.source, err, request)
}
return fetched, true, nil
}
func (b *bundleBuilder) fetchSource(ctx context.Context, request sourceRequest) (fetchedSource, bool, error) {
raw, source, err := b.client.fetch(ctx, request.name, request.endpoint, request.query)
if err != nil {
return fetchedSource{}, false, err
}
if raw == nil {
return fetchedSource{}, false, b.handleMissing(&source, request.missingMessage, request.required)
}
return fetchedSource{raw: raw, source: source}, true, nil
}
func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error { func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error {
source.Missing = true source.Missing = true
if required { if required {
@@ -272,9 +341,13 @@ func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string
return b.applyMissingPolicy(source, "missing_source", message) return b.applyMissingPolicy(source, "missing_source", message)
} }
func (b *bundleBuilder) handleMalformed(source *weatherdata.Source, err error, required bool) error { func (b *bundleBuilder) handleMalformed(source *weatherdata.Source, err error, request sourceRequest) error {
if required { if request.required {
return fmt.Errorf("decode %s from %s: %w", source.Name, source.Endpoint, err) label := request.name
if request.decodeLabel != "" {
label = request.decodeLabel
}
return fmt.Errorf("decode %s from %s: %w", label, source.Endpoint, err)
} }
source.Missing = true source.Missing = true
return b.applyMissingPolicy(source, "malformed_source", fmt.Sprintf("malformed %s data: %v", source.Name, err)) return b.applyMissingPolicy(source, "malformed_source", fmt.Sprintf("malformed %s data: %v", source.Name, err))

View File

@@ -55,12 +55,39 @@ func TestFetchBundleFromFixtures(t *testing.T) {
if bundle.WeatherStory.UpdatedAt == nil { if bundle.WeatherStory.UpdatedAt == nil {
t.Fatalf("WeatherStory.UpdatedAt = nil, want update timestamp") t.Fatalf("WeatherStory.UpdatedAt = nil, want update timestamp")
} }
if len(bundle.Sources) != 7 { if bundle.SPCConvectiveOutlooks == nil || len(bundle.SPCConvectiveOutlooks.Outlooks) != 1 {
t.Fatalf("Sources length = %d, want 7", len(bundle.Sources)) t.Fatalf("SPCConvectiveOutlooks = %#v, want one outlook", bundle.SPCConvectiveOutlooks)
}
if len(bundle.SPCConvectiveOutlooks.Outlooks[0].Geometry) == 0 {
t.Fatalf("SPCConvectiveOutlooks.Outlooks[0].Geometry is empty, want GeoJSON")
}
if len(bundle.SPCConvectiveOutlooks.Discussions) != 1 || bundle.SPCConvectiveOutlooks.Discussions[0].Headline != "Severe storms possible" {
t.Fatalf("SPCConvectiveOutlooks.Discussions = %#v, want one discussion", bundle.SPCConvectiveOutlooks.Discussions)
}
if len(bundle.Sources) != 8 {
t.Fatalf("Sources length = %d, want 8", len(bundle.Sources))
} }
if len(bundle.Warnings) != 0 { if len(bundle.Warnings) != 0 {
t.Fatalf("Warnings length = %d, want no warnings", len(bundle.Warnings)) t.Fatalf("Warnings length = %d, want no warnings", len(bundle.Warnings))
} }
wantPaths := []string{
"/observations",
"/conditions/current",
"/forecast/hourly",
"/forecast/narrative",
"/alerts/active",
"/discussion",
"/weatherstories/latest",
convectiveOutlooksEndpoint,
}
if len(requested) != len(wantPaths) {
t.Fatalf("requested paths = %v, want %d source endpoints", requested, len(wantPaths))
}
for _, want := range wantPaths {
if !containsPath(requested, want) {
t.Fatalf("requested paths = %v, want %s", requested, want)
}
}
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") { if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested) t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
} }
@@ -70,6 +97,9 @@ func TestFetchBundleFromFixtures(t *testing.T) {
if !containsPath(requested, "/weatherstories/latest") { if !containsPath(requested, "/weatherstories/latest") {
t.Fatalf("requested paths = %v, want weather story endpoint", requested) t.Fatalf("requested paths = %v, want weather story endpoint", requested)
} }
if !containsPath(requested, convectiveOutlooksEndpoint) {
t.Fatalf("requested paths = %v, want convective outlook endpoint", requested)
}
} }
func TestFetchBundleBuildsExpectedQueries(t *testing.T) { func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
@@ -92,6 +122,12 @@ func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
} }
continue continue
} }
if strings.HasPrefix(rawURL, convectiveOutlooksEndpoint) {
if strings.Contains(rawURL, "units=") || strings.Contains(rawURL, "precision=") || !strings.Contains(rawURL, "tz=America%2FChicago") {
t.Fatalf("convective outlook request %q should use format and tz only", rawURL)
}
continue
}
if !strings.Contains(rawURL, "units=us") { if !strings.Contains(rawURL, "units=us") {
t.Fatalf("request %q missing units=us", rawURL) t.Fatalf("request %q missing units=us", rawURL)
} }
@@ -127,6 +163,25 @@ func TestFetchBundleRecordsSourceHash(t *testing.T) {
if story.IssuedAt == nil || story.UpdatedAt == nil { if story.IssuedAt == nil || story.UpdatedAt == nil {
t.Fatalf("weather story source timestamps = issued %#v updated %#v, want both", story.IssuedAt, story.UpdatedAt) t.Fatalf("weather story source timestamps = issued %#v updated %#v, want both", story.IssuedAt, story.UpdatedAt)
} }
outlooks := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
if outlooks.Endpoint != convectiveOutlooksEndpoint {
t.Fatalf("convective outlook endpoint = %q, want %s", outlooks.Endpoint, convectiveOutlooksEndpoint)
}
if outlooks.Query["format"] != "json" || outlooks.Query["tz"] != "America/Chicago" || outlooks.Query["units"] != "" || outlooks.Query["precision"] != "" {
t.Fatalf("convective outlook query = %#v, want format and tz only", outlooks.Query)
}
if outlooks.DataSHA256 != hashFixtureData(t, "convective_outlooks.json") {
t.Fatalf("convective outlook DataSHA256 = %q, want fixture hash", outlooks.DataSHA256)
}
if outlooks.Missing {
t.Fatal("convective outlook source Missing = true, want false")
}
if outlooks.IssuedAt == nil || outlooks.IssuedAt.Format(time.RFC3339) != "2026-05-29T15:45:00Z" {
t.Fatalf("convective outlook IssuedAt = %#v, want run issuedAt", outlooks.IssuedAt)
}
if outlooks.UpdatedAt == nil || outlooks.UpdatedAt.Format(time.RFC3339) != "2026-05-29T16:05:00Z" {
t.Fatalf("convective outlook UpdatedAt = %#v, want run updatedAt", outlooks.UpdatedAt)
}
} }
func TestHTTPErrorIsActionable(t *testing.T) { func TestHTTPErrorIsActionable(t *testing.T) {
@@ -189,6 +244,59 @@ func TestNullAlertsMeansNoActiveAlerts(t *testing.T) {
} }
} }
func TestMissingSPCConvectiveOutlooksUsesPolicy(t *testing.T) {
server := fixtureServer(t, map[string]handlerOverride{
convectiveOutlooksEndpoint: {status: http.StatusOK, body: `{"data": null}`},
}, nil)
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
sourceSPCConvectiveOutlooks: config.MissingSourceWarn,
})
bundle, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
if bundle.SPCConvectiveOutlooks != nil {
t.Fatalf("SPCConvectiveOutlooks = %#v, want nil for missing source", bundle.SPCConvectiveOutlooks)
}
source := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
if !source.Missing || len(source.Warnings) != 1 {
t.Fatalf("convective outlook source = %#v, want missing source warning", source)
}
}
func TestEmptySPCConvectiveOutlooksAreCheckedData(t *testing.T) {
server := fixtureServer(t, map[string]handlerOverride{
convectiveOutlooksEndpoint: {status: http.StatusOK, body: `{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`},
}, nil)
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
sourceSPCConvectiveOutlooks: config.MissingSourceWarn,
})
bundle, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
if bundle.SPCConvectiveOutlooks == nil {
t.Fatal("SPCConvectiveOutlooks = nil, want checked empty run")
}
if len(bundle.SPCConvectiveOutlooks.Outlooks) != 0 || len(bundle.SPCConvectiveOutlooks.Discussions) != 0 {
t.Fatalf("SPCConvectiveOutlooks = %#v, want empty arrays", bundle.SPCConvectiveOutlooks)
}
source := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
if source.Missing || len(source.Warnings) != 0 {
t.Fatalf("convective outlook source = %#v, want non-missing source without warnings", source)
}
if source.IssuedAt == nil || source.IssuedAt.Format(time.RFC3339) != "2026-05-29T16:00:00Z" {
t.Fatalf("convective outlook IssuedAt = %#v, want fallback to asOf", source.IssuedAt)
}
for _, warning := range bundle.Warnings {
if warning.Source == sourceSPCConvectiveOutlooks {
t.Fatalf("warnings = %#v, want no convective outlook warning", bundle.Warnings)
}
}
}
func TestMissingSourcePolicyWarnNoneError(t *testing.T) { func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -363,13 +471,14 @@ type handlerOverride struct {
func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server { func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server {
t.Helper() t.Helper()
fixtures := map[string]string{ fixtures := map[string]string{
"/observations": "observations.json", "/observations": "observations.json",
"/conditions/current": "current.json", "/conditions/current": "current.json",
"/forecast/hourly": "hourly.json", "/forecast/hourly": "hourly.json",
"/forecast/narrative": "narrative.json", "/forecast/narrative": "narrative.json",
"/alerts/active": "alerts.json", "/alerts/active": "alerts.json",
"/discussion": "discussion.json", "/discussion": "discussion.json",
"/weatherstories/latest": "weather_story.json", "/weatherstories/latest": "weather_story.json",
convectiveOutlooksEndpoint: "convective_outlooks.json",
} }
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if requested != nil { if requested != nil {

View 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"
}
]
}
}

View File

@@ -17,6 +17,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts" "gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
@@ -28,18 +29,20 @@ import (
type ReportKind string type ReportKind string
const ( const (
ReportDaily ReportKind = "daily" ReportDaily ReportKind = ReportKind(report.CommandNameDaily)
ReportTomorrow ReportKind = "tomorrow" ReportToday ReportKind = ReportKind(report.CommandNameToday)
ReportThreeDay ReportKind = "three-day" ReportTomorrow ReportKind = ReportKind(report.CommandNameTomorrow)
ReportWeekend ReportKind = "weekend" ReportHourly ReportKind = ReportKind(report.CommandNameHourly)
ReportStorm ReportKind = "storm" ReportThreeDay ReportKind = ReportKind(report.CommandNameThreeDay)
ReportWeekend ReportKind = ReportKind(report.CommandNameWeekend)
ReportStorm ReportKind = ReportKind(report.CommandNameStorm)
) )
type BatchKind string type BatchKind string
const ( const (
BatchMorning BatchKind = "morning" BatchMorning BatchKind = BatchKind(report.BatchNameMorning)
BatchEvening BatchKind = "evening" BatchEvening BatchKind = BatchKind(report.BatchNameEvening)
) )
type GenerateRequest struct { type GenerateRequest struct {
@@ -88,21 +91,26 @@ type ReportRequest struct {
} }
type ReportResult struct { type ReportResult struct {
ModuleSnapshot module.Snapshot ModuleSnapshot module.Snapshot
ModuleSnapshotPath string ModuleSnapshotPath string
DataPackage promptinput.Package DataPackage promptinput.Package
DataPackagePath string DataPackagePath string
PreflightPath string PreflightPath string
ReportPath string ReportPath string
OutputPath string OutputPath string
NotificationPath string NotificationPath string
Metadata state.Metadata Metadata state.Metadata
MetadataPath string MetadataPath string
PriorSnapshot *state.PriorSnapshot PriorSnapshot *state.PriorSnapshot
RecentChanges []changes.Change RecentChanges []changes.Change
RenderResult *scriptorium.RenderResult RenderResult *scriptorium.RenderResult
RunResult *scriptorium.RunResult RunResult *scriptorium.RunResult
Notification *NotificationResult StructuredRunResult *scriptorium.StructuredRunResult
GeneratedTextRawPath string
GeneratedTextResultPath string
GeneratedTextPath string
RenderContextPath string
Notification *NotificationResult
} }
type BatchResult struct { type BatchResult struct {
@@ -150,6 +158,7 @@ func (e BatchError) Error() string {
type Renderer interface { type Renderer interface {
Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error) Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error)
Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error) Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error)
StructuredRun(context.Context, scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error)
} }
type Notifier interface { type Notifier interface {
@@ -336,7 +345,7 @@ func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error
if err != nil { if err != nil {
return report.Resolved{}, err return report.Resolved{}, err
} }
id, err := reportIDForCommand(req.Report) id, err := report.IDForCommandName(string(req.Report))
if err != nil { if err != nil {
return report.Resolved{}, err return report.Resolved{}, err
} }
@@ -358,7 +367,7 @@ func ResolveBatch(req BatchRequest, now time.Time) ([]report.Resolved, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
batch, err := reportBatchForCommand(req.Batch) batch, err := report.BatchForCommandName(string(req.Batch))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -373,41 +382,17 @@ func ResolveBatch(req BatchRequest, now time.Time) ([]report.Resolved, error) {
} }
func reportRegistry(cfg config.Config) (report.Registry, error) { func reportRegistry(cfg config.Config) (report.Registry, error) {
registry, err := report.DefaultRegistry().WithModuleOverrides(cfg.ReportModuleOverrides()) overrides, err := cfg.ReportModuleOverrides()
if err != nil {
return report.Registry{}, err
}
registry, err := report.DefaultRegistry().WithModuleOverrides(overrides)
if err != nil { if err != nil {
return report.Registry{}, err return report.Registry{}, err
} }
return registry, nil return registry, nil
} }
func reportIDForCommand(kind ReportKind) (report.ID, error) {
switch kind {
case ReportDaily:
return report.DailyToday, nil
case ReportTomorrow:
return report.DailyTomorrow, nil
case ReportThreeDay:
return report.ThreeDay, nil
case ReportWeekend:
return report.Weekend, nil
case ReportStorm:
return report.Storm, nil
default:
return "", fmt.Errorf("unknown report command %q", kind)
}
}
func reportBatchForCommand(kind BatchKind) (report.Batch, error) {
switch kind {
case BatchMorning:
return report.Morning, nil
case BatchEvening:
return report.Evening, nil
default:
return "", fmt.Errorf("unknown batch command %q", kind)
}
}
func FetchBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) { func FetchBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) {
client, err := weatherapi.New(req.Config) client, err := weatherapi.New(req.Config)
if err != nil { if err != nil {
@@ -482,11 +467,15 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
} }
briefingMetadata := briefing.BuildMetadata(briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected)) briefingMetadata := briefing.BuildMetadata(briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected))
metadata := state.BuildMetadataFromBriefingMetadata(req.Resolved, briefingMetadata, state.ArtifactPaths{ metadata := state.BuildMetadataFromBriefingMetadata(req.Resolved, briefingMetadata, state.ArtifactPaths{
ModuleSnapshot: moduleSnapshotPath, ModuleSnapshot: moduleSnapshotPath,
Metadata: paths.Metadata, Metadata: paths.Metadata,
DataPackage: paths.DataPackage, DataPackage: paths.DataPackage,
Preflight: paths.Preflight, Preflight: paths.Preflight,
RenderedReport: paths.RenderedReport, RenderedReport: paths.RenderedReport,
GeneratedTextRaw: paths.GeneratedTextRaw,
GeneratedTextResult: paths.GeneratedTextResult,
GeneratedText: paths.GeneratedText,
RenderContext: paths.RenderContext,
}) })
dataPackage, err := promptinput.Build(promptinput.BuildRequest{ dataPackage, err := promptinput.Build(promptinput.BuildRequest{
Metadata: promptMetadata(metadata), Metadata: promptMetadata(metadata),
@@ -531,9 +520,36 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
return nil, metadataErr return nil, metadataErr
} }
if renderErr != nil { if renderErr != nil {
if req.Resolved.Definition.GenerationMode == report.GenerationModeGeneratedTextTemplate {
return nil, generatedReportError(req.Resolved, metadata.RunID, "render preflight", renderErr)
}
return nil, renderErr return nil, renderErr
} }
if req.Resolved.Definition.GenerationMode == report.GenerationModeGeneratedTextTemplate {
return generateTextTemplateReport(ctx, generatedReportRequest{
ReportRequest: req,
store: store,
paths: paths,
moduleSnapshot: moduleSnapshot,
moduleSnapshotPath: moduleSnapshotPath,
reportFacts: reportFacts,
dataPackage: dataPackage,
dataPackagePath: dataPackagePath,
briefingMetadata: briefingMetadata,
metadata: metadata,
metadataPath: metadataPath,
preflightPath: preflightPath,
priorSnapshot: priorSnapshot,
recentChanges: recentChanges,
renderResult: renderResult,
renderer: renderer,
})
}
if req.Resolved.Definition.GenerationMode != report.GenerationModeScriptoriumMarkdown {
return nil, fmt.Errorf("generation mode %q is not supported for report %q", req.Resolved.Definition.GenerationMode, req.Resolved.Definition.ID)
}
reportPath, err := store.PrepareRenderedReport(ctx, req.Resolved) reportPath, err := store.PrepareRenderedReport(ctx, req.Resolved)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -543,32 +559,16 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
DataPackagePath: dataPackagePath, DataPackagePath: dataPackagePath,
OutputPath: reportPath, OutputPath: reportPath,
}) })
if runErr == nil && req.OutputPath != "" && req.OutputPath != reportPath { finalized, err := finalizeRenderedReport(ctx, finalizeRenderedReportRequest{
if err := fileutil.CopyFileAtomic(reportPath, req.OutputPath); err != nil { Config: req.Config,
return nil, err Store: store,
} Resolved: req.Resolved,
} Metadata: metadata,
outputPath := reportPath ManagedReportPath: reportPath,
if req.OutputPath != "" { OutputPath: req.OutputPath,
outputPath = req.OutputPath Notifier: req.Notifier,
} GenerationErr: runErr,
metadata.RenderedReportPath = reportPath })
metadataPath, metadataErr = store.SaveMetadata(ctx, metadata)
if metadataErr != nil {
return nil, metadataErr
}
if runErr != nil {
return nil, runErr
}
notification, notificationPath, err := notifyReport(ctx, req.Config, req.Resolved, reportPath, metadata, req.Notifier, store)
if notificationPath != "" {
metadata.NotificationPath = notificationPath
metadataPath, metadataErr = store.SaveMetadata(ctx, metadata)
if metadataErr != nil {
return nil, metadataErr
}
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -580,18 +580,214 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
DataPackagePath: dataPackagePath, DataPackagePath: dataPackagePath,
PreflightPath: preflightPath, PreflightPath: preflightPath,
ReportPath: reportPath, ReportPath: reportPath,
OutputPath: outputPath, OutputPath: finalized.OutputPath,
NotificationPath: notificationPath, NotificationPath: finalized.NotificationPath,
Metadata: metadata, Metadata: finalized.Metadata,
MetadataPath: metadataPath, MetadataPath: finalized.MetadataPath,
PriorSnapshot: priorSnapshot, PriorSnapshot: priorSnapshot,
RecentChanges: recentChanges, RecentChanges: recentChanges,
RenderResult: renderResult, RenderResult: renderResult,
RunResult: runResult, RunResult: runResult,
Notification: notification, Notification: finalized.Notification,
}, nil }, nil
} }
type generatedReportRequest struct {
ReportRequest
store state.Store
paths state.ArtifactPaths
moduleSnapshot module.Snapshot
moduleSnapshotPath string
reportFacts ReportFacts
dataPackage promptinput.Package
dataPackagePath string
briefingMetadata briefing.Metadata
metadata state.Metadata
metadataPath string
preflightPath string
priorSnapshot *state.PriorSnapshot
recentChanges []changes.Change
renderResult *scriptorium.RenderResult
renderer Renderer
}
func generateTextTemplateReport(ctx context.Context, req generatedReportRequest) (*ReportResult, error) {
handler, err := generatedtext.LookupDefinition(req.Resolved.Definition)
if err != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "lookup generated text catalog", err)
}
structuredResult, runErr := req.renderer.StructuredRun(ctx, scriptorium.StructuredRunRequest{
PromptID: req.Resolved.Definition.PromptID,
DataPackagePath: req.dataPackagePath,
OutputPath: req.paths.GeneratedTextRaw,
})
generatedTextResultPath := req.paths.GeneratedTextResult
if structuredResult != nil {
var err error
generatedTextResultPath, err = req.store.SaveGeneratedTextResult(ctx, req.Resolved, structuredResult)
if err != nil {
return nil, err
}
req.metadata.GeneratedTextResultPath = generatedTextResultPath
req.metadataPath, err = req.store.SaveMetadata(ctx, req.metadata)
if err != nil {
return nil, err
}
}
if runErr != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "structured generated text", runErr)
}
rawGeneratedText, err := req.store.LoadGeneratedText(ctx, req.paths.GeneratedTextRaw)
if err != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "load raw generated text", err)
}
generatedText, normalizedGeneratedText, err := handler.Validate(rawGeneratedText)
if err != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "validate generated text", err)
}
generatedTextPath, err := req.store.SaveGeneratedText(ctx, req.Resolved, normalizedGeneratedText)
if err != nil {
return nil, err
}
req.metadata.GeneratedTextPath = generatedTextPath
req.metadataPath, err = req.store.SaveMetadata(ctx, req.metadata)
if err != nil {
return nil, err
}
renderContext, err := handler.BuildRenderContext(req.briefingMetadata, req.moduleSnapshot, req.reportFacts.Collected, req.reportFacts.Derived, generatedText)
if err != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "build render context", err)
}
renderContextPath, err := req.store.SaveRenderContext(ctx, req.Resolved, renderContext)
if err != nil {
return nil, err
}
req.metadata.RenderContextPath = renderContextPath
req.metadataPath, err = req.store.SaveMetadata(ctx, req.metadata)
if err != nil {
return nil, err
}
rendered, err := handler.Render(renderContext)
if err != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "render template", err)
}
reportPath, err := req.store.PrepareRenderedReport(ctx, req.Resolved)
if err != nil {
return nil, err
}
if err := fileutil.WriteFileAtomic(reportPath, rendered); err != nil {
return nil, err
}
finalized, err := finalizeRenderedReport(ctx, finalizeRenderedReportRequest{
Config: req.Config,
Store: req.store,
Resolved: req.Resolved,
Metadata: req.metadata,
ManagedReportPath: reportPath,
OutputPath: req.OutputPath,
Notifier: req.Notifier,
})
if err != nil {
return nil, err
}
return &ReportResult{
ModuleSnapshot: req.moduleSnapshot,
ModuleSnapshotPath: req.moduleSnapshotPath,
DataPackage: req.dataPackage,
DataPackagePath: req.dataPackagePath,
PreflightPath: req.preflightPath,
ReportPath: reportPath,
OutputPath: finalized.OutputPath,
NotificationPath: finalized.NotificationPath,
Metadata: finalized.Metadata,
MetadataPath: finalized.MetadataPath,
PriorSnapshot: req.priorSnapshot,
RecentChanges: req.recentChanges,
RenderResult: req.renderResult,
StructuredRunResult: structuredResult,
GeneratedTextRawPath: req.paths.GeneratedTextRaw,
GeneratedTextResultPath: generatedTextResultPath,
GeneratedTextPath: generatedTextPath,
RenderContextPath: renderContextPath,
Notification: finalized.Notification,
}, nil
}
type finalizeRenderedReportRequest struct {
Config config.Config
Store state.Store
Resolved report.Resolved
Metadata state.Metadata
ManagedReportPath string
OutputPath string
Notifier Notifier
GenerationErr error
}
type finalizeRenderedReportResult struct {
OutputPath string
NotificationPath string
Metadata state.Metadata
MetadataPath string
Notification *NotificationResult
}
func finalizeRenderedReport(ctx context.Context, req finalizeRenderedReportRequest) (finalizeRenderedReportResult, error) {
if req.Store == nil {
return finalizeRenderedReportResult{}, fmt.Errorf("state store is required")
}
if req.ManagedReportPath == "" {
return finalizeRenderedReportResult{}, fmt.Errorf("managed report path is required for report %q", req.Resolved.Definition.ID)
}
metadata := req.Metadata
metadata.RenderedReportPath = req.ManagedReportPath
outputPath := req.ManagedReportPath
if req.OutputPath != "" {
outputPath = req.OutputPath
if req.GenerationErr == nil && req.OutputPath != req.ManagedReportPath {
if err := fileutil.CopyFileAtomic(req.ManagedReportPath, req.OutputPath); err != nil {
return finalizeRenderedReportResult{}, err
}
}
}
metadataPath, err := req.Store.SaveMetadata(ctx, metadata)
if err != nil {
return finalizeRenderedReportResult{}, err
}
result := finalizeRenderedReportResult{
OutputPath: outputPath,
Metadata: metadata,
MetadataPath: metadataPath,
}
if req.GenerationErr != nil {
return result, req.GenerationErr
}
notification, notificationPath, err := notifyReport(ctx, req.Config, req.Resolved, req.ManagedReportPath, metadata, req.Notifier, req.Store)
if notificationPath != "" {
metadata.NotificationPath = notificationPath
metadataPath, saveErr := req.Store.SaveMetadata(ctx, metadata)
if saveErr != nil {
return finalizeRenderedReportResult{}, saveErr
}
result.Metadata = metadata
result.MetadataPath = metadataPath
result.NotificationPath = notificationPath
}
result.Notification = notification
if err != nil {
return result, err
}
return result, nil
}
func notifyReport(ctx context.Context, cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata, notifier Notifier, store state.Store) (*NotificationResult, string, error) { func notifyReport(ctx context.Context, cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata, notifier Notifier, store state.Store) (*NotificationResult, string, error) {
notifier, enabled := reportNotifier(cfg, notifier) notifier, enabled := reportNotifier(cfg, notifier)
if !enabled { if !enabled {
@@ -911,7 +1107,7 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.
PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes, PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes,
} }
switch reportID { switch reportID {
case report.DailyToday, report.DailyTomorrow: case report.Daily, report.Today, report.Tomorrow:
return changes.CompareDaily(previous, current, thresholds) return changes.CompareDaily(previous, current, thresholds)
case report.ThreeDay: case report.ThreeDay:
return changes.CompareThreeDay(previous, current, thresholds) return changes.CompareThreeDay(previous, current, thresholds)
@@ -935,3 +1131,10 @@ func preflightArtifact(result *scriptorium.RenderResult) state.PreflightArtifact
ExitCode: result.ExitCode, ExitCode: result.ExitCode,
} }
} }
func generatedReportError(resolved report.Resolved, runID string, operation string, err error) error {
if err == nil {
return nil
}
return fmt.Errorf("generate report %q run %q: %s: %w", resolved.Definition.ID, runID, operation, err)
}

File diff suppressed because it is too large Load Diff

View File

@@ -59,21 +59,24 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
t.Fatalf("HourlyForecast = %#v, want hourly metadata and one valid-period period", value) t.Fatalf("HourlyForecast = %#v, want hourly metadata and one valid-period period", value)
} }
period := value.Periods[0] period := value.Periods[0]
if period.TextDescription != "Showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 { if period.HourLabel != "8:00 AM" || period.TextDescription != "Showers likely." || period.TextDescriptionLower != "showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 {
t.Fatalf("HourlyForecast period = %#v, want hourly period facts", period) t.Fatalf("HourlyForecast period = %#v, want hourly period facts", period)
} }
if period.StartTime != "2026-05-29 at 8:00 AM" || period.EndTime != "2026-05-29 at 9:00 AM" { if period.PeriodBegins != "2026-05-29 at 8:00 AM" || period.PeriodEnds != "2026-05-29 at 9:00 AM" {
t.Fatalf("HourlyForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime) t.Fatalf("HourlyForecast period times = %q/%q, want friendly local time labels", period.PeriodBegins, period.PeriodEnds)
} }
if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 { if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
t.Fatalf("HourlyForecast period = %#v, want compass wind and precip chance", period) t.Fatalf("HourlyForecast period = %#v, want compass wind and precip chance", period)
} }
if !period.MentionPrecipitation {
t.Fatalf("MentionPrecipitation = false, want true for default threshold")
}
data, err := json.Marshal(output.Value) data, err := json.Marshal(output.Value)
if err != nil { if err != nil {
t.Fatalf("Marshal hourly forecast: %v", err) t.Fatalf("Marshal hourly forecast: %v", err)
} }
jsonText := string(data) jsonText := string(data)
for _, field := range []string{"source_location_id", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} { for _, field := range []string{"source_location_id", "hour_label", "period_begins", "period_ends", "text_description", "text_description_lower", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "mention_precipitation", "relative_humidity_percent"} {
if !strings.Contains(jsonText, field) { if !strings.Contains(jsonText, field) {
t.Fatalf("hourly json = %s, want field %s", jsonText, field) t.Fatalf("hourly json = %s, want field %s", jsonText, field)
} }
@@ -81,8 +84,71 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
if strings.Contains(jsonText, "wind_direction_degrees") || strings.Contains(jsonText, "Tomorrow") { if strings.Contains(jsonText, "wind_direction_degrees") || strings.Contains(jsonText, "Tomorrow") {
t.Fatalf("hourly json = %s, want valid-period prompt fields only", jsonText) t.Fatalf("hourly json = %s, want valid-period prompt fields only", jsonText)
} }
if strings.Contains(jsonText, `"start_time":"2026-05-29T`) || strings.Contains(jsonText, `"end_time":"2026-05-29T`) { if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) {
t.Fatalf("hourly json = %s, want friendly local start/end times", jsonText) t.Fatalf("hourly json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText)
}
}
func TestHourlyForecastPromptExportOmitsTemplateHelpers(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
richText := mustMarshalModuleJSON(t, output.Value)
for _, field := range []string{"hour_label", "text_description_lower", "mention_precipitation"} {
if !strings.Contains(richText, field) {
t.Fatalf("rich hourly json = %s, want helper field %s", richText, field)
}
}
prompt := moduleDataPackageValue[HourlyForecastPromptExport](t, output)
if prompt.Product != "hourly" || prompt.SourceLocationID != "test-grid" || len(prompt.Periods) != 1 {
t.Fatalf("hourly prompt export = %#v, want hourly metadata and one period", prompt)
}
period := prompt.Periods[0]
if period.PeriodBegins != "2026-05-29 at 8:00 AM" || period.PeriodEnds != "2026-05-29 at 9:00 AM" || period.TextDescription != "Showers likely." {
t.Fatalf("hourly prompt period = %#v, want factual period fields", period)
}
if period.TemperatureF == nil || *period.TemperatureF != 76 || period.WindSpeedMph == nil || *period.WindSpeedMph != 14 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
t.Fatalf("hourly prompt period = %#v, want temperature, wind, and precip fields", period)
}
if period.WindDirection != "S" || period.RelativeHumidityPercent == nil || *period.RelativeHumidityPercent != 66 {
t.Fatalf("hourly prompt period = %#v, want wind direction and humidity", period)
}
promptText := mustMarshalModuleJSON(t, output.DataPackageValue())
for _, field := range []string{"period_begins", "period_ends", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} {
if !strings.Contains(promptText, field) {
t.Fatalf("hourly prompt json = %s, want field %s", promptText, field)
}
}
for _, field := range []string{"hour_label", "text_description_lower", "mention_precipitation"} {
if strings.Contains(promptText, field) {
t.Fatalf("hourly prompt json = %s, want omitted helper field %s", promptText, field)
}
}
}
func TestHourlyForecastPrecipMentionThreshold(t *testing.T) {
periods := []weatherdata.ForecastPeriod{
{StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"), ProbabilityOfPrecipitationPercent: floatPtr(19)},
{StartTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"), ProbabilityOfPrecipitationPercent: floatPtr(20)},
{StartTime: mustParseModuleTime("2026-05-29T10:00:00-05:00")},
}
value := hourlyForecastPeriodsWithPrecipMentionThreshold(periods, "America/Chicago", DefaultHourlyForecastPrecipMentionProbabilityThreshold)
if len(value) != 3 {
t.Fatalf("periods length = %d, want 3", len(value))
}
if value[0].MentionPrecipitation {
t.Fatalf("19%% MentionPrecipitation = true, want false")
}
if !value[1].MentionPrecipitation {
t.Fatalf("20%% MentionPrecipitation = false, want true")
}
if value[2].MentionPrecipitation {
t.Fatalf("nil MentionPrecipitation = true, want false")
} }
} }
@@ -97,6 +163,21 @@ func TestHourlyForecastModuleRejectsUnsupportedReports(t *testing.T) {
} }
} }
func TestHourlyForecastModuleBuildsForHourly(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Hourly)
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[HourlyForecastModule](t, output)
if len(value.Periods) != 1 || value.Periods[0].TextDescription != "Showers likely." {
t.Fatalf("HourlyForecast = %#v, want hourly report period", value)
}
}
func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) { func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := testModuleContext() ctx := testModuleContext()
@@ -113,8 +194,8 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
if period.Name != "Today" || period.TextDescription != "Morning storms, then partly sunny." { if period.Name != "Today" || period.TextDescription != "Morning storms, then partly sunny." {
t.Fatalf("NarrativeForecast period = %#v, want Today narrative", period) t.Fatalf("NarrativeForecast period = %#v, want Today narrative", period)
} }
if period.StartTime != "2026-05-29 at 6:00 AM" || period.EndTime != "2026-05-29 at 6:00 PM" { if period.PeriodBegins != "2026-05-29 at 6:00 AM" || period.PeriodEnds != "2026-05-29 at 6:00 PM" {
t.Fatalf("NarrativeForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime) t.Fatalf("NarrativeForecast period times = %q/%q, want friendly local time labels", period.PeriodBegins, period.PeriodEnds)
} }
if period.IsDay == nil || !*period.IsDay || period.TemperatureF == nil || *period.TemperatureF != 81 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 60 { if period.IsDay == nil || !*period.IsDay || period.TemperatureF == nil || *period.TemperatureF != 81 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 60 {
t.Fatalf("NarrativeForecast period = %#v, want day, temperature, and precip values", period) t.Fatalf("NarrativeForecast period = %#v, want day, temperature, and precip values", period)
@@ -127,7 +208,7 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
t.Fatalf("Marshal narrative forecast: %v", err) t.Fatalf("Marshal narrative forecast: %v", err)
} }
jsonText := string(data) jsonText := string(data)
for _, field := range []string{"source_location_id", "text_description", "temperature_f", "wind_speed_mph", "wind_direction", "probability_of_precipitation_percent"} { for _, field := range []string{"source_location_id", "period_begins", "period_ends", "text_description", "temperature_f", "wind_speed_mph", "wind_direction", "probability_of_precipitation_percent"} {
if !strings.Contains(jsonText, field) { if !strings.Contains(jsonText, field) {
t.Fatalf("narrative json = %s, want field %s", jsonText, field) t.Fatalf("narrative json = %s, want field %s", jsonText, field)
} }
@@ -135,8 +216,8 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
if strings.Contains(jsonText, "wind_direction_degrees") { if strings.Contains(jsonText, "wind_direction_degrees") {
t.Fatalf("narrative json = %s, want compass wind_direction without degrees field", jsonText) t.Fatalf("narrative json = %s, want compass wind_direction without degrees field", jsonText)
} }
if strings.Contains(jsonText, `"start_time":"2026-05-29T`) || strings.Contains(jsonText, `"end_time":"2026-05-29T`) { if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) {
t.Fatalf("narrative json = %s, want friendly local start/end times", jsonText) t.Fatalf("narrative json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText)
} }
if strings.Contains(jsonText, "Tomorrow night") { if strings.Contains(jsonText, "Tomorrow night") {
t.Fatalf("narrative json = %s, want only valid-period narrative periods", jsonText) t.Fatalf("narrative json = %s, want only valid-period narrative periods", jsonText)
@@ -163,7 +244,7 @@ func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
t.Fatalf("BuildModule() error = %v", err) t.Fatalf("BuildModule() error = %v", err)
} }
value := moduleValue[MetadataModule](t, output) value := moduleValue[MetadataModule](t, output)
if value.RunID == "" || value.ReportID != report.DailyToday || value.PromptID != "weather.daily_report" { if value.RunID == "" || value.ReportID != report.Daily || value.PromptID != "weather.daily_generated_text" {
t.Fatalf("metadata = %#v, want report identity", value) t.Fatalf("metadata = %#v, want report identity", value)
} }
if value.Location == nil || value.Location.Name != "Brentwood" { if value.Location == nil || value.Location.Name != "Brentwood" {
@@ -194,18 +275,24 @@ func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) {
t.Fatalf("BuildModule() error = %v", err) t.Fatalf("BuildModule() error = %v", err)
} }
value := moduleValue[CurrentConditionsModule](t, output) value := moduleValue[CurrentConditionsModule](t, output)
if value.ConditionText != "Partly cloudy" || value.TemperatureF == nil || *value.TemperatureF != 74 { if value.ConditionText != "Partly cloudy" || value.ConditionTextLower != "partly cloudy" || value.TemperatureF == nil || *value.TemperatureF != 74 {
t.Fatalf("CurrentConditions = %#v, want current condition facts", value) t.Fatalf("CurrentConditions = %#v, want rounded current condition facts", value)
}
if value.ApparentTemperatureF == nil || *value.ApparentTemperatureF != 76 || value.RelativeHumidityPercent == nil || *value.RelativeHumidityPercent != 71 || value.WindSpeedMph == nil || *value.WindSpeedMph != 8 {
t.Fatalf("CurrentConditions rounded values = %#v, want apparent 76, humidity 71, wind 8", value)
} }
if value.WindDirection != "S" { if value.WindDirection != "S" {
t.Fatalf("WindDirection = %q, want S", value.WindDirection) t.Fatalf("WindDirection = %q, want S", value.WindDirection)
} }
if value.WindDirectionText != "south" {
t.Fatalf("WindDirectionText = %q, want south", value.WindDirectionText)
}
data, err := json.Marshal(output.Value) data, err := json.Marshal(output.Value)
if err != nil { if err != nil {
t.Fatalf("Marshal current conditions: %v", err) t.Fatalf("Marshal current conditions: %v", err)
} }
jsonText := string(data) jsonText := string(data)
for _, field := range []string{"condition_text", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction"} { for _, field := range []string{"condition_text", "condition_text_lower", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction", "wind_direction_text"} {
if !strings.Contains(jsonText, field) { if !strings.Contains(jsonText, field) {
t.Fatalf("current json = %s, want field %s", jsonText, field) t.Fatalf("current json = %s, want field %s", jsonText, field)
} }
@@ -215,6 +302,44 @@ func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) {
} }
} }
func TestCurrentConditionsPromptExportOmitsTemplateHelpers(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.CurrentConditions})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
richText := mustMarshalModuleJSON(t, output.Value)
for _, field := range []string{"condition_text_lower", "wind_direction_text"} {
if !strings.Contains(richText, field) {
t.Fatalf("rich current conditions json = %s, want helper field %s", richText, field)
}
}
prompt := moduleDataPackageValue[CurrentConditionsPromptExport](t, output)
if prompt.ConditionText != "Partly cloudy" || prompt.TemperatureF == nil || *prompt.TemperatureF != 74 {
t.Fatalf("current prompt export = %#v, want condition text and temperature", prompt)
}
if prompt.ApparentTemperatureF == nil || *prompt.ApparentTemperatureF != 76 || prompt.RelativeHumidityPercent == nil || *prompt.RelativeHumidityPercent != 71 || prompt.WindSpeedMph == nil || *prompt.WindSpeedMph != 8 {
t.Fatalf("current prompt export = %#v, want apparent temperature, humidity, and wind speed", prompt)
}
if prompt.WindDirection != "S" {
t.Fatalf("current prompt wind direction = %q, want S", prompt.WindDirection)
}
promptText := mustMarshalModuleJSON(t, output.DataPackageValue())
for _, field := range []string{"condition_text", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction"} {
if !strings.Contains(promptText, field) {
t.Fatalf("current prompt json = %s, want field %s", promptText, field)
}
}
for _, field := range []string{"condition_text_lower", "wind_direction_text"} {
if strings.Contains(promptText, field) {
t.Fatalf("current prompt json = %s, want omitted helper field %s", promptText, field)
}
}
}
func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) { func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := testModuleContext() ctx := testModuleContext()
@@ -285,13 +410,19 @@ func TestAreaForecastDiscussionAndWeatherStoryModules(t *testing.T) {
if !story.Available || story.Title != "Rain Chances" || story.Description != "Scattered showers are possible." { if !story.Available || story.Title != "Rain Chances" || story.Description != "Scattered showers are possible." {
t.Fatalf("WeatherStory = %#v, want structured story fields", story) t.Fatalf("WeatherStory = %#v, want structured story fields", story)
} }
if story.PeriodBegins != "2026-05-29 at 6:00 AM" || story.PeriodEnds != "2026-05-29 at 6:00 PM" {
t.Fatalf("WeatherStory period = %q/%q, want friendly local period labels", story.PeriodBegins, story.PeriodEnds)
}
data, err := json.Marshal(storyOutput.Value) data, err := json.Marshal(storyOutput.Value)
if err != nil { if err != nil {
t.Fatalf("Marshal weather story: %v", err) t.Fatalf("Marshal weather story: %v", err)
} }
if !strings.Contains(string(data), "download_url") { if !strings.Contains(string(data), "download_url") || !strings.Contains(string(data), "period_begins") || !strings.Contains(string(data), "period_ends") {
t.Fatalf("weather story json = %s, want snake_case download_url", string(data)) t.Fatalf("weather story json = %s, want snake_case download_url", string(data))
} }
if strings.Contains(string(data), "start_time") || strings.Contains(string(data), "end_time") {
t.Fatalf("weather story json = %s, want period_begins/period_ends instead of start_time/end_time", string(data))
}
} }
func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) { func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
@@ -314,9 +445,37 @@ func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
} }
} }
func TestAreaForecastDiscussionModuleUsesHourlyDefaultSections(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Hourly)
var item module.ConfigItem
for _, candidate := range ctx.Resolved.Definition.Modules {
if candidate.ID == module.AreaForecastDiscussion {
item = candidate
break
}
}
if item.ID == "" {
t.Fatal("hourly default modules missing area_forecast_discussion")
}
output, err := registry.BuildModule(ctx, item)
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
afd := moduleValue[AreaForecastDiscussionModule](t, output)
if len(afd.KeyMessages) != 1 || afd.ShortTerm != "Showers increase this afternoon." {
t.Fatalf("AFD = %#v, want key messages and short term", afd)
}
if afd.Product != "" || afd.LongTerm != "" {
t.Fatalf("AFD = %#v, want product and long term omitted", afd)
}
}
func testModuleContext() ModuleContext { func testModuleContext() ModuleContext {
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00") generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
definition := report.DefaultRegistry().MustLookup(report.DailyToday) definition := report.DefaultRegistry().MustLookup(report.Daily)
resolved := report.Resolved{ resolved := report.Resolved{
Definition: definition, Definition: definition,
GeneratedAt: generatedAt, GeneratedAt: generatedAt,
@@ -327,10 +486,10 @@ func testModuleContext() ModuleContext {
}, },
} }
isDay := true isDay := true
tempF := 74.0 tempF := 74.4
apparentF := 76.0 apparentF := 75.6
humidity := 71.0 humidity := 70.6
windMph := 8.0 windMph := 8.4
windDirection := 190.0 windDirection := 190.0
narrativeTempF := 81.0 narrativeTempF := 81.0
narrativePop := 60.0 narrativePop := 60.0
@@ -484,6 +643,28 @@ func moduleValue[T any](t *testing.T, output *module.Output) T {
return value return value
} }
func moduleDataPackageValue[T any](t *testing.T, output *module.Output) T {
t.Helper()
var value T
data, err := json.Marshal(output.DataPackageValue())
if err != nil {
t.Fatalf("marshal module data package value: %v", err)
}
if err := json.Unmarshal(data, &value); err != nil {
t.Fatalf("decode module data package value: %v", err)
}
return value
}
func mustMarshalModuleJSON(t *testing.T, value any) string {
t.Helper()
data, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal module value: %v", err)
}
return string(data)
}
func mustParseModuleTime(value string) time.Time { func mustParseModuleTime(value string) time.Time {
parsed, err := time.Parse(time.RFC3339, value) parsed, err := time.Parse(time.RFC3339, value)
if err != nil { if err != nil {

View File

@@ -1,20 +1,41 @@
package briefing package briefing
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module" import (
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
)
type CurrentConditionsModule struct { type CurrentConditionsModule struct {
ConditionText string `json:"condition_text,omitempty"` ConditionText string `json:"condition_text,omitempty"`
IsDay *bool `json:"is_day,omitempty"` ConditionTextLower string `json:"condition_text_lower,omitempty"`
TemperatureC *float64 `json:"temperature_c,omitempty"` IsDay *bool `json:"is_day,omitempty"`
TemperatureF *float64 `json:"temperature_f,omitempty"` TemperatureC *int `json:"temperature_c,omitempty"`
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"` TemperatureF *int `json:"temperature_f,omitempty"`
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"` ApparentTemperatureC *int `json:"apparent_temperature_c,omitempty"`
DewpointC *float64 `json:"dewpoint_c,omitempty"` ApparentTemperatureF *int `json:"apparent_temperature_f,omitempty"`
DewpointF *float64 `json:"dewpoint_f,omitempty"` DewpointC *int `json:"dewpoint_c,omitempty"`
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"` DewpointF *int `json:"dewpoint_f,omitempty"`
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"` RelativeHumidityPercent *int `json:"relative_humidity_percent,omitempty"`
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"` WindSpeedKmh *int `json:"wind_speed_kmh,omitempty"`
WindDirection string `json:"wind_direction,omitempty"` WindSpeedMph *int `json:"wind_speed_mph,omitempty"`
WindDirection string `json:"wind_direction,omitempty"`
WindDirectionText string `json:"wind_direction_text,omitempty"`
}
type CurrentConditionsPromptExport struct {
ConditionText string `json:"condition_text,omitempty"`
IsDay *bool `json:"is_day,omitempty"`
TemperatureC *int `json:"temperature_c,omitempty"`
TemperatureF *int `json:"temperature_f,omitempty"`
ApparentTemperatureC *int `json:"apparent_temperature_c,omitempty"`
ApparentTemperatureF *int `json:"apparent_temperature_f,omitempty"`
DewpointC *int `json:"dewpoint_c,omitempty"`
DewpointF *int `json:"dewpoint_f,omitempty"`
RelativeHumidityPercent *int `json:"relative_humidity_percent,omitempty"`
WindSpeedKmh *int `json:"wind_speed_kmh,omitempty"`
WindSpeedMph *int `json:"wind_speed_mph,omitempty"`
WindDirection string `json:"wind_direction,omitempty"`
} }
func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, error) { func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, error) {
@@ -24,17 +45,19 @@ func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, err
} }
value := CurrentConditionsModule{ value := CurrentConditionsModule{
ConditionText: current.ConditionText, ConditionText: current.ConditionText,
ConditionTextLower: strings.ToLower(current.ConditionText),
IsDay: copyBool(current.IsDay), IsDay: copyBool(current.IsDay),
TemperatureC: copyFloat(current.TemperatureC), TemperatureC: roundedInt(current.TemperatureC),
TemperatureF: copyFloat(current.TemperatureF), TemperatureF: roundedInt(current.TemperatureF),
ApparentTemperatureC: copyFloat(current.ApparentTemperatureC), ApparentTemperatureC: roundedInt(current.ApparentTemperatureC),
ApparentTemperatureF: copyFloat(current.ApparentTemperatureF), ApparentTemperatureF: roundedInt(current.ApparentTemperatureF),
DewpointC: copyFloat(current.DewpointC), DewpointC: roundedInt(current.DewpointC),
DewpointF: copyFloat(current.DewpointF), DewpointF: roundedInt(current.DewpointF),
RelativeHumidityPercent: copyFloat(current.RelativeHumidityPercent), RelativeHumidityPercent: roundedInt(current.RelativeHumidityPercent),
WindSpeedKmh: copyFloat(current.WindSpeedKmh), WindSpeedKmh: roundedInt(current.WindSpeedKmh),
WindSpeedMph: copyFloat(current.WindSpeedMph), WindSpeedMph: roundedInt(current.WindSpeedMph),
WindDirection: windDirectionLabel(current.WindDirectionDegrees), WindDirection: windDirectionLabel(current.WindDirectionDegrees),
WindDirectionText: windDirectionTextLabel(current.WindDirectionDegrees),
} }
if value.isEmpty() { if value.isEmpty() {
return nil, nil return nil, nil
@@ -42,8 +65,30 @@ func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, err
return &module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: value}, nil return &module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: value}, nil
} }
func exportCurrentConditionsPromptValue(value any) (any, error) {
rich, ok := value.(CurrentConditionsModule)
if !ok {
return nil, unexpectedPromptExportValue(value, CurrentConditionsModule{})
}
return CurrentConditionsPromptExport{
ConditionText: rich.ConditionText,
IsDay: copyBool(rich.IsDay),
TemperatureC: copyInt(rich.TemperatureC),
TemperatureF: copyInt(rich.TemperatureF),
ApparentTemperatureC: copyInt(rich.ApparentTemperatureC),
ApparentTemperatureF: copyInt(rich.ApparentTemperatureF),
DewpointC: copyInt(rich.DewpointC),
DewpointF: copyInt(rich.DewpointF),
RelativeHumidityPercent: copyInt(rich.RelativeHumidityPercent),
WindSpeedKmh: copyInt(rich.WindSpeedKmh),
WindSpeedMph: copyInt(rich.WindSpeedMph),
WindDirection: rich.WindDirection,
}, nil
}
func (v CurrentConditionsModule) isEmpty() bool { func (v CurrentConditionsModule) isEmpty() bool {
return v.ConditionText == "" && return v.ConditionText == "" &&
v.ConditionTextLower == "" &&
v.IsDay == nil && v.IsDay == nil &&
v.TemperatureC == nil && v.TemperatureC == nil &&
v.TemperatureF == nil && v.TemperatureF == nil &&
@@ -54,5 +99,6 @@ func (v CurrentConditionsModule) isEmpty() bool {
v.RelativeHumidityPercent == nil && v.RelativeHumidityPercent == nil &&
v.WindSpeedKmh == nil && v.WindSpeedKmh == nil &&
v.WindSpeedMph == nil && v.WindSpeedMph == nil &&
v.WindDirection == "" v.WindDirection == "" &&
v.WindDirectionText == ""
} }

View File

@@ -0,0 +1,24 @@
package briefing
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
type DailyPlanningModule struct {
MorningReadiness []string `json:"morning_readiness,omitempty"`
CommuteSchoolWorkdayConcerns []string `json:"commute_school_workday_concerns,omitempty"`
OvernightChangeWatch []string `json:"overnight_change_watch,omitempty"`
}
func buildDailyPlanningModule(ctx ModuleContext, _ any) (*module.Output, error) {
summary := ctx.Derived.FirstDailySummary()
if summary == nil {
return &module.Output{ID: module.DailyPlanning, StanzaName: "daily_planning", Value: DailyPlanningModule{}}, nil
}
planning := buildMorningCommuteOvernightPlanning(summary)
value := DailyPlanningModule{}
if planning != nil {
value.MorningReadiness = append([]string(nil), planning.MorningReadiness...)
value.CommuteSchoolWorkdayConcerns = append([]string(nil), planning.CommuteSchoolWorkdayConcerns...)
value.OvernightChangeWatch = append([]string(nil), planning.OvernightChangeWatch...)
}
return &module.Output{ID: module.DailyPlanning, StanzaName: "daily_planning", Value: value}, nil
}

View File

@@ -2,32 +2,74 @@ package briefing
import ( import (
"fmt" "fmt"
"sort"
"strings" "strings"
"unicode" "unicode"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
type DerivedDaypartSummaryModule struct { type DerivedDaypartSummaryModule struct {
Date string `json:"date,omitempty"` Date string `json:"date,omitempty"`
Period string `json:"period,omitempty"` DisplayName string `json:"display_name,omitempty"`
TempRangeF string `json:"temp_range_f,omitempty"` PeriodBegins string `json:"period_begins,omitempty"`
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"` PeriodEnds string `json:"period_ends,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"` TempRangeF string `json:"temp_range_f,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"` TemperaturePhraseF string `json:"temperature_phrase_f,omitempty"`
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"` ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"` MaxPopPercent *int `json:"max_pop_percent,omitempty"`
DominantCondition string `json:"dominant_condition,omitempty"` MaxPopTime string `json:"max_pop_time,omitempty"`
NotableConditions []string `json:"notable_conditions,omitempty"` MaxPopTimeLabel string `json:"max_pop_time_label,omitempty"`
Snow bool `json:"snow,omitempty"` MentionPrecipitation bool `json:"mention_precipitation,omitempty"`
Ice bool `json:"ice,omitempty"` MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
Fog bool `json:"fog,omitempty"` MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
Heat bool `json:"heat,omitempty"` DominantCondition string `json:"dominant_condition,omitempty"`
Cold bool `json:"cold,omitempty"` DominantConditionLower string `json:"dominant_condition_lower,omitempty"`
Wind bool `json:"wind,omitempty"` DominantConditionDisplay string `json:"dominant_condition_display,omitempty"`
RelevantAlertCount int `json:"relevant_alert_count,omitempty"` TemperatureTrend string `json:"temperature_trend,omitempty"`
TemperatureStartPhraseF string `json:"temperature_start_phrase_f,omitempty"`
TemperatureEndPhraseF string `json:"temperature_end_phrase_f,omitempty"`
TemperaturePeakPhraseF string `json:"temperature_peak_phrase_f,omitempty"`
TemperatureSteadyPhraseF string `json:"temperature_steady_phrase_f,omitempty"`
NotableConditions []string `json:"notable_conditions,omitempty"`
Snow bool `json:"snow,omitempty"`
Ice bool `json:"ice,omitempty"`
Fog bool `json:"fog,omitempty"`
Heat bool `json:"heat,omitempty"`
Cold bool `json:"cold,omitempty"`
Wind bool `json:"wind,omitempty"`
RelevantAlertCount int `json:"relevant_alert_count,omitempty"`
}
type DerivedDaypartSummaryPromptExport struct {
Date string `json:"date,omitempty"`
DisplayName string `json:"display_name,omitempty"`
PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
TempRangeF string `json:"temp_range_f,omitempty"`
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"`
MentionPrecipitation bool `json:"mention_precipitation,omitempty"`
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
DominantCondition string `json:"dominant_condition,omitempty"`
TemperatureTrend string `json:"temperature_trend,omitempty"`
TemperatureStartPhraseF string `json:"temperature_start_phrase_f,omitempty"`
TemperatureEndPhraseF string `json:"temperature_end_phrase_f,omitempty"`
TemperaturePeakPhraseF string `json:"temperature_peak_phrase_f,omitempty"`
TemperatureSteadyPhraseF string `json:"temperature_steady_phrase_f,omitempty"`
NotableConditions []string `json:"notable_conditions,omitempty"`
Snow bool `json:"snow,omitempty"`
Ice bool `json:"ice,omitempty"`
Fog bool `json:"fog,omitempty"`
Heat bool `json:"heat,omitempty"`
Cold bool `json:"cold,omitempty"`
Wind bool `json:"wind,omitempty"`
RelevantAlertCount int `json:"relevant_alert_count,omitempty"`
} }
func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Output, error) { func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Output, error) {
@@ -43,25 +85,84 @@ func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Outpu
return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil
} }
func exportDerivedDaypartSummariesPromptValue(value any) (any, error) {
rich, ok := value.(map[string]DerivedDaypartSummaryModule)
if !ok {
return nil, unexpectedPromptExportValue(value, map[string]DerivedDaypartSummaryModule{})
}
out := make(map[string]DerivedDaypartSummaryPromptExport, len(rich))
for key, daypart := range rich {
out[key] = derivedDaypartSummaryPromptValue(daypart)
}
return out, nil
}
func derivedDaypartSummaryPromptValue(rich DerivedDaypartSummaryModule) DerivedDaypartSummaryPromptExport {
maxPopTime := rich.MaxPopTime
if rich.MaxPopTimeLabel != "" {
maxPopTime = rich.MaxPopTimeLabel
}
return DerivedDaypartSummaryPromptExport{
Date: rich.Date,
DisplayName: rich.DisplayName,
PeriodBegins: rich.PeriodBegins,
PeriodEnds: rich.PeriodEnds,
TempRangeF: rich.TempRangeF,
ApparentTempRangeF: rich.ApparentTempRangeF,
MaxPopPercent: copyInt(rich.MaxPopPercent),
MaxPopTime: maxPopTime,
MentionPrecipitation: rich.MentionPrecipitation,
MaxWindGustMph: copyInt(rich.MaxWindGustMph),
MaxWindGustTime: rich.MaxWindGustTime,
DominantCondition: rich.DominantCondition,
TemperatureTrend: rich.TemperatureTrend,
TemperatureStartPhraseF: rich.TemperatureStartPhraseF,
TemperatureEndPhraseF: rich.TemperatureEndPhraseF,
TemperaturePeakPhraseF: rich.TemperaturePeakPhraseF,
TemperatureSteadyPhraseF: rich.TemperatureSteadyPhraseF,
NotableConditions: append([]string(nil), rich.NotableConditions...),
Snow: rich.Snow,
Ice: rich.Ice,
Fog: rich.Fog,
Heat: rich.Heat,
Cold: rich.Cold,
Wind: rich.Wind,
RelevantAlertCount: rich.RelevantAlertCount,
}
}
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule { func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
temperature := daypartTemperatureDisplay(daypart)
value := DerivedDaypartSummaryModule{ value := DerivedDaypartSummaryModule{
Date: localDateLabel(daypart.Period.Start, timezone), Date: localDateLabel(daypart.Period.Start, timezone),
Period: friendlyPeriodLabel(daypart.Period, timezone), DisplayName: titleWord(strings.TrimSpace(daypart.Name)),
TempRangeF: rangeLabel(daypart.Temperature), PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone),
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature), PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone),
DominantCondition: daypart.DominantCondition, TempRangeF: rangeLabel(daypart.Temperature),
NotableConditions: append([]string(nil), daypart.NotableConditions...), TemperaturePhraseF: temperaturePhraseF(daypart.Temperature),
Snow: daypart.Indicators.Snow, TemperatureTrend: temperature.Trend,
Ice: daypart.Indicators.Ice, TemperatureStartPhraseF: temperature.StartPhrase,
Fog: daypart.Indicators.Fog, TemperatureEndPhraseF: temperature.EndPhrase,
Heat: daypart.Indicators.Heat, TemperaturePeakPhraseF: temperature.PeakPhrase,
Cold: daypart.Indicators.Cold, TemperatureSteadyPhraseF: temperature.SteadyPhrase,
Wind: daypart.Indicators.Wind, ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
RelevantAlertCount: len(daypart.AlertOverlaps), 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 { if daypart.MaxPrecipitationProbability != nil {
value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value) value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value)
value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone) value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone)
value.MaxPopTimeLabel = hourMinuteLabel(daypart.MaxPrecipitationProbability.Time, timezone)
value.MentionPrecipitation = mentionHourlyForecastPrecipitation(&daypart.MaxPrecipitationProbability.Value, DefaultHourlyForecastPrecipMentionProbabilityThreshold)
} }
if daypart.PeakWindGust != nil { if daypart.PeakWindGust != nil {
value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value) value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value)
@@ -70,6 +171,208 @@ func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string
return value return value
} }
type daypartTemperaturePresentation struct {
Trend string
StartPhrase string
EndPhrase string
PeakPhrase string
SteadyPhrase string
}
type temperaturePoint struct {
value int
bandIndex int
phrase string
}
const (
temperatureTrendRising = "rising"
temperatureTrendFalling = "falling"
temperatureTrendPeaking = "peaking"
temperatureTrendSteady = "steady"
)
func daypartTemperatureDisplay(daypart forecast.DaypartSummary) daypartTemperaturePresentation {
points := daypartTemperaturePoints(daypart.HourlyPeriods)
if len(points) == 0 {
return daypartSteadyTemperatureDisplay(temperaturePhraseF(daypart.Temperature))
}
if len(points) == 1 {
return daypartSteadyTemperatureDisplay(points[0].phrase)
}
first := points[0]
last := points[len(points)-1]
peak, peakIndex := peakTemperaturePoint(points)
if peakIndex > 0 && peakIndex < len(points)-1 && peak.bandIndex > first.bandIndex && peak.bandIndex > last.bandIndex {
return daypartTemperaturePresentation{
Trend: temperatureTrendPeaking,
PeakPhrase: peak.phrase,
}
}
switch {
case first.bandIndex < last.bandIndex:
return daypartTemperaturePresentation{
Trend: temperatureTrendRising,
StartPhrase: first.phrase,
EndPhrase: last.phrase,
}
case first.bandIndex > last.bandIndex:
return daypartTemperaturePresentation{
Trend: temperatureTrendFalling,
StartPhrase: first.phrase,
EndPhrase: last.phrase,
}
default:
return daypartSteadyTemperatureDisplay(temperaturePhraseF(daypart.Temperature))
}
}
func daypartSteadyTemperatureDisplay(phrase string) daypartTemperaturePresentation {
if phrase == "" {
return daypartTemperaturePresentation{}
}
return daypartTemperaturePresentation{
Trend: temperatureTrendSteady,
SteadyPhrase: phrase,
}
}
func daypartTemperaturePoints(periods []weatherdata.ForecastPeriod) []temperaturePoint {
sorted := append([]weatherdata.ForecastPeriod(nil), periods...)
sort.SliceStable(sorted, func(i int, j int) bool {
return sorted[i].StartTime.Before(sorted[j].StartTime)
})
points := make([]temperaturePoint, 0, len(sorted))
for _, period := range sorted {
temperature := forecastPeriodTemperatureF(period)
if temperature == nil {
continue
}
rounded := roundedInt(temperature)
if rounded == nil {
continue
}
points = append(points, temperaturePoint{
value: *rounded,
bandIndex: temperatureBandIndex(*rounded),
phrase: temperatureBandPhrase(*rounded),
})
}
return points
}
func peakTemperaturePoint(points []temperaturePoint) (temperaturePoint, int) {
peak := points[0]
peakIndex := 0
for index, point := range points[1:] {
if point.value > peak.value {
peak = point
peakIndex = index + 1
}
}
return peak, peakIndex
}
func forecastPeriodTemperatureF(period weatherdata.ForecastPeriod) *float64 {
switch {
case period.TemperatureF != nil:
return period.TemperatureF
case period.TemperatureFMax != nil:
return period.TemperatureFMax
case period.TemperatureFMin != nil:
return period.TemperatureFMin
case period.TemperatureC != nil:
value := celsiusToFahrenheit(*period.TemperatureC)
return &value
case period.TemperatureCMax != nil:
value := celsiusToFahrenheit(*period.TemperatureCMax)
return &value
case period.TemperatureCMin != nil:
value := celsiusToFahrenheit(*period.TemperatureCMin)
return &value
default:
return nil
}
}
func celsiusToFahrenheit(value float64) float64 {
return value*9/5 + 32
}
func temperatureBandIndex(value int) int {
decade := (value / 10) * 10
remainder := value - decade
if remainder < 0 {
remainder = -remainder
}
band := 1
switch {
case remainder <= 3:
band = 0
case remainder >= 7:
band = 2
}
return decade*3 + band
}
func temperaturePhraseF(value forecast.Range) string {
if value.Min == nil && value.Max == nil {
return ""
}
if value.Min != nil && value.Max != nil {
low := roundedInt(value.Min)
high := roundedInt(value.Max)
if low == nil || high == nil {
return ""
}
lowPhrase := temperatureBandPhrase(*low)
highPhrase := temperatureBandPhrase(*high)
if lowPhrase == highPhrase {
return lowPhrase
}
return lowPhrase + " to " + highPhrase
}
if value.Min != nil {
low := roundedInt(value.Min)
if low == nil {
return ""
}
return temperatureBandPhrase(*low)
}
high := roundedInt(value.Max)
if high == nil {
return ""
}
return temperatureBandPhrase(*high)
}
func temperatureBandPhrase(value int) string {
decade := (value / 10) * 10
remainder := value - decade
if remainder < 0 {
remainder = -remainder
}
qualifier := "mid"
switch {
case remainder <= 3:
qualifier = "low"
case remainder >= 7:
qualifier = "upper"
}
return fmt.Sprintf("%s %ds", qualifier, decade)
}
func sentenceCase(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return ""
}
runes := []rune(trimmed)
runes[0] = unicode.ToUpper(runes[0])
return string(runes)
}
func multipleSummaryDates(summaries []forecast.DailySummary) bool { func multipleSummaryDates(summaries []forecast.DailySummary) bool {
seen := map[string]struct{}{} seen := map[string]struct{}{}
for _, summary := range summaries { for _, summary := range summaries {

View File

@@ -16,7 +16,7 @@ import (
func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) { func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday) ctx := derivedModuleContext(report.Daily)
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary}) output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
if err != nil { if err != nil {
@@ -67,7 +67,7 @@ func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) { func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday) ctx := derivedModuleContext(report.Daily)
ctx.Derived.DailySummaries[0].NarrativePeriods = nil ctx.Derived.DailySummaries[0].NarrativePeriods = nil
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary}) output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
@@ -89,7 +89,7 @@ func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) { func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday) ctx := derivedModuleContext(report.Daily)
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming}) output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
if err != nil { if err != nil {
@@ -102,19 +102,22 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
if len(rainy.PrecipitationWindows) != 2 { if len(rainy.PrecipitationWindows) != 2 {
t.Fatalf("rainy precipitation windows = %#v, want two windows", rainy.PrecipitationWindows) t.Fatalf("rainy precipitation windows = %#v, want two windows", rainy.PrecipitationWindows)
} }
if rainy.PrecipitationWindows[0].Start != "8 AM" || rainy.PrecipitationWindows[0].End != "9 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 { if rainy.PrecipitationWindows[0].PeriodBegins != "2026-05-29 at 8:00 AM" || rainy.PrecipitationWindows[0].PeriodBeginsHourLabel != "8:00 AM" || rainy.PrecipitationWindows[0].PeriodEnds != "2026-05-29 at 9:00 AM" || rainy.PrecipitationWindows[0].PeriodEndsHourLabel != "9:00 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 || rainy.PrecipitationWindows[0].MaxPopHourLabel != "8:00 AM" {
t.Fatalf("first precipitation window = %#v, want 8-9 AM at 60%%", rainy.PrecipitationWindows[0]) t.Fatalf("first precipitation window = %#v, want 8-9 AM at 60%%", rainy.PrecipitationWindows[0])
} }
if rainy.PrecipitationWindows[1].Start != "12 PM" || rainy.PrecipitationWindows[1].End != "2 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 { if rainy.PrecipitationWindows[1].PeriodBegins != "2026-05-29 at 12:00 PM" || rainy.PrecipitationWindows[1].PeriodBeginsHourLabel != "12:00 PM" || rainy.PrecipitationWindows[1].PeriodEnds != "2026-05-29 at 2:00 PM" || rainy.PrecipitationWindows[1].PeriodEndsHourLabel != "2:00 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 || rainy.PrecipitationWindows[1].MaxPopHourLabel != "12:00 PM" {
t.Fatalf("second precipitation window = %#v, want noon-2 PM at 80%%", rainy.PrecipitationWindows[1]) t.Fatalf("second precipitation window = %#v, want noon-2 PM at 80%%", rainy.PrecipitationWindows[1])
} }
data, err := json.Marshal(output.Value) data, err := json.Marshal(output.Value)
if err != nil { if err != nil {
t.Fatalf("marshal precip timing: %v", err) t.Fatalf("marshal precip timing: %v", err)
} }
if !strings.Contains(string(data), "precipitation_windows") || !strings.Contains(string(data), "probability_threshold") { if !strings.Contains(string(data), "precipitation_windows") || !strings.Contains(string(data), "probability_threshold") || !strings.Contains(string(data), "period_begins_hour_label") || !strings.Contains(string(data), "max_pop_hour_label") {
t.Fatalf("precip timing json = %s, want threshold and windows", string(data)) t.Fatalf("precip timing json = %s, want threshold and windows", string(data))
} }
if strings.Contains(string(data), `"start"`) || strings.Contains(string(data), `"end"`) {
t.Fatalf("precip timing json = %s, want period_begins/period_ends instead of start/end", string(data))
}
if strings.Contains(string(data), "first_precip_hour") || strings.Contains(string(data), "last_precip_hour") { if strings.Contains(string(data), "first_precip_hour") || strings.Contains(string(data), "last_precip_hour") {
t.Fatalf("precip timing json = %s, want no ambiguous first/last fields", string(data)) t.Fatalf("precip timing json = %s, want no ambiguous first/last fields", string(data))
} }
@@ -133,9 +136,25 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
} }
} }
func TestPrecipTimingModuleUsesDerivedTimingWithoutDaypartSummaries(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.Hourly)
ctx.Derived.DailySummaries = nil
ctx.Derived.DaypartSummaries = nil
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[PrecipTimingModule](t, output)
if value.MaxPopPercent == nil || *value.MaxPopPercent != 80 || len(value.PrecipitationWindows) != 2 {
t.Fatalf("precip timing = %#v, want derived timing without daypart summaries", value)
}
}
func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) { func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday) ctx := derivedModuleContext(report.Daily)
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries}) output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
if err != nil { if err != nil {
@@ -150,14 +169,20 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 { if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 {
t.Fatalf("morning = %#v, want temp range and precip peak", morning) t.Fatalf("morning = %#v, want temp range and precip peak", morning)
} }
if morning.Date != "2026-05-29" || morning.Period != "2026-05-29 at 6:00 AM to 2026-05-29 at 12:00 PM" { if morning.DisplayName != "Morning" || morning.DominantConditionLower != "showers" || morning.DominantConditionDisplay != "Showers" || morning.TemperaturePhraseF != "upper 50s" || morning.TemperatureTrend != "steady" || morning.TemperatureSteadyPhraseF != "upper 50s" || !morning.MentionPrecipitation || morning.MaxPopTimeLabel != "6:00 AM" {
t.Fatalf("morning period = %q/%q, want friendly local date and period labels", morning.Date, morning.Period) t.Fatalf("morning presentation fields = %#v, want display facts for template composition", morning)
}
if morning.Date != "2026-05-29" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" {
t.Fatalf("morning period = %q/%q/%q, want friendly local date and period labels", morning.Date, morning.PeriodBegins, morning.PeriodEnds)
}
overnight := value["overnight"]
if overnight.MentionPrecipitation {
t.Fatalf("overnight MentionPrecipitation = true, want false below threshold")
} }
afternoon := value["afternoon"] afternoon := value["afternoon"]
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 { if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 {
t.Fatalf("afternoon = %#v, want heat and wind hazard values", afternoon) t.Fatalf("afternoon = %#v, want heat and wind hazard values", afternoon)
} }
overnight := value["overnight"]
if !overnight.Cold { if !overnight.Cold {
t.Fatalf("overnight = %#v, want cold hazard", overnight) t.Fatalf("overnight = %#v, want cold hazard", overnight)
} }
@@ -166,19 +191,236 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
t.Fatalf("marshal daypart summaries: %v", err) t.Fatalf("marshal daypart summaries: %v", err)
} }
jsonText := string(data) jsonText := string(data)
for _, field := range []string{"date", "period", "temp_range_f", "max_pop_percent", "max_wind_gust_mph", "dominant_condition"} { for _, field := range []string{"date", "display_name", "period_begins", "period_ends", "temp_range_f", "temperature_phrase_f", "temperature_trend", "temperature_steady_phrase_f", "max_pop_percent", "max_pop_time_label", "mention_precipitation", "max_wind_gust_mph", "dominant_condition", "dominant_condition_lower", "dominant_condition_display"} {
if !strings.Contains(jsonText, field) { if !strings.Contains(jsonText, field) {
t.Fatalf("daypart json = %s, want field %s", jsonText, field) t.Fatalf("daypart json = %s, want field %s", jsonText, field)
} }
} }
if strings.Contains(jsonText, `"period":{"start"`) || strings.Contains(jsonText, `T06:00:00`) { if strings.Contains(jsonText, `"period":`) || strings.Contains(jsonText, `T06:00:00`) {
t.Fatalf("daypart json = %s, want friendly period label instead of raw timestamps", jsonText) t.Fatalf("daypart json = %s, want friendly period label instead of raw timestamps", jsonText)
} }
} }
func TestDerivedDaypartSummariesPromptExportOmitsTemplateHelpers(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.Daily)
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
richText := mustMarshalModuleJSON(t, output.Value)
for _, field := range []string{"temperature_phrase_f", "dominant_condition_lower", "dominant_condition_display", "max_pop_time_label"} {
if !strings.Contains(richText, field) {
t.Fatalf("rich daypart json = %s, want helper field %s", richText, field)
}
}
prompt := moduleDataPackageValue[map[string]DerivedDaypartSummaryPromptExport](t, output)
morning, ok := prompt["morning"]
if !ok {
t.Fatalf("daypart prompt keys = %#v, want morning", prompt)
}
if morning.Date != "2026-05-29" || morning.DisplayName != "Morning" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" {
t.Fatalf("morning prompt period = %#v, want date/display/period labels", morning)
}
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 || morning.MaxPopTime != "6:00 AM" || !morning.MentionPrecipitation {
t.Fatalf("morning prompt precip/temp = %#v, want factual prompt fields with friendly max pop time", morning)
}
if morning.DominantCondition != "Showers" || morning.TemperatureTrend != "steady" || morning.TemperatureSteadyPhraseF != "upper 50s" {
t.Fatalf("morning prompt condition/trend = %#v, want condition and trend fields", morning)
}
if len(morning.NotableConditions) == 0 || morning.NotableConditions[0] != "Showers" {
t.Fatalf("morning prompt notable conditions = %#v, want copied conditions", morning.NotableConditions)
}
afternoon := prompt["afternoon"]
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 || afternoon.RelevantAlertCount != 1 {
t.Fatalf("afternoon prompt = %#v, want hazard, wind, and alert fields", afternoon)
}
promptText := mustMarshalModuleJSON(t, output.DataPackageValue())
for _, field := range []string{"date", "display_name", "period_begins", "period_ends", "temp_range_f", "max_pop_percent", "max_pop_time", "mention_precipitation", "max_wind_gust_mph", "dominant_condition", "temperature_trend", "temperature_steady_phrase_f", "notable_conditions", "relevant_alert_count"} {
if !strings.Contains(promptText, field) {
t.Fatalf("daypart prompt json = %s, want field %s", promptText, field)
}
}
for _, field := range []string{"temperature_phrase_f", "dominant_condition_lower", "dominant_condition_display", "max_pop_time_label"} {
if strings.Contains(promptText, field) {
t.Fatalf("daypart prompt json = %s, want omitted helper field %s", promptText, field)
}
}
}
func TestDerivedDaypartPromptExportTemperatureTrends(t *testing.T) {
tests := []struct {
name string
temps []float64
wantTrend string
wantStart string
wantEnd string
wantPeak string
wantSteady string
}{
{
name: "rising",
temps: []float64{58, 68},
wantTrend: "rising",
wantStart: "upper 50s",
wantEnd: "upper 60s",
},
{
name: "falling",
temps: []float64{65, 58},
wantTrend: "falling",
wantStart: "mid 60s",
wantEnd: "upper 50s",
},
{
name: "peaking",
temps: []float64{62, 78, 65},
wantTrend: "peaking",
wantPeak: "upper 70s",
},
{
name: "steady",
temps: []float64{77, 78},
wantTrend: "steady",
wantSteady: "upper 70s",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
rich := derivedDaypartSummaryValue(derivedDaypartWithTemperatures(test.name, "2026-05-29T12:00:00-05:00", "sunny", test.temps...), "America/Chicago")
prompt := derivedDaypartSummaryPromptValue(rich)
if prompt.TemperatureTrend != test.wantTrend ||
prompt.TemperatureStartPhraseF != test.wantStart ||
prompt.TemperatureEndPhraseF != test.wantEnd ||
prompt.TemperaturePeakPhraseF != test.wantPeak ||
prompt.TemperatureSteadyPhraseF != test.wantSteady {
t.Fatalf("prompt temperature presentation = %#v", prompt)
}
})
}
}
func TestDerivedDaypartPromptExportMaxPopTimeFallback(t *testing.T) {
withLabel := derivedDaypartSummaryPromptValue(DerivedDaypartSummaryModule{
MaxPopTime: "6 AM",
MaxPopTimeLabel: "6:00 AM",
})
if withLabel.MaxPopTime != "6:00 AM" {
t.Fatalf("MaxPopTime with label = %q, want friendly label", withLabel.MaxPopTime)
}
withoutLabel := derivedDaypartSummaryPromptValue(DerivedDaypartSummaryModule{
MaxPopTime: "6 AM",
})
if withoutLabel.MaxPopTime != "6 AM" {
t.Fatalf("MaxPopTime without label = %q, want fallback time", withoutLabel.MaxPopTime)
}
}
func TestDerivedDaypartTemperaturePresentationFields(t *testing.T) {
tests := []struct {
name string
temps []float64
wantTrend string
wantStart string
wantEnd string
wantPeak string
wantSteady string
}{
{
name: "rising",
temps: []float64{58, 68},
wantTrend: "rising",
wantStart: "upper 50s",
wantEnd: "upper 60s",
},
{
name: "falling",
temps: []float64{65, 58},
wantTrend: "falling",
wantStart: "mid 60s",
wantEnd: "upper 50s",
},
{
name: "peaking",
temps: []float64{62, 78, 65},
wantTrend: "peaking",
wantPeak: "upper 70s",
},
{
name: "steady same band",
temps: []float64{77, 78},
wantTrend: "steady",
wantSteady: "upper 70s",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
summary := derivedDaypartWithTemperatures("afternoon", "2026-05-29T12:00:00-05:00", "sunny", test.temps...)
value := derivedDaypartSummaryValue(summary, "America/Chicago")
if value.DominantConditionDisplay != "Sunny" {
t.Fatalf("DominantConditionDisplay = %q, want Sunny", value.DominantConditionDisplay)
}
if value.TemperatureTrend != test.wantTrend ||
value.TemperatureStartPhraseF != test.wantStart ||
value.TemperatureEndPhraseF != test.wantEnd ||
value.TemperaturePeakPhraseF != test.wantPeak ||
value.TemperatureSteadyPhraseF != test.wantSteady {
t.Fatalf("temperature presentation = %#v", value)
}
})
}
}
func TestTemperaturePhraseF(t *testing.T) {
tests := []struct {
name string
value forecast.Range
want string
}{
{
name: "single low band",
value: forecast.Range{Min: floatPtr(71), Max: floatPtr(73)},
want: "low 70s",
},
{
name: "single upper value",
value: forecast.Range{Min: floatPtr(68), Max: floatPtr(68)},
want: "upper 60s",
},
{
name: "range across bands",
value: forecast.Range{Min: floatPtr(68), Max: floatPtr(75)},
want: "upper 60s to mid 70s",
},
{
name: "max only",
value: forecast.Range{Max: floatPtr(84)},
want: "mid 80s",
},
{
name: "empty",
value: forecast.Range{},
want: "",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := temperaturePhraseF(test.value); got != test.want {
t.Fatalf("temperaturePhraseF() = %q, want %q", got, test.want)
}
})
}
}
func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) { func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyTomorrow) ctx := derivedModuleContext(report.Tomorrow)
outdoorOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows}) outdoorOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows})
if err != nil { if err != nil {
@@ -191,6 +433,12 @@ func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing
if outdoor.Best.Daypart != "overnight" || outdoor.Worst.Daypart != "afternoon" { if outdoor.Best.Daypart != "overnight" || outdoor.Worst.Daypart != "afternoon" {
t.Fatalf("outdoor windows = %#v, want quiet overnight and stormy afternoon", outdoor) t.Fatalf("outdoor windows = %#v, want quiet overnight and stormy afternoon", outdoor)
} }
if outdoor.Best.PeriodBegins != "2026-05-29 at 12:00 AM" || outdoor.Best.PeriodEnds != "2026-05-29 at 6:00 AM" {
t.Fatalf("best outdoor period = %#v, want overnight period labels", outdoor.Best)
}
if outdoor.Worst.PeriodBegins != "2026-05-29 at 12:00 PM" || outdoor.Worst.PeriodEnds != "2026-05-29 at 6:00 PM" {
t.Fatalf("worst outdoor period = %#v, want afternoon period labels", outdoor.Worst)
}
planningOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TomorrowPlanning}) planningOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TomorrowPlanning})
if err != nil { if err != nil {
@@ -209,9 +457,154 @@ func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing
} }
} }
func TestDailyPlanningModulePackagesPlanningFields(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := dailyModuleContext()
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning})
if err != nil {
t.Fatalf("BuildModule(daily planning) error = %v", err)
}
if output.ID != module.DailyPlanning || output.StanzaName != "daily_planning" {
t.Fatalf("output = %#v, want daily planning stanza", output)
}
planning := moduleValue[DailyPlanningModule](t, output)
if len(planning.MorningReadiness) == 0 ||
len(planning.CommuteSchoolWorkdayConcerns) == 0 ||
len(planning.OvernightChangeWatch) == 0 {
t.Fatalf("daily planning = %#v, want populated planning fields", planning)
}
if !containsString(planning.MorningReadiness, "Morning precipitation chance peaks near 60%.") {
t.Fatalf("MorningReadiness = %#v, want precipitation readiness note", planning.MorningReadiness)
}
if !containsString(planning.CommuteSchoolWorkdayConcerns, "Afternoon alert overlap needs attention.") {
t.Fatalf("CommuteSchoolWorkdayConcerns = %#v, want alert-overlap concern", planning.CommuteSchoolWorkdayConcerns)
}
if !containsString(planning.OvernightChangeWatch, "Watch for forecast timing or intensity adjustments overnight.") {
t.Fatalf("OvernightChangeWatch = %#v, want overnight fallback note", planning.OvernightChangeWatch)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("marshal daily planning: %v", err)
}
jsonText := string(data)
for _, field := range []string{"morning_readiness", "commute_school_workday_concerns", "overnight_change_watch"} {
if !strings.Contains(jsonText, field) {
t.Fatalf("daily planning json = %s, want field %s", jsonText, field)
}
}
if strings.Contains(jsonText, "tomorrow_planning") || strings.Contains(jsonText, "morningReadiness") {
t.Fatalf("daily planning json = %s, want daily snake_case fields only", jsonText)
}
}
func TestDailyPlanningModuleRejectsUnsupportedReports(t *testing.T) {
registry := MustDefaultModuleRegistry()
for _, id := range []report.ID{report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm} {
t.Run(string(id), func(t *testing.T) {
ctx := derivedModuleContext(id)
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning})
if err == nil || !strings.Contains(err.Error(), `module "daily_planning" is not compatible with report`) {
t.Fatalf("BuildModule(%s) error = %v, want incompatible report", id, err)
}
})
}
}
func TestDailyPlanningModuleHandlesMissingDailySummary(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := dailyModuleContext()
ctx.Derived.DailySummaries = nil
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning})
if err != nil {
t.Fatalf("BuildModule(daily planning) error = %v", err)
}
planning := moduleValue[DailyPlanningModule](t, output)
if len(planning.MorningReadiness) != 0 ||
len(planning.CommuteSchoolWorkdayConcerns) != 0 ||
len(planning.OvernightChangeWatch) != 0 {
t.Fatalf("daily planning = %#v, want empty output without daily summary", planning)
}
}
func TestTodayPlanningModulePackagesPlanningFields(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := todayModuleContext()
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning})
if err != nil {
t.Fatalf("BuildModule(today planning) error = %v", err)
}
if output.ID != module.TodayPlanning || output.StanzaName != "today_planning" {
t.Fatalf("output = %#v, want today planning stanza", output)
}
planning := moduleValue[TodayPlanningModule](t, output)
if len(planning.MorningReadiness) == 0 ||
len(planning.CommuteSchoolWorkdayConcerns) == 0 ||
len(planning.OutdoorPlanning) == 0 ||
len(planning.LateDayChangeWatch) == 0 {
t.Fatalf("today planning = %#v, want populated planning fields", planning)
}
if !containsString(planning.MorningReadiness, "Morning precipitation chance peaks near 60%.") {
t.Fatalf("MorningReadiness = %#v, want precipitation readiness note", planning.MorningReadiness)
}
if !containsString(planning.OutdoorPlanning, "Best outdoor window: Overnight (cold risk).") ||
!containsString(planning.OutdoorPlanning, "Toughest outdoor window: Afternoon (high precipitation chance, gusty wind, alert overlap, heat risk).") {
t.Fatalf("OutdoorPlanning = %#v, want deterministic best and toughest windows", planning.OutdoorPlanning)
}
if !containsString(planning.LateDayChangeWatch, "Afternoon precipitation timing may shift; current peak is near 80%.") {
t.Fatalf("LateDayChangeWatch = %#v, want late-day change note", planning.LateDayChangeWatch)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("marshal today planning: %v", err)
}
jsonText := string(data)
for _, field := range []string{"morning_readiness", "commute_school_workday_concerns", "outdoor_planning", "late_day_change_watch"} {
if !strings.Contains(jsonText, field) {
t.Fatalf("today planning json = %s, want field %s", jsonText, field)
}
}
if strings.Contains(jsonText, "morningReadiness") || strings.Contains(jsonText, "lateDayChangeWatch") {
t.Fatalf("today planning json = %s, want snake_case fields", jsonText)
}
}
func TestTodayPlanningModuleRejectsUnsupportedReports(t *testing.T) {
registry := MustDefaultModuleRegistry()
for _, id := range []report.ID{report.Tomorrow, report.Daily} {
t.Run(string(id), func(t *testing.T) {
ctx := derivedModuleContext(id)
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning})
if err == nil || !strings.Contains(err.Error(), `module "today_planning" is not compatible with report`) {
t.Fatalf("BuildModule(%s) error = %v, want incompatible report", id, err)
}
})
}
}
func TestTodayPlanningModuleHandlesMissingDailySummary(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := todayModuleContext()
ctx.Derived.DailySummaries = nil
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning})
if err != nil {
t.Fatalf("BuildModule(today planning) error = %v", err)
}
planning := moduleValue[TodayPlanningModule](t, output)
if len(planning.MorningReadiness) != 0 ||
len(planning.CommuteSchoolWorkdayConcerns) != 0 ||
len(planning.OutdoorPlanning) != 0 ||
len(planning.LateDayChangeWatch) != 0 {
t.Fatalf("today planning = %#v, want empty output without daily summary", planning)
}
}
func TestDerivedModulesHandleMissingData(t *testing.T) { func TestDerivedModulesHandleMissingData(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.DailyToday) ctx := derivedModuleContext(report.Daily)
ctx.Derived.DailySummaries = nil ctx.Derived.DailySummaries = nil
ctx.Derived.DaypartSummaries = nil ctx.Derived.DaypartSummaries = nil
@@ -302,6 +695,26 @@ func derivedModuleContext(id report.ID) ModuleContext {
} }
} }
func todayModuleContext() ModuleContext {
ctx := derivedModuleContext(report.Daily)
ctx.Resolved.Definition = report.Definition{
ID: report.Today,
Name: "Today Report",
PromptID: "weather.today_generated_text",
}
return ctx
}
func dailyModuleContext() ModuleContext {
ctx := derivedModuleContext(report.Tomorrow)
ctx.Resolved.Definition = report.Definition{
ID: report.Daily,
Name: "Daily Report",
PromptID: "weather.daily_generated_text",
}
return ctx
}
func derivedDaypart(name string, start string, end string, text string, temperature float64, apparent *float64, precip float64, gust float64) forecast.DaypartSummary { func derivedDaypart(name string, start string, end string, text string, temperature float64, apparent *float64, precip float64, gust float64) forecast.DaypartSummary {
hour := derivedHour(start, text, precip, temperature, apparent, gust) hour := derivedHour(start, text, precip, temperature, apparent, gust)
return forecast.SummarizeDaypart(name, timeutil.Period{ return forecast.SummarizeDaypart(name, timeutil.Period{
@@ -310,6 +723,24 @@ func derivedDaypart(name string, start string, end string, text string, temperat
}, []weatherdata.ForecastPeriod{hour}) }, []weatherdata.ForecastPeriod{hour})
} }
func derivedDaypartWithTemperatures(name string, start string, text string, temperatures ...float64) forecast.DaypartSummary {
startTime := mustParseModuleTime(start)
periods := make([]weatherdata.ForecastPeriod, 0, len(temperatures))
for index, temperature := range temperatures {
periodStart := startTime.Add(time.Duration(index) * time.Hour)
periods = append(periods, weatherdata.ForecastPeriod{
StartTime: periodStart,
EndTime: periodStart.Add(time.Hour),
TextDescription: text,
TemperatureF: floatPtr(temperature),
})
}
return forecast.SummarizeDaypart(name, timeutil.Period{
Start: startTime,
End: startTime.Add(time.Duration(len(temperatures)) * time.Hour),
}, periods)
}
func derivedHour(start string, text string, precip float64, temperature float64, apparent *float64, gust float64) weatherdata.ForecastPeriod { func derivedHour(start string, text string, precip float64, temperature float64, apparent *float64, gust float64) weatherdata.ForecastPeriod {
startTime := mustParseModuleTime(start) startTime := mustParseModuleTime(start)
endTime := startTime.Add(time.Hour) endTime := startTime.Add(time.Hour)

View File

@@ -1,12 +1,16 @@
package briefing package briefing
import ( import (
"strings"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
const DefaultHourlyForecastPrecipMentionProbabilityThreshold = 20
type HourlyForecastModule struct { type HourlyForecastModule struct {
Product string `json:"product,omitempty"` Product string `json:"product,omitempty"`
IssuedAt time.Time `json:"issued_at,omitempty"` IssuedAt time.Time `json:"issued_at,omitempty"`
@@ -16,9 +20,57 @@ type HourlyForecastModule struct {
Periods []HourlyForecastPeriod `json:"periods,omitempty"` Periods []HourlyForecastPeriod `json:"periods,omitempty"`
} }
type HourlyForecastPromptExport struct {
Product string `json:"product,omitempty"`
IssuedAt time.Time `json:"issued_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
SourceLocation string `json:"source_location,omitempty"`
SourceLocationID string `json:"source_location_id,omitempty"`
Periods []HourlyForecastPromptPeriod `json:"periods,omitempty"`
}
type HourlyForecastPeriod struct { type HourlyForecastPeriod struct {
StartTime string `json:"start_time,omitempty"` HourLabel string `json:"hour_label,omitempty"`
EndTime string `json:"end_time,omitempty"` PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
Name string `json:"name,omitempty"`
IsDay *bool `json:"is_day,omitempty"`
ConditionCode *int `json:"condition_code,omitempty"`
TextDescription string `json:"text_description,omitempty"`
TextDescriptionLower string `json:"text_description_lower,omitempty"`
TemperatureC *float64 `json:"temperature_c,omitempty"`
TemperatureF *float64 `json:"temperature_f,omitempty"`
TemperatureCMin *float64 `json:"temperature_c_min,omitempty"`
TemperatureFMin *float64 `json:"temperature_f_min,omitempty"`
TemperatureCMax *float64 `json:"temperature_c_max,omitempty"`
TemperatureFMax *float64 `json:"temperature_f_max,omitempty"`
DewpointC *float64 `json:"dewpoint_c,omitempty"`
DewpointF *float64 `json:"dewpoint_f,omitempty"`
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
WindGustKmh *float64 `json:"wind_gust_kmh,omitempty"`
WindGustMph *float64 `json:"wind_gust_mph,omitempty"`
WindDirection string `json:"wind_direction,omitempty"`
BarometricPressurePa *float64 `json:"barometric_pressure_pa,omitempty"`
BarometricPressureInHg *float64 `json:"barometric_pressure_in_hg,omitempty"`
VisibilityMeters *float64 `json:"visibility_meters,omitempty"`
VisibilityMiles *float64 `json:"visibility_miles,omitempty"`
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"`
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"`
CloudCoverPercent *float64 `json:"cloud_cover_percent,omitempty"`
ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"`
MentionPrecipitation bool `json:"mention_precipitation,omitempty"`
PrecipitationAmountMm *float64 `json:"precipitation_amount_mm,omitempty"`
PrecipitationAmountIn *float64 `json:"precipitation_amount_in,omitempty"`
SnowfallDepthMM *float64 `json:"snowfall_depth_mm,omitempty"`
SnowfallDepthIn *float64 `json:"snowfall_depth_in,omitempty"`
UVIndex *float64 `json:"uv_index,omitempty"`
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
}
type HourlyForecastPromptPeriod struct {
PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
IsDay *bool `json:"is_day,omitempty"` IsDay *bool `json:"is_day,omitempty"`
ConditionCode *int `json:"condition_code,omitempty"` ConditionCode *int `json:"condition_code,omitempty"`
@@ -71,12 +123,30 @@ func buildHourlyForecastModule(ctx ModuleContext, _ any) (*module.Output, error)
return &module.Output{ID: module.HourlyForecast, StanzaName: "hourly_forecast", Value: value}, nil return &module.Output{ID: module.HourlyForecast, StanzaName: "hourly_forecast", Value: value}, nil
} }
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod { func exportHourlyForecastPromptValue(value any) (any, error) {
out := make([]HourlyForecastPeriod, 0, len(periods)) rich, ok := value.(HourlyForecastModule)
if !ok {
return nil, unexpectedPromptExportValue(value, HourlyForecastModule{})
}
return HourlyForecastPromptExport{
Product: rich.Product,
IssuedAt: rich.IssuedAt,
UpdatedAt: copyTime(rich.UpdatedAt),
SourceLocation: rich.SourceLocation,
SourceLocationID: rich.SourceLocationID,
Periods: hourlyForecastPromptPeriods(rich.Periods),
}, nil
}
func hourlyForecastPromptPeriods(periods []HourlyForecastPeriod) []HourlyForecastPromptPeriod {
if len(periods) == 0 {
return nil
}
out := make([]HourlyForecastPromptPeriod, 0, len(periods))
for _, period := range periods { for _, period := range periods {
out = append(out, HourlyForecastPeriod{ out = append(out, HourlyForecastPromptPeriod{
StartTime: friendlyDateTimeLabel(period.StartTime, timezone), PeriodBegins: period.PeriodBegins,
EndTime: friendlyDateTimeLabel(period.EndTime, timezone), PeriodEnds: period.PeriodEnds,
Name: period.Name, Name: period.Name,
IsDay: copyBool(period.IsDay), IsDay: copyBool(period.IsDay),
ConditionCode: copyInt(period.ConditionCode), ConditionCode: copyInt(period.ConditionCode),
@@ -93,6 +163,55 @@ func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string
WindSpeedMph: copyFloat(period.WindSpeedMph), WindSpeedMph: copyFloat(period.WindSpeedMph),
WindGustKmh: copyFloat(period.WindGustKmh), WindGustKmh: copyFloat(period.WindGustKmh),
WindGustMph: copyFloat(period.WindGustMph), WindGustMph: copyFloat(period.WindGustMph),
WindDirection: period.WindDirection,
BarometricPressurePa: copyFloat(period.BarometricPressurePa),
BarometricPressureInHg: copyFloat(period.BarometricPressureInHg),
VisibilityMeters: copyFloat(period.VisibilityMeters),
VisibilityMiles: copyFloat(period.VisibilityMiles),
ApparentTemperatureC: copyFloat(period.ApparentTemperatureC),
ApparentTemperatureF: copyFloat(period.ApparentTemperatureF),
CloudCoverPercent: copyFloat(period.CloudCoverPercent),
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
PrecipitationAmountMm: copyFloat(period.PrecipitationAmountMm),
PrecipitationAmountIn: copyFloat(period.PrecipitationAmountIn),
SnowfallDepthMM: copyFloat(period.SnowfallDepthMM),
SnowfallDepthIn: copyFloat(period.SnowfallDepthIn),
UVIndex: copyFloat(period.UVIndex),
RelativeHumidityPercent: copyFloat(period.RelativeHumidityPercent),
})
}
return out
}
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod {
return hourlyForecastPeriodsWithPrecipMentionThreshold(periods, timezone, DefaultHourlyForecastPrecipMentionProbabilityThreshold)
}
func hourlyForecastPeriodsWithPrecipMentionThreshold(periods []weatherdata.ForecastPeriod, timezone string, threshold float64) []HourlyForecastPeriod {
out := make([]HourlyForecastPeriod, 0, len(periods))
for _, period := range periods {
validPeriod := timeutil.Period{Start: period.StartTime, End: period.EndTime}
out = append(out, HourlyForecastPeriod{
HourLabel: hourMinuteLabel(period.StartTime, timezone),
PeriodBegins: friendlyPeriodBeginsLabel(validPeriod, timezone),
PeriodEnds: friendlyPeriodEndsLabel(validPeriod, timezone),
Name: period.Name,
IsDay: copyBool(period.IsDay),
ConditionCode: copyInt(period.ConditionCode),
TextDescription: period.TextDescription,
TextDescriptionLower: strings.ToLower(period.TextDescription),
TemperatureC: copyFloat(period.TemperatureC),
TemperatureF: copyFloat(period.TemperatureF),
TemperatureCMin: copyFloat(period.TemperatureCMin),
TemperatureFMin: copyFloat(period.TemperatureFMin),
TemperatureCMax: copyFloat(period.TemperatureCMax),
TemperatureFMax: copyFloat(period.TemperatureFMax),
DewpointC: copyFloat(period.DewpointC),
DewpointF: copyFloat(period.DewpointF),
WindSpeedKmh: copyFloat(period.WindSpeedKmh),
WindSpeedMph: copyFloat(period.WindSpeedMph),
WindGustKmh: copyFloat(period.WindGustKmh),
WindGustMph: copyFloat(period.WindGustMph),
WindDirection: windDirectionLabel(period.WindDirectionDegrees), WindDirection: windDirectionLabel(period.WindDirectionDegrees),
BarometricPressurePa: copyFloat(period.BarometricPressurePa), BarometricPressurePa: copyFloat(period.BarometricPressurePa),
BarometricPressureInHg: copyFloat(period.BarometricPressureInHg), BarometricPressureInHg: copyFloat(period.BarometricPressureInHg),
@@ -102,6 +221,7 @@ func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string
ApparentTemperatureF: copyFloat(period.ApparentTemperatureF), ApparentTemperatureF: copyFloat(period.ApparentTemperatureF),
CloudCoverPercent: copyFloat(period.CloudCoverPercent), CloudCoverPercent: copyFloat(period.CloudCoverPercent),
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent), ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
MentionPrecipitation: mentionHourlyForecastPrecipitation(period.ProbabilityOfPrecipitationPercent, threshold),
PrecipitationAmountMm: copyFloat(period.PrecipitationAmountMm), PrecipitationAmountMm: copyFloat(period.PrecipitationAmountMm),
PrecipitationAmountIn: copyFloat(period.PrecipitationAmountIn), PrecipitationAmountIn: copyFloat(period.PrecipitationAmountIn),
SnowfallDepthMM: copyFloat(period.SnowfallDepthMM), SnowfallDepthMM: copyFloat(period.SnowfallDepthMM),
@@ -113,6 +233,10 @@ func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string
return out return out
} }
func mentionHourlyForecastPrecipitation(probability *float64, threshold float64) bool {
return probability != nil && *probability >= threshold
}
func (v HourlyForecastModule) isEmpty() bool { func (v HourlyForecastModule) isEmpty() bool {
return v.Product == "" && return v.Product == "" &&
v.IssuedAt.IsZero() && v.IssuedAt.IsZero() &&

View File

@@ -60,6 +60,36 @@ func windDirectionLabel(degrees *float64) string {
return labels[sector] return labels[sector]
} }
func windDirectionTextLabel(degrees *float64) string {
if degrees == nil {
return ""
}
labels := []string{
"north",
"north-northeast",
"northeast",
"east-northeast",
"east",
"east-southeast",
"southeast",
"south-southeast",
"south",
"south-southwest",
"southwest",
"west-southwest",
"west",
"west-northwest",
"northwest",
"north-northwest",
}
normalized := math.Mod(*degrees, 360)
if normalized < 0 {
normalized += 360
}
sector := int(math.Floor((normalized+11.25)/22.5)) % len(labels)
return labels[sector]
}
func timedClockLabel(value *forecast.TimedValue, timezone string) string { func timedClockLabel(value *forecast.TimedValue, timezone string) string {
if value == nil { if value == nil {
return "" return ""
@@ -67,18 +97,18 @@ func timedClockLabel(value *forecast.TimedValue, timezone string) string {
return clockLabel(value.Time, timezone) return clockLabel(value.Time, timezone)
} }
func periodClockLabel(period timeutil.Period, timezone string) string { func friendlyPeriodBeginsLabel(period timeutil.Period, timezone string) string {
if !period.IsValid() { if !period.IsValid() {
return "" return ""
} }
return clockLabel(period.Start, timezone) + "-" + clockLabel(period.End, timezone) return friendlyDateTimeLabel(period.Start, timezone)
} }
func friendlyPeriodLabel(period timeutil.Period, timezone string) string { func friendlyPeriodEndsLabel(period timeutil.Period, timezone string) string {
if !period.IsValid() { if !period.IsValid() {
return "" return ""
} }
return friendlyDateTimeLabel(period.Start, timezone) + " to " + friendlyDateTimeLabel(period.End, timezone) return friendlyDateTimeLabel(period.End, timezone)
} }
func friendlyDateTimeLabel(value time.Time, timezone string) string { func friendlyDateTimeLabel(value time.Time, timezone string) string {
@@ -126,3 +156,11 @@ func clockLabel(value time.Time, timezone string) string {
} }
return label return label
} }
func hourMinuteLabel(value time.Time, timezone string) string {
location, err := timeutil.LoadLocation(timezone)
if err != nil {
location = time.UTC
}
return value.In(location).Format("3:04 PM")
}

View File

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

View File

@@ -21,6 +21,8 @@ type ModuleContext struct {
type ModuleBuilder func(ModuleContext, any) (*module.Output, error) type ModuleBuilder func(ModuleContext, any) (*module.Output, error)
type ModulePromptExporter func(value any) (any, error)
type ModuleDefinition struct { type ModuleDefinition struct {
ID module.ID ID module.ID
StanzaName string StanzaName string
@@ -31,12 +33,17 @@ type ModuleDefinition struct {
MissingData module.MissingDataBehavior MissingData module.MissingDataBehavior
AllowDuplicate bool AllowDuplicate bool
Builder ModuleBuilder Builder ModuleBuilder
PromptExporter ModulePromptExporter
} }
type ModuleRegistry struct { type ModuleRegistry struct {
definitions map[module.ID]ModuleDefinition definitions map[module.ID]ModuleDefinition
} }
func unexpectedPromptExportValue(got any, want any) error {
return fmt.Errorf("value has type %T, want %T", got, want)
}
func DefaultModuleRegistry() (ModuleRegistry, error) { func DefaultModuleRegistry() (ModuleRegistry, error) {
return NewModuleRegistry(defaultModuleDefinitions()) return NewModuleRegistry(defaultModuleDefinitions())
} }
@@ -130,6 +137,15 @@ func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (
if output.StanzaName != definition.StanzaName { if output.StanzaName != definition.StanzaName {
return nil, fmt.Errorf("module %q produced stanza %q, want %q", definition.ID, output.StanzaName, definition.StanzaName) return nil, fmt.Errorf("module %q produced stanza %q, want %q", definition.ID, output.StanzaName, definition.StanzaName)
} }
if definition.PromptExporter == nil {
output.PromptValue = output.Value
return output, nil
}
promptValue, err := definition.PromptExporter(output.Value)
if err != nil {
return nil, fmt.Errorf("module %q stanza %q prompt export: %w", definition.ID, definition.StanzaName, err)
}
output.PromptValue = promptValue
return output, nil return output, nil
} }
@@ -162,6 +178,8 @@ func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContex
return ctx.Collected.Discussion != nil return ctx.Collected.Discussion != nil
case module.CollectedWeatherStory: case module.CollectedWeatherStory:
return ctx.Collected.WeatherStory != nil return ctx.Collected.WeatherStory != nil
case module.CollectedSPCConvectiveOutlooks:
return ctx.Collected.SPCConvectiveOutlooks != nil
case module.CollectedSourceMetadata: case module.CollectedSourceMetadata:
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0 return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
default: default:
@@ -183,6 +201,8 @@ func derivedFactAvailable(requirement module.FactRequirement, ctx ModuleContext)
return len(ctx.Derived.DaypartSummaries) > 0 return len(ctx.Derived.DaypartSummaries) > 0
case module.RequiresDerivedPrecipTiming: case module.RequiresDerivedPrecipTiming:
return true return true
case module.RequiresDerivedSPCConvectiveOutlooks:
return ctx.Derived.SPCConvectiveOutlooks != nil
default: default:
return false return false
} }
@@ -245,8 +265,8 @@ func (d ModuleDefinition) ValidateOptions(options any) error {
} }
func defaultModuleDefinitions() []ModuleDefinition { func defaultModuleDefinitions() []ModuleDefinition {
allReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend, report.Storm} allReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm}
daypartReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend} daypartReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.ThreeDay, report.Weekend}
return []ModuleDefinition{ return []ModuleDefinition{
{ {
ID: module.Metadata, ID: module.Metadata,
@@ -265,6 +285,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
SupportedReports: allReports, SupportedReports: allReports,
MissingData: module.MissingDataOmit, MissingData: module.MissingDataOmit,
Builder: buildCurrentConditionsModule, Builder: buildCurrentConditionsModule,
PromptExporter: exportCurrentConditionsPromptValue,
}, },
{ {
ID: module.NarrativeForecast, ID: module.NarrativeForecast,
@@ -272,7 +293,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
DefaultOptions: module.NarrativeForecastOptions{}, DefaultOptions: module.NarrativeForecastOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast}, RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods}, RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods},
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow}, SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
MissingData: module.MissingDataOmit, MissingData: module.MissingDataOmit,
Builder: buildNarrativeForecastModule, Builder: buildNarrativeForecastModule,
}, },
@@ -282,16 +303,17 @@ func defaultModuleDefinitions() []ModuleDefinition {
DefaultOptions: module.HourlyForecastOptions{}, DefaultOptions: module.HourlyForecastOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast}, RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods}, RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow}, SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly},
MissingData: module.MissingDataOmit, MissingData: module.MissingDataOmit,
Builder: buildHourlyForecastModule, Builder: buildHourlyForecastModule,
PromptExporter: exportHourlyForecastPromptValue,
}, },
{ {
ID: module.DerivedDailySummary, ID: module.DerivedDailySummary,
StanzaName: "derived_daily_summary", StanzaName: "derived_daily_summary",
DefaultOptions: module.DerivedDailySummaryOptions{}, DefaultOptions: module.DerivedDailySummaryOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming}, RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming},
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow}, SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
MissingData: module.MissingDataError, MissingData: module.MissingDataError,
Builder: buildDerivedDailySummaryModule, Builder: buildDerivedDailySummaryModule,
}, },
@@ -303,12 +325,13 @@ func defaultModuleDefinitions() []ModuleDefinition {
SupportedReports: daypartReports, SupportedReports: daypartReports,
MissingData: module.MissingDataError, MissingData: module.MissingDataError,
Builder: buildDerivedDaypartSummariesModule, Builder: buildDerivedDaypartSummariesModule,
PromptExporter: exportDerivedDaypartSummariesPromptValue,
}, },
{ {
ID: module.PrecipTiming, ID: module.PrecipTiming,
StanzaName: "precip_timing", StanzaName: "precip_timing",
DefaultOptions: module.PrecipTimingOptions{}, DefaultOptions: module.PrecipTimingOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries}, RequiredDerived: []module.FactRequirement{module.RequiresDerivedPrecipTiming},
SupportedReports: allReports, SupportedReports: allReports,
MissingData: module.MissingDataEmpty, MissingData: module.MissingDataEmpty,
Builder: buildPrecipTimingModule, Builder: buildPrecipTimingModule,
@@ -323,6 +346,16 @@ func defaultModuleDefinitions() []ModuleDefinition {
MissingData: module.MissingDataEmpty, MissingData: module.MissingDataEmpty,
Builder: buildAlertDigestModule, Builder: buildAlertDigestModule,
}, },
{
ID: module.SPCConvectiveOutlooks,
StanzaName: string(module.SPCConvectiveOutlooks),
DefaultOptions: module.SPCConvectiveOutlooksOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
SupportedReports: allReports,
MissingData: module.MissingDataEmpty,
Builder: buildSPCConvectiveOutlooksModule,
},
{ {
ID: module.AreaForecastDiscussion, ID: module.AreaForecastDiscussion,
StanzaName: "area_forecast_discussion", StanzaName: "area_forecast_discussion",
@@ -332,6 +365,16 @@ func defaultModuleDefinitions() []ModuleDefinition {
MissingData: module.MissingDataOmit, MissingData: module.MissingDataOmit,
Builder: buildAreaForecastDiscussionModule, Builder: buildAreaForecastDiscussionModule,
}, },
{
ID: module.SPCConvectiveDiscussion,
StanzaName: string(module.SPCConvectiveDiscussion),
DefaultOptions: module.SPCConvectiveDiscussionOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
SupportedReports: allReports,
MissingData: module.MissingDataOmit,
Builder: buildSPCConvectiveDiscussionModule,
},
{ {
ID: module.WeatherStory, ID: module.WeatherStory,
StanzaName: "weather_story", StanzaName: "weather_story",
@@ -350,14 +393,32 @@ func defaultModuleDefinitions() []ModuleDefinition {
MissingData: module.MissingDataEmpty, MissingData: module.MissingDataEmpty,
Builder: buildOutdoorWindowsModule, Builder: buildOutdoorWindowsModule,
}, },
{
ID: module.TodayPlanning,
StanzaName: "today_planning",
DefaultOptions: module.TodayPlanningOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
SupportedReports: []report.ID{report.Today},
MissingData: module.MissingDataEmpty,
Builder: buildTodayPlanningModule,
},
{ {
ID: module.TomorrowPlanning, ID: module.TomorrowPlanning,
StanzaName: "tomorrow_planning", StanzaName: "tomorrow_planning",
DefaultOptions: module.TomorrowPlanningOptions{}, DefaultOptions: module.TomorrowPlanningOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries}, RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
SupportedReports: []report.ID{report.DailyTomorrow}, SupportedReports: []report.ID{report.Tomorrow},
MissingData: module.MissingDataEmpty, MissingData: module.MissingDataEmpty,
Builder: buildTomorrowPlanningModule, Builder: buildTomorrowPlanningModule,
}, },
{
ID: module.DailyPlanning,
StanzaName: "daily_planning",
DefaultOptions: module.DailyPlanningOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
SupportedReports: []report.ID{report.Daily},
MissingData: module.MissingDataEmpty,
Builder: buildDailyPlanningModule,
},
} }
} }

View File

@@ -1,11 +1,15 @@
package briefing package briefing
import ( import (
"encoding/json"
"errors"
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) { func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) {
@@ -38,6 +42,9 @@ func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
t.Fatalf("BuildModule(%s) error = %v", item.ID, err) t.Fatalf("BuildModule(%s) error = %v", item.ID, err)
} }
if output != nil { if output != nil {
if output.DataPackageValue() == nil {
t.Fatalf("BuildModule(%s) data package value = nil", item.ID)
}
outputs = append(outputs, *output) outputs = append(outputs, *output)
} }
} }
@@ -52,9 +59,234 @@ func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
} }
} }
func TestDefaultModuleDefinitionsDeclarePromptExportPolicy(t *testing.T) {
customExporters := map[module.ID]struct{}{
module.CurrentConditions: {},
module.HourlyForecast: {},
module.DerivedDaypartSummaries: {},
}
passThroughExporters := map[module.ID]struct{}{
module.Metadata: {},
module.NarrativeForecast: {},
module.DerivedDailySummary: {},
module.PrecipTiming: {},
module.AlertDigest: {},
module.SPCConvectiveOutlooks: {},
module.AreaForecastDiscussion: {},
module.SPCConvectiveDiscussion: {},
module.WeatherStory: {},
module.OutdoorWindows: {},
module.TodayPlanning: {},
module.TomorrowPlanning: {},
module.DailyPlanning: {},
}
for _, definition := range defaultModuleDefinitions() {
_, custom := customExporters[definition.ID]
_, passThrough := passThroughExporters[definition.ID]
if custom == passThrough {
t.Fatalf("module %q exporter policy custom=%v passThrough=%v, want exactly one policy", definition.ID, custom, passThrough)
}
if custom && definition.PromptExporter == nil {
t.Fatalf("module %q PromptExporter = nil, want custom prompt exporter", definition.ID)
}
if passThrough && definition.PromptExporter != nil {
t.Fatalf("module %q PromptExporter is set, want default pass-through", definition.ID)
}
}
}
func TestModuleRegistryAddsPassThroughPromptValue(t *testing.T) {
registry, err := NewModuleRegistry([]ModuleDefinition{
{
ID: module.Metadata,
StanzaName: "metadata",
Builder: func(ModuleContext, any) (*module.Output, error) {
return &module.Output{
ID: module.Metadata,
StanzaName: "metadata",
Value: testRegistryValue{Message: "rich"},
}, nil
},
},
})
if err != nil {
t.Fatalf("NewModuleRegistry() error = %v", err)
}
output, err := registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
if output == nil {
t.Fatal("BuildModule() output = nil, want output")
}
if output.PromptValue != output.Value {
t.Fatalf("PromptValue = %#v, want pass-through rich value %#v", output.PromptValue, output.Value)
}
if output.DataPackageValue() != output.Value {
t.Fatalf("DataPackageValue() = %#v, want rich value", output.DataPackageValue())
}
}
func TestModuleRegistryAddsCustomPromptValue(t *testing.T) {
registry, err := NewModuleRegistry([]ModuleDefinition{
{
ID: module.Metadata,
StanzaName: "metadata",
Builder: func(ModuleContext, any) (*module.Output, error) {
return &module.Output{
ID: module.Metadata,
StanzaName: "metadata",
Value: testRegistryValue{Message: "rich"},
}, nil
},
PromptExporter: func(value any) (any, error) {
rich, ok := value.(testRegistryValue)
if !ok {
return nil, errors.New("unexpected rich value type")
}
return testRegistryValue{Message: rich.Message + " prompt"}, nil
},
},
})
if err != nil {
t.Fatalf("NewModuleRegistry() error = %v", err)
}
output, err := registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
got, ok := output.PromptValue.(testRegistryValue)
if !ok {
t.Fatalf("PromptValue type = %T, want testRegistryValue", output.PromptValue)
}
if got.Message != "rich prompt" {
t.Fatalf("PromptValue = %#v, want custom prompt value", got)
}
if output.DataPackageValue() != output.PromptValue {
t.Fatalf("DataPackageValue() = %#v, want custom prompt value", output.DataPackageValue())
}
if output.Value.(testRegistryValue).Message != "rich" {
t.Fatalf("Value = %#v, want rich value unchanged", output.Value)
}
}
func TestModuleRegistryWrapsPromptExporterErrors(t *testing.T) {
registry, err := NewModuleRegistry([]ModuleDefinition{
{
ID: module.Metadata,
StanzaName: "metadata",
Builder: func(ModuleContext, any) (*module.Output, error) {
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: testRegistryValue{Message: "rich"}}, nil
},
PromptExporter: func(any) (any, error) {
return nil, errors.New("unsupported value")
},
},
})
if err != nil {
t.Fatalf("NewModuleRegistry() error = %v", err)
}
_, err = registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
if err == nil ||
!strings.Contains(err.Error(), `module "metadata" stanza "metadata" prompt export`) ||
!strings.Contains(err.Error(), "unsupported value") {
t.Fatalf("BuildModule() error = %v, want wrapped exporter error", err)
}
}
func TestModuleRegistryValidatesOutputBeforePromptExport(t *testing.T) {
called := false
registry, err := NewModuleRegistry([]ModuleDefinition{
{
ID: module.Metadata,
StanzaName: "metadata",
Builder: func(ModuleContext, any) (*module.Output, error) {
return &module.Output{ID: module.CurrentConditions, StanzaName: "metadata", Value: testRegistryValue{Message: "rich"}}, nil
},
PromptExporter: func(any) (any, error) {
called = true
return testRegistryValue{Message: "prompt"}, nil
},
},
})
if err != nil {
t.Fatalf("NewModuleRegistry() error = %v", err)
}
_, err = registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
if err == nil || !strings.Contains(err.Error(), `module "metadata" produced output id "current_conditions"`) {
t.Fatalf("BuildModule() error = %v, want output id validation error", err)
}
if called {
t.Fatal("PromptExporter called before output validation")
}
}
func TestModuleRegistryPromptValueIsNotPersistedInSnapshotJSON(t *testing.T) {
registry, err := NewModuleRegistry([]ModuleDefinition{
{
ID: module.Metadata,
StanzaName: "metadata",
Builder: func(ModuleContext, any) (*module.Output, error) {
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: testRegistryValue{Message: "rich"}}, nil
},
PromptExporter: func(any) (any, error) {
return testRegistryValue{Message: "prompt-only"}, nil
},
},
})
if err != nil {
t.Fatalf("NewModuleRegistry() error = %v", err)
}
output, err := registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
snapshot, err := module.NewSnapshot([]module.Output{*output})
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
data, err := json.Marshal(snapshot)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
text := string(data)
if !strings.Contains(text, `"message":"rich"`) {
t.Fatalf("snapshot JSON missing rich value: %s", text)
}
if strings.Contains(text, "prompt-only") || strings.Contains(text, "promptValue") || strings.Contains(text, "PromptValue") {
t.Fatalf("snapshot JSON includes runtime-only prompt value: %s", text)
}
}
func TestHourlyDefaultModuleOptions(t *testing.T) {
definition := report.DefaultRegistry().MustLookup(report.Hourly)
var found bool
for _, item := range definition.Modules {
if item.ID != module.AreaForecastDiscussion {
continue
}
found = true
options, ok := item.Options.(module.AreaForecastDiscussionOptions)
if !ok {
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", item.Options)
}
if strings.Join(options.Sections, ",") != "key_messages,short_term" {
t.Fatalf("AFD sections = %#v, want key messages and short term", options.Sections)
}
}
if !found {
t.Fatal("hourly default modules missing area_forecast_discussion")
}
}
func TestModuleRegistryRejectsUnknownModule(t *testing.T) { func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.ID("unknown")}}) err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.ID("unknown")}})
if err == nil || !strings.Contains(err.Error(), `unknown module "unknown"`) { if err == nil || !strings.Contains(err.Error(), `unknown module "unknown"`) {
t.Fatalf("error = %v, want unknown module", err) t.Fatalf("error = %v, want unknown module", err)
} }
@@ -62,7 +294,7 @@ func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
func TestModuleRegistryRejectsDuplicateModuleIDs(t *testing.T) { func TestModuleRegistryRejectsDuplicateModuleIDs(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{ err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
{ID: module.Metadata}, {ID: module.Metadata},
{ID: module.Metadata}, {ID: module.Metadata},
}) })
@@ -83,12 +315,84 @@ func TestModuleRegistryRejectsDuplicateStanzaNames(t *testing.T) {
func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) { func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.TomorrowPlanning}}) err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.TomorrowPlanning}})
if err == nil || !strings.Contains(err.Error(), `module "tomorrow_planning" is not compatible with report "daily_today"`) { if err == nil || !strings.Contains(err.Error(), `module "tomorrow_planning" is not compatible with report "daily"`) {
t.Fatalf("error = %v, want incompatible report", err) t.Fatalf("error = %v, want incompatible report", err)
} }
} }
func TestModuleRegistryValidatesTodayPlanningSupport(t *testing.T) {
registry := MustDefaultModuleRegistry()
if err := registry.ValidateComposition(report.Today, []module.ConfigItem{{ID: module.TodayPlanning}}); err != nil {
t.Fatalf("ValidateComposition(today) error = %v", err)
}
for _, id := range []report.ID{report.Tomorrow, report.Daily} {
t.Run(string(id), func(t *testing.T) {
err := registry.ValidateComposition(id, []module.ConfigItem{{ID: module.TodayPlanning}})
if err == nil || !strings.Contains(err.Error(), `module "today_planning" is not compatible with report`) {
t.Fatalf("ValidateComposition(%s) error = %v, want incompatible report", id, err)
}
})
}
}
func TestModuleRegistryValidatesDailyPlanningSupport(t *testing.T) {
registry := MustDefaultModuleRegistry()
if err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.DailyPlanning}}); err != nil {
t.Fatalf("ValidateComposition(daily) error = %v", err)
}
for _, id := range []report.ID{report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm} {
t.Run(string(id), func(t *testing.T) {
err := registry.ValidateComposition(id, []module.ConfigItem{{ID: module.DailyPlanning}})
if err == nil || !strings.Contains(err.Error(), `module "daily_planning" is not compatible with report`) {
t.Fatalf("ValidateComposition(%s) error = %v, want incompatible report", id, err)
}
})
}
}
func TestModuleRegistrySupportsTodayEligibleModules(t *testing.T) {
registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.Today, []module.ConfigItem{
{ID: module.Metadata},
{ID: module.CurrentConditions},
{ID: module.NarrativeForecast},
{ID: module.HourlyForecast},
{ID: module.DerivedDailySummary},
{ID: module.DerivedDaypartSummaries},
{ID: module.PrecipTiming},
{ID: module.AlertDigest},
{ID: module.SPCConvectiveOutlooks},
{ID: module.AreaForecastDiscussion},
{ID: module.SPCConvectiveDiscussion},
{ID: module.WeatherStory},
{ID: module.OutdoorWindows},
})
if err != nil {
t.Fatalf("ValidateComposition(today eligible modules) error = %v", err)
}
}
func TestModuleRegistryRejectsHourlyIncompatibleModules(t *testing.T) {
registry := MustDefaultModuleRegistry()
for _, id := range []module.ID{
module.NarrativeForecast,
module.DerivedDailySummary,
module.DerivedDaypartSummaries,
module.OutdoorWindows,
module.TodayPlanning,
module.TomorrowPlanning,
module.DailyPlanning,
} {
t.Run(string(id), func(t *testing.T) {
err := registry.ValidateComposition(report.Hourly, []module.ConfigItem{{ID: id}})
if err == nil || !strings.Contains(err.Error(), `not compatible with report "hourly"`) {
t.Fatalf("ValidateComposition() error = %v, want incompatible hourly module", err)
}
})
}
}
func TestModuleRegistryRejectsDefinitionsWithoutBuilders(t *testing.T) { func TestModuleRegistryRejectsDefinitionsWithoutBuilders(t *testing.T) {
_, err := NewModuleRegistry([]ModuleDefinition{ _, err := NewModuleRegistry([]ModuleDefinition{
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}}, {ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}},
@@ -109,7 +413,7 @@ func TestModuleRegistryRejectsUnsupportedMissingDataWarn(t *testing.T) {
func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) { func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{ err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
{ID: module.Metadata, Options: module.CurrentConditionsOptions{}}, {ID: module.Metadata, Options: module.CurrentConditionsOptions{}},
}) })
if err == nil || !strings.Contains(err.Error(), `module "metadata" options have type module.CurrentConditionsOptions, want module.MetadataOptions`) { if err == nil || !strings.Contains(err.Error(), `module "metadata" options have type module.CurrentConditionsOptions, want module.MetadataOptions`) {
@@ -119,7 +423,7 @@ func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) {
func TestModuleRegistryAcceptsTypedOptions(t *testing.T) { func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{ err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
{ID: module.Metadata, Options: module.MetadataOptions{}}, {ID: module.Metadata, Options: module.MetadataOptions{}},
{ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}}, {ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}},
}) })
@@ -128,6 +432,42 @@ func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
} }
} }
func TestSPCConvectiveOutlookCollectedRequirementAvailability(t *testing.T) {
ctx := ModuleContext{}
if collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
t.Fatal("collectedFactAvailable() = true, want false without source")
}
ctx.Collected = facts.CollectedFacts{SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{}}
if !collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
t.Fatal("collectedFactAvailable() = false, want true with checked source")
}
}
func TestSPCConvectiveOutlookDerivedRequirementAvailability(t *testing.T) {
ctx := ModuleContext{}
if derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
t.Fatal("derivedFactAvailable() = true, want false without derived outlooks")
}
ctx.Derived = facts.DerivedFacts{SPCConvectiveOutlooks: []weatherdata.ConvectiveOutlook{}}
if !derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
t.Fatal("derivedFactAvailable() = false, want true for checked empty derived outlooks")
}
}
func noopModuleBuilder(ModuleContext, any) (*module.Output, error) { func noopModuleBuilder(ModuleContext, any) (*module.Output, error) {
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil
} }
type testRegistryValue struct {
Message string `json:"message"`
}
func testRegistryModuleContext() ModuleContext {
return ModuleContext{
Resolved: report.Resolved{
Definition: report.DefaultRegistry().MustLookup(report.Daily),
},
}
}

View File

@@ -4,6 +4,7 @@ import (
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
@@ -18,8 +19,8 @@ type NarrativeForecastModule struct {
type NarrativeForecastPeriod struct { type NarrativeForecastPeriod struct {
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
StartTime string `json:"start_time,omitempty"` PeriodBegins string `json:"period_begins,omitempty"`
EndTime string `json:"end_time,omitempty"` PeriodEnds string `json:"period_ends,omitempty"`
IsDay *bool `json:"is_day,omitempty"` IsDay *bool `json:"is_day,omitempty"`
TextDescription string `json:"text_description,omitempty"` TextDescription string `json:"text_description,omitempty"`
TemperatureC *float64 `json:"temperature_c,omitempty"` TemperatureC *float64 `json:"temperature_c,omitempty"`
@@ -58,10 +59,11 @@ func buildNarrativeForecastModule(ctx ModuleContext, _ any) (*module.Output, err
func narrativeForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []NarrativeForecastPeriod { func narrativeForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []NarrativeForecastPeriod {
out := make([]NarrativeForecastPeriod, 0, len(periods)) out := make([]NarrativeForecastPeriod, 0, len(periods))
for _, period := range periods { for _, period := range periods {
validPeriod := timeutil.Period{Start: period.StartTime, End: period.EndTime}
out = append(out, NarrativeForecastPeriod{ out = append(out, NarrativeForecastPeriod{
Name: period.Name, Name: period.Name,
StartTime: friendlyDateTimeLabel(period.StartTime, timezone), PeriodBegins: friendlyPeriodBeginsLabel(validPeriod, timezone),
EndTime: friendlyDateTimeLabel(period.EndTime, timezone), PeriodEnds: friendlyPeriodEndsLabel(validPeriod, timezone),
IsDay: copyBool(period.IsDay), IsDay: copyBool(period.IsDay),
TextDescription: period.TextDescription, TextDescription: period.TextDescription,
TemperatureC: copyFloat(period.TemperatureC), TemperatureC: copyFloat(period.TemperatureC),

View File

@@ -8,31 +8,31 @@ type OutdoorWindowsModule struct {
} }
type OutdoorWindowModule struct { type OutdoorWindowModule struct {
Daypart string `json:"daypart"` Daypart string `json:"daypart"`
Start string `json:"start"` PeriodBegins string `json:"period_begins,omitempty"`
End string `json:"end"` PeriodEnds string `json:"period_ends,omitempty"`
Reasons []string `json:"reasons,omitempty"` Reasons []string `json:"reasons,omitempty"`
Score float64 `json:"score"` Score float64 `json:"score"`
} }
func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) { func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) {
windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries) windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries)
value := OutdoorWindowsModule{ value := OutdoorWindowsModule{
Best: outdoorWindowValue(windows.Best), Best: outdoorWindowValue(windows.Best, ctx.Timezone),
Worst: outdoorWindowValue(windows.Worst), Worst: outdoorWindowValue(windows.Worst, ctx.Timezone),
} }
return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil
} }
func outdoorWindowValue(window *OutdoorWindow) *OutdoorWindowModule { func outdoorWindowValue(window *OutdoorWindow, timezone string) *OutdoorWindowModule {
if window == nil { if window == nil {
return nil return nil
} }
return &OutdoorWindowModule{ return &OutdoorWindowModule{
Daypart: window.Daypart, Daypart: window.Daypart,
Start: window.Start, PeriodBegins: friendlyPeriodBeginsLabel(window.Period, timezone),
End: window.End, PeriodEnds: friendlyPeriodEndsLabel(window.Period, timezone),
Reasons: append([]string(nil), window.Reasons...), Reasons: append([]string(nil), window.Reasons...),
Score: window.Score, Score: window.Score,
} }
} }

View File

@@ -185,9 +185,9 @@ func alertStatus(bundle *weatherdata.Bundle) *AlertStatus {
func variantForReport(id report.ID) string { func variantForReport(id report.ID) string {
switch id { switch id {
case report.DailyToday: case report.Daily, report.Today:
return "today" return "today"
case report.DailyTomorrow: case report.Tomorrow:
return "tomorrow" return "tomorrow"
default: default:
return "" return ""

View File

@@ -14,10 +14,13 @@ type PrecipTimingModule struct {
} }
type PrecipitationWindowModule struct { type PrecipitationWindowModule struct {
Start string `json:"start"` PeriodBegins string `json:"period_begins"`
End string `json:"end,omitempty"` PeriodBeginsHourLabel string `json:"period_begins_hour_label,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"` PeriodEnds string `json:"period_ends,omitempty"`
MaxPopTime string `json:"max_pop_time,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) { func buildPrecipTimingModule(ctx ModuleContext, _ any) (*module.Output, error) {
@@ -36,13 +39,16 @@ func precipTimingValue(timing forecast.PrecipTiming, timezone string) PrecipTimi
} }
for _, window := range timing.PrecipitationWindows { for _, window := range timing.PrecipitationWindows {
item := PrecipitationWindowModule{ item := PrecipitationWindowModule{
Start: clockLabel(window.Start, timezone), PeriodBegins: friendlyDateTimeLabel(window.Start, timezone),
PeriodBeginsHourLabel: hourMinuteLabel(window.Start, timezone),
} }
if window.End != nil { if window.End != nil {
item.End = clockLabel(*window.End, timezone) item.PeriodEnds = friendlyDateTimeLabel(*window.End, timezone)
item.PeriodEndsHourLabel = hourMinuteLabel(*window.End, timezone)
} }
item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value) item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value)
item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone) item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone)
item.MaxPopHourLabel = hourMinuteLabel(window.MaxPrecipitationProbability.Time, timezone)
value.PrecipitationWindows = append(value.PrecipitationWindows, item) value.PrecipitationWindows = append(value.PrecipitationWindows, item)
} }
return value return value

View 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
}

View 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
}

View 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
}

View 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)
}
}

View File

@@ -7,6 +7,7 @@ import (
"strings" "strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
) )
type OutdoorWindows struct { type OutdoorWindows struct {
@@ -16,8 +17,7 @@ type OutdoorWindows struct {
type OutdoorWindow struct { type OutdoorWindow struct {
Daypart string Daypart string
Start string Period timeutil.Period
End string
Reasons []string Reasons []string
Score float64 Score float64
} }
@@ -28,6 +28,19 @@ type TomorrowPlanning struct {
OvernightChangeWatch []string OvernightChangeWatch []string
} }
type morningCommuteOvernightPlanning struct {
MorningReadiness []string
CommuteSchoolWorkdayConcerns []string
OvernightChangeWatch []string
}
type TodayPlanning struct {
MorningReadiness []string
CommuteSchoolWorkdayConcerns []string
OutdoorPlanning []string
LateDayChangeWatch []string
}
func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows { func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
var best *OutdoorWindow var best *OutdoorWindow
var worst *OutdoorWindow var worst *OutdoorWindow
@@ -48,8 +61,60 @@ func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
return OutdoorWindows{Best: best, Worst: worst} return OutdoorWindows{Best: best, Worst: worst}
} }
func buildTodayPlanning(summary *forecast.DailySummary) *TodayPlanning {
planning := &TodayPlanning{}
morning := daypartNamed(summary.Dayparts, "morning")
if morning != nil {
planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...)
}
if len(planning.MorningReadiness) == 0 {
planning.MorningReadiness = append(planning.MorningReadiness, "Morning weather looks routine based on the available hourly forecast.")
}
for _, daypart := range summary.Dayparts {
if daypart.Name == "overnight" || daypart.Name == "evening" {
continue
}
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, concernNotes(daypart)...)
}
for _, alert := range summary.AlertOverlaps {
if alert.Event != "" {
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, "Active alert to plan around: "+alert.Event+".")
}
}
if len(planning.CommuteSchoolWorkdayConcerns) == 0 {
planning.CommuteSchoolWorkdayConcerns = append(planning.CommuteSchoolWorkdayConcerns, "No major commute, school, or workday weather concerns stand out in the available forecast.")
}
planning.OutdoorPlanning = append(planning.OutdoorPlanning, outdoorPlanningNotes(summary.Dayparts)...)
if len(planning.OutdoorPlanning) == 0 {
planning.OutdoorPlanning = append(planning.OutdoorPlanning, "No standout outdoor weather constraints are evident in the available forecast.")
}
for _, name := range []string{"afternoon", "evening"} {
daypart := daypartNamed(summary.Dayparts, name)
if daypart != nil {
planning.LateDayChangeWatch = appendUnique(planning.LateDayChangeWatch, lateDayWatchNotes(*daypart)...)
}
}
if len(planning.LateDayChangeWatch) == 0 {
planning.LateDayChangeWatch = append(planning.LateDayChangeWatch, "Watch for forecast timing or intensity adjustments later today.")
}
return planning
}
func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning { func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning {
planning := &TomorrowPlanning{} base := buildMorningCommuteOvernightPlanning(summary)
return &TomorrowPlanning{
MorningReadiness: append([]string(nil), base.MorningReadiness...),
CommuteSchoolWorkdayConcerns: append([]string(nil), base.CommuteSchoolWorkdayConcerns...),
OvernightChangeWatch: append([]string(nil), base.OvernightChangeWatch...),
}
}
func buildMorningCommuteOvernightPlanning(summary *forecast.DailySummary) *morningCommuteOvernightPlanning {
planning := &morningCommuteOvernightPlanning{}
morning := daypartNamed(summary.Dayparts, "morning") morning := daypartNamed(summary.Dayparts, "morning")
if morning != nil { if morning != nil {
planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...) planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...)
@@ -84,6 +149,18 @@ func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning {
return planning return planning
} }
func outdoorPlanningNotes(dayparts []forecast.DaypartSummary) []string {
windows := buildOutdoorWindows(dayparts)
var notes []string
if windows.Best != nil {
notes = append(notes, fmt.Sprintf("Best outdoor window: %s (%s).", titleWord(windows.Best.Daypart), strings.Join(windows.Best.Reasons, ", ")))
}
if windows.Worst != nil && (windows.Best == nil || windows.Worst.Daypart != windows.Best.Daypart) {
notes = append(notes, fmt.Sprintf("Toughest outdoor window: %s (%s).", titleWord(windows.Worst.Daypart), strings.Join(windows.Worst.Reasons, ", ")))
}
return appendUnique(nil, notes...)
}
func readinessNotes(daypart forecast.DaypartSummary) []string { func readinessNotes(daypart forecast.DaypartSummary) []string {
notes := []string{} notes := []string{}
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 50 { if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 50 {
@@ -104,6 +181,27 @@ func readinessNotes(daypart forecast.DaypartSummary) []string {
return appendUnique(nil, notes...) return appendUnique(nil, notes...)
} }
func lateDayWatchNotes(daypart forecast.DaypartSummary) []string {
notes := []string{}
prefix := titleWord(daypart.Name)
if prefix == "" {
prefix = "Late-day"
}
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 {
notes = append(notes, fmt.Sprintf("%s precipitation timing may shift; current peak is near %.0f%%.", prefix, daypart.MaxPrecipitationProbability.Value))
}
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", prefix, daypart.PeakWindGust.Value))
}
if daypart.Indicators.Snow || daypart.Indicators.Ice {
notes = append(notes, prefix+" wintry weather could affect late-day travel.")
}
if len(daypart.AlertOverlaps) > 0 {
notes = append(notes, prefix+" alert timing could affect late-day plans.")
}
return appendUnique(nil, notes...)
}
func concernNotes(daypart forecast.DaypartSummary) []string { func concernNotes(daypart forecast.DaypartSummary) []string {
notes := []string{} notes := []string{}
prefix := titleWord(daypart.Name) prefix := titleWord(daypart.Name)
@@ -190,8 +288,7 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
} }
return OutdoorWindow{ return OutdoorWindow{
Daypart: daypart.Name, Daypart: daypart.Name,
Start: daypart.Period.Start.Format("15:04"), Period: daypart.Period,
End: daypart.Period.End.Format("15:04"),
Reasons: dedupe(reasons), Reasons: dedupe(reasons),
Score: math.Round(score*10) / 10, Score: math.Round(score*10) / 10,
} }

View File

@@ -0,0 +1,26 @@
package briefing
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
type TodayPlanningModule struct {
MorningReadiness []string `json:"morning_readiness,omitempty"`
CommuteSchoolWorkdayConcerns []string `json:"commute_school_workday_concerns,omitempty"`
OutdoorPlanning []string `json:"outdoor_planning,omitempty"`
LateDayChangeWatch []string `json:"late_day_change_watch,omitempty"`
}
func buildTodayPlanningModule(ctx ModuleContext, _ any) (*module.Output, error) {
summary := ctx.Derived.FirstDailySummary()
if summary == nil {
return &module.Output{ID: module.TodayPlanning, StanzaName: "today_planning", Value: TodayPlanningModule{}}, nil
}
planning := buildTodayPlanning(summary)
value := TodayPlanningModule{}
if planning != nil {
value.MorningReadiness = append([]string(nil), planning.MorningReadiness...)
value.CommuteSchoolWorkdayConcerns = append([]string(nil), planning.CommuteSchoolWorkdayConcerns...)
value.OutdoorPlanning = append([]string(nil), planning.OutdoorPlanning...)
value.LateDayChangeWatch = append([]string(nil), planning.LateDayChangeWatch...)
}
return &module.Output{ID: module.TodayPlanning, StanzaName: "today_planning", Value: value}, nil
}

View File

@@ -4,20 +4,21 @@ import (
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
) )
type WeatherStoryModule struct { type WeatherStoryModule struct {
Available bool `json:"available"` Available bool `json:"available"`
OfficeID string `json:"office_id,omitempty"` OfficeID string `json:"office_id,omitempty"`
StartTime time.Time `json:"start_time"` PeriodBegins string `json:"period_begins,omitempty"`
EndTime time.Time `json:"end_time"` PeriodEnds string `json:"period_ends,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
AltText string `json:"alt_text,omitempty"` AltText string `json:"alt_text,omitempty"`
Priority bool `json:"priority"` Priority bool `json:"priority"`
Order int `json:"order"` Order int `json:"order"`
DownloadURL string `json:"download_url,omitempty"` DownloadURL string `json:"download_url,omitempty"`
} }
func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) { func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
@@ -25,18 +26,19 @@ func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
if story == nil { if story == nil {
return nil, nil return nil, nil
} }
period := timeutil.Period{Start: story.StartTime, End: story.EndTime}
value := WeatherStoryModule{ value := WeatherStoryModule{
Available: true, Available: true,
OfficeID: story.OfficeID, OfficeID: story.OfficeID,
StartTime: story.StartTime, PeriodBegins: friendlyPeriodBeginsLabel(period, ctx.Timezone),
EndTime: story.EndTime, PeriodEnds: friendlyPeriodEndsLabel(period, ctx.Timezone),
UpdatedAt: copyTime(story.UpdatedAt), UpdatedAt: copyTime(story.UpdatedAt),
Title: story.Title, Title: story.Title,
Description: story.Description, Description: story.Description,
AltText: story.AltText, AltText: story.AltText,
Priority: story.Priority, Priority: story.Priority,
Order: story.Order, Order: story.Order,
DownloadURL: story.DownloadURL, DownloadURL: story.DownloadURL,
} }
return &module.Output{ID: module.WeatherStory, StanzaName: "weather_story", Value: value}, nil return &module.Output{ID: module.WeatherStory, StanzaName: "weather_story", Value: value}, nil
} }

View File

@@ -84,7 +84,8 @@ type dailySummaryStanza struct {
type daypartSummaryStanza struct { type daypartSummaryStanza struct {
Date string `json:"date,omitempty"` Date string `json:"date,omitempty"`
Period string `json:"period,omitempty"` PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
TempRangeF string `json:"temp_range_f,omitempty"` TempRangeF string `json:"temp_range_f,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"` MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"` MaxPopTime string `json:"max_pop_time,omitempty"`

View File

@@ -96,7 +96,13 @@ func dailySnapshot(t *testing.T, low int, high int, precip int, precipTime strin
DailyPrecipitationProbability: &precip, DailyPrecipitationProbability: &precip,
}}, }},
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{ module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
"morning": {Date: "2026-05-29", Period: "2026-05-29 at 6:00 AM to 2026-05-29 at 10:00 AM", TempRangeF: "60-70", Snow: snow}, "morning": {
Date: "2026-05-29",
PeriodBegins: "2026-05-29 at 6:00 AM",
PeriodEnds: "2026-05-29 at 10:00 AM",
TempRangeF: "60-70",
Snow: snow,
},
}}, }},
module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: precipTimingStanza{MaxPopPercent: &precip, MaxPopTime: precipTime}}, module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: precipTimingStanza{MaxPopPercent: &precip, MaxPopTime: precipTime}},
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: alertDigestStanza{Relevant: relevant}}, module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: alertDigestStanza{Relevant: relevant}},

View File

@@ -32,7 +32,8 @@ func outlookSnapshot(t *testing.T, date string, tempRange string, precip int, pr
return snapshot(t, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{ return snapshot(t, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
date + "_morning": { date + "_morning": {
Date: date, Date: date,
Period: date + " at 6:00 AM to " + date + " at 10:00 AM", PeriodBegins: date + " at 6:00 AM",
PeriodEnds: date + " at 10:00 AM",
TempRangeF: tempRange, TempRangeF: tempRange,
MaxPopPercent: &precip, MaxPopPercent: &precip,
MaxPopTime: precipTime, MaxPopTime: precipTime,

View File

@@ -17,8 +17,10 @@ const helpText = `weatherreporter prepares weather reports from normalized forec
Usage: Usage:
weatherreporter --help weatherreporter --help
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
@@ -36,7 +38,7 @@ Options:
--config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml. --config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml.
--units VALUE Override weather API units. --units VALUE Override weather API units.
--tz NAME Override weather API timezone. --tz NAME Override weather API timezone.
--out PATH Write an extra Markdown report copy for generate commands. --out PATH Write an extra Markdown report copy where supported by the generate command.
--out-dir PATH Write extra Markdown report copies for run commands. --out-dir PATH Write extra Markdown report copies for run commands.
` `
@@ -185,10 +187,10 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
if len(args) == 0 { if len(args) == 0 {
return app.GenerateRequest{}, fmt.Errorf("generate requires a report name") return app.GenerateRequest{}, fmt.Errorf("generate requires a report name")
} }
reportKind, ok := reportKind(args[0]) if _, err := report.IDForCommandName(args[0]); err != nil {
if !ok {
return app.GenerateRequest{}, fmt.Errorf("unknown generate report %q", args[0]) return app.GenerateRequest{}, fmt.Errorf("unknown generate report %q", args[0])
} }
reportKind := app.ReportKind(args[0])
opts, err := parseGenerateFlags(reportKind, args[1:]) opts, err := parseGenerateFlags(reportKind, args[1:])
if err != nil { if err != nil {
@@ -216,6 +218,14 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
switch reportKind { switch reportKind {
case app.ReportDaily: case app.ReportDaily:
if opts.Date == "" {
return app.GenerateRequest{}, fmt.Errorf("generate daily requires --date YYYY-MM-DD")
}
req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
if err != nil {
return app.GenerateRequest{}, err
}
case app.ReportToday:
if opts.Date == "" { if opts.Date == "" {
req.Date = timeutil.LocalDate(r.Clock.Now(), location) req.Date = timeutil.LocalDate(r.Clock.Now(), location)
} else { } else {
@@ -249,10 +259,10 @@ func (r Runner) resolveRun(args []string) (app.BatchRequest, error) {
if len(args) == 0 { if len(args) == 0 {
return app.BatchRequest{}, fmt.Errorf("run requires a batch name") return app.BatchRequest{}, fmt.Errorf("run requires a batch name")
} }
batch, ok := batchKind(args[0]) if _, err := report.BatchForCommandName(args[0]); err != nil {
if !ok {
return app.BatchRequest{}, fmt.Errorf("unknown run batch %q", args[0]) return app.BatchRequest{}, fmt.Errorf("unknown run batch %q", args[0])
} }
batch := app.BatchKind(args[0])
opts, err := parseRunFlags(args[1:]) opts, err := parseRunFlags(args[1:])
if err != nil { if err != nil {
return app.BatchRequest{}, err return app.BatchRequest{}, err
@@ -277,7 +287,7 @@ func parseGenerateFlags(report app.ReportKind, args []string) (generateOptions,
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
opts := generateOptions{} opts := generateOptions{}
addCommonFlags(fs, &opts.commonOptions, true) addCommonFlags(fs, &opts.commonOptions, true)
if report == app.ReportDaily { if report == app.ReportDaily || report == app.ReportToday {
fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD") fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD")
} }
if report == app.ReportStorm { if report == app.ReportStorm {
@@ -379,31 +389,3 @@ func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) {
fs.StringVar(&opts.Output, "out", "", "extra Markdown report copy path") fs.StringVar(&opts.Output, "out", "", "extra Markdown report copy path")
} }
} }
func reportKind(value string) (app.ReportKind, bool) {
switch value {
case string(app.ReportDaily):
return app.ReportDaily, true
case string(app.ReportTomorrow):
return app.ReportTomorrow, true
case string(app.ReportThreeDay):
return app.ReportThreeDay, true
case string(app.ReportWeekend):
return app.ReportWeekend, true
case string(app.ReportStorm):
return app.ReportStorm, true
default:
return "", false
}
}
func batchKind(value string) (app.BatchKind, bool) {
switch value {
case string(app.BatchMorning):
return app.BatchMorning, true
case string(app.BatchEvening):
return app.BatchEvening, true
default:
return "", false
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ package config
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -85,12 +86,31 @@ func TestLoadExampleConfig(t *testing.T) {
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" { if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
t.Fatalf("Location = %#v, want example location", cfg.Location) t.Fatalf("Location = %#v, want example location", cfg.Location)
} }
if cfg.Notify.Distributor.PipelineIDTemplate != "weatherreporter.{artifact_group}" { if cfg.Notify.Distributor.PipelineIDTemplate != "weatherreporter.{report_id}" {
t.Fatalf("PipelineIDTemplate = %q, want example pipeline template", cfg.Notify.Distributor.PipelineIDTemplate) t.Fatalf("PipelineIDTemplate = %q, want example pipeline template", cfg.Notify.Distributor.PipelineIDTemplate)
} }
if len(cfg.Notify.Distributor.ReportPathTemplates) != 1 { if len(cfg.Notify.Distributor.ReportPathTemplates) != 1 {
t.Fatalf("ReportPathTemplates = %#v, want example archive path", cfg.Notify.Distributor.ReportPathTemplates) t.Fatalf("ReportPathTemplates = %#v, want example archive path", cfg.Notify.Distributor.ReportPathTemplates)
} }
overrides, err := cfg.ReportModuleOverrides()
if err != nil {
t.Fatalf("ReportModuleOverrides() error = %v", err)
}
hourly := overrides[report.Hourly]
if len(hourly) != 9 {
t.Fatalf("hourly example override length = %d, want 9", len(hourly))
}
if hourly[0].ID != module.Metadata ||
hourly[1].ID != module.CurrentConditions ||
hourly[2].ID != module.HourlyForecast ||
hourly[3].ID != module.PrecipTiming ||
hourly[4].ID != module.AlertDigest ||
hourly[5].ID != module.SPCConvectiveOutlooks ||
hourly[6].ID != module.AreaForecastDiscussion ||
hourly[7].ID != module.SPCConvectiveDiscussion ||
hourly[8].ID != module.WeatherStory {
t.Fatalf("hourly example override = %#v, want configured module order", hourly)
}
} }
func TestLoadMinimalExampleConfig(t *testing.T) { func TestLoadMinimalExampleConfig(t *testing.T) {
@@ -126,34 +146,243 @@ reports:
deterministic_modules: deterministic_modules:
- metadata - metadata
- current_conditions - current_conditions
- alert_digest
- spc_convective_outlooks
- id: area_forecast_discussion - id: area_forecast_discussion
options: options:
sections: sections:
- short_term - short_term
- spc_convective_discussion
`) `)
cfg, err := LoadFile(path) cfg, err := LoadFile(path)
if err != nil { if err != nil {
t.Fatalf("LoadFile() error = %v", err) t.Fatalf("LoadFile() error = %v", err)
} }
overrides := cfg.ReportModuleOverrides() overrides, err := cfg.ReportModuleOverrides()
items := overrides[report.DailyToday] if err != nil {
if len(items) != 3 { t.Fatalf("ReportModuleOverrides() error = %v", err)
t.Fatalf("daily override length = %d, want 3", len(items))
} }
if items[0].ID != module.Metadata || items[1].ID != module.CurrentConditions || items[2].ID != module.AreaForecastDiscussion { items := overrides[report.Daily]
if len(items) != 6 {
t.Fatalf("daily override length = %d, want 6", len(items))
}
if items[0].ID != module.Metadata ||
items[1].ID != module.CurrentConditions ||
items[2].ID != module.AlertDigest ||
items[3].ID != module.SPCConvectiveOutlooks ||
items[4].ID != module.AreaForecastDiscussion ||
items[5].ID != module.SPCConvectiveDiscussion {
t.Fatalf("daily override = %#v, want configured module order", items) t.Fatalf("daily override = %#v, want configured module order", items)
} }
options, ok := items[2].Options.(module.AreaForecastDiscussionOptions) options, ok := items[4].Options.(module.AreaForecastDiscussionOptions)
if !ok { if !ok {
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", items[2].Options) t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", items[4].Options)
} }
if strings.Join(options.Sections, ",") != "short_term" { if strings.Join(options.Sections, ",") != "short_term" {
t.Fatalf("AFD sections = %#v, want short_term", options.Sections) t.Fatalf("AFD sections = %#v, want short_term", options.Sections)
} }
} }
func TestLoadTodayReportModuleOverrides(t *testing.T) {
path := writeConfig(t, `
reports:
today:
deterministic_modules:
- metadata
- current_conditions
- derived_daily_summary
- derived_daypart_summaries
- today_planning
`)
cfg, err := LoadFile(path)
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
overrides, err := cfg.ReportModuleOverrides()
if err != nil {
t.Fatalf("ReportModuleOverrides() error = %v", err)
}
items := overrides[report.Today]
if len(items) != 5 {
t.Fatalf("today override length = %d, want 5", len(items))
}
want := []module.ID{
module.Metadata,
module.CurrentConditions,
module.DerivedDailySummary,
module.DerivedDaypartSummaries,
module.TodayPlanning,
}
for i, id := range want {
if items[i].ID != id {
t.Fatalf("today override[%d] = %s, want %s", i, items[i].ID, id)
}
}
if _, ok := overrides[report.Daily]; ok {
t.Fatalf("daily override = %#v, want today override to stay distinct", overrides[report.Daily])
}
}
func TestLoadHourlyReportModuleOverrides(t *testing.T) {
path := writeConfig(t, `
reports:
hourly:
deterministic_modules:
- metadata
- hourly_forecast
- precip_timing
- alert_digest
`)
cfg, err := LoadFile(path)
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
overrides, err := cfg.ReportModuleOverrides()
if err != nil {
t.Fatalf("ReportModuleOverrides() error = %v", err)
}
items := overrides[report.Hourly]
if len(items) != 4 {
t.Fatalf("hourly override length = %d, want 4", len(items))
}
if items[0].ID != module.Metadata ||
items[1].ID != module.HourlyForecast ||
items[2].ID != module.PrecipTiming ||
items[3].ID != module.AlertDigest {
t.Fatalf("hourly override = %#v, want configured module order", items)
}
}
func TestLoadReportModuleOverrideAliases(t *testing.T) {
path := writeConfig(t, `
reports:
three-day-outlook:
deterministic_modules:
- metadata
weekend_outlook:
deterministic_modules:
- metadata
storm_report:
deterministic_modules:
- metadata
`)
cfg, err := LoadFile(path)
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
overrides, err := cfg.ReportModuleOverrides()
if err != nil {
t.Fatalf("ReportModuleOverrides() error = %v", err)
}
if len(overrides[report.ThreeDay]) != 1 || overrides[report.ThreeDay][0].ID != module.Metadata {
t.Fatalf("three-day alias override = %#v, want metadata override", overrides[report.ThreeDay])
}
if len(overrides[report.Weekend]) != 1 || overrides[report.Weekend][0].ID != module.Metadata {
t.Fatalf("weekend alias override = %#v, want metadata override", overrides[report.Weekend])
}
if len(overrides[report.Storm]) != 1 || overrides[report.Storm][0].ID != module.Metadata {
t.Fatalf("storm alias override = %#v, want metadata override", overrides[report.Storm])
}
}
func TestValidateReportModuleKeysWithoutMutatingOptions(t *testing.T) {
cfg := Defaults()
rawOptions := map[string]any{
"sections": []any{"short_term"},
}
cfg.Reports = map[string]ReportConfig{
"daily": {
DeterministicModules: []ModuleConfigItem{
{ID: module.Metadata},
{ID: module.AreaForecastDiscussion, Options: rawOptions},
},
deterministicModulesSet: true,
},
}
if err := Validate(cfg); err != nil {
t.Fatalf("Validate() error = %v", err)
}
got, ok := cfg.Reports["daily"].DeterministicModules[1].Options.(map[string]any)
if !ok || !reflect.DeepEqual(got, rawOptions) {
t.Fatalf("Options after Validate = %#v, want original raw map", cfg.Reports["daily"].DeterministicModules[1].Options)
}
}
func TestValidateReportModuleAliasesDirectly(t *testing.T) {
retiredDailyKey := retiredDailyReportKeyForTest()
tests := []struct {
name string
reports map[string]ReportConfig
wantErr string
}{
{
name: "RetiredDailyReportID",
reports: map[string]ReportConfig{
"daily": {},
retiredDailyKey: {},
},
wantErr: "reports." + retiredDailyKey,
},
{
name: "TodayAndDailyAreDistinct",
reports: map[string]ReportConfig{
"daily": {},
"today": {},
},
},
{
name: "UnknownReport",
reports: map[string]ReportConfig{
"moon": {},
},
wantErr: "reports.moon",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := Defaults()
cfg.Reports = tt.reports
err := Validate(cfg)
if tt.wantErr == "" {
if err != nil {
t.Fatalf("Validate() error = %v", err)
}
return
}
if err == nil {
t.Fatal("Validate() error = nil, want report key error")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("Validate() error = %q, want %q", err.Error(), tt.wantErr)
}
})
}
}
func TestReportModuleOverridesRejectsInvalidReportKeys(t *testing.T) {
cfg := Defaults()
cfg.Reports = map[string]ReportConfig{
"moon": {
DeterministicModules: []ModuleConfigItem{{ID: module.Metadata}},
deterministicModulesSet: true,
},
}
_, err := cfg.ReportModuleOverrides()
if err == nil {
t.Fatal("ReportModuleOverrides() error = nil, want invalid report key")
}
if !strings.Contains(err.Error(), "reports.moon") {
t.Fatalf("ReportModuleOverrides() error = %q, want report key context", err.Error())
}
}
func TestReportModuleOverrideValidation(t *testing.T) { func TestReportModuleOverrideValidation(t *testing.T) {
retiredDailyKey := retiredDailyReportKeyForTest()
tests := []struct { tests := []struct {
name string name string
yaml string yaml string
@@ -198,7 +427,7 @@ reports:
deterministic_modules: deterministic_modules:
- tomorrow_planning - tomorrow_planning
`, `,
wantErr: `not compatible with report "daily_today"`, wantErr: `not compatible with report "daily"`,
}, },
{ {
name: "RemovedPlaceholderModule", name: "RemovedPlaceholderModule",
@@ -210,6 +439,16 @@ reports:
`, `,
wantErr: `unknown module "forecast_delta"`, wantErr: `unknown module "forecast_delta"`,
}, },
{
name: "RetiredTomorrowReportID",
yaml: `
reports:
daily_tomorrow:
deterministic_modules:
- metadata
`,
wantErr: "reports.daily_tomorrow",
},
{ {
name: "InvalidOptions", name: "InvalidOptions",
yaml: ` yaml: `
@@ -224,17 +463,27 @@ reports:
wantErr: "options are invalid", wantErr: "options are invalid",
}, },
{ {
name: "DuplicateReportAlias", name: "RetiredDailyReportID",
yaml: ` yaml: `
reports: reports:
daily: daily:
deterministic_modules: deterministic_modules:
- metadata - metadata
daily_today: ` + retiredDailyKey + `:
deterministic_modules: deterministic_modules:
- current_conditions - current_conditions
`, `,
wantErr: "duplicates report override", wantErr: "reports." + retiredDailyKey,
},
{
name: "HourlyIncompatibleDailyModule",
yaml: `
reports:
hourly:
deterministic_modules:
- derived_daily_summary
`,
wantErr: `not compatible with report "hourly"`,
}, },
{ {
name: "UnknownReportField", name: "UnknownReportField",
@@ -261,6 +510,32 @@ reports:
} }
} }
func retiredDailyReportKeyForTest() string {
return strings.Join([]string{"daily", "today"}, "_")
}
func TestReportModuleOverrideRejectsRetiredHourlyKeys(t *testing.T) {
for _, key := range []string{
strings.Join([]string{"near", "term"}, "_"),
strings.Join([]string{"near", "term"}, "-"),
} {
t.Run(key, func(t *testing.T) {
_, err := LoadFile(writeConfig(t, `
reports:
`+key+`:
deterministic_modules:
- metadata
`))
if err == nil {
t.Fatal("LoadFile() error = nil, want unknown report")
}
if !strings.Contains(err.Error(), "is not a known report") {
t.Fatalf("error = %q, want unknown report", err.Error())
}
})
}
}
func TestExplicitMissingConfigReturnsError(t *testing.T) { func TestExplicitMissingConfigReturnsError(t *testing.T) {
_, err := LoadFile(filepath.Join(t.TempDir(), "missing.yml")) _, err := LoadFile(filepath.Join(t.TempDir(), "missing.yml"))
if err == nil { if err == nil {
@@ -599,7 +874,7 @@ func TestLoadFileLoadsSecretsBeforeReturningNotifyConfig(t *testing.T) {
"notify:\n" + "notify:\n" +
" distributor:\n" + " distributor:\n" +
" enabled: true\n" + " enabled: true\n" +
" pipeline_id_template: weatherreporter.{artifact_group}\n" " pipeline_id_template: weatherreporter.{report_id}\n"
if err := os.WriteFile(path, []byte(configYAML), 0o600); err != nil { if err := os.WriteFile(path, []byte(configYAML), 0o600); err != nil {
t.Fatalf("write config fixture: %v", err) t.Fatalf("write config fixture: %v", err)
} }

View File

@@ -4,7 +4,6 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"reflect" "reflect"
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
@@ -12,23 +11,24 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
func (cfg Config) ReportModuleOverrides() map[report.ID][]module.ConfigItem { func (cfg Config) ReportModuleOverrides() (map[report.ID][]module.ConfigItem, error) {
overrides := map[report.ID][]module.ConfigItem{} overrides := map[report.ID][]module.ConfigItem{}
seenReports := map[report.ID]string{}
for key, reportCfg := range cfg.Reports { for key, reportCfg := range cfg.Reports {
id, err := report.IDForConfigKey(key)
if err != nil {
return nil, fmt.Errorf("reports.%s: %w", key, err)
}
if previous, ok := seenReports[id]; ok {
return nil, fmt.Errorf("reports.%s duplicates report override %q", key, previous)
}
seenReports[id] = key
if !reportCfg.deterministicModulesSet { if !reportCfg.deterministicModulesSet {
continue continue
} }
id, err := reportIDForConfigKey(key) overrides[id] = moduleItemsFromConfig(reportCfg.DeterministicModules)
if err != nil {
continue
}
items := make([]module.ConfigItem, 0, len(reportCfg.DeterministicModules))
for _, item := range reportCfg.DeterministicModules {
items = append(items, module.ConfigItem{ID: item.ID, Options: item.Options})
}
overrides[id] = items
} }
return overrides return overrides, nil
} }
func normalizeReportModules(cfg *Config) error { func normalizeReportModules(cfg *Config) error {
@@ -42,9 +42,50 @@ func normalizeReportModules(cfg *Config) error {
reportRegistry := report.DefaultRegistry() reportRegistry := report.DefaultRegistry()
seenReports := map[report.ID]string{} seenReports := map[report.ID]string{}
for key, reportCfg := range cfg.Reports { for key, reportCfg := range cfg.Reports {
reportID, err := reportIDForConfigKey(key) reportID, err := report.IDForConfigKey(key)
if err != nil { if err != nil {
return err return fmt.Errorf("reports.%s: %w", key, err)
}
if previous, ok := seenReports[reportID]; ok {
return fmt.Errorf("reports.%s duplicates report override %q", key, previous)
}
seenReports[reportID] = key
if _, err := reportRegistry.Lookup(reportID); err != nil {
return fmt.Errorf("reports.%s: %w", key, err)
}
if !reportCfg.deterministicModulesSet {
continue
}
for i, rawItem := range reportCfg.DeterministicModules {
options, err := normalizeModuleOptions(moduleRegistry, rawItem.ID, rawItem.Options)
if err != nil {
return fmt.Errorf("reports.%s.deterministic_modules[%d]: %w", key, i, err)
}
reportCfg.DeterministicModules[i].Options = options
}
items := moduleItemsFromConfig(reportCfg.DeterministicModules)
if err := moduleRegistry.ValidateComposition(reportID, items); err != nil {
return fmt.Errorf("reports.%s.deterministic_modules: %w", key, err)
}
cfg.Reports[key] = reportCfg
}
return nil
}
func validateReportModules(cfg Config) error {
if cfg.Reports == nil {
return nil
}
moduleRegistry, err := briefing.DefaultModuleRegistry()
if err != nil {
return fmt.Errorf("initialize module registry: %w", err)
}
reportRegistry := report.DefaultRegistry()
seenReports := map[report.ID]string{}
for key, reportCfg := range cfg.Reports {
reportID, err := report.IDForConfigKey(key)
if err != nil {
return fmt.Errorf("reports.%s: %w", key, err)
} }
if previous, ok := seenReports[reportID]; ok { if previous, ok := seenReports[reportID]; ok {
return fmt.Errorf("reports.%s duplicates report override %q", key, previous) return fmt.Errorf("reports.%s duplicates report override %q", key, previous)
@@ -58,21 +99,31 @@ func normalizeReportModules(cfg *Config) error {
} }
items := make([]module.ConfigItem, 0, len(reportCfg.DeterministicModules)) items := make([]module.ConfigItem, 0, len(reportCfg.DeterministicModules))
for i, rawItem := range reportCfg.DeterministicModules { for i, rawItem := range reportCfg.DeterministicModules {
options, err := normalizeModuleOptions(moduleRegistry, rawItem.ID, rawItem.Options) options := rawItem.Options
if err != nil { if options != nil {
return fmt.Errorf("reports.%s.deterministic_modules[%d]: %w", key, i, err) normalized, err := normalizeModuleOptions(moduleRegistry, rawItem.ID, options)
if err != nil {
return fmt.Errorf("reports.%s.deterministic_modules[%d]: %w", key, i, err)
}
options = normalized
} }
reportCfg.DeterministicModules[i].Options = options
items = append(items, module.ConfigItem{ID: rawItem.ID, Options: options}) items = append(items, module.ConfigItem{ID: rawItem.ID, Options: options})
} }
if err := moduleRegistry.ValidateComposition(reportID, items); err != nil { if err := moduleRegistry.ValidateComposition(reportID, items); err != nil {
return fmt.Errorf("reports.%s.deterministic_modules: %w", key, err) return fmt.Errorf("reports.%s.deterministic_modules: %w", key, err)
} }
cfg.Reports[key] = reportCfg
} }
return nil return nil
} }
func moduleItemsFromConfig(items []ModuleConfigItem) []module.ConfigItem {
out := make([]module.ConfigItem, 0, len(items))
for _, item := range items {
out = append(out, module.ConfigItem{ID: item.ID, Options: item.Options})
}
return out
}
func normalizeModuleOptions(registry briefing.ModuleRegistry, id module.ID, raw any) (any, error) { func normalizeModuleOptions(registry briefing.ModuleRegistry, id module.ID, raw any) (any, error) {
if raw == nil { if raw == nil {
return nil, nil return nil, nil
@@ -84,6 +135,9 @@ func normalizeModuleOptions(registry briefing.ModuleRegistry, id module.ID, raw
if definition.DefaultOptions == nil { if definition.DefaultOptions == nil {
return nil, fmt.Errorf("module %q does not accept options", id) return nil, fmt.Errorf("module %q does not accept options", id)
} }
if err := definition.ValidateOptions(raw); err == nil {
return raw, nil
}
optionType := reflect.TypeOf(definition.DefaultOptions) optionType := reflect.TypeOf(definition.DefaultOptions)
normalized, err := decodeKnownOptions(raw, optionType) normalized, err := decodeKnownOptions(raw, optionType)
if err != nil { if err != nil {
@@ -105,21 +159,3 @@ func decodeKnownOptions(raw any, optionType reflect.Type) (any, error) {
} }
return target.Elem().Interface(), nil return target.Elem().Interface(), nil
} }
func reportIDForConfigKey(key string) (report.ID, error) {
normalized := strings.ReplaceAll(strings.TrimSpace(strings.ToLower(key)), "-", "_")
switch normalized {
case "daily", "daily_today":
return report.DailyToday, nil
case "tomorrow", "daily_tomorrow":
return report.DailyTomorrow, nil
case "three_day", "three_day_outlook":
return report.ThreeDay, nil
case "weekend", "weekend_outlook":
return report.Weekend, nil
case "storm", "storm_report":
return report.Storm, nil
default:
return "", fmt.Errorf("reports.%s is not a known report", key)
}
}

View File

@@ -9,7 +9,7 @@ import (
) )
func Validate(cfg Config) error { func Validate(cfg Config) error {
if err := normalizeReportModules(&cfg); err != nil { if err := validateReportModules(cfg); err != nil {
return err return err
} }
if cfg.WeatherAPI.BaseURL != "" { if cfg.WeatherAPI.BaseURL != "" {

View File

@@ -3,6 +3,7 @@ package facts
import ( import (
"fmt" "fmt"
"sort"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
@@ -12,15 +13,16 @@ import (
) )
type CollectedFacts struct { type CollectedFacts struct {
FetchedAt time.Time FetchedAt time.Time
Observation *weatherdata.Observation Observation *weatherdata.Observation
Current *weatherdata.Current Current *weatherdata.Current
Hourly *weatherdata.ForecastRun Hourly *weatherdata.ForecastRun
Narrative *weatherdata.ForecastRun Narrative *weatherdata.ForecastRun
Alerts *weatherdata.AlertRun Alerts *weatherdata.AlertRun
Discussion *weatherdata.Discussion Discussion *weatherdata.Discussion
Daily *weatherdata.ForecastRun Daily *weatherdata.ForecastRun
WeatherStory *weatherdata.WeatherStory WeatherStory *weatherdata.WeatherStory
SPCConvectiveOutlooks *weatherdata.ConvectiveOutlookRun
SourceProvenance []weatherdata.Source SourceProvenance []weatherdata.Source
SourceWarnings []weatherdata.SourceWarning SourceWarnings []weatherdata.SourceWarning
@@ -31,33 +33,35 @@ func BuildCollected(bundle *weatherdata.Bundle) CollectedFacts {
return CollectedFacts{} return CollectedFacts{}
} }
return CollectedFacts{ return CollectedFacts{
FetchedAt: bundle.FetchedAt, FetchedAt: bundle.FetchedAt,
Observation: bundle.Observation, Observation: bundle.Observation,
Current: bundle.Current, Current: bundle.Current,
Hourly: bundle.Hourly, Hourly: bundle.Hourly,
Narrative: bundle.Narrative, Narrative: bundle.Narrative,
Alerts: bundle.Alerts, Alerts: bundle.Alerts,
Discussion: bundle.Discussion, Discussion: bundle.Discussion,
Daily: bundle.Daily, Daily: bundle.Daily,
WeatherStory: bundle.WeatherStory, WeatherStory: bundle.WeatherStory,
SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...), SPCConvectiveOutlooks: bundle.SPCConvectiveOutlooks,
SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...), SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...),
SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...),
} }
} }
func (f CollectedFacts) Bundle() *weatherdata.Bundle { func (f CollectedFacts) Bundle() *weatherdata.Bundle {
return &weatherdata.Bundle{ return &weatherdata.Bundle{
FetchedAt: f.FetchedAt, FetchedAt: f.FetchedAt,
Observation: f.Observation, Observation: f.Observation,
Current: f.Current, Current: f.Current,
Hourly: f.Hourly, Hourly: f.Hourly,
Narrative: f.Narrative, Narrative: f.Narrative,
Alerts: f.Alerts, Alerts: f.Alerts,
Discussion: f.Discussion, Discussion: f.Discussion,
Daily: f.Daily, Daily: f.Daily,
WeatherStory: f.WeatherStory, WeatherStory: f.WeatherStory,
Sources: append([]weatherdata.Source(nil), f.SourceProvenance...), SPCConvectiveOutlooks: f.SPCConvectiveOutlooks,
Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...), Sources: append([]weatherdata.Source(nil), f.SourceProvenance...),
Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...),
} }
} }
@@ -73,6 +77,8 @@ type DerivedFacts struct {
ValidPeriodNarrativePeriods []weatherdata.ForecastPeriod ValidPeriodNarrativePeriods []weatherdata.ForecastPeriod
ValidPeriodDailyPeriods []weatherdata.ForecastPeriod ValidPeriodDailyPeriods []weatherdata.ForecastPeriod
AlertOverlaps []forecast.AlertOverlap AlertOverlaps []forecast.AlertOverlap
SPCConvectiveOutlooks []weatherdata.ConvectiveOutlook
SPCConvectiveDiscussions []weatherdata.ConvectiveOutlookDiscussion
DailySummaries []forecast.DailySummary DailySummaries []forecast.DailySummary
DaypartSummaries []forecast.DaypartSummary DaypartSummaries []forecast.DaypartSummary
PrecipTiming forecast.PrecipTiming PrecipTiming forecast.PrecipTiming
@@ -96,16 +102,20 @@ func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) {
} }
bundle := req.Collected.Bundle() bundle := req.Collected.Bundle()
period := req.Resolved.ValidPeriod period := req.Resolved.ValidPeriod
spcOutlooks, spcDiscussions := selectSPCConvectiveOutlooks(req.Collected.SPCConvectiveOutlooks, period)
derived := DerivedFacts{ derived := DerivedFacts{
ValidPeriodHourlyPeriods: forecast.SelectHourlyPeriods(req.Collected.Hourly, period), ValidPeriodHourlyPeriods: forecast.SelectHourlyPeriods(req.Collected.Hourly, period),
ValidPeriodNarrativePeriods: forecast.SelectHourlyPeriods(req.Collected.Narrative, period), ValidPeriodNarrativePeriods: forecast.SelectHourlyPeriods(req.Collected.Narrative, period),
ValidPeriodDailyPeriods: forecast.SelectHourlyPeriods(req.Collected.Daily, period), ValidPeriodDailyPeriods: forecast.SelectHourlyPeriods(req.Collected.Daily, period),
AlertOverlaps: forecast.AlertOverlaps(req.Collected.Alerts, period), AlertOverlaps: forecast.AlertOverlaps(req.Collected.Alerts, period),
SPCConvectiveOutlooks: spcOutlooks,
SPCConvectiveDiscussions: spcDiscussions,
} }
derived.PrecipTiming = forecast.BuildPrecipTiming(derived.ValidPeriodHourlyPeriods) derived.PrecipTiming = forecast.BuildPrecipTiming(derived.ValidPeriodHourlyPeriods)
switch req.Resolved.Definition.ID { switch req.Resolved.Definition.ID {
case report.DailyToday, report.DailyTomorrow: case report.Hourly:
case report.Daily, report.Today, report.Tomorrow:
summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts) summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts)
if err != nil { if err != nil {
return DerivedFacts{}, err return DerivedFacts{}, err
@@ -139,3 +149,81 @@ func collectDaypartSummaries(derived DerivedFacts) []forecast.DaypartSummary {
} }
return out return out
} }
func selectSPCConvectiveOutlooks(run *weatherdata.ConvectiveOutlookRun, period timeutil.Period) ([]weatherdata.ConvectiveOutlook, []weatherdata.ConvectiveOutlookDiscussion) {
if run == nil {
return nil, nil
}
outlooks := make([]weatherdata.ConvectiveOutlook, 0, len(run.Outlooks))
days := map[int]struct{}{}
for _, outlook := range run.Outlooks {
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(period) {
continue
}
outlooks = append(outlooks, outlook)
days[outlook.Day] = struct{}{}
}
sort.SliceStable(outlooks, func(i, j int) bool {
left := outlooks[i]
right := outlooks[j]
if left.Day != right.Day {
return left.Day < right.Day
}
if left.OutlookType != right.OutlookType {
return left.OutlookType < right.OutlookType
}
leftRank, leftRankOK := severityRank(left)
rightRank, rightRankOK := severityRank(right)
if leftRankOK != rightRankOK {
return leftRankOK
}
if leftRankOK && leftRank != rightRank {
return leftRank > rightRank
}
if !left.ValidFrom.Equal(right.ValidFrom) {
return left.ValidFrom.Before(right.ValidFrom)
}
if left.Label != right.Label {
return left.Label < right.Label
}
return left.ID < right.ID
})
discussions := make([]weatherdata.ConvectiveOutlookDiscussion, 0, len(run.Discussions))
for _, discussion := range run.Discussions {
if _, ok := days[discussion.Day]; ok {
discussions = append(discussions, discussion)
}
}
sort.SliceStable(discussions, func(i, j int) bool {
left := discussions[i]
right := discussions[j]
if left.Day != right.Day {
return left.Day < right.Day
}
if left.UpdatedAt != nil && right.UpdatedAt != nil && !left.UpdatedAt.Equal(*right.UpdatedAt) {
return left.UpdatedAt.Before(*right.UpdatedAt)
}
if (left.UpdatedAt != nil) != (right.UpdatedAt != nil) {
return left.UpdatedAt != nil
}
if left.Headline != right.Headline {
return left.Headline < right.Headline
}
if left.Summary != right.Summary {
return left.Summary < right.Summary
}
return left.Discussion < right.Discussion
})
return outlooks, discussions
}
func severityRank(outlook weatherdata.ConvectiveOutlook) (int, bool) {
if outlook.SeverityRank == nil {
return 0, false
}
return *outlook.SeverityRank, true
}

View File

@@ -2,6 +2,7 @@ package facts
import ( import (
"encoding/json" "encoding/json"
"strings"
"testing" "testing"
"time" "time"
@@ -16,14 +17,24 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
FetchedAt: fetchedAt, FetchedAt: fetchedAt,
Current: &weatherdata.Current{ConditionText: "Clear"}, Current: &weatherdata.Current{ConditionText: "Clear"},
Hourly: &weatherdata.ForecastRun{Product: "hourly"}, Hourly: &weatherdata.ForecastRun{Product: "hourly"},
Sources: []weatherdata.Source{{Name: "hourly"}}, SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{
Warnings: []weatherdata.SourceWarning{{Source: "discussion", Code: "missing_source"}}, 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) collected := BuildCollected(bundle)
if collected.FetchedAt != fetchedAt || collected.Current.ConditionText != "Clear" || collected.Hourly.Product != "hourly" { if collected.FetchedAt != fetchedAt || collected.Current.ConditionText != "Clear" || collected.Hourly.Product != "hourly" {
t.Fatalf("CollectedFacts = %#v, want source facts copied from bundle", collected) t.Fatalf("CollectedFacts = %#v, want source facts copied from bundle", collected)
} }
if collected.SPCConvectiveOutlooks == nil || collected.SPCConvectiveOutlooks.Outlooks[0].Label != "SLGT" {
t.Fatalf("SPCConvectiveOutlooks = %#v, want source copied from bundle", collected.SPCConvectiveOutlooks)
}
if len(collected.SourceProvenance) != 1 || collected.SourceProvenance[0].Name != "hourly" { if len(collected.SourceProvenance) != 1 || collected.SourceProvenance[0].Name != "hourly" {
t.Fatalf("SourceProvenance = %#v, want hourly source", collected.SourceProvenance) t.Fatalf("SourceProvenance = %#v, want hourly source", collected.SourceProvenance)
} }
@@ -36,11 +47,16 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
if collected.SourceProvenance[0].Name != "hourly" || collected.SourceWarnings[0].Source != "discussion" { if collected.SourceProvenance[0].Name != "hourly" || collected.SourceWarnings[0].Source != "discussion" {
t.Fatalf("collected source slices changed after bundle mutation: %#v %#v", collected.SourceProvenance, collected.SourceWarnings) t.Fatalf("collected source slices changed after bundle mutation: %#v %#v", collected.SourceProvenance, collected.SourceWarnings)
} }
roundTrip := collected.Bundle()
if roundTrip.SPCConvectiveOutlooks == nil || roundTrip.SPCConvectiveOutlooks.Outlooks[0].ID != "day1-categorical-slight" {
t.Fatalf("Bundle().SPCConvectiveOutlooks = %#v, want collected source restored", roundTrip.SPCConvectiveOutlooks)
}
} }
func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) { func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) {
location := testLocation() location := testLocation()
resolved := resolveForTest(t, report.DailyToday, mustParse("2026-05-29T08:00:00-05:00"), location) resolved := resolveForTest(t, report.Daily, mustParse("2026-05-29T08:00:00-05:00"), location)
derived, err := BuildDerived(BuildDerivedRequest{ derived, err := BuildDerived(BuildDerivedRequest{
Resolved: resolved, Resolved: resolved,
Timezone: location.String(), Timezone: location.String(),
@@ -119,7 +135,7 @@ func TestBuildDerivedOutlookBuildsPartialDaySummariesWithMissingOptionalSources(
func TestBuildDerivedWeekendAndTomorrow(t *testing.T) { func TestBuildDerivedWeekendAndTomorrow(t *testing.T) {
location := testLocation() location := testLocation()
for _, id := range []report.ID{report.DailyTomorrow, report.Weekend} { for _, id := range []report.ID{report.Tomorrow, report.Weekend} {
resolved := resolveForTest(t, id, mustParse("2026-05-29T08:00:00-05:00"), location) resolved := resolveForTest(t, id, mustParse("2026-05-29T08:00:00-05:00"), location)
derived, err := BuildDerived(BuildDerivedRequest{ derived, err := BuildDerived(BuildDerivedRequest{
Resolved: resolved, Resolved: resolved,
@@ -136,6 +152,63 @@ func TestBuildDerivedWeekendAndTomorrow(t *testing.T) {
} }
} }
func TestBuildDerivedHourlyUsesRollingWindowFacts(t *testing.T) {
location := testLocation()
resolved := resolveForTest(t, report.Hourly, mustParse("2026-05-29T08:30:00-05:00"), location)
bundle := testBundle(location)
bundle.Alerts = &weatherdata.AlertRun{Alerts: []json.RawMessage{
json.RawMessage(`{"event":"Expired Advisory","headline":"Ends at start","severity":"Minor","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T08:30:00-05:00"}`),
json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T11:00:00-05:00","expires":"2026-05-29T15:00:00-05:00"}`),
json.RawMessage(`{"event":"Evening Advisory","headline":"Starts at end","severity":"Minor","effective":"2026-05-29T14:30:00-05:00","expires":"2026-05-29T18:00:00-05:00"}`),
}}
bundle.SPCConvectiveOutlooks = testConvectiveOutlookRun(location)
derived, err := BuildDerived(BuildDerivedRequest{
Resolved: resolved,
Timezone: location.String(),
Dayparts: testDayparts(),
Collected: BuildCollected(bundle),
})
if err != nil {
t.Fatalf("BuildDerived() error = %v", err)
}
if len(derived.ValidPeriodHourlyPeriods) != 4 {
t.Fatalf("ValidPeriodHourlyPeriods length = %d, want 4 rolling-window hours", len(derived.ValidPeriodHourlyPeriods))
}
if derived.ValidPeriodHourlyPeriods[0].StartTime.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" ||
derived.ValidPeriodHourlyPeriods[3].StartTime.Format(time.RFC3339) != "2026-05-29T14:00:00-05:00" {
t.Fatalf("ValidPeriodHourlyPeriods = %#v, want only hours overlapping 8:30 AM-2:30 PM", derived.ValidPeriodHourlyPeriods)
}
if len(derived.ValidPeriodNarrativePeriods) != 1 {
t.Fatalf("ValidPeriodNarrativePeriods length = %d, want overlapping narrative period", len(derived.ValidPeriodNarrativePeriods))
}
if len(derived.DailySummaries) != 0 || len(derived.DaypartSummaries) != 0 || derived.StormWindowSummary != nil {
t.Fatalf("hourly summaries daily=%#v daypart=%#v storm=%#v, want none", derived.DailySummaries, derived.DaypartSummaries, derived.StormWindowSummary)
}
if derived.PrecipTiming.FirstPrecipitation == nil || derived.PrecipTiming.FirstPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" {
t.Fatalf("PrecipTiming.FirstPrecipitation = %#v, want first selected rainy hour", derived.PrecipTiming.FirstPrecipitation)
}
if derived.PrecipTiming.LastPrecipitation == nil || derived.PrecipTiming.LastPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T14:00:00-05:00" {
t.Fatalf("PrecipTiming.LastPrecipitation = %#v, want final selected rainy window end", derived.PrecipTiming.LastPrecipitation)
}
if len(derived.PrecipTiming.PrecipitationWindows) != 2 {
t.Fatalf("PrecipTiming.PrecipitationWindows = %#v, want two hourly windows", derived.PrecipTiming.PrecipitationWindows)
}
if len(derived.AlertOverlaps) != 1 || derived.AlertOverlaps[0].Event != "Flood Watch" {
t.Fatalf("AlertOverlaps = %#v, want only alert overlapping hourly period", derived.AlertOverlaps)
}
if derived.AlertOverlaps[0].Overlap.End.Format(time.RFC3339) != "2026-05-29T14:30:00-05:00" {
t.Fatalf("Alert overlap end = %s, want clipped to hourly end", derived.AlertOverlaps[0].Overlap.End.Format(time.RFC3339))
}
if got, want := outlookIDs(derived.SPCConvectiveOutlooks), []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic"}; strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("SPCConvectiveOutlooks IDs = %#v, want %#v", got, want)
}
if got, want := discussionHeadlines(derived.SPCConvectiveDiscussions), []string{"day1 early", "day1 late"}; strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("SPCConvectiveDiscussions = %#v, want retained discussions for overlapping SPC day %#v", got, want)
}
}
func TestBuildDerivedStormBuildsWindowSummary(t *testing.T) { func TestBuildDerivedStormBuildsWindowSummary(t *testing.T) {
location := testLocation() location := testLocation()
resolved := resolveStormForTest(t, location) resolved := resolveStormForTest(t, location)
@@ -166,6 +239,124 @@ func TestBuildDerivedStormBuildsWindowSummary(t *testing.T) {
} }
} }
func TestBuildDerivedSelectsSPCConvectiveOutlooksByValidPeriod(t *testing.T) {
location := testLocation()
now := mustParse("2026-05-29T08:00:00-05:00")
bundle := testBundle(location)
bundle.SPCConvectiveOutlooks = testConvectiveOutlookRun(location)
tests := []struct {
name string
resolved report.Resolved
wantOutlookIDs []string
wantDiscussion []string
}{
{
name: "daily",
resolved: resolveForTest(t, report.Daily, now, location),
wantOutlookIDs: []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic"},
wantDiscussion: []string{"day1 early", "day1 late"},
},
{
name: "daily tomorrow",
resolved: resolveForTest(t, report.Tomorrow, now, location),
wantOutlookIDs: []string{"sat-enhanced"},
wantDiscussion: []string{"day2"},
},
{
name: "three day",
resolved: resolveForTest(t, report.ThreeDay, now, location),
wantOutlookIDs: []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic", "sat-enhanced", "sun-slight"},
wantDiscussion: []string{"day1 early", "day1 late", "day2", "day3"},
},
{
name: "weekend",
resolved: resolveForTest(t, report.Weekend, now, location),
wantOutlookIDs: []string{"sat-enhanced", "sun-slight"},
wantDiscussion: []string{"day2", "day3"},
},
{
name: "storm",
resolved: resolveStormForTest(t, location),
wantOutlookIDs: []string{"fri-storm", "fri-low"},
wantDiscussion: []string{"day1 early", "day1 late"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
derived, err := BuildDerived(BuildDerivedRequest{
Resolved: tt.resolved,
Timezone: location.String(),
Dayparts: testDayparts(),
Collected: BuildCollected(bundle),
})
if err != nil {
t.Fatalf("BuildDerived() error = %v", err)
}
if got := outlookIDs(derived.SPCConvectiveOutlooks); strings.Join(got, ",") != strings.Join(tt.wantOutlookIDs, ",") {
t.Fatalf("SPCConvectiveOutlooks IDs = %#v, want %#v", got, tt.wantOutlookIDs)
}
if got := discussionHeadlines(derived.SPCConvectiveDiscussions); strings.Join(got, ",") != strings.Join(tt.wantDiscussion, ",") {
t.Fatalf("SPCConvectiveDiscussions = %#v, want %#v", got, tt.wantDiscussion)
}
})
}
}
func TestBuildDerivedUnsupportedReportReturnsActionableError(t *testing.T) {
location := testLocation()
resolved := resolveForTest(t, report.Hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
resolved.Definition.ID = report.ID("future_report")
_, err := BuildDerived(BuildDerivedRequest{
Resolved: resolved,
Timezone: location.String(),
Dayparts: testDayparts(),
Collected: BuildCollected(testBundle(location)),
})
if err == nil || !strings.Contains(err.Error(), `derived facts are not implemented for report "future_report"`) {
t.Fatalf("BuildDerived() error = %v, want actionable unsupported report error", err)
}
}
func TestBuildDerivedSPCConvectiveOutlooksDistinguishesMissingAndCheckedEmpty(t *testing.T) {
location := testLocation()
resolved := resolveForTest(t, report.Daily, mustParse("2026-05-29T08:00:00-05:00"), location)
bundle := testBundle(location)
bundle.SPCConvectiveOutlooks = nil
derived, err := BuildDerived(BuildDerivedRequest{
Resolved: resolved,
Timezone: location.String(),
Dayparts: testDayparts(),
Collected: BuildCollected(bundle),
})
if err != nil {
t.Fatalf("BuildDerived(missing source) error = %v", err)
}
if derived.SPCConvectiveOutlooks != nil || derived.SPCConvectiveDiscussions != nil {
t.Fatalf("missing source derived outlooks=%#v discussions=%#v, want nil slices", derived.SPCConvectiveOutlooks, derived.SPCConvectiveDiscussions)
}
bundle.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{Outlooks: []weatherdata.ConvectiveOutlook{}, Discussions: []weatherdata.ConvectiveOutlookDiscussion{}}
derived, err = BuildDerived(BuildDerivedRequest{
Resolved: resolved,
Timezone: location.String(),
Dayparts: testDayparts(),
Collected: BuildCollected(bundle),
})
if err != nil {
t.Fatalf("BuildDerived(checked empty source) error = %v", err)
}
if derived.SPCConvectiveOutlooks == nil || len(derived.SPCConvectiveOutlooks) != 0 {
t.Fatalf("checked empty outlooks = %#v, want non-nil empty slice", derived.SPCConvectiveOutlooks)
}
if derived.SPCConvectiveDiscussions == nil || len(derived.SPCConvectiveDiscussions) != 0 {
t.Fatalf("checked empty discussions = %#v, want non-nil empty slice", derived.SPCConvectiveDiscussions)
}
}
func testBundle(location *time.Location) *weatherdata.Bundle { func testBundle(location *time.Location) *weatherdata.Bundle {
return &weatherdata.Bundle{ return &weatherdata.Bundle{
FetchedAt: mustParse("2026-05-29T10:00:00Z"), FetchedAt: mustParse("2026-05-29T10:00:00Z"),
@@ -193,6 +384,65 @@ func testBundle(location *time.Location) *weatherdata.Bundle {
} }
} }
func testConvectiveOutlookRun(location *time.Location) *weatherdata.ConvectiveOutlookRun {
rank1 := 1
rank2 := 2
rank3 := 3
rank4 := 4
rank5 := 5
return &weatherdata.ConvectiveOutlookRun{
Outlooks: []weatherdata.ConvectiveOutlook{
convectiveOutlook(location, "mon-outside", 4, "categorical", "MDT", &rank5, "2026-06-01T00:00:00-05:00", "2026-06-02T00:00:00-05:00"),
convectiveOutlook(location, "sun-slight", 3, "categorical", "SLGT", &rank3, "2026-05-31T06:00:00-05:00", "2026-06-01T00:00:00-05:00"),
convectiveOutlook(location, "fri-low", 1, "categorical", "MRGL", &rank1, "2026-05-29T06:00:00-05:00", "2026-05-29T12:00:00-05:00"),
convectiveOutlook(location, "fri-missing-rank", 1, "categorical", "GEN", nil, "2026-05-29T08:00:00-05:00", "2026-05-29T10:00:00-05:00"),
convectiveOutlook(location, "sat-enhanced", 2, "categorical", "ENH", &rank4, "2026-05-30T01:00:00-05:00", "2026-05-30T12:00:00-05:00"),
convectiveOutlook(location, "fri-high", 1, "categorical", "SLGT", &rank3, "2026-05-29T07:00:00-05:00", "2026-05-29T11:00:00-05:00"),
convectiveOutlook(location, "fri-probabilistic", 1, "probabilistic", "5%", &rank2, "2026-05-29T05:00:00-05:00", "2026-05-29T10:00:00-05:00"),
convectiveOutlook(location, "fri-storm", 1, "categorical", "SLGT", &rank2, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00"),
},
Discussions: []weatherdata.ConvectiveOutlookDiscussion{
{Day: 4, Headline: "day4", UpdatedAt: ptrTime(mustParse("2026-05-31T10:00:00-05:00"))},
{Day: 1, Headline: "day1 late", UpdatedAt: ptrTime(mustParse("2026-05-29T09:00:00-05:00"))},
{Day: 3, Headline: "day3", UpdatedAt: ptrTime(mustParse("2026-05-31T08:00:00-05:00"))},
{Day: 1, Headline: "day1 early", UpdatedAt: ptrTime(mustParse("2026-05-29T08:00:00-05:00"))},
{Day: 2, Headline: "day2", UpdatedAt: ptrTime(mustParse("2026-05-30T08:00:00-05:00"))},
},
}
}
func convectiveOutlook(location *time.Location, id string, day int, outlookType string, label string, rank *int, validFrom string, validTo string) weatherdata.ConvectiveOutlook {
return weatherdata.ConvectiveOutlook{
ID: id,
Day: day,
OutlookType: outlookType,
Label: label,
SeverityRank: rank,
ValidFrom: mustParse(validFrom).In(location),
ValidTo: mustParse(validTo).In(location),
}
}
func outlookIDs(outlooks []weatherdata.ConvectiveOutlook) []string {
out := make([]string, 0, len(outlooks))
for _, outlook := range outlooks {
out = append(out, outlook.ID)
}
return out
}
func discussionHeadlines(discussions []weatherdata.ConvectiveOutlookDiscussion) []string {
out := make([]string, 0, len(discussions))
for _, discussion := range discussions {
out = append(out, discussion.Headline)
}
return out
}
func ptrTime(value time.Time) *time.Time {
return &value
}
func hour(location *time.Location, start string, end string, text string, precip float64, gust float64) weatherdata.ForecastPeriod { func hour(location *time.Location, start string, end string, text string, precip float64, gust float64) weatherdata.ForecastPeriod {
temperature := 70.0 temperature := 70.0
return weatherdata.ForecastPeriod{ return weatherdata.ForecastPeriod{
@@ -215,7 +465,11 @@ func testDayparts() []forecast.DaypartDefinition {
func resolveForTest(t *testing.T, id report.ID, now time.Time, location *time.Location) report.Resolved { func resolveForTest(t *testing.T, id report.ID, now time.Time, location *time.Location) report.Resolved {
t.Helper() t.Helper()
resolved, err := report.Resolve(id, report.ResolveRequest{Now: now, Location: location}) req := report.ResolveRequest{Now: now, Location: location}
if id == report.Daily {
req.Date = now
}
resolved, err := report.Resolve(id, req)
if err != nil { if err != nil {
t.Fatalf("resolve %s: %v", id, err) t.Fatalf("resolve %s: %v", id, err)
} }

View File

@@ -0,0 +1,195 @@
package generatedtext
import (
"fmt"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/reporttemplate"
)
const (
schemaIDHourly = "hourly"
schemaIDDaily = "daily"
schemaIDToday = "today"
schemaIDTomorrow = "tomorrow"
templateIDHourly = "hourly"
templateIDDaily = "daily"
templateIDToday = "today"
templateIDTomorrow = "tomorrow"
)
type validator func([]byte) (any, []byte, error)
type renderContextBuilder func(report.ID, string, briefing.Metadata, module.Snapshot, facts.CollectedFacts, facts.DerivedFacts, any) (any, error)
type catalogEntry struct {
schemaID string
templateID string
validate validator
renderContextBuilder renderContextBuilder
}
type Handler struct {
reportID report.ID
schemaID string
templateID string
validate validator
renderContextBuilder renderContextBuilder
}
var catalog = []catalogEntry{
{
schemaID: schemaIDHourly,
templateID: templateIDHourly,
validate: validateHourly,
renderContextBuilder: buildHourlyContext,
},
{
schemaID: schemaIDDaily,
templateID: templateIDDaily,
validate: validateDaily,
renderContextBuilder: buildDailyContext,
},
{
schemaID: schemaIDToday,
templateID: templateIDToday,
validate: validateToday,
renderContextBuilder: buildTodayContext,
},
{
schemaID: schemaIDTomorrow,
templateID: templateIDTomorrow,
validate: validateTomorrow,
renderContextBuilder: buildTomorrowContext,
},
}
func LookupDefinition(definition report.Definition) (Handler, error) {
if definition.GenerationMode != report.GenerationModeGeneratedTextTemplate {
return Handler{}, fmt.Errorf("report %q uses generation mode %q, not %q", definition.ID, definition.GenerationMode, report.GenerationModeGeneratedTextTemplate)
}
var schemaKnown, templateKnown bool
for _, entry := range catalog {
if entry.schemaID == definition.GeneratedTextSchemaID {
schemaKnown = true
}
if entry.templateID == definition.TemplateID {
templateKnown = true
}
if entry.schemaID == definition.GeneratedTextSchemaID && entry.templateID == definition.TemplateID {
return Handler{
reportID: definition.ID,
schemaID: entry.schemaID,
templateID: entry.templateID,
validate: entry.validate,
renderContextBuilder: entry.renderContextBuilder,
}, nil
}
}
if !schemaKnown {
return Handler{}, fmt.Errorf("generated text schema %q is not supported for report %q", definition.GeneratedTextSchemaID, definition.ID)
}
if !templateKnown {
return Handler{}, fmt.Errorf("report template %q is not supported for report %q", definition.TemplateID, definition.ID)
}
return Handler{}, fmt.Errorf("generated text schema %q and report template %q are not supported together for report %q", definition.GeneratedTextSchemaID, definition.TemplateID, definition.ID)
}
func (h Handler) SchemaID() string {
return h.schemaID
}
func (h Handler) TemplateID() string {
return h.templateID
}
func (h Handler) Schema() ([]byte, error) {
data, err := reporttemplate.Schema(h.schemaID)
if err != nil {
return nil, fmt.Errorf("load generated text schema %q for report %q: %w", h.schemaID, h.reportID, err)
}
return data, nil
}
func (h Handler) Template() (string, error) {
source, err := reporttemplate.Template(h.templateID)
if err != nil {
return "", fmt.Errorf("load report template %q for report %q: %w", h.templateID, h.reportID, err)
}
return source, nil
}
func (h Handler) Validate(data []byte) (any, []byte, error) {
if h.validate == nil {
return nil, nil, fmt.Errorf("generated text validator is not registered for schema %q on report %q", h.schemaID, h.reportID)
}
return h.validate(data)
}
func (h Handler) BuildRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
if h.renderContextBuilder == nil {
return nil, fmt.Errorf("render-context builder is not registered for template %q on report %q", h.templateID, h.reportID)
}
return h.renderContextBuilder(h.reportID, h.templateID, metadata, snapshot, collected, derived, generated)
}
func (h Handler) Render(data any) ([]byte, error) {
rendered, err := reporttemplate.Render(h.templateID, data)
if err != nil {
return nil, fmt.Errorf("render report template %q for report %q: %w", h.templateID, h.reportID, err)
}
return rendered, nil
}
func validateHourly(data []byte) (any, []byte, error) {
return ValidateHourly(data)
}
func validateDaily(data []byte) (any, []byte, error) {
return ValidateDaily(data)
}
func validateToday(data []byte) (any, []byte, error) {
return ValidateToday(data)
}
func validateTomorrow(data []byte) (any, []byte, error) {
return ValidateTomorrow(data)
}
func buildHourlyContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
hourly, ok := generated.(Hourly)
if !ok {
return nil, fmt.Errorf("report template %q requires hourly generated text for report %q", templateID, reportID)
}
return BuildHourlyRenderContext(metadata, snapshot, hourly, collected, derived)
}
func buildDailyContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
daily, ok := generated.(Daily)
if !ok {
return nil, fmt.Errorf("report template %q requires daily generated text for report %q", templateID, reportID)
}
return BuildDailyRenderContext(metadata, snapshot, daily, collected, derived)
}
func buildTodayContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
today, ok := generated.(Today)
if !ok {
return nil, fmt.Errorf("report template %q requires today generated text for report %q", templateID, reportID)
}
return BuildTodayRenderContext(metadata, snapshot, today, collected, derived)
}
func buildTomorrowContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) {
tomorrow, ok := generated.(Tomorrow)
if !ok {
return nil, fmt.Errorf("report template %q requires tomorrow generated text for report %q", templateID, reportID)
}
return BuildTomorrowRenderContext(metadata, snapshot, tomorrow, collected, derived)
}

View File

@@ -0,0 +1,324 @@
package generatedtext
import (
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
func TestCatalogCompleteForGeneratedTextTemplateReports(t *testing.T) {
for _, definition := range report.DefaultRegistry().All() {
if definition.GenerationMode != report.GenerationModeGeneratedTextTemplate {
continue
}
t.Run(string(definition.ID), func(t *testing.T) {
handler, err := LookupDefinition(definition)
if err != nil {
t.Fatalf("LookupDefinition() error = %v", err)
}
if handler.SchemaID() != definition.GeneratedTextSchemaID {
t.Fatalf("SchemaID() = %q, want %q", handler.SchemaID(), definition.GeneratedTextSchemaID)
}
if handler.TemplateID() != definition.TemplateID {
t.Fatalf("TemplateID() = %q, want %q", handler.TemplateID(), definition.TemplateID)
}
if handler.validate == nil {
t.Fatal("validator is nil")
}
if handler.renderContextBuilder == nil {
t.Fatal("render-context builder is nil")
}
if schema, err := handler.Schema(); err != nil {
t.Fatalf("Schema() error = %v", err)
} else if len(schema) == 0 {
t.Fatal("Schema() returned empty asset")
}
if template, err := handler.Template(); err != nil {
t.Fatalf("Template() error = %v", err)
} else if template == "" {
t.Fatal("Template() returned empty asset")
}
})
}
}
func TestCatalogLookupRejectsUnsupportedSchemaAndTemplate(t *testing.T) {
tests := []struct {
name string
definition report.Definition
want []string
}{
{
name: "schema",
definition: func() report.Definition {
definition := report.DefaultRegistry().MustLookup(report.Hourly)
definition.GeneratedTextSchemaID = "missing-schema"
return definition
}(),
want: []string{`generated text schema "missing-schema"`, `report "hourly"`},
},
{
name: "template",
definition: func() report.Definition {
definition := report.DefaultRegistry().MustLookup(report.Hourly)
definition.TemplateID = "missing-template"
return definition
}(),
want: []string{`report template "missing-template"`, `report "hourly"`},
},
{
name: "combination",
definition: func() report.Definition {
definition := report.DefaultRegistry().MustLookup(report.Hourly)
definition.TemplateID = "tomorrow"
return definition
}(),
want: []string{`generated text schema "hourly"`, `report template "tomorrow"`, `report "hourly"`},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := LookupDefinition(test.definition)
if err == nil {
t.Fatal("LookupDefinition() error = nil, want error")
}
for _, want := range test.want {
if !strings.Contains(err.Error(), want) {
t.Fatalf("LookupDefinition() error = %v, want %q", err, want)
}
}
})
}
}
func TestCatalogLookupSupportsTodayDefinition(t *testing.T) {
definition := report.Definition{
ID: report.Today,
GenerationMode: report.GenerationModeGeneratedTextTemplate,
GeneratedTextSchemaID: "today",
TemplateID: "today",
}
handler, err := LookupDefinition(definition)
if err != nil {
t.Fatalf("LookupDefinition(today) error = %v", err)
}
if handler.SchemaID() != "today" || handler.TemplateID() != "today" {
t.Fatalf("handler IDs = %q/%q, want today/today", handler.SchemaID(), handler.TemplateID())
}
if schema, err := handler.Schema(); err != nil {
t.Fatalf("Schema() error = %v", err)
} else if len(schema) == 0 {
t.Fatal("Schema() returned empty asset")
}
if template, err := handler.Template(); err != nil {
t.Fatalf("Template() error = %v", err)
} else if !strings.Contains(template, "# {{ .Report.Title }}") {
t.Fatalf("Template() = %q, want Today template source", template)
}
}
func TestCatalogLookupSupportsDailyDefinitionAssets(t *testing.T) {
definition := report.Definition{
ID: report.Daily,
GenerationMode: report.GenerationModeGeneratedTextTemplate,
GeneratedTextSchemaID: "daily",
TemplateID: "daily",
}
handler, err := LookupDefinition(definition)
if err != nil {
t.Fatalf("LookupDefinition(daily) error = %v", err)
}
if handler.SchemaID() != "daily" || handler.TemplateID() != "daily" {
t.Fatalf("handler IDs = %q/%q, want daily/daily", handler.SchemaID(), handler.TemplateID())
}
if schema, err := handler.Schema(); err != nil {
t.Fatalf("Schema() error = %v", err)
} else if len(schema) == 0 {
t.Fatal("Schema() returned empty asset")
}
if template, err := handler.Template(); err != nil {
t.Fatalf("Template() error = %v", err)
} else if !strings.Contains(template, "# {{ .Report.Title }}") {
t.Fatalf("Template() = %q, want Daily template source", template)
}
}
func TestCatalogValidationDispatchSupportsKnownSchemas(t *testing.T) {
hourlyHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Hourly))
if err != nil {
t.Fatalf("LookupDefinition(hourly) error = %v", err)
}
hourly, normalized, err := hourlyHandler.Validate([]byte(`{
"summary": " Storm chances increase. ",
"forecast_discussion": " A front will keep the region unsettled. "
}`))
if err != nil {
t.Fatalf("Validate(hourly) error = %v", err)
}
if _, ok := hourly.(Hourly); !ok {
t.Fatalf("hourly generated text type = %T, want generatedtext.Hourly", hourly)
}
if !strings.Contains(string(normalized), `"summary":"Storm chances increase."`) {
t.Fatalf("hourly normalized text = %s, want trimmed summary", normalized)
}
tomorrowHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Tomorrow))
if err != nil {
t.Fatalf("LookupDefinition(tomorrow) error = %v", err)
}
tomorrow, normalized, err := tomorrowHandler.Validate([]byte(`{
"summary": " Storms become more likely tomorrow. ",
"forecast_discussion": [" A front will keep showers in the forecast. ", ""]
}`))
if err != nil {
t.Fatalf("Validate(tomorrow) error = %v", err)
}
if _, ok := tomorrow.(Tomorrow); !ok {
t.Fatalf("tomorrow generated text type = %T, want generatedtext.Tomorrow", tomorrow)
}
if !strings.Contains(string(normalized), `"forecast_discussion":["A front will keep showers in the forecast."]`) {
t.Fatalf("tomorrow normalized text = %s, want trimmed discussion paragraph", normalized)
}
todayHandler, err := LookupDefinition(report.Definition{
ID: report.Today,
GenerationMode: report.GenerationModeGeneratedTextTemplate,
GeneratedTextSchemaID: "today",
TemplateID: "today",
})
if err != nil {
t.Fatalf("LookupDefinition(today) error = %v", err)
}
today, normalized, err := todayHandler.Validate([]byte(`{
"summary": " Showers are likely today. ",
"forecast_discussion": [" A front will keep rain chances elevated. ", ""]
}`))
if err != nil {
t.Fatalf("Validate(today) error = %v", err)
}
if _, ok := today.(Today); !ok {
t.Fatalf("today generated text type = %T, want generatedtext.Today", today)
}
if !strings.Contains(string(normalized), `"forecast_discussion":["A front will keep rain chances elevated."]`) {
t.Fatalf("today normalized text = %s, want trimmed discussion paragraph", normalized)
}
dailyHandler, err := LookupDefinition(report.Definition{
ID: report.Daily,
GenerationMode: report.GenerationModeGeneratedTextTemplate,
GeneratedTextSchemaID: "daily",
TemplateID: "daily",
})
if err != nil {
t.Fatalf("LookupDefinition(daily) error = %v", err)
}
daily, normalized, err := dailyHandler.Validate([]byte(`{
"summary": " Showers are possible during the selected day. ",
"forecast_discussion": [" A front will keep rain chances in the forecast. ", ""]
}`))
if err != nil {
t.Fatalf("Validate(daily) error = %v", err)
}
if _, ok := daily.(Daily); !ok {
t.Fatalf("daily generated text type = %T, want generatedtext.Daily", daily)
}
if !strings.Contains(string(normalized), `"forecast_discussion":["A front will keep rain chances in the forecast."]`) {
t.Fatalf("daily normalized text = %s, want trimmed discussion paragraph", normalized)
}
}
func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
hourlyHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Hourly))
if err != nil {
t.Fatalf("LookupDefinition(hourly) error = %v", err)
}
_, err = hourlyHandler.BuildRenderContext(testMetadata(), testSnapshot(t), testCollected(), testDerived(), Tomorrow{
Summary: "Storms become more likely tomorrow.",
ForecastDiscussion: []string{"A front will keep showers in the forecast."},
})
if err == nil {
t.Fatal("BuildRenderContext(hourly) error = nil, want type mismatch")
}
if !strings.Contains(err.Error(), `requires hourly generated text`) {
t.Fatalf("BuildRenderContext(hourly) error = %v, want hourly generated text requirement", err)
}
tomorrowHandler, err := LookupDefinition(report.DefaultRegistry().MustLookup(report.Tomorrow))
if err != nil {
t.Fatalf("LookupDefinition(tomorrow) error = %v", err)
}
_, err = tomorrowHandler.BuildRenderContext(testTomorrowMetadata(), testTomorrowSnapshot(t), testCollected(), testTomorrowDerived(), Hourly{
Summary: "Storm chances increase.",
ForecastDiscussion: "A front will keep showers in the forecast.",
})
if err == nil {
t.Fatal("BuildRenderContext(tomorrow) error = nil, want type mismatch")
}
if !strings.Contains(err.Error(), `requires tomorrow generated text`) {
t.Fatalf("BuildRenderContext(tomorrow) error = %v, want tomorrow generated text requirement", err)
}
todayHandler, err := LookupDefinition(report.Definition{
ID: report.Today,
GenerationMode: report.GenerationModeGeneratedTextTemplate,
GeneratedTextSchemaID: "today",
TemplateID: "today",
})
if err != nil {
t.Fatalf("LookupDefinition(today) error = %v", err)
}
_, err = todayHandler.BuildRenderContext(testTodayMetadata(), testTodaySnapshot(t), testCollected(), testTodayDerived(), Tomorrow{
Summary: "Storms become more likely tomorrow.",
ForecastDiscussion: []string{"A front will keep showers in the forecast."},
})
if err == nil {
t.Fatal("BuildRenderContext(today) error = nil, want type mismatch")
}
if !strings.Contains(err.Error(), `requires today generated text`) {
t.Fatalf("BuildRenderContext(today) error = %v, want today generated text requirement", err)
}
dailyHandler, err := LookupDefinition(report.Definition{
ID: report.Daily,
GenerationMode: report.GenerationModeGeneratedTextTemplate,
GeneratedTextSchemaID: "daily",
TemplateID: "daily",
})
if err != nil {
t.Fatalf("LookupDefinition(daily) error = %v", err)
}
_, err = dailyHandler.BuildRenderContext(testDailyMetadata(), testDailySnapshot(t), testCollected(), testDailyDerived(), Tomorrow{
Summary: "Storms become more likely tomorrow.",
ForecastDiscussion: []string{"A front will keep showers in the forecast."},
})
if err == nil {
t.Fatal("BuildRenderContext(daily) error = nil, want type mismatch")
}
if !strings.Contains(err.Error(), `requires daily generated text`) {
t.Fatalf("BuildRenderContext(daily) error = %v, want daily generated text requirement", err)
}
}
func TestCatalogBuildRenderContextSupportsDaily(t *testing.T) {
handler, err := LookupDefinition(report.Definition{
ID: report.Daily,
GenerationMode: report.GenerationModeGeneratedTextTemplate,
GeneratedTextSchemaID: "daily",
TemplateID: "daily",
})
if err != nil {
t.Fatalf("LookupDefinition(daily) error = %v", err)
}
ctx, err := handler.BuildRenderContext(testDailyMetadata(), testDailySnapshot(t), testCollected(), testDailyDerived(), Daily{
Summary: "Showers are possible during the selected day.",
ForecastDiscussion: []string{"A front will keep rain chances in the forecast."},
})
if err != nil {
t.Fatalf("BuildRenderContext(daily) error = %v", err)
}
if _, ok := ctx.(DailyRenderContext); !ok {
t.Fatalf("BuildRenderContext(daily) = %T, want DailyRenderContext", ctx)
}
}

View File

@@ -0,0 +1,37 @@
package generatedtext
import (
"fmt"
"strings"
)
type Daily struct {
Summary string `json:"summary"`
ForecastDiscussion []string `json:"forecast_discussion"`
PrecipitationTiming string `json:"precipitation_timing,omitempty"`
Confidence string `json:"confidence,omitempty"`
}
func ValidateDaily(data []byte) (Daily, []byte, error) {
value, err := decodeGeneratedText[Daily](data, "daily")
if err != nil {
return Daily{}, nil, err
}
value.Summary = strings.TrimSpace(value.Summary)
value.PrecipitationTiming = strings.TrimSpace(value.PrecipitationTiming)
value.Confidence = strings.TrimSpace(value.Confidence)
value.ForecastDiscussion = trimNonEmpty(value.ForecastDiscussion)
if value.Summary == "" {
return Daily{}, nil, fmt.Errorf("daily generated text summary is required")
}
if len(value.ForecastDiscussion) == 0 {
return Daily{}, nil, fmt.Errorf("daily generated text forecast discussion is required")
}
normalized, err := normalizeGeneratedText(value, "daily")
if err != nil {
return Daily{}, nil, err
}
return value, normalized, nil
}

View File

@@ -0,0 +1,111 @@
package generatedtext
import (
"strings"
"testing"
)
func TestValidateDailyNormalizesJSON(t *testing.T) {
value, normalized, err := ValidateDaily([]byte(`{
"summary": " Showers are possible during the selected day. ",
"forecast_discussion": [
" A front will keep rain chances in the forecast. ",
"",
" Temperatures stay seasonable by afternoon. "
],
"precipitation_timing": " Rain is most likely during the afternoon. ",
"confidence": " Medium "
}`))
if err != nil {
t.Fatalf("ValidateDaily() error = %v", err)
}
if value.Summary != "Showers are possible during the selected day." {
t.Fatalf("Summary = %q, want trimmed summary", value.Summary)
}
if strings.Join(value.ForecastDiscussion, "|") != "A front will keep rain chances in the forecast.|Temperatures stay seasonable by afternoon." {
t.Fatalf("ForecastDiscussion = %#v, want trimmed non-empty paragraphs", value.ForecastDiscussion)
}
if value.PrecipitationTiming != "Rain is most likely during the afternoon." {
t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming)
}
want := `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast.","Temperatures stay seasonable by afternoon."],"precipitation_timing":"Rain is most likely during the afternoon.","confidence":"Medium"}`
if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want)
}
}
func TestValidateDailyOmitsEmptyOptionalFields(t *testing.T) {
_, normalized, err := ValidateDaily([]byte(`{
"summary": "Showers are possible during the selected day.",
"forecast_discussion": ["A front will keep rain chances in the forecast."],
"precipitation_timing": " ",
"confidence": " "
}`))
if err != nil {
t.Fatalf("ValidateDaily() error = %v", err)
}
want := `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."]}`
if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want)
}
}
func TestValidateDailyRejectsInvalidInput(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{
name: "malformed",
in: `{`,
want: "decode daily generated text",
},
{
name: "unknown field",
in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."],"extra":"value"}`,
want: `unknown field "extra"`,
},
{
name: "missing summary",
in: `{"forecast_discussion":["A front will keep rain chances in the forecast."]}`,
want: "summary is required",
},
{
name: "blank summary",
in: `{"summary":" ","forecast_discussion":["A front will keep rain chances in the forecast."]}`,
want: "summary is required",
},
{
name: "missing forecast discussion",
in: `{"summary":"Showers are possible during the selected day."}`,
want: "forecast discussion is required",
},
{
name: "blank forecast discussion",
in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":[" ",""]}`,
want: "forecast discussion is required",
},
{
name: "forecast discussion wrong type",
in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":"A front will keep rain chances in the forecast."}`,
want: "cannot unmarshal string",
},
{
name: "multiple values",
in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."]} {}`,
want: "multiple JSON values",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, _, err := ValidateDaily([]byte(test.in))
if err == nil {
t.Fatal("ValidateDaily() error = nil, want error")
}
if !strings.Contains(err.Error(), test.want) {
t.Fatalf("ValidateDaily() error = %v, want %q", err, test.want)
}
})
}
}

View File

@@ -0,0 +1,38 @@
// Package generatedtext validates structured LLM text and render contexts.
package generatedtext
import (
"fmt"
"strings"
)
type Hourly struct {
Summary string `json:"summary"`
ForecastDiscussion string `json:"forecast_discussion"`
PrecipitationTiming string `json:"precipitation_timing,omitempty"`
Confidence string `json:"confidence,omitempty"`
}
func ValidateHourly(data []byte) (Hourly, []byte, error) {
value, err := decodeGeneratedText[Hourly](data, "hourly")
if err != nil {
return Hourly{}, nil, err
}
value.Summary = strings.TrimSpace(value.Summary)
value.ForecastDiscussion = strings.TrimSpace(value.ForecastDiscussion)
value.PrecipitationTiming = strings.TrimSpace(value.PrecipitationTiming)
value.Confidence = strings.TrimSpace(value.Confidence)
if value.Summary == "" {
return Hourly{}, nil, fmt.Errorf("hourly generated text summary is required")
}
if value.ForecastDiscussion == "" {
return Hourly{}, nil, fmt.Errorf("hourly generated text forecast discussion is required")
}
normalized, err := normalizeGeneratedText(value, "hourly")
if err != nil {
return Hourly{}, nil, err
}
return value, normalized, nil
}

View 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)
}
})
}
}

View File

@@ -0,0 +1,33 @@
package generatedtext
import (
"bytes"
"encoding/json"
"fmt"
"io"
)
func decodeGeneratedText[T any](data []byte, name string) (T, error) {
var value T
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&value); err != nil {
return value, fmt.Errorf("decode %s generated text: %w", name, err)
}
var extra any
if err := decoder.Decode(&extra); err != nil {
if err != io.EOF {
return value, fmt.Errorf("decode %s generated text: %w", name, err)
}
return value, nil
}
return value, fmt.Errorf("decode %s generated text: multiple JSON values", name)
}
func normalizeGeneratedText[T any](value T, name string) ([]byte, error) {
normalized, err := json.Marshal(value)
if err != nil {
return nil, fmt.Errorf("normalize %s generated text: %w", name, err)
}
return normalized, nil
}

View File

@@ -0,0 +1,773 @@
package generatedtext
import (
"fmt"
"sort"
"strings"
"time"
"unicode"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
type HourlyRenderContext struct {
Report HourlyReportContext
GeneratedText Hourly
Modules HourlyTemplateModules
Collected facts.CollectedFacts
Derived facts.DerivedFacts
}
type HourlyReportContext struct {
Title string
LocationName string
GeneratedAt time.Time
GeneratedAtLabel string
ValidPeriod timeutil.Period
ValidPeriodLabel string
Timezone string
}
type HourlyTemplateModules struct {
Metadata *briefing.MetadataModule
CurrentConditions *briefing.CurrentConditionsModule
HourlyForecast *briefing.HourlyForecastModule
PrecipTiming *briefing.PrecipTimingModule
AlertDigest *briefing.AlertDigestModule
SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule
AreaForecastDiscussion *briefing.AreaForecastDiscussionModule
SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule
WeatherStory *briefing.WeatherStoryModule
}
type TomorrowRenderContext struct {
Report TomorrowReportContext
GeneratedText Tomorrow
Modules TomorrowTemplateModules
Collected facts.CollectedFacts
Derived facts.DerivedFacts
}
type DailyRenderContext struct {
Report DailyReportContext
GeneratedText Daily
Modules DailyTemplateModules
Collected facts.CollectedFacts
Derived facts.DerivedFacts
}
type TodayRenderContext struct {
Report TodayReportContext
GeneratedText Today
Modules TodayTemplateModules
Collected facts.CollectedFacts
Derived facts.DerivedFacts
}
type TodayReportContext struct {
Title string
ForecastDate time.Time
ForecastDateLabel string
ForecastDayName string
GeneratedAt time.Time
GeneratedAtLabel string
ValidPeriod timeutil.Period
Timezone string
}
type TodayTemplateModules struct {
Metadata *briefing.MetadataModule
CurrentConditions *briefing.CurrentConditionsModule
HourlyForecast *briefing.HourlyForecastModule
DerivedDailySummary *briefing.DerivedDailySummaryModule
DerivedDaypartSummaries *map[string]briefing.DerivedDaypartSummaryModule
Dayparts []TodayDaypartContext
PrecipTiming *briefing.PrecipTimingModule
AlertDigest *briefing.AlertDigestModule
SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule
AreaForecastDiscussion *briefing.AreaForecastDiscussionModule
SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule
WeatherStory *briefing.WeatherStoryModule
TodayPlanning *briefing.TodayPlanningModule
}
type TodayDaypartContext struct {
Key string
Summary briefing.DerivedDaypartSummaryModule
}
type TomorrowReportContext struct {
Title string
ForecastDate time.Time
ForecastDateLabel string
ForecastDayName string
GeneratedAt time.Time
GeneratedAtLabel string
ValidPeriod timeutil.Period
Timezone string
}
type TomorrowTemplateModules struct {
Metadata *briefing.MetadataModule
CurrentConditions *briefing.CurrentConditionsModule
HourlyForecast *briefing.HourlyForecastModule
DerivedDailySummary *briefing.DerivedDailySummaryModule
DerivedDaypartSummaries *map[string]briefing.DerivedDaypartSummaryModule
Dayparts []TomorrowDaypartContext
PrecipTiming *briefing.PrecipTimingModule
AlertDigest *briefing.AlertDigestModule
SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule
AreaForecastDiscussion *briefing.AreaForecastDiscussionModule
SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule
WeatherStory *briefing.WeatherStoryModule
TomorrowPlanning *briefing.TomorrowPlanningModule
}
type TomorrowDaypartContext struct {
Key string
Summary briefing.DerivedDaypartSummaryModule
}
type DailyReportContext struct {
Title string
ForecastDate time.Time
ForecastDateLabel string
ForecastDayName string
GeneratedAt time.Time
GeneratedAtLabel string
ValidPeriod timeutil.Period
Timezone string
}
type DailyTemplateModules struct {
Metadata *briefing.MetadataModule
CurrentConditions *briefing.CurrentConditionsModule
HourlyForecast *briefing.HourlyForecastModule
DerivedDailySummary *briefing.DerivedDailySummaryModule
DerivedDaypartSummaries *map[string]briefing.DerivedDaypartSummaryModule
Dayparts []DailyDaypartContext
PrecipTiming *briefing.PrecipTimingModule
AlertDigest *briefing.AlertDigestModule
SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule
AreaForecastDiscussion *briefing.AreaForecastDiscussionModule
SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule
WeatherStory *briefing.WeatherStoryModule
OutdoorWindows *briefing.OutdoorWindowsModule
DailyPlanning *briefing.DailyPlanningModule
}
type DailyDaypartContext struct {
Key string
Summary briefing.DerivedDaypartSummaryModule
}
func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Hourly, collected facts.CollectedFacts, derived facts.DerivedFacts) (HourlyRenderContext, error) {
location, err := timeutil.LoadLocation(metadata.Timezone)
if err != nil {
return HourlyRenderContext{}, fmt.Errorf("build hourly render context: %w", err)
}
if metadata.GeneratedAt.IsZero() {
return HourlyRenderContext{}, fmt.Errorf("build hourly render context: generatedAt is required")
}
if !metadata.ValidPeriod.IsValid() {
return HourlyRenderContext{}, fmt.Errorf("build hourly render context: valid period is required")
}
modules, err := hourlyTemplateModules(snapshot)
if err != nil {
return HourlyRenderContext{}, err
}
return HourlyRenderContext{
Report: HourlyReportContext{
Title: "Hourly Report",
LocationName: locationName(metadata),
GeneratedAt: metadata.GeneratedAt,
GeneratedAtLabel: generatedAtLabel(metadata.GeneratedAt, location),
ValidPeriod: metadata.ValidPeriod,
ValidPeriodLabel: periodLabel(metadata.ValidPeriod, location),
Timezone: metadata.Timezone,
},
GeneratedText: generated,
Modules: modules,
Collected: collected,
Derived: derived,
}, nil
}
func BuildDailyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Daily, collected facts.CollectedFacts, derived facts.DerivedFacts) (DailyRenderContext, error) {
location, err := timeutil.LoadLocation(metadata.Timezone)
if err != nil {
return DailyRenderContext{}, fmt.Errorf("build daily render context: %w", err)
}
if metadata.GeneratedAt.IsZero() {
return DailyRenderContext{}, fmt.Errorf("build daily render context: generatedAt is required")
}
if !metadata.ValidPeriod.IsValid() {
return DailyRenderContext{}, fmt.Errorf("build daily render context: valid period is required")
}
modules, err := dailyTemplateModules(snapshot, derived)
if err != nil {
return DailyRenderContext{}, err
}
forecastDate := metadata.ValidPeriod.Start.In(location)
forecastDayName := forecastDate.Format("Monday")
return DailyRenderContext{
Report: DailyReportContext{
Title: forecastDayName + "'s Weather",
ForecastDate: forecastDate,
ForecastDateLabel: forecastDate.Format("Monday, January 2, 2006"),
ForecastDayName: forecastDayName,
GeneratedAt: metadata.GeneratedAt,
GeneratedAtLabel: generatedAtLabel(metadata.GeneratedAt, location),
ValidPeriod: metadata.ValidPeriod,
Timezone: metadata.Timezone,
},
GeneratedText: generated,
Modules: modules,
Collected: collected,
Derived: derived,
}, nil
}
func BuildTodayRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Today, collected facts.CollectedFacts, derived facts.DerivedFacts) (TodayRenderContext, error) {
location, err := timeutil.LoadLocation(metadata.Timezone)
if err != nil {
return TodayRenderContext{}, fmt.Errorf("build today render context: %w", err)
}
if metadata.GeneratedAt.IsZero() {
return TodayRenderContext{}, fmt.Errorf("build today render context: generatedAt is required")
}
if !metadata.ValidPeriod.IsValid() {
return TodayRenderContext{}, fmt.Errorf("build today render context: valid period is required")
}
modules, err := todayTemplateModules(snapshot, derived)
if err != nil {
return TodayRenderContext{}, err
}
forecastDate := metadata.ValidPeriod.Start.In(location)
return TodayRenderContext{
Report: TodayReportContext{
Title: "Today's Weather",
ForecastDate: forecastDate,
ForecastDateLabel: forecastDate.Format("Monday, January 2, 2006"),
ForecastDayName: forecastDate.Format("Monday"),
GeneratedAt: metadata.GeneratedAt,
GeneratedAtLabel: generatedAtLabel(metadata.GeneratedAt, location),
ValidPeriod: metadata.ValidPeriod,
Timezone: metadata.Timezone,
},
GeneratedText: generated,
Modules: modules,
Collected: collected,
Derived: derived,
}, nil
}
func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Tomorrow, collected facts.CollectedFacts, derived facts.DerivedFacts) (TomorrowRenderContext, error) {
location, err := timeutil.LoadLocation(metadata.Timezone)
if err != nil {
return TomorrowRenderContext{}, fmt.Errorf("build tomorrow render context: %w", err)
}
if metadata.GeneratedAt.IsZero() {
return TomorrowRenderContext{}, fmt.Errorf("build tomorrow render context: generatedAt is required")
}
if !metadata.ValidPeriod.IsValid() {
return TomorrowRenderContext{}, fmt.Errorf("build tomorrow render context: valid period is required")
}
modules, err := tomorrowTemplateModules(snapshot, derived)
if err != nil {
return TomorrowRenderContext{}, err
}
forecastDate := metadata.ValidPeriod.Start.In(location)
forecastDayName := forecastDate.Format("Monday")
return TomorrowRenderContext{
Report: TomorrowReportContext{
Title: forecastDayName + "'s Weather",
ForecastDate: forecastDate,
ForecastDateLabel: forecastDate.Format("Monday, January 2, 2006"),
ForecastDayName: forecastDayName,
GeneratedAt: metadata.GeneratedAt,
GeneratedAtLabel: generatedAtLabel(metadata.GeneratedAt, location),
ValidPeriod: metadata.ValidPeriod,
Timezone: metadata.Timezone,
},
GeneratedText: generated,
Modules: modules,
Collected: collected,
Derived: derived,
}, nil
}
func dailyTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (DailyTemplateModules, error) {
lookup := newModuleSnapshotLookup(snapshot)
metadata, err := lookup.metadata()
if err != nil {
return DailyTemplateModules{}, err
}
current, err := lookup.currentConditions()
if err != nil {
return DailyTemplateModules{}, err
}
hourly, err := lookup.hourlyForecast()
if err != nil {
return DailyTemplateModules{}, err
}
daily, err := lookup.derivedDailySummary()
if err != nil {
return DailyTemplateModules{}, err
}
dayparts, err := lookup.derivedDaypartSummaries()
if err != nil {
return DailyTemplateModules{}, err
}
precip, err := lookup.precipTiming()
if err != nil {
return DailyTemplateModules{}, err
}
alerts, err := lookup.alertDigest()
if err != nil {
return DailyTemplateModules{}, err
}
outlooks, err := lookup.spcConvectiveOutlooks()
if err != nil {
return DailyTemplateModules{}, err
}
discussion, err := lookup.areaForecastDiscussion()
if err != nil {
return DailyTemplateModules{}, err
}
spcDiscussion, err := lookup.spcConvectiveDiscussion()
if err != nil {
return DailyTemplateModules{}, err
}
story, err := lookup.weatherStory()
if err != nil {
return DailyTemplateModules{}, err
}
outdoor, err := lookup.outdoorWindows()
if err != nil {
return DailyTemplateModules{}, err
}
planning, err := lookup.dailyPlanning()
if err != nil {
return DailyTemplateModules{}, err
}
return DailyTemplateModules{
Metadata: metadata,
CurrentConditions: current,
HourlyForecast: hourly,
DerivedDailySummary: daily,
DerivedDaypartSummaries: dayparts,
Dayparts: orderedDailyDayparts(dayparts, derived.DaypartSummaries),
PrecipTiming: precip,
AlertDigest: alerts,
SPCConvectiveOutlooks: outlooks,
AreaForecastDiscussion: discussion,
SPCConvectiveDiscussion: spcDiscussion,
WeatherStory: story,
OutdoorWindows: outdoor,
DailyPlanning: planning,
}, nil
}
func todayTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TodayTemplateModules, error) {
lookup := newModuleSnapshotLookup(snapshot)
metadata, err := lookup.metadata()
if err != nil {
return TodayTemplateModules{}, err
}
current, err := lookup.currentConditions()
if err != nil {
return TodayTemplateModules{}, err
}
hourly, err := lookup.hourlyForecast()
if err != nil {
return TodayTemplateModules{}, err
}
daily, err := lookup.derivedDailySummary()
if err != nil {
return TodayTemplateModules{}, err
}
dayparts, err := lookup.derivedDaypartSummaries()
if err != nil {
return TodayTemplateModules{}, err
}
precip, err := lookup.precipTiming()
if err != nil {
return TodayTemplateModules{}, err
}
alerts, err := lookup.alertDigest()
if err != nil {
return TodayTemplateModules{}, err
}
outlooks, err := lookup.spcConvectiveOutlooks()
if err != nil {
return TodayTemplateModules{}, err
}
discussion, err := lookup.areaForecastDiscussion()
if err != nil {
return TodayTemplateModules{}, err
}
spcDiscussion, err := lookup.spcConvectiveDiscussion()
if err != nil {
return TodayTemplateModules{}, err
}
story, err := lookup.weatherStory()
if err != nil {
return TodayTemplateModules{}, err
}
planning, err := lookup.todayPlanning()
if err != nil {
return TodayTemplateModules{}, err
}
return TodayTemplateModules{
Metadata: metadata,
CurrentConditions: current,
HourlyForecast: hourly,
DerivedDailySummary: daily,
DerivedDaypartSummaries: dayparts,
Dayparts: orderedTodayDayparts(dayparts, derived.DaypartSummaries),
PrecipTiming: precip,
AlertDigest: alerts,
SPCConvectiveOutlooks: outlooks,
AreaForecastDiscussion: discussion,
SPCConvectiveDiscussion: spcDiscussion,
WeatherStory: story,
TodayPlanning: planning,
}, nil
}
func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, error) {
lookup := newModuleSnapshotLookup(snapshot)
metadata, err := lookup.metadata()
if err != nil {
return HourlyTemplateModules{}, err
}
current, err := lookup.currentConditions()
if err != nil {
return HourlyTemplateModules{}, err
}
hourly, err := lookup.hourlyForecast()
if err != nil {
return HourlyTemplateModules{}, err
}
precip, err := lookup.precipTiming()
if err != nil {
return HourlyTemplateModules{}, err
}
alerts, err := lookup.alertDigest()
if err != nil {
return HourlyTemplateModules{}, err
}
outlooks, err := lookup.spcConvectiveOutlooks()
if err != nil {
return HourlyTemplateModules{}, err
}
discussion, err := lookup.areaForecastDiscussion()
if err != nil {
return HourlyTemplateModules{}, err
}
spcDiscussion, err := lookup.spcConvectiveDiscussion()
if err != nil {
return HourlyTemplateModules{}, err
}
story, err := lookup.weatherStory()
if err != nil {
return HourlyTemplateModules{}, err
}
return HourlyTemplateModules{
Metadata: metadata,
CurrentConditions: current,
HourlyForecast: hourly,
PrecipTiming: precip,
AlertDigest: alerts,
SPCConvectiveOutlooks: outlooks,
AreaForecastDiscussion: discussion,
SPCConvectiveDiscussion: spcDiscussion,
WeatherStory: story,
}, nil
}
func tomorrowTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TomorrowTemplateModules, error) {
lookup := newModuleSnapshotLookup(snapshot)
metadata, err := lookup.metadata()
if err != nil {
return TomorrowTemplateModules{}, err
}
current, err := lookup.currentConditions()
if err != nil {
return TomorrowTemplateModules{}, err
}
hourly, err := lookup.hourlyForecast()
if err != nil {
return TomorrowTemplateModules{}, err
}
daily, err := lookup.derivedDailySummary()
if err != nil {
return TomorrowTemplateModules{}, err
}
dayparts, err := lookup.derivedDaypartSummaries()
if err != nil {
return TomorrowTemplateModules{}, err
}
precip, err := lookup.precipTiming()
if err != nil {
return TomorrowTemplateModules{}, err
}
alerts, err := lookup.alertDigest()
if err != nil {
return TomorrowTemplateModules{}, err
}
outlooks, err := lookup.spcConvectiveOutlooks()
if err != nil {
return TomorrowTemplateModules{}, err
}
discussion, err := lookup.areaForecastDiscussion()
if err != nil {
return TomorrowTemplateModules{}, err
}
spcDiscussion, err := lookup.spcConvectiveDiscussion()
if err != nil {
return TomorrowTemplateModules{}, err
}
story, err := lookup.weatherStory()
if err != nil {
return TomorrowTemplateModules{}, err
}
planning, err := lookup.tomorrowPlanning()
if err != nil {
return TomorrowTemplateModules{}, err
}
return TomorrowTemplateModules{
Metadata: metadata,
CurrentConditions: current,
HourlyForecast: hourly,
DerivedDailySummary: daily,
DerivedDaypartSummaries: dayparts,
Dayparts: orderedTomorrowDayparts(dayparts, derived.DaypartSummaries),
PrecipTiming: precip,
AlertDigest: alerts,
SPCConvectiveOutlooks: outlooks,
AreaForecastDiscussion: discussion,
SPCConvectiveDiscussion: spcDiscussion,
WeatherStory: story,
TomorrowPlanning: planning,
}, nil
}
type moduleSnapshotLookup struct {
snapshot module.Snapshot
stanzas map[string]module.Output
}
func newModuleSnapshotLookup(snapshot module.Snapshot) moduleSnapshotLookup {
stanzas := make(map[string]module.Output, len(snapshot.Outputs))
for _, output := range snapshot.Outputs {
stanzas[output.StanzaName] = output
}
return moduleSnapshotLookup{
snapshot: snapshot,
stanzas: stanzas,
}
}
func (lookup moduleSnapshotLookup) metadata() (*briefing.MetadataModule, error) {
return optionalStanza[briefing.MetadataModule](lookup, module.Metadata)
}
func (lookup moduleSnapshotLookup) currentConditions() (*briefing.CurrentConditionsModule, error) {
return optionalStanza[briefing.CurrentConditionsModule](lookup, module.CurrentConditions)
}
func (lookup moduleSnapshotLookup) hourlyForecast() (*briefing.HourlyForecastModule, error) {
return optionalStanza[briefing.HourlyForecastModule](lookup, module.HourlyForecast)
}
func (lookup moduleSnapshotLookup) derivedDailySummary() (*briefing.DerivedDailySummaryModule, error) {
return optionalStanza[briefing.DerivedDailySummaryModule](lookup, module.DerivedDailySummary)
}
func (lookup moduleSnapshotLookup) derivedDaypartSummaries() (*map[string]briefing.DerivedDaypartSummaryModule, error) {
return optionalStanza[map[string]briefing.DerivedDaypartSummaryModule](lookup, module.DerivedDaypartSummaries)
}
func (lookup moduleSnapshotLookup) precipTiming() (*briefing.PrecipTimingModule, error) {
return optionalStanza[briefing.PrecipTimingModule](lookup, module.PrecipTiming)
}
func (lookup moduleSnapshotLookup) alertDigest() (*briefing.AlertDigestModule, error) {
return optionalStanza[briefing.AlertDigestModule](lookup, module.AlertDigest)
}
func (lookup moduleSnapshotLookup) spcConvectiveOutlooks() (*briefing.SPCConvectiveOutlooksModule, error) {
return optionalStanza[briefing.SPCConvectiveOutlooksModule](lookup, module.SPCConvectiveOutlooks)
}
func (lookup moduleSnapshotLookup) areaForecastDiscussion() (*briefing.AreaForecastDiscussionModule, error) {
return optionalStanza[briefing.AreaForecastDiscussionModule](lookup, module.AreaForecastDiscussion)
}
func (lookup moduleSnapshotLookup) spcConvectiveDiscussion() (*briefing.SPCConvectiveDiscussionModule, error) {
return optionalStanza[briefing.SPCConvectiveDiscussionModule](lookup, module.SPCConvectiveDiscussion)
}
func (lookup moduleSnapshotLookup) weatherStory() (*briefing.WeatherStoryModule, error) {
return optionalStanza[briefing.WeatherStoryModule](lookup, module.WeatherStory)
}
func (lookup moduleSnapshotLookup) outdoorWindows() (*briefing.OutdoorWindowsModule, error) {
return optionalStanza[briefing.OutdoorWindowsModule](lookup, module.OutdoorWindows)
}
func (lookup moduleSnapshotLookup) dailyPlanning() (*briefing.DailyPlanningModule, error) {
return optionalStanza[briefing.DailyPlanningModule](lookup, module.DailyPlanning)
}
func (lookup moduleSnapshotLookup) todayPlanning() (*briefing.TodayPlanningModule, error) {
return optionalStanza[briefing.TodayPlanningModule](lookup, module.TodayPlanning)
}
func (lookup moduleSnapshotLookup) tomorrowPlanning() (*briefing.TomorrowPlanningModule, error) {
return optionalStanza[briefing.TomorrowPlanningModule](lookup, module.TomorrowPlanning)
}
func optionalStanza[T any](lookup moduleSnapshotLookup, id module.ID) (*T, error) {
name := string(id)
output, ok := lookup.stanzas[name]
if !ok || output.Value == nil {
return nil, nil
}
value, _, err := module.StanzaValue[T](lookup.snapshot, name)
if err != nil {
return nil, fmt.Errorf("build render context module %q: %w", id, err)
}
return &value, nil
}
func orderedTodayDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []TodayDaypartContext {
orderedRows := orderedDaypartRows(dayparts, ordered)
out := make([]TodayDaypartContext, 0, len(orderedRows))
for _, row := range orderedRows {
out = append(out, TodayDaypartContext{Key: row.Key, Summary: row.Summary})
}
return out
}
func orderedDailyDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []DailyDaypartContext {
orderedRows := orderedDaypartRows(dayparts, ordered)
out := make([]DailyDaypartContext, 0, len(orderedRows))
for _, row := range orderedRows {
out = append(out, DailyDaypartContext{Key: row.Key, Summary: row.Summary})
}
return out
}
func orderedTomorrowDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []TomorrowDaypartContext {
orderedRows := orderedDaypartRows(dayparts, ordered)
out := make([]TomorrowDaypartContext, 0, len(orderedRows))
for _, row := range orderedRows {
out = append(out, TomorrowDaypartContext{Key: row.Key, Summary: row.Summary})
}
return out
}
type orderedDaypartRow struct {
Key string
Summary briefing.DerivedDaypartSummaryModule
}
func orderedDaypartRows(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []orderedDaypartRow {
if dayparts == nil || len(*dayparts) == 0 {
return nil
}
out := make([]orderedDaypartRow, 0, len(*dayparts))
seen := map[string]struct{}{}
for _, daypart := range ordered {
for _, key := range daypartModuleKeyCandidates(daypart) {
value, ok := (*dayparts)[key]
if !ok {
continue
}
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
out = append(out, orderedDaypartRow{Key: key, Summary: value})
break
}
}
var remaining []string
for key := range *dayparts {
if _, ok := seen[key]; !ok {
remaining = append(remaining, key)
}
}
sort.Strings(remaining)
for _, key := range remaining {
out = append(out, orderedDaypartRow{Key: key, Summary: (*dayparts)[key]})
}
return out
}
func daypartModuleKeyCandidates(daypart forecast.DaypartSummary) []string {
key := normalizedDaypartKey(daypart.Name)
if key == "" {
key = "unnamed"
}
if daypart.Period.Start.IsZero() {
return []string{key}
}
return []string{key, daypart.Period.Start.Format(timeutil.DateLayout) + "_" + key}
}
func normalizedDaypartKey(value string) string {
lower := strings.ToLower(strings.TrimSpace(value))
var out strings.Builder
lastUnderscore := false
for _, r := range lower {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
out.WriteRune(r)
lastUnderscore = false
continue
}
if !lastUnderscore {
out.WriteByte('_')
lastUnderscore = true
}
}
return strings.Trim(out.String(), "_")
}
func locationName(metadata briefing.Metadata) string {
if metadata.Location != nil {
if metadata.Location.Name != "" && metadata.Location.Region != "" {
return metadata.Location.Name + ", " + metadata.Location.Region
}
if metadata.Location.Name != "" {
return metadata.Location.Name
}
}
if metadata.SourceLocation != "" {
return metadata.SourceLocation
}
if metadata.SourceLocationID != "" {
return metadata.SourceLocationID
}
return "Unknown location"
}
func periodLabel(period timeutil.Period, location *time.Location) string {
return fmt.Sprintf("%s to %s", timeLabel(period.Start, location), timeLabel(period.End, location))
}
func timeLabel(value time.Time, location *time.Location) string {
return value.In(location).Format("2006-01-02 at 3:04 PM")
}
func generatedAtLabel(value time.Time, location *time.Location) string {
return value.In(location).Format("Monday, January 2, 2006 at 3:04 PM")
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
package generatedtext
import (
"fmt"
"strings"
)
type Today struct {
Summary string `json:"summary"`
ForecastDiscussion []string `json:"forecast_discussion"`
PrecipitationTiming string `json:"precipitation_timing,omitempty"`
Confidence string `json:"confidence,omitempty"`
}
func ValidateToday(data []byte) (Today, []byte, error) {
value, err := decodeGeneratedText[Today](data, "today")
if err != nil {
return Today{}, nil, err
}
value.Summary = strings.TrimSpace(value.Summary)
value.PrecipitationTiming = strings.TrimSpace(value.PrecipitationTiming)
value.Confidence = strings.TrimSpace(value.Confidence)
value.ForecastDiscussion = trimNonEmpty(value.ForecastDiscussion)
if value.Summary == "" {
return Today{}, nil, fmt.Errorf("today generated text summary is required")
}
if len(value.ForecastDiscussion) == 0 {
return Today{}, nil, fmt.Errorf("today generated text forecast discussion is required")
}
normalized, err := normalizeGeneratedText(value, "today")
if err != nil {
return Today{}, nil, err
}
return value, normalized, nil
}

View File

@@ -0,0 +1,111 @@
package generatedtext
import (
"strings"
"testing"
)
func TestValidateTodayNormalizesJSON(t *testing.T) {
value, normalized, err := ValidateToday([]byte(`{
"summary": " Showers are likely today. ",
"forecast_discussion": [
" A front will keep rain chances elevated. ",
"",
" Temperatures stay mild through the afternoon. "
],
"precipitation_timing": " Rain is most likely during the afternoon. ",
"confidence": " Medium "
}`))
if err != nil {
t.Fatalf("ValidateToday() error = %v", err)
}
if value.Summary != "Showers are likely today." {
t.Fatalf("Summary = %q, want trimmed summary", value.Summary)
}
if strings.Join(value.ForecastDiscussion, "|") != "A front will keep rain chances elevated.|Temperatures stay mild through the afternoon." {
t.Fatalf("ForecastDiscussion = %#v, want trimmed non-empty paragraphs", value.ForecastDiscussion)
}
if value.PrecipitationTiming != "Rain is most likely during the afternoon." {
t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming)
}
want := `{"summary":"Showers are likely today.","forecast_discussion":["A front will keep rain chances elevated.","Temperatures stay mild through the afternoon."],"precipitation_timing":"Rain is most likely during the afternoon.","confidence":"Medium"}`
if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want)
}
}
func TestValidateTodayOmitsEmptyOptionalFields(t *testing.T) {
_, normalized, err := ValidateToday([]byte(`{
"summary": "Showers are likely today.",
"forecast_discussion": ["A front will keep rain chances elevated."],
"precipitation_timing": " ",
"confidence": " "
}`))
if err != nil {
t.Fatalf("ValidateToday() error = %v", err)
}
want := `{"summary":"Showers are likely today.","forecast_discussion":["A front will keep rain chances elevated."]}`
if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want)
}
}
func TestValidateTodayRejectsInvalidInput(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{
name: "malformed",
in: `{`,
want: "decode today generated text",
},
{
name: "unknown field",
in: `{"summary":"Showers are likely today.","forecast_discussion":["A front will keep rain chances elevated."],"extra":"value"}`,
want: `unknown field "extra"`,
},
{
name: "missing summary",
in: `{"forecast_discussion":["A front will keep rain chances elevated."]}`,
want: "summary is required",
},
{
name: "blank summary",
in: `{"summary":" ","forecast_discussion":["A front will keep rain chances elevated."]}`,
want: "summary is required",
},
{
name: "missing forecast discussion",
in: `{"summary":"Showers are likely today."}`,
want: "forecast discussion is required",
},
{
name: "blank forecast discussion",
in: `{"summary":"Showers are likely today.","forecast_discussion":[" ",""]}`,
want: "forecast discussion is required",
},
{
name: "forecast discussion wrong type",
in: `{"summary":"Showers are likely today.","forecast_discussion":"A front will keep rain chances elevated."}`,
want: "cannot unmarshal string",
},
{
name: "multiple values",
in: `{"summary":"Showers are likely today.","forecast_discussion":["A front will keep rain chances elevated."]} {}`,
want: "multiple JSON values",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, _, err := ValidateToday([]byte(test.in))
if err == nil {
t.Fatal("ValidateToday() error = nil, want error")
}
if !strings.Contains(err.Error(), test.want) {
t.Fatalf("ValidateToday() error = %v, want %q", err, test.want)
}
})
}
}

View File

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

View 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)
}
})
}
}

View File

@@ -21,8 +21,12 @@ const (
AlertDigest ID = "alert_digest" AlertDigest ID = "alert_digest"
AreaForecastDiscussion ID = "area_forecast_discussion" AreaForecastDiscussion ID = "area_forecast_discussion"
WeatherStory ID = "weather_story" WeatherStory ID = "weather_story"
SPCConvectiveOutlooks ID = "spc_convective_outlooks"
SPCConvectiveDiscussion ID = "spc_convective_discussion"
OutdoorWindows ID = "outdoor_windows" OutdoorWindows ID = "outdoor_windows"
TodayPlanning ID = "today_planning"
TomorrowPlanning ID = "tomorrow_planning" TomorrowPlanning ID = "tomorrow_planning"
DailyPlanning ID = "daily_planning"
) )
type ConfigItem struct { type ConfigItem struct {
@@ -31,9 +35,10 @@ type ConfigItem struct {
} }
type Output struct { type Output struct {
ID ID `json:"id"` ID ID `json:"id"`
StanzaName string `json:"stanzaName"` StanzaName string `json:"stanzaName"`
Value any `json:"value"` Value any `json:"value"`
PromptValue any `json:"-" yaml:"-"`
} }
type Snapshot struct { type Snapshot struct {
@@ -41,6 +46,13 @@ type Snapshot struct {
Outputs []Output `json:"outputs"` Outputs []Output `json:"outputs"`
} }
func (o Output) DataPackageValue() any {
if o.PromptValue != nil {
return o.PromptValue
}
return o.Value
}
func NewSnapshot(outputs []Output) (Snapshot, error) { func NewSnapshot(outputs []Output) (Snapshot, error) {
snapshot := Snapshot{ snapshot := Snapshot{
SchemaVersion: SnapshotSchemaVersion, SchemaVersion: SnapshotSchemaVersion,
@@ -105,19 +117,21 @@ func StanzaValue[T any](s Snapshot, name string) (T, bool, error) {
type FactRequirement string type FactRequirement string
const ( const (
CollectedCurrentConditions FactRequirement = "collected.current_conditions" CollectedCurrentConditions FactRequirement = "collected.current_conditions"
CollectedNarrativeForecast FactRequirement = "collected.narrative_forecast" CollectedNarrativeForecast FactRequirement = "collected.narrative_forecast"
CollectedHourlyForecast FactRequirement = "collected.hourly_forecast" CollectedHourlyForecast FactRequirement = "collected.hourly_forecast"
CollectedAlerts FactRequirement = "collected.alerts" CollectedAlerts FactRequirement = "collected.alerts"
CollectedDiscussion FactRequirement = "collected.discussion" CollectedDiscussion FactRequirement = "collected.discussion"
CollectedWeatherStory FactRequirement = "collected.weather_story" CollectedWeatherStory FactRequirement = "collected.weather_story"
CollectedSourceMetadata FactRequirement = "collected.source_metadata" CollectedSPCConvectiveOutlooks FactRequirement = "collected.spc_convective_outlooks"
RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods" CollectedSourceMetadata FactRequirement = "collected.source_metadata"
RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods" RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods"
RequiresDerivedAlertOverlaps FactRequirement = "derived.alert_overlaps" RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods"
RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries" RequiresDerivedAlertOverlaps FactRequirement = "derived.alert_overlaps"
RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries" RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries"
RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing" RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries"
RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing"
RequiresDerivedSPCConvectiveOutlooks FactRequirement = "derived.spc_convective_outlooks"
) )
type MissingDataBehavior string type MissingDataBehavior string
@@ -141,5 +155,9 @@ type AreaForecastDiscussionOptions struct {
Sections []string `json:"sections,omitempty" yaml:"sections,omitempty"` Sections []string `json:"sections,omitempty" yaml:"sections,omitempty"`
} }
type WeatherStoryOptions struct{} type WeatherStoryOptions struct{}
type SPCConvectiveOutlooksOptions struct{}
type SPCConvectiveDiscussionOptions struct{}
type OutdoorWindowsOptions struct{} type OutdoorWindowsOptions struct{}
type TodayPlanningOptions struct{}
type TomorrowPlanningOptions struct{} type TomorrowPlanningOptions struct{}
type DailyPlanningOptions struct{}

View File

@@ -13,7 +13,12 @@ type testStanza struct {
func TestSnapshotPreservesOutputOrderAndJSON(t *testing.T) { func TestSnapshotPreservesOutputOrderAndJSON(t *testing.T) {
snapshot, err := NewSnapshot([]Output{ snapshot, err := NewSnapshot([]Output{
{ID: Metadata, StanzaName: "metadata", Value: testStanza{Message: "first", Count: 1}}, {
ID: Metadata,
StanzaName: "metadata",
Value: testStanza{Message: "first", Count: 1},
PromptValue: testStanza{Message: "prompt-only", Count: 10},
},
{ID: AlertDigest, StanzaName: "alert_digest", Value: testStanza{Message: "second", Count: 2}}, {ID: AlertDigest, StanzaName: "alert_digest", Value: testStanza{Message: "second", Count: 2}},
}) })
if err != nil { if err != nil {
@@ -35,6 +40,26 @@ func TestSnapshotPreservesOutputOrderAndJSON(t *testing.T) {
if got != want { if got != want {
t.Fatalf("json = %s, want %s", got, want) t.Fatalf("json = %s, want %s", got, want)
} }
if strings.Contains(got, "PromptValue") || strings.Contains(got, "promptValue") || strings.Contains(got, "prompt-only") {
t.Fatalf("json includes runtime-only prompt value: %s", got)
}
}
func TestOutputDataPackageValue(t *testing.T) {
output := Output{
ID: Metadata,
StanzaName: "metadata",
Value: testStanza{Message: "rich", Count: 1},
PromptValue: testStanza{Message: "prompt", Count: 2},
}
if got := output.DataPackageValue(); got != output.PromptValue {
t.Fatalf("DataPackageValue() = %#v, want prompt value", got)
}
output.PromptValue = nil
if got := output.DataPackageValue(); got != output.Value {
t.Fatalf("DataPackageValue() = %#v, want rich value fallback", got)
}
} }
func TestSnapshotRejectsDuplicateOutputs(t *testing.T) { func TestSnapshotRejectsDuplicateOutputs(t *testing.T) {
@@ -57,7 +82,12 @@ func TestSnapshotRejectsDuplicateOutputs(t *testing.T) {
func TestStanzaValueDecodesTypedOutput(t *testing.T) { func TestStanzaValueDecodesTypedOutput(t *testing.T) {
snapshot, err := NewSnapshot([]Output{ snapshot, err := NewSnapshot([]Output{
{ID: Metadata, StanzaName: "metadata", Value: testStanza{Message: "available", Count: 3}}, {
ID: Metadata,
StanzaName: "metadata",
Value: testStanza{Message: "available", Count: 3},
PromptValue: testStanza{Message: "prompt-only", Count: 99},
},
}) })
if err != nil { if err != nil {
t.Fatalf("NewSnapshot() error = %v", err) t.Fatalf("NewSnapshot() error = %v", err)
@@ -82,3 +112,29 @@ func TestStanzaValueDecodesTypedOutput(t *testing.T) {
t.Fatal("StanzaValue(missing) found = true, want false") t.Fatal("StanzaValue(missing) found = true, want false")
} }
} }
func TestSPCConvectiveModuleContractsAreStable(t *testing.T) {
if SPCConvectiveOutlooks != ID("spc_convective_outlooks") {
t.Fatalf("SPCConvectiveOutlooks = %q, want stable source/module ID", SPCConvectiveOutlooks)
}
if SPCConvectiveDiscussion != ID("spc_convective_discussion") {
t.Fatalf("SPCConvectiveDiscussion = %q, want stable discussion module ID", SPCConvectiveDiscussion)
}
if CollectedSPCConvectiveOutlooks != FactRequirement("collected.spc_convective_outlooks") {
t.Fatalf("CollectedSPCConvectiveOutlooks = %q, want collected requirement", CollectedSPCConvectiveOutlooks)
}
if RequiresDerivedSPCConvectiveOutlooks != FactRequirement("derived.spc_convective_outlooks") {
t.Fatalf("RequiresDerivedSPCConvectiveOutlooks = %q, want derived requirement", RequiresDerivedSPCConvectiveOutlooks)
}
_ = SPCConvectiveOutlooksOptions{}
_ = SPCConvectiveDiscussionOptions{}
}
func TestDailyPlanningModuleContractIsStable(t *testing.T) {
if DailyPlanning != ID("daily_planning") {
t.Fatalf("DailyPlanning = %q, want stable module ID", DailyPlanning)
}
_ = DailyPlanningOptions{}
}

View File

@@ -16,7 +16,7 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
const SchemaVersion = "weatherreporter.data_package.v2" const SchemaVersion = "weatherreporter.data_package.v3"
const ( const (
metadataStanza = "metadata" metadataStanza = "metadata"
@@ -35,13 +35,17 @@ var briefingCategoryOrder = []string{
var briefingStanzaCategories = map[string]string{ var briefingStanzaCategories = map[string]string{
string(module.AlertDigest): categoryApplicableRiskProducts, string(module.AlertDigest): categoryApplicableRiskProducts,
string(module.SPCConvectiveOutlooks): categoryApplicableRiskProducts,
string(module.DerivedDailySummary): categoryDerivedSummaries, string(module.DerivedDailySummary): categoryDerivedSummaries,
string(module.DerivedDaypartSummaries): categoryDerivedSummaries, string(module.DerivedDaypartSummaries): categoryDerivedSummaries,
string(module.PrecipTiming): categoryDerivedSummaries, string(module.PrecipTiming): categoryDerivedSummaries,
string(module.OutdoorWindows): categoryDerivedSummaries, string(module.OutdoorWindows): categoryDerivedSummaries,
string(module.DailyPlanning): categoryDerivedSummaries,
string(module.TodayPlanning): categoryDerivedSummaries,
string(module.TomorrowPlanning): categoryDerivedSummaries, string(module.TomorrowPlanning): categoryDerivedSummaries,
string(module.NarrativeForecast): categoryNarrativeProducts, string(module.NarrativeForecast): categoryNarrativeProducts,
string(module.AreaForecastDiscussion): categoryNarrativeProducts, string(module.AreaForecastDiscussion): categoryNarrativeProducts,
string(module.SPCConvectiveDiscussion): categoryNarrativeProducts,
string(module.WeatherStory): categoryNarrativeProducts, string(module.WeatherStory): categoryNarrativeProducts,
string(module.CurrentConditions): categoryRawData, string(module.CurrentConditions): categoryRawData,
string(module.HourlyForecast): categoryRawData, string(module.HourlyForecast): categoryRawData,
@@ -129,7 +133,7 @@ func stanzasFromSnapshot(snapshot module.Snapshot) BriefingStanzas {
order := make([]string, 0, len(snapshot.Outputs)) order := make([]string, 0, len(snapshot.Outputs))
for _, output := range snapshot.Outputs { for _, output := range snapshot.Outputs {
order = append(order, output.StanzaName) order = append(order, output.StanzaName)
values[output.StanzaName] = output.Value values[output.StanzaName] = output.DataPackageValue()
} }
return BriefingStanzas{Order: order, Values: values} return BriefingStanzas{Order: order, Values: values}
} }

View File

@@ -19,11 +19,11 @@ func TestBuildDailyDataPackage(t *testing.T) {
if pkg.SchemaVersion != SchemaVersion { if pkg.SchemaVersion != SchemaVersion {
t.Fatalf("SchemaVersion = %q, want %q", pkg.SchemaVersion, SchemaVersion) t.Fatalf("SchemaVersion = %q, want %q", pkg.SchemaVersion, SchemaVersion)
} }
if pkg.RunID != "20260529T100000Z_daily_today" { if pkg.RunID != "20260529T100000Z_daily" {
t.Fatalf("RunID = %q, want metadata run id", pkg.RunID) t.Fatalf("RunID = %q, want metadata run id", pkg.RunID)
} }
if pkg.Report.PromptID != "weather.daily_report" { if pkg.Report.PromptID != "weather.daily_generated_text" {
t.Fatalf("PromptID = %q, want weather.daily_report", pkg.Report.PromptID) t.Fatalf("PromptID = %q, want weather.daily_generated_text", pkg.Report.PromptID)
} }
if pkg.Report.CurrentLocalDate != "2026-05-29" { if pkg.Report.CurrentLocalDate != "2026-05-29" {
t.Fatalf("CurrentLocalDate = %q, want 2026-05-29", pkg.Report.CurrentLocalDate) t.Fatalf("CurrentLocalDate = %q, want 2026-05-29", pkg.Report.CurrentLocalDate)
@@ -107,6 +107,7 @@ func TestBuildUsesNamedSnapshotStanzas(t *testing.T) {
req.Modules = snapshotWithOutputs(t, req.Modules = snapshotWithOutputs(t,
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": req.Metadata.RunID}}, module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": req.Metadata.RunID}},
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{"days": []string{"2026-05-29"}}}, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{"days": []string{"2026-05-29"}}},
module.Output{ID: module.TodayPlanning, StanzaName: "today_planning", Value: map[string]any{"morning_readiness": []string{"routine"}}},
) )
pkg, err := Build(req) pkg, err := Build(req)
@@ -120,6 +121,47 @@ func TestBuildUsesNamedSnapshotStanzas(t *testing.T) {
if _, ok := pkg.Briefing.Values["derived_daypart_summaries"]; !ok { if _, ok := pkg.Briefing.Values["derived_daypart_summaries"]; !ok {
t.Fatal("Briefing.Values[derived_daypart_summaries] missing") t.Fatal("Briefing.Values[derived_daypart_summaries] missing")
} }
if _, ok := pkg.Briefing.Values["today_planning"]; !ok {
t.Fatal("Briefing.Values[today_planning] missing")
}
}
func TestBuildUsesPromptValuesFromSnapshot(t *testing.T) {
req := validBuildRequest(t)
req.Modules = snapshotWithOutputs(t,
module.Output{
ID: module.Metadata,
StanzaName: "metadata",
Value: map[string]string{"run_id": "rich"},
PromptValue: map[string]string{"run_id": "prompt"},
},
module.Output{
ID: module.CurrentConditions,
StanzaName: "current_conditions",
Value: map[string]string{"condition_text": "Rich conditions"},
PromptValue: map[string]string{"condition_text": "Prompt conditions"},
},
module.Output{
ID: module.AlertDigest,
StanzaName: "alert_digest",
Value: map[string]bool{"checked": true},
},
)
pkg, err := Build(req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if got := pkg.Briefing.Values["metadata"].(map[string]string)["run_id"]; got != "prompt" {
t.Fatalf("metadata.run_id = %q, want prompt value", got)
}
if got := pkg.Briefing.Values["current_conditions"].(map[string]string)["condition_text"]; got != "Prompt conditions" {
t.Fatalf("current_conditions.condition_text = %q, want prompt value", got)
}
if got := pkg.Briefing.Values["alert_digest"].(map[string]bool)["checked"]; !got {
t.Fatalf("alert_digest.checked = %v, want rich value fallback", got)
}
} }
func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) { func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) {
@@ -139,7 +181,7 @@ func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) {
if string(first) != string(second) { if string(first) != string(second) {
t.Fatalf("YAML output changed between marshals:\n%s\n---\n%s", string(first), string(second)) t.Fatalf("YAML output changed between marshals:\n%s\n---\n%s", string(first), string(second))
} }
if !strings.Contains(string(first), "schema_version: weatherreporter.data_package.v2") || if !strings.Contains(string(first), "schema_version: weatherreporter.data_package.v3") ||
!strings.Contains(string(first), "briefing:\n") || !strings.Contains(string(first), "briefing:\n") ||
!strings.Contains(string(first), " applicable_risk_products:\n") || !strings.Contains(string(first), " applicable_risk_products:\n") ||
!strings.Contains(string(first), " derived_summaries:\n") || !strings.Contains(string(first), " derived_summaries:\n") ||
@@ -164,6 +206,50 @@ func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) {
} }
} }
func TestMarshalYAMLPlacesSPCConvectiveStanzasInPromptCategories(t *testing.T) {
req := validBuildRequest(t)
req.Modules = snapshotWithOutputs(t,
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": req.Metadata.RunID}},
module.Output{ID: module.SPCConvectiveDiscussion, StanzaName: string(module.SPCConvectiveDiscussion), Value: map[string]any{"discussions": []string{"day1"}}},
module.Output{ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), Value: map[string]any{"outlook_count": 1}},
)
pkg, err := Build(req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
data, err := MarshalYAML(pkg)
if err != nil {
t.Fatalf("MarshalYAML() error = %v", err)
}
text := string(data)
riskIndex := strings.Index(text, " applicable_risk_products:\n")
outlookIndex := strings.Index(text, " spc_convective_outlooks:\n")
narrativeIndex := strings.Index(text, " narrative_products:\n")
discussionIndex := strings.Index(text, " spc_convective_discussion:\n")
if riskIndex < 0 || outlookIndex < 0 || narrativeIndex < 0 || discussionIndex < 0 {
t.Fatalf("YAML output missing SPC convective category placement:\n%s", text)
}
if !(riskIndex < outlookIndex && outlookIndex < narrativeIndex && narrativeIndex < discussionIndex) {
t.Fatalf("YAML output placed SPC convective stanzas in wrong order:\n%s", text)
}
if strings.Contains(text, " raw_data:\n spc_convective") || strings.Contains(text, " derived_summaries:\n spc_convective") {
t.Fatalf("YAML output placed SPC convective stanzas in wrong category:\n%s", text)
}
loaded, err := LoadYAML(data)
if err != nil {
t.Fatalf("LoadYAML() error = %v", err)
}
if _, ok := loaded.Briefing.Values[string(module.SPCConvectiveOutlooks)]; !ok {
t.Fatal("loaded package missing spc_convective_outlooks stanza")
}
if _, ok := loaded.Briefing.Values[string(module.SPCConvectiveDiscussion)]; !ok {
t.Fatal("loaded package missing spc_convective_discussion stanza")
}
}
func TestLoadYAMLRoundTrip(t *testing.T) { func TestLoadYAMLRoundTrip(t *testing.T) {
pkg, err := Build(validBuildRequest(t)) pkg, err := Build(validBuildRequest(t))
if err != nil { if err != nil {
@@ -203,11 +289,11 @@ func TestMarshalYAMLRejectsUncategorizedStanza(t *testing.T) {
func TestLoadYAMLRejectsMisplacedStanza(t *testing.T) { func TestLoadYAMLRejectsMisplacedStanza(t *testing.T) {
data := []byte(` data := []byte(`
schema_version: weatherreporter.data_package.v2 schema_version: weatherreporter.data_package.v3
run_id: 20260529T100000Z_daily_today run_id: 20260529T100000Z_daily
report: report:
id: daily_today id: daily
prompt_id: weather.daily_report prompt_id: weather.daily_generated_text
generated_at: 2026-05-29T10:00:00Z generated_at: 2026-05-29T10:00:00Z
timezone: America/Chicago timezone: America/Chicago
current_local_date: "2026-05-29" current_local_date: "2026-05-29"
@@ -216,7 +302,7 @@ report:
end: 2026-05-30T05:00:00Z end: 2026-05-30T05:00:00Z
briefing: briefing:
metadata: metadata:
run_id: 20260529T100000Z_daily_today run_id: 20260529T100000Z_daily
raw_data: raw_data:
alert_digest: alert_digest:
checked: true checked: true
@@ -230,15 +316,32 @@ recent_changes:
} }
} }
func TestLoadYAMLRejectsOldSchemaVersion(t *testing.T) {
pkg, err := Build(validBuildRequest(t))
if err != nil {
t.Fatalf("Build() error = %v", err)
}
data, err := MarshalYAML(pkg)
if err != nil {
t.Fatalf("MarshalYAML() error = %v", err)
}
data = []byte(strings.Replace(string(data), "weatherreporter.data_package.v3", "weatherreporter.data_package.v2", 1))
_, err = LoadYAML(data)
if err == nil || !strings.Contains(err.Error(), "schemaVersion must be weatherreporter.data_package.v3") {
t.Fatalf("LoadYAML() error = %v, want current schema version error", err)
}
}
func validBuildRequest(t *testing.T) BuildRequest { func validBuildRequest(t *testing.T) BuildRequest {
t.Helper() t.Helper()
generatedAt := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC) generatedAt := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC)
return BuildRequest{ return BuildRequest{
Metadata: Metadata{ Metadata: Metadata{
RunID: "20260529T100000Z_daily_today", RunID: "20260529T100000Z_daily",
ReportID: report.DailyToday, ReportID: report.Daily,
Variant: "today", Variant: "today",
PromptID: "weather.daily_report", PromptID: "weather.daily_generated_text",
GeneratedAt: generatedAt, GeneratedAt: generatedAt,
Timezone: "America/Chicago", Timezone: "America/Chicago",
ValidPeriod: timeutil.Period{ ValidPeriod: timeutil.Period{
@@ -247,7 +350,7 @@ func validBuildRequest(t *testing.T) BuildRequest {
}, },
}, },
Modules: snapshotWithOutputs(t, Modules: snapshotWithOutputs(t,
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": "20260529T100000Z_daily_today"}}, module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": "20260529T100000Z_daily"}},
module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: map[string]string{"condition_text": "Partly cloudy"}}, module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: map[string]string{"condition_text": "Partly cloudy"}},
module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]string{"date": "2026-05-29"}}, module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]string{"date": "2026-05-29"}},
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: map[string]bool{"checked": true}}, module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: map[string]bool{"checked": true}},

View File

@@ -1,43 +1,31 @@
package report package report
import ( import (
"fmt"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
) )
func dailyTodayDefinition() Definition { func dailyDefinition() Definition {
return Definition{ return Definition{
ID: DailyToday, ID: Daily,
Name: "Daily Report", Name: "Daily Report",
PromptID: "weather.daily_report", PromptID: "weather.daily_generated_text",
ComparisonStrategy: CompareSameValidDate, GenerationMode: GenerationModeGeneratedTextTemplate,
ArtifactGroup: "daily", TemplateID: "daily",
BatchOutputName: "daily.md", GeneratedTextSchemaID: "daily",
Generated: true, ComparisonStrategy: CompareSameValidDate,
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow}, ArtifactGroup: "daily",
Modules: dailyTodayModules(), BatchOutputName: "daily.md",
Morning: true, Generated: true,
resolve: resolveDailyToday, CompatiblePriorIDs: []ID{Daily},
Modules: dailyModules(),
resolve: resolveDaily,
} }
} }
func dailyTomorrowDefinition() Definition { func dailyModules() []module.ConfigItem {
return Definition{
ID: DailyTomorrow,
Name: "Tomorrow Planning Brief",
PromptID: "weather.daily_report",
ComparisonStrategy: CompareSameValidDate,
ArtifactGroup: "daily",
BatchOutputName: "tomorrow.md",
Generated: true,
CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
Modules: dailyTomorrowModules(),
Evening: true,
resolve: resolveDailyTomorrow,
}
}
func dailyTodayModules() []module.ConfigItem {
return moduleItems( return moduleItems(
module.Metadata, module.Metadata,
module.CurrentConditions, module.CurrentConditions,
@@ -46,37 +34,19 @@ func dailyTodayModules() []module.ConfigItem {
module.DerivedDaypartSummaries, module.DerivedDaypartSummaries,
module.PrecipTiming, module.PrecipTiming,
module.AlertDigest, module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion, module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory, module.WeatherStory,
module.OutdoorWindows, module.OutdoorWindows,
module.DailyPlanning,
module.HourlyForecast, module.HourlyForecast,
) )
} }
func dailyTomorrowModules() []module.ConfigItem { func resolveDaily(req ResolveRequest) (timeutil.Period, error) {
return moduleItems( if req.Date.IsZero() {
module.Metadata, return timeutil.Period{}, fmt.Errorf("daily report requires an explicit date")
module.CurrentConditions,
module.NarrativeForecast,
module.DerivedDailySummary,
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.AreaForecastDiscussion,
module.WeatherStory,
module.OutdoorWindows,
module.TomorrowPlanning,
module.HourlyForecast,
)
}
func resolveDailyToday(req ResolveRequest) (timeutil.Period, error) {
if !req.Date.IsZero() {
return timeutil.CivilDay(req.Date, req.Location), nil
} }
return timeutil.CivilDay(req.Now, req.Location), nil return timeutil.CivilDay(req.Date, req.Location), nil
}
func resolveDailyTomorrow(req ResolveRequest) (timeutil.Period, error) {
return timeutil.CivilDay(req.Now.In(req.Location).AddDate(0, 0, 1), req.Location), nil
} }

View File

@@ -12,11 +12,13 @@ import (
type ID string type ID string
const ( const (
DailyToday ID = "daily_today" Daily ID = "daily"
DailyTomorrow ID = "daily_tomorrow" Today ID = "today"
ThreeDay ID = "three_day" Tomorrow ID = "tomorrow"
Weekend ID = "weekend" Hourly ID = "hourly"
Storm ID = "storm" ThreeDay ID = "three_day"
Weekend ID = "weekend"
Storm ID = "storm"
) )
type ComparisonStrategy string type ComparisonStrategy string
@@ -25,6 +27,14 @@ const (
CompareSameValidDate ComparisonStrategy = "same_valid_date" CompareSameValidDate ComparisonStrategy = "same_valid_date"
CompareWeekendWindow ComparisonStrategy = "same_weekend_window" CompareWeekendWindow ComparisonStrategy = "same_weekend_window"
CompareExplicitWindow ComparisonStrategy = "explicit_event_window" CompareExplicitWindow ComparisonStrategy = "explicit_event_window"
CompareRollingWindow ComparisonStrategy = "rolling_window"
)
type GenerationMode string
const (
GenerationModeScriptoriumMarkdown GenerationMode = "scriptorium_markdown"
GenerationModeGeneratedTextTemplate GenerationMode = "generated_text_template"
) )
type Batch string type Batch string
@@ -35,18 +45,21 @@ const (
) )
type Definition struct { type Definition struct {
ID ID ID ID
Name string Name string
PromptID string PromptID string
ComparisonStrategy ComparisonStrategy GenerationMode GenerationMode
ArtifactGroup string TemplateID string
BatchOutputName string GeneratedTextSchemaID string
Generated bool ComparisonStrategy ComparisonStrategy
CompatiblePriorIDs []ID ArtifactGroup string
Modules []module.ConfigItem BatchOutputName string
Morning bool Generated bool
Evening bool CompatiblePriorIDs []ID
resolve func(ResolveRequest) (timeutil.Period, error) Modules []module.ConfigItem
Morning bool
Evening bool
resolve func(ResolveRequest) (timeutil.Period, error)
} }
func (d Definition) ResolvePeriod(req ResolveRequest) (timeutil.Period, error) { func (d Definition) ResolvePeriod(req ResolveRequest) (timeutil.Period, error) {

View File

@@ -0,0 +1,55 @@
package report
import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
const hourlyReportHours = 6
func hourlyDefinition() Definition {
return Definition{
ID: Hourly,
Name: "Hourly Report",
PromptID: "weather.hourly_generated_text",
GenerationMode: GenerationModeGeneratedTextTemplate,
TemplateID: "hourly",
GeneratedTextSchemaID: "hourly",
ComparisonStrategy: CompareRollingWindow,
ArtifactGroup: "hourly",
BatchOutputName: "hourly.md",
Generated: true,
CompatiblePriorIDs: []ID{Hourly},
Modules: hourlyModules(),
resolve: resolveHourly,
}
}
func hourlyModules() []module.ConfigItem {
return []module.ConfigItem{
{ID: module.Metadata},
{ID: module.CurrentConditions},
{ID: module.HourlyForecast},
{ID: module.PrecipTiming},
{ID: module.AlertDigest},
{ID: module.SPCConvectiveOutlooks},
{
ID: module.AreaForecastDiscussion,
Options: module.AreaForecastDiscussionOptions{
Sections: []string{"key_messages", "short_term"},
},
},
{ID: module.SPCConvectiveDiscussion},
{ID: module.WeatherStory},
}
}
func resolveHourly(req ResolveRequest) (timeutil.Period, error) {
localNow := req.Now.In(req.Location)
return timeutil.Period{
Start: localNow,
End: localNow.Add(hourlyReportHours * time.Hour),
}, nil
}

92
internal/report/names.go Normal file
View File

@@ -0,0 +1,92 @@
package report
import (
"fmt"
"strings"
)
const (
CommandNameDaily = "daily"
CommandNameToday = "today"
CommandNameTomorrow = "tomorrow"
CommandNameHourly = "hourly"
CommandNameThreeDay = "three-day"
CommandNameWeekend = "weekend"
CommandNameStorm = "storm"
BatchNameMorning = "morning"
BatchNameEvening = "evening"
)
func IDForCommandName(name string) (ID, error) {
switch name {
case CommandNameDaily:
return Daily, nil
case CommandNameToday:
return Today, nil
case CommandNameTomorrow:
return Tomorrow, nil
case CommandNameHourly:
return Hourly, nil
case CommandNameThreeDay:
return ThreeDay, nil
case CommandNameWeekend:
return Weekend, nil
case CommandNameStorm:
return Storm, nil
default:
return "", fmt.Errorf("unknown report command %q", name)
}
}
func CommandNames() []string {
return []string{
CommandNameDaily,
CommandNameToday,
CommandNameTomorrow,
CommandNameHourly,
CommandNameThreeDay,
CommandNameWeekend,
CommandNameStorm,
}
}
func IDForConfigKey(key string) (ID, error) {
normalized := strings.ReplaceAll(strings.TrimSpace(strings.ToLower(key)), "-", "_")
switch normalized {
case "daily":
return Daily, nil
case "today":
return Today, nil
case "tomorrow":
return Tomorrow, nil
case "hourly":
return Hourly, nil
case "three_day", "three_day_outlook":
return ThreeDay, nil
case "weekend", "weekend_outlook":
return Weekend, nil
case "storm", "storm_report":
return Storm, nil
default:
return "", fmt.Errorf("report config key %q is not a known report", key)
}
}
func BatchForCommandName(name string) (Batch, error) {
switch name {
case BatchNameMorning:
return Morning, nil
case BatchNameEvening:
return Evening, nil
default:
return "", fmt.Errorf("unknown batch command %q", name)
}
}
func BatchCommandNames() []string {
return []string{
BatchNameMorning,
BatchNameEvening,
}
}

View File

@@ -26,13 +26,13 @@ func (r Registry) BatchReports(batch Batch, req ResolveRequest) ([]Resolved, err
} }
switch batch { switch batch {
case Morning: case Morning:
ids := []ID{DailyToday, ThreeDay} ids := []ID{Today, ThreeDay}
if req.Now.In(req.Location).Weekday() != time.Sunday { if req.Now.In(req.Location).Weekday() != time.Sunday {
ids = append(ids, Weekend) ids = append(ids, Weekend)
} }
return r.resolveIDs(ids, req) return r.resolveIDs(ids, req)
case Evening: case Evening:
return r.resolveIDs([]ID{DailyTomorrow}, req) return r.resolveIDs([]ID{Tomorrow}, req)
default: default:
return nil, fmt.Errorf("unknown batch %q", batch) return nil, fmt.Errorf("unknown batch %q", batch)
} }

View File

@@ -14,11 +14,14 @@ func TestDailyValidPeriod(t *testing.T) {
location := mustLoadLocation(t) location := mustLoadLocation(t)
now := mustParse("2026-05-29T17:45:00-05:00") now := mustParse("2026-05-29T17:45:00-05:00")
resolved, err := Resolve(DailyToday, ResolveRequest{Now: now, Location: location}) resolved, err := Resolve(Daily, ResolveRequest{Now: now, Location: location})
if err != nil { if err == nil {
t.Fatalf("Resolve() error = %v", err) t.Fatal("Resolve() error = nil, want explicit date requirement")
} }
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T00:00:00-05:00", "2026-05-30T00:00:00-05:00") if !strings.Contains(err.Error(), "requires an explicit date") {
t.Fatalf("Resolve() error = %v, want explicit date requirement", err)
}
_ = resolved
} }
func TestDailyValidPeriodCanUseExplicitDate(t *testing.T) { func TestDailyValidPeriodCanUseExplicitDate(t *testing.T) {
@@ -26,7 +29,54 @@ func TestDailyValidPeriodCanUseExplicitDate(t *testing.T) {
now := mustParse("2026-05-29T17:45:00-05:00") now := mustParse("2026-05-29T17:45:00-05:00")
date := mustParse("2026-05-31T12:00:00-05:00") date := mustParse("2026-05-31T12:00:00-05:00")
resolved, err := Resolve(DailyToday, ResolveRequest{Now: now, Location: location, Date: date}) resolved, err := Resolve(Daily, ResolveRequest{Now: now, Location: location, Date: date})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
assertPeriod(t, resolved.ValidPeriod, "2026-05-31T00:00:00-05:00", "2026-06-01T00:00:00-05:00")
if resolved.Definition.PromptID != "weather.daily_generated_text" {
t.Fatalf("PromptID = %q, want weather.daily_generated_text", resolved.Definition.PromptID)
}
if resolved.Definition.GenerationMode != GenerationModeGeneratedTextTemplate {
t.Fatalf("GenerationMode = %q, want generated_text_template", resolved.Definition.GenerationMode)
}
if resolved.Definition.TemplateID != "daily" {
t.Fatalf("TemplateID = %q, want daily", resolved.Definition.TemplateID)
}
if resolved.Definition.GeneratedTextSchemaID != "daily" {
t.Fatalf("GeneratedTextSchemaID = %q, want daily", resolved.Definition.GeneratedTextSchemaID)
}
}
func TestTodayValidPeriod(t *testing.T) {
location := mustLoadLocation(t)
now := mustParse("2026-05-29T17:45:00-05:00")
resolved, err := Resolve(Today, ResolveRequest{Now: now, Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T00:00:00-05:00", "2026-05-30T00:00:00-05:00")
if resolved.Definition.PromptID != "weather.today_generated_text" {
t.Fatalf("PromptID = %q, want weather.today_generated_text", resolved.Definition.PromptID)
}
if resolved.Definition.GenerationMode != GenerationModeGeneratedTextTemplate {
t.Fatalf("GenerationMode = %q, want generated_text_template", resolved.Definition.GenerationMode)
}
if resolved.Definition.TemplateID != "today" {
t.Fatalf("TemplateID = %q, want today", resolved.Definition.TemplateID)
}
if resolved.Definition.GeneratedTextSchemaID != "today" {
t.Fatalf("GeneratedTextSchemaID = %q, want today", resolved.Definition.GeneratedTextSchemaID)
}
}
func TestTodayValidPeriodCanUseExplicitDate(t *testing.T) {
location := mustLoadLocation(t)
now := mustParse("2026-05-29T17:45:00-05:00")
date := mustParse("2026-05-31T12:00:00-05:00")
resolved, err := Resolve(Today, ResolveRequest{Now: now, Location: location, Date: date})
if err != nil { if err != nil {
t.Fatalf("Resolve() error = %v", err) t.Fatalf("Resolve() error = %v", err)
} }
@@ -37,13 +87,22 @@ func TestTomorrowValidPeriodFromEveningGeneration(t *testing.T) {
location := mustLoadLocation(t) location := mustLoadLocation(t)
now := mustParse("2026-05-29T20:00:00-05:00") now := mustParse("2026-05-29T20:00:00-05:00")
resolved, err := Resolve(DailyTomorrow, ResolveRequest{Now: now, Location: location}) resolved, err := Resolve(Tomorrow, ResolveRequest{Now: now, Location: location})
if err != nil { if err != nil {
t.Fatalf("Resolve() error = %v", err) t.Fatalf("Resolve() error = %v", err)
} }
assertPeriod(t, resolved.ValidPeriod, "2026-05-30T00:00:00-05:00", "2026-05-31T00:00:00-05:00") assertPeriod(t, resolved.ValidPeriod, "2026-05-30T00:00:00-05:00", "2026-05-31T00:00:00-05:00")
if resolved.Definition.PromptID != "weather.daily_report" { if resolved.Definition.PromptID != "weather.tomorrow_generated_text" {
t.Fatalf("PromptID = %q, want weather.daily_report", resolved.Definition.PromptID) t.Fatalf("PromptID = %q, want weather.tomorrow_generated_text", resolved.Definition.PromptID)
}
if resolved.Definition.GenerationMode != GenerationModeGeneratedTextTemplate {
t.Fatalf("GenerationMode = %q, want generated_text_template", resolved.Definition.GenerationMode)
}
if resolved.Definition.TemplateID != "tomorrow" {
t.Fatalf("TemplateID = %q, want tomorrow", resolved.Definition.TemplateID)
}
if resolved.Definition.GeneratedTextSchemaID != "tomorrow" {
t.Fatalf("GeneratedTextSchemaID = %q, want tomorrow", resolved.Definition.GeneratedTextSchemaID)
} }
} }
@@ -58,6 +117,60 @@ func TestThreeDayPeriodCalculation(t *testing.T) {
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:00:00-05:00", "2026-06-01T00:00:00-05:00") assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:00:00-05:00", "2026-06-01T00:00:00-05:00")
} }
func TestHourlyLookupAndPeriodCalculation(t *testing.T) {
location := mustLoadLocation(t)
now := mustParse("2026-05-29T05:15:00-05:00")
resolved, err := Resolve(Hourly, ResolveRequest{Now: now, Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Definition.ID != Hourly {
t.Fatalf("ID = %q, want hourly", resolved.Definition.ID)
}
if resolved.Definition.PromptID != "weather.hourly_generated_text" {
t.Fatalf("PromptID = %q, want weather.hourly_generated_text", resolved.Definition.PromptID)
}
if resolved.Definition.GenerationMode != GenerationModeGeneratedTextTemplate {
t.Fatalf("GenerationMode = %q, want generated_text_template", resolved.Definition.GenerationMode)
}
if resolved.Definition.TemplateID != "hourly" {
t.Fatalf("TemplateID = %q, want hourly", resolved.Definition.TemplateID)
}
if resolved.Definition.GeneratedTextSchemaID != "hourly" {
t.Fatalf("GeneratedTextSchemaID = %q, want hourly", resolved.Definition.GeneratedTextSchemaID)
}
if resolved.Definition.ComparisonStrategy != CompareRollingWindow {
t.Fatalf("ComparisonStrategy = %q, want rolling_window", resolved.Definition.ComparisonStrategy)
}
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:15:00-05:00", "2026-05-29T11:15:00-05:00")
}
func TestHourlyPeriodUsesEffectiveTimezone(t *testing.T) {
location, err := time.LoadLocation("America/New_York")
if err != nil {
t.Fatalf("load location: %v", err)
}
now := mustParse("2026-05-29T10:15:00Z")
resolved, err := Resolve(Hourly, ResolveRequest{Now: now, Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T06:15:00-04:00", "2026-05-29T12:15:00-04:00")
}
func TestHourlyPeriodIsNotCivilDayTruncated(t *testing.T) {
location := mustLoadLocation(t)
now := mustParse("2026-05-29T22:30:00-05:00")
resolved, err := Resolve(Hourly, ResolveRequest{Now: now, Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T22:30:00-05:00", "2026-05-30T04:30:00-05:00")
}
func TestWeekendPeriodCalculation(t *testing.T) { func TestWeekendPeriodCalculation(t *testing.T) {
location := mustLoadLocation(t) location := mustLoadLocation(t)
tests := []struct { tests := []struct {
@@ -138,8 +251,8 @@ func TestMorningBatchSkipsWeekendOnSunday(t *testing.T) {
t.Fatalf("BatchReports() error = %v", err) t.Fatalf("BatchReports() error = %v", err)
} }
ids := resolvedIDs(resolved) ids := resolvedIDs(resolved)
if strings.Join(ids, ",") != "daily_today,three_day" { if strings.Join(ids, ",") != "today,three_day" {
t.Fatalf("ids = %v, want daily_today and three_day", ids) t.Fatalf("ids = %v, want today and three_day", ids)
} }
} }
@@ -153,8 +266,161 @@ func TestEveningBatchIncludesTomorrow(t *testing.T) {
t.Fatalf("BatchReports() error = %v", err) t.Fatalf("BatchReports() error = %v", err)
} }
ids := resolvedIDs(resolved) ids := resolvedIDs(resolved)
if strings.Join(ids, ",") != "daily_tomorrow" { if strings.Join(ids, ",") != "tomorrow" {
t.Fatalf("ids = %v, want daily_tomorrow", ids) t.Fatalf("ids = %v, want tomorrow", ids)
}
}
func TestBatchesDoNotIncludeManualReports(t *testing.T) {
location := mustLoadLocation(t)
req := ResolveRequest{Now: mustParse("2026-05-29T06:00:00-05:00"), Location: location}
morning, err := DefaultRegistry().BatchReports(Morning, req)
if err != nil {
t.Fatalf("BatchReports(morning) error = %v", err)
}
evening, err := DefaultRegistry().BatchReports(Evening, req)
if err != nil {
t.Fatalf("BatchReports(evening) error = %v", err)
}
for _, resolved := range append(morning, evening...) {
if resolved.Definition.ID == Hourly || resolved.Definition.ID == Daily {
t.Fatalf("batch included %q, want manual reports excluded", resolved.Definition.ID)
}
}
}
func TestIDForCommandName(t *testing.T) {
tests := []struct {
name string
want ID
}{
{name: "daily", want: Daily},
{name: "today", want: Today},
{name: "tomorrow", want: Tomorrow},
{name: "hourly", want: Hourly},
{name: "three-day", want: ThreeDay},
{name: "weekend", want: Weekend},
{name: "storm", want: Storm},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := IDForCommandName(tt.name)
if err != nil {
t.Fatalf("IDForCommandName() error = %v", err)
}
if got != tt.want {
t.Fatalf("IDForCommandName() = %q, want %q", got, tt.want)
}
})
}
if names := strings.Join(CommandNames(), ","); names != "daily,today,tomorrow,hourly,three-day,weekend,storm" {
t.Fatalf("CommandNames() = %s, want stable command names", names)
}
dailyID, err := IDForCommandName("daily")
if err != nil {
t.Fatalf("IDForCommandName(daily) error = %v", err)
}
todayID, err := IDForCommandName("today")
if err != nil {
t.Fatalf("IDForCommandName(today) error = %v", err)
}
if dailyID == todayID {
t.Fatalf("daily and today both resolve to %q, want distinct report IDs", dailyID)
}
if _, err := IDForCommandName("near-term"); err == nil || !strings.Contains(err.Error(), `unknown report command "near-term"`) {
t.Fatalf("IDForCommandName(near-term) error = %v, want unknown command", err)
}
}
func TestIDForConfigKey(t *testing.T) {
tests := []struct {
key string
want ID
}{
{key: "daily", want: Daily},
{key: "today", want: Today},
{key: "tomorrow", want: Tomorrow},
{key: "hourly", want: Hourly},
{key: "three_day", want: ThreeDay},
{key: "three-day", want: ThreeDay},
{key: "three_day_outlook", want: ThreeDay},
{key: "three-day-outlook", want: ThreeDay},
{key: "weekend", want: Weekend},
{key: "weekend_outlook", want: Weekend},
{key: "storm", want: Storm},
{key: "storm_report", want: Storm},
}
for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
got, err := IDForConfigKey(tt.key)
if err != nil {
t.Fatalf("IDForConfigKey() error = %v", err)
}
if got != tt.want {
t.Fatalf("IDForConfigKey() = %q, want %q", got, tt.want)
}
})
}
if _, err := IDForConfigKey("daily_tomorrow"); err == nil || !strings.Contains(err.Error(), `report config key "daily_tomorrow" is not a known report`) {
t.Fatalf("IDForConfigKey(daily_tomorrow) error = %v, want unknown key", err)
}
retiredDailyKey := strings.Join([]string{"daily", "today"}, "_")
if _, err := IDForConfigKey(retiredDailyKey); err == nil || !strings.Contains(err.Error(), `report config key "`+retiredDailyKey+`" is not a known report`) {
t.Fatalf("IDForConfigKey(%s) error = %v, want unknown key", retiredDailyKey, err)
}
dailyID, err := IDForConfigKey("daily")
if err != nil {
t.Fatalf("IDForConfigKey(daily) error = %v", err)
}
todayID, err := IDForConfigKey("today")
if err != nil {
t.Fatalf("IDForConfigKey(today) error = %v", err)
}
if dailyID == todayID {
t.Fatalf("daily and today config keys both resolve to %q, want distinct report IDs", dailyID)
}
}
func TestBatchForCommandName(t *testing.T) {
tests := []struct {
name string
want Batch
}{
{name: "morning", want: Morning},
{name: "evening", want: Evening},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := BatchForCommandName(tt.name)
if err != nil {
t.Fatalf("BatchForCommandName() error = %v", err)
}
if got != tt.want {
t.Fatalf("BatchForCommandName() = %q, want %q", got, tt.want)
}
})
}
if names := strings.Join(BatchCommandNames(), ","); names != "morning,evening" {
t.Fatalf("BatchCommandNames() = %s, want stable batch command names", names)
}
if _, err := BatchForCommandName("hourly"); err == nil || !strings.Contains(err.Error(), `unknown batch command "hourly"`) {
t.Fatalf("BatchForCommandName(hourly) error = %v, want unknown batch", err)
}
}
func TestMorningBatchReportOrder(t *testing.T) {
location := mustLoadLocation(t)
resolved, err := DefaultRegistry().BatchReports(Morning, ResolveRequest{
Now: mustParse("2026-05-29T06:00:00-05:00"),
Location: location,
})
if err != nil {
t.Fatalf("BatchReports() error = %v", err)
}
ids := resolvedIDs(resolved)
if strings.Join(ids, ",") != "today,three_day,weekend" {
t.Fatalf("ids = %v, want morning report order", ids)
} }
} }
@@ -168,17 +434,58 @@ func TestRegistryLookupErrorIsActionable(t *testing.T) {
} }
} }
func TestRegistryAllIncludesHourlyInStableOrder(t *testing.T) {
ids := resolvedDefinitionIDs(DefaultRegistry().All())
want := []string{"daily", "today", "tomorrow", "hourly", "three_day", "weekend", "storm"}
if strings.Join(ids, ",") != strings.Join(want, ",") {
t.Fatalf("All() ids = %#v, want %#v", ids, want)
}
}
func TestRegistryDefinitionsHavePromptIDsAndComparisonStrategies(t *testing.T) { func TestRegistryDefinitionsHavePromptIDsAndComparisonStrategies(t *testing.T) {
for _, definition := range DefaultRegistry().All() { for _, definition := range DefaultRegistry().All() {
if definition.PromptID == "" { if definition.PromptID == "" {
t.Fatalf("%s PromptID is empty", definition.ID) t.Fatalf("%s PromptID is empty", definition.ID)
} }
if definition.Generated && definition.GenerationMode == "" {
t.Fatalf("%s GenerationMode is empty", definition.ID)
}
if definition.ComparisonStrategy == "" { if definition.ComparisonStrategy == "" {
t.Fatalf("%s ComparisonStrategy is empty", definition.ID) t.Fatalf("%s ComparisonStrategy is empty", definition.ID)
} }
} }
} }
func TestRegistryDefinitionsDeclareGenerationMetadata(t *testing.T) {
for _, definition := range DefaultRegistry().All() {
if !definition.Generated {
continue
}
if definition.ID == Hourly || definition.ID == Daily || definition.ID == Today || definition.ID == Tomorrow {
wantTemplate := string(definition.ID)
if definition.GenerationMode != GenerationModeGeneratedTextTemplate {
t.Fatalf("%s GenerationMode = %q, want %q", definition.ID, definition.GenerationMode, GenerationModeGeneratedTextTemplate)
}
if definition.TemplateID != wantTemplate {
t.Fatalf("%s TemplateID = %q, want %s", definition.ID, definition.TemplateID, wantTemplate)
}
if definition.GeneratedTextSchemaID != wantTemplate {
t.Fatalf("%s GeneratedTextSchemaID = %q, want %s", definition.ID, definition.GeneratedTextSchemaID, wantTemplate)
}
continue
}
if definition.GenerationMode != GenerationModeScriptoriumMarkdown {
t.Fatalf("%s GenerationMode = %q, want %q", definition.ID, definition.GenerationMode, GenerationModeScriptoriumMarkdown)
}
if definition.TemplateID != "" {
t.Fatalf("%s TemplateID = %q, want empty for Markdown report", definition.ID, definition.TemplateID)
}
if definition.GeneratedTextSchemaID != "" {
t.Fatalf("%s GeneratedTextSchemaID = %q, want empty for Markdown report", definition.ID, definition.GeneratedTextSchemaID)
}
}
}
func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) { func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
tests := []struct { tests := []struct {
id ID id ID
@@ -186,20 +493,39 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
batchOutputName string batchOutputName string
generated bool generated bool
compatiblePriorIDs []ID compatiblePriorIDs []ID
comparisonStrategy ComparisonStrategy
}{ }{
{ {
id: DailyToday, id: Daily,
artifactGroup: "daily", artifactGroup: "daily",
batchOutputName: "daily.md", batchOutputName: "daily.md",
generated: true, generated: true,
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow}, compatiblePriorIDs: []ID{Daily},
comparisonStrategy: CompareSameValidDate,
}, },
{ {
id: DailyTomorrow, id: Today,
artifactGroup: "daily", artifactGroup: "today",
batchOutputName: "today.md",
generated: true,
compatiblePriorIDs: []ID{Today},
comparisonStrategy: CompareSameValidDate,
},
{
id: Tomorrow,
artifactGroup: "tomorrow",
batchOutputName: "tomorrow.md", batchOutputName: "tomorrow.md",
generated: true, generated: true,
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow}, compatiblePriorIDs: []ID{Tomorrow},
comparisonStrategy: CompareSameValidDate,
},
{
id: Hourly,
artifactGroup: "hourly",
batchOutputName: "hourly.md",
generated: true,
compatiblePriorIDs: []ID{Hourly},
comparisonStrategy: CompareRollingWindow,
}, },
{ {
id: ThreeDay, id: ThreeDay,
@@ -207,6 +533,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
batchOutputName: "three-day.md", batchOutputName: "three-day.md",
generated: true, generated: true,
compatiblePriorIDs: []ID{ThreeDay}, compatiblePriorIDs: []ID{ThreeDay},
comparisonStrategy: CompareSameValidDate,
}, },
{ {
id: Weekend, id: Weekend,
@@ -214,6 +541,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
batchOutputName: "weekend.md", batchOutputName: "weekend.md",
generated: true, generated: true,
compatiblePriorIDs: []ID{Weekend}, compatiblePriorIDs: []ID{Weekend},
comparisonStrategy: CompareWeekendWindow,
}, },
{ {
id: Storm, id: Storm,
@@ -221,6 +549,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
batchOutputName: "storm.md", batchOutputName: "storm.md",
generated: true, generated: true,
compatiblePriorIDs: []ID{Storm}, compatiblePriorIDs: []ID{Storm},
comparisonStrategy: CompareExplicitWindow,
}, },
} }
@@ -240,6 +569,9 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
if definition.Generated != tt.generated { if definition.Generated != tt.generated {
t.Fatalf("Generated = %t, want %t", definition.Generated, tt.generated) t.Fatalf("Generated = %t, want %t", definition.Generated, tt.generated)
} }
if definition.ComparisonStrategy != tt.comparisonStrategy {
t.Fatalf("ComparisonStrategy = %q, want %q", definition.ComparisonStrategy, tt.comparisonStrategy)
}
if !reflect.DeepEqual(definition.CompatiblePriorIDs, tt.compatiblePriorIDs) { if !reflect.DeepEqual(definition.CompatiblePriorIDs, tt.compatiblePriorIDs) {
t.Fatalf("CompatiblePriorIDs = %#v, want %#v", definition.CompatiblePriorIDs, tt.compatiblePriorIDs) t.Fatalf("CompatiblePriorIDs = %#v, want %#v", definition.CompatiblePriorIDs, tt.compatiblePriorIDs)
} }
@@ -258,7 +590,7 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
want []module.ID want []module.ID
}{ }{
{ {
id: DailyToday, id: Daily,
want: []module.ID{ want: []module.ID{
module.Metadata, module.Metadata,
module.CurrentConditions, module.CurrentConditions,
@@ -267,14 +599,17 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
module.DerivedDaypartSummaries, module.DerivedDaypartSummaries,
module.PrecipTiming, module.PrecipTiming,
module.AlertDigest, module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion, module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory, module.WeatherStory,
module.OutdoorWindows, module.OutdoorWindows,
module.DailyPlanning,
module.HourlyForecast, module.HourlyForecast,
}, },
}, },
{ {
id: DailyTomorrow, id: Today,
want: []module.ID{ want: []module.ID{
module.Metadata, module.Metadata,
module.CurrentConditions, module.CurrentConditions,
@@ -283,13 +618,48 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
module.DerivedDaypartSummaries, module.DerivedDaypartSummaries,
module.PrecipTiming, module.PrecipTiming,
module.AlertDigest, module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion, module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
module.OutdoorWindows,
module.HourlyForecast,
module.TodayPlanning,
},
},
{
id: Tomorrow,
want: []module.ID{
module.Metadata,
module.CurrentConditions,
module.NarrativeForecast,
module.DerivedDailySummary,
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory, module.WeatherStory,
module.OutdoorWindows, module.OutdoorWindows,
module.TomorrowPlanning, module.TomorrowPlanning,
module.HourlyForecast, module.HourlyForecast,
}, },
}, },
{
id: Hourly,
want: []module.ID{
module.Metadata,
module.CurrentConditions,
module.HourlyForecast,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
},
},
{ {
id: ThreeDay, id: ThreeDay,
want: []module.ID{ want: []module.ID{
@@ -298,7 +668,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
module.DerivedDaypartSummaries, module.DerivedDaypartSummaries,
module.PrecipTiming, module.PrecipTiming,
module.AlertDigest, module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion, module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory, module.WeatherStory,
module.OutdoorWindows, module.OutdoorWindows,
}, },
@@ -311,7 +683,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
module.DerivedDaypartSummaries, module.DerivedDaypartSummaries,
module.PrecipTiming, module.PrecipTiming,
module.AlertDigest, module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion, module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory, module.WeatherStory,
module.OutdoorWindows, module.OutdoorWindows,
}, },
@@ -323,7 +697,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
module.CurrentConditions, module.CurrentConditions,
module.PrecipTiming, module.PrecipTiming,
module.AlertDigest, module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion, module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory, module.WeatherStory,
}, },
}, },
@@ -346,7 +722,7 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
func TestRegistryAppliesModuleOverridesWithoutChangingDefaults(t *testing.T) { func TestRegistryAppliesModuleOverridesWithoutChangingDefaults(t *testing.T) {
base := DefaultRegistry() base := DefaultRegistry()
overridden, err := base.WithModuleOverrides(map[ID][]module.ConfigItem{ overridden, err := base.WithModuleOverrides(map[ID][]module.ConfigItem{
DailyToday: { Daily: {
{ID: module.Metadata}, {ID: module.Metadata},
{ID: module.AlertDigest}, {ID: module.AlertDigest},
}, },
@@ -355,7 +731,7 @@ func TestRegistryAppliesModuleOverridesWithoutChangingDefaults(t *testing.T) {
t.Fatalf("WithModuleOverrides() error = %v", err) t.Fatalf("WithModuleOverrides() error = %v", err)
} }
definition, err := overridden.Lookup(DailyToday) definition, err := overridden.Lookup(Daily)
if err != nil { if err != nil {
t.Fatalf("Lookup(overridden) error = %v", err) t.Fatalf("Lookup(overridden) error = %v", err)
} }
@@ -363,7 +739,7 @@ func TestRegistryAppliesModuleOverridesWithoutChangingDefaults(t *testing.T) {
t.Fatalf("overridden ModuleIDs() = %#v, want metadata and alert digest", definition.ModuleIDs()) t.Fatalf("overridden ModuleIDs() = %#v, want metadata and alert digest", definition.ModuleIDs())
} }
defaultDefinition, err := base.Lookup(DailyToday) defaultDefinition, err := base.Lookup(Daily)
if err != nil { if err != nil {
t.Fatalf("Lookup(default) error = %v", err) t.Fatalf("Lookup(default) error = %v", err)
} }
@@ -386,18 +762,40 @@ func TestRegistryRejectsModuleOverrideForUnknownReport(t *testing.T) {
func TestResolvedMetadata(t *testing.T) { func TestResolvedMetadata(t *testing.T) {
location := mustLoadLocation(t) location := mustLoadLocation(t)
resolved, err := Resolve(DailyToday, ResolveRequest{Now: mustParse("2026-05-29T05:00:00-05:00"), Location: location}) resolved, err := Resolve(Daily, ResolveRequest{
Now: mustParse("2026-05-29T05:00:00-05:00"),
Location: location,
Date: mustParse("2026-05-29T12:00:00-05:00"),
})
if err != nil { if err != nil {
t.Fatalf("Resolve() error = %v", err) t.Fatalf("Resolve() error = %v", err)
} }
metadata := resolved.Metadata() metadata := resolved.Metadata()
if metadata.ReportID != DailyToday { if metadata.ReportID != Daily {
t.Fatalf("ReportID = %q, want daily_today", metadata.ReportID) t.Fatalf("ReportID = %q, want daily", metadata.ReportID)
} }
if metadata.PromptID != "weather.daily_report" { if metadata.PromptID != "weather.daily_generated_text" {
t.Fatalf("PromptID = %q, want weather.daily_report", metadata.PromptID) t.Fatalf("PromptID = %q, want weather.daily_generated_text", metadata.PromptID)
} }
if !strings.Contains(metadata.RunID, "daily_today") { if !strings.Contains(metadata.RunID, "daily") {
t.Fatalf("RunID = %q, want report id", metadata.RunID)
}
}
func TestHourlyMetadataRunIDIncludesReportID(t *testing.T) {
location := mustLoadLocation(t)
resolved, err := Resolve(Hourly, ResolveRequest{Now: mustParse("2026-05-29T05:15:00-05:00"), Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
metadata := resolved.Metadata()
if metadata.ReportID != Hourly {
t.Fatalf("ReportID = %q, want hourly", metadata.ReportID)
}
if metadata.PromptID != "weather.hourly_generated_text" {
t.Fatalf("PromptID = %q, want weather.hourly_generated_text", metadata.PromptID)
}
if !strings.Contains(metadata.RunID, "hourly") {
t.Fatalf("RunID = %q, want report id", metadata.RunID) t.Fatalf("RunID = %q, want report id", metadata.RunID)
} }
} }
@@ -423,6 +821,14 @@ func resolvedIDs(resolved []Resolved) []string {
return ids return ids
} }
func resolvedDefinitionIDs(definitions []Definition) []string {
ids := make([]string, 0, len(definitions))
for _, definition := range definitions {
ids = append(ids, string(definition.ID))
}
return ids
}
func mustLoadLocation(t *testing.T) *time.Location { func mustLoadLocation(t *testing.T) *time.Location {
t.Helper() t.Helper()
location, err := time.LoadLocation("America/Chicago") location, err := time.LoadLocation("America/Chicago")

View File

@@ -12,8 +12,10 @@ type Registry struct {
func DefaultRegistry() Registry { func DefaultRegistry() Registry {
definitions := []Definition{ definitions := []Definition{
dailyTodayDefinition(), dailyDefinition(),
dailyTomorrowDefinition(), todayDefinition(),
tomorrowDefinition(),
hourlyDefinition(),
threeDayDefinition(), threeDayDefinition(),
weekendDefinition(), weekendDefinition(),
stormDefinition(), stormDefinition(),
@@ -73,7 +75,7 @@ func (r Registry) MustLookup(id ID) Definition {
} }
func (r Registry) All() []Definition { func (r Registry) All() []Definition {
ids := []ID{DailyToday, DailyTomorrow, ThreeDay, Weekend, Storm} ids := []ID{Daily, Today, Tomorrow, Hourly, ThreeDay, Weekend, Storm}
out := make([]Definition, 0, len(ids)) out := make([]Definition, 0, len(ids))
for _, id := range ids { for _, id := range ids {
if definition, ok := r.definitions[id]; ok { if definition, ok := r.definitions[id]; ok {

View File

@@ -13,6 +13,7 @@ func stormDefinition() Definition {
ID: Storm, ID: Storm,
Name: "Storm Report", Name: "Storm Report",
PromptID: "weather.storm_report", PromptID: "weather.storm_report",
GenerationMode: GenerationModeScriptoriumMarkdown,
ComparisonStrategy: CompareExplicitWindow, ComparisonStrategy: CompareExplicitWindow,
ArtifactGroup: "storm", ArtifactGroup: "storm",
BatchOutputName: "storm.md", BatchOutputName: "storm.md",
@@ -29,7 +30,9 @@ func stormModules() []module.ConfigItem {
module.CurrentConditions, module.CurrentConditions,
module.PrecipTiming, module.PrecipTiming,
module.AlertDigest, module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion, module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory, module.WeatherStory,
) )
} }

View File

@@ -12,6 +12,7 @@ func threeDayDefinition() Definition {
ID: ThreeDay, ID: ThreeDay,
Name: "3-Day Outlook", Name: "3-Day Outlook",
PromptID: "weather.three_day_outlook", PromptID: "weather.three_day_outlook",
GenerationMode: GenerationModeScriptoriumMarkdown,
ComparisonStrategy: CompareSameValidDate, ComparisonStrategy: CompareSameValidDate,
ArtifactGroup: "three-day", ArtifactGroup: "three-day",
BatchOutputName: "three-day.md", BatchOutputName: "three-day.md",
@@ -30,7 +31,9 @@ func threeDayModules() []module.ConfigItem {
module.DerivedDaypartSummaries, module.DerivedDaypartSummaries,
module.PrecipTiming, module.PrecipTiming,
module.AlertDigest, module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion, module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory, module.WeatherStory,
module.OutdoorWindows, module.OutdoorWindows,
) )

View File

@@ -0,0 +1,51 @@
package report
import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func todayDefinition() Definition {
return Definition{
ID: Today,
Name: "Today Report",
PromptID: "weather.today_generated_text",
GenerationMode: GenerationModeGeneratedTextTemplate,
TemplateID: "today",
GeneratedTextSchemaID: "today",
ComparisonStrategy: CompareSameValidDate,
ArtifactGroup: "today",
BatchOutputName: "today.md",
Generated: true,
CompatiblePriorIDs: []ID{Today},
Modules: todayModules(),
Morning: true,
resolve: resolveToday,
}
}
func todayModules() []module.ConfigItem {
return moduleItems(
module.Metadata,
module.CurrentConditions,
module.NarrativeForecast,
module.DerivedDailySummary,
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
module.OutdoorWindows,
module.HourlyForecast,
module.TodayPlanning,
)
}
func resolveToday(req ResolveRequest) (timeutil.Period, error) {
if !req.Date.IsZero() {
return timeutil.CivilDay(req.Date, req.Location), nil
}
return timeutil.CivilDay(req.Now, req.Location), nil
}

View File

@@ -0,0 +1,48 @@
package report
import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func tomorrowDefinition() Definition {
return Definition{
ID: Tomorrow,
Name: "Tomorrow Report",
PromptID: "weather.tomorrow_generated_text",
GenerationMode: GenerationModeGeneratedTextTemplate,
TemplateID: "tomorrow",
GeneratedTextSchemaID: "tomorrow",
ComparisonStrategy: CompareSameValidDate,
ArtifactGroup: "tomorrow",
BatchOutputName: "tomorrow.md",
Generated: true,
CompatiblePriorIDs: []ID{Tomorrow},
Modules: tomorrowModules(),
Evening: true,
resolve: resolveTomorrow,
}
}
func tomorrowModules() []module.ConfigItem {
return moduleItems(
module.Metadata,
module.CurrentConditions,
module.NarrativeForecast,
module.DerivedDailySummary,
module.DerivedDaypartSummaries,
module.PrecipTiming,
module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
module.OutdoorWindows,
module.TomorrowPlanning,
module.HourlyForecast,
)
}
func resolveTomorrow(req ResolveRequest) (timeutil.Period, error) {
return timeutil.CivilDay(req.Now.In(req.Location).AddDate(0, 0, 1), req.Location), nil
}

View File

@@ -13,6 +13,7 @@ func weekendDefinition() Definition {
ID: Weekend, ID: Weekend,
Name: "Weekend Outlook", Name: "Weekend Outlook",
PromptID: "weather.weekend_outlook", PromptID: "weather.weekend_outlook",
GenerationMode: GenerationModeScriptoriumMarkdown,
ComparisonStrategy: CompareWeekendWindow, ComparisonStrategy: CompareWeekendWindow,
ArtifactGroup: "weekend", ArtifactGroup: "weekend",
BatchOutputName: "weekend.md", BatchOutputName: "weekend.md",
@@ -31,7 +32,9 @@ func weekendModules() []module.ConfigItem {
module.DerivedDaypartSummaries, module.DerivedDaypartSummaries,
module.PrecipTiming, module.PrecipTiming,
module.AlertDigest, module.AlertDigest,
module.SPCConvectiveOutlooks,
module.AreaForecastDiscussion, module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory, module.WeatherStory,
module.OutdoorWindows, module.OutdoorWindows,
) )

View File

@@ -0,0 +1,38 @@
TASK: You are writing structured prose slots for a dated daily weather report.
The calling application will render the final Markdown report. Your job is not to write the full report. Return only a JSON object matching the configured schema.
Use only the supplied `data_package`. Do not invent weather details, times, hazards, probabilities, or impacts that are not supported by the data.
The report focuses on the selected local civil day in `report.valid_period` for the configured location.
Return these fields:
- `summary`: required. 1-2 sentences summarizing the main weather story for the selected day.
- `forecast_discussion`: required. 1 or more short paragraphs explaining the setup, timing, trend, or uncertainty most relevant to the selected day.
- `precipitation_timing`: optional. Include only when the deterministic `precip_timing` module contains precipitation windows.
- `confidence`: optional. Include only if uncertainty, timing spread, or conflicting signals materially affect how the reader should interpret the forecast.
Return JSON only.
# summary
Lead with the most practical local outcome for the selected day. If an active warning is relevant during the report period, lead with the hazard.
Mention the expected temperature character, precipitation risk, wind, visibility, heat, cold, or other hazards only when supported by the data package.
# forecast_discussion
Use deterministic module facts and narrative products to explain the most useful details for the selected day.
Useful context may include:
- timing of condition changes by daypart or hour
- boundaries, forcing, moisture, instability, or storm mode when supported
- active alerts or SPC outlooks that apply to the location
- planning concerns surfaced by `daily_planning`
- confidence or uncertainty
# precipitation_timing
Use 1-2 sentences to add practical precipitation context only when the data package contains deterministic precipitation windows. Include expected timing, type, intensity, duration, and uncertainty only when those details are supported.

View 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.

View File

@@ -0,0 +1,38 @@
TASK: You are writing structured prose slots for a current-day weather report.
The calling application will render the final Markdown report. Your job is not to write the full report. Return only a JSON object matching the configured schema.
Use only the supplied `data_package`. Do not invent weather details, times, hazards, probabilities, or impacts that are not supported by the data.
The report focuses on today's valid period in `report.valid_period` for the configured location.
Return these fields:
- `summary`: required. 1-2 sentences summarizing the main weather story for today.
- `forecast_discussion`: required. 1 or more short paragraphs explaining the setup, timing, trend, or uncertainty most relevant to today.
- `precipitation_timing`: optional. Include only when the deterministic `precip_timing` module contains precipitation windows.
- `confidence`: optional. Include only if uncertainty, timing spread, or conflicting signals materially affect how the reader should interpret the forecast.
Return JSON only.
# summary
Lead with the most practical local outcome for today. If an active warning is relevant during the report period, lead with the hazard.
Mention the expected temperature character, precipitation risk, wind, visibility, heat, cold, or other hazards only when supported by the data package.
# forecast_discussion
Use deterministic module facts and narrative products to explain the most useful details for the current day.
Useful context may include:
- timing of condition changes by daypart or hour
- boundaries, forcing, moisture, instability, or storm mode when supported
- active alerts or SPC outlooks that apply to the location
- planning concerns surfaced by `today_planning`
- confidence or uncertainty
# precipitation_timing
Use 1-2 sentences to add practical precipitation context only when the data package contains deterministic precipitation windows. Include expected timing, type, intensity, duration, and uncertainty only when those details are supported.

View 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.

View File

@@ -0,0 +1,66 @@
// Package reporttemplate provides embedded Markdown templates and schemas.
package reporttemplate
import (
"bytes"
"embed"
"fmt"
"text/template"
)
//go:embed templates/*.md.tmpl schemas/*.schema.json
var assets embed.FS
var templates = map[string]string{
"daily": "templates/daily.md.tmpl",
"hourly": "templates/hourly.md.tmpl",
"today": "templates/today.md.tmpl",
"tomorrow": "templates/tomorrow.md.tmpl",
}
var schemas = map[string]string{
"daily": "schemas/daily.generated_text.schema.json",
"hourly": "schemas/hourly.generated_text.schema.json",
"today": "schemas/today.generated_text.schema.json",
"tomorrow": "schemas/tomorrow.generated_text.schema.json",
}
func Template(id string) (string, error) {
path, ok := templates[id]
if !ok {
return "", fmt.Errorf("unknown report template %q", id)
}
data, err := assets.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read report template %q: %w", id, err)
}
return string(data), nil
}
func Schema(id string) ([]byte, error) {
path, ok := schemas[id]
if !ok {
return nil, fmt.Errorf("unknown generated text schema %q", id)
}
data, err := assets.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read generated text schema %q: %w", id, err)
}
return append([]byte(nil), data...), nil
}
func Render(id string, data any) ([]byte, error) {
source, err := Template(id)
if err != nil {
return nil, err
}
tmpl, err := template.New(id).Option("missingkey=error").Parse(source)
if err != nil {
return nil, fmt.Errorf("parse report template %q: %w", id, err)
}
var out bytes.Buffer
if err := tmpl.Execute(&out, data); err != nil {
return nil, fmt.Errorf("render report template %q: %w", id, err)
}
return out.Bytes(), nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "weatherreporter.daily.generated_text.schema.json",
"title": "Daily GeneratedText",
"type": "object",
"additionalProperties": false,
"required": [
"summary",
"forecast_discussion"
],
"properties": {
"summary": {
"type": "string"
},
"forecast_discussion": {
"type": "array",
"items": {
"type": "string"
},
"minItems": 1
},
"precipitation_timing": {
"type": "string"
},
"confidence": {
"type": "string"
}
}
}

View File

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

View File

@@ -0,0 +1,29 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "weatherreporter.today.generated_text.schema.json",
"title": "Today GeneratedText",
"type": "object",
"additionalProperties": false,
"required": [
"summary",
"forecast_discussion"
],
"properties": {
"summary": {
"type": "string"
},
"forecast_discussion": {
"type": "array",
"items": {
"type": "string"
},
"minItems": 1
},
"precipitation_timing": {
"type": "string"
},
"confidence": {
"type": "string"
}
}
}

View File

@@ -0,0 +1,29 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "weatherreporter.tomorrow.generated_text.schema.json",
"title": "Tomorrow GeneratedText",
"type": "object",
"additionalProperties": false,
"required": [
"summary",
"forecast_discussion"
],
"properties": {
"summary": {
"type": "string"
},
"forecast_discussion": {
"type": "array",
"items": {
"type": "string"
},
"minItems": 1
},
"precipitation_timing": {
"type": "string"
},
"confidence": {
"type": "string"
}
}
}

Some files were not shown because too many files have changed in this diff Show More