Compare commits
14 Commits
b3f7c9c1f2
...
9d6502460e
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d6502460e | |||
| fcf1108641 | |||
| 185605fbf0 | |||
| 662d906997 | |||
| f6e20d1412 | |||
| 9e14a9b7c7 | |||
| 422430613c | |||
| 2a4ce64d6f | |||
| 316ab8f3fc | |||
| f6a68426e1 | |||
| bed2b84100 | |||
| b39ec1e4d3 | |||
| dd92e9b061 | |||
| 8d737395dc |
22
docs/cli.md
22
docs/cli.md
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
73
docs/internal/generatedtext.md
Normal file
73
docs/internal/generatedtext.md
Normal 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.
|
||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
94
docs/internal/reporttemplate.md
Normal file
94
docs/internal/reporttemplate.md
Normal 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.
|
||||||
@@ -6,9 +6,9 @@ This document describes the subprocess adapter in
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
The adapter runs `scriptorium render` for prompt preflight and `scriptorium run`
|
The adapter runs `scriptorium render` for prompt preflight and `scriptorium run`
|
||||||
for Markdown report generation. It isolates subprocess execution, argv
|
for Markdown report generation or structured generated-text output. It isolates
|
||||||
construction, timeout handling, output capture, and exit-code interpretation
|
subprocess execution, argv construction, timeout handling, output capture, and
|
||||||
from app and domain packages.
|
exit-code interpretation from app and domain packages.
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ Inputs:
|
|||||||
- prompt ID
|
- prompt ID
|
||||||
- YAML prompt input data package path
|
- YAML prompt input data package path
|
||||||
- report output path for `run`
|
- report output path for `run`
|
||||||
|
- raw generated-text output path for structured `run`
|
||||||
- configured binary, config path, profile, timeout, and extra arguments
|
- configured binary, config path, profile, timeout, and extra arguments
|
||||||
- context for cancellation
|
- context for cancellation
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ Outputs:
|
|||||||
- truncation flags for captured output
|
- truncation flags for captured output
|
||||||
- exit code
|
- exit code
|
||||||
- report output path for `run`
|
- report output path for `run`
|
||||||
|
- raw generated-text output path for structured `run`
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
@@ -34,9 +36,9 @@ Outputs:
|
|||||||
subprocess execution. It does not choose report types, build prompt input,
|
subprocess execution. It does not choose report types, build prompt input,
|
||||||
fetch weather data, decide workflow order, or persist workflow metadata.
|
fetch weather data, decide workflow order, or persist workflow metadata.
|
||||||
|
|
||||||
The adapter exposes request and result structs for render and run operations.
|
The adapter exposes request and result structs for render, Markdown run, and
|
||||||
State persistence uses a state-owned preflight artifact shape; app
|
structured generated-text run operations. State persistence uses state-owned
|
||||||
orchestration converts render results before saving.
|
artifact shapes; app orchestration converts adapter results before saving.
|
||||||
|
|
||||||
## Config Fields Used
|
## Config Fields Used
|
||||||
|
|
||||||
@@ -60,6 +62,12 @@ Report generation argv starts with:
|
|||||||
scriptorium run --prompt <prompt_id> --input data_package=<path> --out <path>
|
scriptorium run --prompt <prompt_id> --input data_package=<path> --out <path>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Structured generated-text argv uses the same `scriptorium run` form, with the
|
||||||
|
`--out` value set to the raw generated-text JSON artifact path. The adapter
|
||||||
|
does not add `--format`, schema path, or JSON Schema flags for structured
|
||||||
|
generation; Scriptorium selects the structured output schema from prompt
|
||||||
|
configuration.
|
||||||
|
|
||||||
Configured `--config` and `--profile` flags are inserted after the subcommand
|
Configured `--config` and `--profile` flags are inserted after the subcommand
|
||||||
and before prompt-specific arguments. Extra arguments are appended after the
|
and before prompt-specific arguments. Extra arguments are appended after the
|
||||||
built-in arguments.
|
built-in arguments.
|
||||||
@@ -67,8 +75,8 @@ built-in arguments.
|
|||||||
## Execution Behavior
|
## Execution Behavior
|
||||||
|
|
||||||
The adapter runs commands without shell interpolation. The same private
|
The adapter runs commands without shell interpolation. The same private
|
||||||
execution path is used by render and run after command-specific request
|
execution path is used by render, Markdown run, and structured run after
|
||||||
validation and argv construction.
|
command-specific request validation and argv construction.
|
||||||
|
|
||||||
When `scriptorium.timeout` is greater than zero, each subprocess call uses a
|
When `scriptorium.timeout` is greater than zero, each subprocess call uses a
|
||||||
context with that timeout. Stdout and stderr are captured separately, capped at
|
context with that timeout. Stdout and stderr are captured separately, capped at
|
||||||
@@ -81,8 +89,8 @@ context with that timeout. Stdout and stderr are captured separately, capped at
|
|||||||
- Missing run output path returns an error before subprocess execution.
|
- Missing run output path returns an error before subprocess execution.
|
||||||
- Subprocess start errors, context cancellation, and timeouts are wrapped with
|
- Subprocess start errors, context cancellation, and timeouts are wrapped with
|
||||||
operation context by the caller-facing method.
|
operation context by the caller-facing method.
|
||||||
- Nonzero render and run exits return the captured result plus an error
|
- Nonzero render, Markdown run, and structured run exits return the captured
|
||||||
containing the exit code and stderr.
|
result plus an error containing the exit code and stderr.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@@ -97,5 +105,6 @@ Inspect:
|
|||||||
- No shell interpolation is used.
|
- No shell interpolation is used.
|
||||||
- The Scriptorium input name is `data_package`.
|
- The Scriptorium input name is `data_package`.
|
||||||
- The file at the data package path is YAML produced by `internal/promptinput`.
|
- The file at the data package path is YAML produced by `internal/promptinput`.
|
||||||
- Render and run preserve command-specific result structs.
|
- Render, Markdown run, and structured run preserve command-specific result
|
||||||
|
structs.
|
||||||
- Scriptorium-specific flags stay inside adapter and config boundaries.
|
- Scriptorium-specific flags stay inside adapter and config boundaries.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ Inputs:
|
|||||||
- module snapshot
|
- module snapshot
|
||||||
- prompt input data package
|
- prompt input data package
|
||||||
- preflight artifact
|
- preflight artifact
|
||||||
|
- generated-text raw, run-result, validated text, and render-context artifacts
|
||||||
- rendered report path preparation request
|
- rendered report path preparation request
|
||||||
- RunID for inspection lookups
|
- RunID for inspection lookups
|
||||||
|
|
||||||
@@ -24,10 +25,15 @@ Outputs:
|
|||||||
- module snapshot JSON path
|
- module snapshot JSON path
|
||||||
- prompt input data package YAML path
|
- prompt input data package YAML path
|
||||||
- render preflight JSON path
|
- render preflight JSON path
|
||||||
|
- generated-text raw JSON path
|
||||||
|
- generated-text run-result JSON path
|
||||||
|
- validated generated-text JSON path
|
||||||
|
- render context JSON path
|
||||||
- managed Markdown report path
|
- managed Markdown report path
|
||||||
- metadata JSON path
|
- metadata JSON path
|
||||||
- prior comparable snapshot metadata
|
- prior comparable snapshot metadata
|
||||||
- loaded module snapshot or data package
|
- loaded module snapshot, data package, generated text, generated-text run
|
||||||
|
result, or render context
|
||||||
- recent report records for inspection
|
- recent report records for inspection
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
@@ -61,6 +67,10 @@ valid-period start date for dated artifacts, and the RunID.
|
|||||||
<workspace.root>/
|
<workspace.root>/
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.modules.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.modules.json
|
||||||
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.metadata.json
|
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.metadata.json
|
||||||
|
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.raw.json
|
||||||
|
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.run.json
|
||||||
|
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.json
|
||||||
|
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.render_context.json
|
||||||
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.yaml
|
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.yaml
|
||||||
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json
|
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json
|
||||||
notifications/<artifact_group>/<YYYY-MM-DD>/<run_id>.distributor.json
|
notifications/<artifact_group>/<YYYY-MM-DD>/<run_id>.distributor.json
|
||||||
@@ -69,8 +79,11 @@ valid-period start date for dated artifacts, and the RunID.
|
|||||||
|
|
||||||
Metadata is stored beside module snapshots and links the module snapshot, data
|
Metadata is stored beside module snapshots and links the module snapshot, data
|
||||||
package, preflight, report paths, notification path when attempted, and
|
package, preflight, report paths, notification path when attempted, and
|
||||||
configured prompt location. Report listing walks metadata files under the
|
configured prompt location. For generated-text-template reports, metadata also
|
||||||
snapshots directory.
|
records the generated text schema ID and links the raw generated text,
|
||||||
|
Scriptorium run result, validated generated text, and render context artifacts.
|
||||||
|
Markdown-report metadata omits those generated-text fields. Report listing
|
||||||
|
walks metadata files under the snapshots directory.
|
||||||
|
|
||||||
## Prior Lookup
|
## Prior Lookup
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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, ¬ificationArtifact); 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, ¬ificationErr) {
|
||||||
|
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, ¬ification); 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
55
internal/generatedtext/hourly.go
Normal file
55
internal/generatedtext/hourly.go
Normal 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
|
||||||
|
}
|
||||||
86
internal/generatedtext/hourly_test.go
Normal file
86
internal/generatedtext/hourly_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
360
internal/generatedtext/render_context.go
Normal file
360
internal/generatedtext/render_context.go
Normal 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 ""
|
||||||
|
}
|
||||||
249
internal/generatedtext/render_context_test.go
Normal file
249
internal/generatedtext/render_context_test.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
55
internal/report/hourly_report.go
Normal file
55
internal/report/hourly_report.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package report
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
const hourlyReportHours = 6
|
||||||
|
|
||||||
|
func hourlyDefinition() Definition {
|
||||||
|
return Definition{
|
||||||
|
ID: Hourly,
|
||||||
|
Name: "Hourly Report",
|
||||||
|
PromptID: "weather.hourly_generated_text",
|
||||||
|
GenerationMode: GenerationModeGeneratedTextTemplate,
|
||||||
|
TemplateID: "hourly",
|
||||||
|
GeneratedTextSchemaID: "hourly",
|
||||||
|
ComparisonStrategy: CompareRollingWindow,
|
||||||
|
ArtifactGroup: "hourly",
|
||||||
|
BatchOutputName: "hourly.md",
|
||||||
|
Generated: true,
|
||||||
|
CompatiblePriorIDs: []ID{Hourly},
|
||||||
|
Modules: hourlyModules(),
|
||||||
|
resolve: resolveHourly,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hourlyModules() []module.ConfigItem {
|
||||||
|
return []module.ConfigItem{
|
||||||
|
{ID: module.Metadata},
|
||||||
|
{ID: module.CurrentConditions},
|
||||||
|
{ID: module.HourlyForecast},
|
||||||
|
{ID: module.PrecipTiming},
|
||||||
|
{ID: module.AlertDigest},
|
||||||
|
{ID: module.SPCConvectiveOutlooks},
|
||||||
|
{
|
||||||
|
ID: module.AreaForecastDiscussion,
|
||||||
|
Options: module.AreaForecastDiscussionOptions{
|
||||||
|
Sections: []string{"key_messages", "short_term"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ID: module.SPCConvectiveDiscussion},
|
||||||
|
{ID: module.WeatherStory},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveHourly(req ResolveRequest) (timeutil.Period, error) {
|
||||||
|
localNow := req.Now.In(req.Location)
|
||||||
|
return timeutil.Period{
|
||||||
|
Start: localNow,
|
||||||
|
End: localNow.Add(hourlyReportHours * time.Hour),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
60
internal/reporttemplate/reporttemplate.go
Normal file
60
internal/reporttemplate/reporttemplate.go
Normal 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
|
||||||
|
}
|
||||||
187
internal/reporttemplate/reporttemplate_test.go
Normal file
187
internal/reporttemplate/reporttemplate_test.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
71
internal/reporttemplate/templates/hourly.md.tmpl
Normal file
71
internal/reporttemplate/templates/hourly.md.tmpl
Normal 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 }}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user