14 Commits

54 changed files with 3255 additions and 892 deletions

View File

@@ -21,7 +21,7 @@ the managed Markdown report after final metadata is saved.
weatherreporter --help weatherreporter --help
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate near-term [--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
@@ -35,12 +35,14 @@ 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 notification uses the managed report path, not the extra copy. `generate
near-term` uses the current generation time, covers the next six hours in the hourly` covers the next six hours in the effective report timezone, does not
effective report timezone, and does not accept date or event window flags. accept date or event window flags, writes managed generated-text artifacts,
validates the structured text, and renders the managed Markdown report from the
embedded hourly template.
`generate storm` requires explicit event-window bounds with `--start` and `generate storm` requires explicit event-window bounds with `--start` and
`--end`. `--end`.
@@ -54,8 +56,8 @@ 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.
Near-Term Report generation is explicit only; it is not included in `run Hourly Report is explicit only; it is not included in `run morning` or `run
morning` or `run evening`. 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`.
@@ -66,7 +68,7 @@ They do not fetch weather data or invoke `scriptorium`.
- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`. - `--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`: optional date for `generate daily`; defaults 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`.
@@ -83,7 +85,7 @@ are no distributor-specific CLI flags.
```sh ```sh
weatherreporter generate tomorrow --out ./tomorrow.md weatherreporter generate tomorrow --out ./tomorrow.md
weatherreporter generate near-term --out ./near-term.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

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`.
@@ -181,10 +181,9 @@ 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`, `near_term`, `near-term`, Supported report keys are `daily`, `tomorrow`, `hourly`, `three_day`,
`three_day`, `weekend`, and `storm`. Canonical report IDs such as `weekend`, and `storm`. Canonical report IDs such as `daily_today` and
`daily_today` and `daily_tomorrow` are also accepted. `near_term` and `daily_tomorrow` are also accepted.
`near-term` are aliases for the same Near-Term Report override.
Each report entry supports: Each report entry supports:
@@ -208,6 +207,21 @@ reports:
- short_term - short_term
- spc_convective_discussion - spc_convective_discussion
- hourly_forecast - hourly_forecast
hourly:
deterministic_modules:
- metadata
- current_conditions
- hourly_forecast
- precip_timing
- alert_digest
- spc_convective_outlooks
- id: area_forecast_discussion
options:
sections:
- key_messages
- short_term
- spc_convective_discussion
- weather_story
``` ```
Unknown reports, unknown modules, duplicate modules, incompatible report/module 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,16 @@ 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. Markdown report artifacts are written by Scriptorium to
the `--out` path. the `--out` path. Generated-text raw JSON artifacts are also written by
Scriptorium to the `--out` path; later weatherreporter workflow steps validate
and render those bytes.
## Failure Behavior ## Failure Behavior
@@ -80,7 +99,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

@@ -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.
13. Copies the managed report to the requested `--out` path when provided.
14. Saves metadata with the managed report path.
15. If distributor notification is enabled, notifies using the managed report
path as the source file. path as the source file.
16. Save a distributor notification debug artifact and update metadata with its 16. Saves a distributor notification debug artifact and updates metadata with
path. its path.
For `generated_text_template` reports, generation then:
12. Runs structured Scriptorium generation to the raw generated-text JSON path.
13. Saves the structured Scriptorium run result.
14. Validates and saves normalized generated text.
15. Builds and saves a typed render context.
16. Renders Markdown from the embedded template to the managed report path.
17. Saves final metadata with generated-text paths, render context path, schema
ID, and managed report path.
18. Copies the managed report to the requested `--out` path when provided.
19. If distributor notification is enabled, notifies using the managed report
path as the source file.
20. Saves a distributor notification debug artifact and updates metadata with
its path.
If render preflight returns both a result and an error, preflight JSON and 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
@@ -122,6 +158,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 +180,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

@@ -38,7 +38,7 @@ Outputs:
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.
Near-Term Report supports source and valid-period modules that operate over its Hourly Report supports source and valid-period modules that operate over its
rolling six-hour period: `metadata`, `current_conditions`, `hourly_forecast`, rolling six-hour period: `metadata`, `current_conditions`, `hourly_forecast`,
`precip_timing`, `alert_digest`, `spc_convective_outlooks`, `precip_timing`, `alert_digest`, `spc_convective_outlooks`,
`area_forecast_discussion`, `spc_convective_discussion`, and `weather_story`. `area_forecast_discussion`, `spc_convective_discussion`, and `weather_story`.
@@ -67,7 +67,7 @@ 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. Near-Term Report defaults this module to subset of discussion fields. Hourly Report defaults this module to
`key_messages` and `short_term`. `key_messages` and `short_term`.
`spc_convective_outlooks` uses collected SPC run metadata and derived `spc_convective_outlooks` uses collected SPC run metadata and derived

View File

@@ -27,7 +27,7 @@ Outputs:
report-period SPC convective outlooks and discussions, daily summaries, report-period SPC convective outlooks and discussions, daily summaries,
daypart summaries, and Storm Report window summary daypart summaries, and Storm Report window summary
Near-Term Report uses the generic valid-period hourly and narrative selection Hourly Report uses the generic valid-period hourly and narrative selection
for its rolling six-hour window. Its derived facts include precipitation timing 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 from the selected hourly periods, alert overlaps for the six-hour period, and
SPC outlooks/discussions overlapping that period. It does not build daily SPC outlooks/discussions overlapping that period. It does not build daily
@@ -68,7 +68,7 @@ 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.
- Near-Term Report can derive its default module facts without daily or - Hourly Report can derive its default module facts without daily or
daypart summaries. 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.

View File

@@ -0,0 +1,73 @@
# Generated Text Internals
This document describes structured generated-text handling in
`internal/generatedtext`.
## Purpose
`internal/generatedtext` validates structured text returned for
generated-text-template reports and builds curated render contexts for
templates. The first implemented contract is the Hourly Report.
## Inputs And Outputs
Inputs:
- raw Hourly Report GeneratedText JSON
- report metadata from `internal/briefing`
- a module snapshot from `internal/module`
- validated hourly generated text
Outputs:
- typed `Hourly` generated text
- normalized stable JSON for validated hourly generated text
- typed `HourlyRenderContext` values for `internal/reporttemplate`
The hourly generated text JSON accepts:
```json
{
"summary": "string",
"timing": "string",
"impacts": "string",
"confidence": "string"
}
```
`summary`, `timing`, and `impacts` are required after trimming whitespace.
`confidence` is optional and omitted from normalized JSON when blank.
## Boundaries
- This package owns typed generated-text validation and render-context shaping.
- It uses typed module snapshot decoding through `module.StanzaValue`.
- It does not invoke Scriptorium, write state artifacts, choose report
definitions, compare snapshots, or render templates directly in production
workflows.
- It does not use a Go JSON Schema dependency; schema enforcement in Go is
limited to typed JSON decoding, unknown-field rejection, and required-field
checks.
## Failure Behavior
- Malformed generated-text JSON fails with decode context.
- Unknown generated-text JSON fields fail during decoding.
- Empty required hourly fields fail after trimming whitespace.
- Missing required render-context stanzas fail with the stanza name.
- Invalid render metadata, including missing timezone, missing generated time,
or invalid valid period, fails before template rendering.
## Tests
Inspect:
- `internal/generatedtext/hourly_test.go`
- `internal/generatedtext/render_context_test.go`
## Invariants
- Render contexts are curated structs, not raw prompt-input packages.
- Required generated text is normalized before downstream artifact storage.
- Missing optional weather narrative stanzas produce empty or fallback render
context fields rather than forcing raw module data into templates.

View File

@@ -47,9 +47,9 @@ The registry recognizes these IDs:
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.
## Near-Term Composition ## Hourly Composition
The default Near-Term Report module order is: The default Hourly Report module order is:
1. `metadata` 1. `metadata`
2. `current_conditions` 2. `current_conditions`
@@ -61,7 +61,7 @@ The default Near-Term Report module order is:
8. `spc_convective_discussion` 8. `spc_convective_discussion`
9. `weather_story` 9. `weather_story`
Near-Term Report does not include daily or daypart summary modules by default. Hourly Report does not include daily or daypart summary modules by default.
Its `area_forecast_discussion` item is configured to include only Its `area_forecast_discussion` item is configured to include only
`key_messages` and `short_term`. `key_messages` and `short_term`.

View File

@@ -68,11 +68,14 @@ Prompt-facing module intervals use local `period_begins` and `period_ends`
labels; canonical report metadata and source timestamps remain structured labels; canonical report metadata and source timestamps remain structured
timestamps where applicable. timestamps where applicable.
Near-Term Report uses the same package schema and categories. Its default Hourly Report module snapshots use the same package schema and categories when
package includes `precip_timing` under `derived_summaries`, alert and SPC converted into prompt input. The default hourly module list places
outlooks under `applicable_risk_products`, AFD/SPC discussion/weather story `precip_timing` under `derived_summaries`, alert and SPC outlooks under
under `narrative_products`, and current/hourly data under `raw_data`. It does `applicable_risk_products`, AFD/SPC discussion/weather story under
not include daily or daypart summary stanzas. `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.
Current categories are: Current categories are:
@@ -112,7 +115,6 @@ None.
## Skip And Resume Behavior ## Skip And Resume Behavior
None. Recent Changes is always present as an `items` list and may be empty. None. Recent Changes is always present as an `items` list and may be empty.
Near-Term Report currently writes an empty `items` list.
## Failure Behavior ## Failure Behavior

View File

@@ -16,6 +16,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,16 +26,22 @@ Each report definition declares:
- morning or evening batch membership - morning or evening batch membership
- default ordered module composition - default ordered module composition
Markdown report definitions use the `scriptorium_markdown` generation mode.
Their template and structured-text schema identifiers are empty. Hourly Report
declares `generated_text_template` with template ID `hourly` and schema ID
`hourly`; the app uses those identifiers to validate generated text and render
the embedded hourly template.
## 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 Today | `daily_today` | `weather.daily_report` | `scriptorium_markdown` | `daily` | `daily.md` | Daily Today, Daily Tomorrow |
| Daily Tomorrow | `daily_tomorrow` | `weather.daily_report` | `daily` | `tomorrow.md` | Daily Today, Daily Tomorrow | | Daily Tomorrow | `daily_tomorrow` | `weather.daily_report` | `scriptorium_markdown` | `daily` | `tomorrow.md` | Daily Today, Daily Tomorrow |
| Near-Term Report | `near_term` | `weather.near_term_report` | `near-term` | `near-term.md` | Near-Term Report | | Hourly Report | `hourly` | `weather.hourly_generated_text` | `generated_text_template` | `hourly` | `hourly.md` | Hourly Report |
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `three-day` | `three-day.md` | 3-Day Outlook | | 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` | `weekend` | `weekend.md` | Weekend Outlook | | Weekend Outlook | `weekend` | `weather.weekend_outlook` | `scriptorium_markdown` | `weekend` | `weekend.md` | Weekend Outlook |
| Storm Report | `storm` | `weather.storm_report` | `storm` | `storm.md` | Storm Report | | 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.
@@ -43,7 +50,7 @@ All report definitions are eligible for generation.
- Daily Today covers the selected local civil day, or the current local civil - Daily Today 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. - Daily Tomorrow covers the next local civil day from generation time.
- Near-Term Report covers the half-open six-hour period from generation time in - Hourly Report covers the half-open six-hour period from generation time in
the effective report timezone. The duration is an internal report constant, the effective report timezone. The duration is an internal report constant,
not a configuration field. not a configuration field.
- 3-Day Outlook covers the interval from generation time through local midnight - 3-Day Outlook covers the interval from generation time through local midnight
@@ -69,13 +76,12 @@ IDs, then uses the registry for report policy.
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. Report output path copying uses batch output names from report definitions. Report
module overrides can use the `near_term` or `near-term` keys for Near-Term module overrides can use the `hourly` key for Hourly Report.
Report.
## Batch Membership ## Batch Membership
Morning batches include Daily Today, 3-Day Outlook, and Weekend Outlook except Morning batches include Daily Today, 3-Day Outlook, and Weekend Outlook except
on Sunday. Evening batches include Daily Tomorrow. Near-Term Report is not part on Sunday. Evening batches include Daily Tomorrow. Hourly Report is not part
of a scheduled batch. of a scheduled batch.
## State And App Usage ## State And App Usage

View File

@@ -0,0 +1,94 @@
# 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 first implemented template contract is the
Hourly Report.
The package embeds assets from:
- `internal/reporttemplate/templates/*.md.tmpl`
- `internal/reporttemplate/schemas/*.schema.json`
## Inputs And Outputs
Inputs:
- template ID from a report definition
- typed render context built by `internal/generatedtext`
Outputs:
- template source for inspection and tests
- GeneratedText schema bytes for prompt/schema configuration
- rendered Markdown bytes for app orchestration to persist
The implemented template ID is `hourly`. The implemented schema ID is also
`hourly`, backed by `hourly.generated_text.schema.json`.
## Boundaries
This package owns embedded asset lookup, Go template parsing, and Markdown
template execution. It does not fetch weather data, build module outputs,
validate GeneratedText, construct render contexts, choose report definitions,
write artifacts, invoke Scriptorium, or notify distributor.
GeneratedText validation is owned by `internal/generatedtext`. App
orchestration decides which template and schema IDs apply to a report through
`internal/report` definitions.
## Template Contract
Hourly rendering uses a typed render context with:
- report metadata labels such as title, location, valid period, and generation
time
- validated hourly 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
Templates use `text/template` with `missingkey=error`, so missing context fields
fail rendering instead of producing incomplete Markdown.
## Schema Contract
The hourly GeneratedText schema describes the structured prose Scriptorium is
expected to write for the prompt. It requires:
- `summary`
- `timing`
- `impacts`
It allows optional `confidence` and rejects 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
@@ -84,22 +97,27 @@ current report definition.
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 +139,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

@@ -11,24 +11,29 @@ Generation commands:
```text ```text
weatherreporter generate daily --date 2026-05-29 weatherreporter generate daily --date 2026-05-29
weatherreporter generate tomorrow weatherreporter generate tomorrow
weatherreporter generate near-term 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 Markdown-path generation commands resolve a report period, fetch a Weather API
JSON module snapshot, builds a YAML prompt input data package, runs bundle, build a JSON module snapshot, build a YAML prompt input data package,
`scriptorium render`, runs `scriptorium run`, and writes managed artifacts under run `scriptorium render`, run `scriptorium run`, and write managed artifacts
the configured workspace. under the configured workspace. When distributor notification is enabled,
When distributor notification is enabled, weatherreporter uploads the managed weatherreporter uploads the managed Markdown report after report rendering
Markdown report after `scriptorium run` succeeds and final metadata is saved. succeeds and final metadata is saved. `--out PATH` writes an extra Markdown
`--out PATH` writes an extra Markdown copy for the current generated report; it copy for generated reports; it is not used as the distributor upload source.
is not used as the distributor upload source.
`generate near-term` is an explicit generation command. It covers the six-hour `generate hourly` covers the six-hour rolling period from generation time in
rolling period from generation time in the effective report timezone and is not the effective report timezone and is not part of scheduled morning or evening
part of scheduled morning or evening batches. batches. It builds the same module snapshot and data package, runs
`scriptorium render` as preflight, runs structured `scriptorium run` to raw
GeneratedText JSON, validates the structured text, saves a render context, and
renders the managed Markdown report from the embedded hourly template. When
distributor notification is enabled, hourly uploads the managed Markdown report
after final metadata is saved. `--out PATH` writes an extra Markdown copy and
is not used as the distributor upload source.
Batch commands: Batch commands:
@@ -67,10 +72,14 @@ workspace/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.modules.json <run_id>.modules.json
<run_id>.metadata.json <run_id>.metadata.json
near-term/ hourly/
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
storm/ storm/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.modules.json <run_id>.modules.json
@@ -85,7 +94,7 @@ workspace/
weekend/ weekend/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.data_package.yaml <run_id>.data_package.yaml
near-term/ hourly/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.data_package.yaml <run_id>.data_package.yaml
storm/ storm/
@@ -101,7 +110,7 @@ workspace/
weekend/ weekend/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.render.json <run_id>.render.json
near-term/ hourly/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.render.json <run_id>.render.json
storm/ storm/
@@ -117,7 +126,7 @@ workspace/
weekend/ weekend/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.distributor.json <run_id>.distributor.json
near-term/ hourly/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.distributor.json <run_id>.distributor.json
storm/ storm/
@@ -130,7 +139,7 @@ workspace/
<run_id>.md <run_id>.md
weekend/ weekend/
<run_id>.md <run_id>.md
near-term/ hourly/
<run_id>.md <run_id>.md
storm/ storm/
<run_id>.md <run_id>.md
@@ -156,6 +165,7 @@ 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 Hourly Report
- 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
@@ -185,8 +195,9 @@ 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`.
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.
@@ -238,7 +249,7 @@ Markdown or YAML text.
Daily Today and Daily Tomorrow can compare with each other when they cover the Daily Today and Daily Tomorrow can compare with each other when they cover the
same valid local date. 3-Day Outlook compares with prior compatible 3-Day same valid local date. 3-Day Outlook compares with prior compatible 3-Day
snapshots for the same valid local date. Weekend Outlook compares with prior snapshots for the same valid local date. Weekend Outlook compares with prior
compatible Weekend snapshots for the same weekend window. Near-Term Report and compatible Weekend snapshots for the same weekend window. Hourly Report and
Storm Report leave Recent Changes empty. 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,
@@ -252,6 +263,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.
- Hourly generated-text failures preserve available intermediate artifacts,
such as the structured run result, raw generated-text JSON, validated
generated text, and render context. Metadata links those paths when it can be
safely written.
- If distributor notification fails, report artifacts and final metadata remain - 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

View File

@@ -41,7 +41,8 @@ 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; - another short-fuse planning report distinct from the implemented Hourly
Report, if a separate product is needed;
- event-specific reports with stable event IDs; - event-specific reports with stable event IDs;
- storm review or yesterday-style reports using historical observations; - 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
@@ -84,7 +85,6 @@ These enhancements are not current behavior:
- `failure_policy: warn`; - `failure_policy: warn`;
- uploading metadata, module snapshots, data packages, or preflight artifacts; - uploading metadata, module snapshots, data packages, or preflight artifacts;
- polling distributor status after upload acceptance;
- durable upload retry queues; - durable upload retry queues;
- distributor-specific CLI flags; - distributor-specific CLI flags;
- making distributor scan the weatherreporter workspace; - making distributor scan the weatherreporter workspace;

View File

@@ -4,8 +4,8 @@
This roadmap defines planned work to move selected reports from full Markdown This roadmap defines planned work to move selected reports from full Markdown
LLM generation to deterministic Markdown rendering with structured LLM-filled LLM generation to deterministic Markdown rendering with structured LLM-filled
text slots. The first target report is the rolling next-hours report, now named text slots. The first target report is the rolling next-hours report, named
`hourly` rather than `near_term`. `hourly`.
This feature is not implemented yet, so this document lives under This feature is not implemented yet, so this document lives under
`docs/roadmap/`. `docs/roadmap/`.
@@ -67,15 +67,14 @@ not become a second application logic layer.
Schema dependency in the first implementation. Schema dependency in the first implementation.
- Do not generate schemas or templates from inline Go. - Do not generate schemas or templates from inline Go.
- Start with the rolling next-hours report only. - Start with the rolling next-hours report only.
- Rename the planned `near_term` report to `hourly`. - Use `hourly` as the only public report ID for the rolling next-hours
- Do not keep both `near_term` and `hourly` public report IDs. report.
- Do not keep compatibility aliases from retired roadmap terminology.
- Do not add a generic workflow engine or plugin system. - Do not add a generic workflow engine or plugin system.
## Hourly Report Target ## Hourly Report Target
The planned `near_term` report should become the `hourly` report. The rolling next-hours report target identity is:
Target report identity:
- report ID: `hourly` - report ID: `hourly`
- CLI command: `weatherreporter generate hourly` - CLI command: `weatherreporter generate hourly`
@@ -143,7 +142,7 @@ Keep the first schema small. Example shape:
```json ```json
{ {
"summary": "Brief near-term overview.", "summary": "Brief hourly overview.",
"timing": "Plain-language timing of notable changes.", "timing": "Plain-language timing of notable changes.",
"impacts": "Practical impacts for the next few hours.", "impacts": "Practical impacts for the next few hours.",
"confidence": "Optional confidence or uncertainty note." "confidence": "Optional confidence or uncertainty note."

View File

@@ -7,8 +7,8 @@ This roadmap defines the concrete implementation sequence for
will implement each stage in order. will implement each stage in order.
This is a future-work roadmap. Until a stage is implemented, non-roadmap docs This is a future-work roadmap. Until a stage is implemented, non-roadmap docs
must not describe `GeneratedText`, embedded report templates, or must not describe `GeneratedText`, embedded report templates, or other
`weatherreporter generate hourly` as available behavior. unimplemented GeneratedText behavior as available.
## Source Feature Roadmap ## Source Feature Roadmap
@@ -26,8 +26,8 @@ for reaching that shape.
- Implement the first GeneratedText path for the rolling next-hours report, - Implement the first GeneratedText path for the rolling next-hours report,
named `hourly`. named `hourly`.
- Replace planned/public `near_term` naming with `hourly`; do not keep a - Use `hourly` as the only planned public name for the rolling next-hours
public `near-term` alias. report; do not keep compatibility aliases from retired roadmap terminology.
- Use report ID `hourly`, artifact group `hourly`, batch output name - Use report ID `hourly`, artifact group `hourly`, batch output name
`hourly.md`, and CLI command `weatherreporter generate hourly`. `hourly.md`, and CLI command `weatherreporter generate hourly`.
- Keep the rolling report explicit-generation only; do not add scheduled batch - Keep the rolling report explicit-generation only; do not add scheduled batch
@@ -61,7 +61,7 @@ planned public name for the rolling next-hours report.
Files to inspect: Files to inspect:
- `docs/roadmap/generated-text.md` - `docs/roadmap/generated-text.md`
- `docs/roadmap/near-term.md` - the superseded roadmap file for the retired rolling-report name
- `docs/roadmap/future.md` - `docs/roadmap/future.md`
- `docs/roadmap/implementation.md` - `docs/roadmap/implementation.md`
- `internal/app/app.go` - `internal/app/app.go`
@@ -70,30 +70,32 @@ Files to inspect:
Implementation: Implementation:
- Replace `docs/roadmap/near-term.md` with a short superseded note pointing to - Replace the superseded roadmap file for the retired rolling-report name with
`docs/roadmap/generated-text.md`, or delete it if the repository policy at a short note pointing to `docs/roadmap/generated-text.md`, or delete it if
implementation time prefers removing obsolete roadmap files. the repository policy at implementation time prefers removing obsolete
- Ensure no roadmap instructs a future agent to implement public roadmap files.
`near_term` / `near-term` behavior. - Ensure no roadmap instructs a future agent to implement retired public
- If partial `near_term` code already exists, rename it to `hourly` rather identifiers for the rolling report.
than adding aliases: - If partial code using retired identifiers already exists, rename it to
- `ReportNearTerm` -> `ReportHourly`; `hourly` rather than adding aliases:
- `report.NearTerm` -> `report.Hourly`; - app report kind -> `ReportHourly`;
- CLI command `near-term` -> `hourly`; - report ID constant -> `report.Hourly`;
- artifact group `near-term` -> `hourly`; - CLI command -> `hourly`;
- artifact group -> `hourly`;
- prompt IDs and tests updated to hourly names. - prompt IDs and tests updated to hourly names.
- Do not add compatibility aliases for `near-term`. - Do not add compatibility aliases for retired command or config names.
Acceptance criteria: Acceptance criteria:
- Active roadmap docs use `hourly` for the rolling report. - Active roadmap docs use `hourly` for the rolling report.
- No active roadmap describes `near_term` as the target public report ID. - No active roadmap describes a retired identifier as the target public report
ID.
- Existing code compiles after any rename work. - Existing code compiles after any rename work.
Suggested validation: Suggested validation:
```bash ```bash
rg -n "near_term|near-term|NearTerm" docs internal rg -n 'near[_-]term|Near''Term' docs internal
go test ./internal/app ./internal/report ./internal/cli go test ./internal/app ./internal/report ./internal/cli
``` ```
@@ -208,7 +210,7 @@ Implementation:
- no daily summaries, daypart summaries, or storm summary. - no daily summaries, daypart summaries, or storm summary.
- Add config report aliases: - Add config report aliases:
- `hourly` - `hourly`
- reject or do not recognize `near_term` / `near-term`. - reject or do not recognize retired rolling-report config keys.
- Add app report kind `ReportHourly = "hourly"` and map it to - Add app report kind `ReportHourly = "hourly"` and map it to
`report.Hourly`. `report.Hourly`.
- Add CLI parsing and help for: - Add CLI parsing and help for:
@@ -651,17 +653,17 @@ Files to inspect:
Implementation: Implementation:
- Add CLI command tests for `generate hourly`. - Add CLI command tests for `generate hourly`.
- Reject `generate near-term`. - Reject the retired rolling-report command name.
- Reject hourly `--date`, `--start`, `--end`, and duration-like flags. - Reject hourly `--date`, `--start`, `--end`, and duration-like flags.
- Add config override tests for `reports.hourly.deterministic_modules`. - Add config override tests for `reports.hourly.deterministic_modules`.
- Ensure `near_term` and `near-term` report config keys are rejected. - Ensure retired rolling-report config keys are rejected.
- Add app/CLI workflow tests using fake Scriptorium structured output. - Add app/CLI workflow tests using fake Scriptorium structured output.
- Update examples only if they enumerate report module overrides. - Update examples only if they enumerate report module overrides.
Acceptance criteria: Acceptance criteria:
- `weatherreporter --help` lists `generate hourly`; - `weatherreporter --help` lists `generate hourly`;
- `generate near-term` is not accepted; - the retired rolling-report command name is not accepted;
- hourly config overrides validate compatible modules; - hourly config overrides validate compatible modules;
- incompatible daily/daypart modules fail clearly for hourly; - incompatible daily/daypart modules fail clearly for hourly;
- examples load successfully. - examples load successfully.
@@ -693,13 +695,13 @@ Files to inspect and update:
- new `docs/internal/reporttemplate.md` - new `docs/internal/reporttemplate.md`
- `docs/integrations/scriptorium.md` - `docs/integrations/scriptorium.md`
- `docs/roadmap/future.md` - `docs/roadmap/future.md`
- `docs/roadmap/near-term.md` - the superseded roadmap file for the retired rolling-report name
- `examples/config.yml`, only if examples changed - `examples/config.yml`, only if examples changed
Documentation requirements: Documentation requirements:
- Non-roadmap docs describe only implemented hourly and GeneratedText behavior. - Non-roadmap docs describe only implemented hourly and GeneratedText behavior.
- `docs/cli.md` documents `generate hourly`, not `generate near-term`. - `docs/cli.md` documents `generate hourly`, not the retired command name.
- `docs/config.md` documents `reports.hourly` only if override support is - `docs/config.md` documents `reports.hourly` only if override support is
implemented. implemented.
- Internal docs distinguish: - Internal docs distinguish:
@@ -711,20 +713,22 @@ Documentation requirements:
- Scriptorium integration docs include the structured-output command actually - Scriptorium integration docs include the structured-output command actually
used by the adapter. used by the adapter.
- State docs list generated-text artifacts and metadata links. - State docs list generated-text artifacts and metadata links.
- `docs/roadmap/near-term.md` is deleted or marked superseded. - the superseded roadmap file for the retired rolling-report name is deleted
or marked superseded.
- Deferred migrations for other report types stay under roadmap docs only. - Deferred migrations for other report types stay under roadmap docs only.
Acceptance criteria: Acceptance criteria:
- No non-roadmap docs describe unimplemented generated-text migrations. - No non-roadmap docs describe unimplemented generated-text migrations.
- No active docs describe `near_term` as the target public report. - No active docs describe retired rolling-report identifiers as the target
public report.
- Maintained examples remain valid. - Maintained examples remain valid.
Suggested validation: Suggested validation:
```bash ```bash
go test ./internal/config go test ./internal/config
rg -n "near_term|near-term|NearTerm" docs internal examples rg -n 'near[_-]term|Near''Term' docs internal examples
git diff --check git diff --check
``` ```
@@ -745,7 +749,7 @@ git diff --check
Manual checks: Manual checks:
- `weatherreporter --help` lists `generate hourly`. - `weatherreporter --help` lists `generate hourly`.
- `weatherreporter --help` does not list `generate near-term`. - `weatherreporter --help` does not list the retired rolling-report command.
- generated hourly Markdown has deterministic headings and section order. - generated hourly Markdown has deterministic headings and section order.
- GeneratedText JSON is persisted separately from deterministic facts. - GeneratedText JSON is persisted separately from deterministic facts.
- render context JSON is persisted and readable. - render context JSON is persisted and readable.

View File

@@ -1,363 +0,0 @@
# Near-Term Report Roadmap
## Purpose
This roadmap defines planned work to add a rolling `near_term` report focused
on the next several hours. The feature is not implemented yet, so this document
lives under `docs/roadmap/`.
The goal is a frequently generated report that helps readers understand what
matters in the immediate future: current conditions, hourly evolution,
precipitation timing, applicable hazards, applicable SPC risk products, and the
most relevant short-term narrative context.
## Target Behavior
Add a generated report with:
- report ID: `near_term`
- display name: `Near-Term Report`
- prompt ID: `weather.near_term_report`
- artifact group: `near-term`
- batch output name: `near-term.md`
- default valid-period length: 6 hours
- valid period: `[generation_time, generation_time + nearTermHours)`
The report should use a package-owned constant for the valid-period length, for
example:
```go
const nearTermHours = 6
```
Do not make the duration configurable in the first implementation. The constant
exists so the value can be changed later to 4 or 8 hours without changing
valid-period logic in multiple places.
## Locked Decisions
- The report ID is `near_term`.
- The report is rolling and generation-time anchored, not civil-day anchored.
- The first implementation covers the next 6 hours through a constant.
- The report should be generated explicitly by CLI command before deciding
whether it belongs in scheduled batches.
- The first implementation should declare `rolling_window` comparison policy
but should not emit Recent Changes output for `near_term`.
- Alert and SPC products should be included only when their valid periods
overlap the resolved near-term report period.
- SPC discussion text should keep the existing categorical-risk threshold rule
and should still require overlap with the near-term report period.
- The AFD stanza should include key messages and short term text by default.
- Daily-only modules should not be forced into this report.
## Report Definition
Add a report definition under `internal/report`, preferably in a focused
`near_term_report.go` file.
Definition fields:
- `ID`: `NearTerm`
- `Name`: `Near-Term Report`
- `PromptID`: `weather.near_term_report`
- `ComparisonStrategy`: `rolling_window`
- `ArtifactGroup`: `near-term`
- `BatchOutputName`: `near-term.md`
- `Generated`: `true`
- `CompatiblePriorIDs`: `[]ID{NearTerm}`
- `Modules`: near-term module list below
- `resolve`: rolling near-term resolver
Valid-period resolver:
```go
func resolveNearTerm(req ResolveRequest) (timeutil.Period, error) {
localNow := req.Now.In(req.Location)
return timeutil.Period{
Start: localNow,
End: localNow.Add(nearTermHours * time.Hour),
}, nil
}
```
The implementation should use idiomatic package-local constants and avoid
duplicating duration literals in tests or app code.
## Default Module Composition
Default module order should be:
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`
`area_forecast_discussion` should use options equivalent to:
```yaml
sections:
- key_messages
- short_term
```
Do not include these daily/daypart-oriented modules initially:
- `derived_daily_summary`
- `derived_daypart_summaries`
- `outdoor_windows`
- `tomorrow_planning`
If the report needs deterministic summary facts later, add a purpose-built
module such as `derived_near_term_summary` rather than stretching daily modules
into a rolling sub-daily context.
## Prompt Package Shape
The existing prompt-input category layout should remain unchanged:
- `applicable_risk_products`
- `derived_summaries`
- `narrative_products`
- `raw_data`
Near-term output is expected to emphasize:
- `current_conditions` and `hourly_forecast` under `raw_data`
- `precip_timing` under `derived_summaries`
- `alert_digest` and `spc_convective_outlooks` under
`applicable_risk_products`
- `area_forecast_discussion`, `spc_convective_discussion`, and
`weather_story` under `narrative_products`
Module interval fields should use the existing prompt-facing
`period_begins` / `period_ends` convention.
## Overlap And Filtering Rules
Hourly forecast:
- use only hourly periods overlapping the near-term valid period;
- preserve hourly period order;
- do not include the full daily forecast.
Narrative products:
- AFD key messages and short term text may be included because the AFD is an
official short-term forecast discussion product;
- narrative forecast periods are not included by default unless a later prompt
test shows they improve near-term output.
Alerts:
- include active alert overlaps only when the alert overlaps the near-term
valid period;
- if alerts were checked successfully and no alerts overlap, emit checked empty
alert context through existing module behavior.
SPC outlooks:
- include only retained outlooks whose valid periods overlap the near-term
valid period;
- do not include non-overlapping outlooks even if they are severe;
- keep current prompt-facing field exclusions for geometry, severity rank,
expiration time, and source URL.
SPC discussion:
- include discussion only for SPC days where a retained overlapping categorical
outlook has severity rank at least `3`;
- do not include discussion for low-risk, non-categorical-only, or
non-overlapping outlooks.
Weather story:
- include when available under current optional-source behavior;
- do not require the story valid period to exactly match the near-term period
unless later testing shows stale stories are a problem.
## Implementation Stages
### Stage 1: Report Registry
Goal: add the `near_term` report definition and valid-period resolver.
Files to inspect or update:
- `internal/report/definition.go`
- `internal/report/registry.go`
- `internal/report/period.go`
- new `internal/report/near_term_report.go`
- `internal/report/period_test.go`
- `docs/internal/report-registry.md` after implementation
Acceptance criteria:
- `report.DefaultRegistry().Lookup(report.NearTerm)` succeeds.
- `report.Registry.All()` includes the report in a stable order.
- resolving the report at a fixed generation time produces a half-open
six-hour period.
- report metadata and RunID include `near_term`.
### Stage 2: Module Compatibility And Defaults
Goal: allow existing relevant modules to build for `near_term` and define the
default near-term composition.
Files to inspect or update:
- `internal/briefing/modules.go`
- `internal/report/near_term_report.go`
- `internal/briefing/*_module_test.go`
- `internal/module/module_test.go`
Acceptance criteria:
- all default near-term modules validate and build from appropriate test facts;
- daily-only modules remain incompatible unless intentionally expanded;
- `area_forecast_discussion` defaults to key messages and short term only for
this report.
### Stage 3: Derived Facts
Goal: make `internal/facts` derive report-period facts for `near_term`.
Files to inspect or update:
- `internal/facts/facts.go`
- `internal/facts/facts_test.go`
- `internal/forecast` selection helpers, if needed
Acceptance criteria:
- valid-period hourly periods are sliced to the six-hour window;
- precipitation timing is built from that six-hour hourly slice;
- alert overlaps use the near-term valid period;
- SPC outlook and discussion derivation use the near-term valid period;
- no daily summaries or daypart summaries are required for the default
near-term module set.
### Stage 4: CLI And App Wiring
Goal: add explicit generation support without changing existing commands.
Files to inspect or update:
- `internal/app`
- `internal/cli`
- `cmd/weatherreporter/main.go`
- `docs/cli.md` after implementation
Expected command:
```bash
weatherreporter generate near-term [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
```
Acceptance criteria:
- command maps to `report.NearTerm`;
- no `--date`, `--start`, or `--end` flags are added for this report;
- existing `generate daily`, `generate tomorrow`, `generate three-day`,
`generate weekend`, and `generate storm` behavior is unchanged;
- CLI help lists the new command.
### Stage 5: Prompt Input And State Coverage
Goal: prove the managed module snapshot and data package contain the intended
near-term stanzas.
Files to inspect or update:
- `internal/app/app_test.go`
- `internal/promptinput`
- `internal/state`
Acceptance criteria:
- generated module snapshot includes the near-term default module list in
order;
- data package uses existing categories;
- alert/SPC stanzas are omitted or checked-empty according to current module
behavior and valid-period overlap;
- report metadata preserves canonical `valid_period`;
- module interval labels use `period_begins` and `period_ends`.
### Stage 6: Documentation And Examples
Goal: document implemented behavior only after the code exists.
Files to inspect or update after implementation:
- `docs/cli.md`
- `docs/internal/report-registry.md`
- `docs/internal/module.md`
- `docs/internal/facts.md`
- `docs/internal/briefing.md`
- `docs/config.md` only if report module overrides need an example update
- `examples/config.yml` only if it lists report module overrides
Acceptance criteria:
- non-roadmap docs describe `near_term` only after implementation;
- future or deferred near-term summary modules remain under roadmap docs;
- examples, if updated, load through existing config tests.
## Test Plan
Focused tests:
```bash
go test ./internal/report ./internal/briefing ./internal/facts
go test ./internal/app ./internal/cli ./internal/promptinput
```
Full validation:
```bash
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Important coverage:
- six-hour valid-period resolution from a fixed generation time;
- timezone-aware period start and end;
- no civil-day truncation;
- hourly periods selected by overlap with the rolling window;
- alert overlap filtering;
- SPC outlook filtering by overlap;
- SPC discussion filtering by overlap plus categorical severity threshold;
- AFD key messages and short term included;
- daily-only modules rejected or absent from default near-term composition;
- distributor bundle path templates render sensibly for a sub-daily report,
especially valid start time variables.
## Deferred Work
Do not include these in the first implementation:
- user-configurable near-term duration;
- adding `near_term` to scheduled morning or evening batches;
- a dedicated `derived_near_term_summary` module;
- narrative forecast periods by default;
- separate AFD section modules;
- near-term-specific Recent Changes comparison output;
- CLI flags for custom near-term duration.
## Open Questions
None block the roadmap.
Recommendation: implement the first version as explicit `generate near-term`
only, with no scheduled batch membership. That keeps the user-visible behavior
small while the prompt and generated report are tested.
Viable alternative: add `near_term` to a frequent scheduled batch immediately.
That may be useful operationally, but it should wait until the report cadence
and downstream distributor behavior are clear.

View File

@@ -89,3 +89,18 @@ reports:
- weather_story - weather_story
- outdoor_windows - outdoor_windows
- hourly_forecast - hourly_forecast
hourly:
deterministic_modules:
- metadata
- current_conditions
- hourly_forecast
- precip_timing
- alert_digest
- spc_convective_outlooks
- id: area_forecast_discussion
options:
sections:
- key_messages
- short_term
- spc_convective_discussion
- weather_story

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

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

@@ -17,9 +17,11 @@ 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"
"gitea.maximumdirect.net/eric/weatherreporter/internal/reporttemplate"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state" "gitea.maximumdirect.net/eric/weatherreporter/internal/state"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
@@ -30,7 +32,7 @@ type ReportKind string
const ( const (
ReportDaily ReportKind = "daily" ReportDaily ReportKind = "daily"
ReportTomorrow ReportKind = "tomorrow" ReportTomorrow ReportKind = "tomorrow"
ReportNearTerm ReportKind = "near-term" ReportHourly ReportKind = "hourly"
ReportThreeDay ReportKind = "three-day" ReportThreeDay ReportKind = "three-day"
ReportWeekend ReportKind = "weekend" ReportWeekend ReportKind = "weekend"
ReportStorm ReportKind = "storm" ReportStorm ReportKind = "storm"
@@ -103,6 +105,11 @@ type ReportResult struct {
RecentChanges []changes.Change RecentChanges []changes.Change
RenderResult *scriptorium.RenderResult RenderResult *scriptorium.RenderResult
RunResult *scriptorium.RunResult RunResult *scriptorium.RunResult
StructuredRunResult *scriptorium.StructuredRunResult
GeneratedTextRawPath string
GeneratedTextResultPath string
GeneratedTextPath string
RenderContextPath string
Notification *NotificationResult Notification *NotificationResult
} }
@@ -151,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 {
@@ -387,8 +395,8 @@ func reportIDForCommand(kind ReportKind) (report.ID, error) {
return report.DailyToday, nil return report.DailyToday, nil
case ReportTomorrow: case ReportTomorrow:
return report.DailyTomorrow, nil return report.DailyTomorrow, nil
case ReportNearTerm: case ReportHourly:
return report.NearTerm, nil return report.Hourly, nil
case ReportThreeDay: case ReportThreeDay:
return report.ThreeDay, nil return report.ThreeDay, nil
case ReportWeekend: case ReportWeekend:
@@ -490,6 +498,10 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
DataPackage: paths.DataPackage, DataPackage: paths.DataPackage,
Preflight: paths.Preflight, Preflight: paths.Preflight,
RenderedReport: paths.RenderedReport, RenderedReport: paths.RenderedReport,
GeneratedTextRaw: paths.GeneratedTextRaw,
GeneratedTextResult: paths.GeneratedTextResult,
GeneratedText: paths.GeneratedText,
RenderContext: paths.RenderContext,
}) })
dataPackage, err := promptinput.Build(promptinput.BuildRequest{ dataPackage, err := promptinput.Build(promptinput.BuildRequest{
Metadata: promptMetadata(metadata), Metadata: promptMetadata(metadata),
@@ -534,9 +546,34 @@ 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,
dataPackage: dataPackage,
dataPackagePath: dataPackagePath,
briefingMetadata: briefingMetadata,
metadata: metadata,
metadataPath: metadataPath,
preflightPath: preflightPath,
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
@@ -595,6 +632,138 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
}, nil }, nil
} }
type generatedReportRequest struct {
ReportRequest
store state.Store
paths state.ArtifactPaths
moduleSnapshot module.Snapshot
moduleSnapshotPath string
dataPackage promptinput.Package
dataPackagePath string
briefingMetadata briefing.Metadata
metadata state.Metadata
metadataPath string
preflightPath string
recentChanges []changes.Change
renderResult *scriptorium.RenderResult
renderer Renderer
}
func generateTextTemplateReport(ctx context.Context, req generatedReportRequest) (*ReportResult, error) {
structuredResult, runErr := req.renderer.StructuredRun(ctx, scriptorium.StructuredRunRequest{
PromptID: req.Resolved.Definition.PromptID,
DataPackagePath: req.dataPackagePath,
OutputPath: req.paths.GeneratedTextRaw,
})
generatedTextResultPath := req.paths.GeneratedTextResult
if structuredResult != nil {
var err error
generatedTextResultPath, err = req.store.SaveGeneratedTextResult(ctx, req.Resolved, structuredResult)
if err != nil {
return nil, err
}
req.metadata.GeneratedTextResultPath = generatedTextResultPath
req.metadataPath, err = req.store.SaveMetadata(ctx, req.metadata)
if err != nil {
return nil, err
}
}
if runErr != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "structured generated text", runErr)
}
rawGeneratedText, err := req.store.LoadGeneratedText(ctx, req.paths.GeneratedTextRaw)
if err != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "load raw generated text", err)
}
hourlyText, normalizedGeneratedText, err := validateGeneratedText(req.Resolved.Definition, rawGeneratedText)
if err != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "validate generated text", err)
}
generatedTextPath, err := req.store.SaveGeneratedText(ctx, req.Resolved, normalizedGeneratedText)
if err != nil {
return nil, err
}
req.metadata.GeneratedTextPath = generatedTextPath
req.metadataPath, err = req.store.SaveMetadata(ctx, req.metadata)
if err != nil {
return nil, err
}
renderContext, err := buildRenderContext(req.Resolved.Definition, req.briefingMetadata, req.moduleSnapshot, hourlyText)
if err != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "build render context", err)
}
renderContextPath, err := req.store.SaveRenderContext(ctx, req.Resolved, renderContext)
if err != nil {
return nil, err
}
req.metadata.RenderContextPath = renderContextPath
req.metadataPath, err = req.store.SaveMetadata(ctx, req.metadata)
if err != nil {
return nil, err
}
rendered, err := reporttemplate.Render(req.Resolved.Definition.TemplateID, renderContext)
if err != nil {
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "render template", err)
}
reportPath, err := req.store.PrepareRenderedReport(ctx, req.Resolved)
if err != nil {
return nil, err
}
if err := fileutil.WriteFileAtomic(reportPath, rendered); err != nil {
return nil, err
}
req.metadata.RenderedReportPath = reportPath
metadataPath, err := req.store.SaveMetadata(ctx, req.metadata)
if err != nil {
return nil, err
}
outputPath := reportPath
if req.OutputPath != "" {
outputPath = req.OutputPath
if req.OutputPath != reportPath {
if err := fileutil.CopyFileAtomic(reportPath, req.OutputPath); err != nil {
return nil, err
}
}
}
notification, notificationPath, notificationErr := notifyReport(ctx, req.Config, req.Resolved, reportPath, req.metadata, req.Notifier, req.store)
if notificationPath != "" {
req.metadata.NotificationPath = notificationPath
metadataPath, err = req.store.SaveMetadata(ctx, req.metadata)
if err != nil {
return nil, err
}
}
if notificationErr != nil {
return nil, notificationErr
}
return &ReportResult{
ModuleSnapshot: req.moduleSnapshot,
ModuleSnapshotPath: req.moduleSnapshotPath,
DataPackage: req.dataPackage,
DataPackagePath: req.dataPackagePath,
PreflightPath: req.preflightPath,
ReportPath: reportPath,
OutputPath: outputPath,
NotificationPath: notificationPath,
Metadata: req.metadata,
MetadataPath: metadataPath,
RecentChanges: req.recentChanges,
RenderResult: req.renderResult,
StructuredRunResult: structuredResult,
GeneratedTextRawPath: req.paths.GeneratedTextRaw,
GeneratedTextResultPath: generatedTextResultPath,
GeneratedTextPath: generatedTextPath,
RenderContextPath: renderContextPath,
Notification: notification,
}, nil
}
func notifyReport(ctx context.Context, cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata, notifier Notifier, store state.Store) (*NotificationResult, string, error) { 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 {
@@ -938,3 +1107,28 @@ func preflightArtifact(result *scriptorium.RenderResult) state.PreflightArtifact
ExitCode: result.ExitCode, ExitCode: result.ExitCode,
} }
} }
func validateGeneratedText(definition report.Definition, data []byte) (generatedtext.Hourly, []byte, error) {
switch definition.GeneratedTextSchemaID {
case "hourly":
return generatedtext.ValidateHourly(data)
default:
return generatedtext.Hourly{}, nil, fmt.Errorf("generated text schema %q is not supported for report %q", definition.GeneratedTextSchemaID, definition.ID)
}
}
func buildRenderContext(definition report.Definition, metadata briefing.Metadata, snapshot module.Snapshot, hourly generatedtext.Hourly) (any, error) {
switch definition.TemplateID {
case "hourly":
return generatedtext.BuildHourlyRenderContext(metadata, snapshot, hourly)
default:
return nil, fmt.Errorf("report template %q is not supported for report %q", definition.TemplateID, definition.ID)
}
}
func generatedReportError(resolved report.Resolved, runID string, operation string, err error) error {
if err == nil {
return nil
}
return fmt.Errorf("generate report %q run %q: %s: %w", resolved.Definition.ID, runID, operation, err)
}

View File

@@ -349,185 +349,486 @@ func TestGenerateReportOmitsSPCConvectiveDiscussionBelowThreshold(t *testing.T)
} }
} }
func TestGenerateNearTermReportWritesExpectedArtifacts(t *testing.T) { func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
server := nearTermBundleServer(t) server := hourlyBundleServer(t)
cfg := dailyTestConfig(t, server) cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir() cfg.Workspace.Root = t.TempDir()
cfg.Location.ID = "home"
cfg.Location.Name = "Brentwood"
cfg.Location.Region = "MO"
resolved, err := ResolveGenerate(GenerateRequest{ resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg, Config: cfg,
Report: ReportNearTerm, Report: ReportHourly,
}, mustParse("2026-05-29T08:30:00-05:00")) }, mustParse("2026-05-29T08:30:00-05:00"))
if err != nil { if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err) t.Fatalf("ResolveGenerate() error = %v", err)
} }
outputPath := filepath.Join(t.TempDir(), "near-term.md") if resolved.Definition.GenerationMode != report.GenerationModeGeneratedTextTemplate {
renderer := successfulRenderer("# Near-Term Report\n") t.Fatalf("GenerationMode = %q, want generated text template", resolved.Definition.GenerationMode)
}
filesystemStore, err := state.NewFilesystemStore(cfg.Workspace)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
store := &recordingStore{Store: filesystemStore}
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{
Command: []string{"scriptorium", "render"},
Stdout: `{"prepared":true}`,
ExitCode: 0,
},
structuredRunResult: &scriptorium.StructuredRunResult{
Command: []string{"scriptorium", "run"},
Stderr: "wrote generated text",
ExitCode: 0,
},
structuredRunBody: `{
"summary": " Storm chances increase through late morning. ",
"timing": "The main window is 10 AM to noon.",
"impacts": "Brief downpours may slow travel.",
"confidence": "Medium"
}`,
}
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: resolved,
Renderer: renderer,
Store: store,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
if renderer.renderCalls != 1 {
t.Fatalf("render calls = %d, want 1", renderer.renderCalls)
}
if renderer.structuredRunCalls != 1 {
t.Fatalf("structured run calls = %d, want 1", renderer.structuredRunCalls)
}
if renderer.runCalls != 0 {
t.Fatalf("markdown run calls = %d, want none", renderer.runCalls)
}
if renderer.structuredRunRequest.OutputPath != result.GeneratedTextRawPath {
t.Fatalf("structured run OutputPath = %q, want %q", renderer.structuredRunRequest.OutputPath, result.GeneratedTextRawPath)
}
if renderer.structuredRunRequest.DataPackagePath != result.DataPackagePath {
t.Fatalf("structured run DataPackagePath = %q, want %q", renderer.structuredRunRequest.DataPackagePath, result.DataPackagePath)
}
if got, want := strings.Join(store.calls, ","), "module_snapshot,data_package,preflight,metadata,generated_text_result,metadata,generated_text,metadata,render_context,metadata,prepare_report,metadata"; got != want {
t.Fatalf("store calls = %v, want %s", store.calls, want)
}
assertPathsExist(t,
result.ModuleSnapshotPath,
result.DataPackagePath,
result.PreflightPath,
result.GeneratedTextRawPath,
result.GeneratedTextResultPath,
result.GeneratedTextPath,
result.RenderContextPath,
result.ReportPath,
result.MetadataPath,
)
raw, err := os.ReadFile(result.GeneratedTextRawPath)
if err != nil {
t.Fatalf("read raw generated text: %v", err)
}
if !strings.Contains(string(raw), `"summary": " Storm chances increase through late morning. "`) {
t.Fatalf("raw generated text was not preserved:\n%s", string(raw))
}
normalized, err := os.ReadFile(result.GeneratedTextPath)
if err != nil {
t.Fatalf("read validated generated text: %v", err)
}
if string(normalized) != `{"summary":"Storm chances increase through late morning.","timing":"The main window is 10 AM to noon.","impacts":"Brief downpours may slow travel.","confidence":"Medium"}` {
t.Fatalf("validated generated text = %s, want normalized JSON", normalized)
}
renderContext, err := os.ReadFile(result.RenderContextPath)
if err != nil {
t.Fatalf("read render context: %v", err)
}
if !strings.Contains(string(renderContext), `"ReportTitle": "Hourly Report"`) || !strings.Contains(string(renderContext), `"LocationName": "Brentwood, MO"`) {
t.Fatalf("render context missing deterministic fields:\n%s", string(renderContext))
}
reportData, err := os.ReadFile(result.ReportPath)
if err != nil {
t.Fatalf("read report: %v", err)
}
reportText := string(reportData)
for _, want := range []string{
"# Hourly Report",
"Storm chances increase through late morning.",
"The main window is 10 AM to noon.",
"Flood Watch: Flooding possible (Moderate)",
"Short-term AFD narrative for hourly report.",
"Hourly Storm Chances - Scattered showers and thunderstorms are possible.",
} {
if !strings.Contains(reportText, want) {
t.Fatalf("rendered hourly report missing %q:\n%s", want, reportText)
}
}
if result.OutputPath != result.ReportPath {
t.Fatalf("OutputPath = %q, want managed report path %q", result.OutputPath, result.ReportPath)
}
if result.StructuredRunResult == nil || result.StructuredRunResult.OutputPath != result.GeneratedTextRawPath {
t.Fatalf("StructuredRunResult = %#v, want captured structured run result", result.StructuredRunResult)
}
if result.RunResult != nil {
t.Fatalf("RunResult = %#v, want nil for generated-text template workflow", result.RunResult)
}
if len(result.RecentChanges) != 0 {
t.Fatalf("RecentChanges = %#v, want none for hourly report", result.RecentChanges)
}
if result.Metadata.GeneratedTextSchemaID != "hourly" ||
result.Metadata.GeneratedTextRawPath != result.GeneratedTextRawPath ||
result.Metadata.GeneratedTextResultPath != result.GeneratedTextResultPath ||
result.Metadata.GeneratedTextPath != result.GeneratedTextPath ||
result.Metadata.RenderContextPath != result.RenderContextPath ||
result.Metadata.RenderedReportPath != result.ReportPath {
t.Fatalf("metadata generated-text links = %#v, want saved artifact links", result.Metadata)
}
metadataData, err := os.ReadFile(result.MetadataPath)
if err != nil {
t.Fatalf("read metadata: %v", err)
}
if !strings.Contains(string(metadataData), `"generatedTextSchemaId": "hourly"`) ||
!strings.Contains(string(metadataData), result.GeneratedTextRawPath) ||
!strings.Contains(string(metadataData), result.RenderContextPath) {
t.Fatalf("metadata JSON missing generated-text links:\n%s", string(metadataData))
}
}
func TestGenerateHourlyReportCopiesOutputAndNotifiesManagedReport(t *testing.T) {
cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
notifier.result = &NotificationResult{
RunID: "distributor-run",
Status: "succeeded",
UploadStatus: "accepted",
PipelineID: "reports",
Report: []byte(`{"actions":[{"action":"replace_older"}]}`),
}
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
structuredRunBody: validHourlyGeneratedTextJSON(),
}
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: resolved, Resolved: resolved,
OutputPath: outputPath, OutputPath: outputPath,
Renderer: renderer, Renderer: renderer,
Store: store,
Notifier: notifier,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
} }
if result.Metadata.ReportID != report.NearTerm || result.Metadata.PromptID != "weather.near_term_report" {
t.Fatalf("metadata report/prompt = %q/%q, want near-term", result.Metadata.ReportID, result.Metadata.PromptID)
}
if got := result.Metadata.ValidPeriod.Start.Format(time.RFC3339); got != "2026-05-29T08:30:00-05:00" {
t.Fatalf("valid period start = %s, want rolling window start", got)
}
if got := result.Metadata.ValidPeriod.End.Format(time.RFC3339); got != "2026-05-29T14:30:00-05:00" {
t.Fatalf("valid period end = %s, want six-hour rolling window end", got)
}
if renderer.renderRequest.PromptID != "weather.near_term_report" || renderer.runRequest.PromptID != "weather.near_term_report" {
t.Fatalf("renderer prompt IDs = %q/%q, want near-term prompt", renderer.renderRequest.PromptID, renderer.runRequest.PromptID)
}
if !strings.Contains(result.ReportPath, filepath.Join("reports", "near-term")) {
t.Fatalf("ReportPath = %q, want near-term artifact group", result.ReportPath)
}
if !strings.Contains(result.DataPackagePath, filepath.Join("data-packages", "near-term", "2026-05-29")) {
t.Fatalf("DataPackagePath = %q, want near-term artifact group", result.DataPackagePath)
}
assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath, outputPath)
if result.OutputPath != outputPath { if result.OutputPath != outputPath {
t.Fatalf("OutputPath = %q, want requested output copy %q", result.OutputPath, outputPath) t.Fatalf("OutputPath = %q, want requested copy %q", result.OutputPath, outputPath)
} }
copiedReport, err := os.ReadFile(outputPath) assertPathsExist(t, result.ReportPath, outputPath, result.NotificationPath)
reportData, err := os.ReadFile(result.ReportPath)
if err != nil {
t.Fatalf("read managed report: %v", err)
}
copyData, err := os.ReadFile(outputPath)
if err != nil { if err != nil {
t.Fatalf("read output copy: %v", err) t.Fatalf("read output copy: %v", err)
} }
if !strings.Contains(string(copiedReport), "# Near-Term Report") { if string(copyData) != string(reportData) {
t.Fatalf("output copy missing rendered report:\n%s", string(copiedReport)) t.Fatalf("output copy differs from managed report")
}
if len(notifier.requests) != 1 {
t.Fatalf("notification requests = %d, want 1", len(notifier.requests))
}
req := notifier.requests[0]
if req.ReportPath != result.ReportPath {
t.Fatalf("notification ReportPath = %q, want managed report path %q", req.ReportPath, result.ReportPath)
}
if req.ReportPath == outputPath {
t.Fatalf("notification used output copy %q, want managed report path", outputPath)
}
wantBundlePaths := []string{"2026-05-29/hourly/hourly.md"}
if strings.Join(req.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") {
t.Fatalf("notification BundlePaths = %#v, want %#v", req.BundlePaths, wantBundlePaths)
}
if req.PipelineID != "weatherreporter.hourly" {
t.Fatalf("notification PipelineID = %q, want weatherreporter.hourly", req.PipelineID)
}
if req.BundleID != "weatherreporter.home.hourly" {
t.Fatalf("notification BundleID = %q, want weatherreporter.home.hourly", req.BundleID)
}
if req.IdempotencyKey != req.BundleID+"."+result.Metadata.RunID {
t.Fatalf("notification IdempotencyKey = %q, want per-run key", req.IdempotencyKey)
}
if result.Notification == nil || result.Notification.RunID != "distributor-run" {
t.Fatalf("Notification = %#v, want distributor result", result.Notification)
}
if result.Metadata.NotificationPath != result.NotificationPath {
t.Fatalf("metadata NotificationPath = %q, want %q", result.Metadata.NotificationPath, result.NotificationPath)
}
notificationData, err := os.ReadFile(result.NotificationPath)
if err != nil {
t.Fatalf("read notification artifact: %v", err)
}
var notificationArtifact state.DistributorNotificationArtifact
if err := json.Unmarshal(notificationData, &notificationArtifact); err != nil {
t.Fatalf("decode notification artifact: %v", err)
}
if notificationArtifact.SourcePath != result.ReportPath || strings.Join(notificationArtifact.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") || notificationArtifact.RunStatus == nil {
t.Fatalf("notification artifact = %#v, want managed source and run status", notificationArtifact)
}
} }
wantModules := []module.ID{ func TestGenerateHourlyReportNotificationFailureFailsReport(t *testing.T) {
module.Metadata, cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
module.CurrentConditions, notifier.err = errors.New("upload rejected")
module.HourlyForecast, renderer := &recordingRenderer{
module.PrecipTiming, renderResult: &scriptorium.RenderResult{ExitCode: 0},
module.AlertDigest, structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
module.SPCConvectiveOutlooks, structuredRunBody: validHourlyGeneratedTextJSON(),
module.AreaForecastDiscussion,
module.SPCConvectiveDiscussion,
module.WeatherStory,
}
if got := snapshotModuleIDs(result.ModuleSnapshot); strings.Join(moduleIDsForTest(got), ",") != strings.Join(moduleIDsForTest(wantModules), ",") {
t.Fatalf("module snapshot IDs = %#v, want %#v", got, wantModules)
} }
hourly, ok, err := module.StanzaValue[briefing.HourlyForecastModule](result.ModuleSnapshot, "hourly_forecast") _, err := GenerateReport(context.Background(), ReportRequest{
if err != nil { Config: cfg,
t.Fatalf("decode hourly forecast: %v", err) Resolved: resolved,
OutputPath: outputPath,
Renderer: renderer,
Store: store,
Notifier: notifier,
})
if err == nil {
t.Fatal("GenerateReport() error = nil, want notification error")
} }
if !ok || len(hourly.Periods) != 5 { var notificationErr *NotificationError
t.Fatalf("hourly forecast = %#v, want five overlapping near-term periods", hourly) if !errors.As(err, &notificationErr) {
t.Fatalf("GenerateReport() error = %T %v, want NotificationError", err, err)
} }
hourlyJSON := mustMarshalString(t, hourly) if !strings.Contains(err.Error(), `notify report "hourly"`) || !strings.Contains(err.Error(), "upload rejected") {
if !strings.Contains(hourlyJSON, "Showers entering the area") || !strings.Contains(hourlyJSON, "Heavy rain") { t.Fatalf("error = %q, want hourly notification context", err.Error())
t.Fatalf("hourly forecast missing selected near-term periods:\n%s", hourlyJSON) }
if len(notifier.requests) != 1 {
t.Fatalf("notification requests = %d, want 1", len(notifier.requests))
}
paths := hourlyArtifactPaths(t, store, resolved)
assertPathsExist(t, paths.RenderedReport, outputPath, paths.Metadata, paths.Notification)
if notifier.requests[0].ReportPath != paths.RenderedReport {
t.Fatalf("notification ReportPath = %q, want managed report path %q", notifier.requests[0].ReportPath, paths.RenderedReport)
}
notificationData, readErr := os.ReadFile(paths.Notification)
if readErr != nil {
t.Fatalf("read notification artifact after failure: %v", readErr)
}
var notification state.DistributorNotificationArtifact
if err := json.Unmarshal(notificationData, &notification); err != nil {
t.Fatalf("decode notification artifact: %v", err)
}
if notification.Status != "failed" || !strings.Contains(notification.Error, "upload rejected") || notification.SourcePath != paths.RenderedReport {
t.Fatalf("notification failure artifact = %+v, want failed managed-source context", notification)
} }
if strings.Contains(hourlyJSON, "Before-window storms") || strings.Contains(hourlyJSON, "After-window rain") {
t.Fatalf("hourly forecast contains periods outside valid window:\n%s", hourlyJSON)
} }
precip, ok, err := module.StanzaValue[briefing.PrecipTimingModule](result.ModuleSnapshot, "precip_timing") func TestGenerateHourlyReportPersistsPreflightFailure(t *testing.T) {
if err != nil { cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
t.Fatalf("decode precip timing: %v", err) renderer := &recordingRenderer{
} renderResult: &scriptorium.RenderResult{
if !ok || precip.MaxPopPercent == nil || *precip.MaxPopPercent != 80 || len(precip.PrecipitationWindows) != 2 { Command: []string{"scriptorium", "render"},
t.Fatalf("precip timing = %#v, want near-term precipitation windows", precip) Stderr: "render failed",
} ExitCode: 1,
if precip.PrecipitationWindows[0].PeriodBegins != "2026-05-29 at 8:00 AM" || precip.PrecipitationWindows[0].PeriodEnds != "2026-05-29 at 9:00 AM" { },
t.Fatalf("first precip window = %#v, want first selected hour only", precip.PrecipitationWindows[0]) err: errors.New("scriptorium render exited with code 1: render failed"),
}
if precip.PrecipitationWindows[1].PeriodBegins != "2026-05-29 at 10:00 AM" || precip.PrecipitationWindows[1].PeriodEnds != "2026-05-29 at 12:00 PM" {
t.Fatalf("second precip window = %#v, want late-morning near-term rain", precip.PrecipitationWindows[1])
} }
alerts, ok, err := module.StanzaValue[briefing.AlertDigestModule](result.ModuleSnapshot, "alert_digest") _, err := GenerateReport(context.Background(), ReportRequest{
if err != nil { Config: cfg,
t.Fatalf("decode alert digest: %v", err) Resolved: resolved,
OutputPath: outputPath,
Renderer: renderer,
Store: store,
Notifier: notifier,
})
assertGeneratedReportError(t, err, resolved, "render preflight")
if renderer.structuredRunCalls != 0 || renderer.runCalls != 0 {
t.Fatalf("post-preflight calls structured=%d run=%d, want none", renderer.structuredRunCalls, renderer.runCalls)
}
assertNoGeneratedFailureSideEffects(t, notifier, outputPath)
paths := hourlyArtifactPaths(t, store, resolved)
assertPathsExist(t, paths.Preflight, paths.Metadata)
assertPathsMissing(t, paths.GeneratedTextRaw, paths.GeneratedTextResult, paths.GeneratedText, paths.RenderContext, paths.RenderedReport)
preflight, readErr := os.ReadFile(paths.Preflight)
if readErr != nil {
t.Fatalf("read failed preflight: %v", readErr)
}
if !strings.Contains(string(preflight), `"exitCode": 1`) || !strings.Contains(string(preflight), "render failed") {
t.Fatalf("failed preflight was not persisted:\n%s", string(preflight))
}
metadataData, readErr := os.ReadFile(paths.Metadata)
if readErr != nil {
t.Fatalf("read metadata: %v", readErr)
}
if !strings.Contains(string(metadataData), paths.Preflight) || !strings.Contains(string(metadataData), paths.GeneratedTextRaw) {
t.Fatalf("metadata missing failed-run artifact links:\n%s", string(metadataData))
} }
if !ok || !alerts.Checked || alerts.ActiveCount != 3 || alerts.RelevantCount != 1 || len(alerts.Relevant) != 1 || alerts.Relevant[0].Event != "Flood Watch" {
t.Fatalf("alert digest = %#v, want only near-term alert overlap relevant", alerts)
} }
outlooks, ok, err := module.StanzaValue[briefing.SPCConvectiveOutlooksModule](result.ModuleSnapshot, "spc_convective_outlooks") func TestGenerateHourlyReportPersistsStructuredRunFailure(t *testing.T) {
if err != nil { cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
t.Fatalf("decode SPC outlooks: %v", err) renderer := &recordingRenderer{
} renderResult: &scriptorium.RenderResult{ExitCode: 0},
if !ok || !outlooks.Checked || outlooks.OutlookCount != 1 || len(outlooks.Outlooks) != 1 || outlooks.Outlooks[0].Label != "SLGT" { structuredRunResult: &scriptorium.StructuredRunResult{
t.Fatalf("SPC outlooks = %#v, want one overlapping near-term outlook", outlooks) Command: []string{"scriptorium", "run", "--json"},
} Stderr: "generation failed",
discussion, ok, err := module.StanzaValue[briefing.SPCConvectiveDiscussionModule](result.ModuleSnapshot, "spc_convective_discussion") ExitCode: 2,
if err != nil { },
t.Fatalf("decode SPC discussion: %v", err) structuredRunErr: errors.New("scriptorium structured run exited with code 2: generation failed"),
} structuredRunBody: validHourlyGeneratedTextJSON(),
if !ok || len(discussion.Discussions) != 1 || discussion.Discussions[0].Headline != "Near-term severe storms" {
t.Fatalf("SPC discussion = %#v, want discussion for retained overlapping outlook", discussion)
} }
afd, ok, err := module.StanzaValue[briefing.AreaForecastDiscussionModule](result.ModuleSnapshot, "area_forecast_discussion") _, err := GenerateReport(context.Background(), ReportRequest{
if err != nil { Config: cfg,
t.Fatalf("decode AFD: %v", err) Resolved: resolved,
OutputPath: outputPath,
Renderer: renderer,
Store: store,
Notifier: notifier,
})
assertGeneratedReportError(t, err, resolved, "structured generated text")
if renderer.structuredRunCalls != 1 || renderer.runCalls != 0 {
t.Fatalf("calls structured=%d run=%d, want one structured run and no markdown run", renderer.structuredRunCalls, renderer.runCalls)
} }
if !ok || len(afd.KeyMessages) != 1 || afd.ShortTerm != "Short-term AFD narrative for near-term report." { assertNoGeneratedFailureSideEffects(t, notifier, outputPath)
t.Fatalf("AFD = %#v, want key messages and short term", afd)
paths := hourlyArtifactPaths(t, store, resolved)
assertPathsExist(t, paths.Preflight, paths.Metadata, paths.GeneratedTextRaw, paths.GeneratedTextResult)
assertPathsMissing(t, paths.GeneratedText, paths.RenderContext, paths.RenderedReport)
metadataData, readErr := os.ReadFile(paths.Metadata)
if readErr != nil {
t.Fatalf("read metadata: %v", readErr)
}
if !strings.Contains(string(metadataData), paths.GeneratedTextRaw) || !strings.Contains(string(metadataData), paths.GeneratedTextResult) {
t.Fatalf("metadata missing structured failure links:\n%s", string(metadataData))
} }
if afd.Product != "" || afd.LongTerm != "" {
t.Fatalf("AFD = %#v, want near-term defaults to omit product and long term", afd)
} }
if result.PriorSnapshot != nil || len(result.RecentChanges) != 0 || len(result.DataPackage.RecentChanges.Items) != 0 { func TestGenerateHourlyReportPreservesRawTextOnValidationFailure(t *testing.T) {
t.Fatalf("prior=%#v recent=%#v package=%#v, want no rolling-window comparison output", result.PriorSnapshot, result.RecentChanges, result.DataPackage.RecentChanges.Items) cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
structuredRunBody: `{
"summary": "Storm chances increase through late morning.",
"timing": "The main window is 10 AM to noon.",
"impacts": "Brief downpours may slow travel.",
"details": "not allowed"
}`,
} }
data := readDataPackageForTest(t, result)
text := string(data) _, err := GenerateReport(context.Background(), ReportRequest{
for _, want := range []string{ Config: cfg,
"id: near_term", Resolved: resolved,
"prompt_id: weather.near_term_report", OutputPath: outputPath,
"valid_period:", Renderer: renderer,
"recent_changes:", Store: store,
" items: []", Notifier: notifier,
" alert_digest:", })
" spc_convective_outlooks:", assertGeneratedReportError(t, err, resolved, "validate generated text")
" precip_timing:", assertNoGeneratedFailureSideEffects(t, notifier, outputPath)
" area_forecast_discussion:",
" spc_convective_discussion:", paths := hourlyArtifactPaths(t, store, resolved)
" weather_story:", assertPathsExist(t, paths.Preflight, paths.Metadata, paths.GeneratedTextRaw, paths.GeneratedTextResult)
" current_conditions:", assertPathsMissing(t, paths.GeneratedText, paths.RenderContext, paths.RenderedReport)
" hourly_forecast:", raw, readErr := os.ReadFile(paths.GeneratedTextRaw)
} { if readErr != nil {
if !strings.Contains(text, want) { t.Fatalf("read raw generated text: %v", readErr)
t.Fatalf("data package missing %q:\n%s", want, text) }
if !strings.Contains(string(raw), `"details": "not allowed"`) {
t.Fatalf("raw generated text was not preserved:\n%s", string(raw))
} }
} }
for _, omitted := range []string{"validPeriod:", "Before-window storms", "After-window rain", "Evening Advisory", "Day 2 outlook", "Long-term AFD narrative for near-term report."} {
if strings.Contains(text, omitted) { func TestGenerateHourlyReportPreservesValidatedTextOnRenderContextFailure(t *testing.T) {
t.Fatalf("data package contains %q, want near-term filtered/canonical output:\n%s", omitted, text) server := hourlyBundleServer(t)
cfg := hourlyGeneratedTextConfigWithModules(t, server, []string{
"metadata",
"hourly_forecast",
"precip_timing",
"alert_digest",
"area_forecast_discussion",
"weather_story",
"spc_convective_outlooks",
})
resolved, store, notifier, outputPath := resolveHourlyGeneratedTextFixture(t, cfg)
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
structuredRunBody: validHourlyGeneratedTextJSON(),
}
_, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: resolved,
OutputPath: outputPath,
Renderer: renderer,
Store: store,
Notifier: notifier,
})
assertGeneratedReportError(t, err, resolved, "build render context")
assertNoGeneratedFailureSideEffects(t, notifier, outputPath)
paths := hourlyArtifactPaths(t, store, resolved)
assertPathsExist(t, paths.Preflight, paths.Metadata, paths.GeneratedTextRaw, paths.GeneratedTextResult, paths.GeneratedText)
assertPathsMissing(t, paths.RenderContext, paths.RenderedReport)
generatedText, readErr := os.ReadFile(paths.GeneratedText)
if readErr != nil {
t.Fatalf("read validated generated text: %v", readErr)
}
if string(generatedText) != validHourlyGeneratedTextJSON() {
t.Fatalf("validated generated text = %s, want normalized JSON", generatedText)
}
metadataData, readErr := os.ReadFile(paths.Metadata)
if readErr != nil {
t.Fatalf("read metadata: %v", readErr)
}
if !strings.Contains(string(metadataData), paths.GeneratedText) {
t.Fatalf("metadata missing validated generated text link:\n%s", string(metadataData))
} }
} }
riskIndex := strings.Index(text, " applicable_risk_products:")
derivedIndex := strings.Index(text, " derived_summaries:") func TestGenerateHourlyReportRejectsUnsupportedTemplateBeforeRenderContext(t *testing.T) {
narrativeIndex := strings.Index(text, " narrative_products:") cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
rawIndex := strings.Index(text, " raw_data:") resolved.Definition.TemplateID = "missing-template"
alertIndex := strings.Index(text, " alert_digest:") renderer := &recordingRenderer{
precipIndex := strings.Index(text, " precip_timing:") renderResult: &scriptorium.RenderResult{ExitCode: 0},
afdIndex := strings.Index(text, " area_forecast_discussion:") structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
currentIndex := strings.Index(text, " current_conditions:") structuredRunBody: validHourlyGeneratedTextJSON(),
hourlyIndex := strings.Index(text, " hourly_forecast:")
if riskIndex < 0 || derivedIndex < 0 || narrativeIndex < 0 || rawIndex < 0 || alertIndex < 0 || precipIndex < 0 || afdIndex < 0 || currentIndex < 0 || hourlyIndex < 0 ||
!(riskIndex < alertIndex && alertIndex < derivedIndex && derivedIndex < precipIndex && precipIndex < narrativeIndex && narrativeIndex < afdIndex && afdIndex < rawIndex && rawIndex < currentIndex && currentIndex < hourlyIndex) {
t.Fatalf("data package category order is wrong:\n%s", text)
} }
loaded, err := promptinput.LoadYAML(data)
if err != nil { _, err := GenerateReport(context.Background(), ReportRequest{
t.Fatalf("LoadYAML() error = %v", err) Config: cfg,
Resolved: resolved,
OutputPath: outputPath,
Renderer: renderer,
Store: store,
Notifier: notifier,
})
assertGeneratedReportError(t, err, resolved, "build render context")
assertNoGeneratedFailureSideEffects(t, notifier, outputPath)
paths := hourlyArtifactPaths(t, store, resolved)
assertPathsExist(t, paths.Preflight, paths.Metadata, paths.GeneratedTextRaw, paths.GeneratedTextResult, paths.GeneratedText)
assertPathsMissing(t, paths.RenderContext, paths.RenderedReport)
metadataData, readErr := os.ReadFile(paths.Metadata)
if readErr != nil {
t.Fatalf("read metadata: %v", readErr)
}
if !strings.Contains(string(metadataData), paths.GeneratedText) {
t.Fatalf("metadata missing generated text link:\n%s", string(metadataData))
} }
assertNoStaleModuleIntervalKeys(t, loaded.Briefing.Values)
} }
func TestGenerateReportDisabledNotificationDoesNotCallNotifier(t *testing.T) { func TestGenerateReportDisabledNotificationDoesNotCallNotifier(t *testing.T) {
@@ -1364,10 +1665,10 @@ func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
requestDate: time.Time{}, requestDate: time.Time{},
}, },
{ {
name: "near-term", name: "hourly",
kind: ReportNearTerm, kind: ReportHourly,
wantID: report.NearTerm, wantID: report.Hourly,
wantPrompt: "weather.near_term_report", wantPrompt: "weather.hourly_generated_text",
wantStart: "2026-05-29T08:00:00-05:00", wantStart: "2026-05-29T08:00:00-05:00",
wantEnd: "2026-05-29T14:00:00-05:00", wantEnd: "2026-05-29T14:00:00-05:00",
}, },
@@ -1488,7 +1789,7 @@ func dailyBundleServerWithConvectiveResponse(t *testing.T, convectiveResponse st
return server return server
} }
func nearTermBundleServer(t *testing.T) *httptest.Server { func hourlyBundleServer(t *testing.T) *httptest.Server {
t.Helper() t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { switch r.URL.Path {
@@ -1503,11 +1804,11 @@ func nearTermBundleServer(t *testing.T) *httptest.Server {
case "/alerts/active": case "/alerts/active":
_, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Expired Advisory","headline":"Ends at valid start","severity":"Minor","effective":"2026-05-29T06:00:00-05:00","expires":"2026-05-29T08:30:00-05:00"},{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T11:00:00-05:00","expires":"2026-05-29T15:00:00-05:00"},{"event":"Evening Advisory","headline":"Starts at valid end","severity":"Minor","effective":"2026-05-29T14:30:00-05:00","expires":"2026-05-29T18:00:00-05:00"}]}}`)) _, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Expired Advisory","headline":"Ends at valid start","severity":"Minor","effective":"2026-05-29T06:00:00-05:00","expires":"2026-05-29T08:30:00-05:00"},{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T11:00:00-05:00","expires":"2026-05-29T15:00:00-05:00"},{"event":"Evening Advisory","headline":"Starts at valid end","severity":"Minor","effective":"2026-05-29T14:30:00-05:00","expires":"2026-05-29T18:00:00-05:00"}]}}`))
case "/discussion": case "/discussion":
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T08:05:00-05:00","keyMessages":["Storms are most likely late this morning."],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for near-term report."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for near-term report."}}}`)) _, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T08:05:00-05:00","keyMessages":["Storms are most likely late this morning."],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for hourly report."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for hourly report."}}}`))
case "/weatherstories/latest": case "/weatherstories/latest":
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-29T13:00:00Z","endTime":"2026-05-29T20:00:00Z","updatedAt":"2026-05-29T13:05:00Z","title":"Near-Term Storm Chances","description":"Scattered showers and thunderstorms are possible.","altText":"Weather story graphic with rain chances.","priority":true,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/near-term"}}`)) _, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-29T13:00:00Z","endTime":"2026-05-29T20:00:00Z","updatedAt":"2026-05-29T13:05:00Z","title":"Hourly Storm Chances","description":"Scattered showers and thunderstorms are possible.","altText":"Weather story graphic with rain chances.","priority":true,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/hourly"}}`))
case "/outlooks/convective": case "/outlooks/convective":
_, _ = w.Write([]byte(`{"data":{"locationId":"home","locationName":"Brentwood","asOf":"2026-05-29T13:30:00Z","issuedAt":"2026-05-29T13:00:00Z","outlooks":[{"id":"day1-near-term","day":1,"outlookType":"categorical","label":"SLGT","labelText":"Slight Risk","severityRank":3,"validFrom":"2026-05-29T10:00:00-05:00","validTo":"2026-05-29T16:00:00-05:00","issuedAt":"2026-05-29T08:00:00-05:00","containsLocation":true},{"id":"day2-outside","day":2,"outlookType":"categorical","label":"ENH","labelText":"Day 2 outlook","severityRank":4,"validFrom":"2026-05-30T10:00:00-05:00","validTo":"2026-05-30T16:00:00-05:00","issuedAt":"2026-05-29T08:00:00-05:00","containsLocation":true}],"discussions":[{"day":1,"headline":"Near-term severe storms","summary":"Scattered severe storms are possible.","discussion":"Damaging winds may occur during the near-term window.","updatedAt":"2026-05-29T08:15:00-05:00"},{"day":2,"headline":"Day 2 discussion","summary":"Later period risk.","discussion":"This day 2 discussion should not be retained.","updatedAt":"2026-05-29T08:20:00-05:00"}]}}`)) _, _ = w.Write([]byte(`{"data":{"locationId":"home","locationName":"Brentwood","asOf":"2026-05-29T13:30:00Z","issuedAt":"2026-05-29T13:00:00Z","outlooks":[{"id":"day1-hourly","day":1,"outlookType":"categorical","label":"SLGT","labelText":"Slight Risk","severityRank":3,"validFrom":"2026-05-29T10:00:00-05:00","validTo":"2026-05-29T16:00:00-05:00","issuedAt":"2026-05-29T08:00:00-05:00","containsLocation":true},{"id":"day2-outside","day":2,"outlookType":"categorical","label":"ENH","labelText":"Day 2 outlook","severityRank":4,"validFrom":"2026-05-30T10:00:00-05:00","validTo":"2026-05-30T16:00:00-05:00","issuedAt":"2026-05-29T08:00:00-05:00","containsLocation":true}],"discussions":[{"day":1,"headline":"hourly severe storms","summary":"Scattered severe storms are possible.","discussion":"Damaging winds may occur during the hourly window.","updatedAt":"2026-05-29T08:15:00-05:00"},{"day":2,"headline":"Day 2 discussion","summary":"Later period risk.","discussion":"This day 2 discussion should not be retained.","updatedAt":"2026-05-29T08:20:00-05:00"}]}}`))
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@@ -1699,6 +2000,18 @@ func TestRunBatchUsesOutputDirectory(t *testing.T) {
} }
} }
func TestBatchOutputPathUsesHourlyOutputName(t *testing.T) {
definition, err := report.DefaultRegistry().Lookup(report.Hourly)
if err != nil {
t.Fatalf("Lookup(hourly) error = %v", err)
}
outputDir := filepath.Join(t.TempDir(), "reports")
want := filepath.Join(outputDir, "hourly.md")
if got := batchOutputPath(outputDir, definition); got != want {
t.Fatalf("batchOutputPath() = %q, want %q", got, want)
}
}
func mustParse(value string) time.Time { func mustParse(value string) time.Time {
parsed, err := time.Parse(time.RFC3339, value) parsed, err := time.Parse(time.RFC3339, value)
if err != nil { if err != nil {
@@ -1715,6 +2028,75 @@ func dailyTestConfig(t *testing.T, server *httptest.Server) config.Config {
return cfg return cfg
} }
func hourlyGeneratedTextFixture(t *testing.T) (config.Config, report.Resolved, *recordingStore, *recordingNotifier, string) {
t.Helper()
server := hourlyBundleServer(t)
cfg := hourlyGeneratedTextConfig(t, server)
resolved, store, notifier, outputPath := resolveHourlyGeneratedTextFixture(t, cfg)
return cfg, resolved, store, notifier, outputPath
}
func hourlyGeneratedTextConfig(t *testing.T, server *httptest.Server) config.Config {
t.Helper()
cfg := config.Defaults()
applyHourlyGeneratedTextSettings(&cfg, t, server)
return cfg
}
func hourlyGeneratedTextConfigWithModules(t *testing.T, server *httptest.Server, modules []string) config.Config {
t.Helper()
var data strings.Builder
data.WriteString("reports:\n hourly:\n deterministic_modules:\n")
for _, id := range modules {
_, _ = fmt.Fprintf(&data, " - %s\n", id)
}
path := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(path, []byte(data.String()), 0o600); err != nil {
t.Fatalf("write config fixture: %v", err)
}
cfg, err := config.LoadFile(path)
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
applyHourlyGeneratedTextSettings(&cfg, t, server)
return cfg
}
func applyHourlyGeneratedTextSettings(cfg *config.Config, t *testing.T, server *httptest.Server) {
t.Helper()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
cfg.Location.ID = "home"
cfg.Location.Name = "Brentwood"
cfg.Location.Region = "MO"
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
cfg.Notify.Distributor.BundleIDTemplate = "weatherreporter.{location_id}.{report_id}"
cfg.Notify.Distributor.IdempotencyKeyTemplate = "weatherreporter.{location_id}.{report_id}.{run_id}"
cfg.Notify.Distributor.ReportPathTemplates = []string{"{valid_start_date}/{artifact_group}/{batch_output_name}"}
}
func resolveHourlyGeneratedTextFixture(t *testing.T, cfg config.Config) (report.Resolved, *recordingStore, *recordingNotifier, string) {
t.Helper()
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportHourly,
}, mustParse("2026-05-29T08:30:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
filesystemStore, err := state.NewFilesystemStore(cfg.Workspace)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
return resolved, &recordingStore{Store: filesystemStore}, &recordingNotifier{}, filepath.Join(t.TempDir(), "hourly-copy.md")
}
func validHourlyGeneratedTextJSON() string {
return `{"summary":"Storm chances increase through late morning.","timing":"The main window is 10 AM to noon.","impacts":"Brief downpours may slow travel.","confidence":"Medium"}`
}
func generateDailyReportForTest(t *testing.T, cfg config.Config) *ReportResult { func generateDailyReportForTest(t *testing.T, cfg config.Config) *ReportResult {
t.Helper() t.Helper()
cfg.Workspace.Root = t.TempDir() cfg.Workspace.Root = t.TempDir()
@@ -1783,6 +2165,51 @@ func assertPathsExist(t *testing.T, paths ...string) {
} }
} }
func assertPathsMissing(t *testing.T, paths ...string) {
t.Helper()
for _, path := range paths {
if _, err := os.Stat(path); err == nil {
t.Fatalf("artifact %q exists, want missing", path)
} else if !os.IsNotExist(err) {
t.Fatalf("stat artifact %q: %v", path, err)
}
}
}
func hourlyArtifactPaths(t *testing.T, store state.Store, resolved report.Resolved) state.ArtifactPaths {
t.Helper()
paths, err := store.Paths(resolved)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
return paths
}
func assertGeneratedReportError(t *testing.T, err error, resolved report.Resolved, operation string) {
t.Helper()
if err == nil {
t.Fatal("GenerateReport() error = nil, want generated-text report error")
}
text := err.Error()
for _, want := range []string{
fmt.Sprintf("generate report %q", resolved.Definition.ID),
fmt.Sprintf("run %q", resolved.Metadata().RunID),
operation,
} {
if !strings.Contains(text, want) {
t.Fatalf("error = %q, want %q", text, want)
}
}
}
func assertNoGeneratedFailureSideEffects(t *testing.T, notifier *recordingNotifier, outputPath string) {
t.Helper()
if len(notifier.requests) != 0 {
t.Fatalf("notification requests = %#v, want none after generated-text failure", notifier.requests)
}
assertPathsMissing(t, outputPath)
}
func savePriorRun(t *testing.T, store state.Store, resolved report.Resolved, snapshot module.Snapshot) { func savePriorRun(t *testing.T, store state.Store, resolved report.Resolved, snapshot module.Snapshot) {
t.Helper() t.Helper()
moduleSnapshotPath, err := store.SaveModuleSnapshot(context.Background(), resolved, snapshot) moduleSnapshotPath, err := store.SaveModuleSnapshot(context.Background(), resolved, snapshot)
@@ -1874,13 +2301,18 @@ func appBriefingMetadata(resolved report.Resolved) briefing.Metadata {
type recordingRenderer struct { type recordingRenderer struct {
renderCalls int renderCalls int
runCalls int runCalls int
structuredRunCalls int
renderRequest scriptorium.RenderRequest renderRequest scriptorium.RenderRequest
runRequest scriptorium.RunRequest runRequest scriptorium.RunRequest
structuredRunRequest scriptorium.StructuredRunRequest
renderResult *scriptorium.RenderResult renderResult *scriptorium.RenderResult
runResult *scriptorium.RunResult runResult *scriptorium.RunResult
structuredRunResult *scriptorium.StructuredRunResult
err error err error
runErr error runErr error
structuredRunErr error
runBody string runBody string
structuredRunBody string
} }
type recordingStore struct { type recordingStore struct {
@@ -1903,6 +2335,26 @@ func (s *recordingStore) SavePreflight(ctx context.Context, resolved report.Reso
return s.Store.SavePreflight(ctx, resolved, artifact) return s.Store.SavePreflight(ctx, resolved, artifact)
} }
func (s *recordingStore) SaveGeneratedTextRaw(ctx context.Context, resolved report.Resolved, data []byte) (string, error) {
s.calls = append(s.calls, "generated_text_raw")
return s.Store.SaveGeneratedTextRaw(ctx, resolved, data)
}
func (s *recordingStore) SaveGeneratedTextResult(ctx context.Context, resolved report.Resolved, value any) (string, error) {
s.calls = append(s.calls, "generated_text_result")
return s.Store.SaveGeneratedTextResult(ctx, resolved, value)
}
func (s *recordingStore) SaveGeneratedText(ctx context.Context, resolved report.Resolved, data []byte) (string, error) {
s.calls = append(s.calls, "generated_text")
return s.Store.SaveGeneratedText(ctx, resolved, data)
}
func (s *recordingStore) SaveRenderContext(ctx context.Context, resolved report.Resolved, value any) (string, error) {
s.calls = append(s.calls, "render_context")
return s.Store.SaveRenderContext(ctx, resolved, value)
}
func (s *recordingStore) PrepareRenderedReport(ctx context.Context, resolved report.Resolved) (string, error) { func (s *recordingStore) PrepareRenderedReport(ctx context.Context, resolved report.Resolved) (string, error) {
s.calls = append(s.calls, "prepare_report") s.calls = append(s.calls, "prepare_report")
return s.Store.PrepareRenderedReport(ctx, resolved) return s.Store.PrepareRenderedReport(ctx, resolved)
@@ -1924,6 +2376,7 @@ func successfulRenderer(body string) *recordingRenderer {
type selectiveRenderer struct { type selectiveRenderer struct {
renderCalls int renderCalls int
runCalls int runCalls int
structuredRunCalls int
failRenderPrompt string failRenderPrompt string
runBody string runBody string
} }
@@ -1983,6 +2436,11 @@ func (r *selectiveRenderer) Run(_ context.Context, req scriptorium.RunRequest) (
return &scriptorium.RunResult{ExitCode: 0, OutputPath: req.OutputPath}, nil return &scriptorium.RunResult{ExitCode: 0, OutputPath: req.OutputPath}, nil
} }
func (r *selectiveRenderer) StructuredRun(_ context.Context, req scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error) {
r.structuredRunCalls++
return &scriptorium.StructuredRunResult{ExitCode: 0, OutputPath: req.OutputPath}, nil
}
func (r *recordingRenderer) Render(_ context.Context, req scriptorium.RenderRequest) (*scriptorium.RenderResult, error) { func (r *recordingRenderer) Render(_ context.Context, req scriptorium.RenderRequest) (*scriptorium.RenderResult, error) {
r.renderCalls++ r.renderCalls++
r.renderRequest = req r.renderRequest = req
@@ -2002,3 +2460,17 @@ func (r *recordingRenderer) Run(_ context.Context, req scriptorium.RunRequest) (
} }
return r.runResult, r.runErr return r.runResult, r.runErr
} }
func (r *recordingRenderer) StructuredRun(_ context.Context, req scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error) {
r.structuredRunCalls++
r.structuredRunRequest = req
if r.structuredRunBody != "" {
if err := os.WriteFile(req.OutputPath, []byte(r.structuredRunBody), 0o600); err != nil {
return nil, err
}
}
if r.structuredRunResult != nil {
r.structuredRunResult.OutputPath = req.OutputPath
}
return r.structuredRunResult, r.structuredRunErr
}

View File

@@ -97,10 +97,10 @@ func TestHourlyForecastModuleRejectsUnsupportedReports(t *testing.T) {
} }
} }
func TestHourlyForecastModuleBuildsForNearTerm(t *testing.T) { func TestHourlyForecastModuleBuildsForHourly(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := testModuleContext() ctx := testModuleContext()
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.NearTerm) ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Hourly)
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast}) output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
if err != nil { if err != nil {
@@ -108,7 +108,7 @@ func TestHourlyForecastModuleBuildsForNearTerm(t *testing.T) {
} }
value := moduleValue[HourlyForecastModule](t, output) value := moduleValue[HourlyForecastModule](t, output)
if len(value.Periods) != 1 || value.Periods[0].TextDescription != "Showers likely." { if len(value.Periods) != 1 || value.Periods[0].TextDescription != "Showers likely." {
t.Fatalf("HourlyForecast = %#v, want near-term hourly period", value) t.Fatalf("HourlyForecast = %#v, want hourly report period", value)
} }
} }
@@ -335,10 +335,10 @@ func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
} }
} }
func TestAreaForecastDiscussionModuleUsesNearTermDefaultSections(t *testing.T) { func TestAreaForecastDiscussionModuleUsesHourlyDefaultSections(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := testModuleContext() ctx := testModuleContext()
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.NearTerm) ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Hourly)
var item module.ConfigItem var item module.ConfigItem
for _, candidate := range ctx.Resolved.Definition.Modules { for _, candidate := range ctx.Resolved.Definition.Modules {
if candidate.ID == module.AreaForecastDiscussion { if candidate.ID == module.AreaForecastDiscussion {
@@ -347,7 +347,7 @@ func TestAreaForecastDiscussionModuleUsesNearTermDefaultSections(t *testing.T) {
} }
} }
if item.ID == "" { if item.ID == "" {
t.Fatal("near-term default modules missing area_forecast_discussion") t.Fatal("hourly default modules missing area_forecast_discussion")
} }
output, err := registry.BuildModule(ctx, item) output, err := registry.BuildModule(ctx, item)

View File

@@ -138,7 +138,7 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
func TestPrecipTimingModuleUsesDerivedTimingWithoutDaypartSummaries(t *testing.T) { func TestPrecipTimingModuleUsesDerivedTimingWithoutDaypartSummaries(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.NearTerm) ctx := derivedModuleContext(report.Hourly)
ctx.Derived.DailySummaries = nil ctx.Derived.DailySummaries = nil
ctx.Derived.DaypartSummaries = nil ctx.Derived.DaypartSummaries = nil

View File

@@ -249,7 +249,7 @@ func (d ModuleDefinition) ValidateOptions(options any) error {
} }
func defaultModuleDefinitions() []ModuleDefinition { func defaultModuleDefinitions() []ModuleDefinition {
allReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.NearTerm, report.ThreeDay, report.Weekend, report.Storm} allReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm}
daypartReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend} daypartReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend}
return []ModuleDefinition{ return []ModuleDefinition{
{ {
@@ -286,7 +286,7 @@ 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, report.NearTerm}, SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow, report.Hourly},
MissingData: module.MissingDataOmit, MissingData: module.MissingDataOmit,
Builder: buildHourlyForecastModule, Builder: buildHourlyForecastModule,
}, },

View File

@@ -54,8 +54,8 @@ func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
} }
} }
func TestNearTermDefaultModuleOptions(t *testing.T) { func TestHourlyDefaultModuleOptions(t *testing.T) {
definition := report.DefaultRegistry().MustLookup(report.NearTerm) definition := report.DefaultRegistry().MustLookup(report.Hourly)
var found bool var found bool
for _, item := range definition.Modules { for _, item := range definition.Modules {
if item.ID != module.AreaForecastDiscussion { if item.ID != module.AreaForecastDiscussion {
@@ -71,7 +71,7 @@ func TestNearTermDefaultModuleOptions(t *testing.T) {
} }
} }
if !found { if !found {
t.Fatal("near-term default modules missing area_forecast_discussion") t.Fatal("hourly default modules missing area_forecast_discussion")
} }
} }
@@ -112,7 +112,7 @@ func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
} }
} }
func TestModuleRegistryRejectsNearTermIncompatibleModules(t *testing.T) { func TestModuleRegistryRejectsHourlyIncompatibleModules(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
for _, id := range []module.ID{ for _, id := range []module.ID{
module.NarrativeForecast, module.NarrativeForecast,
@@ -122,9 +122,9 @@ func TestModuleRegistryRejectsNearTermIncompatibleModules(t *testing.T) {
module.TomorrowPlanning, module.TomorrowPlanning,
} { } {
t.Run(string(id), func(t *testing.T) { t.Run(string(id), func(t *testing.T) {
err := registry.ValidateComposition(report.NearTerm, []module.ConfigItem{{ID: id}}) err := registry.ValidateComposition(report.Hourly, []module.ConfigItem{{ID: id}})
if err == nil || !strings.Contains(err.Error(), `not compatible with report "near_term"`) { if err == nil || !strings.Contains(err.Error(), `not compatible with report "hourly"`) {
t.Fatalf("ValidateComposition() error = %v, want incompatible near-term module", err) t.Fatalf("ValidateComposition() error = %v, want incompatible hourly module", err)
} }
}) })
} }

View File

@@ -19,7 +19,7 @@ Usage:
weatherreporter --help weatherreporter --help
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate near-term [--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
@@ -37,7 +37,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.
` `
@@ -387,8 +387,8 @@ func reportKind(value string) (app.ReportKind, bool) {
return app.ReportDaily, true return app.ReportDaily, true
case string(app.ReportTomorrow): case string(app.ReportTomorrow):
return app.ReportTomorrow, true return app.ReportTomorrow, true
case string(app.ReportNearTerm): case string(app.ReportHourly):
return app.ReportNearTerm, true return app.ReportHourly, true
case string(app.ReportThreeDay): case string(app.ReportThreeDay):
return app.ReportThreeDay, true return app.ReportThreeDay, true
case string(app.ReportWeekend): case string(app.ReportWeekend):

View File

@@ -28,8 +28,12 @@ func TestRunHelpLongFlag(t *testing.T) {
if !strings.Contains(stdout.String(), "generate daily") { if !strings.Contains(stdout.String(), "generate daily") {
t.Fatalf("help output missing generate command:\n%s", stdout.String()) t.Fatalf("help output missing generate command:\n%s", stdout.String())
} }
if !strings.Contains(stdout.String(), "weatherreporter generate near-term") { if !strings.Contains(stdout.String(), "weatherreporter generate hourly") {
t.Fatalf("help output missing near-term generate command:\n%s", stdout.String()) t.Fatalf("help output missing hourly generate command:\n%s", stdout.String())
}
removedGenerateCommand := "generate " + strings.Join([]string{"near", "term"}, "-")
if strings.Contains(stdout.String(), removedGenerateCommand) {
t.Fatalf("help output includes retired generate command:\n%s", stdout.String())
} }
removedInspectCommand := "inspect " + "briefing" removedInspectCommand := "inspect " + "briefing"
if !strings.Contains(stdout.String(), "inspect modules") || strings.Contains(stdout.String(), removedInspectCommand) { if !strings.Contains(stdout.String(), "inspect modules") || strings.Contains(stdout.String(), removedInspectCommand) {
@@ -685,6 +689,59 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
} }
} }
func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()
scriptoriumPath := writeStructuredOutputScriptorium(t, tempDir)
workspaceRoot := filepath.Join(tempDir, "workspace")
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
outPath := filepath.Join(tempDir, "hourly.md")
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 11, 0, 0, 0, time.UTC)}}
err := runner.Run(context.Background(), []string{
"generate", "hourly",
"--config", configPath,
"--out", outPath,
}, &stdout, &stderr)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
report, err := os.ReadFile(outPath)
if err != nil {
t.Fatalf("read report: %v", err)
}
for _, want := range []string{
"# Hourly Report",
"Storm chances increase through late morning.",
"The main window is 10 AM to noon.",
"Brief downpours may slow travel.",
} {
if !strings.Contains(string(report), want) {
t.Fatalf("report output missing %q:\n%s", want, string(report))
}
}
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "hourly", "2026-05-29", "*.data_package.yaml")
dataPackage, err := os.ReadFile(dataPackagePath)
if err != nil {
t.Fatalf("read managed data package: %v", err)
}
if !strings.Contains(string(dataPackage), "id: hourly") ||
!strings.Contains(string(dataPackage), "prompt_id: weather.hourly_generated_text") ||
!strings.Contains(string(dataPackage), "hourly_forecast:") {
t.Fatalf("data package output missing hourly content:\n%s", string(dataPackage))
}
rawGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.generated_text.raw.json")
validatedGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.generated_text.json")
renderContextPath := oneArtifact(t, workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.render_context.json")
managedReportPath := oneArtifact(t, workspaceRoot, "reports", "hourly", "*.md")
assertFileContains(t, rawGeneratedTextPath, `"summary": " Storm chances increase through late morning. "`)
assertFileContains(t, validatedGeneratedTextPath, `"summary":"Storm chances increase through late morning."`)
assertFileContains(t, renderContextPath, `"ReportTitle": "Hourly Report"`)
assertFileContains(t, managedReportPath, "# Hourly Report")
}
func TestRunInspectGeneratedArtifacts(t *testing.T) { func TestRunInspectGeneratedArtifacts(t *testing.T) {
server := dailyServer(t) server := dailyServer(t)
tempDir := t.TempDir() tempDir := t.TempDir()
@@ -812,7 +869,7 @@ func TestResolveGenerateCommands(t *testing.T) {
}{ }{
{name: "daily", args: []string{"daily", "--date", "2026-05-29"}, want: app.ReportDaily}, {name: "daily", args: []string{"daily", "--date", "2026-05-29"}, want: app.ReportDaily},
{name: "tomorrow", args: []string{"tomorrow"}, want: app.ReportTomorrow}, {name: "tomorrow", args: []string{"tomorrow"}, want: app.ReportTomorrow},
{name: "near-term", args: []string{"near-term"}, want: app.ReportNearTerm}, {name: "hourly", args: []string{"hourly"}, want: app.ReportHourly},
{name: "three-day", args: []string{"three-day"}, want: app.ReportThreeDay}, {name: "three-day", args: []string{"three-day"}, want: app.ReportThreeDay},
{name: "weekend", args: []string{"weekend"}, want: app.ReportWeekend}, {name: "weekend", args: []string{"weekend"}, want: app.ReportWeekend},
{name: "storm", args: []string{"storm", "--start", "2026-05-29T18:00", "--end", "2026-05-30T06:00"}, want: app.ReportStorm}, {name: "storm", args: []string{"storm", "--start", "2026-05-29T18:00", "--end", "2026-05-30T06:00"}, want: app.ReportStorm},
@@ -831,20 +888,20 @@ func TestResolveGenerateCommands(t *testing.T) {
} }
} }
func TestResolveGenerateNearTermAppliesSharedFlags(t *testing.T) { func TestResolveGenerateHourlyAppliesSharedFlags(t *testing.T) {
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
configPath := filepath.Join(t.TempDir(), "config.yml") configPath := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(configPath, []byte("weather_api:\n units: metric\n timezone: UTC\n"), 0o600); err != nil { if err := os.WriteFile(configPath, []byte("weather_api:\n units: metric\n timezone: UTC\n"), 0o600); err != nil {
t.Fatalf("write config: %v", err) t.Fatalf("write config: %v", err)
} }
req, err := runner.resolveGenerate([]string{"near-term", "--config", configPath, "--units", "us", "--tz", "America/Chicago", "--out", "./near-term.md"}) req, err := runner.resolveGenerate([]string{"hourly", "--config", configPath, "--units", "us", "--tz", "America/Chicago", "--out", "./hourly.md"})
if err != nil { if err != nil {
t.Fatalf("resolveGenerate() error = %v", err) t.Fatalf("resolveGenerate() error = %v", err)
} }
if req.Report != app.ReportNearTerm { if req.Report != app.ReportHourly {
t.Fatalf("Report = %q, want near-term", req.Report) t.Fatalf("Report = %q, want hourly", req.Report)
} }
if req.Config.WeatherAPI.Units != "us" { if req.Config.WeatherAPI.Units != "us" {
t.Fatalf("Units = %q, want us", req.Config.WeatherAPI.Units) t.Fatalf("Units = %q, want us", req.Config.WeatherAPI.Units)
@@ -852,21 +909,23 @@ func TestResolveGenerateNearTermAppliesSharedFlags(t *testing.T) {
if req.Config.WeatherAPI.Timezone != "America/Chicago" { if req.Config.WeatherAPI.Timezone != "America/Chicago" {
t.Fatalf("Timezone = %q, want America/Chicago", req.Config.WeatherAPI.Timezone) t.Fatalf("Timezone = %q, want America/Chicago", req.Config.WeatherAPI.Timezone)
} }
if req.OutputPath != "./near-term.md" { if req.OutputPath != "./hourly.md" {
t.Fatalf("OutputPath = %q, want ./near-term.md", req.OutputPath) t.Fatalf("OutputPath = %q, want ./hourly.md", req.OutputPath)
} }
if !req.Date.IsZero() || !req.StormStart.IsZero() || !req.StormEnd.IsZero() { if !req.Date.IsZero() || !req.StormStart.IsZero() || !req.StormEnd.IsZero() {
t.Fatalf("date/storm bounds = %s/%s/%s, want unset for near-term", req.Date, req.StormStart, req.StormEnd) t.Fatalf("date/storm bounds = %s/%s/%s, want unset for hourly", req.Date, req.StormStart, req.StormEnd)
} }
} }
func TestResolveGenerateNearTermRejectsDateAndStormBounds(t *testing.T) { func TestResolveGenerateHourlyRejectsDateAndStormBounds(t *testing.T) {
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
for _, args := range [][]string{ for _, args := range [][]string{
{"near-term", "--date", "2026-05-29"}, {"hourly", "--date", "2026-05-29"},
{"near-term", "--start", "2026-05-29T18:00"}, {"hourly", "--start", "2026-05-29T18:00"},
{"near-term", "--end", "2026-05-29T20:00"}, {"hourly", "--end", "2026-05-29T20:00"},
{"hourly", "--hours", "6"},
{"hourly", "--duration", "6h"},
} { } {
_, err := runner.resolveGenerate(args) _, err := runner.resolveGenerate(args)
if err == nil { if err == nil {
@@ -910,6 +969,19 @@ func TestResolveGenerateAppliesSharedFlags(t *testing.T) {
} }
} }
func TestResolveGenerateRejectsRetiredHourlyCommand(t *testing.T) {
runner := Runner{Clock: fixedClock()}
retired := strings.Join([]string{"near", "term"}, "-")
_, err := runner.resolveGenerate([]string{retired})
if err == nil {
t.Fatal("resolveGenerate() error = nil, want unknown report")
}
if !strings.Contains(err.Error(), "unknown generate report") {
t.Fatalf("error = %q, want unknown generate report", err.Error())
}
}
func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) { func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) {
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
@@ -1079,6 +1151,17 @@ func oneArtifact(t *testing.T, root string, parts ...string) string {
return matches[0] return matches[0]
} }
func assertFileContains(t *testing.T, path string, want string) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
if !strings.Contains(string(data), want) {
t.Fatalf("%s missing %q:\n%s", path, want, string(data))
}
}
func writeFakeScriptorium(t *testing.T, dir string) string { func writeFakeScriptorium(t *testing.T, dir string) string {
t.Helper() t.Helper()
path := filepath.Join(dir, "scriptorium") path := filepath.Join(dir, "scriptorium")
@@ -1109,6 +1192,52 @@ exit 1
return path return path
} }
func writeStructuredOutputScriptorium(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, "scriptorium")
body := `#!/bin/sh
if [ "$1" = "render" ]; then
printf '{"ok":true,"argv":"%s"}' "$*"
exit 0
fi
if [ "$1" = "run" ]; then
out=""
prompt=""
while [ "$#" -gt 0 ]; do
if [ "$1" = "--out" ]; then
shift
out="$1"
elif [ "$1" = "--prompt" ]; then
shift
prompt="$1"
fi
shift
done
if [ "$prompt" = "weather.hourly_generated_text" ]; then
cat > "$out" <<'JSON'
{
"summary": " Storm chances increase through late morning. ",
"timing": "The main window is 10 AM to noon.",
"impacts": "Brief downpours may slow travel.",
"confidence": "Medium"
}
JSON
printf 'wrote generated text\n' >&2
exit 0
fi
printf '# Daily Report\n\nGenerated by fake scriptorium.\n' > "$out"
printf 'wrote report\n' >&2
exit 0
fi
printf 'unexpected command\n' >&2
exit 1
`
if err := os.WriteFile(path, []byte(body), 0o700); err != nil {
t.Fatalf("write fake scriptorium: %v", err)
}
return path
}
func writeFailingScriptorium(t *testing.T, dir string) string { func writeFailingScriptorium(t *testing.T, dir string) string {
t.Helper() t.Helper()
path := filepath.Join(dir, "scriptorium") path := filepath.Join(dir, "scriptorium")

View File

@@ -91,6 +91,22 @@ func TestLoadExampleConfig(t *testing.T) {
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 := cfg.ReportModuleOverrides()
hourly := overrides[report.Hourly]
if len(hourly) != 9 {
t.Fatalf("hourly example override length = %d, want 9", len(hourly))
}
if hourly[0].ID != module.Metadata ||
hourly[1].ID != module.CurrentConditions ||
hourly[2].ID != module.HourlyForecast ||
hourly[3].ID != module.PrecipTiming ||
hourly[4].ID != module.AlertDigest ||
hourly[5].ID != module.SPCConvectiveOutlooks ||
hourly[6].ID != module.AreaForecastDiscussion ||
hourly[7].ID != module.SPCConvectiveDiscussion ||
hourly[8].ID != module.WeatherStory {
t.Fatalf("hourly example override = %#v, want configured module order", hourly)
}
} }
func TestLoadMinimalExampleConfig(t *testing.T) { func TestLoadMinimalExampleConfig(t *testing.T) {
@@ -161,19 +177,10 @@ reports:
} }
} }
func TestLoadNearTermReportModuleOverrides(t *testing.T) { func TestLoadHourlyReportModuleOverrides(t *testing.T) {
tests := []struct {
name string
key string
}{
{name: "UnderscoreAlias", key: "near_term"},
{name: "HyphenAlias", key: "near-term"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := writeConfig(t, ` path := writeConfig(t, `
reports: reports:
`+tt.key+`: hourly:
deterministic_modules: deterministic_modules:
- metadata - metadata
- hourly_forecast - hourly_forecast
@@ -186,17 +193,15 @@ reports:
t.Fatalf("LoadFile() error = %v", err) t.Fatalf("LoadFile() error = %v", err)
} }
overrides := cfg.ReportModuleOverrides() overrides := cfg.ReportModuleOverrides()
items := overrides[report.NearTerm] items := overrides[report.Hourly]
if len(items) != 4 { if len(items) != 4 {
t.Fatalf("near-term override length = %d, want 4", len(items)) t.Fatalf("hourly override length = %d, want 4", len(items))
} }
if items[0].ID != module.Metadata || if items[0].ID != module.Metadata ||
items[1].ID != module.HourlyForecast || items[1].ID != module.HourlyForecast ||
items[2].ID != module.PrecipTiming || items[2].ID != module.PrecipTiming ||
items[3].ID != module.AlertDigest { items[3].ID != module.AlertDigest {
t.Fatalf("near-term override = %#v, want configured module order", items) t.Fatalf("hourly override = %#v, want configured module order", items)
}
})
} }
} }
@@ -284,27 +289,14 @@ reports:
wantErr: "duplicates report override", wantErr: "duplicates report override",
}, },
{ {
name: "DuplicateNearTermAlias", name: "HourlyIncompatibleDailyModule",
yaml: ` yaml: `
reports: reports:
near_term: hourly:
deterministic_modules:
- metadata
near-term:
deterministic_modules:
- current_conditions
`,
wantErr: "duplicates report override",
},
{
name: "NearTermIncompatibleDailyModule",
yaml: `
reports:
near_term:
deterministic_modules: deterministic_modules:
- derived_daily_summary - derived_daily_summary
`, `,
wantErr: `not compatible with report "near_term"`, wantErr: `not compatible with report "hourly"`,
}, },
{ {
name: "UnknownReportField", name: "UnknownReportField",
@@ -331,6 +323,28 @@ reports:
} }
} }
func TestReportModuleOverrideRejectsRetiredHourlyKeys(t *testing.T) {
for _, key := range []string{
strings.Join([]string{"near", "term"}, "_"),
strings.Join([]string{"near", "term"}, "-"),
} {
t.Run(key, func(t *testing.T) {
_, err := LoadFile(writeConfig(t, `
reports:
`+key+`:
deterministic_modules:
- metadata
`))
if err == nil {
t.Fatal("LoadFile() error = nil, want unknown report")
}
if !strings.Contains(err.Error(), "is not a known report") {
t.Fatalf("error = %q, want unknown report", err.Error())
}
})
}
}
func TestExplicitMissingConfigReturnsError(t *testing.T) { 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 {

View File

@@ -113,8 +113,8 @@ func reportIDForConfigKey(key string) (report.ID, error) {
return report.DailyToday, nil return report.DailyToday, nil
case "tomorrow", "daily_tomorrow": case "tomorrow", "daily_tomorrow":
return report.DailyTomorrow, nil return report.DailyTomorrow, nil
case "near_term": case "hourly":
return report.NearTerm, nil return report.Hourly, nil
case "three_day", "three_day_outlook": case "three_day", "three_day_outlook":
return report.ThreeDay, nil return report.ThreeDay, nil
case "weekend", "weekend_outlook": case "weekend", "weekend_outlook":

View File

@@ -114,7 +114,7 @@ func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) {
derived.PrecipTiming = forecast.BuildPrecipTiming(derived.ValidPeriodHourlyPeriods) derived.PrecipTiming = forecast.BuildPrecipTiming(derived.ValidPeriodHourlyPeriods)
switch req.Resolved.Definition.ID { switch req.Resolved.Definition.ID {
case report.NearTerm: case report.Hourly:
case report.DailyToday, report.DailyTomorrow: case report.DailyToday, report.DailyTomorrow:
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 {

View File

@@ -152,9 +152,9 @@ func TestBuildDerivedWeekendAndTomorrow(t *testing.T) {
} }
} }
func TestBuildDerivedNearTermUsesRollingWindowFacts(t *testing.T) { func TestBuildDerivedHourlyUsesRollingWindowFacts(t *testing.T) {
location := testLocation() location := testLocation()
resolved := resolveForTest(t, report.NearTerm, mustParse("2026-05-29T08:30:00-05:00"), location) resolved := resolveForTest(t, report.Hourly, mustParse("2026-05-29T08:30:00-05:00"), location)
bundle := testBundle(location) bundle := testBundle(location)
bundle.Alerts = &weatherdata.AlertRun{Alerts: []json.RawMessage{ 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":"Expired Advisory","headline":"Ends at start","severity":"Minor","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T08:30:00-05:00"}`),
@@ -184,7 +184,7 @@ func TestBuildDerivedNearTermUsesRollingWindowFacts(t *testing.T) {
t.Fatalf("ValidPeriodNarrativePeriods length = %d, want overlapping narrative period", len(derived.ValidPeriodNarrativePeriods)) t.Fatalf("ValidPeriodNarrativePeriods length = %d, want overlapping narrative period", len(derived.ValidPeriodNarrativePeriods))
} }
if len(derived.DailySummaries) != 0 || len(derived.DaypartSummaries) != 0 || derived.StormWindowSummary != nil { if len(derived.DailySummaries) != 0 || len(derived.DaypartSummaries) != 0 || derived.StormWindowSummary != nil {
t.Fatalf("near-term summaries daily=%#v daypart=%#v storm=%#v, want none", derived.DailySummaries, derived.DaypartSummaries, derived.StormWindowSummary) 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" { 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) t.Fatalf("PrecipTiming.FirstPrecipitation = %#v, want first selected rainy hour", derived.PrecipTiming.FirstPrecipitation)
@@ -193,13 +193,13 @@ func TestBuildDerivedNearTermUsesRollingWindowFacts(t *testing.T) {
t.Fatalf("PrecipTiming.LastPrecipitation = %#v, want final selected rainy window end", derived.PrecipTiming.LastPrecipitation) t.Fatalf("PrecipTiming.LastPrecipitation = %#v, want final selected rainy window end", derived.PrecipTiming.LastPrecipitation)
} }
if len(derived.PrecipTiming.PrecipitationWindows) != 2 { if len(derived.PrecipTiming.PrecipitationWindows) != 2 {
t.Fatalf("PrecipTiming.PrecipitationWindows = %#v, want two near-term windows", derived.PrecipTiming.PrecipitationWindows) t.Fatalf("PrecipTiming.PrecipitationWindows = %#v, want two hourly windows", derived.PrecipTiming.PrecipitationWindows)
} }
if len(derived.AlertOverlaps) != 1 || derived.AlertOverlaps[0].Event != "Flood Watch" { if len(derived.AlertOverlaps) != 1 || derived.AlertOverlaps[0].Event != "Flood Watch" {
t.Fatalf("AlertOverlaps = %#v, want only alert overlapping near-term period", derived.AlertOverlaps) 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" { 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 near-term end", derived.AlertOverlaps[0].Overlap.End.Format(time.RFC3339)) 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, ",") { 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) t.Fatalf("SPCConvectiveOutlooks IDs = %#v, want %#v", got, want)
@@ -306,7 +306,7 @@ func TestBuildDerivedSelectsSPCConvectiveOutlooksByValidPeriod(t *testing.T) {
func TestBuildDerivedUnsupportedReportReturnsActionableError(t *testing.T) { func TestBuildDerivedUnsupportedReportReturnsActionableError(t *testing.T) {
location := testLocation() location := testLocation()
resolved := resolveForTest(t, report.NearTerm, mustParse("2026-05-29T08:00:00-05:00"), location) resolved := resolveForTest(t, report.Hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
resolved.Definition.ID = report.ID("future_report") resolved.Definition.ID = report.ID("future_report")
_, err := BuildDerived(BuildDerivedRequest{ _, err := BuildDerived(BuildDerivedRequest{

View File

@@ -0,0 +1,55 @@
// Package generatedtext validates structured LLM text and render contexts.
package generatedtext
import (
"bytes"
"encoding/json"
"fmt"
"io"
"strings"
)
type Hourly struct {
Summary string `json:"summary"`
Timing string `json:"timing"`
Impacts string `json:"impacts"`
Confidence string `json:"confidence,omitempty"`
}
func ValidateHourly(data []byte) (Hourly, []byte, error) {
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
var value Hourly
if err := decoder.Decode(&value); err != nil {
return Hourly{}, nil, fmt.Errorf("decode hourly generated text: %w", err)
}
var extra any
if err := decoder.Decode(&extra); err != nil {
if err != io.EOF {
return Hourly{}, nil, fmt.Errorf("decode hourly generated text: %w", err)
}
} else {
return Hourly{}, nil, fmt.Errorf("decode hourly generated text: multiple JSON values")
}
value.Summary = strings.TrimSpace(value.Summary)
value.Timing = strings.TrimSpace(value.Timing)
value.Impacts = strings.TrimSpace(value.Impacts)
value.Confidence = strings.TrimSpace(value.Confidence)
if value.Summary == "" {
return Hourly{}, nil, fmt.Errorf("hourly generated text summary is required")
}
if value.Timing == "" {
return Hourly{}, nil, fmt.Errorf("hourly generated text timing is required")
}
if value.Impacts == "" {
return Hourly{}, nil, fmt.Errorf("hourly generated text impacts is required")
}
normalized, err := json.Marshal(value)
if err != nil {
return Hourly{}, nil, fmt.Errorf("normalize hourly generated text: %w", err)
}
return value, normalized, nil
}

View File

@@ -0,0 +1,86 @@
package generatedtext
import (
"strings"
"testing"
)
func TestValidateHourlyNormalizesJSON(t *testing.T) {
value, normalized, err := ValidateHourly([]byte(`{
"timing": " main window late morning ",
"summary": " Storm chances increase. ",
"confidence": " Medium ",
"impacts": " Brief downpours. "
}`))
if err != nil {
t.Fatalf("ValidateHourly() error = %v", err)
}
if value.Summary != "Storm chances increase." {
t.Fatalf("Summary = %q, want trimmed summary", value.Summary)
}
want := `{"summary":"Storm chances increase.","timing":"main window late morning","impacts":"Brief downpours.","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.",
"timing": "Late morning.",
"impacts": "Brief downpours.",
"confidence": " "
}`))
if err != nil {
t.Fatalf("ValidateHourly() error = %v", err)
}
want := `{"summary":"Storm chances increase.","timing":"Late morning.","impacts":"Brief downpours."}`
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.","timing":"Late morning.","impacts":"Brief downpours.","extra":"value"}`,
want: `unknown field "extra"`,
},
{
name: "missing summary",
in: `{"timing":"Late morning.","impacts":"Brief downpours."}`,
want: "summary is required",
},
{
name: "blank timing",
in: `{"summary":"Storm chances increase.","timing":" ","impacts":"Brief downpours."}`,
want: "timing is required",
},
{
name: "multiple values",
in: `{"summary":"Storm chances increase.","timing":"Late morning.","impacts":"Brief downpours."} {}`,
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,360 @@
package generatedtext
import (
"fmt"
"strings"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
type HourlyRenderContext struct {
ReportTitle string
LocationName string
ValidPeriod string
GeneratedAt string
GeneratedText Hourly
CurrentConditions string
HourlyForecast []HourlyForecastRow
PrecipitationTiming string
Alerts []string
SPCOutlooks []string
ForecastDiscussion ForecastDiscussion
SPCDiscussions []string
WeatherStory string
}
type HourlyForecastRow struct {
Time string
Summary string
Temperature string
Precipitation string
Wind string
}
type ForecastDiscussion struct {
KeyMessages []string
ShortTerm string
}
func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Hourly) (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")
}
current, err := requiredStanza[briefing.CurrentConditionsModule](snapshot, string(module.CurrentConditions))
if err != nil {
return HourlyRenderContext{}, err
}
hourly, err := requiredStanza[briefing.HourlyForecastModule](snapshot, string(module.HourlyForecast))
if err != nil {
return HourlyRenderContext{}, err
}
precip, err := requiredStanza[briefing.PrecipTimingModule](snapshot, string(module.PrecipTiming))
if err != nil {
return HourlyRenderContext{}, err
}
alerts, err := requiredStanza[briefing.AlertDigestModule](snapshot, string(module.AlertDigest))
if err != nil {
return HourlyRenderContext{}, err
}
outlooks, err := requiredStanza[briefing.SPCConvectiveOutlooksModule](snapshot, string(module.SPCConvectiveOutlooks))
if err != nil {
return HourlyRenderContext{}, err
}
discussion, err := optionalStanza[briefing.AreaForecastDiscussionModule](snapshot, string(module.AreaForecastDiscussion))
if err != nil {
return HourlyRenderContext{}, err
}
spcDiscussion, err := optionalStanza[briefing.SPCConvectiveDiscussionModule](snapshot, string(module.SPCConvectiveDiscussion))
if err != nil {
return HourlyRenderContext{}, err
}
story, err := optionalStanza[briefing.WeatherStoryModule](snapshot, string(module.WeatherStory))
if err != nil {
return HourlyRenderContext{}, err
}
return HourlyRenderContext{
ReportTitle: "Hourly Report",
LocationName: locationName(metadata),
ValidPeriod: periodLabel(metadata.ValidPeriod, location),
GeneratedAt: timeLabel(metadata.GeneratedAt, location),
GeneratedText: generated,
CurrentConditions: currentConditionsLabel(current),
HourlyForecast: hourlyForecastRows(hourly),
PrecipitationTiming: precipitationTimingLabel(precip),
Alerts: alertLabels(alerts),
SPCOutlooks: outlookLabels(outlooks),
ForecastDiscussion: ForecastDiscussion{
KeyMessages: append([]string(nil), discussion.KeyMessages...),
ShortTerm: discussion.ShortTerm,
},
SPCDiscussions: spcDiscussionLabels(spcDiscussion),
WeatherStory: weatherStoryLabel(story),
}, nil
}
func requiredStanza[T any](snapshot module.Snapshot, name string) (T, error) {
value, ok, err := module.StanzaValue[T](snapshot, name)
if err != nil {
var zero T
return zero, fmt.Errorf("build hourly render context: %w", err)
}
if !ok {
var zero T
return zero, fmt.Errorf("build hourly render context requires stanza %q", name)
}
return value, nil
}
func optionalStanza[T any](snapshot module.Snapshot, name string) (T, error) {
value, _, err := module.StanzaValue[T](snapshot, name)
if err != nil {
var zero T
return zero, fmt.Errorf("build hourly render context: %w", err)
}
return value, nil
}
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 currentConditionsLabel(current briefing.CurrentConditionsModule) string {
parts := []string{}
if current.ConditionText != "" {
parts = append(parts, current.ConditionText)
}
if temperature := temperatureLabel(current.TemperatureF, current.TemperatureC); temperature != "" {
parts = append(parts, temperature)
}
if apparent := temperatureLabel(current.ApparentTemperatureF, current.ApparentTemperatureC); apparent != "" {
parts = append(parts, "feels like "+apparent)
}
if current.RelativeHumidityPercent != nil {
parts = append(parts, fmt.Sprintf("humidity %d%%", rounded(*current.RelativeHumidityPercent)))
}
if wind := windLabel(current.WindDirection, current.WindSpeedMph, current.WindSpeedKmh, nil, nil); wind != "" {
parts = append(parts, wind)
}
if len(parts) == 0 {
return "No current conditions available."
}
return strings.Join(parts, "; ") + "."
}
func hourlyForecastRows(hourly briefing.HourlyForecastModule) []HourlyForecastRow {
rows := make([]HourlyForecastRow, 0, len(hourly.Periods))
for _, period := range hourly.Periods {
summary := period.TextDescription
if summary == "" {
summary = period.Name
}
rows = append(rows, HourlyForecastRow{
Time: firstNonEmpty(period.PeriodBegins, period.Name),
Summary: summary,
Temperature: temperatureLabel(period.TemperatureF, period.TemperatureC),
Precipitation: precipitationLabel(period.ProbabilityOfPrecipitationPercent),
Wind: windLabel(period.WindDirection, period.WindSpeedMph, period.WindSpeedKmh, period.WindGustMph, period.WindGustKmh),
})
}
return rows
}
func precipitationTimingLabel(timing briefing.PrecipTimingModule) string {
parts := []string{}
if timing.MaxPopPercent != nil {
max := fmt.Sprintf("Peak precipitation probability %d%%", *timing.MaxPopPercent)
if timing.MaxPopTime != "" {
max += " at " + timing.MaxPopTime
}
parts = append(parts, max)
}
for _, window := range timing.PrecipitationWindows {
label := window.PeriodBegins
if window.PeriodEnds != "" {
label += " to " + window.PeriodEnds
}
if window.MaxPopPercent != nil {
label += fmt.Sprintf(" (max %d%%", *window.MaxPopPercent)
if window.MaxPopTime != "" {
label += " at " + window.MaxPopTime
}
label += ")"
}
parts = append(parts, label)
}
if timing.ThunderMentioned {
parts = append(parts, "Thunder is mentioned in the forecast.")
}
if len(parts) == 0 {
return "No precipitation timing signal above threshold."
}
return strings.Join(parts, "; ")
}
func alertLabels(alerts briefing.AlertDigestModule) []string {
if alerts.Missing {
return []string{"Alert source missing."}
}
out := make([]string, 0, len(alerts.Relevant))
for _, alert := range alerts.Relevant {
main := firstNonEmpty(alert.Event, alert.Headline)
if main == "" {
continue
}
if alert.Headline != "" && alert.Headline != main {
main += ": " + alert.Headline
}
if alert.Severity != "" {
main += " (" + alert.Severity + ")"
}
out = append(out, main)
}
return out
}
func outlookLabels(outlooks briefing.SPCConvectiveOutlooksModule) []string {
out := make([]string, 0, len(outlooks.Outlooks))
for _, outlook := range outlooks.Outlooks {
label := firstNonEmpty(outlook.LabelText, outlook.Label, outlook.OutlookType)
if label == "" {
continue
}
if outlook.PeriodBegins != "" {
label += " from " + outlook.PeriodBegins
if outlook.PeriodEnds != "" {
label += " to " + outlook.PeriodEnds
}
}
out = append(out, label)
}
return out
}
func spcDiscussionLabels(discussion briefing.SPCConvectiveDiscussionModule) []string {
out := make([]string, 0, len(discussion.Discussions))
for _, record := range discussion.Discussions {
label := firstNonEmpty(record.Headline, record.Summary, record.Discussion)
if label == "" {
continue
}
if record.Summary != "" && record.Summary != label {
label += ": " + record.Summary
}
out = append(out, label)
}
return out
}
func weatherStoryLabel(story briefing.WeatherStoryModule) string {
if !story.Available {
return "No weather story available."
}
parts := []string{}
if story.Title != "" {
parts = append(parts, story.Title)
}
if story.Description != "" {
parts = append(parts, story.Description)
}
if len(parts) == 0 {
return "Weather story is available."
}
return strings.Join(parts, " - ")
}
func temperatureLabel(fahrenheit *float64, celsius *float64) string {
if fahrenheit != nil {
return fmt.Sprintf("%d F", rounded(*fahrenheit))
}
if celsius != nil {
return fmt.Sprintf("%d C", rounded(*celsius))
}
return ""
}
func precipitationLabel(percent *float64) string {
if percent == nil {
return ""
}
return fmt.Sprintf("%d%% precipitation", rounded(*percent))
}
func windLabel(direction string, mph *float64, kmh *float64, gustMph *float64, gustKmh *float64) string {
speed := ""
if mph != nil {
speed = fmt.Sprintf("%d mph", rounded(*mph))
} else if kmh != nil {
speed = fmt.Sprintf("%d km/h", rounded(*kmh))
}
if direction != "" && speed != "" {
speed = direction + " " + speed
} else if direction != "" {
speed = direction + " wind"
}
gust := ""
if gustMph != nil {
gust = fmt.Sprintf("gusts %d mph", rounded(*gustMph))
} else if gustKmh != nil {
gust = fmt.Sprintf("gusts %d km/h", rounded(*gustKmh))
}
switch {
case speed != "" && gust != "":
return "wind " + speed + ", " + gust
case speed != "":
return "wind " + speed
case gust != "":
return "wind " + gust
default:
return ""
}
}
func rounded(value float64) int {
if value < 0 {
return int(value - 0.5)
}
return int(value + 0.5)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}

View File

@@ -0,0 +1,249 @@
package generatedtext
import (
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/reporttemplate"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func TestBuildHourlyRenderContext(t *testing.T) {
metadata := testMetadata()
snapshot := testSnapshot(t)
generated := Hourly{
Summary: "Storm chances increase through late morning.",
Timing: "The main window is 10 AM to noon.",
Impacts: "Brief downpours may slow travel.",
Confidence: "Medium confidence in timing.",
}
ctx, err := BuildHourlyRenderContext(metadata, snapshot, generated)
if err != nil {
t.Fatalf("BuildHourlyRenderContext() error = %v", err)
}
if ctx.ReportTitle != "Hourly Report" {
t.Fatalf("ReportTitle = %q, want Hourly Report", ctx.ReportTitle)
}
if ctx.LocationName != "Brentwood, MO" {
t.Fatalf("LocationName = %q, want Brentwood, MO", ctx.LocationName)
}
if ctx.ValidPeriod != "2026-05-29 at 8:30 AM to 2026-05-29 at 2:30 PM" {
t.Fatalf("ValidPeriod = %q, want friendly period", ctx.ValidPeriod)
}
if ctx.CurrentConditions != "Partly cloudy; 74 F; feels like 76 F; humidity 71%; wind S 8 mph." {
t.Fatalf("CurrentConditions = %q, want deterministic summary", ctx.CurrentConditions)
}
if len(ctx.HourlyForecast) != 2 {
t.Fatalf("HourlyForecast length = %d, want 2", len(ctx.HourlyForecast))
}
if row := ctx.HourlyForecast[1]; row.Time != "2026-05-29 at 10:00 AM" || row.Summary != "Showers" || row.Precipitation != "70% precipitation" {
t.Fatalf("HourlyForecast[1] = %#v, want 10 AM showers row", row)
}
if !strings.Contains(ctx.PrecipitationTiming, "Peak precipitation probability 70% at 10 AM") {
t.Fatalf("PrecipitationTiming = %q, want peak probability", ctx.PrecipitationTiming)
}
if strings.Join(ctx.Alerts, "|") != "Flood Watch: Flood Watch until early afternoon (Moderate)" {
t.Fatalf("Alerts = %#v, want alert label", ctx.Alerts)
}
if strings.Join(ctx.SPCOutlooks, "|") != "Slight Risk from 2026-05-29 at 7:00 AM to 2026-05-29 at 3:00 PM" {
t.Fatalf("SPCOutlooks = %#v, want outlook label", ctx.SPCOutlooks)
}
if ctx.ForecastDiscussion.ShortTerm != "Short-term discussion favors increasing rain coverage." {
t.Fatalf("ForecastDiscussion.ShortTerm = %q, want discussion text", ctx.ForecastDiscussion.ShortTerm)
}
if strings.Join(ctx.SPCDiscussions, "|") != "Mesoscale discussion: Strong storms may develop late morning." {
t.Fatalf("SPCDiscussions = %#v, want discussion label", ctx.SPCDiscussions)
}
if ctx.WeatherStory != "Morning storms - Morning storms remain the main story." {
t.Fatalf("WeatherStory = %q, want story label", ctx.WeatherStory)
}
rendered, err := reporttemplate.Render("hourly", ctx)
if err != nil {
t.Fatalf("Render() error = %v", err)
}
text := string(rendered)
for _, want := range []string{
"# Hourly Report",
"Storm chances increase through late morning.",
"- 2026-05-29 at 10:00 AM: Showers; 75 F; 70% precipitation; wind S 10 mph, gusts 18 mph",
"- Flood Watch: Flood Watch until early afternoon (Moderate)",
"Morning storms - Morning storms remain the main story.",
} {
if !strings.Contains(text, want) {
t.Fatalf("rendered template missing %q:\n%s", want, text)
}
}
}
func TestBuildHourlyRenderContextRequiresCoreStanzas(t *testing.T) {
snapshot, err := module.NewSnapshot([]module.Output{
{ID: module.CurrentConditions, StanzaName: string(module.CurrentConditions), Value: briefing.CurrentConditionsModule{}},
})
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
_, err = BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{
Summary: "Storm chances increase.",
Timing: "Late morning.",
Impacts: "Brief downpours.",
})
if err == nil {
t.Fatal("BuildHourlyRenderContext() error = nil, want missing stanza error")
}
if !strings.Contains(err.Error(), `requires stanza "hourly_forecast"`) {
t.Fatalf("BuildHourlyRenderContext() error = %v, want missing hourly forecast stanza", err)
}
}
func testMetadata() briefing.Metadata {
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
return briefing.Metadata{
RunID: "run-1",
ReportID: report.Hourly,
PromptID: "weather.hourly_generated_text",
GeneratedAt: generatedAt,
Units: "imperial",
Timezone: "America/Chicago",
ValidPeriod: timeutil.Period{
Start: generatedAt,
End: generatedAt.Add(6 * time.Hour),
},
Location: &briefing.LocationContext{
Name: "Brentwood",
Region: "MO",
},
}
}
func testSnapshot(t *testing.T) module.Snapshot {
t.Helper()
snapshot, err := module.NewSnapshot([]module.Output{
{
ID: module.CurrentConditions,
StanzaName: string(module.CurrentConditions),
Value: briefing.CurrentConditionsModule{
ConditionText: "Partly cloudy",
TemperatureF: floatPtr(74),
ApparentTemperatureF: floatPtr(76),
RelativeHumidityPercent: floatPtr(71),
WindSpeedMph: floatPtr(8),
WindDirection: "S",
},
},
{
ID: module.HourlyForecast,
StanzaName: string(module.HourlyForecast),
Value: briefing.HourlyForecastModule{
Periods: []briefing.HourlyForecastPeriod{
{
PeriodBegins: "2026-05-29 at 9:00 AM",
TextDescription: "Cloudy",
TemperatureF: floatPtr(74),
ProbabilityOfPrecipitationPercent: floatPtr(30),
WindSpeedMph: floatPtr(8),
WindDirection: "S",
},
{
PeriodBegins: "2026-05-29 at 10:00 AM",
TextDescription: "Showers",
TemperatureF: floatPtr(75),
ProbabilityOfPrecipitationPercent: floatPtr(70),
WindSpeedMph: floatPtr(10),
WindGustMph: floatPtr(18),
WindDirection: "S",
},
},
},
},
{
ID: module.PrecipTiming,
StanzaName: string(module.PrecipTiming),
Value: briefing.PrecipTimingModule{
MaxPopPercent: intPtr(70),
MaxPopTime: "10 AM",
ProbabilityThreshold: 50,
PrecipitationWindows: []briefing.PrecipitationWindowModule{
{
PeriodBegins: "2026-05-29 at 10:00 AM",
PeriodEnds: "2026-05-29 at 12:00 PM",
MaxPopPercent: intPtr(70),
MaxPopTime: "10 AM",
},
},
ThunderMentioned: true,
},
},
{
ID: module.AlertDigest,
StanzaName: string(module.AlertDigest),
Value: briefing.AlertDigestModule{
Checked: true,
ActiveCount: 1,
RelevantCount: 1,
Relevant: []briefing.AlertSummary{
{Event: "Flood Watch", Headline: "Flood Watch until early afternoon", Severity: "Moderate"},
},
},
},
{
ID: module.SPCConvectiveOutlooks,
StanzaName: string(module.SPCConvectiveOutlooks),
Value: briefing.SPCConvectiveOutlooksModule{
Checked: true,
OutlookCount: 1,
Outlooks: []briefing.SPCConvectiveOutlookRecord{
{
Label: "SLGT",
LabelText: "Slight Risk",
PeriodBegins: "2026-05-29 at 7:00 AM",
PeriodEnds: "2026-05-29 at 3:00 PM",
},
},
},
},
{
ID: module.AreaForecastDiscussion,
StanzaName: string(module.AreaForecastDiscussion),
Value: briefing.AreaForecastDiscussionModule{
KeyMessages: []string{"Storms are most likely late morning."},
ShortTerm: "Short-term discussion favors increasing rain coverage.",
},
},
{
ID: module.SPCConvectiveDiscussion,
StanzaName: string(module.SPCConvectiveDiscussion),
Value: briefing.SPCConvectiveDiscussionModule{
IncludedBecause: "categorical severity_rank >= 3",
Discussions: []briefing.SPCConvectiveDiscussionRecord{
{Headline: "Mesoscale discussion", Summary: "Strong storms may develop late morning."},
},
},
},
{
ID: module.WeatherStory,
StanzaName: string(module.WeatherStory),
Value: briefing.WeatherStoryModule{
Available: true,
Title: "Morning storms",
Description: "Morning storms remain the main story.",
},
},
})
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
return snapshot
}
func floatPtr(value float64) *float64 {
return &value
}
func intPtr(value int) *int {
return &value
}

View File

@@ -10,6 +10,7 @@ func dailyTodayDefinition() Definition {
ID: DailyToday, ID: DailyToday,
Name: "Daily Report", Name: "Daily Report",
PromptID: "weather.daily_report", PromptID: "weather.daily_report",
GenerationMode: GenerationModeScriptoriumMarkdown,
ComparisonStrategy: CompareSameValidDate, ComparisonStrategy: CompareSameValidDate,
ArtifactGroup: "daily", ArtifactGroup: "daily",
BatchOutputName: "daily.md", BatchOutputName: "daily.md",
@@ -26,6 +27,7 @@ func dailyTomorrowDefinition() Definition {
ID: DailyTomorrow, ID: DailyTomorrow,
Name: "Tomorrow Planning Brief", Name: "Tomorrow Planning Brief",
PromptID: "weather.daily_report", PromptID: "weather.daily_report",
GenerationMode: GenerationModeScriptoriumMarkdown,
ComparisonStrategy: CompareSameValidDate, ComparisonStrategy: CompareSameValidDate,
ArtifactGroup: "daily", ArtifactGroup: "daily",
BatchOutputName: "tomorrow.md", BatchOutputName: "tomorrow.md",

View File

@@ -14,7 +14,7 @@ type ID string
const ( const (
DailyToday ID = "daily_today" DailyToday ID = "daily_today"
DailyTomorrow ID = "daily_tomorrow" DailyTomorrow ID = "daily_tomorrow"
NearTerm ID = "near_term" Hourly ID = "hourly"
ThreeDay ID = "three_day" ThreeDay ID = "three_day"
Weekend ID = "weekend" Weekend ID = "weekend"
Storm ID = "storm" Storm ID = "storm"
@@ -29,6 +29,13 @@ const (
CompareRollingWindow ComparisonStrategy = "rolling_window" CompareRollingWindow ComparisonStrategy = "rolling_window"
) )
type GenerationMode string
const (
GenerationModeScriptoriumMarkdown GenerationMode = "scriptorium_markdown"
GenerationModeGeneratedTextTemplate GenerationMode = "generated_text_template"
)
type Batch string type Batch string
const ( const (
@@ -40,6 +47,9 @@ type Definition struct {
ID ID ID ID
Name string Name string
PromptID string PromptID string
GenerationMode GenerationMode
TemplateID string
GeneratedTextSchemaID string
ComparisonStrategy ComparisonStrategy ComparisonStrategy ComparisonStrategy
ArtifactGroup string ArtifactGroup string
BatchOutputName string BatchOutputName string

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
}

View File

@@ -1,52 +0,0 @@
package report
import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
const nearTermHours = 6
func nearTermDefinition() Definition {
return Definition{
ID: NearTerm,
Name: "Near-Term Report",
PromptID: "weather.near_term_report",
ComparisonStrategy: CompareRollingWindow,
ArtifactGroup: "near-term",
BatchOutputName: "near-term.md",
Generated: true,
CompatiblePriorIDs: []ID{NearTerm},
Modules: nearTermModules(),
resolve: resolveNearTerm,
}
}
func nearTermModules() []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 resolveNearTerm(req ResolveRequest) (timeutil.Period, error) {
localNow := req.Now.In(req.Location)
return timeutil.Period{
Start: localNow,
End: localNow.Add(nearTermHours * time.Hour),
}, nil
}

View File

@@ -58,19 +58,28 @@ 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 TestNearTermLookupAndPeriodCalculation(t *testing.T) { func TestHourlyLookupAndPeriodCalculation(t *testing.T) {
location := mustLoadLocation(t) location := mustLoadLocation(t)
now := mustParse("2026-05-29T05:15:00-05:00") now := mustParse("2026-05-29T05:15:00-05:00")
resolved, err := Resolve(NearTerm, ResolveRequest{Now: now, Location: location}) resolved, err := Resolve(Hourly, ResolveRequest{Now: now, Location: location})
if err != nil { if err != nil {
t.Fatalf("Resolve() error = %v", err) t.Fatalf("Resolve() error = %v", err)
} }
if resolved.Definition.ID != NearTerm { if resolved.Definition.ID != Hourly {
t.Fatalf("ID = %q, want near_term", resolved.Definition.ID) t.Fatalf("ID = %q, want hourly", resolved.Definition.ID)
} }
if resolved.Definition.PromptID != "weather.near_term_report" { if resolved.Definition.PromptID != "weather.hourly_generated_text" {
t.Fatalf("PromptID = %q, want weather.near_term_report", resolved.Definition.PromptID) 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 { if resolved.Definition.ComparisonStrategy != CompareRollingWindow {
t.Fatalf("ComparisonStrategy = %q, want rolling_window", resolved.Definition.ComparisonStrategy) t.Fatalf("ComparisonStrategy = %q, want rolling_window", resolved.Definition.ComparisonStrategy)
@@ -78,25 +87,25 @@ func TestNearTermLookupAndPeriodCalculation(t *testing.T) {
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:15:00-05:00", "2026-05-29T11:15:00-05:00") assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:15:00-05:00", "2026-05-29T11:15:00-05:00")
} }
func TestNearTermPeriodUsesEffectiveTimezone(t *testing.T) { func TestHourlyPeriodUsesEffectiveTimezone(t *testing.T) {
location, err := time.LoadLocation("America/New_York") location, err := time.LoadLocation("America/New_York")
if err != nil { if err != nil {
t.Fatalf("load location: %v", err) t.Fatalf("load location: %v", err)
} }
now := mustParse("2026-05-29T10:15:00Z") now := mustParse("2026-05-29T10:15:00Z")
resolved, err := Resolve(NearTerm, ResolveRequest{Now: now, Location: location}) resolved, err := Resolve(Hourly, 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-29T06:15:00-04:00", "2026-05-29T12:15:00-04:00") assertPeriod(t, resolved.ValidPeriod, "2026-05-29T06:15:00-04:00", "2026-05-29T12:15:00-04:00")
} }
func TestNearTermPeriodIsNotCivilDayTruncated(t *testing.T) { func TestHourlyPeriodIsNotCivilDayTruncated(t *testing.T) {
location := mustLoadLocation(t) location := mustLoadLocation(t)
now := mustParse("2026-05-29T22:30:00-05:00") now := mustParse("2026-05-29T22:30:00-05:00")
resolved, err := Resolve(NearTerm, ResolveRequest{Now: now, Location: location}) resolved, err := Resolve(Hourly, ResolveRequest{Now: now, Location: location})
if err != nil { if err != nil {
t.Fatalf("Resolve() error = %v", err) t.Fatalf("Resolve() error = %v", err)
} }
@@ -203,7 +212,7 @@ func TestEveningBatchIncludesTomorrow(t *testing.T) {
} }
} }
func TestBatchesDoNotIncludeNearTerm(t *testing.T) { func TestBatchesDoNotIncludeHourly(t *testing.T) {
location := mustLoadLocation(t) location := mustLoadLocation(t)
req := ResolveRequest{Now: mustParse("2026-05-29T06:00:00-05:00"), Location: location} req := ResolveRequest{Now: mustParse("2026-05-29T06:00:00-05:00"), Location: location}
@@ -216,8 +225,8 @@ func TestBatchesDoNotIncludeNearTerm(t *testing.T) {
t.Fatalf("BatchReports(evening) error = %v", err) t.Fatalf("BatchReports(evening) error = %v", err)
} }
for _, resolved := range append(morning, evening...) { for _, resolved := range append(morning, evening...) {
if resolved.Definition.ID == NearTerm { if resolved.Definition.ID == Hourly {
t.Fatalf("batch included %q, want near-term excluded", resolved.Definition.ID) t.Fatalf("batch included %q, want hourly excluded", resolved.Definition.ID)
} }
} }
} }
@@ -232,9 +241,9 @@ func TestRegistryLookupErrorIsActionable(t *testing.T) {
} }
} }
func TestRegistryAllIncludesNearTermInStableOrder(t *testing.T) { func TestRegistryAllIncludesHourlyInStableOrder(t *testing.T) {
ids := resolvedDefinitionIDs(DefaultRegistry().All()) ids := resolvedDefinitionIDs(DefaultRegistry().All())
want := []string{"daily_today", "daily_tomorrow", "near_term", "three_day", "weekend", "storm"} want := []string{"daily_today", "daily_tomorrow", "hourly", "three_day", "weekend", "storm"}
if strings.Join(ids, ",") != strings.Join(want, ",") { if strings.Join(ids, ",") != strings.Join(want, ",") {
t.Fatalf("All() ids = %#v, want %#v", ids, want) t.Fatalf("All() ids = %#v, want %#v", ids, want)
} }
@@ -245,12 +254,44 @@ func TestRegistryDefinitionsHavePromptIDsAndComparisonStrategies(t *testing.T) {
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 {
if definition.GenerationMode != GenerationModeGeneratedTextTemplate {
t.Fatalf("%s GenerationMode = %q, want %q", definition.ID, definition.GenerationMode, GenerationModeGeneratedTextTemplate)
}
if definition.TemplateID != "hourly" {
t.Fatalf("%s TemplateID = %q, want hourly", definition.ID, definition.TemplateID)
}
if definition.GeneratedTextSchemaID != "hourly" {
t.Fatalf("%s GeneratedTextSchemaID = %q, want hourly", definition.ID, definition.GeneratedTextSchemaID)
}
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
@@ -277,11 +318,11 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
comparisonStrategy: CompareSameValidDate, comparisonStrategy: CompareSameValidDate,
}, },
{ {
id: NearTerm, id: Hourly,
artifactGroup: "near-term", artifactGroup: "hourly",
batchOutputName: "near-term.md", batchOutputName: "hourly.md",
generated: true, generated: true,
compatiblePriorIDs: []ID{NearTerm}, compatiblePriorIDs: []ID{Hourly},
comparisonStrategy: CompareRollingWindow, comparisonStrategy: CompareRollingWindow,
}, },
{ {
@@ -384,7 +425,7 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
}, },
}, },
{ {
id: NearTerm, id: Hourly,
want: []module.ID{ want: []module.ID{
module.Metadata, module.Metadata,
module.CurrentConditions, module.CurrentConditions,
@@ -515,20 +556,20 @@ func TestResolvedMetadata(t *testing.T) {
} }
} }
func TestNearTermMetadataRunIDIncludesReportID(t *testing.T) { func TestHourlyMetadataRunIDIncludesReportID(t *testing.T) {
location := mustLoadLocation(t) location := mustLoadLocation(t)
resolved, err := Resolve(NearTerm, ResolveRequest{Now: mustParse("2026-05-29T05:15:00-05:00"), Location: location}) resolved, err := Resolve(Hourly, ResolveRequest{Now: mustParse("2026-05-29T05:15:00-05:00"), Location: location})
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 != NearTerm { if metadata.ReportID != Hourly {
t.Fatalf("ReportID = %q, want near_term", metadata.ReportID) t.Fatalf("ReportID = %q, want hourly", metadata.ReportID)
} }
if metadata.PromptID != "weather.near_term_report" { if metadata.PromptID != "weather.hourly_generated_text" {
t.Fatalf("PromptID = %q, want weather.near_term_report", metadata.PromptID) t.Fatalf("PromptID = %q, want weather.hourly_generated_text", metadata.PromptID)
} }
if !strings.Contains(metadata.RunID, "near_term") { if !strings.Contains(metadata.RunID, "hourly") {
t.Fatalf("RunID = %q, want report id", metadata.RunID) t.Fatalf("RunID = %q, want report id", metadata.RunID)
} }
} }

View File

@@ -14,7 +14,7 @@ func DefaultRegistry() Registry {
definitions := []Definition{ definitions := []Definition{
dailyTodayDefinition(), dailyTodayDefinition(),
dailyTomorrowDefinition(), dailyTomorrowDefinition(),
nearTermDefinition(), hourlyDefinition(),
threeDayDefinition(), threeDayDefinition(),
weekendDefinition(), weekendDefinition(),
stormDefinition(), stormDefinition(),
@@ -74,7 +74,7 @@ func (r Registry) MustLookup(id ID) Definition {
} }
func (r Registry) All() []Definition { func (r Registry) All() []Definition {
ids := []ID{DailyToday, DailyTomorrow, NearTerm, ThreeDay, Weekend, Storm} ids := []ID{DailyToday, DailyTomorrow, 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",

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",

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",

View File

@@ -0,0 +1,60 @@
// Package reporttemplate provides embedded Markdown templates and schemas.
package reporttemplate
import (
"bytes"
"embed"
"fmt"
"text/template"
)
//go:embed templates/*.md.tmpl schemas/*.schema.json
var assets embed.FS
var templates = map[string]string{
"hourly": "templates/hourly.md.tmpl",
}
var schemas = map[string]string{
"hourly": "schemas/hourly.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
}

View File

@@ -0,0 +1,187 @@
package reporttemplate
import (
"encoding/json"
"strings"
"testing"
)
func TestTemplateLookup(t *testing.T) {
source, err := Template("hourly")
if err != nil {
t.Fatalf("Template() error = %v", err)
}
for _, want := range []string{"# {{ .ReportTitle }}", "## Summary", "## Hourly Forecast", "## Weather Story"} {
if !strings.Contains(source, want) {
t.Fatalf("template missing %q:\n%s", want, source)
}
}
}
func TestSchemaLookup(t *testing.T) {
data, err := Schema("hourly")
if err != nil {
t.Fatalf("Schema() error = %v", err)
}
var schema struct {
Type string `json:"type"`
AdditionalProperties bool `json:"additionalProperties"`
Required []string `json:"required"`
Properties map[string]any `json:"properties"`
}
if err := json.Unmarshal(data, &schema); err != nil {
t.Fatalf("schema is invalid JSON: %v", err)
}
if schema.Type != "object" {
t.Fatalf("schema type = %q, want object", schema.Type)
}
if schema.AdditionalProperties {
t.Fatal("additionalProperties = true, want false")
}
if strings.Join(schema.Required, ",") != "summary,timing,impacts" {
t.Fatalf("required = %#v, want summary/timing/impacts", schema.Required)
}
for _, field := range []string{"summary", "timing", "impacts", "confidence"} {
property, ok := schema.Properties[field].(map[string]any)
if !ok {
t.Fatalf("schema property %q missing or invalid", field)
}
if property["type"] != "string" {
t.Fatalf("schema property %q type = %v, want string", field, property["type"])
}
}
}
func TestRenderHourly(t *testing.T) {
rendered, err := Render("hourly", testRenderContext{
ReportTitle: "Hourly Report",
LocationName: "Brentwood",
ValidPeriod: "May 29, 8:30 AM to 2:30 PM",
GeneratedAt: "May 29, 8:30 AM",
CurrentConditions: "74 F, light south wind.",
PrecipitationTiming: "Showers are most likely late morning.",
WeatherStory: "Morning storms remain the main story.",
GeneratedText: testGeneratedText{
Summary: "Storm chances increase through late morning.",
Timing: "The main window is 10 AM to noon.",
Impacts: "Brief downpours may slow travel.",
Confidence: "Medium confidence in timing.",
},
HourlyForecast: []testHourlyRow{
{Time: "9 AM", Summary: "Cloudy", Temperature: "74 F", Precipitation: "30% showers", Wind: "S 8 mph"},
{Time: "10 AM", Summary: "Showers", Temperature: "75 F", Precipitation: "70% showers", Wind: "S 10 mph"},
},
Alerts: []string{"Flood Watch until 2:30 PM"},
SPCOutlooks: []string{"Slight Risk through afternoon"},
SPCDiscussions: []string{"Strong storms may develop late morning."},
ForecastDiscussion: testForecastDiscussion{
KeyMessages: []string{"Storms are most likely late morning."},
ShortTerm: "Short-term discussion favors increasing rain coverage.",
},
})
if err != nil {
t.Fatalf("Render() error = %v", err)
}
text := string(rendered)
for _, want := range []string{
"# Hourly Report",
"Valid: May 29, 8:30 AM to 2:30 PM",
"Storm chances increase through late morning.",
"## Confidence",
"- 10 AM: Showers; 75 F; 70% showers; S 10 mph",
"- Flood Watch until 2:30 PM",
"Short-term discussion favors increasing rain coverage.",
} {
if !strings.Contains(text, want) {
t.Fatalf("rendered template missing %q:\n%s", want, text)
}
}
assertOrderedText(t, text, []string{
"# Hourly Report",
"## Summary",
"## Timing",
"## Impacts",
"## Confidence",
"## Current Conditions",
"## Hourly Forecast",
"## Precipitation Timing",
"## Alerts",
"## SPC Outlooks",
"## Forecast Discussion",
"## SPC Discussion",
"## Weather Story",
})
}
func TestUnknownAssetsReturnActionableErrors(t *testing.T) {
if _, err := Template("daily"); err == nil || !strings.Contains(err.Error(), `unknown report template "daily"`) {
t.Fatalf("Template() error = %v, want unknown template", err)
}
if _, err := Schema("daily"); err == nil || !strings.Contains(err.Error(), `unknown generated text schema "daily"`) {
t.Fatalf("Schema() error = %v, want unknown schema", err)
}
if _, err := Render("daily", testRenderContext{}); err == nil || !strings.Contains(err.Error(), `unknown report template "daily"`) {
t.Fatalf("Render() error = %v, want unknown template", err)
}
}
func TestRenderFailsForMissingContextFields(t *testing.T) {
_, err := Render("hourly", map[string]any{"ReportTitle": "Hourly Report"})
if err == nil {
t.Fatal("Render() error = nil, want missing field error")
}
if !strings.Contains(err.Error(), `render report template "hourly"`) {
t.Fatalf("Render() error = %v, want render context", err)
}
}
type testRenderContext struct {
ReportTitle string
LocationName string
ValidPeriod string
GeneratedAt string
CurrentConditions string
PrecipitationTiming string
WeatherStory string
GeneratedText testGeneratedText
HourlyForecast []testHourlyRow
Alerts []string
SPCOutlooks []string
SPCDiscussions []string
ForecastDiscussion testForecastDiscussion
}
type testGeneratedText struct {
Summary string
Timing string
Impacts string
Confidence string
}
type testHourlyRow struct {
Time string
Summary string
Temperature string
Precipitation string
Wind string
}
type testForecastDiscussion struct {
KeyMessages []string
ShortTerm string
}
func assertOrderedText(t *testing.T, text string, ordered []string) {
t.Helper()
previousIndex := -1
for _, want := range ordered {
index := strings.Index(text, want)
if index < 0 {
t.Fatalf("text missing %q:\n%s", want, text)
}
if index <= previousIndex {
t.Fatalf("%q appears out of order in:\n%s", want, text)
}
previousIndex = index
}
}

View File

@@ -0,0 +1,26 @@
{
"$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",
"timing",
"impacts"
],
"properties": {
"summary": {
"type": "string"
},
"timing": {
"type": "string"
},
"impacts": {
"type": "string"
},
"confidence": {
"type": "string"
}
}
}

View File

@@ -0,0 +1,71 @@
# {{ .ReportTitle }}
{{ .LocationName }}
Valid: {{ .ValidPeriod }}
Generated: {{ .GeneratedAt }}
## Summary
{{ .GeneratedText.Summary }}
## Timing
{{ .GeneratedText.Timing }}
## Impacts
{{ .GeneratedText.Impacts }}
{{ with .GeneratedText.Confidence }}
## Confidence
{{ . }}
{{ end }}
## Current Conditions
{{ .CurrentConditions }}
## Hourly Forecast
{{ range .HourlyForecast }}
- {{ .Time }}: {{ .Summary }}{{ with .Temperature }}; {{ . }}{{ end }}{{ with .Precipitation }}; {{ . }}{{ end }}{{ with .Wind }}; {{ . }}{{ end }}
{{ else }}
- No hourly forecast rows available.
{{ end }}
## Precipitation Timing
{{ .PrecipitationTiming }}
## Alerts
{{ range .Alerts }}
- {{ . }}
{{ else }}
- No active alert overlaps for this report period.
{{ end }}
## SPC Outlooks
{{ range .SPCOutlooks }}
- {{ . }}
{{ else }}
- No overlapping SPC outlooks.
{{ end }}
## Forecast Discussion
{{ range .ForecastDiscussion.KeyMessages }}
- {{ . }}
{{ end }}{{ with .ForecastDiscussion.ShortTerm }}
{{ . }}
{{ end }}
## SPC Discussion
{{ range .SPCDiscussions }}
- {{ . }}
{{ else }}
- No overlapping SPC discussion.
{{ end }}
## Weather Story
{{ .WeatherStory }}

View File

@@ -33,6 +33,10 @@ type ArtifactPaths struct {
Preflight string `json:"preflight"` Preflight string `json:"preflight"`
Notification string `json:"notification,omitempty"` Notification string `json:"notification,omitempty"`
RenderedReport string `json:"renderedReport,omitempty"` RenderedReport string `json:"renderedReport,omitempty"`
GeneratedTextRaw string `json:"generatedTextRaw,omitempty"`
GeneratedTextResult string `json:"generatedTextResult,omitempty"`
GeneratedText string `json:"generatedText,omitempty"`
RenderContext string `json:"renderContext,omitempty"`
} }
type ReportRecord struct { type ReportRecord struct {
@@ -95,6 +99,10 @@ func (s *FilesystemStore) Paths(resolved report.Resolved) (ArtifactPaths, error)
Preflight: s.join(s.preflightDir, group, validDate, filenameBase+".render.json"), Preflight: s.join(s.preflightDir, group, validDate, filenameBase+".render.json"),
Notification: s.join(s.notificationsDir, group, validDate, filenameBase+".distributor.json"), Notification: s.join(s.notificationsDir, group, validDate, filenameBase+".distributor.json"),
RenderedReport: s.join(s.reportsDir, group, filenameBase+".md"), RenderedReport: s.join(s.reportsDir, group, filenameBase+".md"),
GeneratedTextRaw: s.join(s.snapshotsDir, group, validDate, filenameBase+".generated_text.raw.json"),
GeneratedTextResult: s.join(s.snapshotsDir, group, validDate, filenameBase+".generated_text.run.json"),
GeneratedText: s.join(s.snapshotsDir, group, validDate, filenameBase+".generated_text.json"),
RenderContext: s.join(s.snapshotsDir, group, validDate, filenameBase+".render_context.json"),
}, nil }, nil
} }
@@ -148,6 +156,50 @@ func (s *FilesystemStore) SaveDistributorNotification(_ context.Context, resolve
return paths.Notification, nil return paths.Notification, nil
} }
func (s *FilesystemStore) SaveGeneratedTextRaw(_ context.Context, resolved report.Resolved, data []byte) (string, error) {
paths, err := s.Paths(resolved)
if err != nil {
return "", err
}
if err := fileutil.WriteFileAtomic(paths.GeneratedTextRaw, data); err != nil {
return "", err
}
return paths.GeneratedTextRaw, nil
}
func (s *FilesystemStore) SaveGeneratedTextResult(_ context.Context, resolved report.Resolved, value any) (string, error) {
paths, err := s.Paths(resolved)
if err != nil {
return "", err
}
if err := fileutil.WriteJSONAtomic(paths.GeneratedTextResult, value); err != nil {
return "", err
}
return paths.GeneratedTextResult, nil
}
func (s *FilesystemStore) SaveGeneratedText(_ context.Context, resolved report.Resolved, data []byte) (string, error) {
paths, err := s.Paths(resolved)
if err != nil {
return "", err
}
if err := fileutil.WriteFileAtomic(paths.GeneratedText, data); err != nil {
return "", err
}
return paths.GeneratedText, nil
}
func (s *FilesystemStore) SaveRenderContext(_ context.Context, resolved report.Resolved, value any) (string, error) {
paths, err := s.Paths(resolved)
if err != nil {
return "", err
}
if err := fileutil.WriteJSONAtomic(paths.RenderContext, value); err != nil {
return "", err
}
return paths.RenderContext, nil
}
func (s *FilesystemStore) PrepareRenderedReport(_ context.Context, resolved report.Resolved) (string, error) { func (s *FilesystemStore) PrepareRenderedReport(_ context.Context, resolved report.Resolved) (string, error) {
paths, err := s.Paths(resolved) paths, err := s.Paths(resolved)
if err != nil { if err != nil {
@@ -325,6 +377,37 @@ func (s *FilesystemStore) LoadModuleSnapshot(_ context.Context, path string) (mo
return snapshot, nil return snapshot, nil
} }
func (s *FilesystemStore) LoadGeneratedText(_ context.Context, path string) ([]byte, error) {
if path == "" {
return nil, fmt.Errorf("generated text path is required")
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read %q: %w", path, err)
}
return data, nil
}
func (s *FilesystemStore) LoadGeneratedTextResult(_ context.Context, path string, target any) error {
if path == "" {
return fmt.Errorf("generated text result path is required")
}
if target == nil {
return fmt.Errorf("generated text result target is required")
}
return readJSON(path, target)
}
func (s *FilesystemStore) LoadRenderContext(_ context.Context, path string, target any) error {
if path == "" {
return fmt.Errorf("render context path is required")
}
if target == nil {
return fmt.Errorf("render context target is required")
}
return readJSON(path, target)
}
func (s *FilesystemStore) reportRecord(path string) (ReportRecord, error) { func (s *FilesystemStore) reportRecord(path string) (ReportRecord, error) {
var metadata Metadata var metadata Metadata
if err := readJSON(path, &metadata); err != nil { if err := readJSON(path, &metadata); err != nil {

View File

@@ -40,6 +40,35 @@ func TestPathsUseRunIDAndWorkspace(t *testing.T) {
} }
} }
func TestGeneratedTextArtifactPathsUseSnapshotTree(t *testing.T) {
store := newTestStore(t)
resolved := resolveHourlyAt(t, "2026-05-29T05:00:00-05:00")
paths, err := store.Paths(resolved)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
runID := "20260529T100000.000000000Z_hourly"
wants := map[string]string{
"GeneratedTextRaw": filepath.Join("snapshots", "hourly", "2026-05-29", runID+".generated_text.raw.json"),
"GeneratedTextResult": filepath.Join("snapshots", "hourly", "2026-05-29", runID+".generated_text.run.json"),
"GeneratedText": filepath.Join("snapshots", "hourly", "2026-05-29", runID+".generated_text.json"),
"RenderContext": filepath.Join("snapshots", "hourly", "2026-05-29", runID+".render_context.json"),
}
got := map[string]string{
"GeneratedTextRaw": paths.GeneratedTextRaw,
"GeneratedTextResult": paths.GeneratedTextResult,
"GeneratedText": paths.GeneratedText,
"RenderContext": paths.RenderContext,
}
for name, want := range wants {
if !strings.Contains(got[name], want) {
t.Fatalf("%s path = %q, want component %q", name, got[name], want)
}
}
}
func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) { func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00") resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
@@ -180,6 +209,117 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
if strings.Contains(string(data), "MetadataPath") || strings.Contains(string(data), "metadataPath") { if strings.Contains(string(data), "MetadataPath") || strings.Contains(string(data), "metadataPath") {
t.Fatalf("metadata JSON includes runtime-only MetadataPath:\n%s", string(data)) t.Fatalf("metadata JSON includes runtime-only MetadataPath:\n%s", string(data))
} }
for _, unexpected := range []string{"generatedTextSchemaId", "generatedTextRawPath", "generatedTextResultPath", "generatedTextPath", "renderContextPath"} {
if strings.Contains(string(data), unexpected) {
t.Fatalf("metadata JSON includes generated-text field %q for Markdown report:\n%s", unexpected, string(data))
}
}
}
func TestSaveGeneratedTextArtifactsAndMetadataRoundTrip(t *testing.T) {
store := newTestStore(t)
resolved := resolveHourlyAt(t, "2026-05-29T05:00:00-05:00")
briefingMetadata := stateBriefingMetadata(resolved)
rawPath, err := store.SaveGeneratedTextRaw(context.Background(), resolved, []byte(`{"summary":"raw"}`))
if err != nil {
t.Fatalf("SaveGeneratedTextRaw() error = %v", err)
}
resultPath, err := store.SaveGeneratedTextResult(context.Background(), resolved, map[string]any{
"command": []string{"scriptorium", "run"},
"status": "succeeded",
})
if err != nil {
t.Fatalf("SaveGeneratedTextResult() error = %v", err)
}
generatedPath, err := store.SaveGeneratedText(context.Background(), resolved, []byte(`{"summary":"Storm chances increase.","timing":"Late morning.","impacts":"Brief downpours."}`))
if err != nil {
t.Fatalf("SaveGeneratedText() error = %v", err)
}
contextPath, err := store.SaveRenderContext(context.Background(), resolved, struct {
ReportTitle string `json:"reportTitle"`
Location string `json:"location"`
}{
ReportTitle: "Hourly Report",
Location: "Brentwood",
})
if err != nil {
t.Fatalf("SaveRenderContext() error = %v", err)
}
for _, path := range []string{rawPath, resultPath, generatedPath, contextPath} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected generated-text artifact %q: %v", path, err)
}
}
rawData, err := store.LoadGeneratedText(context.Background(), rawPath)
if err != nil {
t.Fatalf("LoadGeneratedText() raw error = %v", err)
}
if string(rawData) != `{"summary":"raw"}` {
t.Fatalf("raw data = %q, want saved bytes", rawData)
}
generatedData, err := store.LoadGeneratedText(context.Background(), generatedPath)
if err != nil {
t.Fatalf("LoadGeneratedText() generated error = %v", err)
}
if !strings.Contains(string(generatedData), `"timing":"Late morning."`) {
t.Fatalf("generated text = %q, want saved normalized JSON", generatedData)
}
var runResult struct {
Command []string `json:"command"`
Status string `json:"status"`
}
if err := store.LoadGeneratedTextResult(context.Background(), resultPath, &runResult); err != nil {
t.Fatalf("LoadGeneratedTextResult() error = %v", err)
}
if runResult.Status != "succeeded" || strings.Join(runResult.Command, " ") != "scriptorium run" {
t.Fatalf("run result = %#v, want saved result", runResult)
}
var renderContext struct {
ReportTitle string `json:"reportTitle"`
Location string `json:"location"`
}
if err := store.LoadRenderContext(context.Background(), contextPath, &renderContext); err != nil {
t.Fatalf("LoadRenderContext() error = %v", err)
}
if renderContext.ReportTitle != "Hourly Report" || renderContext.Location != "Brentwood" {
t.Fatalf("render context = %#v, want saved context", renderContext)
}
paths, err := store.Paths(resolved)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
metadata := BuildMetadataFromBriefingMetadata(resolved, briefingMetadata, ArtifactPaths{
ModuleSnapshot: paths.ModuleSnapshot,
Metadata: paths.Metadata,
DataPackage: paths.DataPackage,
Preflight: paths.Preflight,
RenderedReport: paths.RenderedReport,
GeneratedTextRaw: rawPath,
GeneratedTextResult: resultPath,
GeneratedText: generatedPath,
RenderContext: contextPath,
})
metadataPath, err := store.SaveMetadata(context.Background(), metadata)
if err != nil {
t.Fatalf("SaveMetadata() error = %v", err)
}
var decoded Metadata
metadataData, err := os.ReadFile(metadataPath)
if err != nil {
t.Fatalf("read metadata: %v", err)
}
if err := json.Unmarshal(metadataData, &decoded); err != nil {
t.Fatalf("decode metadata: %v", err)
}
if decoded.GeneratedTextSchemaID != "hourly" {
t.Fatalf("GeneratedTextSchemaID = %q, want hourly", decoded.GeneratedTextSchemaID)
}
if decoded.GeneratedTextRawPath != rawPath || decoded.GeneratedTextResultPath != resultPath || decoded.GeneratedTextPath != generatedPath || decoded.RenderContextPath != contextPath {
t.Fatalf("metadata generated-text paths = %#v, want saved artifact paths", decoded)
}
} }
func TestSaveMetadataUsesExplicitMetadataPath(t *testing.T) { func TestSaveMetadataUsesExplicitMetadataPath(t *testing.T) {
@@ -307,8 +447,8 @@ func TestFindPriorSnapshotSupportsNarrowedWeekendPeriod(t *testing.T) {
func TestFindPriorSnapshotIgnoresRollingWindowReports(t *testing.T) { func TestFindPriorSnapshotIgnoresRollingWindowReports(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
first := resolveNearTermAt(t, "2026-05-29T05:00:00-05:00") first := resolveHourlyAt(t, "2026-05-29T05:00:00-05:00")
second := resolveNearTermAt(t, "2026-05-29T06:00:00-05:00") second := resolveHourlyAt(t, "2026-05-29T06:00:00-05:00")
savePriorMetadata(t, store, first, stateBriefingMetadata(first)) savePriorMetadata(t, store, first, stateBriefingMetadata(first))
prior, err := store.FindPriorSnapshot(context.Background(), second) prior, err := store.FindPriorSnapshot(context.Background(), second)
@@ -405,7 +545,7 @@ func resolveWeekendAt(t *testing.T, value string) report.Resolved {
return resolved return resolved
} }
func resolveNearTermAt(t *testing.T, value string) report.Resolved { func resolveHourlyAt(t *testing.T, value string) report.Resolved {
t.Helper() t.Helper()
location, err := timeutil.LoadLocation("America/Chicago") location, err := timeutil.LoadLocation("America/Chicago")
if err != nil { if err != nil {
@@ -415,7 +555,7 @@ func resolveNearTermAt(t *testing.T, value string) report.Resolved {
if err != nil { if err != nil {
t.Fatalf("parse time: %v", err) t.Fatalf("parse time: %v", err)
} }
resolved, err := report.DefaultRegistry().Resolve(report.NearTerm, report.ResolveRequest{ resolved, err := report.DefaultRegistry().Resolve(report.Hourly, report.ResolveRequest{
Now: now, Now: now,
Location: location, Location: location,
}) })
@@ -471,5 +611,9 @@ func pathsString(paths ArtifactPaths) string {
paths.Preflight, paths.Preflight,
paths.Notification, paths.Notification,
paths.RenderedReport, paths.RenderedReport,
paths.GeneratedTextRaw,
paths.GeneratedTextResult,
paths.GeneratedText,
paths.RenderContext,
}, "\n") }, "\n")
} }

View File

@@ -31,11 +31,16 @@ type Metadata struct {
PreflightPath string `json:"preflightPath"` PreflightPath string `json:"preflightPath"`
NotificationPath string `json:"notificationPath,omitempty"` NotificationPath string `json:"notificationPath,omitempty"`
RenderedReportPath string `json:"renderedReportPath,omitempty"` RenderedReportPath string `json:"renderedReportPath,omitempty"`
GeneratedTextSchemaID string `json:"generatedTextSchemaId,omitempty"`
GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"`
GeneratedTextResultPath string `json:"generatedTextResultPath,omitempty"`
GeneratedTextPath string `json:"generatedTextPath,omitempty"`
RenderContextPath string `json:"renderContextPath,omitempty"`
} }
func BuildMetadataFromBriefingMetadata(resolved report.Resolved, briefingMetadata briefing.Metadata, paths ArtifactPaths) Metadata { func BuildMetadataFromBriefingMetadata(resolved report.Resolved, briefingMetadata briefing.Metadata, paths ArtifactPaths) Metadata {
metadata := resolved.Metadata() metadata := resolved.Metadata()
return Metadata{ out := Metadata{
SchemaVersion: MetadataSchemaVersion, SchemaVersion: MetadataSchemaVersion,
RunID: metadata.RunID, RunID: metadata.RunID,
MetadataPath: paths.Metadata, MetadataPath: paths.Metadata,
@@ -55,6 +60,14 @@ func BuildMetadataFromBriefingMetadata(resolved report.Resolved, briefingMetadat
PreflightPath: paths.Preflight, PreflightPath: paths.Preflight,
RenderedReportPath: paths.RenderedReport, RenderedReportPath: paths.RenderedReport,
} }
if resolved.Definition.GenerationMode == report.GenerationModeGeneratedTextTemplate {
out.GeneratedTextSchemaID = resolved.Definition.GeneratedTextSchemaID
out.GeneratedTextRawPath = paths.GeneratedTextRaw
out.GeneratedTextResultPath = paths.GeneratedTextResult
out.GeneratedTextPath = paths.GeneratedText
out.RenderContextPath = paths.RenderContext
}
return out
} }
func copyLocation(location *briefing.LocationContext) *briefing.LocationContext { func copyLocation(location *briefing.LocationContext) *briefing.LocationContext {

View File

@@ -17,10 +17,17 @@ type Store interface {
SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error) SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error)
SavePreflight(context.Context, report.Resolved, PreflightArtifact) (string, error) SavePreflight(context.Context, report.Resolved, PreflightArtifact) (string, error)
SaveDistributorNotification(context.Context, report.Resolved, DistributorNotificationArtifact) (string, error) SaveDistributorNotification(context.Context, report.Resolved, DistributorNotificationArtifact) (string, error)
SaveGeneratedTextRaw(context.Context, report.Resolved, []byte) (string, error)
SaveGeneratedTextResult(context.Context, report.Resolved, any) (string, error)
SaveGeneratedText(context.Context, report.Resolved, []byte) (string, error)
SaveRenderContext(context.Context, report.Resolved, any) (string, error)
PrepareRenderedReport(context.Context, report.Resolved) (string, error) PrepareRenderedReport(context.Context, report.Resolved) (string, error)
SaveMetadata(context.Context, Metadata) (string, error) SaveMetadata(context.Context, Metadata) (string, error)
FindPriorSnapshot(context.Context, report.Resolved) (*PriorSnapshot, error) FindPriorSnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
LoadModuleSnapshot(context.Context, string) (module.Snapshot, error) LoadModuleSnapshot(context.Context, string) (module.Snapshot, error)
LoadGeneratedText(context.Context, string) ([]byte, error)
LoadGeneratedTextResult(context.Context, string, any) error
LoadRenderContext(context.Context, string, any) error
} }
type PriorSnapshot struct { type PriorSnapshot struct {